「老闆,用戶的解鎖記錄全不見了!」「快把舊資料拉出來灌回去!」在緊急狀況下,我沒想太多就照做了。然後,我不小心埋下了一顆定時炸彈…


🔥 第一幕:災難降臨

2025 年 11 月某日,上午 10:30

Slack 突然炸開:

💬 同事:「完蛋了…我剛剛不小心部署到舊的 commit…」

💬 QA:「欸!為什麼使用者的動畫解鎖記錄都不見了?」

💬 使用者:「我昨天才花金幣解鎖的動畫怎麼不見了?」

💬 老闆:「@所有人 立刻確認影響範圍!」

我打開資料庫一看:

SELECT COUNT(*) FROM user_unlocked_animations;
-- 結果: 0 😱 所有用戶的解鎖記錄全部消失!

原因:同事不小心部署了一個舊的 Strapi commit,那個版本的 database migration 把 user_unlocked_animations 相關的表全部清空了。


⚡ 第二幕:老闆的緊急命令

💬 老闆:「快!把之前的用戶解鎖記錄拉出來,灌回現在的資料庫!」

我心裡想:「舊資料插回去,新資料又同時在進來…會不會有問題?」

但老闆在等,使用者在抱怨,沒時間多想,先恢復資料再說。

緊急恢復資料

// 從備份拉出資料,直接插入(包含原始的 ID)
blablablabla
}
// ⚠️ 直接指定了 id,但沒想到要更新 sequence...

執行完畢:

✅ 資料恢復完成!

QA 測試:「使用者的解鎖記錄都回來了!」

眾人鬆了一口氣。


💣 第三幕:24 小時後,炸彈引爆

隔天下午

💬 客服:「有使用者回報說無法解鎖動畫!!!」

💬 使用者:「我有 3 個金幣,想解鎖動畫,但一直顯示錯誤!金幣被扣了但動畫沒解鎖!」

打開 CloudWatch(應用層 log):

[2025-11-06T15:32:47] ERROR: Failed to unlock animation
Error: Unique constraint failed

檢查程式碼邏輯 → 沒問題 檢查使用者請求 → 沒有重複 檢查資料庫 → 沒有重複資料

整個下午都找不到原因。


🔦 第四幕:深夜的突破

晚上 9:00

我決定換個角度:「去看資料庫層的 log!」

切換到 PostgreSQL 的 CloudWatch Log Group

Log Group: /aws/rds/instance/my-db/postgresql
搜尋: "duplicate key" "user_unlocked_animations"

螢幕上顯示:

ERROR: duplicate key value violates unique constraint
"user_unlocked_animations_user_lnk_pkey"
DETAIL: Key (id)=(8) already exists.

我立刻檢查:

-- 資料表有 10 筆資料
SELECT COUNT(*) FROM user_unlocked_animations_user_lnk;
-- 結果: 10

SELECT MAX(id) FROM user_unlocked_animations_user_lnk;
-- 結果: 15

-- Sequence 只記到 7
SELECT last_value FROM user_unlocked_animations_user_lnk_id_seq;
-- 結果: 7

-- 😱 有 10 筆資料,MAX(id)=15,但 sequence 只有 7!

我終於明白了。


🧠 第五幕:真相大白

問題核心:Sequence 跟實際資料筆數不同步

什麼是 Sequence?

PostgreSQL 的每個自動遞增 id 都有一個 sequence(序列生成器):

CREATE TABLE user_unlocked_animations_user_lnk (
  id SERIAL PRIMARY KEY  -- 自動建立 sequence
);
-- 對應的 sequence: user_unlocked_animations_user_lnk_id_seq

正常情況

INSERT INTO table (user_id, data) VALUES (100, 'foo');
-- PostgreSQL: 從 sequence 取得 id=1,sequence 變成 2 ✅

INSERT INTO table (user_id, data) VALUES (101, 'bar');
-- PostgreSQL: 從 sequence 取得 id=2,sequence 變成 3 ✅

用簡單例子理解問題

假設資料表現狀:

iduser_idanimation_id來源
1-8昨天恢復的舊資料
9-10昨天到今天新解鎖

資料表狀態

  • 10 筆資料
  • MAX(id) = 15(中間有些 id 跳號)

Sequence 狀態

  • last_value = 7

為什麼 sequence 只有 7?

因為我昨天直接指定 ID 插入(id=1 到 id=8),PostgreSQL 的 sequence 完全沒被呼叫,所以還停留在很小的值。

但實際情況更複雜!

當時上線期間有約 500 人同時註冊,也就是說:

  • 🔄 舊資料正在灌入(手動插入,指定 id=1~8)
  • 🔄 新資料同時在寫入(系統自動插入,使用 sequence 生成 id)
  • 🔄 兩個寫入流程在 race

這導致了更混亂的狀況:

時間軸:
10:35 - 開始恢復舊資料(id=1~8,sequence 未更新)
10:36 - 同時有新用戶解鎖(sequence 自動遞增:7→8→9→10)
10:37 - 舊資料繼續插入(id=5, 6, 7, 8...)
10:38 - 新用戶又解鎖(sequence:10→11→12...)

