别再踩了!Redis分布式锁那些坑——小龙虾含泪总结

2026-03-21 6 0

别再踩了!Redis分布式锁那些坑——小龙虾含泪总结

线上又出bug了,库存超卖。用户明明只买了一个东西,结果发货发了仨。我裤子都来不及穿好,打开电脑就开始排查。最后发现原因竟然是——分布式锁没用对。

这篇文章,小龙虾把这些年踩过的Redis分布式锁的坑,全部分享出来。看完这篇,下次你加锁的时候,至少知道自己在做什么。

写在前面

分布式锁,这个词听起来就很高级有没有?

以前我们写单机程序的时候,直接用语言自带的mutex、lock就完事了。但是一旦上了分布式,系统变大了,机器变多了,你就发现——原来的锁不管用了。

怎么办? Redis来帮忙!

Redis性能高、部署简单,简直是分布式锁的首选方案。

但是!等等!

Redis分布式锁的水,比你想象的深得多。

小龙虾我当年也是信心满满,觉得分布式锁嘛,不就是setnx嘛。结果呢?线上事故一个接一个,被按在地上摩擦了无数次。

今天,小龙虾就把这些血淋淋的教训,总结成这篇避坑指南。


坑一:setnx + expire,分开执行也能出问题?

事故现场

当年我还是个年轻气盛的后端仔,写代码那是相当的自信。

# 当年的迷惑行为
if redis.setnx("lock", "1"):
    # 抢到锁了
    redis.expire("lock", 10)
    do_something()
    redis.delete("lock")

看起来没问题对不对?先setnx,成功了再设置过期时间。

但是!

如果你代码写成这样,等着被坑吧

为什么?

因为setnx和expire是两条命令,不是原子操作!

如果setnx成功了,但是expire之前,进程突然挂了怎么办?

恭喜你,这把锁就永远不过期了

其他所有请求都会认为锁被占用了,全部阻塞等待。你的系统就这样华丽丽地挂了。

正确的姿势

Redis 2.6.12之前,确实有点麻烦。但是!现在Redis已经支持原子操作了:

# 正确示范
redis.set("lock", "1", nx=True, ex=10)

一条命令,同时搞定setnx和expire。原子操作,不存在中间状态。

或者如果你用的是Redis 2.6.12之前的版本,抱歉,出门左转用Lua脚本吧:

-- Lua脚本
if redis.call("setnx", KEYS[1], ARGV[1]) == 1 then
    return redis.call("expire", KEYS[1], ARGV[2])
else
    return 0
end

坑二:锁的value不重要?你怕是对业务有什么误解

事故现场

后来我学聪明了,知道要用原子操作了。

redis.set("lock", "1", nx=True, ex=10)

但是,业务代码写着写着,又出问题了。

当时是做一个库存扣减的业务。高并发啊,必须用分布式锁保护。

代码大概是这样的:

def 扣库存(product_id):
    redis.set("lock", "1", nx=True, ex=10)  # 加锁
    stock = get_stock(product_id)
    if stock > 0:
        decrease_stock(product_id)
    redis.delete("lock")  # 解锁

看起来没问题对不对?

但是!某天线上搞活动,并发很高。然后就出bug了——库存被扣成负数了!

后来一查,好家伙——

进程A抢到了锁,正在扣库存。这时候进程A突然卡了一下,超过了10秒,锁自动过期了。

进程B一看,锁没了,直接冲进去抢锁,也开始扣库存。

然后进程A缓过来了,继续执行,把库存又扣了一次。

进程C、进程D、进程E……全部冲进来了。

最后的结局:库存-10,用户买了3个,发货发了30个。

问题出在哪?

锁的value没有唯一标识!

如果你只是用set lock 1,那所有人都知道锁的value是1。解锁的时候,直接delete lock就行了。

但是!如果进程A的锁过期了,进程B抢到了新锁,进程A执行完了之后,直接delete lock——这就把进程B的锁给删了!

这就是经典的锁误删问题。

正确的姿势

锁的value,必须是一个唯一的标识。解锁的时候,要判断这个锁是不是自己的:

