
做过几年并发编程的人基本都绕不开一个灵魂拷问锁到底该加在哪里、加到多细。刚工作那会儿我处理一个订单导出功能为了省事直接在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%也就是P0.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 先回答三个问题再做决定在设计锁的粒度时我一般会先回答三个问题答完基本就有方向了临界区里到底在做什么如果里面有数据库IO、远程调用、文件读写这些耗时操作优先考虑把这些操作移出临界区而不是纠结该加粗锁还是加细锁。锁里只保留必须原子执行的几步其他统统挪出来。这份共享数据被多少并发线程一起读写如果并发度极低那锁粗一点反而简单可靠如果高并发就必须按数据维度拆锁否则后续开发维护的成本会高到失控。多个变量之间是否必须保持统一原子性如果一次业务操作必须同时更新A和B那么A、B要么共用一把锁要么必须约定严格的加锁顺序不能想当然地各拆各的锁。这三个问题都不用看代码先把业务逻辑画清楚就能回答。画出必须原子执行的最小集合之后锁的粒度其实已经浮出水面了。3. JVM里的锁粒度演进从重量级到自适应3.1 synchronized锁升级JVM帮你做代价自适应很多老教程还在讲synchronized是重量级锁性能差要用Lock替代。这个观点放在JDK 8之后已经不完全对了。现在的synchronized有锁升级机制会根据竞争情况在偏向锁、轻量级锁、重量级锁之间切换偏向锁只有一个线程反复进入临界区时几乎没有加锁成本相当于无竞争锁。轻量级锁出现少量竞争时采用CAS自旋尝试抢占不需要立刻挂起线程适合临界区执行时间很短的场景。重量级锁竞争激烈时才升级为操作系统级别的互斥锁线程会阻塞唤醒代价最大。这说明JVM本身就在做代价自适应竞争不激烈时让锁尽可能轻竞争激烈时才切换到重量级。所以别再纠结synchronized和ReentrantLock谁更快在低竞争的绝大多数场景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读读可以并发写写、写读互斥。适用于读多写少的场景比如配置缓存、门店信息等数据用读写锁包裹能明显降低读操作的竞争概率。Semaphore和CountDownLatch这类工具更值得聊。它们本质上已经把锁从互斥资源变成了许可控制的并发上限粒度从一个变量只能一个人改扩展为一个资源池最多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个独立的锁对象 StripedLock 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; -- 如果typeALL或者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除了锁住level5的记录还会锁住相邻索引区间防止其他事务在区间内插入新的level5数据。这意味着你以为自己在做细粒度行锁实际上右边的线程只要碰附近区间的插入操作也可能被阻塞。这也是为什么我建议高并发更新的核心表尽量用主键或唯一索引精准定位目标行业务上需要批量更新时可以用分段批量提交而不是一把大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 StripedMySQL单行更新主键定位行锁InnoDB行锁(走索引)分布式按业务资源互斥按SKU/userId等维度拆isolated lock keyRedis Redisson5. 锁粒度匹配的决策框架与真实优化案例5.1 五步决策法从临界区到压测验证聊了这么多原理落地的时候我有一套固定的五步决策法每次新接口涉及并发控制都会走一遍画临界区把业务操作涉及的所有共享变量、缓存、数据库行列出来标出哪些操作必须原子执行其他操作全部移出临界区。评估并发度是单机并发还是多机并发预期QPS是多少竞态窗口是毫秒级还是秒级并发度和粒度密切相关并发越低越不需要激进拆锁。选锁类型单机用synchronized或ReentrantLock多机用Redis分布式锁或数据库乐观锁。这个选择的前提是知道数据一致性要求有多高——允许最终一致甚至可以用乐观锁加大促重试。按数据维度拆分优先按业务主键userId、skuId、orderId拆分锁让不同实体的操作互相不干扰。如果不能拆分再看是否可以用乐观锁或者队列化解决。压测验证这个步骤多数人会省掉。锁粒度到底合不合理不能靠感觉要压测。用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和压测数据留档之后线上出性能问题可以直接对照。并发编程这条路上没有一劳永逸的答案但有一整套可以复用的判断方法这篇文章算是我自己的一份沉淀。