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 怎么知道哪个键过期了?它会每秒扫描多次,但扫描是 随机的。
每次扫描,它会:
- 随机取 20 个键
- 检查其中过期的键
- 如果超过 25% 过期了,再扫下一批
这意味着:如果大量令牌在同一秒过期,Redis 会卡在这一轮扫描里。延迟上升,吞吐下降。
怎么避免?我的做法是:
- 错开过期时间:给每个令牌桶加一个随机偏移,比如 TTL = 86400 + rand(0, 3600)
- 控制令牌总数:不要给每个用户都创建桶。只有活跃用户才创建。
内存监控:别等到报警才看
我建议你在 Redis 里加上这些监控指标:
INFO memory:查看 used_memory 和 maxmemoryINFO 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)
嗯,这就是我目前用得最顺手的方案。稳、快、不丢令牌、不占内存。如果你现在正在设计令牌存储层,可以直接拿这个思路去落地。