iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 20 篇

Day 20|MVCC:拿到鎖了,為什麼還讀到舊資料?

  • 分享至 

  • xImage
  •  

先來個前情提要,Day 18用 SELECT ... FOR UPDATE,讓同一個使用者的下單流程依序取得 users 這一列的鎖,避免兩筆訂單同時扣掉相同的可用餘額。

但實際測試時,會遇到一個很反直覺的問題:

明明已經拿到鎖,為什麼後面的查詢還是可能讀到舊資料?

既然當時提到了 MySQL InnoDB 的 REPEATABLE READ ,那就在這篇文章好好地探討一下 REPEATABLE READ 吧!


當資料庫裡的資料不只有一個版本

首先,先來搞懂 MVCC到底是怎麼決定「我現在看到哪一個版本」的?

[註] MVCC(Multi-Version Concurrency Control,多版本併發控制):一列資料保留多個版本,讓讀和寫互不阻擋。

InnoDB 的資料並不是單純地「UPDATE 之後把舊資料蓋掉」,當一筆資料被修改時,InnoDB 會保留舊版本所需要的資訊,讓不同 Transaction 可以依照自己的可見性規則讀取資料。

舉例來說,order_items 的某筆資料先由 T1 建立,之後又被 T2、T3 修改,每次修改都會留下前一個版本,因此這筆資料其實可以視為一條版本鏈:最新的是 T3 寫入的 V3,往前是 T2 寫入的 V2,再往前則是 T1 建立的 V1。

當另一個 Transaction 讀取這筆資料時,InnoDB 不只是單純拿「最新版本」,而是會根據自己的 Read View,沿著版本鏈判斷哪一個版本對自己可見。

這些舊版本資訊會透過 InnoDB 的 undo log 保存,但問題是如果資料有這麼多版本,要怎麼決定當下要讀取哪一個版本?答案就是 Read View。


什麼是Read View?

一般的 SELECT 屬於「快照讀」,第一次進行快照讀時,InnoDB 會建立 Read View,根據當時 Transaction 的狀態,判斷哪些版本對目前 Transaction 可見。

所以 MVCC 真正解決的問題是「當同一筆資料有多個版本時,目前這個 Transaction 應該看到哪一個?」。

舉例:假如 T2 建立 Read View 時,T1 還沒有提交,那麼 T1 當時寫入的新版本,對 T2 的這個快照來說就還不可見,之後即使T1 commit,T2 原本建立的 Read View 仍然可以讓後續的快照讀維持一致性。因此,T1 已經 COMMIT」和「T2 的快照讀一定看得到 T1」並不是同一件事。


快照讀 v.s 當前讀?

理解 Read View 後,再回頭看我們的程式碼:

@Transactional   // 預設 REPEATABLE READ
public void createUserOrder(CreateOrderItemRequest request, String userId) {
    Order order = orderMapper.selectById(request.getOrderId());
    Menu menu = menuMapper.selectById(request.getMenuId());

    User user = userMapper.selectForUpdate(userId);

    long available =
        user.getBalance()
        - orderItemMapper.getFrozenAmount(userId);

    if (available < menu.getUnitPrice() * request.getQuantity()) {
        throw new InsufficientBalanceException("餘額不足");
    }

    orderItemMapper.insert(item);
}

這裡其實混用了兩種不同的讀取方式:

快照讀 當前讀
常見語法 SELECT SELECT ... FOR UPDATE、UPDATE、DELETE
依據 Read View 目前資料版本
是否使用快照 是 否
是否加鎖 否 是
用途 一致地讀取資料 取得目前資料並進行後續操作

套回上面的程式碼:

程式碼 實際執行的 SQL 讀法
orderMapper.selectById(...) SELECT * FROM orders WHERE order_id = ? 快照讀,Read View 在這裡建立
userMapper.selectForUpdate(userId) SELECT * FROM users WHERE user_id = ? FOR UPDATE 當前讀,讀最新版本並上鎖
orderItemMapper.getFrozenAmount(userId) SELECT SUM(oi.subtotal) FROM order_items oi JOIN orders o ON oi.order_id = o.order_id WHERE oi.user_id = ? AND o.status IN ('OPEN', 'FAILED') 快照讀,沿用第一列建立的 Read View

只有userMapper.selectForUpdate(userId)是當前讀,即便已經拿到最新的 Row Lock(列鎖)不代表「同一個 Transaction 裡所有 SELECT 都會自動看到最新資料」。

Row Lock(列鎖)是「控制誰可以同時修改/取得這筆資料」,而 MVCC 「決定這個 Transaction 的快照讀可以看到哪個版本」。


那REPEATABLE READ 和 READ COMMITTED 差在哪裡?

MySQL InnoDB 預設使用 REPEATABLE READ。REPEATABLE READ 和 READ COMMITTED 的差異之一,是快照讀建立 Read View 的時機不同。

在 REPEATABLE READ 下,Transaction 第一次進行快照讀後建立 Read View,後續的快照讀會沿用同一個 Read View,因此同一個 Transaction 內,多次 SELECT 可以維持相同的資料視圖。

而在 READ COMMITTED 下,每次快照讀都會重新建立 Read View。因此,如果兩次 SELECT 之間有其他 Transaction 提交了修改,後一次 SELECT 就可能看到較新的資料。

因此,如果 T2 在等待鎖之前就建立了 Read View,在 REPEATABLE READ 下,最後的 SUM() 仍可能沿用原本的 Read View。

如果改成使用READ COMMITTED:

@Transactional(isolation = Isolation.READ_COMMITTED)

那麼最後的 SUM() 會重新建立 Read View,因此可以看到 T1 已經提交的 order_item。這也是我們這個下單流程最後選擇 READ_COMMITTED 的原因。


上一篇
Day 19|@Transactional 的邊界:哪些操作該被 rollback?
下一篇
Day 21|截止到期觸發扣款:解決「所有機器看到同一份任務」
系列文
做一個團購後端,順便搞懂那些事 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言