前言
手机游戏项目中,由于用户在很多时间使用的是移动网络,和服务器连接不稳定在所难免。客户端发送给服务端的请求没接收到应答,也是经常碰到的情况。
同样是没有接收到应答,是因为服务端未接收到请求,还是发送应答给客户端失败,客户端很难区分。对客户端来说,这两种情况几乎没有什么分别。 这会带来一个问题:客户端在无法接收到应答的时候,是否发送重试请求? 如果是因为服务端没收到请求造成的无应答,那么发送重试请求并没有什么问题。但如果是因为服务端发送应答给客户端失败造成的无应答,那么发送重试请求,会让服务端重复处理已处理过的请求。 如果只是强化、升级这种请求,重复处理请求也许问题也不是太大。但如果是购买、消费这种请求,重复消费恐怕会引起玩家的重度不适,收到很多吐槽和投诉。解决方案
我们需要解决的核心问题,是让客户端可以安全的发送重试请求。服务端应该能够正确的区分哪些请求是重试请求,避免重复处理。但如何实现这一点呢?
经过一些思考,我初步的实现了一个解决方案。客户端发送请求唯一标识
对于手机游戏项目,大部分请求是带有用户属性的。首先,我们可以将请求区分的范围,缩小到同一用户的请求中。比如,在我们的项目中,通过传递 token
参数实现对用户身份的认证。
flag
参数,这是一个随机数。我们约定,客户端发送的每个新请求,都应该具有不同的 flag
值,而发送的重试请求,则使用失败的原请求的 flag
值。 服务端通过应答数据缓存和接收到请求的 flag
值,就可以区分是新请求还是重试请求。 # 新请求curl -v ""# 新请求curl -v ""# 重试请求curl -v ""
服务端缓存应答
服务端将缓存每个用户最后一个请求的应答数据,缓存数据的键名使用 token
参数构造,存储请求的动作 action
、应答数据 reply
和唯一标识 flag
值,如图:
服务端区分请求类型
服务端收到客户端的请求后,首先使用 token
参数组织键名,并从缓存中获取用户上一个请求的应答数据。
-
如果请求的动作
判定为重试请求,直接将缓存的应答数据action
和唯一标识flag
与缓存数据一致reply
发送给客户端。 -
如果请求的动作
判定为新请求,根据动作action
和唯一标识flag
与缓存数据不一致action
将请求数据分发给对应的业务处理逻辑,并将处理结果组织成应答后发送给客户端。
分析和实例
通过缓存的应答数据和请求唯一标识,我们能够区分请求是新请求还是重试请求,从而确定对应的处理策略,避免请求被重复处理。
以下是目前线上项目使用的代码实例,其中 Response:send
是发送应答的方法,Response:checkRetry
是检查请求是否为重试请求的方法。
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 selfend--- 发送应答---- @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 endend--- 检查重试请求,如果存在缓存则返回缓存---- @return booleanfunction 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 falseendreturn Response:init()