Redis 分布式
Redis 分布式
1. 消息队列
Redis 可以承担部分消息传递场景,但选择数据结构之后,还需要建立消费确认、失败恢复和幂等协议。把消息放进去、取出来,不等于已经可靠完成业务。有序性、重复处理和可靠性仍是关键问题,不能仅因为使用List就认定三者都自动满足。
本篇使用 Redis 7.0 及以上已有能力,所有流程及结果是教学推演,未连接服务实测。命令面向独立练习环境;教学键或消费组已存在时请换前缀,不覆盖、删除或清空原数据。
先按需要区分三种消息传递方式
| 方式 | 怎样传递 | 需要注意什么 |
|---|---|---|
| List | 生产者入队,消费者阻塞弹出;例如LPUSH配合BRPOP | 弹出后业务尚未完成,消费者此时崩溃可能失去任务;要额外设计处理中队列、确认与回收 |
| Pub/Sub | 发布给当前订阅者 | 没有可回查的消费积压和确认记录,离线订阅者可能错过消息,不适合直接当可靠任务队列 |
| Streams | 追加消息,消费组分配给消费者,并记录待确认消息 | 支持构建确认、接管和重试流程,但业务幂等、存储持久性与保留策略仍要设计 |
List的“待处理→处理中”可以用LMOVE/BLMOVE原子移动,而不是先弹出后再另发命令登记。旧材料常用RPOPLPUSH表达这类思路,但移动并不等于自动确认:仍要设计处理中超时、重新投递和任务唯一标识。Cluster中涉及两个队列键还需要同槽,例如使用相同hash tag。
单个队列的数据顺序也不等于业务完成顺序;消费者并行、失败重试和任务耗时不同都会影响后者。
跟踪一条消息的交付与确认
假设消息表示“处理订单O42”,教学消息ID指定为1000-0。以下命令只展示操作,没有实际执行。必须使用新建、空的教学Stream,才能使用这个指定ID;生产应用通常使用星号生成ID。
redis-cli XGROUP CREATE 'demo:repair:stream:{orders}' workers 0 MKSTREAM
redis-cli XADD 'demo:repair:stream:{orders}' 1000-0 business_id O42 action process_order
redis-cli XREADGROUP GROUP workers C1 COUNT 1 STREAMS 'demo:repair:stream:{orders}' '>'
XADD保存消息;XREADGROUP中的“>”请求本组尚未交付的新消息。C1拿到消息后,消费组的待确认列表PEL记录其ID和相关交付状态。PEL不是第二份业务订单数据,也不是业务提交记录。
C1完成业务之后才确认:
redis-cli XACK 'demo:repair:stream:{orders}' workers 1000-0
确认会将该ID从本消费组PEL移除,不会因为XACK而删除Stream中的消息。消费确认与消息保留是两套职责。XREADGROUP和XACK官方说明
确认前崩溃,为什么还需要业务幂等
最容易混淆的情况,是C1已经提交业务效果,却在发送XACK之前崩溃。此时业务已完成,PEL仍显示消息未确认,系统不能只凭PEL判断“业务一定没做过”。
其他消费者不会仅因为C1崩溃就自动处理全部待确认消息。恢复程序应检查积压,并在满足接管策略时取回任务。例如假设该消息已空闲至少30秒,C2可以使用:
redis-cli XPENDING 'demo:repair:stream:{orders}' workers
redis-cli XAUTOCLAIM 'demo:repair:stream:{orders}' workers C2 30000 0-0 COUNT 1
XAUTOCLAIM返回扫描游标和接管结果;生产实现应继续扫描,而不是把一次COUNT 1当成清理全部积压。30秒只是教学阈值,过短可能接管仍在正常工作的消费者任务,因此接管本身也不能替代幂等。XAUTOCLAIM
业务处理应把幂等标识的唯一约束与业务效果放在同一个可靠事务中。下面是伪代码,假设业务存储提供唯一约束和事务;不是Redis自动替跨数据库实现事务。
process(message):
begin business transaction
try insert processed_events(business_id = "O42") with unique constraint
if a duplicate-key result is returned:
rollback this attempt
confirm the previously committed outcome
XACK message
return
apply the business change
commit business transaction
XACK message
并发时由唯一约束裁决,而不是无保护地“先查再写”。提交结果不确定时应先查明事务结果,不能因为超时就确认成功或重复执行业务。若业务效果是外部付款、发邮件等不能加入该事务的操作,还要使用下游幂等键、事务消息或其他可靠协议。
本例C2发现O42已有已提交记录,跳过重复业务效果后确认,因此消息可以交付两次,业务只产生一次效果;这不意味着Streams对任意业务无条件提供恰好一次。

