☰
MySQL MVCC底层原理与银行转账事务设计实战
2026/10/21 6:39:38 网站建设 项目流程

1. 先搞懂一个底层问题:MVCC到底在解决什么

MySQL 的 MVCC(Multi-Version Concurrency Control,多版本并发控制)是我这几年做金融类系统时,觉得最值得反复咀嚼的一块东西。日常开发里,你写一条 UPDATE 或 SELECT,背后靠的其实是这套机制在支撑并发读写的稳定性。很多人背过“MVCC 是乐观锁的一种实现”,或者“通过快照实现隔离”,这些说法都对,但真到你动手设计转账这一类资金流转系统时,你会发现:理解 MVCC 能决定你写出的是玩具代码还是能扛住对账的系统。

为什么拿银行同行转账来做引子?因为它是教科书级的强一致性场景。一个账户扣款,另一个账户加款,金额必须分毫不差,而且同一时刻可能有几百笔转账打向同一个收款账户。如果在设计时没把 MVCC 的行为想清楚,轻则出现“扣了钱但没到账”,重则资金表出现负数。这个项目的核心考点不是“写个接口把余额改一下”,而是:并发下数据的一致性如何被 MVCC 保护,以及在保护不了的地方你该补什么。

这篇文章既适合刚接触 MySQL 隔离级别和事务的开发者,也适合要做资金类业务但还没理顺“MVCC + 行锁 + 幂等控制”这套组合拳的从业者。我会从 MVCC 的底层机制讲起,再落到银行同行转账的领域建模与 SQL 实现,中间穿插我实际踩过的坑。

从认知层看,MVCC 要解决的核心冲突是:读与写同时发生时,如何让读的人不阻塞,写的人也不阻塞,同时大家看到的还不乱套。那它凭什么做到?凭“多个版本”。同一行数据,在某个结构里保留了历史版本链,不同事务可以根据自己的隔离级别选择看哪个版本。你不需要一把大锁把所有读写都串行化,这就是 MVCC 的性能底气。

不过要提醒一句:MVCC 不是银弹,它管的是“读”的提交可见性,管不了“写写冲突”。转账场景里,两条事务同时扣同一个账户余额,MVCC 自身不会替你做串行化,这需要另外的兜底手段,核心是行锁和因此衍生的锁等待、死锁检测。所以 MVCC 从来不能脱离锁单独理解——它是读写之间的缓冲,而锁是写写之间的闸门。搞懂这一点,银行转账的事务设计才算入了门。

2. 四个机制拆解 MVCC 的内部运行逻辑

2.1 隐藏在每一行里的三个“隐形字段”

先说 InnoDB 里一行数据除了业务字段之外,还藏了哪些东西。你通常会忽略它们,但它们才是 MVCC 的原材料。

第一是DB_TRX_ID,记录这行数据最近一次被修改时的事务 ID。事务 ID 在 InnoDB 内部是全局递增的,你哪怕开 100 个连接同时操作,每个新事务拿到的 ID 都比之前所有事务大。第二是DB_ROLL_PTR,也就是回滚指针,它指向 undo log 里的上一个版本。这意味着同一行数据的各个历史版本,是通过这枚指针串成一条链的。第三是DB_ROW_ID,在没有指定主键时 InnoDB 隐式分配的单调自增行 ID,有主键的时候它不被使用。

回想一下你平时看SHOW CREATE TABLE的输出,压根看不到这些字段,但它们在数据页里真实存在。正因如此,MVCC 不需要额外建一张版本映射表,所有版本信息都“贴着”行本身存储。这个细节在性能上很关键——查找历史版本时,沿着回滚指针在 undo log 里走一两条链接就行,成本相当低。

2.2 undo log 里保存的不是“旧数据”,而是“反操作”

很多同学疑惑:undo log 不是用来做事务回滚的吗?怎么又是版本链?其实两者用的是同一份数据,只是用途不同。

