分布式锁从入门到放弃:我用血泪换来的经验
事情是这样的,那是一个普通的加班夜,我像往常一样愉快地写着增删改查,突然产品经理走过来说:"兄弟,咱们这个库存扣减功能好像有问题,高并发时会超卖啊!"
我心想,这不简单,加个锁嘛。于是我百度了一下"Redis分布式锁",copy了一段代码,上线。
然后呢?然后我就开启了为期三天的地狱模式。库存扣减确实不超卖了,但是有时候明明有库存,却扣减失败;有时候锁一直释放不掉;有时候并发一高,整个服务就卡死。
今天,让我用血泪教训告诉你,分布式锁没那么简单。
第一坑:Redis分布式锁的"假锁"
最开始我是这么写的:
// 错误示例1:最基础的锁
if (redis.setnx("lock:" + productId, "1")) {
try {
// 扣减库存
decreaseStock(productId);
} finally {
redis.del("lock:" + productId);
}
}
看起来没问题?恭喜你,这段代码能坑死你。
问题1:没有设置过期时间
如果程序在try块里抛异常或者卡死了,锁永远不会释放,后面所有请求都得等着,等着,等着……直到海枯石烂。
问题2:没有原子性
你可能觉得加了finally就万无一失?Too young。假设这种情况:
// 线程A获取锁成功
// 线程A在处理业务,TTL还剩30ms
// 30ms到了,Redis自动删除锁
// 线程B获取锁成功,开始处理
// 线程A处理完了,执行del("lock")
// 线程B的锁被删了!
// 线程C获取锁成功,和线程B同时处理
// 并发事故+1
这就是著名的"锁续期"问题,也是Redisson这些客户端为什么要用watchdog机制。
第二坑:Redisson就安全了?太天真
被坑了一次之后,我学聪明了,直接上Redisson。网上都说这个库靠谱,底层实现了一套完整的分布式锁。
用了之后确实香了一段时间,直到有一次压测,我们发现了一个诡异的问题:
1000个并发请求抢一个锁,平均响应时间800ms,P99延迟直接飙到3秒。
查了半天才发现,问题出在Redis的Pub/Sub机制上。Redisson用的是发布-订阅模式来实现锁的异步通知,当并发量大了之后,Pub/Sub本身就成了瓶颈。
更坑的是,Redisson的锁是可重入的,底层用Hash结构存储:
Key: lock:product:123
Value: {
"uuid:threadId": 1 // 重入次数
}
听起来很美好?但如果你不小心在finally里释放锁时判断条件写错了,就会出现:
// 错误:不管三七二十一就释放
} finally {
redissonClient.getLock("lock:product:123").unlock();
}
这种情况会导致:线程A的锁被线程B给释放了,然后线程C又拿到了锁——三个线程同时在处理,库存肯定又超卖了。
第三坑:数据库锁?那是另一个坑
后来有人建议我用数据库的行锁,说简单可靠。我一想,对啊,MySQL的SELECT ... FOR UPDATE不香吗?
香是香,但有个前提:你的事务必须足够短。问题是业务复杂度上来之后,谁敢保证事务一定能快速结束?
更坑的是数据库死锁:
// 线程A:先锁商品表,再锁订单表
START TRANSACTION;
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- 获得锁
SELECT * FROM order WHERE id = 100 FOR UPDATE; -- 等待
// 线程B:先锁订单表,再锁商品表
START TRANSACTION;
SELECT * FROM order WHERE id = 100 FOR UPDATE; -- 获得锁
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- 等待
// 死锁!
这种ABBA死锁在分布式环境下太常见了。解决方法也很简单:所有事务必须按统一顺序获取锁。说起来容易,做起来难——代码写多了,谁还记得住那么多规则?
正确姿势:我的血泪总结
踩了这么多坑之后,我总结了一套相对靠谱的实践:
1. 能不用分布式锁就不用
没错,我是认真的。很多并发问题根本不需要分布式锁:
- Redis原子操作:
DECR、INCR这些命令本身就是原子的,一个DECR product_stock就解决了,干嘛要加锁? - 消息队列:把并发请求串行化,Kafka、RocketMQ了解一下?削峰填谷一把好手。
- 单线程设计:有些场景下一个Redis单线程就能搞定,干嘛自己给自己找麻烦?
2. 真的需要锁?选对方案
// 推荐:使用Redisson,但要正确使用
RLock lock = redissonClient.getLock("lock:" + productId);
// 等待锁超时30秒,自动解锁10秒
// watchDog会自动续期
lock.lock(30, 10, TimeUnit.SECONDS);
try {
// 业务逻辑
} finally {
// 必须判断是否是当前线程持有的锁!
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
3. 考虑主从切换
如果你用的是Redis主从架构,当主节点挂掉、从节点还没同步完锁信息的时候,就会出现"多把锁"的诡异问题。
解决方案:
- RedLock:同时向N个Redis实例申请锁,超过半数成功才算获取锁。缺点是需要部署多个Redis,成本高,性能差。
- ZooKeeper:CP模型,天然支持选举和故障切换,就是性能比Redis差一些。
- Etcd:和ZK类似,用Raft协议保证一致性。
我的建议是:除非金融级场景,否则别整RedLock。多数业务用Redis单节点+合理的过期时间就够了。
写在最后
分布式锁看似简单,实则坑深似海。从最初的setnx到Redisson,从数据库锁到ZooKeeper,我走了太多弯路。
但最大的坑其实是:以为加了个锁就万能了。实际上,99%的并发问题都应该从架构层面解决,而不是靠锁这种"术"的层面。
产品经理又要改需求了,这次他说要做一个"秒杀系统",支持100万人同时抢购。我当场就想辞职。
算了,还是先去看看Redis文档吧。毕竟,crud boy的命,就是这么苦。
本文作者:一个被分布式锁毒打过的后端工程师