前言
手机游戏项目中,由于用户在很多时间使用的是移动网络,和服务器连接不稳定在所难免。客户端发送给服务端的请求没接收到应答,也是经常碰到的情况。
同样是没有接收到应答,是因为服务端未接收到请求,还是发送应答给客户端失败,客户端很难区分。对客户端来说,这两种情况几乎没有什么分别。
这会带来一个问题:客户端在无法接收到应答的时候,是否发送重试请求?
如果是因为服务端没收到请求造成的无应答,那么发送重试请求并没有什么问题。但如果是因为服务端发送应答给客户端失败造成的无应答,那么发送重试请求,会让服务端重复处理已处理过的请求。
如果只是强化、升级这种请求,重复处理请求也许问题也不是太大。但如果是购买、消费这种请求,重复消费恐怕会引起玩家的重度不适,收到很多吐槽和投诉。
解决方案
我们需要解决的核心问题,是让客户端可以安全的发送重试请求。服务端应该能够正确的区分哪些请求是重试请求,避免重复处理。但如何实现这一点呢?
经过一些思考,我初步的实现了一个解决方案。
客户端发送请求唯一标识
对于手机游戏项目,大部分请求是带有用户属性的。首先,我们可以将请求区分的范围,缩小到同一用户的请求中。比如,在我们的项目中,通过传递 token
参数实现对用户身份的认证。
客户端在发送请求时,多传递一个 flag
参数,这是一个随机数。我们约定,客户端发送的每个新请求,都应该具有不同的 flag
值,而发送的重试请求,则使用失败的原请求的 flag
值。
服务端通过应答数据缓存和接收到请求的 flag
值,就可以区分是新请求还是重试请求。
1
2
3
4
5
6
7
8
| # 新请求
curl -v "http://lua.zivn.me/?token=4bcf03eaf46ae3976a5774f06fdc415e&action=equip.buy&equipId=1&flag=0.927991823060438"
# 新请求
curl -v "http://lua.zivn.me/?token=4bcf03eaf46ae3976a5774f06fdc415e&action=equip.sell&sellIds=25&flag=0.14721225947141647"
# 重试请求
curl -v "http://lua.zivn.me/?token=4bcf03eaf46ae3976a5774f06fdc415e&action=equip.sell&sellIds=27&flag=0.14721225947141647"
|
服务端缓存应答
服务端将缓存每个用户最后一个请求的应答数据,缓存数据的键名使用 token
参数构造,存储请求的动作 action
、应答数据 reply
和唯一标识 flag
值,如图:
服务端区分请求类型
服务端收到客户端的请求后,首先使用 token
参数组织键名,并从缓存中获取用户上一个请求的应答数据。
如果请求的动作 action
和唯一标识 flag
与缓存数据一致
判定为重试请求,直接将缓存的应答数据 reply
发送给客户端。
如果请求的动作 action
和唯一标识 flag
与缓存数据不一致
判定为新请求,根据动作 action
将请求数据分发给对应的业务处理逻辑,并将处理结果组织成应答后发送给客户端。
分析和实例
通过缓存的应答数据和请求唯一标识,我们能够区分请求是新请求还是重试请求,从而确定对应的处理策略,避免请求被重复处理。
以下是目前线上项目使用的代码实例,其中 Response:send
是发送应答的方法,Response:checkRetry
是检查请求是否为重试请求的方法。
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
| local xxtea = loadMod("xxtea")
local util = loadMod("core.util")
local exception = loadMod("core.exception")
local request = loadMod("core.request")
local counter = loadMod("core.counter")
local sysConf = loadMod("config.system")
local changeLogger = loadMod("core.changes")
local redis = loadMod("core.driver.redis")
local cacheConf = loadMod("config.cache")
local shmDict = loadMod("core.driver.shm")
local shmConf = loadMod("config.shm")
--- Response模块
local Response = {
--- 请求缓存键名前缀
CACHE_KEY_PREFIX: lastRes",
--- Response存储处理器实例
cacheHelper = nil,
}
--- 生成重试缓存键名
--
-- @param number userId 用户ID
-- @return string 重试缓存键名
function Response:getCacheKey(userId)
return util:getCacheKey(self.CACHE_KEY_PREFIX, userId)
end
--- Response模块初始化
--
-- @return table Response模块
function Response:init()
if sysConf.PRIORITY_USE_SHM then
self.cacheHelper = shmDict:getInstance(shmConf.DICT_DATA)
else
self.cacheHelper = redis:getInstance(cacheConf.INDEX_CACHE)
end
return self
end
--- 发送应答
--
-- @param string message 应答数据
-- @param table headers 头设置
function Response:say(message, headers)
ngx.status = ngx.HTTP_OK
for k, v in pairs(headers) do
ngx.header[k] = v
end
ngx.print(message)
ngx.eof()
end
--- 构造并发送应答数据
--
-- @param table|string message 消息
-- @param boolean noCache 不缓存消息
function Response:send(message, noCache)
local headers = {
charset = sysConf.DEFAULT_CHARSET,
content_type = request:getCoder():getHeader()
}
if sysConf.DEBUG_MODE then
ngx.update_time()
headers.mysqlQuery = counter:get(counter.COUNTER_MYSQL_QUERY)
headers.redisCommand = counter:get(counter.COUNTER_REDIS_COMMAND)
headers.execTime = ngx.now() - request:getTime()
end
if sysConf.ENCRYPT_RESPONSE then
message = xxtea.encrypt(message, sysConf.ENCRYPT_KEY)
end
self:say(message, headers)
if not noCache then
local action = request:getAction()
local token = request:getToken(false)
local flag = request:getRandom()
if token ~= "" and flag ~= "" then
local cacheKey = self:getCacheKey(token)
local cacheData = { action = action, flag = flag, headers = headers, reply = message }
self.cacheHelper:set(cacheKey, cacheData, sysConf.REQUEST_RETRY_EXPTIME)
end
end
end
--- 检查重试请求,如果存在缓存则返回缓存
--
-- @return boolean
function Response:checkRetry()
local action = request:getAction()
local token = request:getToken(false)
local flag = request:getRandom()
if token ~= "" and flag ~= "" then
local cacheKey = self:getCacheKey(token)
local cacheData = self.cacheHelper:get(cacheKey)
if cacheData and cacheData.action == action and cacheData.flag == flag then
self:say(cacheData.reply, cacheData.headers)
return true
end
end
return false
end
return Response:init()
|