高并发下的分布式事务状态机设计:基于Redis的补偿机制实战
前言:别把Redis当数据库用,要当“状态机引擎”
在高并发场景下,聊分布式事务如果还在扯两阶段提交(2PC),那基本没法落地。性能扛不住。既然用户指定了Redis,说明追求的是极致的吞吐量。Redis确实不适合直接存业务数据,但它极其适合做状态机的协调器。
我们要做的是基于 Saga 模式 的状态机,利用 Redis 的原子性指令和 Lua 脚本,把“事务状态”和“补偿逻辑”管起来。
1. 核心设计:状态机模型 (State Machine)
不要用简单的 Key-Value。我们需要定义一个状态流转模型。
状态定义:
INIT: 事务开始EXECUTING: 执行中COMPLETED: 成功FAILED: 失败(触发补偿)COMPENSATING: 补偿中ROLLBACKED: 已回滚
数据结构选择:
建议使用 Hash 结构存储事务上下文,配合 List/ZSet 做任务队列。
// 事务根信息 (Key: tx:root:{tx_id})
{
"status": "EXECUTING",
"current_step": 2,
"total_steps": 5,
"created_at": 1698888888
}
// 步骤信息 (Key: tx:step:{tx_id}:{step_id})
{
"action": "deduct_stock", // 正向操作
"compensation": "refund_stock", // 逆向补偿
"status": "SUCCESS",
"retry_count": 0
}
2. 解决痛点:Redis 持久化缺陷的补偿机制
Redis 的持久化(RDB/AOF)在极端情况下(如断电)可能会丢数据,或者只丢一部分。如果 Redis 挂了,内存里的状态没了,怎么办?
这里必须引入 “外部补偿机制” 来兜底。
方案 A:基于消息队列的“事务日志” (可靠性落地方案)
这是最稳妥的。Redis 只是执行层,不是信任层。
- 前置写盘:在操作 Redis 状态机之前,先将事务信息(TxID、参数、步骤)以 Half Message (半消息) 的形式发送到 Kafka/RocketMQ。
- 状态机执行:消费者读取消息,操作 Redis 状态机。
- 确认提交:Redis 操作成功后,提交 MQ 消息。
- 补偿触发:如果 Redis 步骤失败,或者 MQ 回调超时,由独立的 定时任务扫描器 扫描 MQ 的 Half Topic,发现未提交的,则根据 TxID 在 Redis 里查询当前状态。
方案 B:定时任务扫描 (Redis 内部自洽方案)
如果必须完全依赖 Redis(不引入 MQ),你需要一个独立的 Scheduler (调度器)。
补偿机制核心逻辑:
- Redis Key 过期时间 (TTL) 保护:
每个事务根 Key (tx:root:{tx_id}) 都必须设置 TTL(例如 1 小时)。如果事务正常结束,删除 Key。如果超时未删,Redis 自动清理。 - 定时扫描补偿 (Scavenger):
编写一个独立的 Go/Java 服务,定期(如每分钟)执行 Lua 脚本扫描特定的 ZSet 队列(存储“执行中”但超过阈值时间的事务)。- 发现
status = FAILED且compensation_status = PENDING-> 触发补偿逻辑。 - 发现
status = EXECUTING且age > 5s-> 标记为疑似死信,触发补偿。
- 发现
3. 防止“坑”:高并发下的三大顽疾
在 Redis 高并发写入下,状态机极易出错,必须用 Lua 脚本保证原子性。
3.1 空回滚 (Null Compensation)
场景:分支事务 A 还没执行正向操作(INIT 状态都没入参),就收到了补偿指令。
Redis Lua 解法:
在执行补偿脚本前,必须检查状态。如果 Key 不存在或状态是 INIT,直接标记为 ROLLBACKED,不做任何业务操作。
3.2 幂等性 (Idempotency)
场景:网络抖动导致重试,或者补偿逻辑触发多次。
Redis Lua 解法:
利用 Lua 脚本的 GET + SET NX 逻辑。
-- 伪代码
local status = redis.call('GET', KEYS[1])
if status == 'COMPLETED' then
return 1 -- 已经成功了,直接返回,不做重复操作
end
-- 执行业务逻辑...
3.3 防悬挂 (Anti-Suspension)
场景:RPC 超时,客户端认为失败了触发补偿,但服务端其实执行成功了,只是响应没回来。结果先执行补偿(回滚),后执行正向操作(扣款),导致数据不一致。
Redis Lua 解法:
这是最难的。在正向操作执行时,必须先检查是否已经收到了“补偿指令”。
设计原则:补偿操作要“逆序”执行,且正向操作必须具备“检查补偿状态”的能力。
如果 Redis 中该事务的 compensation_flag 被置为 1,则正向操作直接拒绝。
4. 实战代码:Redis Lua 脚本示例
这是整个设计的核心:原子性保证。
脚本 1:执行正向操作 (带防悬挂检查)
-- KEYS[1]: tx:step:{tx_id}:{step_id}
-- ARGV[1]: status (SUCCESS/FAIL)
-- ARGV[2]: compensation_flag (0/1)
-- 1. 检查是否已经触发了补偿 (防悬挂)
local current_flag = redis.call('HGET', KEYS[1], 'compensation_flag')
if current_flag == '1' then
return {-1, "SUSPENSION_DETECTED"} -- 拒绝执行,防止悬挂
end
-- 2. 更新状态
redis.call('HSET', KEYS[1], 'status', ARGV[1], 'update_time', ARGV[3])
-- 3. 如果失败,标记需要补偿
if ARGV[1] == 'FAIL' then
redis.call('HSET', KEYS[1], 'compensation_flag', '1')
-- 推送到补偿队列 (List)
redis.call('RPUSH', 'tx:compensation:queue', ARGV[2])
end
return {0, "OK"}
脚本 2:执行补偿操作 (带空回滚检查)
-- KEYS[1]: tx:step:{tx_id}:{step_id}
-- ARGV[1]: compensation_action_name
-- 1. 获取当前状态
local status = redis.call('HGET', KEYS[1], 'status')
-- 2. 空回滚处理:如果Key不存在,或者状态是INIT,直接标记为已回滚
if status == nil or status == 'INIT' then
redis.call('HSET', KEYS[1], 'status', 'ROLLBACKED', 'note', 'NULL_COMPENSATION')
return {0, "NULL_ROLLBACK"}
end
-- 3. 幂等性检查:如果已经是 ROLLBACKED,直接返回
if status == 'ROLLBACKED' then
return {0, "ALREADY_ROLLBACKED"}
end
-- 4. 执行补偿逻辑 (这里通常会触发调用下游的补偿接口)
-- 实际业务中,这里可能只是修改状态,由外部 Worker 读取状态去调用接口
redis.call('HSET', KEYS[1], 'status', 'COMPENSATING')
-- 模拟调用下游补偿...
-- ... (业务代码)
redis.call('HSET', KEYS[1], 'status', 'ROLLBACKED')
return {0, "COMPENSATION_DONE"}
5. 总结与建议
在高并发下设计这套系统,不要迷信 Redis 的数据落地。
- 黄金法则:Redis 只是缓存和状态协调者,MQ 才是数据一致性的基石。
- 监控:必须建立完善的监控大盘,监控
tx:compensation:queue的积压情况。 - 降级:如果 Redis 挂了,要有降级方案。比如直接走 TCC 模式,或者暂时冻结事务,人工介入。
这套基于 Redis Lua 的状态机,能抗住万级 QPS,但请务必在业务层做好幂等和防悬挂的单元测试,这比技术选型更重要。