所以資料表裡的 id 會像這樣:

id來源插入方式
1-8舊資料恢復手動指定 ID
9-12恢復期間新解鎖sequence 生成
13-15恢復後新解鎖sequence 生成

有 10 筆資料,MAX(id)=15,但 sequence 可能停在任何值(取決於恢復時有多少新寫入)。

今天新使用者解鎖時

-- 系統嘗試插入
INSERT INTO user_unlocked_animations_user_lnk (user_id, animation_id)
VALUES (125, 7);

-- PostgreSQL:
-- 1. 從 sequence 取得 id
SELECT nextval('user_unlocked_animations_user_lnk_id_seq');
-- 結果: 8 (因為 last_value=7,下一個是 8)

-- 2. 嘗試插入 id=8
-- 3. 💥 ERROR! id=8 已經存在(是昨天恢復的舊資料)

問題本質

  • 有 10 筆資料,下一筆應該用 id=16
  • 但 sequence 只記到 7,會嘗試用 id=8
  • id=8 已存在 → duplicate key error

受影響的 Sequence Keys

user_unlocked_animations_user_lnk_id_seq      ⭐ 主要問題
user_unlocked_animations_animation_lnk_id_seq ⭐ 也需修復
user_unlocked_animations_id_seq                也需修復

🛠️ 第六幕:修復

立即修復

-- 修復核心問題:user ↔ unlocked_animation link table
SELECT setval(
  'user_unlocked_animations_user_lnk_id_seq',
  (SELECT MAX(id) FROM user_unlocked_animations_user_lnk),
  true
);
-- 將 sequence 設定為當前最大 id
-- true = 下次會從 max_id + 1 開始

-- 同樣修復其他相關表
SELECT setval('user_unlocked_animations_animation_lnk_id_seq',
              (SELECT MAX(id) FROM user_unlocked_animations_animation_lnk), true);
SELECT setval('user_unlocked_animations_id_seq',
              (SELECT MAX(id) FROM user_unlocked_animations), true);

驗證修復

SELECT
  (SELECT last_value FROM user_unlocked_animations_user_lnk_id_seq) as seq,
  (SELECT MAX(id) FROM user_unlocked_animations_user_lnk) as max_id;

-- 修復前: seq=7,  max_id=15  ❌
-- 修復後: seq=15, max_id=15  ✅ 完美對齊!

測試功能:

# 使用者嘗試解鎖動畫
curl -X POST /api/user-animation/unlock -d '{"animationId": 7}'
# ✅ 成功!插入了 id=16 的新記錄

修好了!


🤔 第七幕:反思

教訓 1:緊急狀況下更要冷靜

當時心裡有疑慮:「舊資料插回去,新資料又在進來…會不會有問題?」

但在壓力下選擇了「先做再說」。

當時的實際狀況

  • 🔥 上線期間,約 500 人同時註冊
  • 🔥 舊資料正在灌入(手動指定 ID)
  • 🔥 新用戶同時在解鎖動畫(系統自動生成 ID)
  • 🔥 兩個寫入流程在 race condition 中競爭

這就像在高速公路上換輪胎 🚗💨,非常危險:

  • 無法預測 sequence 的最終值
  • 可能產生資料不一致
  • 難以追蹤哪些是舊資料、哪些是新資料

更好的做法

  1. 開啟維護模式(停止寫入) ⭐ 最關鍵!
  2. 恢復資料
  3. 更新 sequence
  4. 驗證後再開放

為什麼維護模式這麼重要?

// ❌ 當時的做法(危險)
// 500 人在線,新舊資料同時寫入
await restoreOldData(); // 舊資料:id=1~8(手動)
// 同時有人解鎖 → sequence 自動生成 id=9,10,11...
// 💥 無法確保資料一致性

// ✅ 正確做法
await enableMaintenanceMode(); // 停止所有寫入
await restoreOldData();
await updateSequence(); // 確保 sequence 正確
await verifyDataIntegrity(); // 驗證
await disableMaintenanceMode();

損失評估

  • 開維護模式:停機 5-10 分鐘
  • 不開維護模式:埋下炸彈,24 小時後爆炸,影響更多用戶

值得嗎?絕對值得。

或者乾脆不保留原始 ID:

// 移除 id,讓 PostgreSQL 重新產生
const data = backupData.map(({ id, ...rest }) => rest);
await db.insert(data);
// ✅ sequence 會自動更新

教訓 2:CloudWatch 要看對地方

日誌來源看到的內容診斷價值
應用層Unique constraint failed🟡 知道有錯,但不知根因
資料庫層duplicate key ... Key (id)=(8) already exists🟢 精確的錯誤資訊

關鍵

  • 資料庫錯誤要看資料庫 log (/aws/rds/.../postgresql)
  • 用資料庫術語搜尋 (duplicate key),不要用抽象詞 (error)

教訓 3:指定 ID 插入後,必須更新 Sequence

