3、令牌存储与失效机制:基于Redis的令牌存储,TTL策略

上一章我们聊了令牌桶怎么计数、怎么限流。

但有个更重要的问题——令牌本身存在哪?

存内存里?服务一重启,全部丢失。

存数据库?每次请求都查一次?那延迟直接爆炸。

所以行业里几乎清一色选择了 Redis。为什么?说白了,就四个字:快、持久、原子、好配。

核心结论:Redis 是令牌存储的主流方案。它兼顾了性能与可靠性。但存什么、怎么存、什么时候删——这里面坑不少。

数据结构选择:不只是用 String 那么简单

我早期做的一个项目,直接用 Redis String 存令牌数量。

SET user::9527 10
DECR user::9527

简单粗暴。但问题很快来了——令牌桶需要记录 最后填充时间,String 存不了复合结构。

现在主流做法是组合使用:

  • Hash:存令牌桶的复合状态(令牌数 + 时间戳)
  • String:存一些简单的计数器(比如单次请求的令牌消耗记录)
  • Sorted Set:用在滑动窗口场景,时间戳当 score(这个后面章节细说)

我个人习惯用 Hash + TTL 的组合。举个例子:

// 令牌桶结构(Hash)
HSET :bucket:user123 tokens 10 last_refill 1700000000
EXPIRE :bucket:user123 86400

// 读的时候
HGET :bucket:user123 tokens
HGET :bucket:user123 last_refill

这样既有结构,又能自动过期。嗯,这里要注意,如果提前删了 Hash,桶状态就丢了。所以 TTL 要留够余量。

数据结构 适用场景 我的推荐度
String 纯计数器,无需复合字段 ⭐⭐
Hash 令牌桶 + 时间戳 + 其他字段 ⭐⭐⭐⭐⭐
Sorted Set 滑动窗口限流(按时间排序) ⭐⭐⭐

TTL 策略:设多长?设了之后怎么续?

你会不会纠结——TTL 设短了,用户活跃期内桶突然消失?设长了,已不用的桶占着 Redis 内存?

我给你一个参考值:24小时 (86400秒)。

为什么?因为大部分场景里,用户每天至少登录一次。24小时正好覆盖一天的活跃周期。

但别忘了 续期。每次用户请求时,如果桶还在,就用 EXPIRE 刷新 TTL。这样活跃用户的桶永远不会消失,非活跃用户自然淘汰。

小技巧:用 SETEX 或 PEXPIRE 来设置毫秒级 TTL。我习惯用毫秒,因为有些高并发场景下,秒级精度不够。

另一种策略是 懒过期——不设全局 TTL,而是在每次访问时检查是否过期。但这会给代码增加一个判断分支,我一般不推荐。

Redis 内存淘汰机制:当内存满了怎么办?

Redis 不是无限内存的。万一令牌数据占满了怎么办?

这时 Redis 的 淘汰策略 就上场了。我会在配置里这样选:

  • allkeys-lru:最久没用的键优先淘汰。适合令牌桶,因为不活跃用户的桶先被踢出去。
  • volatile-ttl:TTL 越近的优先淘汰。前提是你设了 TTL。
  • noeviction:满了就报错。别用这个,除非你保证永远不超。

警告:我曾经在一个高并发项目里用了 noeviction,结果高峰期 Redis 直接返回 OOM 错误。令牌拿不到,所有请求被限死。后来紧急切到 allkeys-lru,问题才解决。

过期扫描与定时清理:Redis 幕后干了啥

Redis 怎么知道哪个键过期了?它会每秒扫描多次,但扫描是 随机的。

每次扫描,它会:

  1. 随机取 20 个键
  2. 检查其中过期的键
  3. 如果超过 25% 过期了,再扫下一批

这意味着:如果大量令牌在同一秒过期,Redis 会卡在这一轮扫描里。延迟上升,吞吐下降。

怎么避免?我的做法是:

  • 错开过期时间:给每个令牌桶加一个随机偏移,比如 TTL = 86400 + rand(0, 3600)
  • 控制令牌总数:不要给每个用户都创建桶。只有活跃用户才创建。
令牌存储与失效核心流程 用户请求令牌 检查 Redis:令牌桶存在? Hash::bucket:user123 桶不存在 创建新桶 + 设 TTL 桶存在 读取令牌 + 续期 TTL 执行限流逻辑 DECR 令牌 / 返回允许或拒绝 注意:Redis 过期扫描是随机 + 惰性删除,不要依赖 TTL 做精确时序控制

内存监控:别等到报警才看

我建议你在 Redis 里加上这些监控指标:

  • INFO memory:查看 used_memory 和 maxmemory
  • INFO keyspace:查看每个 db 的 key 数量
  • MEMORY USAGE <key>:看单个 key 占多少内存。我有次发现一个 Hash 里塞了 10 万个字段,吓一跳。

还有一点——别把所有令牌桶放在一个 db 里。Redis 单线程处理一个 db 的命令,如果 db0 里全是令牌操作,会影响其他业务。我习惯把限流数据单独放到 db 1 或 db 2,业务数据放 db 0。

一个完整写入示例

最后给一段我实际项目里用的代码片段。你可以直接照着改:

// 创建或更新令牌桶
// key: :bucket:{userId}
// 字段: tokens, last_refill
// TTL: 86400 + 随机偏移 0~3600 秒

local random_ttl = math.random(0, 3600)
redis.call('HMSET', key, 'tokens', initial_tokens, 'last_refill', current_time)
redis.call('EXPIRE', key, 86400 + random_ttl)

// 获取令牌时刷新 TTL(续期)
redis.call('EXPIRE', key, 86400 + random_ttl)

嗯,这就是我目前用得最顺手的方案。稳、快、不丢令牌、不占内存。如果你现在正在设计令牌存储层,可以直接拿这个思路去落地。