MySQL普通的增删改查语句都是默认乐观锁这个问题我看着眼熟面试被问过群里也被争论过。先说结论不是而且差得很远。普通增删改查走的是InnoDB默认的锁机制与MVCC多版本控制乐观锁压根不是数据库的默认配置它属于应用层方案。这个误解之所以流传广是因为很多人把普通SELECT不加锁和乐观锁划了等号又把MySQL会自动加锁理解成了悲观锁。两条线混在一起越聊越乱。这篇文章就把这两条线彻底剥开数据库底层的锁到底怎么加、MVCC到底做了什么、真正的乐观锁在MySQL里该怎么实现从原理到落地一次讲透。这篇内容适合所有被并发控制搞晕的开发者无论你是写业务接口还是做系统设计把乐观锁、悲观锁、MVCC这三件事搞清楚再去谈高并发才算心里有底。1. 先把这个误区的根源拆清楚乐观锁到底是什么1.1 乐观锁根本不是MySQL的功能是应用层的事要理解问题先得给乐观锁正名。乐观锁从来不是一个数据库内建机制它是一种并发控制思想核心就一句话假设冲突很少发生所以我更新数据时不做提前加锁而是通过某种校验条件来判断数据是否已经被别人改过。最常见的落地方式就是版本号字段。我给你一个最典型的表结构CREATE TABLE t_goods ( id INT PRIMARY KEY, stock INT NOT NULL, version INT NOT NULL DEFAULT 0 );业务逻辑是这样先查出数据拿到version0然后执行更新UPDATE t_goods SET stock stock - 1, version version 1 WHERE id 1 AND version 0;注意最后这个version 0它就是一个CAS条件Compare And Set的缩写先比较再设置。如果这条UPDATE影响行数为0说明在你读出数据之后、更新之前已经有别的事务把version改掉了。这时候你就要决定重试还是放弃。这套方案的价值在于冲突检测放在UPDATE语句自身携带的条件里应用层不依赖任何数据库锁协议。所以你说普通增删改查是默认乐观锁这从定义上就说不通——乐观锁需要你主动设计version字段、主动把version写进WHERE条件一个默认的动作怎么可能完成这么复杂的设计。1.2 乐观锁的前提与代价乐观锁虽然名字带乐观但用起来有几条硬前提必须有可比较的版本字段或者业务条件字段用来说明数据在我读取之后没有被修改更新语句必须把这个条件放进WHERE通过影响行数判断成功与否冲突发生后应用层必须实现重试逻辑这个重试往往要配合最大重试次数限制代价也很明显。如果系统写冲突频率很高乐观锁会让大量请求走重试路径每次都重新查数据、重新拼SQL反而比悲观锁更慢。我在实际项目里见过一个案例运营后台批量修改商品信息同一批数据几千条并发更新version冲突率高得离谱一条更新最多重试了十几次接口耗时从50ms直接飙到2秒。所以乐观锁适合的场景是读多写少、冲突概率低的业务比如点赞数、阅读量、评测打分这类。另外还有一个经常被忽视的点乐观锁不是完全无锁。你以为整个流程都没有锁但UPDATE语句本身在InnoDB里执行时仍然需要获取行锁只是这个锁持有时间极短只覆盖一条SQL的执行瞬间。乐观锁的真正意义不是消除锁而是把锁的持有时间压缩到最小把冲突判断交给WHERE条件而不是长时间占坑。这一点后面讲当前读的时候还会再提。2. MySQL默认的普通增删改查实际走的是哪套机制2.1 普通SELECT根本不加锁它走的是MVCC快照读既然乐观锁在普通增删改查里不成立那默认的SELECT到底在做什么答案是MVCCMulti-Version Concurrency Control多版本并发控制。InnoDB的普通SELECT是一个一致性快照读。它不在表或行上加任何锁而是通过undo log构造出历史版本数据再配合ReadView读视图决定当前事务能看到哪些版本去读取一个符合当前隔离级别的快照。说人话就是你执行SELECT的那一刻InnoDB会基于当前事务的视角给你生成一份当时可见的数据快照。举个例子在默认的REPEATABLE READ隔离级别下-- 事务A BEGIN; SELECT stock FROM t_goods WHERE id 1; -- 读到 stock 10 -- 此时事务B执行以下操作并提交 UPDATE t_goods SET stock 9 WHERE id 1; COMMIT; -- 事务A再次查询 SELECT stock FROM t_goods WHERE id 1; -- 依然读到 stock 10这个依然读到10就是快照读的典型行为事务A第一次SELECT时生成了ReadView后续的普通SELECT都基于同一个ReadView看到的数据停留在那个时间点不感知其他事务的提交。这套机制的作用是让读操作不阻塞写操作、写操作也不阻塞读操作从而大幅提升并发性能。很多人把这种不加锁还能读到一致数据的能力误会成乐观锁但我必须强调MVCC是一种快照隔离机制它的目标是让读与写之间不互相等待而乐观锁是一种更新冲突控制策略它的目标是让写与写之间通过版本校验避免覆盖。两者解决的问题不同实现层次完全不同不能混为一谈。2.2 普通UPDATE/DELETE/INSERT实际是会加锁的和SELECT不同写操作在InnoDB里默认是当前读。所谓当前读就是读取数据库里最新已提交的数据并且对涉及的行加锁不允许别的事务同时修改。具体来说INSERT如果插入的行在唯一键上和其他事务冲突会触发锁等待。另外InnoDB对刚插入的行采取了一种隐式锁策略通过事务ID标记锁定关系不需要立刻生成显式锁结构只在发生冲突时才升级成显式锁。这是一种性能优化手段但它仍然是锁机制的一部分不是乐观锁。UPDATE会对要修改的记录加排他锁X锁同时根据条件范围还可能加间隙锁Gap Lock或临键锁Next-Key Lock防止幻读。DELETE和UPDATE类似对删除的行加排他锁。所以你看普通UPDATE这条路径上根本没有乐观的意思。它是标准的悲观锁策略先把行锁住再修改再释放。我们经常会用SHOW ENGINE INNODB STATUS查看锁信息里面Transaction那一栏全是这类锁的TRANSACTIONS记录。如果普通UPDATE是乐观锁那这些排他锁信息就无从解释。2.3 快照读、当前读、加锁读三者别搞混这三类读操作是把MySQL并发机制理解清楚的关键我梳理成一个对照你看一眼就能记住操作类型SQL示例是否加锁读的是什么快照读SELECT * FROM t WHERE id 1不加锁基于ReadView的历史快照当前读UPDATE t SET .../DELETE FROM t ...加排他锁最新已提交数据加锁读SELECT * FROM t WHERE id 1 FOR UPDATE加排他锁最新已提交数据加锁读SELECT * FROM t WHERE id 1 LOCK IN SHARE MODE加共享锁最新已提交数据这个表里的不加锁是让很多人产生MySQL默认乐观锁错觉的根源。可你要注意不加锁只是读不加锁属于MVCC的设计决策而乐观锁是针对更新的冲突控制方案二者逻辑层次不一样。如果硬要说普通SELECT是乐观的那它乐观的只是不阻塞写、不占用锁资源这和更新前校验版本号完全是两码事。3. 排他锁、共享锁与SELECT ... FOR UPDATE悲观锁的正确打开方式3.1 悲观锁的适用场景上一节已经说明MySQL的默认写操作本身就带锁这其实就是悲观锁的范畴。所谓悲观锁就是我担心数据会被别人改所以我在读的时候就把它锁住直到我修改完成才放手。数据库用锁机制把并发操作串行化从根本上杜绝覆盖。什么时候该用悲观锁典型场景是冲突概率高、业务要求强一致的数据比如账户余额、订单状态、库存扣减。这类数据一旦并发修改出错后果是资金损失或超卖不是重试一次就能弥补的。此时用SELECT ... FOR UPDATE把关键行锁住是更稳妥的选择。SELECT ... FOR UPDATE是加锁读执行时会获取当前行的排他锁其他事务既不能改这行、也不能用FOR UPDATE或LOCK IN SHARE MODE去读这行。直到你执行COMMIT或ROLLBACK锁才释放。LOCK IN SHARE MODE则是共享锁多个事务可以同时加共享锁读但都不能修改只有所有共享锁释放后才能写。这里有个很容易踩的坑SELECT ... FOR UPDATE必须在事务里使用而且一定要让事务尽量短。比如接口里查完数据去做远程调用一个FOR UPDATE锁横跨网络请求几百毫秒这期间的并发全部排队数据库连接很容易被耗尽。我见过线上事故就是这样来的排队一多连接池打满服务直接雪崩。3.2 直接UPDATE和SELECT FOR UPDATE到底什么关系有同学会问既然普通UPDATE也会加锁那我为什么还要先SELECT ... FOR UPDATE直接UPDATE不就行了这个问题问到点子上了。普通UPDATE确实加锁但它的加锁是改的时候才锁。如果你的业务流程是先读判断再改三段式那么从读到改之间有一个时间窗口这个窗口里数据可能被别的会话改掉。举一个最典型的订单支付场景查询待支付订单状态拿到金额调用支付接口回调后更新订单状态为已支付如果你直接在第3步执行UPDATE order SET status PAID WHERE id ?第1步读到的金额可能在支付过程中已经被运营后台修改了但你并不知道。而如果你在第1步就用SELECT * FROM order WHERE id ? FOR UPDATE把订单行锁住那么从读取到更新的整个过程中任何其他事务都无法修改这个订单一致性才有保障。所以SELECT ... FOR UPDATE解决的是读-改-写之间的一致性它的核心是防止并发修改发生在你正在处理的同一罩视野内。普通UPDATE解决的是写-写冲突两者不是一回事。在真正的高一致性业务里二者往往是配合使用的。3.3 悲观锁的锁粒度选择悲观锁使用时的另一个关键点是锁粒度。MySQL的锁可以作用在行、间隙、表、甚至元数据上你写WHERE id 1InnoDB走主键索引锁的就是这一行这个粒度最细也最高效。但如果WHERE条件没走索引比如全表扫描时InnoDB会对扫描到的所有记录加锁甚至可能退化成锁表并发性能直接归零。我实际排查过一个问题一个后台批量更新接口WHERE条件用的是status ACTIVE但这个字段没有索引结果线上这个表的所有写操作全部被堵死。解决办法就是给status加上索引让锁只落在符合条件的少数行上。所以悲观锁不是简单写个FOR UPDATE就完事索引设计是锁粒度可控的前提。索引没建好锁的范围就失控锁的范围失控并发性能就崩盘。这个因果关系在任何一张大表上都成立尤其要注意。4. 如果你想用乐观锁MySQL里该怎么落地4.1 版本号字段的完整实践既然普通增删改查不是乐观锁那想用乐观锁就得自己写。最经典的做法是加version字段整套流程直接照抄这个模板第一步建表时加版本字段CREATE TABLE t_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, stock INT NOT NULL, version INT NOT NULL DEFAULT 0 ) ENGINEInnoDB;第二步接口逻辑中先查出数据和版本号SELECT id, stock, version FROM t_product WHERE id 100; -- 假设返回 stock 10, version 3第三步业务校验通过后执行条件更新UPDATE t_product SET stock stock - 1, version version 1 WHERE id 100 AND version 3;第四步根据影响行数判断结果影响行数是1说明这次更新成功version从3变4其他并发事务基于version3的更新都会失败影响行数是0说明有并发请求抢先更新了version已经不是3当前请求需要重试或返回提示这里有个细节很多人会做错UPDATE里一定别忘了version version 1。如果你只写SET stock stock - 1而不管version那么两个并发请求都基于version3执行WHERE version 3虽然MySQL的行锁会让它们串行执行但第二个事务等待结束后读到的仍然满足version3更新被判定为成功version也没变就失去了版本校验的意义。version字段必须在每次成功更新时同步递增校验才能循环成立。4.2 业务条件型乐观锁不一定要version字段版本号不是乐观锁的唯一实现。在很多场景下业务本身的约束条件就能充当版本校验。最典型的例子是库存扣减UPDATE t_product SET stock stock - 1 WHERE id 100 AND stock 0;这条SQL在扣减库存时强制要求剩余库存大于0一旦库存不足影响行数就是0业务就知道扣减失败。它不需要维护version字段直接利用业务规则做CAS条件。另一个常见场景是状态机流转比如订单只能从待支付变到已支付UPDATE t_order SET status PAID WHERE order_id ? AND status PENDING;如果订单状态已经被其他人改成CANCELLED这条更新影响行数为0就不会出现已取消的订单被支付这种逻辑错误。业务条件型乐观锁的好处是省字段、语义直观坏处是它只能覆盖条件唯一的场景。如果你的业务需要通用版本管理比如修改一整个商品的多个字段没有天然的强约束条件那就老老实实用version字段。4.3 乐观锁的高并发调优与失效边界乐观锁在低冲突场景下确实优雅但有几个边界必须心里有数。第一冲突率高时重试开销压不住。每次冲突都要重新查询最新版本再拼一次更新SQLRTT翻倍。如果冲突率超过10%我建议直接换悲观锁别硬撑。衡量办法很简单压测时看日志里影响行数为0的比例即可。第二覆盖写问题用乐观锁是防不住的。乐观锁只能保证基于某个版本的更新不丢失但如果你有两套服务分别更新同一行的不同字段各带各的version互相不感知照样会出现逻辑冲突。这种情况通常要做行级字段拆分或者引入分布式锁做粗粒度互斥。第三长事务下乐观锁会放大失败的体验。一个事务在10秒前读了version310秒后才执行UPDATE这期间只要有任何一次其他提交更新就会失败。用户的请求被延长了成功的概率反而更低。所以使用乐观锁的接口必须追求读后立即更新整个读-改-写窗口越短越好。窗口一旦变长乐观锁就从乐观变得悲剧。我自己的调优经验是把乐观锁的更新SQL和业务逻辑做强绑定尽量减少读与写之间的无关计算。比如对库存操作不要在应用层做复杂的价格计算计算逻辑拆到独立的环节让UPDATE尽量贴近查询减少版本过期窗口。5. 常见问题与排查实录5.1 高频误区速查讨论这个问题时下面这几条是出现频率最高的错误认知直接给你列成表以后谁再问你就能直接甩参考常见说法实际情况普通SELECT是乐观锁不是是MVCC一致性快照读普通UPDATE默认不加锁错了UPDATE会对行加排他锁乐观锁是MySQL的默认机制不是乐观锁是应用层方案需要显式设计SELECT ... FOR UPDATE是乐观锁不是这是典型的悲观锁加锁读乐观锁完全无锁不准确底层UPDATE仍需短暂获取行锁MVCC和乐观锁是一回事不一样MVCC解决读写并发乐观锁解决写写冲突这张表里的每一行都是我在实际沟通或代码评审中真实遇到过的说法。其中MVCC和乐观锁是一回事这条最坑因为它会让新手以为MySQL天然给你兜底了版本控制其实你连version字段都没建兜底从何谈起。5.2 一个并发扣库存的真实排查案例我之前维护过一个秒杀系统库存表结构和前面t_product基本一致。上线初期为了追求速度直接用UPDATE ... WHERE id ?扣库存结果压测时总是出现超卖。一开始大家以为是MySQL默认配置问题查了半天。后来把SQL改成UPDATE t_product SET stock stock - 1 WHERE id ? AND stock 0超卖就消失了。这段经历让我彻底明白单纯依赖普通增删改查根本不具备乐观锁的CAS条件你必须主动把约束写进UPDATE里面。这也正是默认乐观锁这句话最大的危害——它让人以为什么都不用做系统就自动防止了并发覆盖而实际上MySQL只保证单条SQL的原子性完全不保证业务层面的版本一致性。你不在SQL里表达我要防止超卖数据库再强也不会替你判断。5.3 当锁问题真正发生时怎么排查如果线上出现锁等待、死锁、更新超时排查路径基本是固定的我给你一套可复用的步骤第一步先看进程列表。执行SHOW FULL PROCESSLIST找出处于Waiting for lock状态的会话记录下它的SQL和事务ID。第二步再看锁等待关系。MySQL 8.0可以查performance_schema.data_lock_waits表直接看到哪个事务在等哪个事务的锁阻塞链一目了然。5.7及更早版本只能靠SHOW ENGINE INNODB STATUS重点看LATEST DETECTED DEADLOCK和TRANSACTIONS两个段落。第三步分析索引使用情况。用EXPLAIN看相关SQL的执行计划如果type列的值为ALL说明全表扫描锁的范围大概率失控优先优化索引。第四步如果是死锁注意死锁记录里涉及到哪几张表、哪几条索引。我做过的死锁多半是多个事务以不同顺序更新同一组资源比如事务A先更新表1再更新表2事务B先更新表2再更新表1互相等待形成环。解法就是约定全局固定的更新顺序。5.4 关于增删改查和锁的几条实用建议最后分享几条我平时写代码的具体习惯供你参考。第一先判断业务冲突概率再选锁。冲突低、读多写少用乐观锁冲突高、强一致用悲观锁。这个决策要在设计阶段做不要上线后再补。第二写UPDATE永远带上业务条件。无论你用的是乐观锁还是悲观锁WHERE里都应该包含业务判断条件库存大于0、状态正确、版本匹配。这层保障是最后一道防线即使上层逻辑有BugSQL也能挡住一部分错误。第三事务越短越好。锁的持有时间跟事务长度成正比事务里不要放远程调用、不要放复杂计算、更不要放用户输入等待。我见过一个转账接口事务里调了第三方支付回调等待单次事务跑了几十秒这个表上所有操作全部排队。事务短了锁的问题就少了一半。第四没事多看SHOW ENGINE INNODB STATUS。别等问题出现了才去查定期观察锁等待和死锁日志很多并发隐患能提前暴露。生产环境定期巡检MySQL状态日志是成本最低的数据库运维手段。我个人的真实体会是MySQL给开发者提供了一个非常强大的并发控制工具箱MVCC解决了读写互不阻塞的问题锁机制解决了写写冲突问题但**乐观锁这件事数据库始终没有替你做过未来也不会。** 它是业务层必须表达的策略需要你亲手设计version字段、亲手写WHERE条件、亲手做重试逻辑。明白这一点再去看各种增删改查语句你就能清晰区分哪些是数据库帮你的哪些是需要你自己承担的。