将消息交付、业务提交、崩溃接管和确认分开,理解为何待确认状态不等于未执行业务。
消息可靠性还取决于Redis持久化、复制和故障恢复;未充分持久化的消息可能丢失。截断或删除消息也可能让PEL仍有ID却拿不到原内容,需要协调保留时间与消费进度,并处理重试次数、失败任务和告警。严格顺序可考虑同一业务对象串行处理,但不能把消费组并行处理说成全部按序完成。
面试回答
Redis可用List、Pub/Sub或Streams传递消息。List的阻塞弹出简单,但弹出后崩溃需要处理中队列和回收协议;Pub/Sub面向当前订阅者,不提供可靠积压确认;Streams消费组用PEL记录待确认消息,可配合XACK和接管恢复任务。业务提交与确认不是同一步,确认前崩溃可能重复交付,因此需要业务幂等。还应设计消息保留、失败重试、顺序及持久化边界,不能仅靠消息ID就保证可靠或恰好一次。
2. 说一下 Redis 的并发访问
Redis的并发问题,通常是多个客户端共同修改同一业务状态。可采用合适的原子命令、短Lua脚本、乐观并发协议,必要时使用锁。单条命令原子,并不代表由多条命令组成的业务流程也原子。
为什么读、改、写会丢失一次修改
假设库存为10,A、B各扣1。若每个客户端都执行“GET→本地减1→SET”,允许出现以下交错:
| 顺序 | A | B | Redis中的库存 |
|---|---|---|---|
| 1 | 读到10 | 10 | |
| 2 | 读到10 | 10 | |
| 3 | 写入9 | 9 | |
| 4 | 写入9 | 9 |
两次请求都以为扣减成功,最终却只少了1。这是丢失更新;不要把这个算例直接叫作已经发生超卖。
若业务只是无条件减1,单条DECR可避免上述交错,两次命令使库存10→9→8。不过库存不能小于0时,还需要把“检查足够”和“扣减”放在同一个受保护操作里,不能先GET判断、再单独DECR。
条件判断与扣减放进短Lua脚本
以下完整脚本可另存为 demo_decrease_stock.lua。教学库存键是非负整数字符串,数量为正整数;为便于初学者理解,示例将两者限制在十亿以内。错误参数、无库存键或不合法状态在写入前报错;库存不足返回0和原库存,不写入负数。
-- KEYS[1]: isolated stock key; ARGV[1]: requested quantity.
if #KEYS ~= 1 or #ARGV ~= 1 then
return redis.error_reply("Expected one key and one argument")
end
local function bounded_integer(value, minimum)
if not value or not string.match(value, "^%d+$") then
return nil
end
if #value > 1 and string.sub(value, 1, 1) == "0" then
return nil
end
local number = tonumber(value)
if not number or number < minimum or number > 1000000000 then
return nil
end
return number
end
local quantity = bounded_integer(ARGV[1], 1)
if not quantity then
return redis.error_reply("Quantity must be a positive bounded integer")
end
local raw_stock = redis.call("GET", KEYS[1])
local stock = bounded_integer(raw_stock, 0)
if not stock then
return redis.error_reply("Stock is missing or invalid")
end
if stock < quantity then
return {0, stock}
end
local remaining = redis.call("DECRBY", KEYS[1], quantity)
return {1, remaining}
独立练习环境中的调用形式如下,未实际执行。SET只在新教学键上初始化,不用它重置现有业务状态;库存1的实验使用另一个独立键,不能混用上一实验结果。
redis-cli SET 'demo:repair:stock:{p1}:ten' 10 NX
redis-cli --eval demo_decrease_stock.lua 'demo:repair:stock:{p1}:ten' , 1
redis-cli --eval demo_decrease_stock.lua 'demo:repair:stock:{p1}:ten' , 1
初始10时,两次成功返回剩余9、8。另设初始1的键,两次扣1只能一个成功、一个不足,最终0;谁先成功取决于处理顺序。键不存在时也不能默认库存为0后悄悄写入。Redis Lua执行语义

