Redis(下):超出缓存用途的用法:锁、队列与排行榜

📅
2 分钟阅读
·

系列目录

  1. MySQL 索引与慢查询:B+ 树如何减少扫描
  2. 声明式事务之下:InnoDB 的 MVCC 与锁
  3. 接入层:Nginx 反向代理与 OpenResty 的边界
  4. Web 容器与 Netty:线程模型之下的 IO 模型
  5. Redis(上):缓存用法与单线程模型的限制
  6. Redis(下):超出缓存用途的用法:锁、队列与排行榜(本篇)

分布式锁的续期与 fencing token

分布式锁与唯一 ID:多实例部署后的新问题)记录了云盘多实例部署后用 tair putIfAbsent 实现分布式锁的过程,包括锁超时、续期、释放误删三个坑,以及用 token 加条件删除的修法。那篇关注的是「单机锁到分布式锁的前提变化」,本篇关注的是锁本身依赖的协调服务能提供多强的保证,以及 Redis 在缓存之外被用来做锁、队列、排行榜时,这些用法各自成立的条件。

那篇留下两个没有展开的问题。第一个是锁超时与续期。那篇已记录「按历史耗时加余量设 TTL、执行时间不可控的长任务每过三分之一 TTL 带 token 续期」的做法,没有展开的是这套续期逻辑做成通用组件后的行为。Redisson 把它做成了 watchdog:获取锁时若不指定超时,默认 30 秒,后台每隔 10 秒(即三分之一个 lockWatchdogTimeout)检查持有者仍是自己再续期。续期降低锁过期概率,不能消除它:一次长 Full GC 会同时停住业务线程和续期线程,GC 结束时锁可能已过期并被他人获取,旧持有者仍会继续执行临界区代码。

第二个问题是 fencing token。2016 那篇提到,即使获取、续期、条件删除都原子,TTL 到期后恢复的旧持有者仍可能继续写下游。要阻止它覆盖新持有者的结果,需要给每次成功获取锁分配一个单调递增的 fencing token,并由被写入的资源(数据库、消息系统)拒绝较小 token 的写入。Redis 的 SET NX PX 返回值只有成功或失败,不返回递增编号,需要业务自己再发一次 INCR 拿序号,且这次 INCRSET NX 不在同一个原子操作里。这意味着用 Redis 做锁,fencing token 这层保护要使用方自己补齐,没补齐时锁只能降低并发冲突的频率,不能保证写入顺序。

Redis 锁的保证范围取决于 Redis 本身能提供的保证。RedLock 争论也围绕这一点展开。

RedLock 争论:分歧在对故障模型的假设

单实例 Redis 锁的写法在 2016 那篇已经覆盖:SET key value NX PX ttl,value 是只有持有者能造出来的唯一值,释放用 Lua 脚本做「值仍等于 token 才删除」的 CAS。

# 获取锁:NX 保证只在 key 不存在时设置,PX 设过期
SET lock:order:123 "instance-7:9f3a-c1b2" NX PX 30000

# 释放锁:Lua 脚本保证「比对 + 删除」原子
# KEYS[1]=lock key, ARGV[1]=持有者 token
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

单实例的问题是:Redis 实例宕机后,锁服务不可用;主从切换时,异步复制可能使新主库尚未收到锁的写入,而旧主库已经宕机,锁记录随之丢失。antirez 提出的 RedLock 算法试图在不依赖主从复制的前提下提供更强的保证:部署 N 个(通常 5 个)独立的 Redis 实例,客户端向所有实例发 SET NX PX 获取同一把锁,只在多数派(N/2+1)成功且总耗时小于锁的 TTL 时,才认为获取成功;释放时向所有实例发删除。

2016 年 Martin Kleppmann 在《How to do distributed locking》一文里质疑了这个算法,antirez 随后在博客回应。两人的分歧可以拆成三组假设。

第一组是时钟假设。RedLock 的 TTL 依赖各节点时钟走得差不多快。Kleppmann 指出,如果某个节点的系统时钟突然向前跳(NTP 同步、虚拟机迁移、管理员改时间),这个节点上的锁会提前过期,多数派的计算就失效。antirez 回应 Redis 实例不该用 NTP 做大幅调整,且 ttl 是个软约束,锁本来就要按「会过期」设计。两人说的都对,但前提不同:antirez 假设运维能控制时钟偏移在毫秒级,Kleppmann 假设时钟跳变是不可完全排除的故障。

第二组是进程停顿假设。客户端拿到锁后,如果发生长 GC 或进程被挂起,停顿超过 TTL,锁会过期,其他客户端可以拿到同一把锁,两个客户端都认为自己持有锁。RedLock 没有针对持有者停顿的额外保护,只处理单实例宕机问题。Kleppmann 的结论是:要处理这种场景,必须用 fencing token,让下游资源拒绝旧持有者的写入。RedLock 不提供 fencing token;antirez 认为可以用 INCR 生成一个,但如前所述,INCRSET NX 不原子。