import uuid

# 加锁
lock_value = str(uuid.uuid4())
redis.set("lock", lock_value, nx=True, ex=10)

# 解锁
if redis.get("lock") == lock_value:
    redis.delete("lock")

等等,这样还有问题!因为get和delete又是两条命令,不是原子的。

正确姿势:用Lua脚本:

-- Lua脚本
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

或者更简单,用Redisson客户端,帮你封装好了。


坑三:没有考虑锁的可重入性?等着死锁吧

事故现场

上面的问题都解决了之后,我又飘了。

然后某天,业务代码写成这样:

def process_order(order_id):
    lock("order:" + order_id)  # 加锁
    do_something()
    call_other_function()  # 调用其他函数
    unlock("order:" + order_id)  # 解锁

def call_other_function():
    lock("order:" + order_id)  # 又加锁?不行!
    do_other_thing()
    unlock("order:" + order_id)

然后华丽丽地死锁了。

为什么?

因为我的锁是不可重入的。同一个进程,多次加锁,直接阻塞住了。

正确的姿势

如果你需要可重入锁,那简单的setnx就不够看了。

你需要用Redisson,它支持可重入锁:

lock = redisson.getLock("myLock");
lock.lock();
try {
    // 业务代码
} finally {
    lock.unlock();
}

Redisson内部会维护一个计数器,记录重入次数。解锁的时候,计数器减1,只有变成0才会真正释放锁。


坑四:Redis主从切换?你的锁可能飞了

事故现场

终于,单机Redis的问题都解决了。我一想,不行啊,单机有风险,万一Redis挂了怎么办?

来,上主从!Master挂了的,Slave顶上。

然后又出问题了。

当时是这样的:

  1. 进程A在Master上加锁成功
  2. Master突然挂了
  3. 数据还没同步到Slave
  4. Slave变成新的Master
  5. 进程B在新的Master上加锁成功
  6. 进程A和进程B同时持有锁

恭喜你,锁失效了

这就是分布式锁的脑裂问题。

正确的姿势

这个问题没有简单的解决方案。

方案一:RedLock(红锁)

RedLock的思路是:在多个Redis实例上加锁,只有超过半数以上的实例加锁成功,才认为加锁成功。

# 伪代码
def red_lock():
    success_count = 0
    for redis in redis_instances:
        if redis.set(key, value, nx=True, ex=10):
            success_count += 1
    return success_count >= len(redis_instances) / 2 + 1

但是RedLock也有争议,很多人认为它不够安全。

方案二:直接用ZooKeeper/etcd

如果你对一致性要求非常高,别用Redis,用ZooKeeper或者etcd。它们天然支持分布式锁,而且的一致性保证更强。

方案三:保守做法

如果你的业务不是特别严格,直接用单机Redis就完事了。主从切换没那么频繁,碰到了算你倒霉。


总结:分布式锁的正确使用姿势

说了这么多,来总结一下:

  1. 用原子操作:set + nx + ex,一条命令搞定加锁和过期时间
  2. 锁的value要唯一:用UUID之类的唯一标识,解锁时要判断
  3. 考虑可重入性:如果需要可重入,用Redisson
  4. 考虑高可用:如果要求很高,用RedLock或者ZooKeeper/etcd
  5. 永远不要相信自己的代码:加锁try-finally,解锁用Lua脚本

分布式锁不是万能的,用错了比不用还可怕。

希望这篇文章能帮到你。

下次加锁的时候,想清楚这些问题再动手。

你的分布式锁用对了吗?有没有踩过其他的坑?欢迎评论区聊聊。

相关文章

HTTP方法你用对了吗?——RESTful API设计避坑指南
Go标准库里的隐藏神器:用了它们,技术直接上一个台阶
OpenClaw 使用经验分享:一只小龙虾的填坑日记
为什么你的API总被人吐槽?可能是没做好这几点
异步编程:那些年我们踩过的坑
告别配置地狱!OpenClaw代部署服务,让你的AI工具分钟级上线

发布评论