精彩博文

NG Gaming精彩博文,分享游戏API接口、NG包网、包网平台、包网系统及游戏平台运营等行业资讯,提供专业的游戏行业知识与解决方案。

防重放与单次 I/O 去重:基于 Redis + Lua 脚本的高并发 API 防刷实践

2026-10-07 精彩博文 26

在高并发 TG Mini App 架构实战:API 验签网关微秒级性能优化中,我们探讨了将验签逻辑前置到 OpenResty 网关层对提升系统吞吐量的巨大收益。但在实际的 Web3 游戏、TG 小程序或高频 API 对接场景中,仅校验 MD5/HMAC 签名是远远不够的。

黑客完全可以通过抓包获取合法的请求参数与签名,并在极短时间内进行反复重放,造成重复扣款、刷积分或接口刷单等严重后果。本文将深入讲解如何利用 Nonce + Timestamp 结合 Redis + Lua 脚本 的单次 I/O 原子操作,构建毫秒级响应的 API 防重放体系。




一、 防重放机制原理:Timestamp 与 Nonce 的协同拦截

单靠参数签名(Signature)无法杜绝重放攻击,必须引入两个核心维度:

  1. Timestamp(时间戳):限制请求的生命周期(例如只允许 $\pm 60$ 秒内的请求 passing)。超时请求直接抛弃,缩小防重放校验的时间窗口。

  2. Nonce(随机数/唯一 Request ID):在有效时间窗口内,确保每一个请求的 Nonce 唯一。一旦相同 Nonce 再次出现,即判定为重放攻击。



二、 传统验签的性能痛点:多次网络 RTT 的延迟叠加

在传统的网关或业务逻辑层校验中,完成一次完整的防重放与防刷流程通常需要多次 Redis 网络交互(RTT):

  • 第 1 次 RTT:查询 API Key / Secret 是否合法及状态。

  • 第 2 次 RTT:查询 Nonce 是否已存在(GET nonce)。

  • 第 3 次 RTT:若不存在则写入 Nonce 并设置 TTL(SETEX nonce ttl 1)。

  • 第 4 次 RTT:触发频控逻辑(INCR rate_limit_key)。

在高并发场景下,4 次 Redis 网络往返即使在内网也会产生 2~5ms 的延迟。当并发达到上万 QPS 时,网关连接池会迅速耗尽,形成严重的请求排队与超时。



三、 解决方案:Redis + Lua 脚本实现单次原子操作

为了消除 RTT 叠加带来的延时,我们可以将秘钥校验、Nonce 去重与速率限制逻辑打包成一段 Lua 脚本,在 Redis 服务端一次性原子化执行:

  • 单次 RTT 消耗:网关仅需与 Redis 进行 1 次网络交互。

  • 原子性保障:Redis 单线程执行 Lua 脚本期间不会插入其他命令,完美解决并发竞争(Race Condition)。

  • 开销最小化:自动完成 Nonce 过期设置(TTL 匹配时间戳窗口)与频控递增。



四、 OpenResty 调用 lua-resty-redis 实战代码

以下是在 OpenResty(Nginx + Lua)网关层嵌入 Redis Lua 脚本的完整实践逻辑。

1. Redis Lua 防重放与防刷脚本 (prevent_replay.lua)

Lua
-- KEYS[1]: Nonce Key (e.g. nonce:merchant_123:abc123xyz)-- KEYS[2]: Rate Limit Key (e.g. rate:merchant_123)-- ARGV[1]: TTL (Timestamp 允许的最大窗口时间,如 60 秒)-- ARGV[2]: Rate Limit Max Count (如 1 秒内最多 200 次)-- ARGV[3]: Rate Limit Expiration (如 1 秒)-- 1. 检查 Nonce 是否存在(去重)local nonce_exists = redis.call("EXISTS", KEYS[1])if nonce_exists == 1 then
    return {0, "REPLAY_ATTACK_DETECTED"}end-- 2. 检查速率限制 (Rate Limit)local current_requests = redis.call("INCR", KEYS[2])if tonumber(current_requests) == 1 then
    redis.call("EXPIRE", KEYS[2], tonumber(ARGV[3]))endif tonumber(current_requests) > tonumber(ARGV[2]) then
    return {0, "RATE_LIMIT_EXCEEDED"}end-- 3. 写入 Nonce 并设置过期时间redis.call("SET", KEYS[1], "1", "EX", tonumber(ARGV[1]))return {1, "SUCCESS"}

2. OpenResty Lua 网关接入层实现

Lua
local redis = require "resty.redis"local red = redis:new()
red:set_timeout(1000) -- 1秒超时local ok, err = red:connect("127.0.0.1", 6379)if not ok then
    ngx.log(ngx.ERR, "Redis 连接失败: ", err)
    ngx.exit(500)end-- 获取请求参数local args = ngx.req.get_uri_args()local merchant_id = args["merchant_id"]local nonce = args["nonce"]local timestamp = tonumber(args["timestamp"])-- 校验时间戳范围 (例如 60 秒)local now = ngx.time()if not timestamp or math.abs(now - timestamp) > 60 then
    ngx.status = 400
    ngx.say('{"code": 400, "message": "Invalid Timestamp"}')    return ngx.exit(200)end-- 执行 Redis Lua 脚本local lua_script = [[ ... 上述 Lua 脚本内容 ... ]]local nonce_key = "nonce:" .. merchant_id .. ":" .. noncelocal rate_key = "rate:" .. merchant_id .. ":" .. os.date("%Y%m%d%H%M%S")local res, err = red:eval(lua_script, 2, nonce_key, rate_key, 60, 200, 1)if not res then
    ngx.log(ngx.ERR, "Lua 脚本执行失败: ", err)
    ngx.exit(500)endif res[1] == 0 then
    ngx.status = 403
    ngx.say('{"code": 403, "message": "' .. res[2] .. '"}')    return ngx.exit(200)end-- 连接池归还red:set_keepalive(10000, 100)



五、 总结与最佳实践

通过将 Nonce 校验与防刷逻辑下沉至 Redis Lua 脚本,网关层处理单个请求的验签与防重放耗时可压缩至 1ms 以内。

针对更高并发的场景,还可以在网关层结合 Nginx 本地内存 shared dict 对高频秘钥进行本地缓存,进一步提升吞吐量。关于完整的 API 网关架构优化设计与微秒级响应实践,可参阅主文章高并发 TG Mini App 架构实战:API 验签网关微秒级性能优化。


猜你喜欢