☰
明明加了锁却还是超卖?分布式锁的边界与事务时序陷阱
2026/10/17 3:43:57 网站建设 项目流程

生产环境凌晨一点,某电商团队的库存模块突然出现超卖。所有人第一反应是分布式锁没加好,排查了半天却发现锁的日志一切正常:同一个商品ID,加锁、释放记录完全成对出现,Redis里也没看到异常。可数据就是对不上,少了几十件库存,账单也差了数。这种“明明加了锁,高并发下数据还是对不上”的问题,我估摸着绝大多数后端开发都遇到过,而且八成以上的人一开始根本没想明白问题出在哪。

这个现象常见的程度,甚至高过空指针。很多人习惯性把分布式锁当成“加了就安全”的万能保险,却忽略了锁只是协议中的一个约束,它保护的是“访问顺序”,而不是“数据最终结果”。一旦锁的边界、粒度和业务事务没有对齐,锁就形同虚设。这篇文章不绕理论,直接拆几个我在实际项目里踩过的、以及帮别人排查过的典型场景,说清楚这些坑到底是怎么埋进去的,以及怎么一步步定位和解决。适合正在做订单、库存、支付、积分、活动等高并发对账场景的开发者,也适合那些被线上数据不一致问题折磨了一个礼拜的人。

1. 先搞清楚分布式锁的边界:它到底保护了什么

很多人对分布式锁的认知就一句话:同一时刻只允许一个线程进入临界区。这句话本身没错,但它掩盖了一个更关键的问题——锁保护的临界区到底包裹了哪些代码、哪些数据、哪些操作。边界错了,互斥就变成了一场自嗨。

1.1 锁的职责是互斥,不是一致性

分布式锁在真实系统里做且只做一件事:让多个进程/线程对同一个共享资源串行访问。它完全不负责数据的正确性。数据正确性是业务代码和数据库约束的事,锁只是给你创造一个“串行操作同一个东西”的窗口。

我见过很多团队把分布式锁当成事务的替身:以为锁住了就能防止超卖、防止重复记账、防止状态错乱。实际上锁保证的是“你的业务逻辑没有并发交错执行”,但如果你业务逻辑本身有问题,比如先查再扣不是原子的,或者落库时缺少约束,那么即便串行了,数据依然可以是错的。

举个例子,一个发送积分的接口,逻辑是“先查询用户当前积分,再增加100”。如果把锁加在查和增之间,确实两个线程不会同时操作同一用户。但如果这个逻辑本身没有做幂等,调用两次就是会加两次积分。锁只是让两次调用“先后执行”,不会合并成一次。所以记住:锁解决并发交错,不解决重复和错误。幂等,是另一层独立的安全网。

1.2 锁的key划分决定了你的并发安全边界

锁的key是所有问题的起点。同一个资源,如果不同的调用方用了不同的key,那这两个调用之间就完全不会互相排斥。

我见过一个比较典型的错误:订单创建服务里用lock:order:{orderId},库存扣减服务里却用了lock:stock:{skuId},两个服务本来要一起保护“下单扣库存”这个整体操作,却分别锁了不同的东西,结果订单和库存数据对不上,查半天锁日志全是绿的。

更隐蔽的是粒度问题。比如一个商品有10个SKU,理论上应该锁SKU维度。但如果锁的是商品维度,确实互斥范围变大了,不会出问题,只影响性能;反过来,如果明明要锁商品维度,却错误锁到了分类维度,那同一分类下不同商品ID之间完全不互斥,超卖就这么来了。

这里我提供一个判断标准:锁的key应该等于业务互斥的最小资源范围。如果你要扣的是某个SKU的库存,key里必须带上SKU ID;如果你要防止的是某个用户在多个设备上重复提交订单,key里就必须有用户ID和业务类型。key的范围大于业务范围,最多损失性能;key的范围小于业务范围,直接丢数据。

1.3 带租约锁过期是“自我保护”,也是“定时炸弹”

Redis分布式锁几乎都会设置过期时间,这是为了防止持有锁的线程崩溃后死锁。但过期时间本身就是一把双刃剑:如果过期时间设短了,而锁内的业务操作超过了这个时间,锁会自动释放,另一个线程就会趁虚而入,此时两个线程同时在跑同一段业务逻辑。

