引言
分布式系统中的并发控制,始终是开发者绕不开的经典课题。Redis 作为高性能键值存储的代表,凭借极快速度与丰富的功能,被广泛集成到各类业务场景中。其中 SETNX(SET if Not eXists)命令常被用于实现分布式锁,但它的用法远没有表面上那么简单。最近,我花了整整三天才彻底解决一个棘手的并发问题——从表面现象深入到底层原理,再到最终落地方案,整个排查过程收获颇丰。下面将这次案例详细拆解,希望能帮你避开同样的陷阱。

背景:分布式锁的需求从何而来
我们的系统中有一个关键业务逻辑,必须在分布式环境下保证原子性:例如用户余额扣减,同一时间只能由一个请求执行,否则数据会出现不一致。要实现这种互斥访问,使用 Redis 的 SETNX 来构建分布式锁,往往是许多团队的第一选择。
SETNX 的原理简洁明了:只有当键不存在时,才会设置成功并返回 1(表示当前请求获取到锁);如果键已存在,则返回 0(表示锁已被其他请求占用)。这种特性天然适用于“谁先到达谁持有”的互斥场景。
初版实现与暴露的问题
最初的实现代码非常直接:
local lock_key = "balance_lock:" .. user_id
local lock_value = "locked"
local lock_expire = 10 -- 锁的过期时间,单位:秒
local acquired = redis.call("SETNX", lock_key, lock_value)
if acquired == 1 then
redis.call("EXPIRE", lock_key, lock_expire)
return true
else
return false
end
这段代码逻辑看起来没有毛病:尝试获取锁→成功后设置过期时间→失败则直接返回。可一上线,两个严重问题立刻暴露出来:
- 锁无法释放:部分场景下,锁就像被焊死了一样,后续请求永远拿不到锁。
- 并发竞争:在高并发情况下,多个请求竟然同时获取到了锁,导致业务逻辑被重复执行,数据瞬间崩溃。
问题分析:深入排查根因
1. 锁无法释放的真正原因
锁释放不了,无非两种情形:
- 业务逻辑执行时间超过了锁的过期时间——锁自动失效,但业务仍在运行。
- 业务逻辑抛出异常,压根没有执行到释放锁的代码行。
我们遇到的主要是第一种。当时锁过期时间只设了10秒,可某些业务逻辑偏偏需要15秒。第10秒锁一释放,另一个请求立刻抢到锁,两个请求同时操作同一笔余额,后果可想而知。
2. 并发竞争的细节分析
再仔细看那段代码,SETNX 和 EXPIRE 是两步独立操作——并非原子执行。想象一个场景:SETNX 刚成功,还没来得及执行 EXPIRE,Redis 实例就挂了或者网络断了。锁永远没有过期时间,变成了“僵尸锁”。虽然概率很低,但在高并发生产环境中,墨菲定律总会应验。
即便过期时间设置正确,锁依赖自动过期来释放也可能引发“误认”。举个例子:
- 请求A拿到锁,过期时间10秒。
- 请求A执行了15秒,第10秒锁自动释放。
- 第11秒,请求B拿到锁,也开始执行业务。
- 此时A和B同时在运行业务逻辑,并发问题重现。
解决方案的探索过程
方案1:使用Lua脚本保证原子性
既然问题出在“两步非原子”,那就把它们合并为一个原子操作。用 Lua 脚本将 SETNX 和 EXPIRE 封装到一起:
local lock_key = "balance_lock:" .. user_id
local lock_value = "locked"
local lock_expire = 10 -- 锁的过期时间,单位:秒
local acquired = redis.call("SET", lock_key, lock_value, "NX", "EX", lock_expire)
if acquired then
return true
else
return false
end
Redis 的 SET 命令直接支持 NX(等价于 SETNX)和 EX(设置过期时间),一步到位。这解决了“锁没有过期时间”的问题,但业务逻辑执行时间超过预设时间、导致锁提前失效的问题依然存在。
方案2:动态延长锁的过期时间
于是考虑为锁增加“续命”机制——看门狗。它定期检查锁是否仍被当前线程持有,如果业务还在运行,就自动将过期时间向后延长。伪代码大致如下:
-- 获取锁
local function acquire_lock(user_id)
local lock_key = "balance_lock:" .. user_id
local lock_value = generate_unique_id() -- 生成唯一ID
local lock_expire = 10 -- 初始过期时间
local acquired = redis.call("SET", lock_key, lock_value, "NX", "EX", lock_expire)
if acquired then
-- 启动看门狗线程,定期延长锁的过期时间
start_watchdog(lock_key, lock_value, lock_expire)
return true
else
return false
end
end
-- 释放锁
local function release_lock(user_id)
local lock_key = "balance_lock:" .. user_id
local lock_value = get_thread_local_value() -- 获取当前线程的锁值
-- 只有锁的值匹配时才释放
if redis.call("GET", lock_key) == lock_value then
redis.call("DEL", lock_key)
stop_watchdog()
return true
else
return false
end
end
这个思路能够动态调整过期时间,避免锁被提前释放。但代价是实现复杂度增加,需要额外维护一个看门狗线程。
方案3:使用Redlock算法实现更高容错
如果业务对一致性要求极高(比如金融交易场景),可以考虑 Redis 官方推荐的 Redlock 算法。其核心思想是:在多个独立 Redis 实例上同时尝试获取锁,只有当大多数实例都成功获取到锁时,才认为锁获取成功。
Redlock 的基本步骤:
- 获取当前时间(T1)。
- 依次尝试在 N 个 Redis 实例上获取锁,使用相同的键和随机值,并设置相同的过期时间。
- 计算总耗时(T2 - T1)。如果耗时超过锁的过期时间,或者没能拿到大多数实例的锁,则释放所有已获取的锁。
- 锁获取成功则执行业务,完成后释放所有实例上的锁。
Redlock 的优点是在部分实例故障时仍能保证锁的安全性,缺点是实现复杂、性能开销较大。
最终落地方案的选择与实现
结合我们业务的场景(对一致性要求不是极端高,但对性能敏感)以及团队的技术栈,最终选择了方案1(原子性 SET 命令) + 方案2(看门狗机制)的组合方案:
- 使用
SET命令的NX和EX选项原子性地获取锁并设置过期时间。 - 对于长时间执行的业务逻辑,启动看门狗线程,定期给锁续命。
- 释放锁时校验锁的值是否匹配,防止误删其他请求持有的锁。
优化后的实现代码:
-- 获取锁
local function acquire_lock(user_id)
local lock_key = "balance_lock:" .. user_id
local lock_value = generate_unique_id() -- 生成唯一ID
local lock_expire = 10 -- 初始过期时间
local acquired = redis.call("SET", lock_key, lock_value, "NX", "EX", lock_expire)
if acquired then
-- 存储锁的值,用于后续释放
set_thread_local_value(lock_value)
-- 启动看门狗线程
start_watchdog(lock_key, lock_value, lock_expire)
return true
else
return false
end
end
-- 释放锁
local function release_lock(user_id)
local lock_key = "balance_lock:" .. user_id
local lock_value = get_thread_local_value()
-- 使用Lua脚本保证原子性
local script = [[
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
]]
local released = redis.call("EVAL", script, 1, lock_key, lock_value)
if released == 1 then
stop_watchdog()
return true
else
return false
end
end
总结:分布式锁避坑指南
回顾这次“三天大战 SETNX”的经历,分布式锁的坑远不止表面那几行代码。以下几个关键点必须牢记在心:
- 原子性操作:锁的获取和过期时间设置必须绑定在一起,绝不能让中间状态有机可乘。
- 锁的释放:只有锁的持有者才能释放锁,校验值的步骤不能省略。
- 锁的续约:业务逻辑可能执行超过预设时间,看门狗机制能防患于未然。
- 容错性:Redis 实例挂了怎么办?至少要有降级方案或重试机制。
最终方案既保留了性能优势(原子性命令的轻量),又补上了可靠性短板(看门狗续命)。这次排查让我对分布式并发控制的理解又深了一层——技术选型时不要被“看起来简单”迷惑,一定要深入推演各种边界情况。希望这篇文章能帮你少走弯路。
