分布式锁的代价与选择,为何我们最终选择了拥抱Redisson?
摘要:很多时候,我们从简单方案过渡到复杂方案,并不是因为想炫技,而是在无数次'掉坑'之后,对代码、对线上的敬畏。但同样,在面对过度设计时,也要有敢于说'不'的底气:如果单实例
写在前面的话
不知道你有没有过这种经历:在本地开发测试时一切顺风顺水,逻辑严丝合缝。可一旦代码部署到线上,面对高并发的真实流量,各种匪夷所思的数据异常就开始冒头了。
我最早遇到的"库存超卖"就是这样一个典型案例。从最初相信 Java 自带的锁,到后来手写 Redis 锁,再到最后折腾出稳定方案,这个过程其实就是对"并发"二字理解不断加深的过程。
今天想聊聊这块内容,不堆砌概念,只讲讲这条路是怎么一步步走过来的。
一、一切的起点:synchronized 的舒适区
刚开始写代码时,思维往往停留在"单机"模式。遇到需要控制并发的地方,直觉反应就是加个 synchronized 关键字。
1. 曾经写过的代码
// 简单的库存扣减
public synchronized void deductStock(String productId) {
// 1. 查询库存
Product product = stockMapper.selectById(productId);
// 2. 判断并扣减
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
stockMapper.updateById(product);
}
}
2. 这个方案能用吗?
能用,但有前提。
如果你的系统是一个简单的后台管理系统,或者是一个单节点部署的内部工具,并发量极低,那么 synchronized 完全足够。它简单、高效,且无需引入外部依赖,是解决单机并发问题的"如意金箍棒"。
3. 为什么后来不行了?
问题的关键在于”跨进程“。
当业务发展,服务需要部署两台甚至更多服务器时,每台服务器都有一个独立的 JVM。
服务器 A 的 synchronized 锁住了它自己的线程。
服务器 B 的 synchronized 锁住了它自己的线程。
结果:A 和 B 同时放行了一个请求,扣减了同一件商品。库存立刻变负数。
这时候我们意识到:我们需要一把能管得住所有服务器的"大锁"。
二、初尝分布式锁:Redis SETNX 的尝试
既然 JVM 内部的锁不管用了,那自然要找一个所有服务器都能访问到的第三方组件来存这把锁。Redis 因为其高性能和简单的 API,成了首选。
1. 最直观的写法
Redis 有个命令叫 SETNX (SET if Not Exists)。这名字听起来就天生是为了抢占资源设计的。
# 谁先执行成功,谁就抢到了锁
SETNX lock:product:101 1
逻辑很简单:
多个服务器同时发 SETNX 命令。
只有一个能返回 1(成功),其他的返回 0(失败)。
抢到锁的执行业务,做完之后 DEL 删除锁。
2. 现实中的意外
这个方案最大的隐患在于“删锁”这步。
如果代码在执行业务逻辑时,服务器突然断电了,或者进程崩溃了,导致 DEL 命令没来得及发出。
后果:这把锁就像"幽灵"一样永远存在于 Redis 里。后续所有针对这个商品的请求,都会因为拿不到锁而被死死卡住。
改进方案:必须加过期时间。
SETNX lock:product:101 1
EXPIRE lock:product:101 10 # 10秒后自动过期
3. 还是不够完美
SETNX 和 EXPIRE 是两条命令,不是原子操作。如果在第一句和第二句之间由于网络抖动或者服务重启断开了,锁依然会变成"死锁"。
适用场景:
这种简单的 SETNX 方案,在很早期的 Redis 版本或者一些非核心业务(比如简单的定时任务去重)中还可以见到,但在对于数据准确性要求极高的交易核心链路,它显然过于脆弱了。
三、进阶:原子性与"锁不住"的尴尬
吸取了死锁的教训,后来 Redis 官方推出了原子命令,或者我们通用 Lua 脚本来保证操作原子性。
1. 修复死锁问题
# 一条命令搞定加锁和过期时间
SET lock:product:101 uuid NX PX 10000
这就解决了原子性问题。只要锁加上了,由于有过期时间,哪怕服务器爆炸,锁最终也会自动消失,系统能自动恢复。
2. 引入了新问题:锁因为超时提前释放了
假设我们将锁的过期时间设为 10秒。
但那天的数据库特别卡,业务逻辑执行了 15秒。
这就出现了一个严重的逻辑漏洞:
T0秒:线程 A 加锁成功。
T10秒:锁自动过期释放。
T11秒:线程 B 进场,发现没锁,加锁成功。
T15秒:线程 A 终于执行完了,发起 DEL 删除锁。