很多开发者的处理方式是把过期时间设置得很长,比如5分钟,觉得“业务再慢也不可能超过5分钟”。可一旦遇到GC停顿、Full GC、网络抖动、外部接口超时重试,5分钟也可能扛不住。更现实的问题在于,过期时间设太长,意味着持有锁的线程如果真崩溃了,其他线程要等满5分钟才能进来,这在故障场景下是不可接受的。

所以正确的做法不是调大过期时间,而是引入续租机制,或者选用已经实现了续租的客户端。这类问题本质上是“锁的生命周期”和“业务生命周期”没有对齐,后面第3部分我会专门展开。

2. 五个让锁“白加”的高频场景,个个都踩过

这一节直接给场景,每一个都是我见过的真实线上事故。读的时候可以对照自己项目里的代码,看看有没有中枪。

2.1 本地锁误当分布式锁:单机代码搬上微服务

很多单体项目里习惯用synchronized或者ReentrantLock做并发控制,这在单机部署时是有效的。一旦服务从一台扩到多台,本地锁立刻失效,因为不同JVM里的锁互相不可见。这时候如果没意识到要换成分布式锁,高并发下重复执行、超卖、重复消息消费就全来了。

这种问题最坑的一点是开发环境很难发现,单机测试一切正常,测不出问题。一上生产多实例就跑出脏数据。我在排查这种问题时,第一步永远是问“服务部署了几个节点”,如果节点数大于1,看代码里有没有synchronized或者本地Lock直接保护共享资源,只要出现,基本可以断定是失效的。

2.2 不统一的锁key设计:业务各自为政

这种情况同样常见,尤其是在微服务架构里。团队A在订单服务里用lock:order:{orderId}做幂等,团队B在支付回调里用order:{orderId}:pay做防重。两个服务都在防同一笔订单的重复支付,但key根本不不一样。结果回调并发进来时,A的锁拦不住B的入口,就会有两笔支付流水同时处理。

这里的教训是:分布式锁的key必须在团队间、服务间达成统一约定,最好沉淀成内部的公共配置或工具类。不同团队不能各自发明命名规则,否则互斥就是空谈。我建议项目中严格要求:凡是涉及同一资源的并发控制,必须使用同一个Redis key模板,并且key的生成逻辑收口到一个公共方法里,不允许在业务代码里手工拼接。

2.3 无界锁与超时释放的矛盾

有一类团队不喜欢给锁设置过期时间,逻辑是“业务执行完才释放锁,没执行完就不该放”。这种方案看着安全,但实际上只要线程在持锁期间发生长时间GCFull GC、断网、或者被kill掉,锁就会永远卡在Redis里,其他线程全部阻塞,形成全局死锁。生产环境里最怕的就是这种“无界锁”,出故障时连恢复手段都很有限。

正确做法是锁必须有过期时间,但过期时间不能靠拍脑袋。一个实操经验是:先根据业务最大耗时评估出一个安全阈值,比如正常业务最短耗时5秒、最大耗时15秒,那就设置成30秒,留一倍余量。同时尽量使用支持看门狗续期的方案,让锁的有效期能随业务执行动态延长。Redis客户端Redisson默认的watchdog就是干这个的,但要不要用、怎么用,必须结合自己的业务场景来判断,不能盲目依赖。

2.4 锁内做长事务:持锁时间失控

这个场景在高并发下非常致命。有人为了让“锁”和“事务”保持一致,把整个事务方法包在锁里,甚至把很多无关的远程调用、第三方API调用也放进锁内。结果一个线程持锁执行了30秒,Redis锁过期了,另一个线程冲进来读取了未提交的数据,或者重复执行了扣减逻辑。

长事务的本质问题是事务提交时机被拉长,而锁的过期时间不会等你。解决思路是尽量缩短临界区,把远程调用、外部接口、消息发送这些耗时操作移出锁外,只让真正的数据操作留在锁内。如果业务确实需要长时间持锁,那就要配套续租机制。

2.5 锁内嵌套外部调用:锁的“微操”失败

这个坑和长事务类似,但更隐蔽。它不是一个事务拖了很久,而是锁内代码调用了外部系统,外部系统又反过来调用本系统的接口。如果调用链路中某个环节因为网络抖动超时,锁内线程还在傻等,Redis锁却已经释放了。

