iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Vibe Coding

夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略系列 第 24

Day24|先做再說,真的撞到再處理?從樂觀鎖看懂並發寫入

  • 分享至 

  • xImage
  •  

昨天,我們從 State Machine(狀態機)的地圖一路走進 Code(程式碼),看見 BuJo 怎麼限制 Activity 只能沿著合法路線改變 State。

不過,就算每一條路都規定好了,還有一個問題沒有解決:

如果兩個 Request 幾乎同時看見「這條路可以走」,又一起往下執行呢?

今天就來認識一個我覺得名字超可愛的東西:

Optimistic Locking(樂觀鎖)。

它到底在「樂觀」什麼?等等我們直接放回 BuJo 的 Code 裡,一起來看看它是如何處理兩個 Request 同時撞在一起這種尷尬的情況。


Optimistic Locking(樂觀鎖)到底在樂觀什麼?

第一次看到這個名字的時候我真的覺得超~可愛(笑)。

然後它的想法也真的非常「樂觀」:

先相信大部分時候,大家不會那麼剛好同時修改同一筆資料。

所以 Optimistic Locking(樂觀鎖)不會一開始就把資料鎖住,要求其他操作排隊。

它會先讓不同操作各自進行,直到真正準備更新時,才做最後一次確認:

讀取資料
↓
各自處理
↓
準備更新
↓
原本的條件還成立嗎?
├─ 成立 → 更新
└─ 不成立 → 不更新

也就是說,它的「樂觀」不是完全不防衝突,而是:

先假設衝突不常發生,真的要寫入時再處理。


寫入以前,要拿什麼來確認資料有沒有變?

Optimistic Locking(樂觀鎖)需要一個可以拿來比較的值。

常見方式包括:

比較方式 系統記住什麼 特點
Version Number(版本號) 例如 version = 3 最典型,資料更新時版本號跟著增加
Updated At(最後更新時間) 例如 updated_at 可以沿用時間欄位,但要留意時間精度
Original Field Value(原本的欄位值) 例如原本的 status 適合只在意特定欄位是否改變的情境

不管用哪一種,核心都一樣:

原本的值還符合
→ 才更新成新值

這就是 CAS(Compare-And-Swap,比較並交換)的核心思路。

不過這裡還有一個關鍵:

「比較」和「更新」不能拆成兩個有空隙的動作。

如果先檢查一次:

現在還是 voting

過一下才執行 Update,中間還是可能被另一個 Request 先改掉。

所以真正需要的是:

讓比較條件直接跟著這次 Update 一起執行。

BuJo 沒有另外建立 version

它拿來比較的,就是昨天一直在看的:

status


回到 BuJo:狀態沒錯,通知卻可能發兩次

假設兩個確認成團的 Request 幾乎同時抵達,而且都在 Activity 還是 voting 時讀到資料:

Request A                  Request B
    │                          │
    ├─ 讀到 voting             ├─ 讀到 voting
    │                          │
    ├─ 改成 confirmed          ├─ 改成 confirmed
    │                          │
    └─ 建立成團通知             └─ 又建立一次成團通知

最後 Activity 的狀態看起來沒有問題:

status = confirmed

真正出錯的是,同一次成團通知可能被建立兩次。

對使用者來說,就可能因此收到兩則一模一樣的通知。

像這種多個 Request 在重疊的時間裡處理同一份資料,就是 Concurrency(並發)的情境。

而我們現在要避免的,就是像「同一個成團通知被建立兩次」這樣,因為多個操作彼此交錯而產生錯誤結果的 Race Condition(競態條件)

所以 BuJo 需要守住的是:

同一次確認成團,只能有一個 Request 成功繼續往下執行。

那它到底怎麼判斷誰可以繼續?

答案就藏在更新條件裡。


真正的比較與更新,藏在 where

先只把這次最重要的部分拉出來看:

const { count } = await tx.activity.updateMany({
  where: {
    id,

    // 現在的 status 仍然要和這次 Request 一開始讀到的一樣
    status: activity.status,
  },
  data: {
    // 條件仍成立,才改成 confirmed
    status: "confirmed",
  },
});

這裡其實同時限制了:

id
→ 找到這一場 Activity

status: activity.status
→ 再加上原本的 State 當更新條件

假設 Request A 和 Request B 一開始都讀到:

voting

Request A 先執行:

WHERE id = ... AND status = voting
↓
符合
↓
更新成 confirmed

等 Request B 執行時:

WHERE id = ... AND status = voting
↓
資料庫現在已經是 confirmed
↓
不符合
↓
不更新

也就是說,前面提到的 CAS,在 BuJo 裡就是透過這次 Conditional Update(條件更新)落地:

status 仍然符合原本的值
→ 才更新成 confirmed

count 告訴程式誰更新成功了

Prisma 的 updateMany() 會回傳實際更新了幾筆資料。

在這段流程裡,主要有兩種結果:

