ARTICLE DETAIL

资讯详情

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

高并发库存争抢实战:分布式锁与强一致方案解析

高并发库存争抢实战:分布式锁与强一致方案解析 想当年做秒杀系统那会儿压测一上量库存就超卖数据库直接被拖垮大半夜被电话叫醒排查问题的场景到现在都记忆犹新。一个“在高并发下把库存扣对”的需求背后牵扯出的强一致性问题逼着我把分布式锁从原理到落地整个啃了一遍。今天就把这些实战经验和踩坑记录整理出来希望能帮到正在处理类似问题的朋友。标题里说的“强一致锁”其实我们日常讨论的就是分布式锁在临界区保护中的强一致语义。库存争抢本质上是一个典型的并发写冲突场景多个请求同时读库存、判断库存足够、执行扣减任何一个环节出现交错就会出现超卖。而解决这个问题的核心不在于“锁住操作”而在于“锁要一致、锁要可靠、锁要能兜底”。库存争抢的技术本质并发、竞争与一致性1.1 扣减库存到底难在哪里先看一个最简单的超卖场景。两个用户同时下单商品库存只剩1件。请求A读取到库存为1请求B也读取到库存为1A扣减库存变为0B基于自己读取到的旧值1再次扣减库存变成-1超卖发生。问题根子在于“读-判断-写”这个复合操作不是原子的。哪怕你的数据库是MySQL如果用先查再更新这种两步操作天然存在时间窗口。很多人第一个念头是那我给这行记录加行锁不就行了没错SELECT ... FOR UPDATE确实能在单库场景解决但一旦碰到高并发数据库的行锁竞争会让InnoDB的锁系统不堪重负尤其是热点行——所有请求都打在同一个SKU上锁等待和上下文切换会瞬间飙升。1.2 强一致锁要解决的不是“锁”而是“谁先谁后”我们换个角度来看“库存争抢”。在并发环境下系统真正需要保证的是同一时刻只有一个请求能够进入“扣减库存”这个临界区。至于进入之后是先到先得还是按优先级排队那是业务策略问题但“只有一个请求能改库存”这个底层约束就是强一致锁要提供的能力。这也解释了为什么我们要引入分布式锁。单机场景下synchronized或者JVM的ReentrantLock就能搞定但微服务架构下多个服务实例共享同一个数据库线程锁只对本地线程生效A机器上的锁管不住B机器上的代码。这时必须有一个所有服务实例都能访问的第三方协调者——Redis、ZooKeeper、etcd或者数据库本身——来充当“裁判”确保全局只有一个线程能拿到锁。1.3 性能与一致性的博弈为什么不能全用数据库锁可能有人会问既然数据库本身就能提供行锁为什么还要费劲搞一套分布式锁直接UPDATE inventory SET stock stock - 1 WHERE stock 0不就行了这个方法确实能防超卖而且性能也不错因为它是单条原子SQL。但问题在于真实业务很少只是一个“库存减1”的操作。比如订单创建需要同时校验优惠券、锁库存、生成订单号、记录流水这些操作分布在不同的表甚至不同的服务里没法用一条SQL覆盖。那就必须把“锁库存”作为独立事务的一部分在整个业务操作期间持有锁数据库锁的粒度就太重了。Redis分布式锁的价值就在这里。它把锁的粒度缩小到一个业务操作锁的持有时间由业务自己控制而不是让数据库事务一直占着行锁。理想状态下扣减库存用原子操作业务状态流转用分布式锁保护两者结合既保证了强一致又把数据库的压力降下来。常见方案盘点乐观锁、悲观锁与Redis锁2.1 数据库乐观锁CAS思路在库存扣减中的应用乐观锁的核心是CASCompare And Swap。实现上就是在库存表加一个version字段更新时校验版本号是否匹配。UPDATE inventory SET stock stock - #{buyCount}, version version 1 WHERE sku_id #{skuId} AND version #{oldVersion}如果影响行数为0说明version已经被其他请求改过当前请求需要重试或返回失败。这个方案的好处是简单、无锁等待坏处是并发冲突高时重试率飙升用户体验差。而且它只能解决“扣减”这个动作的一致性如果后续业务流程失败需要回滚还得额外做补偿。2.2 数据库悲观锁select for update的适用边界悲观锁就是SELECT ... FOR UPDATE。同一行数据在同一时间只会被一个事务持有锁其他事务必须等待。这个方案适合并发量不大但强一致要求极高的场景比如财务对账、最后一口价的稀缺商品。在秒杀这种几十万QPS的流量下它基本撑不住。我实测过一个纯数据库行锁的扣减接口2000并发压上去数据库的Threads_running直接飙到几百CPU打满TPS上不去大量请求堆积在锁等待上。2.3 Redis分布式锁从SETNX到Redisson用Redis做分布式锁最基础的就是SETNX命令SET key value NX EX 30。NX表示只有当key不存在时才设置成功EX设置过期时间防止客户端宕机导致死锁。这个方案能解决大部分问题但有个著名的坑锁过期时间不可控。业务执行超过锁的过期时间锁自动释放其他线程就能拿到锁此时两个线程同时进入了临界区强一致被瞬间打破。Redisson框架提供了看门狗机制来解决这个问题。默认锁的leaseTime是30秒如果业务没执行完看门狗会每10秒自动续期一次把锁的生命周期拉长。实现原理就是后台起一个定时任务在锁快过期时检查当前线程是否仍持有锁是就执行PEXPIRE续期。RLock lock redissonClient.getLock(inventory:lock: skuId); boolean locked lock.tryLock(5, TimeUnit.SECONDS); if (locked) { try { // 业务操作校验库存、扣减库存、创建订单 // 即使业务执行超过30秒看门狗会自动续期 } finally { lock.unlock(); } }Redisson的RLock还实现了可重入同一个线程可以多次加锁而不死锁。它底层用Redis的Hash结构记录持有锁的线程ID和重入次数所以比纯SETNX更接近JVM锁的行为。强一致锁方案选型Redis vs ZooKeeper vs 数据库3.1 为什么说Redis锁在绝大多数场景够用但不完美Redis锁最大的优势是性能好。单线程模型、内存操作加锁解锁的耗时在毫秒甚至微秒级别。在高并发库存场景下只要保持锁的粒度小、持有时间短Redis完全能顶住。但它的强一致一直有争议。根本问题在于Redis的主从复制是异步的。如果采用主从架构客户端A在主节点加锁成功主节点还没来得及把锁数据复制到从节点就宕机了此时故障转移发生从节点提升为主节点锁数据丢失客户端B再用同一个key加锁就能成功于是两个客户端同时持有锁安全边界被打破。这个缺陷在严格意义上让Redis锁无法做到线性一致性。业界也有过激烈讨论Martin Kleppmann和Redisson作者之间那场著名的论证核心就是围绕这个问题展开的。3.2 ZooKeeper和etcd的临时顺序节点方案如果业务对强一致的诉求极其苛刻比如资金类操作一般会考虑用ZooKeeper或etcd来实现分布式锁。ZooKeeper的临时顺序节点加watch监听机制可以保证客户端崩溃后节点自动删除锁自动释放避免死锁。InterProcessMutex lock new InterProcessMutex(client, /inventory/lock/ skuId); boolean locked lock.acquire(5, TimeUnit.SECONDS);ZooKeeper锁的可靠性和强一致确实比Redis好但性能有明显差距。每一次加锁解锁都要经过Zab协议的多节点确认延迟在几十毫秒级别在高频扣减场景下扛不住大流量。etcd基于Raft协议实现线性一致性读性能和一致性都优于ZooKeeper但引入一个etcd集群的运维成本也不小。我个人的建议是除非业务明确要求“绝对不能出现两个线程同时持有锁”否则Redis锁在库存场景下已经够用不必为了理论上的完美去增加系统复杂度。3.3 一个被忽视的方案Lua脚本实现原子扣减很多团队在做库存扣减时最终走的是另一条路——Redis Lua脚本。思路很简单把库存预加载到Redis用Lua脚本保证“判断库存足够扣减”这两个操作原子执行。local stock tonumber(redis.call(GET, KEYS[1])) local buyCount tonumber(ARGV[1]) if stock buyCount then redis.call(DECRBY, KEYS[1], buyCount) return 1 end return 0Redis本身就是单线程执行命令Lua脚本又是原子执行所以这段脚本不会出现并发交错的问题。相比分布式锁它没有加锁解锁的额外开销吞吐量更高。缺点是需要额外维护Redis中的库存数据和数据库库存的一致性要处理缓存击穿、库存回补、DB和缓存同步等问题。我做库存类系统时通常的架构是两级配合Redis Lua扛住峰值流量保证前端展示和下单入口不超卖MQ异步通知后端服务更新数据库库存数据库作为最终数据源。锁库存不再是简单“加锁”而是一套完整的“预扣异步对账异常回补”机制。实操演练用Redisson实现一个高可用的库存扣减服务4.1 项目环境与依赖准备这里先说明一下下面这套代码是基于常见实践的补充方案我自己的项目也是这么搭建的你可以根据实际情况调整。环境信息如下JDK 8 Spring Boot 2.xRedis 5.x 以上建议开启AOF持久化appendfsync everysec数据库MySQL 5.7InnoDB引擎依赖redisson-spring-boot-starter 3.16.xdependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.16.8/version /dependency4.2 库存扣减核心代码下面是一段我在真实项目中验证过的代码骨架。校验库存、尝试加锁、执行业务、释放锁每一步都做了异常兜底。Service public class InventoryService { Autowired private RedissonClient redissonClient; Autowired private InventoryMapper inventoryMapper; Transactional(rollbackFor Exception.class) public boolean deductStock(Long skuId, Integer buyCount) { // 1. 最终一致性兜底数据库自己的乐观锁或条件更新确保不超卖 int updated inventoryMapper.deductStockWithCondition(skuId, buyCount); if (updated 0) { return false; } // 2. 业务补偿当需要做复杂业务流转时加分布式锁保护临界区 String lockKey inventory:lock: skuId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { // 拿不到锁说明有其他请求正在处理相同SKU return false; } // 3. 核心业务逻辑创建订单、锁券、扣减库存等 orderService.createOrder(skuId, buyCount); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (locked) { lock.unlock(); } } } }这段代码里有几个关键点要拆开讲。首先我把数据库的乐观扣减放在了加锁之前。这样做的考虑是如果纯粹为了防超卖数据库的条件更新已经能兜底不需要分布式锁。分布式锁真正保护的是后面那些多步骤业务操作。用锁先保证同一时刻只有一个请求在创建订单避免优惠券重复使用、订单数据错乱等问题。其次tryLock的第一个参数是等待时间第二个参数是锁的过期时间。实际项目中等待时间不宜太长3秒比较合理超过就快速失败把流量挡在外面避免线程大量堆积。过期时间则要大于最长业务执行时间如果业务不确定可以不传过期时间让看门狗自动续期——但要注意看门狗机制要求在JVM进程内才能生效如果你的服务是短生命周期脚本建议还是显式设置过期时间。4.3 批量库存扣减的实现细节多商品一次性下单时需要同时扣减多个SKU的库存。这时候加锁的顺序很重要否则会出现死锁。举个简单例子请求A先锁了商品1再锁商品2请求B先锁了商品2再锁商品1。A持有1等待2B持有2等待1两边谁也不让谁最终死锁。解决办法是所有锁按固定顺序获取。比如先把所有SKU排序从小到大依次加锁ListLong skuIds orderItems.stream().map(OrderItem::getSkuId).sorted().collect(Collectors.toList()); ListRLock locks new ArrayList(); try { for (Long skuId : skuIds) { RLock lock redissonClient.getLock(inventory:lock: skuId); // 依次获取所有锁拿不到就快速失败释放已有锁 boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { return false; } locks.add(lock); } // 所有锁都拿到了执行批量扣减 return doBatchDeduct(orderItems); } finally { // 释放锁时按相反顺序释放 Collections.reverse(locks); locks.forEach(RLock::unlock); }这里强调两个实践心得。一是加锁顺序用排序解决这个方案简单有效但要注意排序逻辑必须全局一致不能A服务用升序B服务用降序。二是释放顺序和加锁顺序相反虽然Redis锁本身没有死锁检测但一致地使用这个规范可以大幅降低出错概率。4.4 库存预热与缓存一致性管理高并发场景下把库存直接打在Redis上需要一个完整的缓存管理方案。我采用的思路是活动开始前把数据库库存加载到Redis活动期间所有扣减操作走Redis Lua脚本活动结束后把Redis剩余库存异步写回数据库。加载库存的代码大致如下public void preloadStock(Long skuId, Integer stock) { String key stock: skuId; stringRedisTemplate.opsForValue().set(key, String.valueOf(stock)); } public int deductStockByLua(Long skuId, Integer buyCount) { String script local stock tonumber(redis.call(GET, KEYS[1])) local buyCount tonumber(ARGV[1]) if stock buyCount then redis.call(DECRBY, KEYS[1], buyCount) return 1 end return 0; Long result stringRedisTemplate.execute( new DefaultRedisScript(script, Long.class), Arrays.asList(stock: skuId), buyCount.toString() ); return result null ? 0 : result.intValue(); }这里要特别提醒一个容易踩的坑如果Redis中的库存扣到了0同一时刻大量请求都来查这个key会因为缓存穿透直接打到数据库。解决方案是在扣减失败后做一个本地短时间缓存记录“已售罄”状态比如在本地内存里存一个30秒内有效的标记避免每一个请求都去访问Redis。高并发下的问题排查与避坑指南5.1 锁失效的几大典型场景我在实际运维中遇到过的锁失效归纳起来主要有这几类第一类是主从切换导致锁丢失。前面提过Redis主从复制是异步的主节点宕机后从节点升主锁信息丢失。这个问题在Redis 5.0之后的RedLock方案里得到一定程度的缓解但RedLock本身也有争议。我的建议是对库存这种允许小概率超卖、可以通过对账人工修正的场景直接接受这个风险不做过度设计对资金类的严格场景换用ZooKeeper方案。第二类是锁过期时间设置不合理。业务执行时间波动大设置固定过期时间很容易踩坑。比如大促期间数据库慢查询通常几十毫秒的操作变成3秒如果你把锁过期时间设为2秒锁提前失效另一个线程趁虚而入。解决思路就是前面提到的看门狗自动续期或者在业务代码里显式检测锁是否还在自己手里。第三类是GC停顿导致的锁丢失。JVM发生Full GC时业务线程被暂停如果暂停时间超过锁的过期时间锁就自动释放了。这个问题很难从应用层解决只能尽量优化GC缩短停顿时间或者增大锁的过期时间容忍度。5.2 压测时发现TPS上不去的排查记录之前做压测时遇到一个很有意思的问题加了分布式锁之后TPS反而不如不加锁时直接扣数据库。一开始以为锁竞争太激烈后来查下来发现是锁粒度太粗了。当时的场景是一个订单包含多种商品代码里把同一个用户的整个购物车都用一个锁串起来扣减。用户A和用户B明明买的是不同的商品却因为同一个key互相阻塞。优化方案是把锁的粒度从user维度缩小到sku维度谁买哪个商品就锁哪个商品的key不同商品之间的请求互不干扰。同样是1000并发优化后TPS提升了将近3倍。另一个排查点是用JMeter压测时线程数开得很大但Redis的connected_clients指标飙升连接数打满导致获取连接超时。Redisson默认的连接池大小是64高并发下不够用需要根据压测的实际QPS调整connectionPoolSize和connectionMinimumIdleSize。5.3 一个更加工程化的兜底机制对账与补偿不管用Redis锁还是ZooKeeper锁理论上都可能因为各种极端情况出现不一致。工程上最后一道防线就是对账。我在项目中维护了一张库存流水表每次扣减库存都记录一条流水包含订单号、SKU、扣减数量、时间戳。后台跑一个定时任务把流水汇总和库存表的实际值做比对发现不一致就告警并触发补偿逻辑。这才是我认为的“强一致”的完整含义——锁只是手段对账保证最终一致业务才能睡得着觉。5.4 关于锁的监控与告警最后聊一下监控。分布式锁加了之后不能黑盒运行至少要监控三个指标锁等待时间tryLock获取锁的耗时如果持续偏高说明锁竞争激烈需要考虑缩小锁粒度。锁持有时间实际执行完业务到释放锁的间隔如果经常接近过期时间说明业务逻辑太重或者数据库有慢查询。锁获取失败率tryLock返回false的比例如果超过一定阈值说明流量已经超出系统的处理能力需要限流降级。这些指标用Micrometer接入Prometheus再挂到Grafana上大促期间一眼就能看出系统状态。多说两句回到标题如何解决高并发下的库存争抢问题我的核心心得是没有银弹只有取舍。纯Redis锁性能好但理论上不够强一致ZooKeeper强一致但性能一般数据库锁简单但扛不住大流量。真正的高手是在理解业务诉求之后组合多种方案用锁解决“并发互斥”用乐观锁解决“冲突检测”用异步对账解决“最终兜底”。从压测数据来看我目前的这套Redis Lua Redisson组合方案在单机Redis、8核16G的服务器上扛住每秒3000以上的扣减请求没有压力数据库压力也很小。如果你正在做类似的库存系统建议先从最简单的数据库条件更新做起再逐步引入Redis缓存和分布式锁——不要一上来就上最强方案分布式锁引入的复杂度有时候比它解决的问题还要多。最后再分享一个小技巧在压测时可以故意把库存设置成很小的值比如3件然后用几百个并发去抢观察最终数据库的剩余库存是否精确为0。如果为负数说明你的防御没到位如果为0且没有请求成功基本上锁就是有效的。这个小实验成本极低却能帮你快速验证整套方案的可靠性。
返回列表