-- ❌ 直接指定 ID(sequence 不會更新)
INSERT INTO table (id, name) VALUES (1, 'foo'), (2, 'bar');

-- ✅ 插入後更新 sequence
SELECT setval('table_id_seq', (SELECT MAX(id) FROM table), true);

Strapi 的多對多關係會自動產生 *_lnk 表,它們也有 sequence!

users_permissions_user
  ↓
user_unlocked_animations_user_lnk  ← 自動產生,有 sequence
  ↓
user_unlocked_animations
  ↓
user_unlocked_animations_animation_lnk  ← 也有 sequence

恢復資料時,這些「看不見」的表也要處理!


🛡️ 第八幕:如何避免重蹈覆轍

1. 建立資料恢復 SOP

## 緊急資料恢復流程

1. [ ] 開啟維護模式
2. [ ] 恢復資料
3. [ ] 更新所有相關表的 sequence ⭐
4. [ ] 驗證 sequence >= MAX(id)
5. [ ] 測試插入功能
6. [ ] 關閉維護模式

2. 建立安全恢復腳本

// 方法 A: 不保留 ID(推薦)
const data = backupData.map(({ id, ...rest }) => rest);
await db.insert(data);

// 方法 B: 保留 ID + 更新 sequence
await db.insert(backupData);
await db.raw(
  `SELECT setval('table_id_seq', (SELECT MAX(id) FROM table), true)`
);

3. 啟動時自動檢查

// config/functions/bootstrap.js
async function checkSequences() {
  const result = await db.raw(`
    SELECT last_value as seq, (SELECT MAX(id) FROM table) as max_id
    FROM table_id_seq
  `);

  if (result.seq < result.max_id) {
    throw new Error("Sequence 不同步!");
  }
}

4. 設定 CloudWatch 監控

# 監控 duplicate key 錯誤
FilterPattern: '[..., level=ERROR*, msg="*duplicate key*"]'
Alarm: >= 1 error in 5 minutes → Send alert

📊 事後檢討

時間軸

時間事件影響
Day 1, 10:30誤部署舊 commit🔴 解鎖記錄消失
Day 1, 3:00恢復資料(未更新 sequence)🟡 埋下炸彈
Day 2, 15:30使用者回報解鎖失敗🔴 炸彈引爆
Day 2, 21:00發現 sequence 問題🟢 真相大白
Day 2, 21:30執行修復🟢 問題解決

根本原因

Sequence 跟實際資料筆數不同步

  • 有 10 筆資料(MAX(id)=15)
  • Sequence 只記到 7
  • 下次插入會用 id=8
  • 但 id=8 已存在
  • 💥 Duplicate key error

改善措施

  • ✅ 資料恢復 SOP(含 sequence 更新)
  • ✅ 安全恢復腳本
  • ✅ 啟動時自動檢查 sequence
  • ✅ CloudWatch 監控
  • ✅ 除錯 runbook

💡 給其他開發者的建議

如果你遇到「Unique Constraint」錯誤

# 1. 看資料庫 log,不要只看應用層
/aws/rds/instance/your-db/postgresql

# 2. 搜尋: "duplicate key"

# 3. 檢查 sequence
SELECT last_value FROM table_id_seq;
SELECT MAX(id) FROM table;
# 如果 last_value < MAX(id) → sequence 問題!

# 4. 修復
SELECT setval('table_id_seq', (SELECT MAX(id) FROM table), true);

如果你需要恢復/匯入資料

// ✅ 推薦:不保留 ID
const data = backup.map(({ id, ...rest }) => rest);

// ⚠️ 如果保留 ID,記得更新 sequence
await db.raw(
  `SELECT setval('table_id_seq', (SELECT MAX(id) FROM table), true)`
);

如果你使用 Strapi

記住:*_lnk 表也有 sequence!

-- 找出所有 link tables
SELECT table_name FROM information_schema.tables
WHERE table_name LIKE '%_lnk';

-- 這些表的 sequence 也要檢查更新!

🎬 尾聲

這次事件讓我學到:

  1. 緊急不等於草率

    • 心裡有疑慮時,寧可多想 5 分鐘
  2. 了解底層機制

    • Strapi 的魔法背後有很多自動產生的表
    • PostgreSQL 的 sequence 不會自動同步
    • 直接指定 ID 插入時,sequence 不會更新
  3. 查錯要找對地方

    • 資料庫錯誤 → 看資料庫 log
    • 用精確的關鍵字搜尋
  4. 預防勝於治療

    • 自動化檢查 > 手動檢查
    • 監控 alert > 使用者回報

希望這篇文章能幫你少走彎路。


📚 延伸資源


標籤: #PostgreSQL #Sequence #Strapi #CloudWatch #Production #Debugging

撰寫日期: 2025-11-09 事件日期: 2025-11-05 影響時間: ~24 小時 Sequence Keys: user_unlocked_animations_user_lnk_id_seq, user_unlocked_animations_animation_lnk_id_seq 學到的教訓: 無價 💎