为什么业务缓存需要 TTL 抖动,而防重放 Key 通常不需要?
从 HMAC、Timestamp 与 Nonce 出发,讲清防重放安全状态和业务缓存为何需要不同的 Redis TTL 策略。
在梳理网关的 HMAC 防重放设计时,我一开始卡在了一个看起来很简单的问题上:
Redis 缓存为了防止大量 Key 同时过期,通常会给 TTL 加随机抖动。
那么网关里的 Nonce 防重放 Key 也是 Redis Key,为什么通常又不需要 TTL 抖动?
继续想下去,问题反而更多了。
如果一个商品请求本身也要经过网关,也会携带 Timestamp、Nonce,也会写一个防重放 Key,那么所谓的“业务缓存”和“网关防重放状态”到底差在哪里?
表面上看,它们都在 Redis 里,都有 Key,都有 TTL。
真正的区别其实不在 Redis,而在于:
这个 Key 到底是在缓存一份可以重新获取的数据,还是在保存一段不能提前丢失的安全状态。
这篇文章就是把我这次绕进去的几个问题重新捋一遍。
HMAC 能防篡改,但不能单独防重放
先从一个正常请求开始。
假设客户端调用:
POST /api/v1/order请求中携带:
Timestamp: 10:00:00Nonce: a8f3d921...Signature: xxxx客户端会把 Method、Path、Timestamp、Nonce、Body 等内容按照约定规则组成 Canonical String,然后使用双方持有的 MAC Key 计算 HMAC。
例如原始 Body:
{ "amount": 100}如果被攻击者修改成:
{ "amount": 10000}Body 发生变化,服务端重新计算出的 HMAC 就会不同。
因此签名校验可以发现:
请求内容被修改了。
但这里有一个很容易忽略的问题。
攻击者完全可以不修改任何东西,而是把抓到的合法请求原封不动地再发一次:
Timestamp = 10:00:00Nonce = abc123Body = {"amount":100}Signature = xxxxxx由于所有参与签名的数据都没变化,服务端重新计算 HMAC 时结果仍然一致。
也就是说:
HMAC 解决的是完整性和身份校验问题,它本身并不能阻止一个合法请求被完整复制后再次发送。
这就是为什么还需要 Timestamp 和 Nonce。
Timestamp 不是为了让 Nonce 更随机
我一开始对 Timestamp 的理解有点偏。
容易下意识觉得:
Nonce 再加一个 Timestamp,不就更不容易撞了吗?
但这不是 Timestamp 最重要的职责。
如果 Nonce 本身使用足够大的随机空间,例如 128 bit 随机值,那么它的碰撞概率本来就已经非常低。
Timestamp 真正解决的是:
这个请求还能活多久?
例如网关规定:
请求时间与服务器时间相差不能超过 5 分钟可以理解为:
abs(server_time - request_timestamp) <= 300s如果当前服务器时间已经是 10,而请求里的 Timestamp 还是 10,那么即使:
- HMAC 完全正确
- Nonce 从没出现过
- Body 没被修改
这个请求仍然应该被拒绝。
因为它已经过了有效窗口。
所以 Timestamp 的职责更准确地说是:
限制请求的时间边界。
没有 Timestamp 的话,攻击者今天抓到一个合法请求,理论上很久以后仍然可以拿出来尝试重放。
Nonce 解决的是“这个请求是不是已经用过”
Timestamp 只能判断:
这个请求是不是太旧了?
但它不能判断:
这个请求在有效期内是不是已经执行过一次?
例如:
10:00:00 第一次请求10:00:10 第二次重放两个请求都处于 5 分钟有效窗口内。
因此还需要一个每次请求都不同的一次性随机值:
Nonce例如:
Request A -> nonce-ARequest B -> nonce-BRequest C -> nonce-C服务端第一次看到一个 Nonce 时,需要把它登记下来。
例如:
replay:user123:nonce-A然后执行:
SET replay:user123:nonce-A 1 NX EX 360这里的 NX 很关键:
只有 Key 不存在时才能写入。第一次请求:
Key 不存在SET NX 成功-> 继续执行攻击者重新发送同一个请求:
Key 已经存在SET NX 失败-> 判定为重放而且这个操作最好使用单条 Redis 命令完成:
SET key value NX EX ttl不要拆成:
SETNXEXPIRE因为两个独立操作之间存在失败窗口。
如果 SETNX 成功以后进程异常退出,而 EXPIRE 还没执行,这个 Key 就可能永久存在。
我真正绕进去的地方:Timestamp 是 5 分钟,TTL 能不能只有 1 分钟?
这个问题看起来像是在问一个 Redis 参数。
实际上它问的是:
Redis 需要记住一个 Nonce 多久,才能完整覆盖这个请求仍然合法的时间?
假设:
Timestamp 有效窗口 = 5 分钟Replay Key TTL = 1 分钟第一次请求:
10:00:00
Timestamp = 10:00:00Nonce = abc123网关写入:
SET replay:abc123 1 NX EX 60第一次请求通过。
到了:
10:01:00Redis Key 过期。
然后攻击者在:
10:02:00把原请求完整重放一次。
此时先检查 Timestamp:
10:02 - 10:00 = 2 分钟仍然没有超过 5 分钟。
Timestamp 合法。
HMAC 也合法,因为请求没有被修改。
再看 Redis:
replay:abc123已经不存在了。
于是再次执行:
SET replay:abc123 1 NX EX 60会成功。
也就是说:
同一个请求可能再次通过。
所以这里真正应该记住的是:
Replay Key TTL >= Timestamp 最大有效窗口例如:
Timestamp Window = 300sReplay TTL = 360s多出来的一点时间可以作为时钟偏差或边界缓冲。
这时候两者的关系就很清楚了:
Timestamp 定义一个请求最多可以活多久。
Redis 至少要在整个存活窗口内记得这个 Nonce 已经使用过。
如果 Redis 提前忘记,而 Timestamp 仍然认可这个请求,那么防重放机制就出现了空窗期。
那 TTL 抖动到底是在解决什么问题?
这就回到了最开始让我迷糊的地方。
我们平时确实经常会看到这样的缓存设计:
TTL = 30min + random(0~5min)为什么?
假设一个商品服务启动时预热了 10 万个商品缓存:
10:00:00
product:1 EX 1800product:2 EX 1800product:3 EX 1800...product:100000 EX 1800如果所有 Key 都是 30 分钟 TTL,那么在 10 左右,它们可能在很短时间内集中过期。
随后大量用户请求商品数据:
GET product:1GET product:2GET product:3...Redis 大量 MISS。
服务只能回源数据库:
Redis MISS | vMySQL | v重新建立缓存本来应该被 Redis 吸收的流量,突然一起打到数据库。
这就是典型的缓存雪崩场景之一。
所以业务缓存常常写成:
TTL = 1800 + random(0~300)变成:
product:1 -> 1834sproduct:2 -> 1967sproduct:3 -> 1812sproduct:4 -> 2041s让过期时间分散开。
因此:
业务缓存的 TTL 抖动,本质上是在解决大量缓存同时失效导致的流量集中和回源压力问题。
可商品请求不也是从网关进来的吗?
这是我第二个疑惑。
例如:
GET /products/1001确实可能经历:
客户端 | vNginx | vSaaS Gateway | vProduct Service | vRedis / MySQL在网关层,它可能产生:
replay:user1:nonce123进入商品服务以后,又可能访问:
product:1001所以同一个请求链路里完全可能同时出现两种 Redis Key:
GET /products/1001 | v Gateway | +-- replay:user1:nonce123 | v Product Service | +-- product:1001 | v MySQL问题就在这里:
请求是不是经过同一个网关,并不能决定这些 Redis Key 使用同一种 TTL 策略。
因为它们表达的是两件完全不同的事情。
业务缓存和安全状态到底差在哪?
我后来觉得,区分两者最简单的方法不是看 Key 名,也不是看它是不是存在 Redis。
而是问一句:
这个 Key 过期以后,系统会做什么?
业务缓存
例如:
product:1001它表达的是:
商品 1001 数据的一份缓存副本。
过期以后:
Redis MISS | v查询 MySQL | v重新生成缓存也就是说,这个 Key 没了以后,原始数据通常还在。
系统只是需要付出一次额外成本重新获取它。
所以业务缓存的 TTL 主要考虑的是:
- 缓存命中率
- 数据新鲜度
- Redis 资源
- 数据库压力
- 回源成本
很多情况下提前几十秒或者晚几十秒,并不会破坏业务正确性。
因此 TTL 可以为了性能加入一定随机性。
防重放 Key
再看:
replay:user1:nonce123它表达的是:
在当前安全窗口里,这个请求已经使用过。
它过期以后不会发生:
Redis MISS-> 查 MySQL-> 把 replay key 重建回来因为它不是某份原始数据的缓存副本。
它本身就是:
服务器对“这个请求已经使用过”这件事的记忆。
一旦提前删除,服务器就失忆了。
而这会直接影响:
ALLOW还是:
DENY所以它更像是一段短生命周期的安全状态,而不是普通的数据缓存。
这也是为什么:
同样是 Redis Key,不能因为实现方式一样,就把它们当成同一类东西。
为什么防重放 Key 通常不需要 TTL 抖动?
把前面的逻辑串起来,这个问题就比较容易回答了。
第一,它通常不是批量创建的
Replay Key 一般随着真实请求不断产生:
10:00:01 nonce-A10:00:02 nonce-B10:00:07 nonce-C10:00:15 nonce-D假设统一 TTL 为 300 秒,它们也会自然错峰过期:
10:05:01 nonce-A 过期10:05:02 nonce-B 过期10:05:07 nonce-C 过期10:05:15 nonce-D 过期请求时间本身已经把 Key 的生命周期打散了。
这和“系统启动时一次性预热 10 万个缓存”不是同一种场景。
第二,它过期以后没有昂贵的回源重建
普通缓存:
Key 过期-> Cache Miss-> 查数据库-> 重建缓存Replay Key:
Key 过期-> 安全窗口结束-> 删除即可所以它不存在典型的:
大量 Key 同时过期,然后把数据库打爆。
第三,它的 TTL 直接参与安全边界
假设:
Timestamp Window = 300s如果写成:
TTL = 300 +/- random(60)某个 Key 可能只有:
240s于是会出现:
240s ~ 300s这 60 秒里:
Timestamp 仍然有效Replay Key 已经不存在攻击者就可能利用这个窗口重放请求。
所以对于这种安全状态:
不能为了所谓的“随机过期”而让 TTL 有机会短于安全窗口。
如果一定要加抖动,可以只往上加吗?
理论上可以。
例如:
Timestamp Window = 300sReplay TTL = 300 + random(0~60)那么最终是:
300~360 秒这样不会提前丢失安全状态。
但通常意义不大。
因为 Replay Key 本身:
- 生命周期短
- Value 很小
- 随请求自然错峰产生
- 不需要回源数据库重建
- TTL 本身还属于安全策略的一部分
因此,很多场景直接设置一个明确的安全值反而更容易维护:
Timestamp Window = 300sReplay TTL = 360s比增加无实际收益的随机性更加清晰。
一个请求里完全可以存在两套 TTL
最后把整条调用链放到一起看。
客户端发出:
Timestamp = 10:00:00Nonce = abc123Signature = HMAC(...)网关:
校验 HMAC | v校验 Timestamp | vSET replay:abc123 1 NX EX 360 | v请求通过业务服务:
GET product:1001缓存不存在时:
SELECT * FROM product WHERE id = 1001然后业务服务重新缓存:
SET product:1001 {...} EX 1957这里:
Replay TTL = 360s是为了:
在整个安全窗口内记住这个请求已经使用过。
而:
Product TTL = 1800 + random(...)是为了:
控制缓存生命周期,并避免大量业务缓存集中失效。
虽然两者都叫 TTL,也都可能存在 Redis 中,但它们解决的完全不是同一个问题。
最后,把这次绕晕我的地方压缩成几句话
现在我会这样理解这套机制。
Timestamp 限制请求能活多久。
例如:
5 分钟超过时间窗口直接拒绝。
Nonce 保证一个请求在有效窗口内只能使用一次。
服务端通过:
SET key value NX EX ttl原子地登记这个 Nonce 是否已经出现过。
Replay TTL 必须覆盖 Timestamp 的整个有效窗口。
例如:
Timestamp Window = 5minReplay TTL = 6min不能出现:
Timestamp 还有效Redis 却已经忘了这个 Nonce而对于 TTL 抖动,也不能简单理解成:
Redis Key 都应该随机过期。
真正应该先问的是:
这个 Key 过期以后,系统发生什么?
如果它是一份普通业务缓存,过期后会产生数据库回源压力,那么随机抖动可以帮助打散失效时间。
如果它保存的是一段安全状态,那么 TTL 本身可能就是安全边界的一部分,提前失效甚至会带来漏洞。
所以我这次真正理清的一点是:
Redis 只是存储工具,TTL 策略最终由 Key 的业务语义决定。