第三组是网络故障假设。客户端在获取多数派的过程中,如果与部分实例网络分区,算法靠 TTL 让这些实例上的锁自动过期。问题是客户端自己也可能在获取成功后、执行业务前发生分区,期间锁过期被他人抢走,客户端却不知道。Kleppmann 认为这等于锁失效了,antirez 认为任何分布式锁都有这个窗口,业务仍要按幂等或 fencing 设计。

RedLock 的分歧主要来自故障假设。它要求接受「锁服务依赖时钟大致正确、持有者不会长停顿」这些前提;在多数派实例可用时,它提供的锁服务强于单实例 Redis 锁。无法接受这些前提时,可使用基于共识协议(ZAB、Raft)的协调服务(ZooKeeper、etcd)。它们用租约与会话管理锁的归属,并用单调递增的 zxid/revision 作为 fencing token。ZooKeeper/etcd:小数据强一致的协调服务会展开这条路线。

分布式锁的保证强度应按业务需求选择。定时任务去重、缓存重建互斥等场景能够通过幂等处理锁失效造成的偶发重叠,单实例 Redis 锁可以满足要求。锁失效可能造成资金或库存错误时,可使用 RedLock 加 fencing,或直接使用 ZK/etcd。选择条件取决于下游约束能否控制锁失效造成的后果。

List 当队列:没有确认机制意味着丢消息无法发现

第二个用法是 List 当轻量队列。这里没有对应的真实生产经历,云盘的异步需求当时都走了公司内部的 MQ(mafka),没有用 Redis List 承载过业务消息。调研结论是,不将 Redis List 用于业务消息。

Redis List 的 LPUSH + BRPOP 能构成一个最简队列:生产者 LPUSH 把消息塞进 List 头部,消费者用 BRPOP 阻塞等待 List 尾部有数据。BRPOP 返回元素的同时会从 List 里移除它,所以一条消息只会被一个消费者拿到。

# 生产者
LPUSH queue:order "{...order event json...}"

# 消费者:阻塞等待最多 30 秒
BRPOP queue:order 30

这套写法可以工作,但与 MQ 相比缺少三个机制。

第一是确认机制。BRPOP 取走元素的同时,元素就从 List 里消失了。如果消费者取出消息后、处理完成前进程崩溃,这条消息没有任何地方留存,丢失后无法发现。Kafka 的做法是消费位移与消息存储分离:消息存在日志里,消费者处理完才提交位移,未提交的消息下次还能读到。RocketMQ、RabbitMQ 的 ack 机制同理。List 队列要补这层,得自己实现:用 BRPOPLPUSH 把取出的消息原子地移到一个 processing 列表,处理完再 LREM 删除,定时扫描 processing 列表把超时未删的重新入队。每个消费方都要实现这套逻辑,且 processing 列表本身没有持久化保证,Redis 实例重启时它会和原队列一起丢失。

第二是重试与死信。MQ 提供重试次数、退避策略和死信队列;失败到一定次数的消息进入死信队列等待人工处理。List 队列需要自行实现这些机制,或不重试并在失败时丢弃消息。

第三是积压监控。List 的 LLEN 能看积压量,但没有 MQ 那种「消费速率、生产速率、积压趋势」的现成指标。需要自己采集 LLEN 做告警。

除了这三个机制,还需要管理连接占用。消费者用 BRPOP 阻塞等待时,连接在阻塞期间不会被释放,Redis 会为其保留连接状态。如果消费者数量多,每个消费者一个阻塞连接,连接数会随消费者数线性增长。Redis 单实例的连接数有上限(默认 maxclients 10000),大量阻塞连接会占用这个配额,也可能耗尽客户端连接池。MQ 的长轮询或推送模型会复用连接,一个连接可以消费多个分区或队列;Redis List 队列需要自行处理这一点。

List 队列适合丢失后可重算的场景,例如可重跑的统计任务、缓存预热消息和对结果不敏感的通知。这类场景中,消息丢失后可以重新触发,确认机制和重试的工程成本可能高于收益。消息丢失会造成业务状态不一致,且无法通过重算恢复时,应使用 MQ。Kafka(上):吞吐的来源是顺序 IOKafka(下):生产端、broker 与消费端的可靠性配置展开 Kafka 的吞吐来源和可靠性边界,消息语义:按 at-least-once 设计业务代码展开消息语义与消费幂等。

zset 排行榜与延迟任务:内存随成员数线性增长

