分布式锁避坑指南:从锁粒度到事务边界的正确设计
最近一个同行跟我吐槽说他们系统上线没多久就出了事故订单金额对不上账库存超卖了几百件。几个后端排查了两三天Redis 正常、锁也加了、业务逻辑翻来覆去看了好几遍愣是没找到原因。后来有人提了一句“你们的锁粒度是不是有问题”他回去仔仔细细一查当场就不说话了。类似的故事我在不同场合反复听过最后几乎都能追溯到几个非常隐蔽的设计盲区上。这些坑往往和“锁没加对”没直接关系反而集中在锁的粒度、释放时机、事务边界、锁误删、过期时间这些细节上。今天这篇就把这块掰开揉碎聊透从粒度设计到释放时机从 Redis 锁到数据库锁再到一个很多团队都中招的事务边界错位问题。看完你应该能少踩几个很贵的坑。分布式锁解决的核心问题一句话就能说清让多个节点在访问同一个共享资源时彼此排队避免同时写入或读写交错导致的数据不一致。单体应用时代一个synchronized或数据库行锁就能搞定大多数并发问题但系统一旦拆成多个服务实例多个进程同时跑JVM 内部的那套锁就管不住跨进程访问了。这时候就需要一个所有节点都能看见的“公共门卫”——也就是分布式锁。很多人一提分布式锁第一反应就是 Redis 的setnx或者 Redisson 的getLock。确实Redis 锁是目前最普遍、性能和并发能力最平衡的方案但它的坑也最多。我见过太多项目里锁是加了可加锁的粒度、锁的过期时间、释放锁的方式、锁与事务的边界这些细节全都没人认真想过。结果就是表面上所有请求都拿到了锁数据该错还是错。下面我按实际踩坑频率从高到低讲每个点都会说清楚“为什么错”和“怎么改”。2. 锁的粒度没想清楚锁等于白加2.1 粒度太粗并发能力被锁死锁粒度这个坑在业务系统里极其常见因为业务模型天然是分层的。比如一个商家下有多个店铺一个店铺下有多个商品。很多人加锁图省事直接用商家 ID 做锁的 key锁的范围就一下子覆盖了整个商家下面的所有店铺和商品。这样做最直接的问题是本来 A 店铺和 B 店铺是两个完全独立的资源完全可以并行处理结果因为锁粒度太粗A 店铺的写入把 B 店铺的写入也堵住了系统的吞吐量直接被打骨折。极端情况下一个商家下面挂了几百个店铺每秒钟几百个请求全在排队等一把锁这个接口的 QPS 直接从几千掉到几十。更隐蔽的问题是锁粒度太粗会放大死锁和长事务的风险。锁覆盖范围一大持锁时间必然变长因为每个请求要处理的共享数据变多了。而持锁时间越长锁过期、锁误删、事务回滚这些连锁反应就越容易触发。我见过一个案例某项目给“用户维度”加锁结果这个用户同时操作了订单、优惠券、积分三个模块三个模块都去抢同一把用户锁操作顺序稍有不慎就互相等待最后全部超时用户体感直接崩溃。所以设计锁的第一步永远是问自己我到底在保护哪个共享资源这个资源的唯一标识是什么粒度还能不能再细一点2.2 粒度太细交叉死锁找上门但锁粒度也不是越细越好。如果把锁的 key 设计到“订单的每一行明细”这个级别每个明细单独一把锁那就要小心新问题了。比如一个请求要更新订单的明细 A 和明细 B另一个请求要更新明细 B 和明细 A两个请求各自拿到其中一把锁然后互相等对方手里的另一把锁就形成了交叉死锁。这个问题的本质是所有细粒度锁方案的共同风险多个资源之间的操作顺序如果不统一就可能在资源上加锁的顺序上产生环路。解决方式也简单——所有需要多个细粒度锁的场景必须约定一个全局唯一的加锁顺序比如按明细 ID 升序加锁或者干脆把粒度合并到“订单级别”来避免交叉依赖。锁粒度设计本质是在“并发度”和“一致性”之间找平衡点。最理想的状态是锁的粒度刚好覆盖住需要保护的那份共享数据不多也不少。但现实中很多人根本没考虑过这个平衡锁 key 随手一写事后线上出问题才开始排查。提示设计锁 key 时先问问自己“这个 key 能唯一标识我保护的数据吗两个完全不相干的请求会因为这个 key 互相阻塞吗”如果两个答案里有一个是“不”那这个粒度就有问题。3. 释放锁的时机比加锁更讲究3.1 锁释放和事务回滚的顺序颠倒很多人以为加锁之后只要记得删 key 就行了其实释放锁的时机比加锁更讲究。最经典也最致命的坑是锁还没释放事务就回滚了。举个典型场景你在 Service 方法里先加锁然后调用一个事务方法更新数据库事务方法抛了异常事务回滚了而锁的释放代码写在finally里照样执行。表面上看锁最终释放了没问题对吧但实际情况是锁释放之后另一个请求立刻抢到锁开始读数据。此时前一个请求的事务虽然回滚了但它在事务里做的修改可能已经被其他线程看到了。这取决于数据库的隔离级别。如果隔离级别是读已提交回滚前的修改对其他事务不可见问题还不算大但如果隔离级别不够严格或者你在这中间还写了缓存回滚前的脏数据可能已经被写进 Redis 了那就真的麻烦了。正确的顺序永远是先让事务提交完成再释放锁。锁的释放必须放在事务提交之后不能放在finally里跟事务回滚逻辑混在一起。这个顺序一旦搞反轻则数据不一致重则直接触发线上事故。我自己后来总结了一条原则锁的成功释放意味着被保护的数据已经处于最终一致状态。所以在释放锁之前必须确保所有对该数据的修改都已落库、提交或回滚完毕。3.2 锁的过期时间是双刃剑Redis 锁如果设置了过期时间一旦业务执行时间超过锁的过期时间锁会自动失效。此时另一个请求就能拿到锁两个请求同时操作同一份数据从而产生经典的“锁失效导致并发写入”问题。很多人为了解决这个问题会把过期时间设得很长比如五分钟、十分钟。但这样做有副作用如果持有锁的节点突然宕机了其他节点要等锁过期才能重新获取这段时间系统等于进入了“单点阻塞”状态。更麻烦的是如果持有锁的节点还活着但执行特别慢锁过期后别的节点进来了两边的数据就会开始打架。Redisson 有看门狗机制会自动给锁续期但它也有局限。如果持锁线程长时间不释放看门狗会一直续下去锁永远不会过期别的节点就永久等锁。所以过期时间设计要么让业务执行时间远小于锁的超时时间要么引入可靠的续期机制同时业务代码里加一个超时熔断防止单次执行时间超出锁有效期。我自己的习惯是加锁时先估算业务的最长执行时间然后过期时间设为这个估计值的 2 到 3 倍同时配上续期兜底双保险。比如一个业务最长预计 200 毫秒跑完锁的过期时间就设 500 毫秒到 1 秒再用看门狗续期做兜底。千万别把过期时间拍脑袋设成 10 分钟。3.3 锁误删A 删了 B 的锁锁误删也很高频而且非常隐蔽。用 Redis 的setnx加锁时如果 A 请求加锁成功但因为某些原因持锁时间超过了过期时间锁自动过期了此时 B 请求加锁成功接着 A 请求终于执行完了它拿着自己之前的锁标识去释放锁结果把 B 请求的锁给删了。相当于 B 在干活的时候A 把 B 的“门牌”摘了第三个请求 C 也能进来了。三个请求同时操作同一份数据数据怎么可能会对得上。解决方式其实很简单加锁时生成一个唯一的随机值UUID 或雪花 ID释放锁前先比对 value 是否一致一致才删除。这个逻辑在 Redisson 里是内置的但很多手写 Redis 锁的代码里经常直接del key了事完全没做 value 校验。注意如果你还在手写 Redis 锁先问自己三件事——锁有没有唯一 value释放前有没有校验有没有续期机制三个答案有一个“没有”那我建议你直接换 Redisson别拿生产环境练手。4. 锁边界和事务边界错位导致一致性崩盘4.1 Transactional 和手动加锁的天然矛盾这个问题在 Spring 的Transactional场景下极其常见但很多人从来没意识到它有多严重。很多人写代码时把加锁写在方法里事务注解也加在方法上看起来万事大吉。但实际问题在于Spring 的Transactional是基于代理实现的事务在方法调用前后开启和提交而锁是在方法内部手动加的。这就可能产生一个可怕的时序锁在方法内部释放了事务却还没提交。如果锁在事务提交前释放下一个请求拿到锁读到的还是旧数据或者在极端隔离级别下读到未提交数据数据不一致照样存在。你以为加了锁实际上锁根本没保护住事务提交那一刻的数据。怎么判断你的代码有没有这个问题很简单的自查办法看你的方法里锁释放代码和事务提交代码的相对位置。如果锁释放代码写在finally里而事务是由 Spring 注解在方法返回后才提交的那几乎可以确定你的锁边界和事务边界是错位的。4.2 正确做法把锁放到事务边界的上层要解决这个问题最稳妥的办法是别手动在业务方法里加锁而是把锁的获取和释放放到事务边界的上层。比如在一个独立的 Service 方法里先开启事务再获取锁处理完业务后先提交事务再释放锁。Spring 的TransactionTemplate可以帮你精确控制这个顺序比直接堆注解靠谱得多。下面是一个简化的示例结构public void executeWithLock(String orderId) { // 1. 先获取分布式锁确保当前只有一个线程能进来 String lockKey lock:order: orderId; boolean locked redisLock.tryLock(lockKey, 5000, TimeUnit.MILLISECONDS); if (!locked) { throw new BizException(系统繁忙请稍后重试); } try { // 2. 在事务模板里执行业务逻辑事务提交发生在 transactionTemplate 回调结束之前 transactionTemplate.execute(status - { // 这里执行真正的业务读写数据库 orderService.updateOrderStatus(orderId); // 事务会在这个回调返回后提交 return null; }); // 3. 锁释放前事务已经提交完成数据处于最终一致状态 } finally { redisLock.unlock(lockKey); } }这个代码的顺序是先加锁再用TransactionTemplate开启事务事务提交完成后才在finally里释放锁。锁释放的这一刻事务已经提交完毕别的请求拿到的必然是最新数据。在代码评审里如果发现Transactional和手动加锁写在同一个方法里我基本上都会提醒对方重新审视边界顺序。这个坑因为太隐蔽很多资深开发都中过招。5. Redis 锁和数据库锁各有哪些隐藏限制5.1 Redis 锁性能好但极限场景有隐患Redis 锁的优势很明显性能高、实现简单、跨语言支持好。但它有几个隐藏限制在高并发场景下会暴露出来。第一个是前面提到的锁失效问题。Redis 锁依赖过期时间过期时间短了业务没跑完锁就没了过期时间长了持有锁的节点宕机后等待恢复的时间也长。Redisson 的看门狗能自动续期但也只解决了一半问题——如果持锁线程长期不释放看门狗会一直续期其他节点永远拿不到锁。第二个问题是 Redis 锁依赖 Redis 的可用性。如果 Redis 发生主从切换锁的记录可能在新主节点上不存在旧的锁就形同虚设。Redisson 的 RedLock 算法就是为这个场景设计的但它也有自己的争议和复杂度。绝大多数业务系统用普通 Redisson 锁就够了只有对一致性要求极高的金融级场景才需要考虑 RedLock。第三个问题是连接成本。高并发下大量请求同时获取锁Redis 的连接数会迅速攀升如果连接池配置不足锁获取本身就容易成为瓶颈。我见过一个项目锁的等待时间飙升最后发现是 Redis 连接池被占满了而不是锁本身有问题。5.2 数据库锁没有过期问题但性能天花板低数据库锁最常用的方式是SELECT ... FOR UPDATE。它利用数据库自身的事务机制保证同一行记录只能被一个事务修改。数据库锁最大的优点是没有过期时间问题事务提交或回滚时锁会自动释放天然规避了 Redis 锁的自动过期和续期难题。但数据库锁的短板也很明显性能上限不高。特别是在高并发场景下大量事务同时竞争同一行锁数据库连接池容易被打满系统整体响应时间会迅速恶化。用数据库锁的时候最容易踩的坑是锁范围过大和持锁期间干慢活。比如在一个事务里先SELECT ... FOR UPDATE锁住了一张订单表的一行然后在事务内部去调一个远程接口或者做一个非常耗时的计算那这把锁的持有时间会被无限拉长其他节点全部堵在数据库连接池里最后把连接池耗尽。我之前见过一个案例在事务里调用外部支付回调回调超时数据库连接全部堆积在同一行锁上整个服务直接雪崩。所以使用数据库锁有一条铁律加锁之后所有操作必须快、必须短任何涉及网络 IO 或长耗时逻辑都不能放在锁内执行。数据库锁更适合对一致性要求高、并发量相对可控的场景比如管理后台的审批操作、财务对账任务。纯面向 C 端的高并发接口还是优先考虑 Redis 锁。6. 几乎没人注意的读写冲突、可重入与公平性6.1 分布式锁不仅防写写冲突更要防读写冲突很多团队设计锁的时候脑子里只有一句话两个写操作不能同时发生。但分布式锁真正要防范的远不止写写冲突还有读写冲突。假设一个请求正在更新订单状态另一个请求正在读取订单状态。如果读请求没有加锁它可能在写请求尚未提交时读到中间状态导致展示数据不一致。更危险的是如果读请求拿到了数据之后要做后续处理比如基于读取到的订单金额去计算佣金那最终算出来的可能就是一个错误值。所以判断一个操作要不要加锁不能只看它是不是写操作还要考虑这个读操作会不会读到“半成品”数据读完会不会基于这份数据做进一步写入如果答案是“会”那读操作也必须加锁或者走乐观锁方案。6.2 可重入性同一把锁自己卡死自己分布式锁如果设计成不可重入那同一个线程持有锁的情况下再次请求加锁会直接把自己阻塞住形成死锁。比如一个方法加了锁内部又调用了另一个加了同样锁的方法如果锁的实现不支持可重入第二次获取锁就会失败线程直接卡死。这种问题在代码分层比较深的时候特别容易出现你根本想不到自己会在锁里调用一个带锁的方法。Redisson 的锁默认是可重入的但如果你手写 Redis 锁用setnx实现那基本就是不可重入的。这个问题在实际项目中踩的人太多了所以我见到的团队基本都换成了 Redisson而不是自己造轮子。6.3 公平性先到真的先得吗Redisson 默认的锁是非公平的也就是说后到的请求可能插队先获取锁。对大多数业务来说非公平锁足够用了因为公平锁需要维护一个等待队列性能开销会明显增加。但在一些对顺序有严格要求的场景比如抽奖活动、秒杀抢购需要保证先到先得那你就得自己实现公平锁或者选择支持公平锁的组件。我做活动类项目时一般会在需求评审阶段就把“是否要求公平性”这个问题问清楚因为它直接决定了锁方案的选择。团队内部没对齐这个需求上了线才发现顺序乱套那改起来真的非常痛苦。7. 还有锁的监控很多故障其实可以提前发现分布式锁相关的故障很多时候其实不是因为锁本身有问题而是因为系统里根本没有锁的监控指标。你不知道锁的平均等待时间多少持锁时间多少超时次数多少那就只能等故障爆发了才去排查被动又痛苦。我在排查线上故障时第一件事永远是看锁的监控数据如果锁的平均等待时间突然飙升说明系统里存在大量锁竞争要么锁粒度太粗要么并发量超过了预期。如果持锁时间经常接近超时阈值说明锁的过期时间设计不合理或者业务在锁里干了太多不该干的活。如果某个 key 上的锁请求次数异常多说明这个 key 的粒度设计可能太粗或者热点过于集中。给锁加上监控和告警很多时候比优化锁本身更能提前发现隐患。锁的监控要抓三个核心指标请求锁的次数、等待锁的时长、持锁的时长。围绕这三项再做告警基本就能覆盖掉绝大多数分布式锁相关的故障场景。8. 一套可以直接照抄的分布式锁设计清单每次上线涉及分布式锁的系统我都会拿下面这张清单逐条过一遍。这套清单不是理论而是我在多个项目里反复对照后固定下来的习惯。第一锁的 key 设计必须能唯一标识被保护的那份共享数据。尽量细粒度但不同资源的锁不能形成交叉依赖。如果拿不准就列出所有并发请求访问的资源逐一确认锁不会互相阻塞。第二锁的范围必须覆盖完整的“读改写”逻辑。不能出现只给写操作加了锁、读操作裸奔的情况。第三锁的释放必须晚于事务提交。如果用的是 Spring 事务建议用 TransactionTemplate 精确控制顺序不要用 Transactional 和手动加锁混在一起。第四锁必须有超时熔断机制。不能依赖锁的过期时间作为唯一兜底因为过期时间太大宕机恢复时间就长过期时间太小业务没跑完锁就没了。第五使用 Redis 锁必须验证锁的 value 是唯一随机值防止误删别人的锁。这一条手写锁代码里最常被忽略。第六锁的持有期间不能做任何网络 IO 或长耗时操作。这条对数据库锁尤其关键Redis 锁也一样只是在 Redis 上表现不明显容易被忽略。第七锁方案必须支持可重入。否则嵌套方法调用会死锁这是最不好排查的故障之一。第八锁的监控指标必须接入至少包括等待时间、持锁时间、超时次数。监控不是可选项是你提前发现隐患的眼睛。这套清单的每一项背后都是一个真实项目里踩出来的教训。很多时候问题不是出在技术选型上而是出在那些“看起来理所当然”的细节上。如果你在项目里也遇到过类似诡异的并发问题不妨回头对照一下上面这几点看看是不是哪条被忽略了。