分布式锁的代价与选择,为何我们最终选择了拥抱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 删除锁。
阅读全文