WEBKT

利用 Redis 原子指令实现 TCC Try 阶段的分布式锁:避免重试风暴的实战指南

42 0 0 0

在微服务架构中,TCC(Try-Confirm-Cancel)模式是解决分布式事务的常用方案。其中,Try 阶段往往需要锁定资源。如果 Try 阶段失败,业务方通常会通过定时任务或消息队列进行重试。如果大量请求同时失败并触发重试,且没有有效的流量控制,极易引发**“重试风暴”**,导致下游服务雪崩。

利用 Redis 的原子指令(如 SETNXEVAL)实现非阻塞的分布式锁,是缓解这一问题的有效手段。核心思路是:在 Try 阶段抢占锁,抢占失败则直接拒绝,避免无效的重试占用资源。

以下是具体的实现方案:

1. 核心原子指令解析

  • SETNX key value (Set if Not Exists)

    • 原理:仅当 key 不存在时,才设置 key 的值。
    • 作用:这是最基础的加锁原语。如果返回 1,表示获取锁成功;返回 0,表示锁已被占用。
    • 局限性:单纯的 SETNX 很难设置过期时间(TTL),如果持有锁的进程崩溃,锁将永久存在,导致死锁。
  • EVAL script numkeys key [key ...] arg [arg ...]

    • 原理:将 Lua 脚本发送到 Redis 服务器原子性执行。
    • 作用:Lua 脚本在 Redis 中是单线程执行的,保证了复合操作的原子性。我们可以将“判断锁是否存在 -> 设置锁 -> 设置过期时间”这几步合并在一个脚本中执行,避免竞态条件。

2. Try 阶段的非阻塞锁实现

为了避免重试风暴,Try 阶段的锁必须具备以下特性:

  1. 非阻塞:获取锁失败立即返回,不等待。
  2. 防死锁:必须设置过期时间(TTL)。
  3. 幂等性:锁的值(Value)应具有唯一性(通常使用 UUID + 线程 ID),防止误删其他线程的锁。

方案 A:Redis 2.6.12+ 版本的 SET 扩展命令(推荐)

这是目前最简洁、最推荐的方式。Redis 2.6.12 之后,SET 命令增加了 NX(不存在才设置)和 EX(过期时间)参数,实现了原子的“加锁+设置过期时间”操作。

Lua 脚本封装(EVAL):

-- KEYS[1]: 锁的 Key
-- ARGV[1]: 锁的唯一标识 Value (防止误删)
-- ARGV[2]: 过期时间(秒)

local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]

-- 原子性设置:仅当 key 不存在时设置,并设置过期时间
local result = redis.call('SET', key, value, 'NX', 'EX', expire)

if result == 'OK' then
    return 1 -- 获取锁成功
else
    return 0 -- 获取锁失败(已被占用)
end

业务逻辑(Try 阶段):

  1. 生成全局唯一的 RequestID(例如 UUID)。
  2. 执行上述 Lua 脚本。
  3. 如果返回 0:说明资源被锁定,Try 阶段直接失败。此时不要立即重试,而是将任务投递到延迟队列(如 RabbitMQ 的 Delay 插件或 Redis 的 ZSet),等待一段时间后再次进入 Try 阶段。这样可以有效分散流量,避免重试风暴。
  4. 如果返回 1:获取锁成功,继续执行 Try 逻辑(冻结库存、创建预订单等)。

方案 B:针对旧版本 Redis 的 SETNX + EXPIRE(存在风险)

如果环境受限必须使用旧命令,需要通过 Lua 脚本保证原子性,因为 SETNXEXPIRE 分开调用在极端情况下可能失败(如设置完 SETNX 后进程挂掉,没来得及设置过期时间)。

Lua 脚本:

local lock_key = KEYS[1]
local lock_value = ARGV[1]
local expire_time = ARGV[2]

-- 1. 尝试获取锁
if redis.call('SETNX', lock_key, lock_value) == 1 then
    -- 2. 获取成功,立即设置过期时间
    redis.call('EXPIRE', lock_key, expire_time)
    return 1
else
    return 0
end

3. 锁的释放(Confirm/Cancel 阶段)

释放锁时,必须保证只有锁的持有者才能释放自己的锁

释放逻辑(Lua 脚本):

-- KEYS[1]: 锁 Key
-- ARGV[1]: 锁的唯一标识 Value

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

注意:不要使用 DEL 直接删除,否则可能删除掉刚刚过期、被其他线程重新获取的锁。

4. 总结与避坑指南

  1. 锁粒度:Try 阶段的锁粒度要尽可能细,只锁住冲突的资源(如 lock:stock:skuId),避免全局锁导致吞吐量下降。
  2. 超时时间:TCC 的 Try 阶段通常比较耗时(涉及 RPC 调用),锁的过期时间(TTL)一定要大于业务执行的平均耗时,否则锁提前释放,导致数据不一致。建议设置为业务平均耗时的 3-5 倍。
  3. 重试策略:既然使用了锁来防止重试风暴,那么在 Try 失败(获取锁失败)时,必须配合指数退避(Exponential Backoff)算法进行重试,或者直接走 Cancel 流程释放预留资源。
  4. 监控:监控 Redis 中锁的 Key 数量,如果发现大量 Key 积压,说明并发压力过大或 TTL 设置过短,需要及时调整。

通过 Redis 原子指令构建的分布式锁,为 TCC 的 Try 阶段增加了一道“闸门”,将并发冲突转化为有序的排队或延迟处理,是保障微服务高可用的重要一环。

码农架构师 TCC分布式事务Redis分布式锁高并发架构

评论点评