WEBKT

高并发下的分布式事务状态机设计:基于Redis的补偿机制实战

35 0 0 0

前言:别把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 只是执行层,不是信任层。

  1. 前置写盘:在操作 Redis 状态机之前,先将事务信息(TxID、参数、步骤)以 Half Message (半消息) 的形式发送到 Kafka/RocketMQ。
  2. 状态机执行:消费者读取消息,操作 Redis 状态机。
  3. 确认提交:Redis 操作成功后,提交 MQ 消息。
  4. 补偿触发:如果 Redis 步骤失败,或者 MQ 回调超时,由独立的 定时任务扫描器 扫描 MQ 的 Half Topic,发现未提交的,则根据 TxID 在 Redis 里查询当前状态。

方案 B:定时任务扫描 (Redis 内部自洽方案)

如果必须完全依赖 Redis(不引入 MQ),你需要一个独立的 Scheduler (调度器)

补偿机制核心逻辑:

  1. Redis Key 过期时间 (TTL) 保护
    每个事务根 Key (tx:root:{tx_id}) 都必须设置 TTL(例如 1 小时)。如果事务正常结束,删除 Key。如果超时未删,Redis 自动清理。
  2. 定时扫描补偿 (Scavenger)
    编写一个独立的 Go/Java 服务,定期(如每分钟)执行 Lua 脚本扫描特定的 ZSet 队列(存储“执行中”但超过阈值时间的事务)。
    • 发现 status = FAILEDcompensation_status = PENDING -> 触发补偿逻辑。
    • 发现 status = EXECUTINGage > 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 的数据落地

  1. 黄金法则:Redis 只是缓存和状态协调者,MQ 才是数据一致性的基石
  2. 监控:必须建立完善的监控大盘,监控 tx:compensation:queue 的积压情况。
  3. 降级:如果 Redis 挂了,要有降级方案。比如直接走 TCC 模式,或者暂时冻结事务,人工介入。

这套基于 Redis Lua 的状态机,能抗住万级 QPS,但请务必在业务层做好幂等和防悬挂的单元测试,这比技术选型更重要。

架构师老王 分布式事务Redis状态机Saga模式

评论点评