最近在带团队做数据库优化评审有同事抛了个问题过来“MySQL普通的增删改查语句都是默认乐观锁”群里瞬间分成了两派一派觉得好像听过这个说法另一派直接说你在逗我InnoDB默认就是行锁加MVCC跟乐观锁有什么关系。我翻了翻手头的项目代码和线上配置决定认真把这件事掰扯清楚。先说结论普通增删改查语句不是默认乐观锁恰恰相反MySQL InnoDB引擎的默认写行为更接近悲观锁的隐式实现而乐观锁是一种需要开发者在业务层主动设计才能生效的并发控制方案。但这道题的价值在于它暴露了很多人对“锁”的理解停留在名词层面没有真正搞清楚数据库在一条SQL执行时到底锁了什么、什么时候不锁、乐观锁又靠什么起作用。这篇文章不打算念文档我会从InnoDB的真实加锁行为开始用可复现的实验演示默认场景下的锁竞争再手把手实现一版真正的乐观锁方案最后把我这些年排查线上死锁、并发更新丢数据攒下来的经验一并整理出来。不管你是刚入门写CRUD的新手还是已经在处理高并发更新的老手读完应该都能把“乐观锁”这三个字从口头禅变成真正的工具箱。1. 先给结论默认不是乐观锁但这句话也不是纯谣言1.1 “默认乐观锁”这个错觉是怎么来的网上确实能看到“MySQL的增删改查默认就是乐观锁”这种说法尤其在一些培训机构的课件里。我推测他们混淆了几个概念一是InnoDB的普通SELECT不阻塞其他事务给人“不加锁”的错觉二是MVCC的快照读看起来每个事务各看各的很像乐观并发控制三是很多人在项目里用过版本号更新就默认数据库“自带”了这个机制。这三件事叠加在一起就拼出了一个错误的认知闭环。实际拆开看普通SELECT确实不加行锁但它不加锁的原因是InnoDB走的是MVCC快照读读的是历史版本这跟乐观锁的“先读后比较再更新”完全不是一回事。而UPDATE、DELETE、INSERT这些写操作InnoDB默认会给涉及的行加排他锁这个行为是自动的、强制性的。1.2 乐观锁和悲观锁的底层差异很多人喜欢背定义说乐观锁就是假设冲突少、不加锁悲观锁就是假设冲突多、先加锁。这么理解太粗糙了。真正的分水岭在于锁的持有方是谁。悲观锁由数据库在读写时自动申请、自动释放用户不用管但你承担的是锁等待和死锁的风险。比如两个事务同时更新同一行后到的事务就必须等先到的事务提交或回滚。InnoDB的行锁本质上就属于这种悲观的自动行为因为它默认假设写冲突一定存在先锁住再说。乐观锁的锁不在数据库里而是由业务代码通过条件更新来模拟的。典型做法是表里加一个version字段更新时写成UPDATE ... SET version version 1 WHERE id ? AND version ?如果影响行数为0说明你读到的版本已经过期需要重试或报错。这种锁完全靠应用层的判断来生效数据库本身压根不知道你在“乐观”。所以你问“MySQL默认是不是乐观锁”答案很清晰默认的写路径是悲观式的自动锁乐观锁默认不存在它是你通过SQL条件自己实现的。2. 一条普通SQL执行时InnoDB到底在锁什么东西2.1 你写的SELECT可能真的没锁但它的无锁是有前提的这里必须把SELECT拆成两种情况普通SELECT和加锁SELECT。普通SELECT * FROM user WHERE id 1在InnoDB的默认隔离级别可重复读下走的是一致性非锁定读也就是快照读。它会根据事务开始时的快照返回数据不申请任何行锁。这也是有人误以为它是“乐观锁”的原因因为确实没有加锁动作。但快照读不等于无条件无延迟。如果你的事务已经通过其他语句创建了读取视图快照读访问的是历史版本可能出现你读到的是旧数据的情况。多数业务在单事务里先读后写这时普通的SELECT读到的是旧版本后面UPDATE用的是当前版本中间的判断就可能失效。这就是为什么单纯依赖先SELECT判断再UPDATE的下单扣库存逻辑在高并发下会超卖因为你认为“没锁”的读其实是基于旧快照的判断。2.2 UPDATE和DELETE才是真正的隐式行锁很多开发同学没有意识到InnoDB在UPDATE和DELETE执行时会对扫描到的行加排他锁。即便你只更新了一行如果WHERE条件没法走索引InnoDB会扫描全表并对所有扫描到的行加锁。我用一个典型场景举例-- 会话A BEGIN; UPDATE user SET balance balance - 100 WHERE id 1; -- 会话B此时执行 UPDATE user SET balance balance 50 WHERE id 1; -- 会话B会被阻塞直到会话A执行COMMIT或ROLLBACK为什么B会被阻塞因为A已经在id1这行上持有了排他锁。B的更新操作也申请排他锁只能等待。数据库在这里没有和你商量它的默认行为就是“你更新我锁住别人别想动”这是典型的悲观锁思路。所谓“默认乐观锁”的说法在这里完全站不住脚。2.3 INSERT的锁行为同样不是乐观的INSERT在大多数情况下不阻塞其他INSERT但这不等于乐观。它的核心机制是隐式锁——在插入时并不立刻生成显式的锁结构而是利用事务ID和主键唯一性来判别冲突。如果另一个事务还没提交且已经插入了相同主键或唯一键那么当前INSERT会被阻塞因为数据库要保证唯一性约束。更常见的麻烦是插入意向锁和间隙锁的配合。当两个事务同时往一个范围的索引中插入记录时可能互相等待这就是插入意向锁引起的死锁。所以INSERT没有你想的那么“乐观”它依然是一个需要数据库全局协调一致性的操作。一句话总结InnoDB默认把写操作当成了必须加锁的悲观行为把普通的读做成了无锁的快照读。默认悲观锁说的就是写默认快照读说的是普通读。两者都不是乐观锁。3. 实操演示用实验把默认锁逼出原形3.1 环境准备与实验说明建议你用MySQL 5.7以上版本InnoDB引擎隔离级别选可重复读默认。我用的环境是MySQL 8.0.32客户端用命令行或者Navicat都可以关键是开两个会话。建一张极简的用户表CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, name varchar(50) DEFAULT NULL, balance int DEFAULT 0, version int DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB; INSERT INTO user (name, balance, version) VALUES (张三, 1000, 0);这张表很普通就是一张标准的增删改查表。我们接下来看它到底是不是“默认乐观锁”。3.2 实验过程两个事务同时改一行打开两个MySQL会话执行下面的操作会话ABEGIN; UPDATE user SET balance balance - 100 WHERE id 1;这时不要提交。然后会话BBEGIN; UPDATE user SET balance balance - 200 WHERE id 1;你会发现会话B卡住了迟迟不出结果。等几秒后查询performance_schema.data_lock_waits能看到B在等待A持有的行锁。如果你设置一个锁等待超时时间来观察更直观SET SESSION innodb_lock_wait_timeout 5; UPDATE user SET balance balance - 200 WHERE id 1; -- 5秒后报错Lock wait timeout exceeded; try restarting transaction这个实验已经足够说明问题默认情况下写同一行的两个事务根本没法并发执行后到者阻塞。如果MySQL默认是乐观锁这不该发生才对。3.3 对比实验普通SELECT确实不阻塞还是两个会话。先让会话A执行BEGIN; UPDATE user SET balance balance - 100 WHERE id 1;不提交。然后会话BSELECT * FROM user WHERE id 1;你会发现SELECT秒回而且返回的还是旧值1000。这就是快照读的效果读的是历史版本不感知未提交修改。但请注意这个“不阻塞”并不代表乐观锁成立因为一旦B想更新立刻会被卡住。做这个实验很容易踩的坑是两个会话都开了事务但都没提交改完后如果直接关掉客户端事务会回滚看起来什么都没发生。所以一定要先执行COMMIT再检查结果或者用显式回滚配合日志观察。3.4 在SQL层面手工实现一个乐观锁更新为了对比我们把刚才的user表加一列version模拟一次标准的乐观锁更新-- 第一步读取当前数据 SELECT balance, version FROM user WHERE id 1; -- 假设读到 balance 1000, version 0 -- 第二步条件更新要求版本号保持 READ 时读到的值 UPDATE user SET balance balance - 100, version version 1 WHERE id 1 AND version 0; -- 如果影响行数为 1说明更新成功没有冲突 -- 如果影响行数为 0说明版本号已被其他事务改掉需要重新读取或报错这条UPDATE靠的是version 0这个条件来模拟锁。在并发场景下两个事务都读到version0第二个事务执行更新时条件不再成立影响行数为0自动失败。数据库本身没有在这里加任何额外锁锁的“判断”完全在SQL条件里。这就和默认的UPDATE行为形成了鲜明对照默认UPDATE是阻塞别人的排他锁乐观锁UPDATE是不阻塞、但靠条件让冲突方直接失败。阻塞和失败这是两种完全不同的策略。4. 真正用好乐观锁版本号方案的完整实操4.1 从版本号到CAS乐观锁的两种落地形态最直观的乐观锁是版本号法。表里加一个int或bigint字段每次更新时version加1WHERE里带版本号。还有一种变体叫CASCompare And Set适合金额、库存等数值型场景直接在WHERE里带“修改前的余额”做条件UPDATE inventory SET stock stock - 1 WHERE id 1 AND stock 1;这个写法尤其适合库存防超卖因为它把“比较当前值”和“更新”合并成一条原子SQL少了先读再写的时间窗口。但注意如果条件是WHERE stock 5并发下两个事务读到stock5第二个更新也会失败所以实际项目里我更推荐用版本号做通用方案。4.2 乐观锁更新失败后怎么办乐观锁最常被问到的问题是影响行数为0接下来怎么处理我的建议分两步。第一步区分“真的失败”和“偶发冲突”。可以重写为UPDATE user SET balance balance - 100, version version 1 WHERE id 1 AND version #{readVersion};如果返回0说明当前行的version已经不是你读到的readVersion了。这时业务上一般有两种选择给用户提示“数据已更新请刷新”或者做一次自动重试重新读取最新version再执行一次更新。重试次数通常限制在3次以内避免无限循环拖垮数据库。第二步考虑是否需要记录冲突。线上高并发下单场景我一般会加一个冲突日志表记录失败对象的id、期望版本、实际版本和时间方便后续排查是不是某个字段被频繁更新。4.3 乐观锁不是银弹选型对比什么时候用乐观锁什么时候老老实实用悲观锁不能拍脑袋。我给团队定的选择标准是读多写少且冲突率低于5%的场景用乐观锁比如文章阅读量、点赞数、评价分数写冲突率高的场景用悲观锁或消息队列串行化比如订单状态流转、余额频繁变更。乐观锁最大的好处是省去了行锁等待数据库负载低吞吐量可观。坏处也很明显事务内如果连续更新多行可能部分成功部分失败需要额外设计补偿逻辑还有乐观锁无法防止“先读的是旧数据但旧数据恰好满足条件”这种情况因为快照读读到的是历史版本当你执行UPDATE时虽然WHERE里的version条件用的是读到的旧值但UPDATE语句本身走的是当前读它能查到最新数据所以条件不满足就更新失败这其实是乐观锁的正确工作方式只是业务方会觉得“我明明刚读到的怎么更新不了”。悲观锁也有不可替代的场景比如高频热点行的积分扣减如果两个人同时给同一个用户加积分用乐观锁会导致一方永远失败体验极差。这时用SELECT ... FOR UPDATE或UPDATE默认行锁才是靠谱的思路。我给一个粗略的对比表格对比维度乐观锁版本号/CAS悲观锁InnoDB默认行锁锁的位置应用层SQL条件数据库内部行锁是否阻塞不阻塞冲突方直接失败阻塞冲突方等待适用冲突率低冲突、高读取高冲突、高写入实现成本需要加字段和改SQL不需要改表结构风险点更新失败、补偿复杂锁等待、死锁默认情况数据库不提供需自己写InnoDB写操作默认生效5. 锁相关的高频误区和线上排查经验5.1 把“快照读不回滚”误当成乐观锁很多人拿“事务A读到旧值但事务B改完提交A还能继续操作”举例说这不是乐观锁吗这是混淆了MVCC和乐观锁。MVCC保证的是事务隔离性让快照读不会读到未提交的数据也不会因为其他事务提交而重新计算。但A后续执行UPDATE时它加的锁一定是当前读会使用最新版本的记录去更新。如果中间B已经改过A的更新要么阻塞要么覆盖B的改动具体取决于A的事务隔离级别和是否先加了锁。这完全不是乐观锁的“失败即重试”策略。5.2 索引字段与锁范围的关系排查线上锁等待时我见过太多“明明只更新一行却锁住了一大片”的案例。原因无他WHERE条件没走索引InnoDB只能扫描全表并对扫描到的记录逐行加锁。如果表数据量很大锁的数量和范围会急剧膨胀。所以这里有个特别实用的经验UPDATE和DELETE的WHERE条件尽量走唯一索引或覆盖索引。哪怕是普通非唯一索引也能显著缩小锁范围。如果用不到索引可以考虑改成先查主键再按主键更新或者干脆在设计阶段就避免用非索引列做更新条件。5.3 死锁排查的三板斧死锁是悲观锁路上的常见事故。很多人一遇到死锁就懵我总结了一套排查套路。第一步拿到错误信息里的SQL。MySQL的死锁日志包含事务信息和持锁等待关系先看LATEST DETECTED DEADLOCK段找到两个事务分别持有什么锁、等待什么锁。第二步画出加锁顺序。死锁的本质是对多个资源加锁顺序不一致。比如事务A先锁id1再锁id2事务B先锁id2再锁id1。解决方案就是统一加锁顺序比如强制按id升序加锁。第三步考虑减少持锁时间。把大事务拆小、及时提交、避免事务里做外部接口调用这些都能显著降低死锁概率。我在项目里常用一个技巧对热点行追加一个随机冗余字段做散列把单行锁竞争分摊到多行上这招在秒杀类业务里非常有效。5.4 隔离级别对加锁行为的影响不同隔离级别下同一个UPDATE的锁行为完全不同。读未提交和读已提交的UPDATE一般只锁目标行而可重复读为了保证可重复读语义除了锁目标行还会在索引范围上加间隙锁或临键锁阻止其他事务在范围内插入新记录。这个特性很容易引发线上问题两个事务更新不同记录但因为间隙锁互相覆盖导致互相等待。遇到这种情况排查时第一步就是看是不是默认的可重复读级别再看SQL的等值查询还是范围查询。如果业务确实不需要可重复读可以考虑把会话或全局隔离级别调整为读已提交这能大幅减少间隙锁引发的死锁。5.5 一个被忽略的乐观锁陷阱事务隔离级别下的版本号更新最后分享一个坑。很多团队把乐观锁和事务放在一起用事务里先读取版本号然后执行UPDATE再COMMIT。但如果在可重复读隔离级别下你的事务里第一次读取已经建立了快照后面即使其他事务改了version并提交你的事务内再次SELECT读到还是旧快照版本号不会变。你可能会以为自己拿到的版本是最新的但执行UPDATE时条件又不成立反复重试都失败因为快照一直返回旧值。这个问题的解法有两个一是把需要动态读取最新版本号的SQL放到事务外执行先读再开事务更新二是用SELECT ... FOR UPDATE加锁读强制读取当前最新版本但这又回到了悲观锁的怀抱。所以我常说乐观锁不是写一个版本号就完事它跟事务隔离级别的配合才是真正的考点。6. 写在最后锁不是数据库的赠品是自己设计的产物回到最初那个问题我现在的答案比开头更完整了MySQL普通增删改查语句默认提供的是一套“读靠快照、写靠行锁”的悲观的底线机制。乐观锁从来不是数据库默认行为的副产品而是开发者在业务规则上额外雕刻出来的并发控制方案。我在实际项目里踩过不少次坑最深的一条体会是不要因为面试背了“乐观锁不加锁”就去改造所有更新语句。乐观锁真正解决的问题是低冲突场景下的无阻塞更新悲观锁真正解决的问题是高冲突场景下的一致性协调。把两者混着用而不设计回退机制才是线上事故的最大来源。如果你现在正在设计一个新功能的高并发更新逻辑建议先回答自己三个问题冲突发生的频率高不高冲突后的重试成本可不可控业务能不能接受部分请求失败想清楚这三个问题再去决定用乐观锁、行锁还是队列串行化你大概率就不会看走眼。最后送一个处理版本号冲突时的小技巧在UPDATE失败后不要立刻重试先睡眠极短时间比如5到10毫秒再读最新版本能有效避免“冲突—重试—再次撞上”的抖动循环。这个小细节让我们的库存扣减接口在秒杀高峰期稳定了很多希望你也能用上。