假如事务 A 把 balance 从 100 改成 80,undo log 里会记录一个“逆操作”:如果要回滚,把 balance 改回 100。但从 MVCC 的角度看,这份“旧值 100”并不是原始快照,而是通过逆运算得到的逻辑版本。另一个事务 B 在 A 还没提交时去读这一行,发现 DB_TRX_ID 是未提交的 A,就沿着 DB_ROLL_PTR 找到旧版本,读到的还是 100。

这里我想强调一个容易懵的点:undo log 里的版本,并不等价于数据库里“真实存在”的整行数据。它是一个行数据的“差异记录”。所以当你需要沿着版本链往前翻好几个版本时,InnoDB 会基于最新行数据依次回放逆操作来重建旧版本。如果版本链特别深,是会影响读取性能的,这也解释了为什么频繁更新同一行会让某些查询变慢。

2.3 Read View 是事务与版本链之间的“滤镜”

Read View 才是 MVCC 判断“哪个版本可见”的核心工具。它的工作方式非常像滤镜:创建一个 Read View 时,InnoDB 会记录当前活跃事务列表以及高低水位,然后拿着这组信息去比对版本链上每个版本的 DB_TRX_ID。

一次完整的可见性判断规则是:如果版本的事务 ID 小于 Read View 的低水位值,说明该版本在 Read View 创建之前就已经提交,直接可见;如果大于等于高水位值,说明这个版本是未来事务,不可见;如果在高低水位之间但不在活跃事务列表里,说明创建 Read View 时该事务已经提交,可见;如果还在活跃列表里,则不可见,继续沿回滚指针找更早版本。

这就是为什么可重复读隔离级别下,同一条 SELECT 反复执行结果一致——因为它用的是同一个 Read View。而在读已提交下,每次 SELECT 都会生成新的 Read View,所以能看到其他事务新提交的改动。

我见过不少人在这块死记硬背,实际上你手动扮演一次 Read View 判定流程就懂了:把几个事务按 ID 排好,拿着高低水位和活跃列表,像查户口一样逐个版本问一句“你提交了吗”,问完就出结果了。这个方法我一直推荐给团队新人,效率挺高。

2.4 隔离级别与锁的边界

接下来把隔离级别和 MVCC、锁的关系串起来。直接说结论:

读未提交不会生成 Read View,每次读都拿最新版本,写写冲突依靠行锁,但因为能读取未提交数据,风险极大。读已提交每次读都新建 Read View,解决了脏读,但两次读可能拿到不同结果,也就是不可重复读。可重复读是 MySQL 的默认级别,事务开始时建一个 Read View,整个事务内复用,因此相同条件读到的结果始终一致。可串行化则会直接退化为读锁加写锁,MVCC 的并发红利基本在侧重读场景下失效。

有个实战要点:可重复读虽然语义上隔绝了不可重复读,但幻读问题并没有彻底解决——InnoDB 在 MySQL 层面还不是纯靠快照解决幻读,而是通过当前读配合间隙锁(Next-Key Lock)来兜底。意思是:你用快照读看不到新插入的行,但你敢在当前读(比如 SELECT ... FOR UPDATE)下去查,就会看到新行,这时要靠锁把范围内的间隙锁住,才防止并发插入。

到这里你已经理解 MVCC 的整套骨架了:隐藏字段存素材,undo log 存旧版,Read View 做判定,行锁兜底写冲突。这四个零件合在一起,才让 InnoDB 在并发读写压力下做到了“读不阻塞写、写不阻塞读”。

3. 实操复盘:手工走一遍 MVCC 的运行全过程

3.1 准备实验环境

与其看一百篇文章,不如自己动手摆弄一次。拿 MySQL 8.x 做实验,建一张极简账户表。

