Software Development2025 IT 鐵人 30 天挑戰
Effect 服務管理(四)
延續 Day21-Day22 資料庫服務範例
在 Day21-Day22 我們已經建立好 ConfigLive、LoggerLive、DatabaseLive,並進一步組成應用層服務 MainLive:
ConfigLive ─┐
├─(merge)→ AppConfigLive →(provide)→ DatabaseLive → MainLive
LoggerLive ─┘
MainLive → 提供給 HTTPServer(或你的主程式)今天(Day23)我們延續這個架構,專注在「建構期」的可靠性:
- 備援(fallback):失敗時如何降級
- catchAll vs orElse:何時需要錯誤資訊、何時直接切換
- 重試與退避:先試幾次再決定
- 局部備援 vs 全域備援
- 錯誤映射與觀測性
錯誤處理:catchAll 與 orElse
有些時候建構 Layer 也可能失敗,這時可用錯誤處理 API 提供替代方案,讓程式持續運行。
catchAll:可取得錯誤並轉為備援 Layer
import { Context, Effect, Layer } from "effect"
// 回憶一下我們有哪一些服務
class Config extends Context.Tag("Config")<
Config,
{
readonly getConfig: Effect.Effect<{
readonly logLevel: string
readonly connection: string
}>
}
>() {}
class Logger extends Context.Tag("Logger")<
Logger,
{
readonly log: (message: string) => Effect.Effect<void>
}
>() {}
class Database extends Context.Tag("Database")<
Database,
{ readonly query: (sql: string) => Effect.Effect<unknown> }
>() {}
// ┌─── Layer<Database, never, Logger | Config>
// ▼
const DatabaseLivePrimary = Layer.effect(
Database,
Effect.gen(function*() {
// 模擬主資料庫失敗,觸發 Layer.catchAll 的降級路徑
return yield* Effect.fail(new Error("Primary database connection failed (simulated)"))
})
)
// ┌─── Layer<Database, never, Logger>
// ▼
const InMemoryDatabaseLive = Layer.effect(
Database,
Effect.gen(function*() {
const logger = yield* Logger
const connection = "in-memory://db"
return {
query: (sql: string) =>
Effect.gen(function* () {
yield* logger.log(`Executing query (memory): ${sql}`)
return { result: `Results from ${connection}` }
})
}
})
)
// 失敗時回退到備援
// ┌─── Layer.Layer<Database, never, Config | Logger>
// ▼
const DatabaseLive = DatabaseLivePrimary.pipe(
Layer.catchAll((e) => {
console.log(`Recovering from error\n${String(e)}`)
return InMemoryDatabaseLive
})
)還記得我們這個系列其中一個目的是學習產品級的軟體開發嗎?這邊我們用實際的例子來說明,當服務發生錯誤而被降級到 in-memory DB 這樣的處理方式有哪些好處與壞處:
淺談 in-memory DB 的降級策略
我覺得用真實情境來探討這個議題會比較好理解,廢話不多說,我們直接看例子。
案例一:Rate Limiter 依賴 Redis,Redis 掛了
- 背景:API 入口使用 Redis 做 token bucket 限流,保護下游。
- 事件:Redis 叢集維護期間短暫不可用,
connect/PING逾時。 - 降級:
- 熔斷 Redis 客戶端(打開 circuit),避免每次呼叫都卡在逾時。
- 切到「單機 in-memory token bucket」實作,門檻調低(更保守)。
- 只影響瞬時流量控制;不涉及持久資料。
- 防護:
- 設定上限(每實例每分鐘 N 次),避免單機記憶體爆掉。
- 發告警+標記降級 header/metrics(如
X-Degraded: rate-limit-in-memory)。
- 恢復:
- 監測 Redis 心跳恢復穩定(連續通過 N 次),關閉熔斷,切回 Redis。
- in-memory 計數無需回補,直接丟棄。
為什麼可行?限流屬於「控制面」且可接受短暫不一致;跨實例不同步雖有誤差,但比全面停擺好。
案例二:Feature Flags/設定服務故障
- 背景:旗標服務(如 PostHog/自建 Config Server)提供開關;應用在啟動時與定期拉取。
- 事件:外部旗標服務逾時或 5xx,暫時拉不到最新設定。
- 降級:
- 使用「最後已知設定」的 in-memory 快取(帶 TTL - Time To Live)。
- 僅允許讀取旗標,不允許「變更旗標」的寫入。
- 風險:
- 旗標可能不是最新(例如 A/B 測試比例暫時不更新)。
- 恢復:
- 旗標服務恢復後立即刷新快取,復原到正常同步流程。
為什麼可行?旗標多半是讀多寫少,短暫過期可接受;不涉及交易資料。
案例三:排行榜/熱門文章(read-only)
- 背景:首頁需要熱門內容,平時從資料庫或快取查詢。
- 事件:主要資料庫連線失敗。
- 降級:
- 回應「最後生成的熱門內容快照」(保存在應用的 in-memory)給 GET 請求。
- 暫停會改變排行榜結果的寫入動作(例如人工把文章「置頂」),或把這些請求放進可持久的佇列,等主要資料庫恢復後再處理。
- 影響:
- 使用者看到的熱門榜單可能稍舊,但服務不中斷。
- 恢復:
- 資料庫恢復後,重新生成快照並替換 in-memory 內容。
為什麼可行?業務是唯讀回應,時效性要求可容忍幾分鐘延遲。
反例:訂單/金流(不要用 in-memory 當寫入替代)
- 背景:建立訂單需要寫入資料庫(或事件儲存)。
- 錯誤做法:資料庫掛了就把訂單「暫存在 in-memory」等待回補。
- 問題:
- 多實例不一致,重啟遺失,回補順序與去重困難,容易產生重複訂單或遺漏。
- 正確降級:
- 直接「拒絕」建立訂單並清楚回應錯誤,或
- 將請求寫入「可持久的佇列」(Kafka/SQS/RabbitMQ),由後台消費者在主庫恢復後入庫(仍需設計去重與重試,並清楚對外回應「已受理/非即時確認」)。
重點:關鍵寫入不要回退到記憶體;要嘛拒絕,要嘛進持久佇列,確保可追溯與不丟單。
orElse:不需錯誤內容,直接換備援 Layer
// ┌─── 備援 Layer
// ▼
const database = DatabaseLivePrimary.pipe(Layer.orElse(() => DatabaseFallbackLive))
Effect.runFork(Layer.launch(database))何時用 catchAll 與 orElse
- 需要錯誤資訊時 → 用
catchAll:你想根據錯誤內容做不同處置(降級、調整設定、紀錄更多脈絡),就用catchAll取得錯誤值來決策。 - 不在意錯誤內容時 → 用
orElse:只要「失敗就切換」備援方案,且備援不需要原始錯誤內容時,orElse最簡潔。
補充:分層備援
在產品級系統中,備援不要一次跳到「全域降級」。先局部、再邊界、最後才是全域,這樣影響面最小、回復也最快。
層級 1(局部/服務內) :某個依賴掛了 → 換同質的備援實作
層級 2(應用/邊界層) :某個業務功能受阻 → 提供「read-only/簡化」替代結果
層級 3(全域/系統級) :整體不可用 → 回應降級頁面/靜態 503 與明確告警搜尋功能備援實例
使用者在網站輸入關鍵字執行搜尋,系統需穩定回傳結果清單;若部分依賴故障,必須在不影響對外一致性的前提下降級服務:
- 服務輸出:字串清單;空清單代表目前無可用結果(對外一致)。
- 正常路徑:
Database可用 → 回傳db:${q}結果。 - 唯讀降級:
Database不可用 → 切換ReadOnly,優先使用Cache;命中回傳快取結果cache:${q},未命中回傳空清單。 - 極限備援:
Cache層亦不可用或沒有 cache 資料 → 回傳空清單。 - 測試切換:以
TestMode = "dbSuccess" | "cacheHit" | "cacheMiss"驗證三種情境。
graph TD
A[Search run] --> B{DB layer available}
B -->|yes| R1[db result]
B -->|no| C[ReadOnly mode]
C --> D{Cache layer available}
D -->|yes| E{Cache hit}
E -->|yes| R2[cache result]
E -->|no| R_EMPTY[empty result]
D -->|no| F[Degraded]
F --> R_EMPTY[empty result]讓我們來看看程式碼吧!😌
// 服務契約:快取(Read-only)。提供以 key 讀取字串值。
class Cache extends Context.Tag("Cache")<Cache, {
readonly get: (key: string) => Effect.Effect<string | undefined>
}>() {}
// 服務契約:資料庫。以查詢字串回傳結果清單。
class Database extends Context.Tag("Database")<Database, {
readonly find: (q: string) => Effect.Effect<ReadonlyArray<string>>
}>() {}
// 服務契約:搜尋。對外暴露單一 run 方法。
class Search extends Context.Tag("Search")<Search, {
readonly run: (q: string) => Effect.Effect<ReadonlyArray<string>>
}>() {}
// 主路徑:Search 直接委派給 Database(完整功能)
const SearchLivePrimary = Layer.effect(
Search,
Effect.gen(function*() {
const db = yield* Database
return {
run: (q: string) => db.find(q)
}
})
)
// 次級路徑(唯讀):Search 透過 Cache 回應。命中快取則回傳命中結果,否則回傳空陣列(對外一致)。
const SearchReadOnlyLive = Layer.effect(
Search,
Effect.gen(function*() {
const cache = yield* Cache
return {
run: (q: string) =>
Effect.gen(function*() {
const hit = yield* cache.get(`search:${q}`)
return hit ? [hit] : []
})
}
})
)
// 測試模式:控制各種情境
// - "dbSuccess": 資料庫可用(主路徑)
// - "cacheHit": 快取命中(唯讀降級)
// - "cacheMiss": 快取未命中(最終降級)
type TestMode = "cacheMiss" | "cacheHit" | "dbSuccess"
const TEST_MODE = "cacheHit" as TestMode
const QUERY = "hihi"
console.log("TEST_MODE", TEST_MODE)
// 小工具:將多個 Layer 依序串接,若前者失敗則切換到下一個(降級鏈)
function withFallbacks<S, E, R>(
first: Layer.Layer<S, E, R>,
...fallbacks: ReadonlyArray<Layer.Layer<S, E, R>>
): Layer.Layer<S, E, R> {
return fallbacks.reduce(
(acc, fb) => acc.pipe(Layer.catchAll(() => fb)),
first
)
}
function buildCacheLayer(mode: TestMode): Layer.Layer<Cache, unknown, never> {
// 情境:快取命中時,直接以快取提供資料
if (mode === "cacheHit") {
return Layer.effect(
Cache,
Effect.succeed({
get: (key: string) => Effect.succeed(key === `search:${QUERY}` ? `cache:${QUERY}` : undefined)
})
)
}
// 真實快取(Redis):此處故意失敗以模擬不可用
const CacheRedisLive = Layer.effect(
Cache,
Effect.fail(new Error("Redis unavailable (simulated)"))
)
// 後備快取(記憶體):永遠可用,但預設沒有資料
const CacheInMemoryLive = Layer.effect(
Cache,
Effect.succeed({
get: (_key: string) => Effect.succeed<string | undefined>(undefined)
})
)
// 先試 Redis,失敗則降級到記憶體快取
return CacheRedisLive.pipe(Layer.catchAll(() => CacheInMemoryLive))
}
function buildDatabaseLayer(mode: TestMode): Layer.Layer<Database, unknown, never> {
// 成功情境:資料庫可用
if (mode === "dbSuccess") {
return Layer.effect(Database, Effect.succeed({ find: (q: string) => Effect.succeed([`db:${q}`]) }))
}
// 失敗情境:模擬主資料庫連線失敗
return Layer.effect(Database, Effect.fail(new Error("Primary database connection failed (simulated)")))
}
// 依 TEST_MODE 建立對應的快取與資料庫 Layer
const CacheLayer = buildCacheLayer(TEST_MODE)
const DatabaseLayer = buildDatabaseLayer(TEST_MODE)
// 最終降級:回傳空清單,維持服務可用性
const DegradedSearchLive = Layer.succeed(Search, { run: (_q: string) => Effect.succeed([]) })
// 組合降級鏈:Primary -> ReadOnly -> Degraded
const SearchLive = withFallbacks(
SearchLivePrimary.pipe(Layer.provide(DatabaseLayer)),
SearchReadOnlyLive.pipe(Layer.provide(CacheLayer)),
DegradedSearchLive
)
// 系統注入:此例中只需提供 Search 即可
const MainLive = SearchLive
// 應用程式:呼叫 Search.run 並回傳結果
const program = Effect.gen(function*() {
const search = yield* Search
const results = yield* search.run(QUERY)
return results
})
// 提供 Layer 並執行
const runnable = Effect.provide(program, MainLive)
Effect.runPromise(runnable).then(console.log).catch(console.error)程式碼解讀
- Primary(完整功能):
SearchLivePrimary依賴DatabaseLayer,成功回傳db:${q}。 - Fallback Chain:以
withFallbacks(Primary, ReadOnly, Degraded)依序降級。 - ReadOnly(唯讀降級):
SearchReadOnlyLive依賴CacheLayer,命中回cache:${q};未命中回[]。 - Degraded(全域備援):
DegradedSearchLive回傳[];與 ReadOnly 未命中一致,滿足對外一致性。 - TestMode:
"dbSuccess" | "cacheHit" | "cacheMiss"控制三種情境,便於示範與測試。示例使用TEST_MODE = "cacheHit"。 - QUERY:以
QUERY常數(例如"hihi")搭配cache:${QUERY}模擬快取命中條件。
關鍵要點:
- 局部先救火(換掉單一依賴的實作),不影響其他服務。
- 應用邊界提供 read-only 或簡化結果,維持體驗可用性與可預期性。
- 最後準備全域備援,讓系統即使在極端情況下也能穩定回應(現實狀況還要搭配明確告警給使用者)。
總結
- 判斷是否可降級:可降級用
orElse;需根據錯誤決策用catchAll。 - 加上重試/退避:對暫時性錯誤先重試,最後再決定降級或失敗。
- 分層思考備援:能局部就局部;必要時提供全域備援方案。
參考資料
Last updated on