为什么业务缓存需要 TTL 抖动,而防重放 Key 通常不需要?

jessy jessy #redis#backend#security#cache#distributed-systems

从 HMAC、Timestamp 与 Nonce 出发,讲清防重放安全状态和业务缓存为何需要不同的 Redis TTL 策略。

为什么业务缓存需要 TTL 抖动,而防重放 Key 通常不需要?

在梳理网关的 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:00
Nonce: a8f3d921...
Signature: xxxx

客户端会把 Method、Path、Timestamp、Nonce、Body 等内容按照约定规则组成 Canonical String,然后使用双方持有的 MAC Key 计算 HMAC。

例如原始 Body:

{
"amount": 100
}

如果被攻击者修改成:

{
"amount": 10000
}

Body 发生变化,服务端重新计算出的 HMAC 就会不同。

因此签名校验可以发现:

请求内容被修改了。

但这里有一个很容易忽略的问题。

攻击者完全可以不修改任何东西,而是把抓到的合法请求原封不动地再发一次:

Timestamp = 10:00:00
Nonce = abc123
Body = {"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-A
Request B -> nonce-B
Request 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

不要拆成:

SETNX
EXPIRE

因为两个独立操作之间存在失败窗口。

如果 SETNX 成功以后进程异常退出,而 EXPIRE 还没执行,这个 Key 就可能永久存在。


我真正绕进去的地方:Timestamp 是 5 分钟,TTL 能不能只有 1 分钟?

这个问题看起来像是在问一个 Redis 参数。

实际上它问的是:

Redis 需要记住一个 Nonce 多久,才能完整覆盖这个请求仍然合法的时间?

假设:

Timestamp 有效窗口 = 5 分钟
Replay Key TTL = 1 分钟

第一次请求:

10:00:00
Timestamp = 10:00:00
Nonce = abc123

网关写入:

SET replay:abc123 1 NX EX 60

第一次请求通过。

到了:

10:01:00

Redis 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 = 300s
Replay TTL = 360s

多出来的一点时间可以作为时钟偏差或边界缓冲。

这时候两者的关系就很清楚了:

Timestamp 定义一个请求最多可以活多久。
Redis 至少要在整个存活窗口内记得这个 Nonce 已经使用过。

如果 Redis 提前忘记,而 Timestamp 仍然认可这个请求,那么防重放机制就出现了空窗期。


那 TTL 抖动到底是在解决什么问题?

这就回到了最开始让我迷糊的地方。

我们平时确实经常会看到这样的缓存设计:

TTL = 30min + random(0~5min)

为什么?

假设一个商品服务启动时预热了 10 万个商品缓存:

10:00:00
product:1 EX 1800
product:2 EX 1800
product:3 EX 1800
...
product:100000 EX 1800

如果所有 Key 都是 30 分钟 TTL,那么在 10 左右,它们可能在很短时间内集中过期。

随后大量用户请求商品数据:

GET product:1
GET product:2
GET product:3
...

Redis 大量 MISS。

服务只能回源数据库:

Redis MISS
|
v
MySQL
|
v
重新建立缓存

本来应该被 Redis 吸收的流量,突然一起打到数据库。

这就是典型的缓存雪崩场景之一。

所以业务缓存常常写成:

TTL = 1800 + random(0~300)

变成:

product:1 -> 1834s
product:2 -> 1967s
product:3 -> 1812s
product:4 -> 2041s

让过期时间分散开。

因此:

业务缓存的 TTL 抖动,本质上是在解决大量缓存同时失效导致的流量集中和回源压力问题。


可商品请求不也是从网关进来的吗?

这是我第二个疑惑。

例如:

GET /products/1001

确实可能经历:

客户端
|
v
Nginx
|
v
SaaS Gateway
|
v
Product Service
|
v
Redis / 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-A
10:00:02 nonce-B
10:00:07 nonce-C
10: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 = 300s
Replay TTL = 300 + random(0~60)

那么最终是:

300~360 秒

这样不会提前丢失安全状态。

但通常意义不大。

因为 Replay Key 本身:

  • 生命周期短
  • Value 很小
  • 随请求自然错峰产生
  • 不需要回源数据库重建
  • TTL 本身还属于安全策略的一部分

因此,很多场景直接设置一个明确的安全值反而更容易维护:

Timestamp Window = 300s
Replay TTL = 360s

比增加无实际收益的随机性更加清晰。


一个请求里完全可以存在两套 TTL

最后把整条调用链放到一起看。

客户端发出:

Timestamp = 10:00:00
Nonce = abc123
Signature = HMAC(...)

网关:

校验 HMAC
|
v
校验 Timestamp
|
v
SET 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 = 5min
Replay TTL = 6min

不能出现:

Timestamp 还有效
Redis 却已经忘了这个 Nonce

而对于 TTL 抖动,也不能简单理解成:

Redis Key 都应该随机过期。

真正应该先问的是:

这个 Key 过期以后,系统发生什么?

如果它是一份普通业务缓存,过期后会产生数据库回源压力,那么随机抖动可以帮助打散失效时间。

如果它保存的是一段安全状态,那么 TTL 本身可能就是安全边界的一部分,提前失效甚至会带来漏洞。

所以我这次真正理清的一点是:

Redis 只是存储工具,TTL 策略最终由 Key 的业务语义决定。