CREATE TABLE account ( id BIGINT PRIMARY KEY, balance DECIMAL(12,2) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB; INSERT INTO account (id, balance, version) VALUES (1, 100.00, 0);

先开两个终端会话模拟两个并发事务。终端 A 开始事务并更新余额,但不提交;终端 B 同时查询这笔数据,看它会读到什么。如果你用的隔离级别是可重复读,终端 B 会看到 100,即便终端 A 已经在未提交状态把余额改成了 80。

3.2 观察隐藏列和版本链

想看隐藏列,单纯 SELECT 是看不到的,你需要直接指定列名。不过 InnoDB 在系统库里有一套接口可以查到部分信息,如果使用 MySQL 8.0 的information_schema.INNODB_TABLES和INNODB_COLUMNS,结合INNODB_METRCTS能看到表结构,但最直观的方式还是对 undo log 下手,那就太底层了。实际工作中我更建议直接通过EXPLAIN或performance_schema观察行锁等待,而不是去翻数据页。

这里有一个靠谱的观察技巧:先开两个事务,让事务 A 修改行但不提交,紧接着事务 B 执行SELECT * FROM account WHERE id = 1 FOR UPDATE,你在第二个会话会立刻看到锁等待。等事务 A 回滚后,B 的查询才会返回,且拿到的还是 100。这一过程恰好说明当前读直接被行锁阻塞,而普通快照读不受影响。

3.3 可重复读与读已提交的读法差异

接着做对比实验。开三个会话:会话 A 开启事务并 UPDATE 1 号账户的余额从 100 改成 80,不提交;会话 B 开启事务后,连续执行两次普通 SELECT;第一次查询在 A 提交前进行,看到 100;然后 A 提交;B 执行第二次普通 SELECT,注意此时结果可重复读下仍是 100,但在读已提交级别下刷新成 80。

关键验证点在于:可重复读因为复用同一个 Read View,所以它感知不到新提交;读已提交每次查询都生成新 Read View,所以能看到变化。这个差异在银行转账业务里相当要命——如果转账系统的事务把“读余额”和“扣款”拆开,并且业务方用读已提交,你就得小心不可重复读导致余额在事务中途被改变。

4. 从 MVCC 走到业务层:银行同行转账的领域建模

4.1 业务需求到底有哪些硬性约束

刚才的 MVCC 只是地基,真的往银行存款转账业务上建房子,需要先梳理业务约束。我参与过好几个偏金融支付方向的项目,这类系统的刚性需求大致是五条:

单笔转账要么全部成功、要么全部失败。扣款成功但加款失败是绝对不允许发生的。任何情况下不允许出现账户余额小于零。转账流水必须可追溯,每笔资金的来源和去向要清楚。同一笔转账请求不能因为网络重试导致重复扣款或重复加款。并发场景下,同一收款人同一时刻收到多笔汇款,系统不能出现更新丢失。

前两条是事务的基本要求。第三条要求你在 SQL 里必须带余额条件判断,不能只依赖应用层代码先查后改。第四、第五是流水表和幂等键的重点。理解了这些约束以后,再往下设计表结构和事务,方向才不会歪。

4.2 转账业务的核心表结构设计

表是资金的骨架。同行转账最核心的两张表是账户表和流水表,此外我强烈建议加一张“转账订单表”来做幂等控制。

账户表负责存余额,但这里有个误区:不要在账户表里存“已转账次数”“可用额度”这类扩展信息,保持表的职责单一。流水表是资金变动的明细账,它不只做记录,更是将来对账的底稿。转账订单表则单独承担幂等去重的责任。

单表字段上,我倾向给流水表加一个biz_id(业务 ID)。这个字段不是自增主键,而是由业务方生成或由转账接口入参携带,保证同一笔转账订单重复提交时,系统能通过唯一索引直接拦下来。转账订单表也有自己的唯一订单号,它和流水表的关系是一对一或一对多(比如拆分了多笔入账),但对同行转账的简单场景,一对一是够用的。

4.3 为什么账户表不需要幂等键

一个容易被追问的点是:既然要做幂等,为什么不在账户表上做一个幂等字段?原因是账户表的核心职责是余额本身,不做请求级别的去重。转账订单表把请求去重做掉,流水表把资金改动记录下来,账户表只做行级余额变更,这样每个数据结构只干一件事,日后的维护成本会低很多。

如果真把幂等键塞进账户表,会造成一次转账要同时更新账户表两个属性,事务范围变大,行锁持有时间变长,并发吞吐直接受到影响。听起来很细微,真实线上环境放大后效果极其明显。

5. 转账核心流程与 SQL 落地:行锁怎么加、MVCC 怎么配合

5.1 一版经得起追问的转账接口流程

我把同行转账接口压缩成四个步骤:校验 + 加锁、扣款、入账、记流水。

这里有个规划要点:第一步不能“先 SELECT 出来看看余额够不够再 UPDATE”,必须把 UPDATE 语句本身当作唯一的余额校验手段。因为第一步如果只是普通 SELECT,你拿到的 MVCC 快照可能和最终实际余额不一致,并发时会出现更严重的逻辑漏洞。正确做法是直接发出一句带条件判断的 UPDATE。

UPDATE account SET balance = balance - 100.00 WHERE id = 1 AND balance >= 100.00;

如果影响行数为 0,说明余额不足或账户不存在,直接返回失败;影响行数为 1,则锁定了这一行,后续操作你才真正“拥有”这笔行数据的更新权。

5.2 扣款与加款的先后顺序,为什么强烈要求先扣款

在同行转账场景里,先扣付款方账户的款,再给收款方账户加款,这个顺序可以作为一条纪律看待。原因有两个:

第一,从资金安全角度,扣款成功是入账的前提。试想一下你先把入账做掉再扣款,万一扣款那一步因为余额不足挂了,回收资金会变得非常麻烦。第二,从锁竞争角度,扣款行锁的申请先于加款行锁,能降低死锁出现概率。死锁出现的一个典型原因是两个事务以相反顺序申请多把行锁,如果你把所有转账事务都定成先扣后加、先小账户后大账户之类的统一顺序,死锁的暴露面就小很多。

还有人会问:如果收款方支行和付款方支行是不同 MySQL 实例,那不就是跨库转账?那就要引入分布式事务,这里只在“同一个 MySQL 实例”的同行转账边界下讨论,跨行、跨库暂时不展开,不过后续我可以单独写一篇。

5.3 完整转账事务的伪代码与真实 SQL

结合上面的思路,完整的转账事务可以这样组织:

START TRANSACTION; UPDATE account SET balance = balance - 100.00 WHERE id = 1 AND balance >= 100.00; SELECT ROW_COUNT(); -- 若为 0,ROLLBACK UPDATE account SET balance = balance + 100.00 WHERE id = 2; INSERT INTO transfer_order (order_id, from_acct, to_acct, amount, status) VALUES ('ORDER20250101001', 1, 2, 100.00, 'SUCCESS'); INSERT INTO account_flow (flow_id, acct_id, change_amount, order_id) VALUES ('FLOW20250101001', 1, -100.00, 'ORDER20250101001'), ('FLOW20250101002', 2, 100.00, 'ORDER20250101001'); COMMIT;

这里要注意两点:两条流水我放在 INSERT 里一次完成,减少与数据库的交互次数;订单号 ORDER20250101001 在转账订单表有唯一索引,重复提交会因为主键冲突或唯一键冲突直接告警,从而做到幂等。

为什么这个事务能适配 MVCC?因为加款步骤的 UPDATE 会用到当前读,实时把收款账户的最新行锁住并更新;而事务内的 SELECT(如果有)会使用快照读,看到的是事务开始时的版本。读与写各走各的机制,互不阻塞,最终一致性由 COMMIT 决定。

5.4 行锁等待、死锁与超时参数

再聊几个你在真实压测时几乎必遇的问题。转账事务里两条 UPDATE 都会申请行锁,如果事务 A 先锁了账户 1 再等账户 2,事务 B 先锁了账户 2 再等账户 1,就会形成死锁。InnoDB 会自动探测死锁并回滚其中一方,但被回滚一方可能已经执行了大半逻辑。所以“给所有转账统一加锁顺序”是有效规避手段。

另外要调整innodb_lock_wait_timeout,很多数据库默认是 50 秒。在转账业务下,这个值往往太长,用户根本没办法等那么久,而且长锁等待也会拖垮连接池。实践中我会把锁等待超时调低到 5 秒以内,把超出的请求引导到重试队列或直接返回“系统繁忙”。要知道,高并发下宁可快速失败,也比让用户无限转圈好。

5.5 幂等:防重试是转账的“安全气囊”

网络超时到底是扣款成功还是没成功?这是所有支付类业务都要正面处理的疑难问题。解决它靠的是幂等键。转账接口接入层收到请求后,先以订单号去查转账订单表,存在则直接返回上一次的处理结果,不存在才发起真正的转账事务。

有人问我,订单号查询会不会有性能瓶颈?其实完全不会。用订单号的唯一索引查一次,而且每次转账都只用一条极短主键查询,成本很低。面对极端连点或调用方重试时,这笔开销远小于资金错的代价。

6. 常见问题与排查实录

问:为什么我明明开了事务,整个事务内 SELECT 出来的余额还是旧值?答:你大概率用的可重复读,事务内第一条普通 SELECT 建立 Read View,后续快照读都以此为准。想读最新已提交数据,用当前读SELECT ... FOR UPDATE或FOR SHARE。

问:UPDATE 行数返回 0,但余额明明够,这是什么原因?答:先检查 UPDATE 条件里的 balance 字段是否和表结构一致,尤其是 DECIMAL 精度和类型转换。曾经有同事误把字段定义成 INT,金额在应用层按分存储,导致 SQL 里写balance >= 1判断出错,排查了半小时。字段约束和单位统一是隐形大坑。

问:同时给一个收款账户打 1000 笔钱,会不会出现同一行被串行更新、性能剧降?答:一定会的。收款账户在同一行上加款,天然是热点行。并发越高,锁等待越严重。要解决这个热点,常见思路是把收款账户余额拆成多个子账户,或者用异步汇总这类方案。这个优化属于架构层面,初版系统大可不必做,但你要心里有数。

问:MVCC 能不能防止“扣款成功后写入流水失败”?答:不能。流水表插入失败会导致整个事务回滚,扣款也随之回滚。MVCC 管的是可见性,事务原子性靠的是 undo log 和回滚机制,两者不冲突。做资金类系统时,你得把“任一环节失败即 ROLLBACK”写在事务边界能覆盖的范围内,不能把写流水放在事务外。

问:用了行锁是不是就安全了?要不要再加乐观锁 version 字段?答:看场景。行锁解决的是同一行并发更新的互斥问题,MVCC 解决的是读写隔离问题,version 乐观锁解决的是“读取期间数据被别人改了”的检测问题。对转账来说,余额扣减语句已带条件判断,行锁已提供互斥,再加 version 属于冗余设计;但如果你做的是修改用户资料这类“先读后改”弱一致性场景,乐观锁 version 查改非常有效。不要盲目堆机制,先分清冲突类型。

问:同城同行转账是否还需要“分布式事务”或“消息队列”?答:不需要。同库多行更新,一个本地事务就能搞定。即使以后拆库、拆成多个服务,也要先明确是否真的跨越了数据库实例,再决定引入方案,否则就是为架构复杂度自掏腰包。

我在实际排查过一个线上事故:由于一个定时任务对同一行数据做大范围更新,长时间持锁,导致大量转账事务进入锁等待,接口耗时飙到十秒。当时我们加了一整套监控,重点看information_schema.INNODB_TRX里活跃事务的持续时间、performance_schema.data_lock_waits里的锁等待关系,才定位到是同一把行锁引发的事故。现在每回设计新系统,我都会提前确认是否有“长事务 + 热点行”这种组合雷区。

最后再分享一个小技巧:建表时给账户表加一列updated_at,每次更新顺手维护它。排查锁问题时你会发现,一个长时间未更新的updated_at,往往意味着某个事务持锁已久,这比分析事务 SQL 直观得多。转账系统越往后做,排查手段和预防意识就越比某条 SQL 的写法值钱。

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

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

立即咨询