ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

分布式锁避坑指南:从锁粒度到事务边界的正确设计

分布式锁避坑指南:从锁粒度到事务边界的正确设计

最近一个同行跟我吐槽,说他们系统上线没多久就出了事故:订单金额对不上账,库存超卖了几百件。几个后端排查了两三天,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 上表现不明显,容易被忽略。

第七,锁方案必须支持可重入。否则嵌套方法调用会死锁,这是最不好排查的故障之一。

第八,锁的监控指标必须接入,至少包括等待时间、持锁时间、超时次数。监控不是可选项,是你提前发现隐患的眼睛。

这套清单的每一项,背后都是一个真实项目里踩出来的教训。很多时候问题不是出在技术选型上,而是出在那些“看起来理所当然”的细节上。如果你在项目里也遇到过类似诡异的并发问题,不妨回头对照一下上面这几点,看看是不是哪条被忽略了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进