第三个用法是 Sorted Set(zset)做排行榜和延迟任务。zset 的每个成员带一个 score,内部按 score 排序,ZADD 写入,ZREVRANGE 按 score 从高到低取前 N 名。排行榜是它的典型场景:score 存积分或成绩,成员存用户 ID。

# 写入/更新积分
ZADD leaderboard:game1 9500 "user:42"

# 取前 10 名(含积分)
ZREVRANGE leaderboard:game1 0 9 WITHSCORES

zset 排行榜的成立条件是成员数可控。zset 的内存随成员数线性增长,每个成员除了 score 和成员值,还要维护跳表的多层指针。成员数为百万的排行榜,内存占用在百 MB 量级。如果排行榜是全局的,成员数随用户增长持续膨胀,zset 会成为大 key:ZREVRANGE 本身是 O(log N + M),但 ZRANGE 全量取、ZREMRANGEBYRANK 批量删或整个 zset 的 DEL 都会因 N 大而阻塞实例。Redis(上):缓存用法与单线程模型的限制讲过单线程模型下慢命令拖累整个实例,这里同样适用。

4.0 的 lazyfree 能缓解删除阻塞:UNLINK 代替 DEL,把实际释放内存的工作交给后台线程,主线程只做摘除。删除大 zset 时,UNLINK 可避免 DEL 在主线程释放内存造成的阻塞。lazyfree 不会减少 zset 日常占用的内存。控制成员数仍要靠业务手段:只保留前 N 名、按时间分片多个 zset、定期清理低分成员。

延迟任务是 zset 的另一个用法:score 存任务的到期时间戳,一个轮询进程定时 ZRANGEBYSCORE 取出到期任务。使用这一方案时有两个限制。一是轮询频率与精度:轮询越密,延迟越精确,但每次 ZRANGEBYSCORE 都是 O(log N + M) 的查询,频率过高会增加实例负载。二是任务量大时的内存问题:和排行榜一样,zset 随任务数线性增长。RocketMQ 提供延迟消息,任务调度中间件也能按时间触发任务,本系列第 15、16 篇会展开。

边界清单

判断 Redis 的每种超缓存用法是否成立,复用 Redis(上):缓存用法与单线程模型的限制的两个问题:这个场景接受丢数据吗?这个用法会产生慢命令或大 key 吗?

各用法与成立条件、失效条件的对照
用法成立条件失效条件
分布式锁(单实例 SET NX PX + Lua 解锁)锁失效偶发重叠可被业务幂等或数据约束兜住锁失效会造成不可逆的资金/库存错误,且未配 fencing token
分布式锁(RedLock)接受「时钟大致正确、持有者不长停顿」前提,需多数派实例可用时钟跳变不可控、持有者可能长 GC、要求锁失效概率为零
List 当队列(LPUSH/BRPOP消息丢了能重算或重发,不要求确认/重试/积压监控消息丢失会造成业务状态不一致且无法恢复
zset 排行榜成员数可控,内存占用在预算内,删除用 UNLINK全局排行榜、成员数随用户无限增长
zset 延迟任务任务量可控,轮询频率与实例负载匹配,精度要求不高任务量大、需秒级精度、要求持久化保证

几点补充:

  1. Redis 锁的保证范围受 Redis 本身限制。 SET NX PX 是原子的,但 TTL 到期后的行为、主从复制的延迟和实例宕机后的切换窗口,都不在单条命令的原子性范围内。评估锁的可靠性时,还要考虑这些条件。
  2. fencing token 需要下游校验。 只在锁服务获取 token 而下游不校验时,token 无法阻止旧持有者写入。补齐这层需要业务和存储一起改。
  3. processing 列表不能提供持久化确认。 它能发现「取出后崩溃」的丢失,但 processing 列表本身也在 Redis 里,实例重启时同样会丢失。需要持久化确认机制时,应使用支持该机制的 MQ。
  4. 大 zset 属于大 key。 Redis(上):缓存用法与单线程模型的限制的大 key 纪律在这里全部适用:监控 MEMORY USAGE、删除用 UNLINK、按业务拆分。
  5. lazyfree 缓解删除时的阻塞。 4.0 的 UNLINK 把内存释放交给后台线程,但平时占用的大 key 内存不会自动回收。控制成员数仍是业务侧的责任。

当时的选型

不能丢失的异步需求使用 MQ,Redis 队列只用于可重算的任务。锁的选型按失效后果划分:失效可由幂等处理的场景使用单实例 Redis 锁,失效会造成不可逆后果的场景使用 ZK/etcd 或补 fencing。排行榜和延迟任务可用 zset,前提是成员数和任务量在内存预算内,删除使用 UNLINK。Redis 锁、队列、排行榜和延迟任务能否使用,取决于其依赖的 Redis 行为保证及这些保证失效的条件。

参考资料


753 字 · 54 段落
ximing

Follow onGitHub

相关文章