摘要:在一个平常的夜晚,收到业务反馈某个检测单流程卡住,不能继续往下走。接到反馈后,连忙上线排查。发现问题点在一个诡异的现象——数据库更新消失:在事务中,先更新一行记录,后读取该行,读取到竟为更新前的旧值!
分享概要
一、问题背景
二、有什么场景可能会出现这样的问题?
主从同步延迟?事务没有生效?快照读?三、Update执行流程
源码分析启发四、场景复现
表结构和数据步骤五、总结
注: 本文使用的MySQL版本5.7.44, 事务隔离级别为REPEATABLE-READ。
一、问题背景
在一个平常的夜晚,收到业务反馈某个检测单流程卡住,不能继续往下走。接到反馈后,连忙上线排查。发现问题点在一个诡异的现象——数据库更新消失:在事务中,先更新一行记录,后读取该行,读取到竟为更新前的旧值!
接口对应的业务逻辑大致有以下几步:
用户选择一批初始状态为10的检测单ID发起流转;后端服务获取检测单对应的异常信息,将没有异常的检测单的状态修改为 20。如果有异常的,则不修改状态;最后根据检测单状态和其他信息执行不同的业务逻辑。以上步骤均在一个事务内执行。
假设第一步获取到的检测单id列表为[1001,1002],第二、第三步简化流程如下:
# T0 select获取检测单的异常记录select id, exception_type, qc_order_id from qc_order_exceptionwhere qc_order_id in (1001, 1002)# T1 执行如下SQL,ID为1001, 1002更新检测单记录的状态。 注: 更新前 status = 10update qc_orderset status = 20where id in (1001, 1002); # T2 执行如下SQL,通过ID获取检测单信息。 注:T1更新为satus=20的记录,可能读取出来的 status = 10select id, status, from qc_order where id in (1001, 1002)# T3 根据检测单状态,执行不同的业务逻辑。 if (qcOrder.getStatus == 20) { // 检测单无异常 log.info("逻辑1"); // 正常情况:1001、1002都执行这里逻辑。打印日志: "逻辑1"} else { log.info("逻辑2"); // 偶发异常现象:1002走上面逻辑 1001走了这里的逻辑。 打印日志: "逻辑2"。但分明T1时刻ID = 1001这条记录的status应该已经被更新为了20}接到业务反馈后,我们在打印日志: "逻辑2" 的分支中,加了监控告警,触发告警后,检测单没有后续的操作。这时去查看数据库中检测单的状态,没有异常的检测单的状态均为 status = 20(检测单最后状态是对的,但是在处理业务逻辑时,可能读取出旧值 status=10)。
我们观测日志发现,status被更新为20(即没有异常)的一批检测单,大部分情况,该批检测单内所有检测单执行打印日志: "逻辑1";小部分情况,比如上面举例的[1001,1002],这批检测单中,ID=1001的检测单执行:打印日志: "逻辑2"。——出现了奇怪的“更新消失”现象!
遇到这种偶发性"更新消失"的场景,此时我们表情如下:
二、有什么场景可能会出现这样的问题?
1. 主从同步延迟?
如果T2时刻,获取数据库记录时是通过数据库从节点读取,可能因为主从同步延迟的原因,导致读取到数据库旧值。为什么不是这个原因?T2时刻,通过断点com.alibaba.druid.pool.DruidPooledConnection(项目使用Druid连接池)的方法,确认T1和T2时刻执行SQL是用同一个数据源。所以不可能是主从同步延迟的原因。2. 事务没有生效?
如果事务没有生效时,存在 A 事务修改记录的status = 20,事务B又修改status = 10。如果没有事务,事务A可以看到事务B的修改。为什么不可能是这个原因?业务逻辑方法上有使用声明式事务@Transctional,断点后,确认线程栈中包含org.springframework.transaction.interceptor.TransactionInterceptor.invoke,可以判定所有操作均在同一个事务中执行。在测试环境复现场景时,在事务未提交时,记录修改,在其他事务不可见。3. 快照读?
ReadView
事务级别为 REPEATABLE-READ 时,则事务第一次执行 SELECT 时会生成一个 ReadView ,后续所有读操作复用该 ReadView。ReadView 的内容为:
m_ids:在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务"指的是启动了但还没提交的事务。min_trx_id:在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。max_trx_id:这个并不是 m ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的id 值,也就是全局事务中最大的事务 id 值 +1。creator_trx_id:创建该 Read View 的事务的事务 id。快照读流程
当需要读取某行记录流程大致如下:
InnoDB 会先会定位到该行的最新版本(即当前数据页中的最新记录)。遍历 Undo Log 版本链找到一个当前 ReadView 可见的版本记录: 从最新版本开始,沿着行记录的DB_ROLL_PTR指针(指向旧版本数据的指针)遍历 Undo Log 中的历史版本链,通过行记录的 DB_TRX_ID(最后修改该行记录的事务ID)判断是否满足可见条件,条件如下:如果版本 DB_TRX_ID == creator_trx_id:该版本是本事务修改的,可见。如果版本 DB_TRX_ID如果版本 DB_TRX_ID >= max_trx_id:该版本在 Read View 创建后启动,不可见。遍历记录的版本链,继续查找更旧版本。如果版本 DB_TRX_ID 在 m_ids 列表中:该版本对应的事务在 Read View 创建时仍活跃(未提交),不可见,继续查找更旧版本。如果找到满足可见的版本,返回该版本数据。如果不满足,则不返回数据。如果在事务中执行了 Update 语句,则更新的行记录的 DB_TRX_ID 应该更新为当前事务ID 。根据快照读的流程,在更新后通过 Select 读取的这条行记录时,当前事务ID == DB_TRX_ID,这条行记录应该被读取出来,不应通过 DB_ROLL_PTR 读取历史版本数据。
但事故中,更新后偶发性地读取到历史版本的数据,是什么原因造成的呢?
苦思冥想后没有头绪,最终决定 Show code——尝试从 MySQL 源码中找到答案。
三、Update执行流程
1. 源码分析
阅读MySQL源码的Update SQL执行逻辑,从 mysql_update 函数中看出端倪,整理后更新的简化流程如下:
MySQL 在更新记录前,会通过 compare_records 函数判断行记录更新前后是否发生了变化,如果行记录没有发生变化,则跳过更新。
compare_records 怎么比对行记录更新前后是否有变化的?
2. 启发
从流程图中可以看出:
如果行记录更新前后没有变化, 就不会调用存储引擎执行实际更新行记录逻辑,Update 语句可以正常执行没有报错。批更新记录时,MySQL是逐行对比后,再调用存储引擎执行更新。那又为什么没有数据变化呢?
我们检查了业务逻辑代码,发现更新检测单状态时,并没有对记录加锁,那很大可能是存在并发执行的其他流程修改了检测单状态,且两个流程修改的检测单状态相同,导致数据执行前后没有变化。我们在测试环境构造下面场景来复现。
四、场景复现
1. 表结构和数据
create table qc_order_exception( id int notnull primary key auto_increment comment'id', qc_order_id int notnullcomment'检测单id', exception_type tinyintcomment'异常类型', create_time datetime notnulldefaultcurrent_timestampcomment'创建时间', update_time datetime notnulldefaultcurrent_timestamponupdatecurrent_timestampcomment'更新时间') engine = innodbcomment'检测单异常记录';createtable qc_order( id int notnull primary key auto_increment comment'id', status int notnullcomment'状态', create_time datetime notnulldefaultcurrent_timestampcomment'创建时间', update_time datetime notnulldefaultcurrent_timestamponupdatecurrent_timestampcomment'更新时间') engine = innodbcomment'检测单表';insertinto qc_order(id, status)values (1001, 10), (1002, 10);2. 步骤
记录初始状态:
select id, status, create_time, update_timefrom qc_order;T0时刻(事务A): 开启事务 && 创建ReadView
begin;select id, exception_type, qc_order_idfrom qc_order_exceptionwhere qc_order_id in (1001, 1002); -- 返回数据为空。select trx_id, trx_state, trx_state, trx_mysql_thread_id, trx_query from information_schema.INNODB_TRX;执行结果:
T1时刻(事务B): 开启事务 && 更新数据 id=1001 && 获取事务信息 && 提交
begin;update qc_orderset status = 20where id = 1001;select trx_id, trx_state, trx_state, trx_mysql_thread_id, trx_query from information_schema.innodb_TRX;commit;执行结果:
事务B中:
Update 返回: 匹配行数: 1, 更新行行数: 1 。 表示更新成功了。select ... from information_schema 返回的线程ID: 8,trx_id:4364。T2时刻(事务A): 更新数据 id in (1001,1002),设置 status = 20
update qc_orderset status = 20where id in (1001, 1002);select trx_id, trx_state, trx_state, trx_mysql_thread_id, trx_query from information_schema.INNODB_TRX;执行结果:
T3时刻(事务A): 查询数据 id=1001
select id, status, create_time, update_timefrom qc_orderwhere id in (1001, 1002);执行结果:
返回的 id=1001 数据, status = 10。
注意:
事务A的trx_id:4366事务B的trx_id:4364使用 undrop-for-innodb 工具分析ibd文件,观测qc_order表记录的 DB_TRX_ID 。验证是否事务A没有更新记录id=1001
undrop-for-innodb 是一款专为 InnoDB 存储引擎设计的开源数据恢复工具,支持从文件级别恢复误删除、损坏或丢失的数据。也可以用来从ibd文件中解析出表的数据,其兼容 MySQL 5.7 版本。
执行如下步骤:
# 1. 关闭 MySQL,复制 qc_order的ibd文件到 ./data 目录。需要确保MySQL参数, innodb_File_per_table=on[root@localhost undrop-for-innodb]# ls -lh ./data/total 96K-rw-r-----. 1 root root 96K Aug 17 21:49 qc_order.ibd# 2. 使用 stream_parser 进行拆页[root@localhost undrop-for-innodb]# ./undrop-for-innodb/stream_parser -f ./data/qc_order.ibd Opening file: ./data/qc_order.ibdFile information:ID of device containing file: 64768inode number: 70389401protection: 100640 (regular file)number of hard links: 1user ID of owner: 0group ID of owner: 0device ID (if special file): 0blocksize for filesystem I/O: 4096number of blocks allocated: 192time of last access: 1755438550 Sun Aug 17 21:49:10 2025time of last modification: 1755438550 Sun Aug 17 21:49:10 2025time of last status change: 1755438825 Sun Aug 17 21:53:45 2025total size, in bytes: 98304 (96.000 kiB)Size to process: 98304 (96.000 kiB)All workers finished in 0 sec# 会生成目录:pages-qc_order.ibd/,其中 FIL_PAGE_INDEX/ 里是索引页# 3. 确定叶子页文件名[root@localhost undrop-for-innodb]# ls -lh ./pages-qc_order.ibd/FIL_PAGE_INDEX/total 16K-rw-r--r--. 1 root root 16K Aug 17 21:57 0000000000000049.page# 4. 准备qc_order的DDL。# undrop-for-innodb 的 c_parser 在解析 CREATE TABLE 时有个限制 —— 它的 SQL parser 只接受 简化版的 DDL,不能有多余的引号或不支持的选项。[root@localhost undrop-for-innodb]# cat ./qc_order.sql CREATE TABLE qc_order ( id INT NOT NULL, status INT NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=latin1 ROW_FORMAT=Dynamic;# 5. 用 c_parser 解析叶子页。数据输出到 dump.tsv[root@localhost undrop-for-innodb]# ./undrop-for-innodb/c_parser -6f ./pages-qc_order.ibd/FIL_PAGE_INDEX/0000000000000049.page -t ./qc_order.sql > ./dump.tsv SET FOREIGN_KEY_CHECKS=0;LOAD DATA LOCAL INFILE '/root/undrop-for-innodb/dumps/default/qc_order' REPLACE INTO TABLE `qc_order` CHARACTER SET UTF8 FIELDS TERMINATED BY '\t' OPTIONALLY ENCLOSED BY '"' LINES STARTING BY 'qc_order\t' (`id`, `status`, `create_time`, `update_time`);-- STATUS {"records_expected": 2, "records_dumped": 2, "records_lost": false} STATUS END# 6. 查看 dump.tsv 内容[root@localhost undrop-for-innodb]# cat dump.tsv -- Page id: 3, Format: COMPACT, Records list: Valid, Expected records: (2 2)00000000110C 2A0000013C0323 qc_order 100120 "2025-08-17 17:04:35.0" "2025-08-17 17:06:57.0"00000000110E 2B0000013D0403 qc_order 100220 "2025-08-17 17:04:35.0" "2025-08-17 17:07:42.0"-- Page id: 3, Found records: 2, Lost records: NO, Leaf page: YES# 第一列为记录的 DB_TRX_ID,以十六进制输出,也就是我们需要找的数据了。转换为 10进制:00000000110C -> 4364 (id=1001)00000000110E -> 4366 (id=1002)DB_TRX_ID = 4364,就对应事务B的 trx_id。
DB_TRX_ID = 4366,就对应事务A的 trx_id。可以表明事务B 更新了id=1001,事务A更新了id=1002。
五、总结
1、事务中执行 Update 后为什么有时候不能读到更新后的数据,只能读到历史数据?
如果在事务A创建了 ReadView 后,Update语句执行前,存在其他流程并发的执行相同行记录的更新并提交了事务,且两个事务更新后的数据一致。当事务A执行 Update 语句时,MySQL会因行记录更新前后数据没有发生变化,跳过这条记录的更新。因为 MySQL 跳过了行记录的更新,则行记录的 DB_TRX_ID 不会更新为当前事务 A 的事务ID。Update 更新后,通过Select 读取更新记录,根据快照读流程,这条记录的最新版本的数据对当前事务A 是不可见的,那只能通过记录的历史版本链,读取出创建ReadView开始前的数据返回。2、怎么避免 这种 "更新消失" 场景?
在事务开始前,使用 select ... for update 将待更新的记录加锁。避免事务期间,其他事务更新。对于先更新后读取的场景,可以将更新的内容填充到后读取的记录中。采用乐观锁机制,记录增加 version 字段,每次更新的时候:version = version + 1。参考:
关于作者
来源:dbaplus社群一点号