在
黑客完全可以通过抓包获取合法的请求参数与签名,并在极短时间内进行反复重放,造成重复扣款、刷积分或接口刷单等严重后果。本文将深入讲解如何利用 Nonce + Timestamp 结合 Redis + Lua 脚本 的单次 I/O 原子操作,构建毫秒级响应的 API 防重放体系。
一、 防重放机制原理:Timestamp 与 Nonce 的协同拦截
单靠参数签名(Signature)无法杜绝重放攻击,必须引入两个核心维度:
Timestamp(时间戳):限制请求的生命周期(例如只允许 $\pm 60$ 秒内的请求 passing)。超时请求直接抛弃,缩小防重放校验的时间窗口。
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)
-- 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 网关接入层实现
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 网关架构优化设计与微秒级响应实践,可参阅主文章

NG包网