Redis的SETNX命令存在非原子性问题,导致锁无法释放和并发竞争。采用SET命令的NX和EX选项实现原子性获取锁并设置过期时间,结合看门狗动态续约,释放时校验锁值,有效解决了分布式锁的可靠性问题。
分布式系统中的并发控制一直是核心挑战。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
这段代码逻辑看起来没有毛病:尝试获取锁→成功则设置过期时间→失败直接返回。可一上线,两个严重问题就冒出来了:
锁释放不了,无外乎两种情形:
我们遇到的主要是第一种。当时锁过期时间只设了10秒,可有些业务逻辑偏偏要跑15秒。第10秒锁一释放,另一个请求立马抢到锁,两个请求同时操作同一笔余额,后果可想而知。
再细看那段代码,SETNX 和 EXPIRE 是两步独立操作——不是原子的。想象一下:SETNX 刚成功,还没执行 EXPIRE 呢,Redis 实例挂了或者网络断了。锁永远没有过期时间,就变成了“僵尸锁”。虽然概率低,但在高并发生产环境中,墨菲定律总会应验。
就算过期时间设置对了,锁靠自动过期来释放也会引发“误认”。举个例子:
既然问题出在“两步非原子”,那把它们合在一起就完了。用 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(设置过期时间),一步到位。这解决了“锁没有过期时间”的问题,但业务逻辑跑太久导致锁提前失效的问题依然存在。
那就给锁配上“续命”机制——看门狗。它定期检查锁是否还被持有,如果业务还在跑,就自动把过期时间往后推。伪代码大致长这样:
-- 获取锁
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
这个思路能动态调整过期时间,避免锁被提前释放。但代价是实现复杂,需要额外维护一个看门狗线程。
如果业务对一致性要求极高(比如金融交易),可以考虑 Redis 官方推荐的 Redlock 算法。核心思想:在多个独立 Redis 实例上获取锁,只有大多数实例都成功获取,才认为锁到手。
Redlock 基本步骤:
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”的经历,分布式锁的坑远不止表面那几行代码。几个关键点必须刻在脑子里:
最终方案既保留了性能优势(原子性命令的轻量),又补上了可靠性短板(看门狗续命)。这次排查让我对分布式并发控制的理解又深了一层——技术选型时别被“看起来简单”迷惑,一定要钻到背后去推演边界情况。希望这篇文章能帮你少走弯路。
侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述