对照错误交错与原子条件扣减,区分单条命令、状态条件和完整业务操作。
不同工具保护的范围不同
| 工具 | 能解决什么 | 不能直接推断什么 |
|---|---|---|
| INCR/DECR等单命令 | 特定对象的原子修改 | 多命令判断与修改自动原子 |
| Pipeline | 减少交互等待,提高特定负载吞吐 | 批量命令不能被其他客户端插入 |
| MULTI/EXEC | EXEC中排队命令连续执行 | 无条件支持“读结果后分支”,或运行时错误自动回滚 |
| WATCH+事务 | 监视键变化,EXEC发现冲突时放弃,再由客户端重试 | 冲突下总能一次成功;重试没有成本 |
| Lua | 在服务端短脚本内完成条件和修改 | 跨数据库事务、持久性或业务幂等自动成立 |
| 分布式锁 | 协调遵守协议的参与者执行临界操作 | 锁租约失效后旧客户端绝不会继续运行 |
MULTI中的GET结果通常要到EXEC后才取得,不能像普通程序那样在排队过程中拿它决定下一条命令。WATCH的重试应有次数、退避和失败策略。事务和脚本都不能被解释成关系型数据库式的自动撤销所有已执行写入。Redis事务文档
最后,原子扣减只保护Redis里的这个状态。订单写库失败如何补偿、客户端超时重试是否会再次扣减、复制故障切换是否丢失修改,都需要业务协议。Lua在Cluster中访问的键要通过KEYS声明,多键操作还需满足同槽要求。
面试回答
Redis单条命令可以原子修改状态,但客户端的读、改、写多步骤仍可能交错,例如两个请求读到库存10后都写9,丢失一次扣减。简单计数可用原子命令,库存条件可用短Lua把检查和扣减合并;也可用WATCH加事务处理乐观冲突。Pipeline主要减少网络交互,不提供同等原子保护。还要区分原子性、回滚、持久性和幂等,跨数据库效果及超时重试不能仅靠单次脚本保证。
3. 说一下 Redis 实现分布式锁
Redis分布式锁常用共享键表示某个资源当前的持有者,让不同应用实例协调临界操作。实际更准确地说,它通常是带有效期的租约:成功取得一段时间的使用资格,不等于程序永远独占资源。
应先看业务是否真需要锁:能用数据库唯一约束、条件更新或Redis短Lua解决的情况,不必自动增加一个跨网络锁协议。锁还会增加等待、超时及故障处理成本。
加锁、解锁与续期都要检查身份
用一条SET同时完成“键不存在才设置”和“设置有效期”:
redis-cli SET 'demo:repair:lock:{p1}' tokenA NX PX 5000
这只是教学标识。生产中每次获取尝试都应生成足够唯一的持有标识,例如安全随机值,不能把固定线程ID反复复用。返回OK代表这次取得;失败不能继续按已持锁执行;超时则结果不确定,需要按协议查明或放弃,不能自行假定成功。
不能先SETNX再另发EXPIRE,因为两条命令之间崩溃可能留下没有有效期的锁。解锁也不能直接DEL,而要比较当前值是否仍属于本次尝试,并在同一个脚本内删除。以下脚本可另存为 demo_unlock.lua:
-- Delete only a lock owned by this acquisition attempt.
if #KEYS ~= 1 or #ARGV ~= 1 or ARGV[1] == "" then
return redis.error_reply("Expected one key and a non-empty owner token")
end
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
redis-cli --eval demo_unlock.lua 'demo:repair:lock:{p1}' , tokenA
返回0表示此时没有删掉属于该标识的锁,可能已经到期或换了持有者,不能把它解释成“无条件解锁成功”。Redis SET及锁示例
业务可能超过租约时间时,可在仍确认持有且尚未到期的前提下,提前按相同标识续期。脚本同时检查标识和剩余有效期;没有过期时间或已没有正的剩余毫秒时,本例拒绝续期。以下是 demo_renew_lock.lua:
-- Renew only an unexpired lock owned by the same attempt.
if #KEYS ~= 1 or #ARGV ~= 2 or ARGV[1] == "" then
return redis.error_reply("Expected one key, owner token and TTL")
end
if not string.match(ARGV[2], "^%d+$") then
return redis.error_reply("TTL must be a positive bounded integer")
end
local ttl_ms = tonumber(ARGV[2])
if not ttl_ms or ttl_ms < 1 or ttl_ms > 604800000 then
return redis.error_reply("Invalid TTL")
end
if redis.call("GET", KEYS[1]) == ARGV[1]
and redis.call("PTTL", KEYS[1]) > 0 then
return redis.call("PEXPIRE", KEYS[1], ttl_ms)
end
return 0
redis-cli --eval demo_renew_lock.lua 'demo:repair:lock:{p1}' , tokenA 5000
无法确认续期成功时,客户端不能继续把资格当作有效;但“停止本客户端发新请求”也不一定能撤销已在网络中或下游处理中的请求。续期不是发生暂停、分区时的无条件安全证明。
锁过期不会自动停止旧客户端
假设A在0秒取得5秒租约,1~7秒暂停;5秒租约失效,B在5.1秒取得新锁;7秒A恢复。A用tokenA解锁时发现当前为tokenB,因此比较删除脚本不会误删B的锁。
这解决的是误删新锁,不是全部问题:A恢复后仍可能把暂停前准备好的旧业务写入发给下游。Redis不会因为键过期就杀掉A,也不会自动撤回A的外部请求。

