MySQL 更新消失案
1. 問題背景
2. 有什么場景可能會出現這樣的問題?
2.1 主從同步延遲?
2.2 事務沒有生效?
2.3 快照讀?
3. Update執行流程
3.1 源碼分析
3.2 啟發
4. 場景復現
4.1 表結構和數據
4.2 步驟
5. 總結
注: 本文使用的MySQL版本 5.7.44, 事務隔離級別為 REPEATABLE-READ。
1. 問題背景
在一個平常的夜晚,收到業務反饋某個檢測單流程卡住,不能繼續往下走。接到反饋后,連忙上線排查。發現問題點在一個詭異的現象 —— 數據庫更新消失: 在事務中,先更新一行記錄,后讀取該行,讀取到竟為更新前的舊值!
接口對應的業務邏輯大致有以下幾步:
- 用戶選擇一批初始狀態為10的檢測單ID發起流轉;
- 后端服務獲取檢測單對應的異常信息,將沒有異常的檢測單的狀態修改為 20。如果有異常的,則不修改狀態;
- 最后根據檢測單狀態和其他信息執行不同的業務邏輯。
以上步驟均在一個事務內執行。
假設第一步獲取到的檢測單id列表為[1001,1002],第二、第三步簡化流程如下:
# T0 select獲取檢測單的異常記錄
select id, exception_type, qc_order_id
from qc_order_exception
where qc_order_id in (1001, 1002)
# T1 執行如下SQL,ID為1001, 1002更新檢測單記錄的狀態。 注: 更新前 status = 10
update qc_order
set status = 20
where id in (1001, 1002);
# T2 執行如下SQL,通過ID獲取檢測單信息。 注: T1更新為satus=20的記錄,可能讀取出來的 status = 10
select 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"?!霈F了奇怪的“更新消失”現象!
遇到這種偶發性"更新消失"的場景,此時我們表情如下:
2. 有什么場景可能會出現這樣的問題?
2.1 主從同步延遲?
- 如果 T2時刻,獲取數據庫記錄時是通過數據庫從節點讀取,可能因為主從同步延遲的原因,導致讀取到數據庫舊值
- 為什么不是這個原因?
T2時刻,通過斷點com.alibaba.druid.pool.DruidPooledConnection(項目使用Druid連接池)的方法,確認T1和T2時刻執行SQL是用同一個數據源。所以不可能是主從同步延遲的原因。
2.2 事務沒有生效?
- 如果事務沒有生效時,存在 A 事務修改記錄的status = 20,事務B又修改status = 10。如果沒有事務,事務A可以看到事務B的修改。
- 為什么不可能是這個原因?
業務邏輯方法上有使用聲明式事務@Transctional,斷點后,確認線程棧中包含org.springframework.transaction.interceptor.TransactionInterceptor.invoke,可以判定所有操作均在同一個事務中執行。
在測試環境復現場景時,在事務未提交時,記錄修改,在其他事務不可見。
2.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< 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 源碼中找到答案。
3. Update執行流程
3.1 源碼分析
閱讀MySQL源碼的Update SQL執行邏輯,從 mysql_update() 函數中看出端倪,整理后更新的簡化流程如下:
?? MySQL 在更新記錄前,會通過 compare_records() 函數判斷行記錄更新前后是否發生了變化,如果行記錄沒有發生變化,則跳過更新。
compare_records() 怎么比對行記錄更新前后是否有變化的?
圖片
- 如果存儲引擎支持部分列讀取,則只比較需要更新的列的值是否相等,如果列可為NULL,需要額外比較NULL值是否相等。
- 對于只支持完整讀取的存儲引擎:
判斷是否是固定長度的列?如果是,則直接通過 memcmp 字節比較是否相同。否則執行第2步。
判斷更新前后的數據,所有可為NULL的列的NULL值是否相同?如果不相同,直接返回數據不相同。否則執行第3步。
遍歷所有需要更新的列,比較更新前后值是否相同。
3.2 啟發
從流程圖中可以看出:
- 如果行記錄更新前后沒有變化, 就不會調用存儲引擎執行實際更新行記錄邏輯, Update 語句可以正常執行沒有報錯。
- 批更新記錄時,MySQL是逐行對比后,再調用存儲引擎執行更新。
那又為什么沒有數據變化呢?
- 我們檢查了業務邏輯代碼,發現更新檢測單狀態時,并沒有對記錄加鎖,那很大可能是存在并發執行的其他流程修改了檢測單狀態,且兩個流程修改的檢測單狀態相同,導致數據執行前后沒有變化。
我們在測試環境構造下面場景來復現。
4. 場景復現
4.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);4.2 步驟
記錄初始狀態:
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;執行結果:
圖片
- 事務A的線程ID: 7,trx_id: 421164174274160。這是臨時的事務ID。
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中:
- Update 返回: 匹配行數: 1, 更新行行數: 1 。 表示更新成功了。
- select ... from information_schema 返回的線程ID: 8,trx_id:4364。
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;執行結果:
圖片
- Update返回: 匹配行數: 2, 更新行行數: 1 。表示找到2條數據,但只更新一條,有的數據實際沒有執行更新。
- 事務A的 trx_id 發生變更,trx_id: 4366。因為這里執行了DML操作,需要分配實際的 trx_id。
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-----. 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.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--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 1001 20 "2025-08-17 17:04:35.0" "2025-08-17 17:06:57.0"
00000000110E 2B0000013D0403 qc_order 1002 20 "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??梢员砻魇聞誃 更新了id=1001,事務A更新了 id=1002。
5. 總結
- 事務中執行 Update 后為什么有時候不能讀到更新后的數據,只能讀到歷史數據?
如果在事務A創建了 ReadView 后,Update語句執行前,存在其他流程并發的執行相同行記錄的更新并提交了事務,且兩個事務更新后的數據一致。當事務A執行 Update 語句時,MySQL會因行記錄更新前后數據沒有發生變化,跳過這條記錄的更新。
因為 MySQL 跳過了行記錄的更新,則行記錄的 DB_TRX_ID 不會更新為當前事務 A 的事務ID。Update 更新后,通過Select 讀取更新記錄,根據快照讀流程,這條記錄的最新版本的數據對當前事務A 是不可見的,那只能通過記錄的歷史版本鏈,讀取出創建ReadView開始前的數據返回。
- 怎么避免 這種 "更新消失" 場景?
在事務開始前,使用 select ... for update 將待更新的記錄加鎖。避免事務期間,其他事務更新。
對于先更新后讀取的場景,可以將更新的內容填充到后讀取的記錄中。
采用樂觀鎖機制,記錄增加 version 字段,每次更新的時候:version = version + 1。
參考:
- https://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html
- https://github.com/twindb/undrop-for-innodb
關于作者
曾明,轉轉履約中臺研發工程師,主要負責質檢業務。























