利用 Redis 原子指令实现 TCC Try 阶段的分布式锁:避免重试风暴的实战指南
42
0
0
0
在微服务架构中,TCC(Try-Confirm-Cancel)模式是解决分布式事务的常用方案。其中,Try 阶段往往需要锁定资源。如果 Try 阶段失败,业务方通常会通过定时任务或消息队列进行重试。如果大量请求同时失败并触发重试,且没有有效的流量控制,极易引发**“重试风暴”**,导致下游服务雪崩。
利用 Redis 的原子指令(如 SETNX、EVAL)实现非阻塞的分布式锁,是缓解这一问题的有效手段。核心思路是:在 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 阶段的锁必须具备以下特性:
- 非阻塞:获取锁失败立即返回,不等待。
- 防死锁:必须设置过期时间(TTL)。
- 幂等性:锁的值(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 阶段):
- 生成全局唯一的 RequestID(例如 UUID)。
- 执行上述 Lua 脚本。
- 如果返回 0:说明资源被锁定,Try 阶段直接失败。此时不要立即重试,而是将任务投递到延迟队列(如 RabbitMQ 的 Delay 插件或 Redis 的 ZSet),等待一段时间后再次进入 Try 阶段。这样可以有效分散流量,避免重试风暴。
- 如果返回 1:获取锁成功,继续执行 Try 逻辑(冻结库存、创建预订单等)。
方案 B:针对旧版本 Redis 的 SETNX + EXPIRE(存在风险)
如果环境受限必须使用旧命令,需要通过 Lua 脚本保证原子性,因为 SETNX 和 EXPIRE 分开调用在极端情况下可能失败(如设置完 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. 总结与避坑指南
- 锁粒度:Try 阶段的锁粒度要尽可能细,只锁住冲突的资源(如
lock:stock:skuId),避免全局锁导致吞吐量下降。 - 超时时间:TCC 的 Try 阶段通常比较耗时(涉及 RPC 调用),锁的过期时间(TTL)一定要大于业务执行的平均耗时,否则锁提前释放,导致数据不一致。建议设置为业务平均耗时的 3-5 倍。
- 重试策略:既然使用了锁来防止重试风暴,那么在 Try 失败(获取锁失败)时,必须配合指数退避(Exponential Backoff)算法进行重试,或者直接走 Cancel 流程释放预留资源。
- 监控:监控 Redis 中锁的 Key 数量,如果发现大量 Key 积压,说明并发压力过大或 TTL 设置过短,需要及时调整。
通过 Redis 原子指令构建的分布式锁,为 TCC 的 Try 阶段增加了一道“闸门”,将并发冲突转化为有序的排队或延迟处理,是保障微服务高可用的重要一环。