十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分布式锁实战:从库存超发原理到Redisson落地与踩坑记录

分布式锁实战:从库存超发原理到Redisson落地与踩坑记录 我先交代一下背景。做后端开发的兄弟应该都有过这种经历某个大促的凌晨运营那边消息满天飞说秒杀活动超卖了库存瞬间变负数。你打开监控面板一看高峰期下单接口的 QPS 快被打满了应用日志里各种超时重试数据库里库存字段值不对劲。这种事故我在前几年负责电商订单系统时实打实踩过好几轮最后用分布式锁把这问题压了下去期间也踩了不少文档里根本不会写的坑。这篇文章直接把我当时的完整思路、落地代码、踩坑记录整理出来给同样在处理库存超发问题的朋友一个可参考的副本。先说清楚一个容易搞混的点很多人以为超发是并发高导致的其实并发只是催化剂。真正的原因是多个请求同时读到同一个库存值然后在各自的内存空间里做判断和扣减最后把结果写回去后写的把先写的覆盖了。单机时代我们习惯用 synchronized 或者 JVM 内部的 Lock但服务一拆多节点、部署多副本之后每个 JVM 的锁管不到其他节点这时候才轮到分布式锁上场。所以说到底分布式锁解决的是跨进程互斥是给“多个独立进程同时操作共享资源”这回事加一道门禁。1. 超发问题是怎么来的为什么单机锁挡不住1.1 一次真实事故的完整复盘大促当晚运营配置了一批限量 100 件的优惠券页面一上线流量瞬间涌进来。我这边看到的异常是数据库里优惠券库存字段变成了负数最大负到 -7。查日志发现下单接口里用了类似这样的逻辑先查询当前库存判断是否大于 0大于 0 就执行 UPDATE 把库存减 1。这个判断和扣减之间隔着网络 IO、JVM 线程调度、数据库锁竞争时间差足够让一百个并发请求都读到库存等于 1然后各自通过判断各自执行扣减最后库存就被打成负数了。这其实就是典型的 check-then-act 竞态条件。问题的关键不是 update 语句本身而是“检查库存是否充足”和“扣减库存”这两步操作没有形成原子性。单机环境还能靠加锁把这两步锁住多机部署之后就锁不住了。1.2 为什么 synchronized 到多节点就失效了synchronized 是 JVM 内置的监视器锁它锁的是对象头里的 Mark Word本质上是同一台机器上不同线程之间的协调机制。服务部署了三个节点每个节点上有 200 个线程同时跑这时候 synchronized 保证的是单个节点内互斥三个节点之间完全隔离跟没加锁一样。还有一个很容易被忽略的点即使只部署了单节点如果应用里用了线程池异步处理、消息队列消费、定时任务调度这些线程可能分散在不同 JVM 实例中synchronized 同样管不到。我后来排查过一个诡异的问题明明同一台机器上两个定时任务都加了 synchronized结果两个任务还是同时执行了最后发现它们分别跑在两个独立的 Spring 容器里——每个容器各有一个 JVM锁自然是各锁各的。所以要解决超发必须有一个独立于所有业务节点之外的东西来做协调让所有节点都认它、都听它的话。分布式的“分布”二字指的就是这个协调者不在任何一个业务进程之内而是一个所有进程都能访问的公共组件。1.3 分布式锁解决的到底是哪一类问题分布式锁本质上是一道分布式互斥机制同一时刻只允许一个客户端持有锁只有持锁的客户端能操作临界区资源操作完成后主动释放锁其他客户端只能等待或轮询。套到超发场景里锁保护的临界区就是那两行代码检查库存、扣减库存。一旦这个区域变成互斥访问同一时刻只有一个请求能进去别的请求要么等它出来再进去要么直接被拒掉超发现象就从根本上消除。需要提醒的是分布式锁不是万能药。它适合保护“短时间、小粒度”的临界区。如果你的业务逻辑在锁内做了大量耗时的网络调用比如远程接口、慢 SQL、批量文件处理那么这把锁会拖垮整个系统的吞吐量。我见过一个团队把整个订单创建流程都塞进分布式锁里结果 QPS 直接从 2000 掉到 50数据库没超发接口超时倒是刷屏了。这种场景应该思考怎么缩减临界区范围而不是无脑加锁。2. Redis 分布式锁的完整设计思路2.1 从 SETNX 说起每一步都是踩坑换来的Redis 里最早实现分布式锁的方式是 SETNX 命令全称是 SET if Not eXists只在 key 不存在时设置成功存在时返回失败。利用这个特性可以让多个客户端竞争同一个 key谁设置成功谁就获得锁。逻辑很直观但真用起来漏洞不少。初期我写的代码是这样的先用 SETNX 去抢锁抢到了就执行业务逻辑最后 DEL 释放锁。跑了两天发现一个严重问题如果业务逻辑抛异常了或者服务进程直接崩溃了DEL 代码压根执行不到锁就会永远留在 Redis 里后面的请求永远拿不到锁系统直接瘫痪。然后我想了个办法给锁 key 设置过期时间。SETNX 成功之后再执行 EXPIRE让锁在 30 秒后自动消失。可是这又引入了新的问题SETNX 和 EXPIRE 是两条命令不是原子的。如果 SETNX 成功之后进程在 EXPIRE 执行前崩溃了锁照样变成僵尸锁。当时解决方式是写 Lua 脚本把 SETNX 和 EXPIRE 合并成一个原子操作发给 Redis 执行。后来发现这根本不用自己写因为 Redis 官方早就考虑到了——SET 命令扩展了 NX 和 EX 参数一行命令搞定SET lock_key some_value NX EX 30。到这一步加锁才算是有了一个相对完整的基础版本。2.2 过期时间设多长设短了业务没跑完怎么办过期时间 TTL 的选择是分布式锁设计里最容易出问题的决策点。设短了业务逻辑还没执行完锁就自动释放后面的请求趁虚而入临界区形同虚设。设长了一旦持锁节点真的宕机锁要很久才能被清除系统整体可用性大大降低。一个常见做法是看业务峰值耗时来估算统计一下所有加锁保护的逻辑在 P9999% 请求都能完成的时间的水位再乘以一个安全系数。比如库存扣减逻辑正常跑 50 毫秒P99 是 200 毫秒那么 TTL 设置 3 到 5 秒就挺充裕。但大促期间线上负载高、GC 暂停时间长、网络抖动频发TTL 很容易被突破。我自己更倾向于用实现成熟的客户端库的看门狗机制而不是手工拍脑袋定一个固定值。Redisson 的 Watchdog 逻辑是这样的如果锁的 TTL 没有显式指定默认 30 秒Redisson 会启动一个后台定时任务每隔 TTL 的 1/3 时间也就是约 10 秒检查一下当前线程是否还持有锁如果持有一边给锁续期 30 秒。这样只要业务线程还活着锁就不会因为 TTL 太短被误删业务跑完主动释放锁后台任务自动取消。这套机制把“锁过期”这个老大难问题关进了笼子里。2.3 释放锁为什么不能直接 DEL必须用 Lua 脚本很多初学者会忽视释放锁时的身份校验问题。直接 DEL 会有一个经典坑A 线程持锁执行业务因为某种原因业务耗时超过了 TTL锁被 Redis 自动释放B 线程抢到锁开始执行这时候 A 线程业务终于跑完了执行 DEL把 B 线程的锁给删了C 线程又趁虚而入拿到锁。结果就是同一时刻 B 和 C 同时进入临界区超发问题换了个姿势又回来了。解决思路是在锁的 value 里存一个只有当前线程/请求知道的唯一标识通常用 UUID 加线程 ID释放锁的时候先 GET 一下判断 value 是否等于自己的标识相等才 DEL。但这一步 GET 和 DEL 之间同样有竞态窗口所以正确的姿势是用 Lua 脚本把比较和删除包成原子操作if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end这段脚本的意思是只有当 key 里存的 value 等于我自己的唯一标识时才删除这个 key。这样即使 A 线程的锁早就过期了它来释放时发现 value 不是自己的直接返回失败不会误删 B 线程的锁。这个细节非常重要我后来技术评审时只要看到有人直接写 DEL 释放锁基本可以直接判断他对分布式锁的理解还停留在表面。2.4 为什么我说 Redis 分布式锁要搭配业务兜底才稳妥Redis 分布式锁有个无法回避的缺陷Redis 主从架构不是强一致性的。A 线程在主节点上拿到了锁主节点还没来得及把数据同步到从节点就宕机了从节点晋升为主节点后锁数据丢失B 线程在新主节点上也能拿到锁两个线程同时进入临界区。这个问题的严重程度取决于你的业务容错能力。如果超发了可以接受事后人工补偿比如人工核销、退款、补发那 Redis 锁完全够用。如果超发一点就会造成资损且无法挽回那就要考虑 Zookeeper 那种基于 ZAB 协议实现强一致的锁方案或者引入 RedLock 算法。不过 RedLock 在业界争议很大有些专家认为它并不能真正保证安全性我在生产环境没有采用它而是选择了“Redis 锁 数据库乐观锁兜底”的组合策略。后面讲代码实现时我会把这两层怎么配合一起说清楚。3. 库存秒杀场景的代码落地全过程3.1 先看一个不加锁的原始实现理解问题是如何发生的我拿一个典型的商品库存表来演示表里只有一个商品、一个库存字段。不加任何锁的情况下扣减库存的 Mapper 接口长这样Update(UPDATE product_stock SET stock stock - 1 WHERE id #{productId}) int deductStock(Param(productId) Long productId);Service 层逻辑是public ResultBoolean createOrder(Long productId) { ProductStock stock stockDao.selectById(productId); if (stock.getStock() 0) { return Result.fail(库存不足); } stockDao.deductStock(productId); return Result.success(true); }这段代码就是我在文章开头复盘事故时提到的经典写法。先用 select 查一次库存判断是否充足再执行 update 扣减。并发高的时候多个请求都 select 到了同一条库存记录stock 都等于 1都通过了 if 判断都执行了扣减库存就变成负数。这里还有一个很多人没注意到的问题即使把判断逻辑直接写进 SQL比如UPDATE product_stock SET stock stock - 1 WHERE id #{productId} AND stock 0在单条语句层面确实是原子的能够防止超扣。但它没有办法告诉业务层“这次到底有没有扣成”还需要判断 update 返回的影响行数如果为 0 说明库存不够。单纯靠这一招可以保住库存不为负但如果我们后面还要串行执行其他业务逻辑比如创建订单、发消息、写流水这些操作依然需要一把全局锁来串行化否则多个请求可能同时创建出超出库存的订单。3.2 引入 Redisson 分布式锁写一个可运行的完整示例下面是我在生产环境使用过的代码基于 Spring Boot 和 Redisson。Redisson 是 Java 生态里比较成熟的一个 Redis 客户端库它把加锁、续期、释放等细节都封装好了不像用 Jedis 或者 Lettuce 需要自己处理那么多边界情况。先引入依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.17.7/version /dependency然后在配置类里创建一个 RedissonClient 实例Configuration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setDatabase(0) .setConnectionMinimumIdleSize(5) .setConnectionPoolSize(20); return Redisson.create(config); } }核心 Service 扣减逻辑Service public class StockService { private static final String STOCK_LOCK_PREFIX stock:lock:; Autowired private RedissonClient redissonClient; Autowired private StockDao stockDao; public ResultBoolean createOrderWithLock(Long productId) { String lockKey STOCK_LOCK_PREFIX productId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(2, 10, TimeUnit.SECONDS); if (!locked) { return Result.fail(系统繁忙请稍后重试); } ProductStock stock stockDao.selectById(productId); if (stock.getStock() 0) { return Result.fail(库存不足); } boolean success stockDao.deductStock(productId); if (!success) { return Result.fail(扣减失败); } // 这里可以继续执行创建订单、写流水等业务 return Result.success(true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail(系统异常); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这里有几个细节值得细说tryLock 第一个参数是等待时间意思是抢不到锁时最多等 2 秒超过就放弃第二个参数是锁的 TTL单位是秒但注意 Redisson 在显式传入 leaseTime 的时候不会启动看门狗锁到期自动释放你要自己评估业务时长是否够用如果不传第二个参数Redisson 才会启用看门狗自动续期。我的建议是对于库存扣减这种短平快的操作显式传入 TTL 是够用的因为逻辑本身只有一次查询和一次更新但如果你在锁里调用了远程接口或者执行了批量操作一定要用看门狗模式也就是不传 leaseTime让 Redisson 自动续期否则 TTL 到期锁被释放后续请求就会趁虚而入。finally 块里我先用 isHeldByCurrentThread 判断一下当前线程是否还持有这把锁再决定是否释放。这样可以避免一个极端场景业务在锁内执行时间过长锁已经自动过期了后续代码走到 finally 时如果直接 unlock可能导致释放其他线程的锁。虽然 Redisson 的 unlock 内部有 value 校验不会真的误删但养成这个判断习惯能帮你写出更健壮的代码。3.3 数据库层兜底分布式锁 乐观锁双重保险前面说了 Redis 分布式锁存在主从切换丢锁的隐患为了把这个风险降到最低我会在数据库层再加一道乐观锁兜底。具体做法是在库存表加一个 version 字段每次更新时带上版本号只有当版本号匹配时才更新成功Update(UPDATE product_stock SET stock stock - 1, version version 1 WHERE id #{productId} AND version #{version}) int deductStockWithVersion(Param(productId) Long productId, Param(version) Long version);Service 层在锁内读取库存时把 version 也查出来执行更新时把 version 带进去如果 update 返回值为 0说明这条记录的版本号已经被其他事务修改了本次扣减失败直接返回库存不足或者稍后重试。这两层叠加起来的效果是分布式锁解决的是请求并发度高时的串行化问题让绝大多数请求都能快速、有序地完成扣减数据库乐观锁解决的是极端情况下锁失效时的最终一致性防线。即使某一次 Redis 锁出了问题乐观锁也会拦截掉并发扣减保证库存不会被打成负数。我在压测环境里模拟过主节点宕机、业务流量持续的故障场景纯 Redis 锁时超发量大约有 0.2% 的波动加了乐观锁兜底后这个数字直接归零。代价是乐观锁在并发冲突高时会导致一部分 update 失败、需要重试或提示用户但因为分布锁已经拦截了大部分并发这个代价在实际生产中可以忽略不计。3.4 锁粒度设计锁商品还是锁订单粗细怎么划分分布式锁的粒度设计直接决定系统的并发能力和稳定性。拿库存系统来说你有两种锁法锁全局比如stock:lock:all所有商品的扣减共抢一把锁实现最简单但并发能力极低一件商品的扣减会阻塞所有其他商品的扣减请求按商品粒度锁比如stock:lock:{productId}每个商品有自己独立的锁互不干扰并发能力大幅提升。我的经验是尽量把锁粒度细化到业务的最小操作单元上。库存扣减的最小单元是单商品所以锁 key 按 productId 生成是最合理的。如果你的业务里存在批量扣减比如一次下单包含多个商品那就得引入多把锁、控制加锁顺序或者设计一个合并锁 key 的方案。比如把商品 ID 排序后拼接成一个字符串再加锁这样可以避免多个请求互相持有对方等待的锁造成死锁。另外锁的粒度也要考虑 Redis 集群的分片。如果你用的是 Redis Clusterkey 哈希槽是根据 key 字符串计算的不同商品的锁 key 会分配到不同的分片节点上天然分散了锁的读写压力。如果所有商品都用同一个 key 做锁所有锁请求都会打到同一个分片上这个分片会成为性能瓶颈同时还可能导致整个集群数据倾斜。所以设计锁 key 时尽量让自己业务里的不同维度能自然分布。4. 分布式锁实战中的坑与排查经验4.1 锁过期导致业务漏出的真实案例和修复方案有一次上线新活动后我收到告警说某个商品的库存扣减量超过了配置的库存总量。查日志发现一个诡异的现象扣减逻辑实际执行时间最长的一次达到了 12 秒而锁的 TTL 只设了 5 秒。业务在锁还剩下的最后几秒里执行了一次耗时很长的 GC日志显示 GC 暂停导致整个线程停顿了 7 秒锁早就到期了其他线程拿到锁又开始跑同一段逻辑。那次事故后我把所有显式传了 TTL 的锁全部改成了 Redisson 的看门狗模式同时加了一个业务超时监控。只要发现锁内执行时长超过 3 秒就打点报警。改完之后类似的漏出问题再没出现过。这里也分享一个判断技巧如果你的业务逻辑里几乎没有外部依赖本地纯内存计算和数据库单条更新显式 TTL 就够了如果涉及外部 HTTP 调用、消息队列发送、分布式事务哪怕你说概率很低也请务必用看门狗或者把 TTL 调到峰值耗时的 3 倍以上别拿概率赌线上稳定性。4.2 Redis 主从切换导致锁丢失的兜底策略关于 Redis 主从切换丢锁网上也有很多讨论真实环境里确实会发生。有一次我们压测一个秒杀活动专门做了故障演练在流量高峰期手动把 Redis 主节点 kill 掉让哨兵自动切换。结果监测到有大约 0.3% 的请求出现了两个线程同时进入临界区的情况也就是超发。那次演练让我意识到纯靠 Redis 锁保底不现实必须有多级防护。我采用的方案就是前面讲的数据库乐观锁兜底。如果你不想在代码里写乐观锁也可以考虑让 Redis 走 RedLock 算法在多个独立的 Redis 节点上分别加锁超过半数成功才算加锁成功。但 RedLock 在业内争论很大我自己的判断是工程上实现复杂、运维成本高、收益有限不如数据库层兜底来得直接有效。还有一个小技巧给锁 key 的 value 加上当前节点的机器名和线程 ID排查问题的时候通过 Redis 客户端查看 value能直接定位到是哪个节点、哪个线程持锁在故障复盘时非常有用。4.3 锁内做远程调用的性能灾难与替代方案我踩过的另一个大坑是把外部 RPC 调用放进了锁里。当时业务方要求下单成功后打电话通知客户我图省事把通知逻辑直接写到了扣减库存的锁内结果锁的持有时间从几毫秒变成了几百毫秒系统吞吐量直接掉了一个量级。后来的改造方案是锁内只做必要的库存预占和订单状态初始化然后把后续的短信通知、优惠券发放、积分累计全部丢进消息队列异步消费。锁的服务时间回到几十毫秒水平吞吐量恢复正常。这背后其实是一个更通用的原则锁要短跨界操作要出锁。凡是能在锁外做的就不要拖到锁里凡是能异步做的就不要同步阻塞在临界区里。另外要注意一个问题锁内的 Redis 操作和业务数据库操作不要在同一个事务里混用。因为 Redis 锁的释放和数据库事务提交之间天然存在先后顺序问题。如果先释放锁再提交事务其他线程拿到锁后可能读到未提交的旧库存如果先提交事务再释放锁极端情况下锁一直持有会导致其他线程长时间阻塞。我常用的顺序是业务操作完成后先提交事务再在 finally 里释放锁。这样其他线程拿到锁时看到的已经是新数据了。4.4 常见问题速查表症状根本原因处理办法库存变成负数并发请求同时通过库存判断在扣减 SQL 里加 stock 0 条件配合分布式锁串行化锁长期不释放接口一直阻塞业务抛异常后没有释放锁用 try-finally 确保 unlock 执行锁生命周期不要超过一次请求请求量上来后 Redis 连接被打爆锁的获取和释放频率过高连接池太小提高连接池大小降低锁粒度避免把远程调用放锁内服务重启后锁还留在 Redis 里没有设置过期时间使用 SET NX EX 语法或 Redisson 的默认看门狗锁偶尔失效出现超发Redis 主从切换导致 key 丢失数据库乐观锁兜底或评估是否引入多节点 RedLock线程 A 释放了线程 B 的锁释放前没有校验持有者身份使用 Lua 脚本比较 value 后删除不要直接 DEL锁内代码误删其他业务数据锁 key 命名过于宽泛比如全部商品共用一个锁将锁 key 细化到资源维度例如按 productId 拆分秒杀接口吞吐量极低锁内包含大量耗时操作缩短临界区范围耗时的操作改消息队列异步处理4.5 选型建议与我的最终判断如果你现在的系统规模还不大单机 Redis 加 Redisson 完全够用不需要为了分布式锁单独引入一套 Zookeeper 集群。只有当你的业务对一致性要求极高、且能接受 ZAB 协议带来的性能开销时Zookeeper 临时顺序节点方案才值得考虑。Zookeeper 的锁语义清晰、无过期困扰、天然可重入但是性能比 Redis 低了一个量级而且运维组件越多故障面越大。从我个人的最终实践来看稳定方案是Redisson 分布式锁做第一道防线数据库乐观锁做最终保底锁 key 精确到商品维度临界区只保留必保操作释放锁时务必校验持有者身份。这套组合在多次大促和秒杀活动中经受住了考验没有出现一次超发事故。分享一个更实战的经验上线前用压测工具模拟 500 并发同时抢 10 个库存商品观察库存是否正确归零、订单是否没有超额生成、Redis 锁 key 是否全部释放。这个场景能一次性把文章里提到的绝大多数问题暴露出来。我在团队里要求每次涉及库存扣减的代码变更都必须先跑通这个并发压测用例再提测。如果你正在被超发问题折磨别急着自己造轮子把上面这套方案跑一遍很多问题会在压测阶段就露出马脚而不是等到大促当夜再来开作战室复盘。
返回列表