1. 一次线上故障的复盘:两个账户互转,为什么压测到300并发就“卡死”了
事情要从某次银行内部的同行转账模块压测说起。我们当时在做一个账户体系迁移的模拟项目,大额转账、小额转账、代发工资这些场景都跑在一套基于MySQL的账务系统上。功能测试全部通过,一上压测,问题就来了:并发提到300左右,转账接口的P99迅速飙升到5秒以上,后台日志里全是锁等待超时,甚至零星出现死锁回滚。
排查的时候看数据库,发现大量事务卡在一条SQL上:SELECT * FROM account WHERE account_no = ? FOR UPDATE。再往前翻监控,发现A账户转B账户、B账户转A账户这种“互转”请求只要同时进来,两个事务就会互相等对方释放行锁,最终触发innodb_deadlock_detect回滚其中一方。
那一刻我意识到,很多人(包括当时的我)把MySQL的MVCC理解成了“万能并发工具”,觉得有了它,读和写就能完全各跑各的。但实际上,MVCC解决的只是读多写少场景下的读写阻塞问题,它本身不负责写入的一致性。真正兜底的是InnoDB的行锁、事务隔离级别和你在SQL里写下的那行WHERE条件。这篇文章就把这两件事彻底讲透:MVCC到底是怎么工作的,银行同行转账这种资金类场景又该怎么利用它构造成一个不丢钱、不超扣、不卡死的架构。
2. MVCC底层机制拆解:三个隐藏字段、一条版本链、一份ReadView
2.1 InnoDB凭什么能给一行数据保存“多个版本”
MVCC,全称Multi-Version Concurrency Control,多版本并发控制。它的核心做法是:一行数据在数据库里不只是存一个当前值,而是在Undo Log里保留一串“历史版本”。某个事务去读的时候,可以根据自己事务的可见范围,挑一个合适的版本来读。
这个能力依赖InnoDB聚簇索引行记录上的三个隐藏字段:
| 隐藏字段 | 作用 | 说明 |
|---|---|---|
DB_TRX_ID | 最近一次修改这行数据的事务ID | 每次UPDATE会更新这个字段 |
DB_ROLL_PTR | 回滚指针 | 指向Undo Log中该行之前版本的记录,把新版本和旧版本串成链表 |
DB_ROW_ID | 隐式自增ID | 表没有显式主键时,InnoDB用它作为聚簇索引键 |
举个例子。假设账户A余额初始是500元,事务ID为10的事务把它改成400元,接着事务ID为15又把它改成300元。那么当前聚簇索引行里存的是“300元 +DB_TRX_ID=15+DB_ROLL_PTR指向400元那份Undo记录”;而400元那份Undo记录的DB_ROLL_PTR又指向500元那份Undo记录。这样就形成了一条从新到旧的版本链,最新版本在最前面,旧的被回滚指针串在后面。
2.2 ReadView:一个事务“看得到哪些版本”的判断标准
版本链有了,接下来就是决定某个事务能看到链上的哪一版。这个判断逻辑由一个叫ReadView的结构体完成。它内部维护四个关键属性:
creator_trx_id:创建这个ReadView的事务IDm_ids:创建ReadView那一刻,当前未提交事务ID的集合min_trx_id:m_ids中的最小事务IDmax_trx_id:下一个将要分配的事务ID,可以理解为“未来事务”的分界线
判断某一版本是否可见时,从版本链最前面(最新版本)开始,逐个回溯。规则简化为四句话:
- 如果版本的事务ID等于
creator_trx_id,说明是自己改的,可见。 - 如果版本的事务ID小于
min_trx_id,说明该事务在ReadView创建前已经提交,可见。 - 如果版本的事务ID大于等于
max_trx_id,说明该事务在ReadView创建后才启动,不可见,继续往前找。 - 如果
min_trx_id <= 事务ID < max_trx_id,看它是否在m_ids里:在,说明还没提交,不可见;不在,说明已提交,可见。
这个机制看起来抽象,换到转账场景里就很好理解了。事务A在10点整开启并查询账户B的余额,生成了它的ReadView。此时事务C正在扣B的钱但还没提交,那么事务A沿着版本链找,只可能找到事务C修改之前的那个版本,B的余额在A眼里依然是扣减前的金额。这就是为什么MySQL的REPEATABLE READ隔离级别下,同一个事务多次查询同一个账户,得到的结果始终一致。
2.3 读提交和可重复读,差就差在“ReadView什么时候生成”
MySQL默认的隔离级别是REPEATABLE READ,简称RR;很多人熟悉的READ COMMITTED,简称RC。两者的数据读取行为差异,根源就在ReadView的生成时机:
- RR模式:事务里第一次执行SELECT时才生成ReadView,之后整个事务都复用这一个ReadView。所以RR能保证可重复读——一个事务内多次查询结果一致。
- RC模式:每次执行SELECT都重新生成一个ReadView,能看到其他事务最新提交的数据。所以RC存在不可重复读现象。
注意,RC模式下,同一个事务里前一条SELECT和后一条SELECT可能看到不同的余额,这在资金对账场景里有时候让人抓狂。但RC也有好处:ReadView不长期持有,Undo Log可以更快被purge,版本链的膨胀压力小很多。两种模式没有绝对好坏,后面讲隔离级别选型时再细说。
3. 银行同行转账的事务实现:MVCC管读,行锁管写,别把两者的职责搞混
3.1 扣款为什么不能直接依赖MVCC
我见过一种典型的错误理解:“既然MVCC能保证读到一致的数据,那转账扣款时先查余额,再UPDATE,不就安全了吗?”
这个想法危险在两点。
第一,MVCC的“快照读”只会读到历史版本,不读取最新已提交版本。如果两个事务同时扣A账户的钱,都先用普通的SELECT balance FROM account WHERE account_no='A'读到余额是500元,然后各自在业务代码里计算“扣完剩400”,再执行UPDATE account SET balance=400 WHERE account_no='A',后提交的那个事务会把前一个事务的扣减结果覆盖掉。这就是典型的丢失更新,余额直接对不上。
第二,MVCC不参与写入锁的调度。真正的写入安全,靠的是UPDATE语句本身触发的当前读和自动加上的排他锁。所谓当前读,就是读取数据的最新已提交版本,并且给这行加锁,锁释放前其他事务无法修改它。UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE都属于当前读。
3.2 转账代码里最核心的一条SQL:条件更新
资金类操作有个通用套路,叫“条件更新(Optimistic Conditional Update)”。扣款时不要先SELECT再UPDATE,而是直接拼一个带余额校验条件的UPDATE:
UPDATE account SET balance = balance - 100, version = version + 1 WHERE account_no = 'A' AND balance >= 100;这条SQL的执行流程是这样的:MySQL先对account_no='A'这行做当前读,拿到最新余额,判断balance >= 100是否成立,成立则执行扣减并返回影响行数1,不成立则返回影响行数0。应用层根据影响行数判断扣款是否成功,0就说明余额不足,直接返回“余额不足”即可。
这个方案的优点在于原子性——判断余额和扣减是在同一行锁的保护下连续完成的,没有中间空隙,不需要额外加分布式锁。但这种写法有一定争议:如果业务侧还需要读取账户的其他信息,比如账户状态、冻结金额,那条件更新就没法覆盖全部逻辑,还是得配合SELECT ... FOR UPDATE。接下来看完整流程。
3.3 一次同行转账的事务主流程长什么样
以同一个MySQL实例内的同行转账为例,最稳妥的流程是这样:
- 开启事务。
- 按固定顺序(比如
account_no从小到大)锁定付款账户和收款账户,使用SELECT ... FOR UPDATE。 - 判定付款账户状态正常、余额充足。
- 扣减付款账户余额。
- 增加收款账户余额。
- 写入转账流水。
- 提交事务。
这里有两个极其重要的细节,都是实践中用真金白银换来的教训。
第一个细节:加锁顺序必须一致。如果事务X先锁A再锁B,事务Y先锁B再锁A,两个事务就会互相等待,发生死锁。最简单有效的规避办法是,在代码里对账户号排序后再加锁。例如:
List<String> accountNos = Arrays.asList(fromAcc, toAcc).stream().sorted().collect(Collectors.toList()); for (String accNo : accountNos) { AccountDO account = accountMapper.selectForUpdate(accNo); // 按accNo判断是付款方还是收款方,走不同处理逻辑 }第二个细节:当前读锁定的是聚簇索引记录。账户表查询条件一定要走索引,否则InnoDB会退化为锁定多条记录甚至全表范围,并发能力急剧下降。账户号必须有唯一索引,这是底线。
4. 落地一个同行转账架构:表结构、请求幂等、事务边界全都要管
4.1 账户表和流水表怎么设计才不踩坑
一个简化的账务库,至少包含两张核心表:账户表和转账流水表。
账户表:
CREATE TABLE account ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_no VARCHAR(32) NOT NULL COMMENT '银行账号', user_id BIGINT NOT NULL COMMENT '用户ID', balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '可用余额', frozen_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '冻结金额', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_account_no (account_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;转账流水表:
CREATE TABLE transfer_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, request_id VARCHAR(64) NOT NULL COMMENT '幂等请求号', from_account VARCHAR(32) NOT NULL, to_account VARCHAR(32) NOT NULL, amount DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME DEFAULT NULL, UNIQUE KEY uk_request_id (request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为什么账户金额一定要用DECIMAL(18,2)而不是DOUBLE?因为浮点数是二进制存储的,0.1这样的十进制小数无法精确表示,资金计算里哪怕差一分钱都是事故。DECIMAL是字符串形式的十进制存储,精度有保证。
4.2 幂等设计:网络重试和前端重复点击的防线
线上转账接口一定会被重试。客户端超时重发、消息队列重复消费、运营后台人工重试,任何一个入口没做幂等,都有可能让同一笔转账执行两遍。
推荐做法是request_id唯一索引配合“先插入后处理”的流程:
- 接口收到请求时,生成或透传
request_id(客户端生成,或用时间戳+随机数+机器ID拼装)。 - 开启事务,尝试向
transfer_log插入一条status=0的流水,主键冲突则说明重复请求,直接返回上一次处理结果。 - 插入成功才继续加锁、扣款、入账。
- 事务提交后,把流水状态更新为成功。
这里有个成熟的变体:有些人会把“登记流水”和“账户扣款”放在同一个事务里,避免“流水登记了但扣款失败”的不一致。我的经验是,转账操作本身事务很短,直接放一起即可;流水表状态字段在事务内先置为“处理中”,扣款入账成功后再置为“成功”,中间状态用于故障排查。
4.3 事务边界:不要在事务里发短信、调外部接口、执行慢SQL
这是转账系统架构设计里最容易被新手忽略、后果却最严重的一环。
转账主流程的事务里,只允许做数据库操作,比如加锁、更新余额、写流水。发短信通知、推送App消息、调用风控外部接口,这些通通不能放在事务里。原因很简单:事务还没提交,行锁还握着,远程调用动辄几百毫秒甚至超时,就等于把其他转账请求活活堵在锁外面。压测时出现的几百个线程同时卡住,多数情况就是有人在事务里干了大外部操作。
正确的做法是事务提交成功后,把“需要发送通知”这件事记到本地消息表,再通过异步任务扫表发送。事务只负责账务本身,通知结果不影响资金一致性。这个思想在金融系统里叫“最终一致”,主链路保证资金安全,附属链路异步收敛。
4.4 单库事务就够了吗:同行转账在什么情况下才需要分布式事务
严格意义上的“银行同行转账”,如果账户在同一套账务系统、同一个MySQL实例,那本地事务 + InnoDB行锁 + 唯一索引幂等已经完全够用。ACID由数据库保证,不需要引入任何分布式事务框架。
但如果账户体系拆成了多个库,比如按用户ID分库,A账户在库1,B账户在库2,那本地事务就管不住了。常见方案是TCC、SAGA或事务消息。以TCC为例:Try阶段冻结双方金额,Confirm阶段实际扣减/入账,Cancel阶段释放冻结。TCC的本质不是MVCC能覆盖的范畴,属于应用层分布式事务的编排,需要自己做幂等、防悬挂、空回滚。
我在模拟项目里把“同行转账”限定在单库内先做完做对,再去扩展分库方案。顺序不能反,因为分布式事务的很多坑,根子还是对单库事务和锁的理解不到位。
5. 转账场景实战踩坑:死锁、长事务与隔离级别的真实选择
5.1 互转死锁的完整排查链路
死锁在转账场景里是最常见的“隐形杀手”。前面提到固定锁顺序能规避,但排查过程也值得完整记录一遍。
第一次发现死锁时,先看MySQL的错误日志,里面有类似这样的内容:
LATEST DETECTED DEADLOCK *** TRANSACTION 1: UPDATE account SET balance = balance - 100 WHERE account_no = 'A' *** TRANSACTION 2: UPDATE account SET balance = balance - 100 WHERE account_no = 'B'单纯看单条SQL看不出问题,要结合SHOW ENGINE INNODB STATUS的完整输出,确认两个事务各自的持锁顺序。当时的现象是:事务1已持有A锁想拿B锁,事务2已持有B锁想拿A锁,环形等待,互不相让。死锁检测机制每隔一段时间扫描一次,检测到就回滚其中一个事务。客户端报错是Deadlock found when trying to get lock; try restarting transaction。
常规解法有两个。一是代码里统一排序加锁,上面已经写过。二是开启innodb_deadlock_detect=OFF然后用innodb_lock_wait_timeout兜底——但金融类系统我不建议关死锁检测,宁可回滚一个事务让客户端重试,也不能让事务无限等下去。
5.2 长事务对MVCC版本链的“反噬”
MVCC不是没有代价的。每行数据被修改一次,旧版本就会存到Undo Log里,版本链变长。正常情况下,事务提交后,purge线程会把不需要的旧版本清理掉。但如果有一个很老的事务一直不提交,它的ReadView就会一直认为那些旧版本“可能还需要读到”,于是Undo Log越积越多,版本链越来越长,每次当前读都要沿着链回溯判断,查询性能肉眼可见地下降。
转账场景里的长事务来源,最常见的就是事务里做了远程调用,或者事务开始后大量时间花在非核心校验上,真正扣款只有几十毫秒,事务却撑了好几秒。排查长事务可以查information_schema.innodb_trx:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;重点关注trx_started比较早、事务里SQL数量却很少的连接。定位到后,把它从大事务里拆出来,尽量做到“校验在前,事务在后”。
5.3 隔离级别选型:RR和RC到底选哪个
这个争论在转账系统里尤其绕不开。MySQL默认RR,但不少互联网支付架构师喜欢改成RC。我的观点是:先明确你要解决什么问题,再选隔离级别,不要盲从。
资金主链路里,真正保护不超扣、不丢钱的,是行锁和条件更新,不是隔离级别。MVCC管的是读一致性:RR下同一事务里多次“快照读”结果一致,RC下可能不同。转账系统如果做日终对账,通常希望交易过程中查询余额是某一个时间点的一致性快照,RR更贴近这种语义。
RC也有一些实际好处:不加间隙锁(Gap Lock),并发插入时锁冲突少;ReadView每次重建,旧版本释放更快,Undo Log膨胀压力小。如果业务上可以接受“同一个事务里两次读余额可能不同”,RC也是可以用的。
实务上比较稳妥的组合是:账务主流程用RR,财务对账、报表等只读场景用RC或直接只读从库。报警监控里盯住innodb_trx表,一旦事务持续时间超过阈值立刻告警,隔离级别的选择就交给具体业务去权衡,而不是一刀切。
5.4 压测验证的正确姿势
架构落地后一定要压测,而且不能只测“转账成功路径”。我的建议是至少压三组场景:
- 正常并发转账,验证P99和吞吐量,重点看锁等待时间。
- 单账户热点压测:所有流量都往同一个账户打,比如代发工资场景,几万笔钱同时打到同一个账户,这是最考验行锁和MVCC版本链的场景。
- 幂等重试压测:模拟重复请求,确认
request_id唯一索引能挡住重复入账。
压测过程里把innodb_lock_wait_timeout调小到5秒以内,比默认的50秒更贴近线上超时预期。一旦出现锁等待超过3秒,就说明事务粒度或锁顺序有问题,需要回头检查。
6. 关于MVCC和转账架构,我最后想说的几句话
回到最初的那次压测故障。问题其实不是MySQL不够快,而是我们对并发控制机制的理解有偏差。MVCC让读不阻塞写,行锁让写不互相覆盖,两者各管一摊,配合好了,单机能扛住的转账并发相当可观。
我自己的实操体会是:遇到资金类场景,优先级永远是“先保证当前读加锁的顺序一致,再追求SQL优化,最后才考虑换存储”。很多团队一上来就想引入分布式事务中间件,反而把单库内几个索引、一把锁就能解决的问题复杂化了。
如果这篇文章只能留下一句话,那就是:理解MVCC,要记住它管的是“读”,不管“写”;做好转账,要记住所有写都必须走当前读加锁,并且锁的获取顺序永远保持一致。这两点想清楚,大部分转账相关的并发问题都能在动手写代码之前就避掉。