我排查过一个案例:加锁后调用短信服务发送验证码,短信服务超时重试了三次,每次30秒,锁早已过期,但业务线程还在等。此时同一用户的另一个请求拿到锁进来了,两个请求在同一个用户维度并发执行。解决这类问题的方法很粗暴但有效:锁内不允许出现无超时限制的阻塞调用。所有外部请求必须设置明确的超时时间,并且不允许无限重试。

2.6 小结:从这些场景提取共性

看一圈下来会发现一个规律:所有“白加”的锁,本质上都是锁的生命周期和业务生命周期错位。要么锁的key兜不住业务的资源范围,要么锁的持有时间短于业务的实际执行时间,要么锁释放的时机早于数据落库的时间。理解了这条共性,后面排查问题就只需要围绕一个方向:锁到底有没有覆盖到业务真正需要保护的那段时间线和那部分数据范围。

3. 锁与事务的“先后之谜”:最隐蔽的坑

如果说前五个场景是“锁没锁对地方”,那这一节要聊的是“锁锁对了,但还是出问题”的情况。这也是标题里那个问题最核心的答案之一,值得单独拆开讲。

3.1 到底谁先结束?事务提交和锁释放的时序

很多业务里,锁和数据库事务是配合使用的。常见写法有两种。第一种是把锁放在事务方法外面,比如在一个门面方法里先加锁,再调用一个@Transactional方法,业务完成后解锁。这种写法通常不会有问题,因为被事务方法的方法在返回时就已经提交了,锁在事务提交之后才释放。

真正出问题的往往是第二种写法:锁被包在事务方法内部。比如:

@Transactional public void processOrder(Order order) { String lockKey = "stock:sku:" + order.getSkuId(); String lockValue = UUID.randomUUID().toString(); boolean locked = redisLock.tryLock(lockKey, lockValue, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException("系统繁忙,请重试"); } try { // 扣减库存、创建订单、记录流水 stockService.deduct(order.getSkuId(), order.getQuantity()); orderService.create(order); logService.record(order); } finally { redisLock.unlock(lockKey, lockValue); } }

这段代码从表面上毫无问题,锁拿到了、业务做了、finally里释放了。但仔细想时序:Spring的事务代理在方法返回后才会提交事务。也就是说,当方法执行到最后一行解锁时,事务还没有提交。锁一旦释放,另一个线程立刻可以拿到同一把锁并进入这段逻辑。此时第一个线程的数据库事务还在半空中。第二个线程去查询库存,可能查到的还是扣减前的旧值,或者要更新的数据被第一个事务锁行,形成锁等待甚至死锁。

这个窗口期就是数据对不上的根本原因之一。你不是没加锁,而是锁释放的时机比事务提交早了那么一步,高并发下这一步就会被无限放大。

3.2 正确处理方式:把解锁动作挪到事务提交之后

要解决这个问题,核心原则一句话:锁的释放必须发生在事务提交之后。具体实现有几条路,各有利弊。

第一条路:把锁放到事务方法外面,撬动事务代理的提交顺序。

public void processOrder(Order order) { String lockKey = "stock:sku:" + order.getSkuId(); String lockValue = UUID.randomUUID().toString(); boolean locked = redisLock.tryLock(lockKey, lockValue, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException("系统繁忙,请重试"); } try { orderTxManager.processOrderInTx(order); } finally { redisLock.unlock(lockKey, lockValue); } }

这里orderTxManager是独立的Spring Bean,它的processOrderInTx方法带有@Transactional。Spring的代理机制会在这个方法返回后提交事务,然后才执行外层processOrder方法里的finally释放锁。这个方案实现简单,维护成本低,也是我比较推荐的方式。前提是注意不要让orderTxManager被同类内部调用,否则事务会失效。

第二条路:通过事务同步机制在提交后释放锁。

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { redisLock.unlock(lockKey, lockValue); } });

这种方式把解锁动作注册成事务提交后的回调,保证锁释放一定晚于事务提交。缺点是代码侵入性略强,而且如果项目里不属于Spring的@Transactional管理,比如自己手写JDBC事务,这个方案就不适用。

第三条路:干脆不要用声明式事务,改成手动事务,把commit放在解锁前。虽然可行,但会让代码显得很沉重,除非确实有复杂的事务边界需求,否则不推荐。

这个“锁必须比事务晚释放”的原则,我建议直接写进团队的代码规范里,比事后排查省一百倍时间。

