详解skynet 频发的重入问题

例子说明

我们假设有2个服务,1个用户。
银行服务 提供转账
分控服务 对用户进行风控,提供阻止转账取款功能
用户一 用手机转账

bank_service.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
local account_amonut_map = {}   --账户余额
local CMD = {}
--转账
function CMD.transfer(from_account, to_account, num)
local amonut = account_amonut_map[from_account] or 0
if amonut < num then
return false --余额不足
end

if not skynet.call(".control_service", 'lua', 'control', from_account, num) then
return false
end

local to_amonut = account_amonut_map[to_account] or 0
account_amonut_map[from_account] = amonut - num
account_amonut_map[to_account] = to_amonut + num

return true
end

control_service.lua

1
2
3
4
5
local CMD = {}
--分控查询 默认允许
function CMD.control(account, num)
return true
end

client.lua

1
2
--user_1给user_2转一千
skynet.call('.bank_service', 'lua', 'transfer', 'user_1', 'user_2', 1000)

我们可以看到处理的流程是这样的:

  1. client->bank_service(transfer)
  2. bank_service判断用余额是否足够
  3. 不够返回失败
  4. 够,bank_service->control_service(control)
  5. control_service判断是否风控
  6. 是返回失败
  7. 否返回成功
  8. bank_service根据结果决定操作
  9. 没风控,执行转账
  10. 风控了,返回失败

这段逻辑按照串行执行,没有问题。但是skynet.call是一个rpc操作,有异步挂起,流程从串行变成了并发
bank_service会在发送control消息后挂起等待control_service回复之后继续处理该执行流。但是问题是bank_service它不是阻塞等待。
它还能处理其他请求,挂起的仅仅是之前那个请求而已。

现在我们可以假设用户有:
余额1000

用户手很快,连续点了两下触发了并发。

  1. (请求1)client->bank_service(transfer(1000))
  2. (请求1)bank_service判断用余额是否足够
  3. (请求1)够 bank_service->control_service(control) 挂起
  4. (请求2)client->bank_service(transfer(1000))
  5. (请求2)bank_service判断用余额是否足够
  6. (请求2)够 bank_service->control_service(control) 挂起
  7. (请求1)收到回复没有风控,扣款1000,余额0。
  8. (请求2)收到回复没有风控,扣款1000,余额-1000。震惊,见鬼的bug出现了。

解决方案

并发改串行

利用skynet.queue,把执行流串行化,是最简单的方案。

bank_service.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
local queue = require "skynet.queue"()
local account_amonut_map = {} --账户余额
local CMD = {}
--转账
function CMD.transfer(from_account, to_account, num)
return queue(function()
local amonut = account_amonut_map[from_account] or 0
if amonut < num then
return false --余额不足
end

if not skynet.call(".control_service", 'lua', 'control', from_account, num) then
return false
end

local to_amonut = account_amonut_map[to_account] or 0
account_amonut_map[from_account] = amonut - num
account_amonut_map[to_account] = to_amonut + num

return true
end)
end

这样处理流程就变成了(请求1开始->结束)(请求2开始->结束),这个处理请求不可能再并发了。

并发锁拒绝并发处理

bank_service.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
local g_transfering = {}
local queue = require "skynet.queue"()
local account_amonut_map = {} --账户余额
local CMD = {}
--转账
local function transfer(from_account, to_account, num)
local amonut = account_amonut_map[from_account] or 0
if amonut < num then
return false --余额不足
end

if not skynet.call(".control_service", 'lua', 'control', from_account, num) then
return false
end

local to_amonut = account_amonut_map[to_account] or 0
account_amonut_map[from_account] = amonut - num
account_amonut_map[to_account] = to_amonut + num

return true
end

function CMD.transfer(from_account, to_account, num)
if g_transfering[from_account] then
return false
end
g_transfering[from_account] = true
local ret = transfer(from_account, to_account, num)
g_transfering[from_account] = false

return ret
end

这样不会并发了,拒绝同时转账。

前置扣款

bank_service.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
local account_amonut_map = {}   --账户余额
local CMD = {}
--转账
function CMD.transfer(from_account, to_account, num)
local amonut = account_amonut_map[from_account] or 0
if amonut < num then
return false --余额不足
end

account_amonut_map[from_account] = amonut - num
if not skynet.call(".control_service", 'lua', 'control', from_account, num) then
account_amonut_map[from_account] = amonut + num
return false
end

local to_amonut = account_amonut_map[to_account] or 0
account_amonut_map[to_account] = to_amonut + num

return true
end

双重判断

bank_service.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
local account_amonut_map = {}   --账户余额
local CMD = {}
--转账
function CMD.transfer(from_account, to_account, num)
local amonut = account_amonut_map[from_account] or 0
if amonut < num then
return false --余额不足
end

if not skynet.call(".control_service", 'lua', 'control', from_account, num) then
return false
end

amonut = account_amonut_map[from_account] or 0
if amonut < num then
return false --余额不足
end

account_amonut_map[from_account] = amonut + num
local to_amonut = account_amonut_map[to_account] or 0
account_amonut_map[to_account] = to_amonut + num

return true
end

这样能并发,也不会出问题。

只有skynet有并发重入问题吗?

只要存在并发,都会有这个问题,包括传统的使用异步消息回调,而不使用rpc调用也存在此问题。

异步回调无非是这样子,用skynet模拟。

bank_service.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
local account_amonut_map = {}   --账户余额
local CMD = {}
--转账
function CMD.transfer(from_account, to_account, num)
local amonut = account_amonut_map[from_account] or 0
if amonut < num then
return false --余额不足
end

skynet.send(".control_service", 'lua', 'control', from_account, num)
end

--回调消息处理
function CMD.transfer_callback(ret)
if not ret then return end

account_amonut_map[from_account] = amonut + num
local to_amonut = account_amonut_map[to_account] or 0
account_amonut_map[to_account] = to_amonut + num

--回复客户端成功
return true
end

这样,只要服务端是支持并发的,就也会存在问题。


详解skynet 频发的重入问题
https://huahua132.github.io/2024/03/24/skynet_frame/reent/
作者
huahua132
发布于
2024年3月24日
许可协议