「老闆,用戶的解鎖記錄全不見了!」「快把舊資料拉出來灌回去!」在緊急狀況下,我沒想太多就照做了。然後,我不小心埋下了一顆定時炸彈…
🔥 第一幕:災難降臨
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 ✅
用簡單例子理解問題
假設資料表現狀:
| id | user_id | animation_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 的最終值
- 可能產生資料不一致
- 難以追蹤哪些是舊資料、哪些是新資料
更好的做法:
- 開啟維護模式(停止寫入) ⭐ 最關鍵!
- 恢復資料
- 更新 sequence ⭐
- 驗證後再開放
為什麼維護模式這麼重要?
// ❌ 當時的做法(危險)
// 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);
教訓 4:了解 Strapi 的 Link Tables
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 也要檢查更新!
🎬 尾聲
這次事件讓我學到:
緊急不等於草率
- 心裡有疑慮時,寧可多想 5 分鐘
了解底層機制
- Strapi 的魔法背後有很多自動產生的表
- PostgreSQL 的 sequence 不會自動同步
- 直接指定 ID 插入時,sequence 不會更新
查錯要找對地方
- 資料庫錯誤 → 看資料庫 log
- 用精確的關鍵字搜尋
預防勝於治療
- 自動化檢查 > 手動檢查
- 監控 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
學到的教訓: 無價 💎