count 代表什麼 後續處理
count === 1 有 1 筆資料符合 id + status,更新成功 繼續確認成團流程
count === 0 原本的 status 已經不再符合 停止後續處理

所以 Code 接著會判斷:

// 沒有任何資料符合原本的 id + status
// 代表這次 Request 依賴的 State 已經改變
if (count === 0) {
  return false;
}

count === 0 並不代表 Backend 故障。

而是代表這次 Request 原本依賴的 State,已經被其他操作改變。

因此 BuJo 選擇回傳:

409 Conflict(衝突)

最後再用 Test 確認:沒搶到更新後,真的會停嗎?

BuJo 也替這個衝突分支留下了 Test。

這裡先模擬 Conditional Update(條件更新)沒有更新到任何資料,也就是:

count = 0
// 模擬另一個操作已經先改變 Activity State
// 因此這次 Conditional Update 沒有更新到任何資料
prisma.activity.updateMany.mockResolvedValueOnce({ count: 0 });

await confirmFormation(makeReq(), res);

// 沒搶到這次狀態轉換,就不能繼續建立站內通知
expect(prisma.notification.createMany).not.toHaveBeenCalled();

// API 要明確回報目前 State 已經發生衝突
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
  message: "此活動狀態已被異動,請重新整理後再試",
});

這個 Test 驗證的就是:

count = 0
↓
停止後續處理
↓
不建立站內通知
↓
回傳 409 Conflict

也就是確認:

原本的 State 已經失效後,這次 Request 不會繼續把後面的流程做完。


搶到這次更新後,還有一整組資料要一起完成

到這裡,Optimistic Locking(樂觀鎖)已經幫我們守住:

只有成功更新 State 的 Request 能繼續。

不過昨天也看過,確認成團不只會改變 status

BuJo 還要一起:

Activity → confirmed
+
儲存 confirmed_slot_id
+
建立站內通知

這幾個寫入共同代表一次「確認成團」。

所以真正的 Code 會把它們放進同一個 Transaction(交易)裡:

// 把這次「確認成團」需要的資料庫操作放進同一個 Transaction
const won = await prisma.$transaction(async (tx) => {
  const { count } = await tx.activity.updateMany({
    where: {
      id,

      // 只有 State 還和這次 Request 一開始讀到的一樣,才允許更新
      status: activity.status,
    },
    data: {
      status: "confirmed",
    },
  });

  // 沒搶到狀態轉換,就直接停止
  if (count === 0) return false;

  // 只有成功改變 State 的 Request 才能繼續儲存最後確認的時段
  await tx.activitySchedule.update({
    where: { activity_id: id },
    data: {
      confirmed_slot_id: confirmedSlotId,
    },
  });

  // 也只有勝出的 Request 會建立站內通知
  if (notifyTargets.length > 0) {
    await tx.notification.createMany({
      data: notifyTargets.map((participant) => ({
        user_id: participant.user_id,
        type: "activity_confirmed",
        reference_id: id,
        reference_type: "activity",
      })),
    });
  }

  return true;
});

// 沒搶到這次 State Transition 就回報目前資料已經被其他操作改變
if (!won) {
  return res.status(409).json({
    message: req.t("activity.stateChangedConcurrently"),
  });
}

這裡可以把兩件事分開看:

Optimistic Locking(樂觀鎖)
→ 決定哪一個 Request 能繼續

Transaction(交易)
→ 保證勝出的這組資料庫操作一起成功或一起失敗

例如 Activity 已經改成 confirmed,後面的確認時段卻寫入失敗,Transaction 會讓前面的資料庫變更一起取消。

這樣就不會留下:

Activity 已經 confirmed
但是 confirmed_slot_id 沒有成功寫入

這種只完成一半的資料。


樂觀派也不是毫無準備的!

納今天的 Optimistic Locking(樂觀鎖)就講到這裡啦~

我真的覺得樂觀鎖這個名字很可愛。

它很像那種做事很敢衝的朋友:

「先做再說啦!真的撞到了再處理!」

不會因為覺得「等等可能有人跟我撞在一起」,就先站在原地不動。

但它的「樂觀」也不是毫無戒心。

它只是選擇先往前走,到了真正要寫入資料的時候,再替自己做最後一次確認。

有點像:

「我相信應該不會出事,但真的要交出去以前,我還是會再看一眼。」

所以它不是什麼都不管的樂天派,而是:

先做、再確認,該留的最後一道把關還是沒有少。

樂觀鎖介紹完了,是不是會想:

有樂觀鎖,那是不是也有悲觀鎖呀?

當然有啦~~

明天就來看看 Pessimistic Locking(悲觀鎖),又會是什麼個性的傢伙吧!


參考資料


上一篇
Day 23|地圖看懂了,角色真的會照著走嗎?從 BuJo Code 看懂 State Machine 怎麼實作
下一篇
Day25|悲觀一點反而比較安全?從悲觀鎖看懂最後一個名額怎麼守
系列文
夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言