首页 > 数据库    Redis日期:2026-06-18 / 浏览

引言

在分布式系统中,实现高效的并发控制是一个永恒的话题。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. 锁无法释放:在某些情况下,锁没有被正确释放,导致后续请求无法获取锁。
  2. 并发竞争:在高并发场景下,多个请求可能会同时获取锁,导致业务逻辑被重复执行。

问题分析

1. 锁无法释放

锁无法释放的原因通常有两种:

  • 业务逻辑执行时间超过锁的过期时间,导致锁自动释放,但业务逻辑仍在执行。
  • 业务逻辑抛出异常,未能执行锁释放的逻辑。

在我们的案例中,问题主要是第一种情况。由于锁的过期时间设置较短(10秒),而某些业务逻辑的执行时间可能超过10秒,导致锁被提前释放。此时,另一个请求可能会获取到锁,从而导致并发问题。

2. 并发竞争

在高并发场景下,SETNXEXPIRE是两个独立的操作,不是原子性的。如果在SETNX成功之后、EXPIRE执行之前,Redis实例崩溃或网络中断,那么锁将无法设置过期时间,从而导致锁永远无法释放。虽然这种情况发生的概率较低,但在高并发的生产环境中仍有可能出现。

此外,即使锁的过期时间设置正确,由于锁的释放是依赖过期时间的,可能会导致多个请求同时认为自己获取了锁。例如:

  • 请求A获取锁,设置过期时间为10秒。
  • 请求A执行耗时15秒的业务逻辑,锁在第10秒时自动释放。
  • 请求B在第11秒时获取锁,开始执行业务逻辑。
  • 此时,请求A和请求B同时执行业务逻辑,导致并发问题。

解决方案的探索

方案1:使用Lua脚本保证原子性

为了解决SETNXEXPIRE的非原子性问题,我们可以使用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:动态延长锁的过期时间

为了解决业务逻辑执行时间过长的问题,我们可以引入一个“看门狗”机制,定期检查锁是否仍然持有,并在需要时延长锁的过期时间。以下是伪代码实现:

- - 获取锁
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的基本步骤:

  1. 获取当前时间(T1)。
  2. 依次尝试在N个Redis实例上获取锁,使用相同的键和随机值,并设置相同的过期时间。
  3. 计算获取锁的总耗时(T2 - T1),如果耗时超过锁的过期时间,或者未能在大多数实例上获取锁,则释放所有锁。
  4. 如果锁获取成功,则执行业务逻辑,并在完成后释放锁。

Redlock的优点是在部分Redis实例故障时仍能保证锁的安全性,缺点是实现复杂,性能较低。

最终解决方案

结合我们的业务场景和性能要求,我们最终选择了方案1(原子性SET命令)和方案2(看门狗机制)的结合:

  1. 使用SET命令的NXEX选项原子性地获取锁并设置过期时间。
  2. 为长时间执行的业务逻辑启动看门狗线程,定期延长锁的过期时间。
  3. 在释放锁时,检查锁的值是否匹配,避免误删其他请求的锁。

以下是优化后的实现:

- - 获取锁
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虽然是一个强大的工具,但在高并发场景下需要额外注意以下几点:

  1. 原子性操作:确保锁的获取和设置过期时间是原子性的,避免中间状态。
  2. 锁的释放:只有锁的持有者才能释放锁,避免误删其他请求的锁。
  3. 锁的续约:对于长时间执行的业务逻辑,需要动态延长锁的过期时间。
  4. 容错性:在极端情况下(如Redis实例崩溃),需要有备选方案保证系统可用性。

最终,我们的解决方案结合了Redis的原子性操作和看门狗机制,既保证了性能,又提高了可靠性。这次经历让我对分布式系统的并发控制有了更深的理解,也让我明白了在技术选型时不能只看表面,而需要深入思考其适用场景和潜在问题。

觉得上面的内容有用吗?快来点个赞吧!

点赞() 我要打赏

温馨提示 : 本站内容来自会员投稿以及互联网,所有源码及教程均为作者总结编辑,请大家在使用过程中提前做好备份,以免发生无法预知的错误,源码类教程请勿直接用于生产环境!

 可能感兴趣的文章