
你有没有认真想过银行转账那条确认按钮背后到底发生了什么转账可不是简单的从A账户扣钱往B账户加钱——只要并发一大、数据一多就会冒出一堆鬼问题同一个账户被两个人同时扣款余额变成负数两笔转账同时发起数据库直接报死锁业务侧重试请求结果把钱重复转了两遍……这些问题如果不从根本上理解数据库的事务和并发控制机制就算照着网上的代码抄一遍线上也会出事故。这篇文章我只想讲透两件事MySQL InnoDB 的 MVCC多版本并发控制到底是怎么回事以及基于这套机制一个同行转账的库内转账架构应该怎么设计。两件事看似独立实际上强相关——你搞懂了 MVCC才会明白为什么转账更新要用行锁、为什么要区分快照读和当前读、为什么事务隔离级别不能乱调。文章适用对象是后端开发、DBA 和对数据库原理感兴趣的架构师读完你不仅能说出 MVCC 的可见性规则还能直接落地一套具备幂等、防死锁、可排查的转账实现。1. 先弄清楚同行转账到底要解决什么问题1.1 转账业务的三条硬底线先把场景限定清楚。这里说的同行转账指的是同一个数据库实例、同一个 schema 内的两个账户之间的资金划转。也就是说不涉及跨系统、跨数据库的分布式事务所有操作都在一个 MySQL 事务能覆盖的范围内。这种场景是最常见的单体服务转账模型也是理解复杂分布式事务的前提。在这个场景里业务对底层存储有三条硬底线要求。第一资金不能丢、不能错。转出100元要么 A 和 B 都更新成功要么都不更新。这就意味着扣减和增加必须在一个数据库事务里要么全部提交要么全部回滚。这里说的错还包括精度问题比如余额字段如果用了浮点类型转几次账之后就可能会冒出 0.00000001 的尾差这在账务系统里是不可接受的。第二并发下不能出现脏账。如果 A 账户只有100元同时发起两笔各80元的转账最终余额不能变成 -60。也就是说对余额的扣减操作必须串行化同一时间只能有一个事务在修改同一行。这不是业务规则而是数据一致性规则——账户余额在任何时刻都不能被两个事务同时改动。第三业务侧的重试不能造成重复扣款。客户端发起转账时可能因为网络超时重试同一个请求服务端必须有能力识别这个请求我处理过了否则就会把钱重复转出去。这三条底线分别对应数据库的原子性Atomicity、隔离性Isolation和业务层的幂等设计。有意思的地方在于任何一条出了问题你最终去排查现场的时候看到的都是余额不对流水重复锁等待超时这类现象而根因往往就是 MVCC 和锁的使用姿势不对。1.2 MySQL 为什么能扛住这种高并发账务操作很多人在面试里背过 MVCC 的定义但真到了排查问题的时候不知道脏读、不可重复读、幻读分别对应哪些业务现象也不知道快照读和当前读在转账流程里各用于哪个环节。原因很简单理解停留在背概念没有和真实场景绑定。我习惯把 InnoDB 的这套并发控制机制拆成两道关卡来看。第一道关卡是锁。对于写操作UPDATE、DELETEInnoDB 通过行锁来保证同一时刻只有一个事务能修改同一行数据。这是数据库的物理防线。第二道关卡是 MVCC。对于读操作MVCC 通过保存多个版本的行数据让读操作不必等待写操作释放锁也不用读到未提交的脏数据。这是数据库的逻辑防线。两道关卡配合InnoDB 才能在高并发和强一致之间取得平衡。转账场景里扣款属于写必须走锁余额展示、流水查询属于读可以走 MVCC 的快照读。搞清这个分工你就理解了大半。2. MVCC 的底层实现拆解2.1 四个基础零件隐藏字段、undo log、read view、版本链MVCC 全称 Multi-Version Concurrency Control多版本并发控制。它的核心思想是一个数据行在 InnoDB 里不是只有一个当前值而是存在一条版本链记录了这行数据从诞生到现在的多个历史版本。读操作在读的时候根据自己的视角选择合适的历史版本从而做到不加锁也能读。支撑这套机制的有四个基础零件。第一个是聚簇索引里的三个隐藏字段。每个数据行上除了业务字段InnoDB 还会额外维护DB_TRX_ID6字节最后一次修改这一行的事务 ID。DB_ROLL_PTR7字节回滚指针指向 undo log 中该行历史版本的位置。DB_ROW_ID6字节单调递增的行 ID作为行的唯一标识如果表没有显式主键。别看这三个字段小而隐蔽MVCC 的整个可见性判断、回滚、快照读全部依赖它们。第二个是 undo log回滚日志。当一条 UPDATE 执行时InnoDB 会把旧版本的行数据写到 undo log 里然后把新版本的数据写到数据页中。undo log 里的旧版本数据通过 DB_ROLL_PTR 和新版本形成一条链这就是版本链。事务回滚时InnoDB 用 undo log 把数据恢复成旧值事务提交后undo log 里的历史版本也不会立刻删除因为可能还有事务正在用它做快照读。第三个是 read view中文叫一致性读视图。它的作用是回答一个问题站在这个视图生成的时间点哪些事务的修改对我可见可以把它理解成拍摄了一张数据库状态的照片。第四个是版本链上的可见性判断规则。有了上面三个零件InnoDB 通过一套规则判断行上的事务 ID 是否对当前 read view 可见不可见就沿着版本链向上找直到找到可见的版本。用生活类比undo log 就像书稿的修改历史每一处改动都记录在案随时能找回之前的措辞read view 就是你审稿时的时间点视角在这个时间点之前定稿的改动你能看到之后还没发生的改动你看不到正在别人手里改着的内容你也看不到。2.2 read view 的可见性判断规则逐条核对这是面试高频问题也是排查数据不一致问题的基本功。先看 read view 的结构它里面有四个关键成员m_ids生成 read view 时当前正在活跃未提交的所有事务 ID 列表。min_trx_idm_ids 中最小的那个事务 ID。max_trx_id生成 read view 时下一个即将分配给事务的 ID可以理解为系统当前已分配的最大事务 ID 1。creator_trx_id创建当前 read view 的那个事务自己的 ID。判断一行数据其上的 DB_TRX_ID 记为 trx_id是否可见规则如下条件判断结果解释trx_id creator_trx_id可见数据是自己这个事务修改的自己当然能看到trx_id min_trx_id可见修改该行的事务在 read view 生成之前就已经提交trx_id max_trx_id不可见修改该行的事务是在 read view 生成之后才申请的 ID相当于将来的数据min_trx_id trx_id max_trx_id且 trx_id 在 m_ids 中不可见事务还在活跃未提交属于未提交的脏数据min_trx_id trx_id max_trx_id且 trx_id 不在 m_ids 中可见事务虽然不是最早的一批但已经提交拿一个具体例子来核对。假设当前事务 T1 生成 read view 时系统里活跃的事务有 T2ID2和 T3ID3已经分配过最大的事务 ID 是 5所以下一个新事务会拿到 ID6。那么这个问题里 m_ids{2,3}min_trx_id2max_trx_id6。此时读某一行行上 trx_id1小于 min_trx_id说明修改这行的事务已经提交可见。行上 trx_id2在 m_ids 中事务未提交不可见。行上 trx_id5不在 m_ids 中且介于 min 和 max 之间说明该事务在 read view 生成前已经提交可见。行上 trx_id6大于等于 max_trx_id这是生成 read view 之后才出现的事务不可见。我建议大家把这五个条件背下来因为它们太常用了。遇到为什么我读不到刚提交的数据这类问题第一反应就应该是拿这些条件去核对对应的 trx_id 是在 read view 的哪个区间。2.3 隔离级别决定 read view 的刷新时机read view 的结构是一样的但不同隔离级别下read view 的生成时机完全不同。READ COMMITTED读已提交级别下事务中每次执行 SELECT 都会重新生成一个新的 read view。所以这个隔离级别下事务内两次 SELECT 可能读到不同的数据这就是不可重复读。REPEATABLE READ可重复读级别下事务中只在第一次 SELECT 时生成 read view之后一直复用。这就是 MySQL 默认隔离级别 RR 下可重复读的实现原理——不是靠锁锁住其他事务而是靠一个固定的 read view 让整个事务始终看到同一份数据视图。READ UNCOMMITTED 不生成 read view直接读最新版本所以会读到未提交的脏数据。SERIALIZABLE 则把读操作也强制变成加锁读基本不依赖 MVCC性能最差但隔离性最强。这里必须强调一个让很多人绕晕的点MVCC 解决的是普通 SELECT快照读的一致性问题。对于 UPDATE、DELETE、SELECT ... FOR UPDATE 这类当前读它们永远读的是最新版本并加行锁不通过 read view 判断。当前读遇到锁冲突时只能等待锁释放所以并发性能比快照读低。3. 快照读与当前读转账架构里怎么分工3.1 两大读机制的区别很多初学者以为 SELECT 就是一个东西直到做了账务系统才明白 SELECT 和 SELECT ... FOR UPDATE 是两种完全不同的操作。快照读consistent read和当前读locking read的区别我用一张表拆清楚对比项快照读当前读执行方式普通 SELECTSELECT ... FOR UPDATE、UPDATE、DELETE加锁行为不加锁对命中的行加排他锁事务结束才释放数据版本RR 下用事务第一个 read viewRC 下用本次语句的 read view始终读取最新已提交版本典型用途查询流水、展示余额、报表统计资金扣减前锁定账户、更新余额关键点在于当前读是读和锁绑在一起的。UPDATE 在执行时不仅要读最新值还要把命中的行锁住防止别人同时改。而快照读因为不产生锁读操作之间永远互不阻塞。3.2 读读、读写、写写MVCC 和锁各管一段刚才说到MVCC 的核心能力是读不加锁、读不阻塞写。那么各种并发问题分别由谁解决脏读RC 及以上隔离级别MVCC 通过 read view 保证读不到未提交数据。不可重复读RR 下 MVCC 通过固定 read view 保证多次读一致。幻读RR 下 MVCC 对快照读可以避免幻读但对当前读无法单靠 MVCC 解决需要间隙锁gap lock和临键锁next-key lock配合。丢失更新MVCC 不解决这个问题。两个事务同时扣同一账户余额如果都先 SELECT 再 UPDATE就会出现经典的丢失更新两个事务都读到100元各自扣了80最后余额被写成20而不是 -60。转账场景中最容易出问题的不是读而是并发的写。两个事务同时扣同一账户的余额MVCC 在这里帮不上忙必须靠行锁当前读来保证写写互斥。简单总结读读并发靠 MVCC读写并发靠 MVCC写写并发靠锁。所以转账架构里账务更新必须走当前读加锁普通查询走快照读。3.3 RR 隔离级别下先快照后更新的坑这是一个非常实战的坑不少老手都踩过。场景事务 A 中先执行普通 SELECT 查询余额比如读到余额是100。然后执行 UPDATE 修改余额为20。再执行一次普通 SELECT 查询余额。在 RR 隔离级别下第二次 SELECT 复用的还是第一个 read view读到的仍然是旧值100而不是刚更新过的20。这个现象会让业务觉得诡异我明明刚更新成功为什么再查一遍又是更新前的值尤其在一些展示和更新混合的逻辑里稍不注意就会把旧值展示给用户或者依据旧值做错误判断。解决办法有两条思路对要更新的数据先 SELECT ... FOR UPDATE 获取最新值不要在事务内先做无锁快照读。如果业务必须先读后写再读统一用当前读FOR UPDATE保证每次都看到最新值。这个坑也解释了为什么转账扣款的实现里大家习惯性先 SELECT ... FOR UPDATE而不是先普通 SELECT 再 UPDATE。前者保证你锁住的是最新余额后者则可能在 read view 的干扰下基于旧值做判断。4. 同行转账架构落地表与事务全流程4.1 表结构设计给资金字段留出余力说完了原理进入实战。先设计两张表账户表 t_account 和转账流水表 t_transfer_log。CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, account_no VARCHAR(32) NOT NULL COMMENT 账号, user_name VARCHAR(64) NOT NULL COMMENT 户名, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT 可用余额, status TINYINT NOT NULL DEFAULT 1 COMMENT 账户状态1正常0冻结, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号备用, gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_account_no (account_no) ) ENGINEInnoDB COMMENT账户表; CREATE TABLE t_transfer_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, transfer_no VARCHAR(64) NOT NULL COMMENT 转账流水号业务幂等键, from_account_no VARCHAR(32) NOT NULL COMMENT 转出账号, to_account_no VARCHAR(32) NOT NULL COMMENT 转入账号, amount DECIMAL(18,2) NOT NULL COMMENT 转账金额, status TINYINT NOT NULL COMMENT 1成功 2失败 3处理中, fail_reason VARCHAR(255) DEFAULT NULL COMMENT 失败原因, gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_transfer_no (transfer_no) ) ENGINEInnoDB COMMENT转账流水表;几个字段设计的考虑值得展开。account_no 必须是唯一索引。业务维度的账号是逻辑主键但物理主键用自增 id避免随机字符串插入到聚簇索引时导致页分裂。balance 用 DECIMAL(18,2) 而不是 FLOAT 或 DOUBLE。DECIMAL 是定点数精确保存储FLOAT/DOUBLE 是浮点数有精度误差。资金操作不能用四舍五入的近似处理这一点没有商量余地。18,2意味着支持到万亿级别对绝大多数业务够用。status 字段要保留。账户可能被冻结、销户转账前必须校验状态不能只盯余额。比如一个销户账户如果还能收款钱进去了却无法处理最后账就会乱。version 字段是乐观锁的备用方案。如果你不想用 SELECT ... FOR UPDATE 锁行也可以用版本号做乐观更新。但账务系统里我倾向于悲观锁方案因为事务本身已经把隔离性、回滚、锁等待都处理好了直接用 FOR UPDATE 最成熟心智负担最小。乐观锁适合读多写少的场景要做失败重试账务场景写冲突频繁重试成本高。4.2 转账事务的原子操作序列设计好表之后转账的核心逻辑就是一组原子操作序列。1. 生成全局唯一流水号 transferNo 2. START TRANSACTION 3. 按固定排序规则确定加锁顺序稍后解释 4. 锁定转出账户 SELECT account_no, balance, status FROM t_account WHERE account_no ? FOR UPDATE; 校验 status1正常balance amount 5. 锁定转入账户 SELECT account_no FROM t_account WHERE account_no ? FOR UPDATE; 校验 status1正常收款账户不要求余额 6. 扣减转出账户 UPDATE t_account SET balance balance - ? WHERE account_no ?; 7. 增加转入账户 UPDATE t_account SET balance balance ? WHERE account_no ?; 8. 插入流水 INSERT INTO t_transfer_log(transfer_no, from_account_no, to_account_no, amount, status) VALUES (?, ?, ?, ?, 1); 9. COMMIT 异常时 ROLLBACK并更新流水状态为失败第4步非常关键。先 SELECT ... FOR UPDATE 锁定转出账户这一行就属于当前事务了。此时其他事务再尝试 UPDATE 这一行会阻塞直到当前事务提交或回滚。第5步锁定转入账户同理。锁定了两行之后第6和第7步的 UPDATE 就已经保证在两行都被我锁住的前提下执行不会出现并发打架。第6步的 UPDATE 我没有带 balance ? 条件因为第4步已经校验过了。这里有两种风格一种是先锁后校验再更新清晰、好排查另一种是一条 UPDATE 带条件比如直接写UPDATE t_account SET balance balance - 100 WHERE account_no A AND balance 100通过影响行数判断是否扣款成功。两种都能用我更推荐第一种因为转账涉及两个账户你需要在更新前一次性看清楚转出账户的状态和余额提前发现异常可以早点回滚避免做了扣款动作才发现转入账户有问题。第8步的流水插入必须和资金变动在同一个事务里。流水是账务凭证必须和资金变动同生共死。如果流水单独一个事务可能出现资金成功但流水丢失对账时无法解释。这里还要说一句所有操作做完后COMMIT 之前其他人是看不到这笔转账的中间状态的。转账过程中如果有人用快照读查询余额读到的是事务开始前的旧快照如果有人用当前读查询会阻塞在第4、5步的锁上。这两种表现分别来自 MVCC 和行锁恰好呼应前面的原理。4.3 加锁顺序解决并发互转死锁的关键两个账户互相转账时如果加锁顺序不一致就会死锁。看这个经典场景事务1A 转给 B先锁 A 再锁 B。事务2B 转给 A先锁 B 再锁 A。同时发生事务1持有 A 的锁在等 B 的锁事务2持有 B 的锁在等 A 的锁两个事务互相等待形成死锁。InnoDB 检测到死锁后会回滚其中一个事务通常回滚代价较小或持有锁较少的那一个另一个事务继续执行。业务侧会收到 1213 错误。但这种撞车会浪费性能而且失败的事务还需要重试用户体验不好。解决思路非常简单所有事务都按照同一个顺序加锁。最通用的做法是对两个账号做排序比如按 account_no 字典序较小的账号先锁较大的账号后锁。这样无论是 A 转 B 还是 B 转 A锁的顺序都是先锁小号再锁大号就不会出现循环等待。还有一种做法是按账号哈希值排序。原理一样就是要保证任何转账事务看到的锁顺序是全系统一致的。排序逻辑要放在事务开始之前不能等到加锁的时候现算。有了统一的加锁顺序事务2虽然也发起转账但它在申请第一把锁时就会阻塞等待事务1释放而不会持有反向的锁去制造死锁。代价是并发度稍微降低但换来了稳定性和可预期性这在资金场景里是非常划算的。4.4 幂等与重试业务层的最后一道防线数据库层面解决了原子性和并发但还有一个问题是数据库管不了的客户端重试。用户点了一次转账网络超时了客户端发起重试。如果服务端不处理这条转账请求就被执行了两次钱就重复转了。解决方案是在业务侧做幂等。做法是给每次转账生成一个全局唯一的业务流水号 transfer_no这个流水号作为转账请求的幂等键。数据库层面用唯一索引 uk_transfer_no 兜底。处理流程里进入转账处理逻辑后先尝试插入一条 status3处理中的流水记录。如果 transfer_no 已经存在唯一索引会报出 Duplicate entry 错误说明这个请求之前已经处理过直接查询原流水状态返回即可。如果插入成功说明是新请求放行进入转账事务。转账事务提交后把流水状态更新为成功。这样就算客户端重复发了一百次同一个请求只有第一次真正执行了转账后面的请求都会被唯一索引拦住。注意幂等键生成要放在最前端用UUID或者雪花ID都行但必须保证每次真实转账请求携带的是同一个流水号。如果客户端重新发起了一个新的流水号那就不是重试而是新转账请求会正常执行。5. 常见问题与排查实录5.1 余额更新静默失败有一种很隐蔽的 bug业务代码用了带条件的 UPDATE但不检查影响行数。比如扣款语句写成UPDATE t_account SET balance balance - 100 WHERE account_no A AND balance 100。当余额不足时这条 UPDATE 执行成功但不影响任何行。如果业务代码没检查影响行数就继续执行转入账户的增加操作最终结果是转入方账户加了100转出方账户一分没扣——账不平。正确的做法是执行完扣款 UPDATE 后必须检查 affected rows如果为 0立即抛出异常回滚整个事务并给出明确错误码余额不足。这种静默失败比数据库直接报错更危险因为没人注意到账已经不平了往往要等到对账环节才发现。5.2 Deadlock found when trying to get lock报错文本是ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction。出现这个错误时InnoDB 已经自动回滚了其中一个事务或者让你自己回滚业务需要做的第一件事不是慌而是找出死锁现场。排查步骤执行SHOW ENGINE INNODB STATUS查看 LATEST DETECTED DEADLOCK 段落。里面会列出两个事务的 SQL 语句、占用的锁、等待的锁以及发生死锁的时机。看这两个事务是否在加锁顺序上出现了交叉。如果是加锁顺序问题按上一节的办法统一排序即可。如果没有加锁顺序问题就要检查是不是事务持锁时间太长。比如事务里做了外部接口调用、循环查询等耗时操作导致锁一直不释放其他事务堆积。这种问题的优化方向是缩小事务范围把耗时操作移出事务。5.3 事务内快照读与当前读混用导致的数据错乱前面第3章提到的先快照后更新的坑真正遇到时会表现成什么业务现象我自己遇到过类似的事务内先做了普通 SELECT 查询余额用于页面展示然后做了余额扣减之后又查询一次余额用于返回结果返回给用户的是更新前的旧值。用户刚转完账页面上显示的余额却没变于是重复提交转账。还有一种更隐蔽的在批量账户统计时用了快照读统计期间其他事务在进行转账最终统计结果是某一时间点的一致性快照这本身是符合可重复读语义的。但如果业务团队不知道这个特性误以为统计结果是最新值就可能基于过期数据做决策。所以我现在的习惯是在一段业务逻辑里明确区分哪些数据需要最新值、哪些数据可以读快照。需要最新值并且后续要更新的数据一律用当前读纯展示、报表类数据用快照读完全够还能避免锁阻塞。5.4 锁等待超时innodb_lock_wait_timeout 的事故当前读遇到锁冲突时不会立刻报错而是等待。默认等待时间是 50 秒innodb_lock_wait_timeout 参数。如果 50 秒内持有锁的事务还没提交等待方会报ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction。这个 50 秒默认值是双刃剑。一方面它给了长事务足够的缓冲时间另一方面高并发账务场景里一个事务卡 50 秒会让大量用户请求堆积在数据库上甚至拖垮整个服务。排查锁等待的思路先查information_schema.innodb_trx看有哪些事务是 RUNNING 状态运行了多久执行了什么 SQL。再用performance_schema.data_lock_waits看阻塞关系找到谁在等谁的锁。一般是某个事务忘了提交、或者事务里混入了网络调用把锁攥在手里不放。实操层面我会把账务核心接口的 innodb_lock_wait_timeout 调小比如 5 秒。锁等待超过 5 秒就快速失败提示系统繁忙请稍后重试让用户快速感知而不是傻等 50 秒。这个参数在 MySQL 里既支持全局设置也支持 session 级设置可以在连接池层面做区分核心转账连接用短超时报表查询连接用长超时或默认值。最后再分享一点个人体会。我见过不少人把转账架构当成拼接代码来做抄一套表结构、抄一段事务逻辑上线跑几天发现各种数据对不上然后开始加分布式锁、加消息队列、加各种重试补偿越改越复杂。实际上你只要把 MVCC 的 read view 机制、快照读和当前读的区别、行锁的加锁顺序想透彻这套转账架构几乎是可以推导出来的——表怎么建、锁怎么加、流水怎么记每一步都能从数据一致性的要求里找到理由。技术方案不是背出来的是推出来的。