锁粒度优化实战:从synchronized到分布式锁的取舍与设计
2026/9/16 1:38:45 网站建设 项目流程

做过几年并发编程的人基本都绕不开一个灵魂拷问:锁到底该加在哪里、加到多细。刚工作那会儿我处理一个订单导出功能,为了省事直接在service方法上甩了一个synchronized,结果压测一上来吞吐量直接躺平,所有请求排成一条长队。后来学乖了,把锁拆细,结果又踩了死锁的坑。这两次教训让我明白一个事:并发编程里的锁从来不是越细越好,也不是越少越好,关键在锁的粒度和程序设计的逻辑边界是否匹配。这篇文章就把我这些年在这里面踩过的坑和总结的方法一起聊透,从单机JVM到数据库再到分布式,一次讲清楚。

1. 锁粒度失衡的两种现场:粗锁拖垮性能,细锁拖垮大脑

1.1 粗粒度锁的代价:全局互斥的"单行道"

先看一个最典型的反面教材。很多同学刚接触并发编程时,习惯这样写:

@Service public class UserPointsService { public synchronized void addPoints(Long userId, int points) { // 1. 查询用户信息 // 2. 计算新的积分值 // 3. 更新数据库 // 4. 写操作日志 } }

synchronized加在方法上,等价于用当前对象作为锁,也就是整个UserPointsService实例同时只能有一个线程进入。你想想会发生什么:用户A领积分,用户B签到,用户C兑换商品,本来完全不相关的三笔操作,就因为都路过了这个service,全部串行执行。更致命的是临界区里有数据库查询、积分规则计算、日志写入,这些耗时的IO操作全都在锁内完成,单位时间内能处理的请求数量自然惨不忍睹。

这时候可以用Amdahl定律来算一笔账。加速比公式是Speedup = 1 / [(1 - P) + P / N],其中P代表可并行部分的比例,N代表处理器核心数。如果串行比例是10%,也就是P=0.9,那么即使核心数无限大,理论上限也只有10倍加速。换句话说,一把粗锁带来的串行化瓶颈,会直接锁死你的水平扩展能力。我在实际项目里遇到过最夸张的情况:一个每日结算接口在晚上高峰期,TPS从2000掉到200,线程dump一看,几十个线程全部BLOCKED在同一把锁上。

粗粒度锁的典型表现其实很好辨认,我整理了一张自查表:

症状背后原因
TPS不随线程池增大而提升,甚至下降锁竞争成为唯一瓶颈
线程dump出现大量BLOCKED线程全局互斥导致线程排队
CPU使用率不高但接口RT持续走高线程都在等待锁,没有真正执行
单个请求耗时正常,但整体吞吐很低锁持有时间过长,串行化比例偏高

粗锁的问题是显性的,性能一压测就能发现。真正麻烦的是第二种——细粒度锁。

1.2 细粒度锁的真正风险:锁边界与业务边界错位

很多人以为锁越细越好,于是想尽办法把一把大锁拆成很多把小锁。但拆锁一旦拆不对,问题比粗锁更隐蔽。

最常见的坑是死锁。比如一个账户转账场景,你为了减少锁冲突,给每个账户单独设置一把锁:

public void transfer(Long fromAccount, Long toAccount, BigDecimal amount) { synchronized (accountLock.getLock(fromAccount)) { synchronized (accountLock.getLock(toAccount)) { // 扣减fromAccount,增加toAccount } } }

线程A执行A→B转账,先锁A账户再锁B账户;线程B执行B→A转账,先锁B账户再锁A账户。结果两个线程各持一把锁,互相等待对方释放,直接死锁。这不是理论推演,我在代码评审里见过太多次这种写法了。解决方式无非是约定所有线程按同一个顺序(比如按账户ID排序)获取锁,但这个约束一旦写到代码里,后期维护的人不一定知道,很容易在某个角落里又写出一段破坏顺序的逻辑。

另一种更隐蔽的问题是原子性被破坏。假设一个用户有两份数据:账户余额和积分,你为了"细粒度"给余额和积分分别配了锁。某个操作需要同时扣减余额和积分,你先后获取了两个锁。但在两次加锁之间,另一个线程可能已经读取了中间状态,看到余额扣了但积分没扣,业务一致性直接崩了。这个后果比性能下跌严重得多。

所以一定要理解:锁的粒度不是指代码里new了多少个Lock对象,而是锁保护的"共享变量集合"有多大。粒度匹配的本质,是让锁的边界和程序逻辑要求原子性的边界对齐。没有对齐的细粒度锁,本质上是在用复杂度换正确性,不值当。

2. 粒度匹配的本质:锁的边界与程序逻辑的边界对齐

2.1 锁真正解决的三个问题:互斥、可见性、有序性

聊粒度之前,先把锁的语义搞清楚。很多人以为锁只是"互相排斥",其实锁解决的是三个问题:

  • 互斥:同一时刻只有一个线程能进入临界区,这是最直观的。
  • 可见性:持有锁的线程在释放锁之前的所有修改,对后面获取同一把锁的线程是可见的。也就是说,锁不仅是操作串行化,还承担了内存屏障的职责。
  • 有序性:临界区内的代码在持有锁期间不会被重排序到锁外面,编译器、CPU的指令重排要受锁边界的约束。

这就解释了一个常见误区:如果你给每个变量单独配锁,那Java内存模型只能保证"同一把锁"内的可见性和有序性,跨锁的访问没有任何保护。操作A改了余额,操作B读积分,两者用的是不同的锁,即使时间上B在A之后开始,B也完全看不到A的修改。所以只看互斥来设计锁粒度,迟早要出问题。

2.2 一个直观的代价模型:竞争概率、临界区长度与持有时间

锁的粒度设计,本质上是在做一个平衡,代价大概可以拆成三个要素的乘积:

锁的代价 ≈ 竞争概率 × 临界区长度 × 锁持有时间

这三个要素是相互制约的。临界区越长,也就是临界区内代码越多,锁持有时间就越长,并发线程撞上同一把锁的概率自然越大。很多优化手段都是在削减其中某一个要素:缩短临界区(把耗时的IO移出锁)、减少锁持有时间(用乐观锁配合重试)、降低竞争概率(按业务维度拆分锁)。

说个生活化的类比。公共厕所的隔间数量是固定的,对应CPU的核心数;每个隔间被占用多久,就是锁持有时间;排队的人有多少,就是竞争概率。如果有人在隔间里刷手机,外面的人就会越排越多;如果隔间太少,人流量一大照样排队。锁的粒度匹配,就是在安排"哪些操作需要独占隔间,哪些操作可以多个资源共用隔间"。

有个和直觉相悖的点:锁对象多并不等于粒度细,锁保护的共享变量集合小才是粒度细。假如你有100个业务主键,但所有主键都用同一个"锁池"里的同一把锁保护,那效果和全局锁没区别。真正的细粒度是把锁和业务主键一一对应,比如“userId为1的线程只和userId为1的线程竞争,不干扰userId为2的线程”。

2.3 先回答三个问题再做决定

在设计锁的粒度时,我一般会先回答三个问题,答完基本就有方向了:

  1. 临界区里到底在做什么?如果里面有数据库IO、远程调用、文件读写这些耗时操作,优先考虑把这些操作移出临界区,而不是纠结该加粗锁还是加细锁。锁里只保留必须原子执行的几步,其他统统挪出来。
  2. 这份共享数据被多少并发线程一起读写?如果并发度极低,那锁粗一点反而简单可靠;如果高并发,就必须按数据维度拆锁,否则后续开发维护的成本会高到失控。
  3. 多个变量之间是否必须保持统一原子性?如果一次业务操作必须同时更新A和B,那么A、B要么共用一把锁,要么必须约定严格的加锁顺序,不能想当然地各拆各的锁。

这三个问题都不用看代码,先把业务逻辑画清楚就能回答。画出"必须原子执行的最小集合"之后,锁的粒度其实已经浮出水面了。

3. JVM里的锁粒度演进:从重量级到"自适应"

3.1 synchronized锁升级:JVM帮你做代价自适应

很多老教程还在讲synchronized是重量级锁,性能差,要用Lock替代。这个观点放在JDK 8之后已经不完全对了。现在的synchronized有锁升级机制,会根据竞争情况在偏向锁、轻量级锁、重量级锁之间切换:

  • 偏向锁:只有一个线程反复进入临界区时,几乎没有加锁成本,相当于"无竞争锁"。
  • 轻量级锁:出现少量竞争时,采用CAS自旋尝试抢占,不需要立刻挂起线程,适合临界区执行时间很短的场景。
  • 重量级锁:竞争激烈时,才升级为操作系统级别的互斥锁,线程会阻塞唤醒,代价最大。

这说明JVM本身就在做"代价自适应":竞争不激烈时,让锁尽可能轻;竞争激烈时,才切换到重量级。所以别再纠结synchronizedReentrantLock谁更快,在低竞争的绝大多数场景,synchronized已经够用,而且代码更简洁。当然,JDK后续版本逐步废弃偏向锁,也是因为高并发服务化场景下锁竞争普遍激烈,偏向锁的大部分收益已经被运行时代价抵消,JVM的选择本身也在随负载模型变化。

3.2 Lock与AQS:显式控制持有时间和等待策略

ReentrantLock和AQS的价值不在于"比synchronized快",而在于能显式控制持有的粒度。比如tryLock()可以限时等待,避免线程无限期阻塞:

Lock lock = lockContainer.getLock(userId); boolean acquired = lock.tryLock(200, TimeUnit.MILLISECONDS); if (!acquired) { throw new BizException("系统繁忙,请稍后重试"); } try { // 这里只放必须原子执行的操作 doUpdate(userId); } finally { lock.unlock(); }

这种写法等于给锁的持有时间画了一条红线:最多等200毫秒,等不到就直接失败返回或者走重试。比傻等一把全局锁健康得多。另一个典型的粒度拆分是ReentrantReadWriteLock,读读可以并发,写写、写读互斥。适用于读多写少的场景,比如配置缓存、门店信息等数据用读写锁包裹,能明显降低读操作的竞争概率。

SemaphoreCountDownLatch这类工具更值得聊。它们本质上已经把"锁"从互斥资源变成了"许可"控制的并发上限,粒度从"一个变量只能一个人改"扩展为"一个资源池最多n个人并发访问"。比如限流场景下限制某接口同时最多10个线程执行DB查询,用Semaphore就比用互斥锁合理得多。

3.3 从ConcurrentHashMap看拆锁的正确姿势:按数据维度拆分

并发容器的演进是最好的教材。ConcurrentHashMap在Java 7时用分段锁,把整个Map分成16个Segment,写操作只锁对应的Segment,不同Segment可以并发写,粒度已经从"整个Map"降到了"一个段"。Java 8进一步抛弃分段锁,改为对单个桶(bucket)加锁:桶内冲突少时用CAS,冲突激烈时对桶的头节点加synchronized。粒度又降到了"单个桶"。

这个过程的本质,是按照数据的哈希维度拆分锁。实践中我们做本地锁也有同样的思路,比如用Guava的Striped锁,按业务主键的哈希值分片:

// 1000个分片,实际上有1000个独立的锁对象 Striped<Lock> stripedLocks = Striped.lock(1000); Lock lock = stripedLocks.get(userId % 1000); lock.lock(); try { // 按userId维度操作的临界区 } finally { lock.unlock(); }

同一个用户的操作会命中同一个分片,不同用户大概率命中不同分片,并发吞吐比全局锁提升了几十倍。但注意分片数量不能拍脑袋定,太少了退化成粗锁,太多了锁对象本身的维护成本和碰撞概率的收益会边际递减。

4. 分布式与数据库场景:粒度失控的隐形重灾区

4.1 MySQL行锁变表锁:索引失效直接放大锁粒度

单机锁写明白之后,再看数据库和分布式场景。这里有个更隐蔽的粒度陷阱,行锁分分钟会退化成表锁。

InnoDB默认是按索引定位记录加锁的。问题在于,如果更新语句的WHERE条件没有索引,InnoDB就只能扫描全部记录才能确认要更新哪些行,这时候行锁会扩大到整张表的所有记录,等价于表锁:

-- user_name列上没有索引,你以为只锁一行,实际锁了全表 UPDATE user_points SET points = points - 200 WHERE user_name = 'zhangsan';

线上排查时,我是用两条语句验证的:

EXPLAIN SELECT * FROM user_points WHERE user_name = 'zhangsan'; -- 如果type=ALL或者rows字段特别大,说明没有走索引

这种从"行锁到表锁"的放大,本质上就是锁粒度在某条SQL里突然失控。业务高峰期出现大面积更新超时、死锁报错,多半就是这类语句在作祟。解决办法很简单:让WHERE条件走主键或唯一索引。高并发更新场景建议直接用主键ID定位单行,别在非索引字段上做更新过滤。

4.2 间隙锁与范围锁:锁粒度不仅体现在行上

还有人把行锁理解为"只锁住命中的那一行",这也是错的。InnoDB在REPEATABLE READ隔离级别下,范围操作会加间隙锁(Gap Lock)和临键锁(Next-Key Lock),锁住的不是一个点,而是一个区间。

举个例子,你按非唯一索引的level字段更新一批用户:

UPDATE user_points SET points = points + 100 WHERE level = 5;

如果level不是唯一索引,InnoDB除了锁住level=5的记录,还会锁住相邻索引区间,防止其他事务在区间内插入新的level=5数据。这意味着你以为自己在做"细粒度行锁",实际上右边的线程只要碰附近区间的插入操作,也可能被阻塞。这也是为什么我建议高并发更新的核心表,尽量用主键或唯一索引精准定位目标行,业务上需要批量更新时可以用分段批量提交,而不是一把大SQL扫全表。

这里提个小建议:线上高并发更新场景,如果批量更新确实躲不开范围条件,宁可拆成多个单行/主键更新语句分批次执行,把锁的控制权拿回自己手里,也别让InnoDB偷偷把一个区间锁死。

4.3 Redis分布式锁:锁的key要匹配资源维度而不是方法维度

再往前一步,到了分布式场景,锁粒度最容易出错的地方是Redis分布式锁的key设计。

我见过不少项目,整个库存服务只有一把锁,key就叫inventory_lock。结果不同SKU的扣减全部互斥。用户A买手机,用户B买电脑,两个毫无关联的购买请求,被同一把分布式锁排着队执行。这是典型的"锁key的资源维度"搞错了——锁对应的是"方法"而非"数据单元"。

正确的做法是让锁key与业务资源维度一一对应:

lock:stock:sku_1001 lock:stock:sku_1002

这样手机和电脑的库存扣减就能并行,只有同一SKU的并发扣减才需要排队。我在实际项目里用Redisson做类似控制时,习惯把锁key的命名规范写进团队编码规范,因为分布式锁一旦上了线,锁key散落在代码各个角落,没有规范的话排查起来特别痛苦。

另外要注意锁的过期时间。很多同学直接setnx key value ex 5,然后业务逻辑跑了8秒,锁提前过期了,两个线程同时进了临界区。这不是粒度问题,是持有时间边界没控制好。我的做法是用Redisson的看门狗机制做自动续期,或者在锁key里带上业务维度的唯一标识,释放时用Lua脚本校验是不是自己加的锁,防止误删别人的锁。

到这里可以横着对比一下不同层次锁的粒度选择:

场景推荐锁粒度典型工具
单机方法级短临界区synchronized/ReentrantLockJDK自带锁
单机读多写少读写锁ReentrantReadWriteLock/StampedLock
单机按数据维度并发分片锁/Striped锁Guava Striped
MySQL单行更新主键定位行锁InnoDB行锁(走索引)
分布式按业务资源互斥按SKU/userId等维度拆isolated lock keyRedis Redisson

5. 锁粒度匹配的决策框架与真实优化案例

5.1 五步决策法:从临界区到压测验证

聊了这么多原理,落地的时候我有一套固定的五步决策法,每次新接口涉及并发控制都会走一遍:

  1. 画临界区:把业务操作涉及的所有共享变量、缓存、数据库行列出来,标出哪些操作必须原子执行,其他操作全部移出临界区。
  2. 评估并发度:是单机并发还是多机并发?预期QPS是多少?竞态窗口是毫秒级还是秒级?并发度和粒度密切相关,并发越低,越不需要激进拆锁。
  3. 选锁类型:单机用synchronizedReentrantLock,多机用Redis分布式锁或数据库乐观锁。这个选择的前提是知道数据一致性要求有多高——允许最终一致,甚至可以用乐观锁加大促重试。
  4. 按数据维度拆分:优先按业务主键(userId、skuId、orderId)拆分锁,让不同实体的操作互相不干扰。如果不能拆分,再看是否可以用乐观锁或者队列化解决。
  5. 压测验证:这个步骤多数人会省掉。锁粒度到底合不合理,不能靠感觉,要压测。用JMeter或wrk跑几个线程数梯度(比如50/100/200),对比TPS和RT变化,如果TPS出现明显拐点或者RT随线程数暴涨,说明锁的粒度还需要调。

5.2 优化实例:优惠券发放从全局锁到用户维度锁

说一个我实际做过的优化案例。商城大促时发优惠券,规则是每个用户只能领取一次,券库存有限。最初的实现是:

public void grantCoupon(Long userId, Long couponId) { synchronized (this) { // 检查用户是否已领取 // 扣减券库存 // 记录用户领券明细 } }

这把全局锁把所有用户的领券请求全部串行化。压测100并发时,TPS只有300左右,用户端频繁超时。当时我做的优化分两步:

第一步,把全局锁换成用户ID维度的分片锁,不同用户之间不再互相等待:

Lock lock = stripedLock.get(userId); lock.lock(); try { // 检查用户是否已领取 // 扣减券库存 // 记录用户领券明细 } finally { lock.unlock(); }

第二步,把券库存的扣减改成数据库乐观锁,核心SQL只做条件更新:

UPDATE coupon_stock SET stock = stock - 1 WHERE coupon_id = #{couponId} AND stock > 0;

通过影响行数来判断是否抢到库存,并发冲突时快速失败。改造之后,100并发下TPS提升到2200左右,RTT也从濒临超时降到了100毫秒内。这个案例的核心,不是用什么高大上的技术,而是把锁的粒度从"方法维度"降到了"用户维度",同时把库存竞争用乐观锁的方式移到数据库内部去做原子判定。

5.3 排查锁粒度问题的几个实用手法

最后分享几个排查锁粒度问题的实战手法,都是我踩过坑之后沉淀下来的:

线程dump是最直接的手段。线上接口变慢,先jstack抓一下线程状态。如果大量线程处于BLOCKED状态,而且都卡在同一个锁对象上,那基本可以断定锁粒度过粗了。重点看两个信息:一是阻塞线程数量占线程池的比例,二是锁对象对应的业务代码在哪个类哪个方法上。

用Arthas或JFR做热点分析。我习惯用Arthas的thread命令看线程栈,配合JFR的锁竞争事件,能精准定位到"哪把锁竞争最激烈、持有时间最长"。数据比感觉可靠得多,别靠猜就改锁的粒度。

锁key和锁对象的命名规范很重要。分布式锁的key如果取名混乱,线上排查的时候根本不知道这个key保护的是哪份资源。我现在的规范是lock:业务域:资源类型:资源ID,本地锁也通过工厂方法获取,禁止到处new Object()当锁对象。

别为了无锁而无锁。有段时间我一看到synchronized就想优化成CAS。结果在高竞争场景下,CAS自旋会大量消耗CPU,比互斥锁更差。CAS适合竞争很低的场景;竞争激烈时老老实实用锁,配合锁粒度优化,比强行无锁有效得多。

写到这里,我的核心感受其实是:锁粒度这个问题,表面看是并发编程的技术题,深层看是业务原子性的理解题。把业务边界画清楚了,锁的粒度自然就浮现出来。每次改动锁的粒度,我都会顺手把线程dump和压测数据留档,之后线上出性能问题可以直接对照。并发编程这条路上没有一劳永逸的答案,但有一整套可以复用的判断方法,这篇文章算是我自己的一份沉淀。

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

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

立即咨询