ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL默认是乐观锁吗?快照读、当前读与InnoDB真实并发机制解析

MySQL默认是乐观锁吗?快照读、当前读与InnoDB真实并发机制解析 先直接说结论不是。MySQL普通的增删改查语句默认状态下既不是乐观锁也不是悲观锁。严格来讲“乐观锁”从来都不是数据库默认自带的行为它是在业务代码里、靠开发人员手动实现的一种并发控制策略。而MySQL的InnoDB引擎在底层默认用的是MVCC多版本并发控制 行级锁这套组合普通SELECT走的是不加锁的快照读UPDATE/DELETE走的是会加锁的当前读。我见过不少面试者和刚入行的开发被这道题绕进去原因多半是把“乐观锁”和“MVCC的快照读”给搞混了。今天这篇文章就把这件事彻底拆开讲透为什么会产生这种误解、MySQL默认到底在做什么、真正的乐观锁怎么实现、以及生产中到底该怎么选。1. “默认乐观锁”这个说法是怎么来的一场概念混淆1.1 乐观锁的正确定义它属于应用层而不是数据库行为先看乐观锁的定义。乐观锁假设并发冲突的概率很低所以操作前不加锁只在提交/更新的时候检查数据是否被改过。检查通过就更新检查不通过就放弃或者重试。它被叫做“锁”但实际上它不使用数据库的任何加锁机制本质是“无锁”方案的一种。真正的乐观锁落地在SQL层面就是一条很普通的带条件UPDATE比如UPDATE inventory SET stock stock - 1, version version 1 WHERE id 1 AND version 2;执行完之后通过affected rows影响行数判断当前是否冲突。影响行数为1说明更新成功为0说明version已经被别人改过了。注意这条语句在数据库眼里就是一条普通的UPDATEMySQL没有任何特殊的“乐观锁模式”。所以“数据库默认乐观锁”这个说法从根上就不成立——如果数据库默认就是乐观锁那所有UPDATE都不需要加锁判断了这显然和InnoDB的真实行为矛盾。1.2 MVCC被误认成乐观锁的常见误解概念混淆的第二层源于MVCC。MVCC是InnoDB实现多隔离级别的核心机制。它通过undolog维护记录的历史版本让普通SELECT一致性读/快照读可以去读取某个快照时刻的数据而不用等待其他事务释放锁。这确实和乐观锁的“不阻塞读”思想有相似之处但两者完全不是一回事MVCC是数据库引擎层的机制解决的是“读不阻塞写、写不阻塞读”的问题乐观锁是业务应用层的策略解决的是“更新冲突如何被检测”的问题MVCC的“版本”是指数据行的多个历史版本由undo log自动维护乐观锁的“版本号”是业务字段比如version INT由应用代码手动递增。很多文章写“乐观锁的实现原理是MVCC”这就是典型的张冠李戴。MVCC让不同事务的读写并发执行但它并没有主动帮你“检测更新冲突”或“重试失败事务”。真要检测冲突数据库用的是锁以及事务提交时的冲突检测应用要用乐观锁还是得自己加version字段。一句话总结MVCC解决“读到什么”乐观锁解决“更新冲突了怎么办”。两者没有归属关系。2. InnoDB默认并发控制的真实面目快照读、当前读与行锁2.1 普通查询的“不加锁”不叫乐观锁叫快照读在MySQL的InnoDB下一条普通的SELECT * FROM user WHERE id 1默认是不加锁的。它走的是快照读也叫做一致性读、非锁定读靠undo log构建的版本链去读一个一致性快照。在可重复读RR隔离级别下事务第一次执行快照读时会生成一个ReadView整个事务期间所有快照读都基于这个ReadView在读已提交RC级别下每条语句执行前都会生成一个新的ReadView所以能读到其他事务已提交的最新变化。这个机制最直接的好处是读操作永远不会因为行锁被阻塞。即使另一个事务正在对某一行做UPDATE并持有X锁普通SELECT依然可以瞬间读到该行在锁前版本的数据无需等待。于是我经常看到有人得出这样的结论“MySQL的SELECT不加锁更新时才检查版本这不就是乐观锁吗”这就是误解的根源。快照读不加锁是因为MVCC压根不需要锁来保证读的一致性乐观锁也要保证更新的一致性但它靠的是业务字段校验。两者外表相似机制完全不同。更关键的区别在于快照读没有冲突检测逻辑读到的旧版本是合法返回不会触发重试乐观锁每次更新前要主动比对version冲突时明确报错或重试。2.2 UPDATE和DELETE的真实默认行为当前读 排他锁再看写操作。一条普通的UPDATE或DELETE在InnoDB里执行方式根本不是“走快照比对一下版本没变就更新”。它需要先进行当前读锁定读读取该行当前已提交的最新版本并加上一把排他锁X锁然后才执行修改。大致流程是-- 比如执行这条 UPDATE user SET age 30 WHERE id 1; -- InnoDB内部等效为示意不是真实语句 SELECT * FROM user WHERE id 1 FOR UPDATE; -- 拿到X锁后再修改最后在事务提交时释放锁也就是说InnoDB对写操作默认加锁允许写与写互斥不允许两个事务同时修改同一行。这个行为是典型的悲观并发控制我操作前先把资源锁住不让别人动做完再放。所以讽刺的是如果非要把增删改查归类到“乐观锁/悲观锁”这套话术里MySQL的UPDATE和DELETE反而更接近悲观锁思想而不是乐观锁。当然这也只是个类比MySQL并不“选择”乐观或悲观策略它只是在引擎层面设计了一套加锁协议而已。2.3 InnoDB默认的锁体系一览为了把“默认行为”说清楚我整理一个InnoDB常见锁的表格方便对照锁类型锁模式默认出现场景说明共享锁S锁读锁SELECT ... LOCK IN SHARE MODE允许多个事务同时读取同一行但阻止其他事务修改该行排他锁X锁写锁UPDATE/DELETE/SELECT ... FOR UPDATE写写互斥事务提交或回滚后释放记录锁Record Lock锁单行记录主键或唯一索引等值条件只锁命中的那一条记录间隙锁Gap Lock锁范围间隙RR级别下的范围条件锁住记录之间的空隙防止其他事务插入Next-Key Lock记录锁间隙锁RR级别默认解决幻读问题的关键锁“一个区间该区间第一条记录”意向锁IS/IX锁表级标记锁加行锁之前自动加用于快速判断表级操作是否会与行级锁冲突插入意向锁特殊间隙锁插入操作时触发多个事务可同时插入不同间隙互不阻塞自增锁AUTO-INC Lock表级锁插入自增主键列保证自增值连续分传统模式和轻量模式这些锁都在引擎层自动发生不用你写任何关键字。它们组成的是你听到“数据库默认有锁”时真正的含义——这是InnoDB的悲观式加锁体系不是乐观锁体系。3. 手动实现乐观锁的正确姿势版本号与CAS条件更新既然数据库默认不做乐观锁那业务里真的想用乐观锁就得自己把它写出来。常见的做法有两种版本号方案和CAS条件更新方案。3.1 版本号方案的标准SQL写法版本号是最通用、最稳妥的做法。给表加一个version字段常用INT也可用TIMESTAMP每次更新时同时递增版本号更新条件里带上旧版本号-- 事务开始 SELECT stock, version FROM inventory WHERE id 1; -- 假设拿到 stock10, version3 UPDATE inventory SET stock stock - 1, version version 1 WHERE id 1 AND version 3; -- 影响行数1 → 更新成功0 → 版本冲突需要重试或报错 -- 事务提交在Java/Spring等常用后端场景里MyBatis-Plus自带的Version注解其实就是干这个事它会在UPDATE语句里自动拼上AND version旧值并检查影响行数为0就抛乐观锁异常。用框架的好处是少写重复代码但理解原理还得靠上面的SQL。使用版本号要注意一个细节更新时所有已经被读取过的、需要更新的字段最好都在同一条UPDATE里完成。比如扣库存和改订单状态别分两条UPDATE否则第一个UPDATE成功、第二个失败事务回滚后version字段也被回滚前后状态不一致要靠事务来兜底。3.2 用CAS条件更新实现无锁并发CASCompare And Set是另一种实践。不是搞一个专门的version字段而是把“我读取时的业务值”直接放进更新条件里。典型例子是余额更新-- 事务开始 SELECT balance FROM account WHERE id 1; -- 拿到 balance100 UPDATE account SET balance balance - 30 WHERE id 1 AND balance 100;如果balance100的条件成立说明这行的余额在我读完之后没有被其他事务改过更新成功。如果不成立说明别人已经扣过款了当前操作基于的是过期数据不能继续。这个方案省掉一个version字段但同样有局限它只能校验你写在WHERE里的那一个字段。比如业务里同时要校验balance和status你得把两个字段都作为WHERE条件组合起来否则只校验一个就存在漏网场景。3.3 影响行数为0的三种情况区分“冲突”和“其他原因”写乐观锁代码时最容易踩的坑就是把“影响行数为0”一律当成版本冲突。实际上还有另外两种情况条件本身就不成立。比如WHERE id1 AND version3里那条id1的记录可能已经被物理删除。数据没有变化。MySQL的优化规则是如果更新前后的值完全相同影响行数也可能为0取决于驱动和配置。更新的是0行的其他原因比如WHERE条件里引用了NULL字段或类型不匹配导致匹配不到行。所以生产环境里不能只凭影响行数输出“乐观锁冲突”这个结论。稳妥的做法是在影响行数为0时再根据业务场景做一次重查看记录是否存在、version是否已变化再决定是重试、抛错还是忽略。3.4 实现乐观锁的几个隐藏细节version字段一定要有初始值不能为NULL。底层框架处理NULL时经常异常SQL比较version NULL永远不成立。version用INT/LONG/BIGINT不要用浮点数。浮点精度问题会在高并发下产生难以察觉的错误比较。UPDATE里必须递增version。很多人写了WHERE version 旧值却忘了SET version version 1。这样第二次并发更新时条件仍然可能成立乐观锁彻底失效。注意ABA问题。如果有事务把version从1改成2又改回1另一个事务用WHERE version1也能更新成功。业务上能接受的就接受不能接受的可以加updated_at时间戳或者用versionupdated_at双条件。WHERE条件字段尽量走索引。如果乐观锁通过非唯一字段匹配可能退化成全表扫描性能问题比并发冲突更致命。4. 生产环境中的锁选择从业务含义倒推技术方案4.1 先问业务“丢更能不能接受”很多开发纠结于“到底用乐观锁还是悲观锁”其实路线是反的。你得先搞清楚业务对并发冲突的容忍度。举个例子用户下单后要扣库存。如果库存多扣一点可以在后续退款流程里补救那可以用乐观锁冲突了就让用户稍后重试简单高效如果库存必须精确多扣或少扣都会带来资损那就要用数据库悲观锁或分布式锁把并发风险前置。乐观锁的代价是用户可能在冲突时操作失败需要重试悲观锁的代价是别的请求会排队等待吞吐量降低。所以本质上是一个可用性与一致性的权衡能接受失败重试选乐观锁不能让失败发生选悲观锁。4.2 乐观锁典型场景库存扣减并发量高、单次操作短、冲突概率相对可控版本号方案很契合。秒杀场景甚至可以配合Redis预扣减、数据库兜底。余额更新涉及资金数据建议悲观锁或强一致事务但如果业务明确定位为“可重试”场景也可以乐观锁加失败重试。通用CRUD服务尤其是基于MyBatis-Plus这类框架做无状态增删改查时如果你不想引入分布式锁给基础表加version字段、用框架内置乐观锁插件是最省心的方案。长链路异步任务比如订单状态从“待支付”到“已支付”期间有多服务异步处理数据库行锁很难覆盖全链路比较适合用版本号保证最终一致性。4.3 悲观锁典型场景热点资源更新某一行的数据会被大量并发更新乐观锁会导致大量重试和冲突反而比加锁慢。跨表强一致事务需要在同一次数据库事务里更新订单表扣库存表记录流水表不能用乐观锁“碰运气”必须用SELECT ... FOR UPDATE把要更新的行锁住。金融类操作转账、对账、充值退款这类对一致性要求极高宁可让请求排队等待也不允许中途失败。4.4 一眼看清的对比案例我用一张表放一个秒杀库存场景和普通异步场景的对比逻辑会更直观比较维度秒杀扣库存悲观锁方案普通版本号更新乐观锁方案核心SQLSELECT ... FOR UPDATE; UPDATE stockstock-1UPDATE stockstock-1, versionversion1 WHERE id1 AND version?冲突处理排队等待前一个事务提交后后续自动继续影响行数为0立即返回冲突/重试并发吞吐相对低请求会阻塞相对高无阻塞但冲突时浪费一次执行一致性保障性强事务内互斥中等依赖业务重试逻辑实现复杂度需要事务包裹注意锁超时需要version字段影响行数判断从这张表能直观感受到没有哪个方案绝对更好只有哪个方案更贴合业务约束。5. 写在最后关于这个问题的几点个人体会回到标题那个问题本身。如果有人再问我“MySQL普通的增删改查语句都是默认乐观锁吗”我会建议他先把概念理顺普通SELECT走快照读不加锁这跟乐观锁没有关系UPDATE/DELETE走当前读排他锁默认加锁这是InnoDB的悲观式行为INSERT涉及隐式锁和插入意向锁同样跟乐观锁无关乐观锁是应用层开发者自己写出来的行为数据库根本不负责检测。我自己的实践体会是与其死记“哪个是乐观锁、哪个是悲观锁”不如在做CRUD之前先想清楚一句话“我这条更新操作允不允许因为并发冲突而失败重跑”如果允许就写version字段做乐观锁如果不允许就老老实实开事务、加SELECT ... FOR UPDATE做悲观锁。MySQL只是给你提供了锁和MVCC这两个工具箱至于你的业务在工具链上选择哪一层做并发保护那是你自己要做的决策。还有一个小技巧可以分享日常排查线上问题时如果一个UPDATE经常出现“明明数据没改影响行数却是0”的现象先不要怀疑数据库出问题用EXPLAIN看执行计划再确认WHERE条件是否命中索引最后再看是不是version没变导致了条件不成立。这个排查顺序能省你不少时间。说到底把“锁”理解成数据库的一种机制、把“乐观锁”理解成应用的一种设计这两层分开了很多并发问题自然就清晰了。
返回列表