用大白话把 Redis 队列、悲观/乐观锁、顺序讲清楚
下面用最直白的语言,把 Redis 常用的队列机制、悲观锁和乐观锁、以及“顺序”相关的问题讲清楚。目标是让你能理解概念、知道常见实现方式、明白优缺点和常见坑,并能选择合适的方案。
一、先说结论(先有个总体印象)
- Redis 队列常用两种实现:基于 List 的简单队列(LPUSH/RPUSH + LPOP/RPOP/BRPOP),以及功能更强的 Redis Streams(XADD / XREADGROUP)。
- 想要“可靠消费”(处理失败或消费者挂掉还能保证消息不丢且可重试),常用的模式是把消息从“待处理队列”原子地移动到“正在处理队列”,处理完成再删除;或者用 Streams 的 consumer group + ack 机制。
- 悲观锁适合竞争严重、必须排他执行的场景;乐观锁适合冲突少、愿意重试的场景。
- Redis 中实现悲观锁常用 SET NX PX(并用唯一 token + Lua 删除);乐观锁常用 WATCH + MULTI/EXEC。
- 关于“顺序”:单个队列(List)能保证 FIFO 顺序,但在多消费者场景下全局严格顺序往往不能保证;Redis Streams 在单流上有顺序 ID,但分配给多个消费者时也可能不保证全局严格顺序,更多是“每个分片/消费者的顺序”。
二、Redis 队列机制(大白话 + 常见命令)
基础:Redis List(最简单的队列)
- 操作:RPUSH 把消息推到队尾,LPOP 从队头取,组合起来就是 FIFO。
- 生产者:
RPUSH myqueue msg1 - 消费者:
LPOP myqueue或阻塞BLPOP myqueue timeout
- 生产者:
- 阻塞版本(BRPOP/BLPOP):如果队列空,消费者可以阻塞等待新消息到来,适合实时消费。
- 优点:简单、速度快、延迟低。
- 缺点:单纯的 LPOP 会造成“消息可能丢失”的问题:消费端取出后挂掉,消息就丢了。
可靠消费模式(RPOPLPUSH / BRPOPLPUSH)
- 目标:避免消费者取出消息后挂掉导致消息丢失。
- 思路:把消息“原子地”从待处理队列移到处理队列(processing queue),处理成功后再从处理队列删除;失败或超时后把消息移回待处理队列或放到死信队列(DLQ)。
- 命令:
RPOPLPUSH source dest(非阻塞)BRPOPLPUSH source dest timeout(阻塞版本)
- 典型流程(伪代码):
BRPOPLPUSH pending processing timeout(阻塞等待并把消息移到 processing)- 执行任务
- 成功:从 processing 删除该消息(LREM)
- 如果在处理期间挂掉,需要有监控/定期扫描 processing 中超时的消息,重试或移到 DLQ
- 要点:
RPOPLPUSH是原子操作(移动是原子性的),所以不会出现消息被同时两个消费者拿到的情况。- 需要自己实现“处理超时检测”和“重试/死信”逻辑。
更现代的:Redis Streams(推荐用于复杂场景)
- Streams = 类似 Kafka 的消息流,天然支持持久化 ID、consumer group、ack、pending list(PEL)。
- 基本命令:
- 生产者:
XADD mystream * field value - 消费组读取:
XREADGROUP GROUP g1 c1 COUNT 10 BLOCK 2000 STREAMS mystream > - 消费确认:
XACK mystream g1 id - 获取挂起消息:
XPENDING mystream g1 - 重新分配/领取超时消息:
XCLAIM
- 生产者:
- 优点:
- 支持消费组(多个消费者共享消费,不会重复消费,管理方便)
- 有待确认队列(Pending Entries List),能检测和重处理“未确认”的消息
- 支持消息持久化、更复杂的消费控制(按 ID 拉取)
- 适用场景:
- 需要可靠性、消息重试、逐条确认、可查询历史等场景。
- 注意:
- Streams 的消息 ID 保持顺序(单流内),但分配给多个消费者时,全局严格顺序不可保证(见“顺序”部分)。
三、实现可靠队列的常见策略(大白话步骤)
- 简单模式(轻量、消息可丢)
- 生产:
RPUSH queue msg - 消费:
LPOP queue或BRPOP
- 生产:
- 确保不丢失(RPOPLPUSH 模式)
- 取:
BRPOPLPUSH pending processing timeout - 成功:
LREM processing 1 msg - 超时检查:定期扫描 processing,若某条消息处理超时,
RPOPLPUSH回到pending或放DLQ。
- 取:
- 用 Streams(更强)
- 生产:
XADD - 消费组读取:
XREADGROUP ... > - 成功:
XACK - 超时/未确认:
XPENDING查到超时消息,XCLAIM再分配并重试,超多次可放入死信 stream。
- 生产:
四、悲观锁 vs 乐观锁(大白话 + 何时用)
悲观锁(Pessimistic Lock)
- 思路:我在做某事之前先把资源锁住,其他人只能等我释放后才能操作。
- 适合场景:高冲突场景、必须严格串行化的操作(比如写同一块关键资源,不能并发)。
- 在 Redis 的实现(单机常见):
SET lock_key token NX PX 30000:如果成功,表示拿到锁;用唯一 token(比如 UUID)标识持有者。- 解锁必须校验 token:用 Lua 脚本 atomically 检查 token 再删除锁,避免误删别人锁。
- Redis 分布式锁(Redlock):多个 Redis 实例上做多数派判断,有争议但常用。注意 Redlock 适合大多数情况,但在极端网络分区场景有风险。
- 缺点:可能导致性能瓶颈(串行化)、死锁(需设置超时并谨慎释放)。
示例(拿锁):
- 拿锁:
SET mylock NX PX 10000 - 释放(Lua 保证原子):
EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 mylock
乐观锁(Optimistic Lock)
- 思路:我先读取数据,不加锁地计算,准备写入时再检查期间有没有别人改过;如果改过就失败并重试。
- 适合场景:冲突少,可以接受重试(比如库存扣减、计数器等)。
- Redis 实现:
WATCH key+MULTI/EXEC- WATCH 后检测 key,如果有别人改了,EXEC 会失败返回 nil,需要重试逻辑。
- 优点:并发性能好(大多数情况下无需阻塞)。
- 缺点:冲突多时会频繁重试,效率反而差。
示例(伪代码):
shell
1
2
3
4
5
6
7
1. WATCH balance
2. val = GET balance
3. 如果 val >= amount:
- MULTI
- SET balance val - amount
- EXEC
4. 如果 EXEC 返回 null(有人改了),回到步骤1重试
五、顺序(Order)——能保证什么,不能保证什么?
要区分两件事:
- 队列内部的顺序(单条链路上的顺序);
- 多消费者场景下的“全局顺序”。
要点:
- 单个 List 队列(RPUSH + LPOP)在单个 Redis 实例上是 FIFO 的:生产 A, B, C,消费者按拿到的顺序会取到 A,B,C(如果只有一个消费者)。
- 如果有多个消费者同时从队列取消息:
- 仍然是“最先被取出的消息先被处理”,但因为不同消费者的处理速度不同,最终完成处理的顺序可能和入队顺序不同(消费顺序 != 完成顺序)。
- 如果你需要“严格按入队顺序完成处理”,必须串行化处理(单消费者或加锁),但会牺牲吞吐量。
- Streams 的顺序:
- Stream 内每条消息有自增的 ID(形如 167771...-0),表示插入顺序,单流插入顺序有保证。
- 在 consumer group 下,消息被分配给不同消费者(取决于消费者读取行为),因此不同消费者处理完成的顺序不一定保持全局严格顺序。
- 如果需要保证“严格顺序消费并处理”,通常需要单消费者或依据业务做分区(sharding)并保证每个分区内部顺序。
- 使用分片/分区(scale-out)时:
- 常见做法是把消息按 key/hash 分到不同队列,每个队列单独保持顺序,这样既能并发又能保证每个 key 的顺序(类似 Kafka 的 partition)。
总结:Redis 能保证单队列的入队顺序,但多消费者/并发处理会破坏“全局完成顺序”。若业务要求严格顺序,必须设计成串行或按分片保证局部顺序。
六、实践建议(常见场景和推荐方案)
- 场景:简单任务、容忍丢失或重复、延迟敏感
- 用 List + BRPOP 即可。
- 场景:必须可靠、不丢消息、要重试、监控 pending
- 推荐 Redis Streams + Consumer Group。它内置了 PEL、ack、重试机制。
- 场景:需要简单可靠(没有 Streams 环境)
- 用 RPOPLPUSH/BRPOPLPUSH + processing queue + 超时重试 + DLQ。
- 场景:并发争抢少,愿意重试
- 用 WATCH(乐观锁),写逻辑简洁,性能好。
- 场景:要严格互斥(写共享资源)
- 用 SET NX PX + token + Lua(悲观锁),注意持久化和超时设置。
- 如果有分布式多实例部署,使用分布式锁时要理解 Redlock 的假设和限制,并确保用 token 校验释放锁。
七、常见坑和注意事项(务必记住)
- 不要用简单的
SET key value当解锁操作(可能会删掉别人的锁),必须用 token 校验。 - RPOPLPUSH 模式需要定期扫描 processing 列表,否则死掉的任务会永久卡住。
- BRPOPLPUSH 是阻塞的,消费者挂掉会导致 pending 消息卡在 processing,需要监控。
- Redis 单实例发生故障会丢失内存数据(如果没持久化),Streams 可以持久化到 AOF/RDB,但仍需做好备份/高可用。
- 乐观锁(WATCH)在高并发冲突场景下可能会导致活跃重试,从而降低吞吐量。
- 如果需要跨多个 Redis 实例的强一致性锁,Redlock 有争议,理解其校验条件再用。
八、小示例(伪代码,帮助理解)
- RPOPLPUSH 模式(可靠队列,伪流程)
- 消费者循环:
-
shell1 2 3 4 5 6 7
- msg = BRPOPLPUSH("pending", "processing", 5) - if msg: process(msg) if success: LREM("processing", 1, msg) else: // 失败处理:可能放回 pending 或者放 DLQ - 后台重试:
- 定期扫描 "processing" 中超时未被删除的消息,RPOPLPUSH 回到 pending 或者放 DLQ。
- Redis Streams(消费组)
- 生产:
XADD mystream * payload "..." - 消费(消费者 c1):
XREADGROUP GROUP g1 c1 COUNT 10 BLOCK 2000 STREAMS mystream >- for id,msg in results:
process(msg)
if success:XACK mystream g1 id
else: 不 ack(会留在 PEL)
- 处理超时:管理程序用
XPENDING找到超时未 ack 的 id,用XCLAIM重新分配并处理,重试多次可XDEL或转 DLQ。
- 悲观锁(拿锁+释放)
- 拿锁:
SET lock_key token NX PX 10000 - 解锁(Lua脚本):
- if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end
- 乐观锁(WATCH)
shell
1
2
3
4
5
6
- WATCH key
- v = GET key
- MULTI
- SET key new_v
- EXEC
- 如果 EXEC 返回空 -> 表示碰到并发改动 -> 重试
九、总结(一句话)
- 想要高吞吐:用无锁或乐观锁;想要可靠性和重试:用 RPOPLPUSH 模式或更推荐使用 Redis Streams;想要强排它性就用悲观锁(SET NX PX + token + Lua)。顺序能在单队列内保证,但并发处理或分布式时要设计分片或串行以满足严格顺序需求。





评论
登录后即可评论
分享你的想法,与作者互动
暂无评论