Redis的SETNX并发问题让我加了三天班

  • 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. 锁无法释放:在某些情况下,锁没有被正确释放,导致后续请求无法获取锁。
  2. 并发竞争:在高并发场景下,多个请求可能会同时获取锁,导致业务逻辑被重复执行。

问题分析

1. 锁无法释放

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

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

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

2. 并发竞争

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

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

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

解决方案的探索

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

为了解决SETNXEXPIRE的非原子性问题,我们可以使用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的基本步骤:

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

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

最终解决方案

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

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

以下是优化后的实现:

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

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

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

相关推荐
demo007x2 小时前
Docling 文档转换以及技术架构分析
前端·后端·程序员
京东云开发者2 小时前
京东市民服务又“上新”!这次是黑龙江“龙易办”
前端
用户5191495848453 小时前
Windows 渗透测试载荷加载器 POC 工具集
人工智能·aigc
袋鱼不重3 小时前
我的神奇同事,AI 用多了居然写了个 Open In Codex
前端·后端·ai编程
大树883 小时前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
用户8356290780513 小时前
使用 Python 操作 Word 内容控件
后端·python
像我这样帅的人丶你还3 小时前
啥? 前端也要会干Java?🛵🛵🛵
后端
Hommy883 小时前
【剪映小助手】添加贴纸接口(Add Sticker)
后端·github·剪映小助手·视频剪辑自动化·剪映api
通信小呆呆3 小时前
当算法有了“五感”:多模态数据融合如何向人体感官协同学习?
人工智能·学习·算法·机器学习·机器人