3.3 Redisson 看门狗能解决一切吗?

很多读者看到这会想:我用Redisson的lock方法,它自带看门狗续期,是不是就没这个问题了?这里得泼一盆冷水。看门狗解决的是“锁过期时间不够”的问题,它不解决“锁释放先于事务提交”的问题。

Redisson默认的watchdog会每隔一段时间把锁的过期时间续到30秒,只要持有锁的线程还活着,锁就不会自动释放。这避免了业务没跑完锁就过期的情况。但如果你在事务方法内部加锁,方法执行完时finally里主动调用了unlock,那么看门狗续再多期也没用,锁依然会在事务提交前被手动释放。所以,看门狗是续命工具,不是时序工具。

另外,Redisson的tryLock如果传了leaseTime,看门狗机制就不会启用。很多人以为传了leaseTime反而更安全,实际上这意味着锁在指定时间后一定会被强制释放,不管业务有没有结束。这个细节非常容易踩,务必注意:只有不传leaseTime的lock()或者传leaseTime = -1时才启用自动续期。

还有一个隐藏问题:主从部署下的锁丢失。Redis主节点写入锁成功,但数据还没同步到从节点,主节点宕机,从节点晋升为主节点,此时新的主节点上并没有这把锁的记录,另一个线程就能加锁成功。分布式锁在Redis主从架构下不是绝对安全的,RedLock方案理论上能解决,但实现复杂、争议也多,一般业务如果可接受微小概率的数据不一致,大多数人还是会选择普通锁加幂等兜底。这里不展开,但你得知道这个隐患的存在。

4. 组合拳:锁 + 幂等 + 唯一约束 + 对账

锁只是第一道防线,一个健壮的高并发系统不能只有一个防线。我看到的那些真正稳定运行的系统,几乎都是锁、幂等、唯一约束、对账四层一起上的。

4.1 幂等:分布式锁最好的朋友

锁可以保证同时只有一个线程在跑,但它不能保证这个线程只跑了一次。比如前面的例子,即使锁完全正确,消息队列因为消费者处理超时而重新投递,同一个请求被执行两次,第一次成功,第二次也成功,数据就多算了一遍。锁在这个时候帮不上忙,它管不了“同一个请求重复到达”。

幂等设计的本质是:无论一个操作被执行一次还是多次,最终状态一致。最简单的幂等实现是给每个请求分配一个唯一业务ID,处理前先查这个ID是否已经存在,存在就直接返回成功。更稳妥的方式是结合数据库唯一索引来防重,第4.2节会讲。

需要特别提醒的是,很多人把Redis锁当成幂等手段,用setIfAbsent判断请求是否已处理。这在小规模场景下没问题,但如果锁过期了、或者缓存被清理,重复请求还是会穿透进去,所以真正的幂等应该落在数据层,而不是缓存层。

4.2 唯一键约束:数据库层面的最终防线

Redis锁再强,也会有失效的可能。数据库唯一索引是最后一道物理防线,而且它不需要依赖任何分布式组件,天然具有全局唯一性。

比如防止重复支付回调处理,可以在支付流水表上建(order_id, payment_status)的唯一索引,或者直接对order_id建唯一索引。并发请求进来时,不管Redis锁有没有拦住,数据库层面也只会有一条记录插入成功,其他的会抛出DuplicateKeyException,业务捕获后当作重复处理即可。

这个方案的优点很突出:数据库的约束不依赖网络、不依赖缓存、不怕锁过期。缺点是不够灵活,不是所有场景都能用唯一索引表达。比如“同一用户同一秒只能领取一次优惠券”,唯一索引能表达;但“同一用户最多领取3张”,就需要配合其他设计。不管怎样,只要场景允许,我都会优先考虑加唯一索引作为兜底。

4.3 对账与补偿:系统最后的安全网

即便有了锁、幂等、唯一约束,分布式系统依然可能在极端情况下产生不一致。比如Redis主从切换丢锁导致两个线程同时扣库存,又或者数据库死锁导致某个事务回滚,但消息队列已经通知了下游。

所以线上系统必须有对账和补偿机制。常见做法是:核心业务操作落流水表,定时任务扫描流水表,比对订单状态、库存流水、支付记录之间的数据是否一致,发现差异就触发补偿修复。

