- Redis的SETNX并发问题让我加了三天班*
引言
在分布式系统中,实现高效的并发控制是一个永恒的话题。Redis作为一个高性能的键值存储系统,因其出色的性能和丰富的功能被广泛应用于各种场景。其中,SETNX(SET if Not eXists)命令常被用作分布式锁的实现基础,但它的使用并非总是那么简单。最近,我在项目中遇到了一个棘手的并发问题,最终花费了三天时间才彻底解决。本文将详细分析这个问题,探讨其背后的原理,并分享最终的解决方案。
背景:分布式锁的需求
在我们的系统中,有一个关键的业务逻辑需要保证在分布式环境下的原子性操作。例如,用户在进行余额扣减时,必须确保同一时间只有一个请求能够成功执行,否则可能会导致余额不一致的问题。为了实现这一点,我们决定使用Redis的SETNX命令来实现分布式锁。
SETNX的基本原理是:当且仅当键不存在时,将键的值设置为指定的值,并返回1(表示成功);如果键已经存在,则返回0(表示失败)。这种特性非常适合用来实现分布式锁。
初版实现与问题
最初,我们的实现非常简单:
lua
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秒),而某些业务逻辑的执行时间可能超过10秒,导致锁被提前释放。此时,另一个请求可能会获取到锁,从而导致并发问题。
2. 并发竞争
在高并发场景下,SETNX和EXPIRE是两个独立的操作,不是原子性的。如果在SETNX成功之后、EXPIRE执行之前,Redis实例崩溃或网络中断,那么锁将无法设置过期时间,从而导致锁永远无法释放。虽然这种情况发生的概率较低,但在高并发的生产环境中仍有可能出现。
此外,即使锁的过期时间设置正确,由于锁的释放是依赖过期时间的,可能会导致多个请求同时认为自己获取了锁。例如:
- 请求A获取锁,设置过期时间为10秒。
- 请求A执行耗时15秒的业务逻辑,锁在第10秒时自动释放。
- 请求B在第11秒时获取锁,开始执行业务逻辑。
- 此时,请求A和请求B同时执行业务逻辑,导致并发问题。
解决方案的探索
方案1:使用Lua脚本保证原子性
为了解决SETNX和EXPIRE的非原子性问题,我们可以使用Lua脚本将这两个操作合并为一个原子操作:
lua
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:动态延长锁的过期时间
为了解决业务逻辑执行时间过长的问题,我们可以引入一个"看门狗"机制,定期检查锁是否仍然持有,并在需要时延长锁的过期时间。以下是伪代码实现:
lua
- - 获取锁
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算法。Redlock的核心思想是:在多个独立的Redis实例上获取锁,只有当大多数实例都成功获取锁时,才认为锁获取成功。
以下是Redlock的基本步骤:
- 获取当前时间(T1)。
- 依次尝试在N个Redis实例上获取锁,使用相同的键和随机值,并设置相同的过期时间。
- 计算获取锁的总耗时(T2 - T1),如果耗时超过锁的过期时间,或者未能在大多数实例上获取锁,则释放所有锁。
- 如果锁获取成功,则执行业务逻辑,并在完成后释放锁。
Redlock的优点是在部分Redis实例故障时仍能保证锁的安全性,缺点是实现复杂,性能较低。
最终解决方案
结合我们的业务场景和性能要求,我们最终选择了方案1(原子性SET命令)和方案2(看门狗机制)的结合:
- 使用
SET命令的NX和EX选项原子性地获取锁并设置过期时间。 - 为长时间执行的业务逻辑启动看门狗线程,定期延长锁的过期时间。
- 在释放锁时,检查锁的值是否匹配,避免误删其他请求的锁。
以下是优化后的实现:
lua
- - 获取锁
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实例崩溃),需要有备选方案保证系统可用性。
最终,我们的解决方案结合了Redis的原子性操作和看门狗机制,既保证了性能,又提高了可靠性。这次经历让我对分布式系统的并发控制有了更深的理解,也让我明白了在技术选型时不能只看表面,而需要深入思考其适用场景和潜在问题。