Featured image of post 妥善的处理重试请求

妥善的处理重试请求

前言

  手机游戏项目中,由于用户在很多时间使用的是移动网络,和服务器连接不稳定在所难免。客户端发送给服务端的请求没接收到应答,也是经常碰到的情况。
  同样是没有接收到应答,是因为服务端未接收到请求,还是发送应答给客户端失败,客户端很难区分。对客户端来说,这两种情况几乎没有什么分别。
  这会带来一个问题:客户端在无法接收到应答的时候,是否发送重试请求?
  如果是因为服务端没收到请求造成的无应答,那么发送重试请求并没有什么问题。但如果是因为服务端发送应答给客户端失败造成的无应答,那么发送重试请求,会让服务端重复处理已处理过的请求。
  如果只是强化、升级这种请求,重复处理请求也许问题也不是太大。但如果是购买、消费这种请求,重复消费恐怕会引起玩家的重度不适,收到很多吐槽和投诉。

解决方案

  我们需要解决的核心问题,是让客户端可以安全的发送重试请求。服务端应该能够正确的区分哪些请求是重试请求,避免重复处理。但如何实现这一点呢?
  经过一些思考,我初步的实现了一个解决方案。

客户端发送请求唯一标识

  对于手机游戏项目,大部分请求是带有用户属性的。首先,我们可以将请求区分的范围,缩小到同一用户的请求中。比如,在我们的项目中,通过传递 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 参数组织键名,并从缓存中获取用户上一个请求的应答数据。

  1. 如果请求的动作 action 和唯一标识 flag 与缓存数据一致
    判定为重试请求,直接将缓存的应答数据 reply 发送给客户端。

  2. 如果请求的动作 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()