这一步看起来不“高级”,但它是最后一个安全网。我参与过的所有生产级电商系统,几乎都有一个对账任务在默默跑。分布式系统做不到绝对的强一致,但通过补偿可以达到最终一致。这不是理论,是工程常态。

5. 排查实录:锁有没有生效,五步定位

文章写到这里,如果你已经被线上问题折磨过一段时间,下面的内容会更接近实际。这是一套我反复用过的排查思路,按照这个顺序做,大概率能在半小时内锁定问题方向。

5.1 第一步:确认锁的key与业务资源是否严格对齐

先检查所有并发入口访问同一个资源时,用的锁key是不是同一个。最简单的方法是把生产环境里Redis的key按前缀拉出来看,比如keys "lock:*"(生产环境慎用,量大时不要这么干,可以用scan),检查是否存在多个不同风格的key覆盖同一资源。也可以在代码里全局搜索加锁工具类的调用处,把每个锁的key模板列出来,人工对比。

如果同一个资源出现了两种不同的key模板,那问题大概率出在这里。

5.2 第二步:检查锁释放与事务提交时序

这是最容易被忽视的一环。打开代码文件,看清楚锁是在事务方法内部还是外部释放的。如果是方法内部,看看解锁动作是在finally里还是事务同步回调里。如果你的项目里锁包在事务方法内,或者锁写在事务注解方法里且手动释放,那就要高度怀疑时序问题。

确认手段其实也简单:在解锁前后各打一条日志,同时在事务提交的回调里也打一条日志,对比线上日志的时间戳,立刻就能看出锁释放是不是先于事务提交。

5.3 第三步:看锁续期与过期是否触发

去Redis里看锁的剩余有效时间和实际持锁时长。如果打印日志里记录了加锁时间和解锁时间,而两者之差接近锁的过期时间,那就说明锁可能已经在业务完成前被自动释放了。这时要做的是引入续租机制,或者把过期时间适当调大,但调大不是长久之计,最终方案还是续租加缩短临界区。

5.4 第四步:压测复现,观察并发窗口

静态代码看不出问题时,就上并发压测。用一个多线程脚本模拟N个并发请求同时操作同一资源,并在业务代码里埋点记录锁获取时间、临界区进入时间、事务提交时间、锁释放时间。对比日志会发现即便代码逻辑看着没问题,某些请求的锁释放时间和事务提交时间之间依然存在间隙。

这种复现不一定每次都触发,但并发线程数越大,触发概率越高。一旦能复现,把日志时间线拉出来,问题就很直观了。

5.5 常见问题速查表

我整理了一张速查表,方便对照排查:

现象可能原因验证方法解决方向
多实例下数据不一致用了本地锁而非分布式锁检查代码中是否存在synchronized/ReentrantLock保护共享资源替换为分布式锁
同一资源多个key并存锁key设计不统一搜索所有加锁代码,比对key模板统一key生成规则,收口到公共工具类
锁内操作未结束但锁已失效锁过期时间太短查看日志中加解锁时间差与过期时间引入续租机制或缩短临界区
锁释放后数据仍异常锁释放早于事务提交查询事务提交回调日志时间戳把解锁挪到事务提交后,或使用事务同步回调
数据库唯一约束冲突重复请求没被幂等拦住查看数据库duplicate key日志加强业务幂等,捕获冲突按重复处理
主从切换后锁丢失Redis主从异步复制压测主节点故障场景接受极小概率不一致或引入RedLock方案

排查完这一圈,90%的“加了锁数据还是对不上”问题都能找到根因。剩下的10%,基本都是分布式系统中多点协同的偶发性问题,通常就需要对账任务和人工修复兜底了。

这篇文章写的都是我在实际项目里反复踩过的坑和验证过的手段。这些年我最大的体会是:分布式锁从来不是一个拿来即用的保险箱,它是一个对“时间边界”和“资源边界”要求极高的协议组件。你设计的锁,必须精确覆盖到业务真正需要互斥的那段生命周期,且释放时机必须不早于数据落库。锁加上幂等、唯一约束、对账,才算真正组成了一套完整的并发治理体系。遇到线上数据不一致时,先不要怀疑锁没生效,而是先问一句:这把锁锁对了吗?锁的失效时刻,是不是已经晚于了数据的一致性时刻?想明白这两个问题,问题往往就不再是问题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询