延續上一篇的問題
在上一篇文章中,我們遇到了一個問題:Link Table 資料遺失導致使用者沒有角色,所有 API 都回傳 401。
文章最後我拋出了一個問題:
如果你的系統把角色資訊 cache 在 Redis、JWT claim、或 BFF 層,當 Link Table 資料不正確時,系統應該:
名詞解釋:
- Redis:一種高速的記憶體資料庫(In-Memory Database),常用於快取(Cache)熱門資料,避免每次都查詢主資料庫
- JWT claim:JWT Token 內的資料欄位。例如把使用者角色直接寫在 Token 裡:
{ "sub": "user_123", "role": "admin" },這樣就不用每次都查 DB - BFF(Backend For Frontend):一種架構模式,在前端和後端 API 之間多一層「專為前端服務的後端」,常會在這層做權限快取
- 立即全站拒絕?
- 繼續相信 cache?
- 還是進入 degraded mode?
我的選擇是:進入 Degraded Mode。
這篇文章會解釋為什麼,以及如何實作。
一句話定義
Degraded Mode = 系統已知自己「部分不可信」,主動降級功能以維持安全與可用性。
不是壞了、不是裝沒事,而是:
- 我知道哪裡壞
- 我知道哪些功能不能給
- 我知道要保住什麼
為什麼不選另外兩個方案?
❌ 方案 A:立即全站拒絕
所有 API → 401/503 → 業務全掛
問題:
- 使用者完全無法使用系統
- 對業務衝擊太大
- 「寧可錯殺一百」的策略在商業系統中代價過高
適用場景: 金融交易、醫療處方等「錯了比沒有更糟」的場景
❌ 方案 B:繼續相信 Cache
JWT claim 說你是 admin → 就讓你當 admin
問題:
- 如果 DB 資料才是真相,cache 可能已經過時
- 權限外洩風險
- 「裝沒事」是最危險的策略
適用場景: 幾乎沒有。這是最糟的選擇。
✅ 方案 C:進入 Degraded Mode
JWT 有效 → 信 identity
Role 不可信 → 降級功能
寫入操作 → 靜默拒絕
這是平衡安全性與可用性的最佳解。
Degraded Mode 的三個核心原則
原則 1:Fail Closed(權限要保守)
不確定能不能給 → 就不要給
這是安全系統的基本原則。當授權資料不可信時,預設行為應該是「拒絕」而非「允許」。
原則 2:功能降級 ≠ 系統死亡
核心服務要活,但敏感操作要關
使用者還是可以登入、可以看資料,只是不能做寫入操作。這比全站掛掉好得多。
原則 3:可觀測、可恢復
系統要知道「我現在在 degraded」
這不是 runtime exception,是一個明確的系統狀態。要能監控、要能告警、要能手動恢復。
狀態機設計
觸發 AUTHZ_DEGRADED 的條件:
// 啟動時自檢
async function checkRoleIntegrity(): Promise<boolean> {
// 條件 1:Link Table 是否有資料
const linkCount = await db('users_roles_lnk').count();
if (linkCount === 0) return false;
// 條件 2:使用者是否都有角色
const orphanUsers = await db.raw(`
SELECT COUNT(*) FROM users u
LEFT JOIN users_roles_lnk lnk ON u.id = lnk.user_id
WHERE lnk.role_id IS NULL
`);
if (orphanUsers.count > 100) return false; // 容忍少量異常
return true;
}
功能矩陣:什麼能做、什麼不能做
| 功能 | NORMAL | DEGRADED | 說明 |
|---|---|---|---|
| 登入 | ✅ | ✅ | 認證層正常 |
| 讀取個人資料 | ✅ | ✅ | 唯讀安全 |
| 讀取公開內容 | ✅ | ✅ | 無權限需求 |
| 建立訂單 | ✅ | ❌ | 寫入操作 |
| 修改個人設定 | ✅ | ❌ | 寫入操作 |
| 管理後台 | ✅ | ❌ | 高權限操作 |
| 刪除資料 | ✅ | ❌ | 破壞性操作 |
原則:唯讀 + 最小暴露
實作:Middleware 層的守門員
// 系統狀態管理
enum SystemState {
NORMAL = 'NORMAL',
AUTHZ_DEGRADED = 'AUTHZ_DEGRADED',
}
class SystemStateManager {
private state: SystemState = SystemState.NORMAL;
setDegraded() {
this.state = SystemState.AUTHZ_DEGRADED;
logger.warn('[SYSTEM] Entering AUTHZ_DEGRADED mode');
this.notifyOps(); // 發送告警
}
isDegraded(): boolean {
return this.state === SystemState.AUTHZ_DEGRADED;
}
private notifyOps() {
// Slack、PagerDuty、Sentry...
}
}
const systemState = new SystemStateManager();
// Middleware:根據操作類型決定是否放行
function degradedGuard(requiredLevel: 'read' | 'write') {
return async (ctx, next) => {
if (systemState.isDegraded() && requiredLevel === 'write') {
// 靜默拒絕,不回傳錯誤
ctx.status = 204; // No Content
return;
}
return next();
};
}
// 使用方式
router.get('/api/me', degradedGuard('read'), getProfile);
router.post('/api/orders', degradedGuard('write'), createOrder);
router.delete('/api/users/:id', degradedGuard('write'), deleteUser);
關鍵設計:靜默拒絕
// ❌ 不要這樣
ctx.throw(503, 'System in degraded mode');
// ✅ 這樣做
ctx.status = 204; // 操作被吃掉,但不報錯
為什麼?因為 Degraded State 是系統內部狀態,不是產品狀態。
為什麼選擇「靜默」?
使用者不需要知道系統哪裡壞了
❌ 暴露 degraded 給前端:
- 前端要寫 if (degraded) { ... }
- UX 出現模糊的警告訊息
- PM 問:什麼時候會好?
- 系統狀態污染產品語意
✅ 靜默處理:
- 按鈕自然不能按
- 表單自然不能送
- 沒有錯誤碼、沒有恐慌訊息
- 系統像「變慢、變保守」,但沒壞
這叫 Graceful Degradation(優雅降級)。
責任分界
| 系統該負責的事 | 產品不該知道的事 |
|---|---|
| 判斷是否 degraded | 哪張表壞了 |
| 決定哪些能力可用 | RBAC 是否完整 |
| 阻止高風險操作 | Link Table 有沒有資料 |
| 發送內部告警 | 系統內部狀態 |
產品只關心「能力」,不關心「原因」。
JWT 怎麼處理?答案是「半信」
在 Degraded Mode 下:
// ✅ 信 identity(使用者是誰)
const userId = jwt.sub; // 這個可以信
// ❌ 不信 role/permission(使用者能做什麼)
const userRole = jwt.role; // 這個不能信
JWT 在 degraded mode 下只剩下:
{
"sub": "user_12345",
"iat": 1705123456
}
Role 和 permission 一律從「不信任」的角度處理。
監控:沒有觀測就等於沒做
至少需要三個 signal:
// 1. 系統狀態指標
metrics.gauge('system_state', {
state: systemState.isDegraded() ? 'AUTHZ_DEGRADED' : 'NORMAL'
});
// 2. 被擋下的寫入請求數量
metrics.counter('degraded_write_blocked', {
endpoint: ctx.path
});
// 3. Degraded 持續時間
metrics.gauge('degraded_duration_seconds', {
since: degradedStartTime
});
這些只會出現在:
- Grafana Dashboard
- Slack 告警
- Sentry Tag
使用者永遠看不到。
資料層的保護
在 Degraded Mode 下,資料層要做到「什麼都不做」:
async function createOrder(data) {
// 最後一道防線
if (systemState.isDegraded()) {
logger.info('[DEGRADED] Order creation skipped', { data });
return null; // 靜默返回
}
// 正常邏輯
return await db('orders').insert(data);
}
Degraded Mode 下禁止:
- ❌ 寫入 DB
- ❌ 更新 Cache
- ❌ 發送 Event
- ❌ 進入 Queue
寧願什麼都不做,也不要做錯。
這是一種產品哲學
我選擇的是:
「系統不穩定,不應該成為使用者的心理負擔。」
這在醫療系統、金融系統、長期使用型 App 中,是非常重要的價值觀。
使用者打開 App,看到「系統維護中」會焦慮。但如果他只是發現「今天好像不能下單」,他可能只會想「等一下再試」。
差異在於:我們沒有把系統問題轉嫁給使用者。
結論
當授權資料不可信時:
| 選項 | 安全性 | 可用性 | 使用者體驗 |
|---|---|---|---|
| 全站拒絕 | ⭐⭐⭐ | ❌ | 😱 恐慌 |
| 相信 Cache | ❌ | ⭐⭐⭐ | 😊 但有風險 |
| Degraded Mode | ⭐⭐ | ⭐⭐ | 😐 正常但受限 |
Degraded Mode 是平衡的藝術。
最後一句話:
當授權資料不可信時,最危險的不是服務中斷,而是系統假裝一切正常。
系列文章
- 資料庫同步的隱藏陷阱:Link Table 的重要性
- 當授權資料不可信時,我選擇讓系統安靜地退後一步(本文)
