别再踩了!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顶上。
然后又出问题了。
当时是这样的:
- 进程A在Master上加锁成功
- Master突然挂了
- 数据还没同步到Slave
- Slave变成新的Master
- 进程B在新的Master上加锁成功
- 进程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就完事了。主从切换没那么频繁,碰到了算你倒霉。
总结:分布式锁的正确使用姿势
说了这么多,来总结一下:
- 用原子操作:set + nx + ex,一条命令搞定加锁和过期时间
- 锁的value要唯一:用UUID之类的唯一标识,解锁时要判断
- 考虑可重入性:如果需要可重入,用Redisson
- 考虑高可用:如果要求很高,用RedLock或者ZooKeeper/etcd
- 永远不要相信自己的代码:加锁try-finally,解锁用Lua脚本
分布式锁不是万能的,用错了比不用还可怕。
希望这篇文章能帮到你。
下次加锁的时候,想清楚这些问题再动手。
你的分布式锁用对了吗?有没有踩过其他的坑?欢迎评论区聊聊。