对照锁值变化与比较删除,理解唯一持有标识保护的范围。
fencing要求下游共同执行规则
对旧客户端写入风险,一种办法是由可靠协调机制签发单调递增的fencing token,并让下游存储原子地检查凭证和执行写入。例如初始库存10、最大已接受凭证40:
- A用凭证41写入9,存储同时记录最大凭证41。
- A暂停且旧租约失效;B取得42,写入8并把最大凭证改为42。
- A恢复,用旧41试图写回9;存储检查41小于42,拒绝,库存保持8。
凭证比较不能只在客户端提前做一次,也不能将“查询最大凭证”和“写业务数据”拆成无保护的两个步骤。下游不支持并落实校验时,仅在请求中携带一个数字没有防护效果。
随机锁标识解决“是不是同一次持有”,fencing token解决“是否已经落后于更新的持有者”,二者不同。UUID不是单调凭证;会因异步复制故障回退的Redis计数也不能未经分析就充当可靠签发机制。fencing本身不代替重复业务请求的幂等。Redis分布式锁文档中的一致性提醒

将下游凭证校验和数据写入放在同一保护范围,展示旧任务为什么不能覆盖更新结果。
单节点故障切换和Redlock要分别分析
单主节点上的原子加锁,并不自动保证异步复制切换后的互斥:A成功取得锁后,锁尚未复制主节点就故障,新主可能没有该锁,从而让B也取得资格。因此,可靠性不能只描述成“加了过期时间就安全”。
Redlock讨论的是多个独立实例,不是一个主节点及其副本。例如五个实例要求至少三个取得同一标识,并且获取花费的时间小于租约;实际可用时间还要扣掉获取耗时和时钟漂移余量。未获得有效多数时,需要尽力释放部分获取的锁并按退避策略重试。
多节点方案增加交互和故障状态,安全性仍依赖时钟、暂停、重启、租约以及业务资源的保护模型,不能用“多数成功”承诺任何场景都绝对安全。涉及关键业务,应分析一致性需求及下游约束,而不是把锁当作事务或强一致存储的替代物。Redlock算法及前提
面试回答
Redis租约锁可用SET NX PX原子地竞争共享键,值保存每次尝试的唯一标识;解锁和续期用Lua比较标识后执行,避免误操作其他持有者的锁。有效期防止崩溃后长期占用,但到期不会停止旧任务,因此还要分析暂停、网络和下游旧写入,必要时使用可靠fencing机制。异步复制切换可能丢失锁;Redlock要求独立实例的有效多数及时间前提,不能无条件保证安全,也不能替代业务幂等和事务。
阅读导航
上一章:Redis 持久化
下一章:Redis 常见面试题




