1. 锁的分类坐标系在选锁之前先搞清楚几件事几年前我在上线前突然撞上一个现实问题商品详情页显示库存剩5件用户下单买了3件支付成功之后后台查库存却还是5件下单通道继续开着后台一会儿就欠了20多件货。排查到最后两个服务节点各自执行了一遍“先查库存再扣减”的逻辑中间没有加任何互斥两条线程都把对方刚扣完的数据当成了初始值。那是我第一次深刻意识到乐观锁、悲观锁、局部锁这些概念不该只躺在面试题里它们是真能在线上救命的。先说一个很多人没想透的结论乐观锁和悲观锁是一对局部锁跟它俩不是同一维度上的分类。乐观锁与悲观锁回答的是“冲突发生时我是提前防守还是最后补救”局部锁回答的是“这把锁的作用范围到底在哪”。很多人把三者摆在一张表里横向比较其实是拿两种坐标系去套同一个问题越比越别扭。1.1 并发冲突的源头从一次库存覆盖事故讲起要理解锁先看两个线程同时执行一次“读-改-写”。线程A读出库存5线程B也读出库存5A把库存改成2B不知道A改过又把库存改成2一次扣减变成了两次扣减却只减少了一次库存业务上直接表现就是超卖。这也是所有并发控制都要解决的根源问题读写交错且缺乏可见性约束。在数据库里它对应的是脏写、丢失更新、不可重复读一类问题在JVM里它对应的是共享变量的内存可见性和原子性冲突。锁要做的不是消灭并发而是把“读-改-写”这个不可分割的语义重新拼回去让你在一步操作完成之前别人插不进脚。说得再直白一点锁的本质就是给临界区画一条界线让所有想踩进这块区域的线程排好队或者让它们发现“你踩过了”之后自己退回重来。1.2 三个维度看懂锁我平时分析任何锁只看三个维度。互斥方式冲突用阻塞排队解决还是用版本校验解决。前者是悲观锁后者是乐观锁。作用范围锁住的是一个方法、一个对象、一张表、一行数据还是一个独立进程内的共享资源。局部锁讨论的就是这一层。资源形态锁落在数据库行上落在JVM对象头上还是落在某个集中式存储的key上。这决定了分布式锁为什么必须单独拿出来讲。举个例子synchronized是悲观锁的一种典型它活在JVM里SELECT ... FOR UPDATE也是悲观锁它活在数据库里。两者底层机制完全不同但哲学思想一致。所以你在面试中被问到“乐观锁和悲观锁的区别”时先要明白它们是一对“策略”不是一对“API”。具体到实现时才有行锁、表锁、对象锁、分布式锁这些分支。数据库里的锁粒度也要专门补一句MySQL InnoDB 除了行锁还有表锁、意向锁、间隙锁。间隙锁是很多人忽略的元凶它在可重复读隔离级别下为了防止幻读不但锁住命中行还会锁住索引之间的“空隙”。曾经有个同事在订单区间查询时发现并发插入老是被阻塞查了半天才定位到间隙锁。所以当你以为自己在用乐观锁时数据库底层可能已经默默给你上了一把悲观锁——事务里的每个读都有可能在特定条件下产生锁。2. 先锁后改——悲观锁为什么在高冲突下保命2.1 数据库悲观锁的完整链路悲观锁的核心假设很直白在我处理这条数据期间一定有人也在处理它所以我要在动手之前先把路占住。数据库里的悲观锁长这样BEGIN; SELECT * FROM inventory WHERE sku_id 1001 FOR UPDATE; -- 业务代码判断库存充足执行扣减 UPDATE inventory SET stock stock - 1 WHERE sku_id 1001; COMMIT;FOR UPDATE会让数据库对sku_id 1001这行记录加排他锁。从这条SELECT开始到事务COMMIT为止其他想对这行加锁的会话全部卡在锁等待中。注意这个场景必须在事务里执行而且事务要尽快提交否则锁持有时间会拉得很长。排他锁由数据库管理你怎么判断锁到底生效没有打开information_schema.INNODB_TRX和sys.innodb_lock_waits能看到当前活跃事务和锁等待关系。线上排查锁问题基本靠这两张视图比在代码里加日志靠谱得多。2.2 悲观锁为什么能覆盖整段业务判断常见质疑是SELECT FOR UPDATE和 UPDATE 之间隔了好几步业务判断锁不会断掉吗不会。数据库的锁归属到事务事务不COMMIT锁就不释放。也就是说从锁定那一刻开始后续所有步骤都被“包”在锁的遮蔽范围里。这是悲观锁最舒服的用法用一行锁换一段完整的临界区。你在代码里写if (stock 0)判断库存这个判断与最后的扣减操作之间所有并发请求都不可能插进来因为整个事务就像一个屏障。从这里还能得出一个选型启发如果你的业务逻辑特别长中间涉及多次查询、远程调用、人工审核悲观锁绝对要慎用。因为长事务意味着长持有锁后面排队的线程等到超时继而报错。严格来说悲观锁更适合“短、平、快”的临界区比如余额扣减、库存修正、状态流转。2.3 悲观锁的代价阻塞、超时与死锁悲观锁最大的代价是阻塞。一个事务持有锁的时间是50ms后面排了10个事务即使它们操作的数据和当前业务完全不同只要落在同一行上就得排队。阻塞还会引发两个连锁问题。锁等待超时MySQL 默认innodb_lock_wait_timeout是50秒并发一高很容易触发超时回滚。如果业务里还有重试逻辑超时后的重试又会冲击数据库形成恶性循环。死锁事务A锁了行1要等行2事务B锁了行2要等行1两边互不相让。InnoDB 会定期检测死锁发现后强制回滚其中一个事务。你的代码如果不捕获死锁异常用户就会看到一条莫名其妙的报错。另一个容易被忽略的点是索引。FOR UPDATE如果没有走索引InnoDB 只能锁表或锁住更大的范围。我遇到过where条件字段忘建索引的情况并发一上来所有下单请求串行执行接口TPS从800直接掉到20。索引决定了锁的精度这句话建议贴在监控看板上。2.4 JVM 里同样悲观的 synchronized 与 Lock数据库之外单机应用内的共享变量修改也会走悲观路线。synchronized自动加锁、自动释放即使方法抛异常锁也能归还。ReentrantLock多了超时、响应中断等能力适合对锁获取时间有硬指标的代码。我在项目里见过最典型的悲观锁误用是把锁加在方法入口而方法内部80%的时间都在做纯计算根本不碰共享资源。这等于让所有线程在一个计算密集区域外排队毫无意义。悲观锁的持有范围必须尽量小锁完马上释放别让它陪着你走完整个业务流程。3. 不锁资源、锁版本——乐观锁的实现原理与隐藏代价3.1 版本号机制一条 UPDATE 解决超卖乐观锁的经典做法是在表上增加一个version字段每次更新前先读出当前版本更新时把版本号当条件UPDATE inventory SET stock stock - 1, version version 1 WHERE sku_id 1001 AND version 5;如果影响行数大于0说明更新成功因为版本没被别人动过影响行数等于0说明版本已经被改了你需要重新查询当前值再走一遍流程。这是“乐观”的完整表达先放手去干失败了再回头。相比数据库悲观锁版本号方案不需要事务包住整段时间更新和校验由这一条UPDATE语句原子完成。并发量大的时候它不会因为锁排队而阻塞。这也是为什么高并发扣库存场景大多选乐观锁的重要原因。我实际压测过一个性能对比相同硬件上悲观锁方案在100并发时TPS约400乐观锁方案能到1500左右主要差距就来自省去了大量阻塞等待。3.2 CAS 与自旋JVM 层面的乐观锁数据库用版本号JVM 里用的是 CASCompare And Swap。比如AtomicInteger调compareAndSet(expect, update)底层执行一条CPU指令比较和交换在硬件层面是原子的。自旋结构长这样while (true) { int current atomic.get(); int next current 1; if (atomic.compareAndSet(current, next)) { break; } }冲突时就循环重新读值直到CAS成功。这个“重试再试”的结构和数据库版本号机制的上层逻辑是一模一样的只是一个在CPU指令级一个在SQL语句级。在低冲突下CAS几乎零开销在高冲突下大量线程在死循环里空转CPU会被顶到很高。3.3 乐观锁的三处翻车现场乐观锁不是免费的下面三个坑我都在线上踩过。第一个是ABA问题。线程A读到值5线程B把值改成6又改回5线程A再次CAS时发现版本还是5就误以为过程中没人动过。数据库版本号字段因为自增不会回退所以不容易出问题JVM的CAS如果拿对象引用做比较就需要额外加版本标记比如AtomicStampedReference。第二个是重试代价被低估。冲突率10%时大部分请求第一次就能成功重试开销很小冲突率达到30%以上大量失败的UPDATE会持续消耗数据库CPU和IO整体性能反而断崖式下跌。乐观锁真的不适合高冲突场景冲突到了一定阈值还不如直接悲观锁把请求串行化。第三个是版本条件被业务条件干扰。有一次我在订单表上写UPDATE ... WHERE id ? AND user_id ? AND version ?本意是多条件校验结果因为某些历史数据的user_id字段被迁移脚本改过导致该更新的行更新不了兜底逻辑直接炸掉。版本条件的where里只保留主键和版本号业务校验放到代码层去做。4. 乐观锁 vs 悲观锁同一道题的两个解法4.1 完整对比表维度悲观锁乐观锁加锁时机操作前先加锁操作时不加锁更新时校验冲突处理阻塞等待重试或放弃适用冲突率高冲突低冲突响应时间可能被阻塞拖长通常稳定事务要求必须包在事务里单条UPDATE即可完成实现复杂度依赖数据库行锁要自行处理重试逻辑读多写少可能拖累读并发更适合写冲突严重天然排队大量重试对长业务宽容度低锁持有时长难控高无锁窗口死锁概率较高极低强一致体验好需要补偿或重试4.2 选型逻辑冲突概率是唯一判断依据核心判断标准只有一个冲突概率乘以操作成本哪个解法更划算。如果冲突率低乐观锁最合适因为省去了排队开销如果冲突率高到重试概率超过50%悲观锁的排队反而更可预测。还要看操作失败的成本更新失败后重新读取一次再试成本很低可以用乐观锁如果失败之后要把整个业务流程重走一遍那最好别赌运气直接悲观锁。实际工作中还要看数据库负载。乐观锁表面不产生锁等待但大量失败的UPDATE依然会计入数据库资源消耗在高压力下这比等锁更难受。一次大促后我对比过慢查询日志发现失败UPDATE虽然只有0.1%的业务占比却占掉了主库近10%的IO因为它的执行计划要走一遍主键索引加版本字段判断。4.3 混合使用一套积分系统的双锁方案我维护过一套积分系统账户余额用悲观锁因为钱不能允许任何重试式补偿余额一致性要求极高积分明细表用乐观锁因为明细并发写入量大且同一用户短期内的并发写入很少。两条线用同一账户ID串联起来但锁的方案完全不一样。这种混合方案值得参考。不要把“用哪个锁”看成非此即彼而要看业务对“不可容忍的瞬间不一致”到底有多敏感。余额错了用户立刻投诉明细少一条用户往往感知不到那我就让前者更保守后者更激进。5. 局部锁常被忽略却最容易踩坑的一类锁5.1 局部锁到底指什么“局部锁”不是一个有规范定义的术语但在Java面试和实战代码里经常出现。它通常指锁对象和锁的作用域只存在于某个局部范围例如一个方法体、一个代码块、一个局部变量。先看一段典型代码public void increase() { Object lock new Object(); // 局部锁对象 synchronized (lock) { count; } }如果increase()同时被多个线程调用每个线程进来时创建的lock都是新对象大家锁的完全不是同一个东西所以这段代码没有任何互斥效果。这是最经典的“局部锁无效”案例。很多新手写过我早期也写过线上监控看起来线程不少但锁就像不存在一样问题照旧复现。那局部锁是不是意味着不能用不是。关键在于多线程协作时必须共享同一个锁对象实例如果锁对象是局部创建且不被外部共享它只能保护单线程上下文里的临界区多线程下就是空锁。5.2 锁的对象无关关键在共享我复盘线上问题的时候发现很多人把“局部”理解成“粒度更小的锁”其实不是。局部锁描述的是锁对象的可见性和生命周期而不是锁的类型。乐观锁与悲观锁的区别在“加不加锁”局部锁的区别在“锁放哪、谁看得见”。想让变量被多个线程安全访问做法不是new一个局部锁对象而是要把锁对象提升到所有参与者都能看到的位置——实例字段、静态字段、外部方法参数传入。锁对象的局部性只能决定它何时被回收不能决定它能否完成互斥。如果使用场景确实只在单线程内流转那更推荐的做法是线程封闭让可变数据只存在于线程私有的栈帧中不逃逸到外部。此时你压根不需要锁。Java 的锁消除优化也与此有关JIT 在分析出对象不会逃逸时会把不必要的加锁代码直接擦掉这也是为什么局部的synchronized (lock)可能没有实际代价但也没有实际意义。5.3 ThreadLocal 到底算不算锁ThreadLocal 经常被拿来跟“局部”放在一起讨论但它本质上是线程隔离不是锁。它确实让每个线程拥有独立的变量副本从而绕过竞争从“不用锁”的角度解决冲突这跟乐观锁“少锁”的思想有些像但和互斥无关。多个线程如果各自操作自己的副本根本不存在竞争自然不需要锁一旦你需要让多个线程看到同一个值ThreadLocal 就失效了。所以我面试时经常说ThreadLocal 的价值是降低对锁的需求它不是一种锁。局部锁如果指“每个线程各自持有一把锁”那这把锁只能保护各自的私有空间跨线程同步等于零。5.4 局部锁真正有用的两种场景局部代码块里只涉及受控的单一线程访问局部锁对象作为轻量级的操作护栏用来防代码以后被其他人误改成多线程。严格控制锁的持有时长时把锁放在方法最里层让锁对象的引用局部化用了马上释放出了作用域锁就自动归还。数据库里的行锁也是“局部化”思想的体现。它锁的是单行而不是整张表就是把加锁范围局部化减少对无关数据的影响。锁的范围越小冲突越少锁的范围越大安全性越高但并发能力越低。这和“局部锁”理解成加锁粒度是相通的。6. 其他锁类型盘点每个都有不可替代的用武之地6.1 JVM 锁三件套的边界ReentrantLock比synchronized多出lockInterruptibly()、tryLock(timeout)、任意位置加锁/解锁还能指定公平策略。可惜它需要手动管理解锁一旦忘记释放就是硬bug。我的经验是能用synchronized就用synchronized只有确实需要超时抢锁或中断响应时才换ReentrantLock。ReadWriteLock的价值在读写分离读读可以并行读写互斥写写互斥。适合读多写少的场景。有个常见的坑是锁升级持有读锁时再去拿写锁很容易死锁正确做法是先释放读锁再获取写锁。另一个坑是写锁饥饿如果读线程太多写线程可能一直等不到锁第三方实现里往往有公平模式可以缓解。StampedLock是 JDK 8 加入的一种乐观读锁读的时候不真正加锁写完再校验版本戳类似乐观锁的思路。它的tryOptimisticRead返回一个版本戳业务逻辑执行完后再调用validate检查是否被写操作打断。性能很亮眼但API设计上容易写错我建议常规项目别碰除非性能瓶颈已经被定位到锁竞争上了。6.2 公平锁与非公平锁锁按获取顺序可以分成公平锁和非公平锁。公平锁严格排队避免饥饿非公平锁允许新线程直接抢锁性能更高但可能让排队等待的线程饿死。JVM 里synchronized是非公平的ReentrantLock可以构造公平版本。我实际使用中很少要求绝对公平除非业务对处理先后顺序有硬性要求。非公平锁的吞吐量通常更好这也是为什么默认都是非公平。自旋锁也值得一提它是乐观锁思想在锁实现层面的体现线程不立刻阻塞而是反复循环检测锁状态。优点是避免了线程切换开销缺点是CPU空转。Java 的AbstractQueuedSynchronizer内部就大量使用了自旋逻辑但自旋次数必须控制否则高冲突下CPU直接被烧满。6.3 分布式锁跨进程之后没人能独善其身应用服务拆成多个节点后JVM 本地的锁无法跨进程互斥分布式锁才登场。它要解决的是多个独立服务节点之间的统一互斥。常见实现有三种。基于Redis加锁用SET key value NX PX timeout加锁释放前先校验value是否是自己的防止释放掉别人的锁再用Lua脚本保证“判断删除”原子操作。基于数据库唯一索引插入一条唯一索引记录代表获得锁删除记录代表释放简单可靠但性能和可用性有限。基于ZooKeeper / etcd用临时顺序节点加监听机制节点崩溃后锁会自动清理避免死锁。有一个原则我在团队里反复强调分布式锁不是银弹。跨节点通信延迟、时钟漂移、GC停顿都可能让锁持有时间被意外拉长。Redis锁最常见的坑是忘了设过期时间一个节点崩溃后锁一直不释放其他节点全部卡死。后来所有加锁操作强制带PX超时并且在释放时校验value。如果业务能接受终态一致性优先用版本号、幂等键这类方案替代强互斥。7. 实战选型与经验总结7.1 直接抄作业的选型决策表业务场景首选方案理由单JVM内并发修改共享变量synchronized / ReentrantLock实现成本最低单机库存扣减乐观锁版本号无锁阻塞吞吐量高高一致性转账数据库悲观锁避免重试补偿读多写少缓存刷新ReadWriteLock读写分离多实例互斥Redis Lua 分布式锁跨进程可见单线程数据隔离ThreadLocal根本不用锁选型的核心顺序是先定作用范围单JVM还是跨进程再看冲突概率最后看业务对短暂不一致的容忍度。顺序反了很容易选错。7.2 我踩过的几个坑第一给库存扣减加版本号时把version version 1写进UPDATE里表面没问题可历史数据里有一部分是迁移脚本拷过来的版本号重复导致某些行更新失败。后来把版本条件单独放并给订单号加了唯一约束才彻底解决。第二MySQL 的FOR UPDATE没走索引某次大促时一条慢SQL拖垮整个数据库连接池。那次事故后我给自己立了个规矩凡是涉及锁的SQL先看执行计划确认type走的是range或ref而不是ALL。第三Redis分布式锁忘记设置过期时间一个节点崩溃后锁一直不释放所有其他节点卡死。更糟的是的主节点恰好做了锁续期逻辑进一步拉长了持有时间。之后的规范是加锁带超时释放校验身份续期必须配置监控告警。第四局部锁踩坑也就是开头说的案例多线程下synchronized(new Object())等于没锁。排查时监控显示线程不少但问题就是复现。从那之后我在Code Review里加了一条硬性检查锁对象必须是共享字段或者方法参数传入拒绝局部new出来的对象直接进synchronized。第五乐观锁重试风暴。某个活动的领券接口用了CAS自旋退避冲突率高时线程全在循环重试把CPU打满。后来加了最大重试次数超过就直接失败让客户端稍后再来系统反而稳了。7.3 一句话收尾锁这回事核心不是“哪个锁更高级”而是“冲突发生在哪里哪个锁能精确覆盖它”。乐观锁、悲观锁、局部锁、分布式锁它们是同一道题的四个解法不存在绝对优劣。关键是把锁的范围、持有时长、冲突概率三个参数摸清楚让每个锁落在它该落的位置上。我在实际项目里最深的体会是写业务代码的人往往不关心锁但系统出问题的时候第一个背锅的又往往是锁。与其等事故教做人不如在设计阶段就多问自己一句——这里真的需要锁吗如果有是乐观的还是悲观的作用范围到底到哪一层
阅读完成 · 觉得有帮助?