首页 > 数据库 >Redis SETNX并发问题解决策略

Redis SETNX并发问题解决策略

来源:互联网 2026-06-19 08:34:08

Redis的SETNX命令存在非原子性问题,导致锁无法释放和并发竞争。采用SET命令的NX和EX选项实现原子性获取锁并设置过期时间,结合看门狗动态续约,释放时校验锁值,有效解决了分布式锁的可靠性问题。

引言

分布式系统中的并发控制一直是核心挑战。Redis作为高性能键值存储的明星产品,凭借超快速度和丰富功能,被广泛应用于各类业务场景。其中SETNX(SET if Not eXists)命令常被用来搭建分布式锁,但它的用法远没有看上去那么简单。最近,我花费三天时间才解决一个棘手的并发问题——从表面现象挖掘到底层原理,再到最终落地方案,整个过程收获颇丰。下面详细拆解这个案例,希望能帮你避开同样的坑。

Redis SETNX并发问题解决策略

长期稳定更新的攒劲资源: >>>点此立即查看<<<

背景:分布式锁的需求

我们的系统里,某个关键业务逻辑必须在分布式环境下保证原子性:比如用户余额扣减,同一时间只能有一个请求执行,否则数据可能混乱。要实现这种互斥,选用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秒,可有些业务逻辑偏偏要跑15秒。第10秒锁一释放,另一个请求立马抢到锁,两个请求同时操作同一笔余额,后果可想而知。

2. 并发竞争

再细看那段代码,SETNXEXPIRE 是两步独立操作——不是原子的。想象一下:SETNX 刚成功,还没执行 EXPIRE 呢,Redis 实例挂了或者网络断了。锁永远没有过期时间,就变成了“僵尸锁”。虽然概率低,但在高并发生产环境中,墨菲定律总会应验。

就算过期时间设置对了,锁靠自动过期来释放也会引发“误认”。举个例子:

  • 请求A拿到锁,过期时间10秒。
  • 请求A执行了15秒,第10秒锁自动释放。
  • 第11秒,请求B拿到锁,也开始了业务。
  • 这时A和B同时在跑业务逻辑,并发问题重现。

解决方案的探索

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

既然问题出在“两步非原子”,那把它们合在一起就完了。用 Lua 脚本将 SETNXEXPIRE 封装成一个原子操作:

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 基本步骤:

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

Redlock 的优点是在部分实例故障时仍能保证锁的安全性,缺点是实现复杂、性能开销大。

最终解决方案

结合我们业务的场景(对一致性要求不是极端高,但性能敏感)和团队的技术栈,最终选了方案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 实例挂了怎么办?至少要有降级方案或重试机制。

最终方案既保留了性能优势(原子性命令的轻量),又补上了可靠性短板(看门狗续命)。这次排查让我对分布式并发控制的理解又深了一层——技术选型时别被“看起来简单”迷惑,一定要钻到背后去推演边界情况。希望这篇文章能帮你少走弯路。

侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述

热游推荐

更多
湘ICP备14008430号-1 湘公网安备 43070302000280号
All Rights Reserved
本站为非盈利网站,不接受任何广告。本站所有软件,都由网友
上传,如有侵犯你的版权,请发邮件给xiayx666@163.com
抵制不良色情、反动、暴力游戏。注意自我保护,谨防受骗上当。
适度游戏益脑,沉迷游戏伤身。合理安排时间,享受健康生活。