接到业务反馈后,我们在打印日志: "逻辑2" 的分支中,加了监控告警,触发告警后,检测单没有后续的操作。这时去查看数据库中检测单的状态,没有异常的检测单的状态均为 status = 20(检测单最后状态是对的,但是在处理业务逻辑时,可能读取出旧值 status=10)。
我们观测日志发现,status被更新为20(即没有异常)的一批检测单,大部分情况,该批检测单内所有检测单执行打印日志: "逻辑1";小部分情况,比如上面举例的[1001,1002],这批检测单中,ID=1001的检测单执行:打印日志: "逻辑2"。——出现了奇怪的“更新消失”现象!
遇到这种偶发性"更新消失"的场景,此时我们表情如下:
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。
快照读流程
当需要读取某行记录流程大致如下:
如果版本 DB_TRX_ID == creator_trx_id:该版本是本事务修改的,可见。
如果版本 DB_TRX_ID< min_trx_id:该版本在 Read View 创建时已提交,可见。
如果版本 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 源码中找到答案。
阅读MySQL源码的Update SQL执行逻辑,从 mysql_update() 函数中看出端倪,整理后更新的简化流程如下:
MySQL 在更新记录前,会通过 compare_records() 函数判断行记录更新前后是否发生了变化,如果行记录没有发生变化,则跳过更新。
compare_records() 怎么比对行记录更新前后是否有变化的?
从流程图中可以看出:
那又为什么没有数据变化呢?
我们在测试环境构造下面场景来复现。
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);
记录初始状态:
select id, status, create_time, update_time
from qc_order;
T0时刻(事务A): 开启事务 && 创建ReadView
begin;
select id, exception_type, qc_order_id
from qc_order_exception
where 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_order
set status = 20
where id = 1001;
select trx_id, trx_state, trx_state, trx_mysql_thread_id, trx_query from information_schema.INNODB_TRX;
commit;
执行结果:
事务B中:
T2时刻(事务A): 更新数据 id in (1001,1002),设置 status = 20
update qc_order
set status = 20
where 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_time
from qc_order
where 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
# 2. 使用 stream_parser 进行拆页
[root@localhost undrop-for-innodb]# ./undrop-for-innodb/stream_parser -f ./data/qc_order.ibd
Opening file: ./data/qc_order.ibd
File information:
ID of device containing file: 64768
inode number: 70389401
protection: 100640 (regular file)
number of hard links: 1
user ID of owner: 0
group ID of owner: 0
device ID (if special file): 0
blocksize for filesystem I/O: 4096
number of blocks allocated: 192
time of last access: 1755438550 Sun Aug 17 21:49:10 2025
time of last modification: 1755438550 Sun Aug 17 21:49:10 2025
time of last status change: 1755438825 Sun Aug 17 21:53:45 2025
total 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
# 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`);
# 6. 查看 dump.tsv 内容
[root@localhost undrop-for-innodb]# cat dump.tsv
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"
# 第一列为记录的 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开始前的数据返回。
在事务开始前,使用 select ... for update 将待更新的记录加锁。避免事务期间,其他事务更新。
对于先更新后读取的场景,可以将更新的内容填充到后读取的记录中。
采用乐观锁机制,记录增加 version 字段,每次更新的时候:version = version + 1。
参考:
来源丨公众号:转转技术(ID:zhuanzhuantech)dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn