iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Modern Web

用 Effect 實現產品級軟體系列 第 23

[學習 Effect Day23] Effect 服務管理(四)

  • 分享至 

  • xImage
  •  

延續 Day21-Day22 資料庫服務範例

在 Day21-Day22 我們已經建立好 ConfigLiveLoggerLiveDatabaseLive,並進一步組成應用層服務 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))

何時用 catchAllorElse

  • 需要錯誤資訊時 → 用 catchAll:你想根據錯誤內容做不同處置(降級、調整設定、紀錄更多脈絡),就用 catchAll 取得錯誤值來決策。
  • 不在意錯誤內容時 → 用 orElse:只要「失敗就切換」備援方案,且備援不需要原始錯誤內容時,orElse 最簡潔。

補充:分層備援

在產品級系統中,備援不要一次跳到「全域降級」。先局部、再邊界、最後才是全域,這樣影響面最小、回復也最快。

層級 1(局部/服務內) :某個依賴掛了 → 換同質的備援實作
層級 2(應用/邊界層) :某個業務功能受阻 → 提供 read-only 或簡化替代結果
層級 3(全域/系統級) :整體不可用 → 回應降級頁面/靜態 503 與明確告警

搜尋功能備援實例

使用者在網站輸入關鍵字執行搜尋,系統需穩定回傳結果清單;若部分依賴故障,必須在不影響對外一致性的前提下降級服務:

  • 服務輸出:字串清單;空清單代表目前無可用結果(對外一致)。
  • 正常路徑Database可用 → 回傳db:${q}結果。
  • 唯讀降級Database不可用 → 切換ReadOnly,優先使用Cache;命中回傳快取結果cache:${q},未命中回傳空清單。
  • 極限備援Cache層亦不可用或沒有 cache 資料 → 回傳空清單。
  • 測試切換:以 TestMode = "dbSuccess" | "cacheHit" | "cacheMiss" 驗證三種情境。

https://ithelp.ithome.com.tw/upload/images/20251007/201759902DPLgRgsDX.png

讓我們來看看程式碼吧!😌

// 服務契約:快取(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
  • 加上重試/退避:對暫時性錯誤先重試,最後再決定降級或失敗。
  • 分層思考備援:能局部就局部;必要時提供全域備援方案。

參考資料


上一篇
[學習 Effect Day22] Effect 服務管理(三)
下一篇
[學習 Effect Day24] Effect 服務管理(五)
系列文
用 Effect 實現產品級軟體24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言