☰
MySQL默认不是乐观锁:InnoDB行锁与乐观锁实现全解析
2026/10/1 11:23:15 网站建设 项目流程

最近在带团队做数据库优化评审,有同事抛了个问题过来:“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已经在id=1这行上持有了排他锁。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`) ) ENGINE=InnoDB; INSERT INTO user (name, balance, version) VALUES ('张三', 1000, 0);

这张表很普通,就是一张标准的增删改查表。我们接下来看它到底是不是“默认乐观锁”。

3.2 实验过程:两个事务同时改一行

打开两个MySQL会话,执行下面的操作:

会话A:

BEGIN; UPDATE user SET balance = balance - 100 WHERE id = 1;

这时不要提交。然后会话B:

BEGIN; 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;

不提交。然后会话B:

SELECT * 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这个条件来模拟锁。在并发场景下,两个事务都读到version=0,第二个事务执行更新时条件不再成立,影响行数为0,自动失败。数据库本身没有在这里加任何额外锁,锁的“判断”完全在SQL条件里。

这就和默认的UPDATE行为形成了鲜明对照:默认UPDATE是阻塞别人的排他锁,乐观锁UPDATE是不阻塞、但靠条件让冲突方直接失败。阻塞和失败,这是两种完全不同的策略。

4. 真正用好乐观锁:版本号方案的完整实操

4.1 从版本号到CAS:乐观锁的两种落地形态

最直观的乐观锁是版本号法。表里加一个int或bigint字段,每次更新时version加1,WHERE里带版本号。还有一种变体叫CAS(Compare And Set),适合金额、库存等数值型场景,直接在WHERE里带“修改前的余额”做条件:

UPDATE inventory SET stock = stock - 1 WHERE id = 1 AND stock >= 1;

这个写法尤其适合库存防超卖,因为它把“比较当前值”和“更新”合并成一条原子SQL,少了先读再写的时间窗口。但注意,如果条件是WHERE stock = 5,并发下两个事务读到stock=5,第二个更新也会失败,所以实际项目里我更推荐用版本号做通用方案。

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先锁id=1再锁id=2,事务B先锁id=2再锁id=1。解决方案就是统一加锁顺序,比如强制按id升序加锁。

第三步,考虑减少持锁时间。把大事务拆小、及时提交、避免事务里做外部接口调用,这些都能显著降低死锁概率。我在项目里常用一个技巧:对热点行追加一个随机冗余字段做散列,把单行锁竞争分摊到多行上,这招在秒杀类业务里非常有效。

5.4 隔离级别对加锁行为的影响

不同隔离级别下,同一个UPDATE的锁行为完全不同。读未提交和读已提交的UPDATE一般只锁目标行,而可重复读为了保证可重复读语义,除了锁目标行,还会在索引范围上加间隙锁或临键锁,阻止其他事务在范围内插入新记录。

这个特性很容易引发线上问题:两个事务更新不同记录,但因为间隙锁互相覆盖,导致互相等待。遇到这种情况,排查时第一步就是看是不是默认的可重复读级别,再看SQL的等值查询还是范围查询。如果业务确实不需要可重复读,可以考虑把会话或全局隔离级别调整为读已提交,这能大幅减少间隙锁引发的死锁。

5.5 一个被忽略的乐观锁陷阱:事务隔离级别下的版本号更新

最后分享一个坑。很多团队把乐观锁和事务放在一起用,事务里先读取版本号,然后执行UPDATE,再COMMIT。但如果在可重复读隔离级别下,你的事务里第一次读取已经建立了快照,后面即使其他事务改了version并提交,你的事务内再次SELECT读到还是旧快照,版本号不会变。你可能会以为自己拿到的版本是最新的,但执行UPDATE时条件又不成立,反复重试都失败,因为快照一直返回旧值。

这个问题的解法有两个:一是把需要动态读取最新版本号的SQL放到事务外执行,先读再开事务更新;二是用SELECT ... FOR UPDATE加锁读,强制读取当前最新版本,但这又回到了悲观锁的怀抱。所以我常说,乐观锁不是写一个版本号就完事,它跟事务隔离级别的配合才是真正的考点。

6. 写在最后:锁不是数据库的赠品,是自己设计的产物

回到最初那个问题,我现在的答案比开头更完整了:MySQL普通增删改查语句默认提供的,是一套“读靠快照、写靠行锁”的悲观的底线机制。乐观锁从来不是数据库默认行为的副产品,而是开发者在业务规则上额外雕刻出来的并发控制方案。

我在实际项目里踩过不少次坑,最深的一条体会是:不要因为面试背了“乐观锁不加锁”就去改造所有更新语句。乐观锁真正解决的问题是低冲突场景下的无阻塞更新,悲观锁真正解决的问题是高冲突场景下的一致性协调。把两者混着用而不设计回退机制,才是线上事故的最大来源。

如果你现在正在设计一个新功能的高并发更新逻辑,建议先回答自己三个问题:冲突发生的频率高不高?冲突后的重试成本可不可控?业务能不能接受部分请求失败?想清楚这三个问题,再去决定用乐观锁、行锁还是队列串行化,你大概率就不会看走眼。

最后送一个处理版本号冲突时的小技巧:在UPDATE失败后不要立刻重试,先睡眠极短时间(比如5到10毫秒)再读最新版本,能有效避免“冲突—重试—再次撞上”的抖动循环。这个小细节让我们的库存扣减接口在秒杀高峰期稳定了很多,希望你也能用上。

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

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

立即咨询