Phase 10 — Integration + Final Assessment(Day 93–100)

100 Day Engineer Challenge

Phase 10 — Integration + Final Assessment(Day 93–100)

這份逐日教材依循內部規劃文件的範圍與排序展開1

本檔案由兩個任務接力完成:本段(T-037)涵蓋 Day 93–97:DSA Mock +Database/Concurrency/Distributed Systems Deep Dive + System Design Mock;Day 98–100(Backend Architecture Challenge / Teach-back Exam /Final Boss)由 T-038 接續寫在本檔案後半段,不另開新檔。

Phase 10「不再大量增加新知識」(§14 開頭原文),是 Phase 01–09 內容的重組/實測——目的是「把 knowledge 轉換成 ability」。Day 93–97 刻意不重新從零教一次 Phase 02–07 已經教過的完整推導過程,而是要求在時間壓力(Day 93、97 的計時模擬)或更深的操作/整合情境(Day 94–96 的 Deep Dive)下重新調用這些知識。每天開頭都會列出「涵蓋範圍」,明確指出這一天引用哪些更早的任務(T-021~T-032)覆蓋過的哪些具體主題,讓讀者不需要翻回原始 Day 內容就能定位要複習的範圍。

Day 93 — DSA Mock:5 題計時模擬

學習目標

看完並完成今天內容後,能夠:

  1. 在時間壓力下,靠自己(不翻教材)走完第 22 節「DSA 的特殊驗收」2定義的完整流程:Identify Pattern →Choose Data Structure → Brute Force → Optimize → Complexity →Implement → Test Edge Cases。
  2. 對 5 題各自留下 Pattern/Why/Mistake/Complexity/Reusable Insight 完整紀錄(DSA 特殊驗收格式),而不是解完就跳過不留紀錄。
  3. 判斷自己在哪些 Core Pattern 上生疏(距離 Day 11–30 已經過了超過 60 天),作為要不要額外加練的具體依據,而不是憑感覺。

涵蓋範圍

  • T-021(Day 11–15:Array / String / HashMap / HashSet / Linked List / Stack / Queue)
  • T-022(Day 16–20:Deque / Heap / Binary Tree / BST / Trie /Graph)
  • T-023(Day 21–25:Complexity / Two Pointers / Sliding Window /Binary Search / Sorting)
  • T-024(Day 26–30:Recursion / DFS / BFS / Backtracking / Greedy /Dynamic Programming / Graph)

計時規則

沿用 Day 70(System Design 計時模擬)建立的節奏,套用到 DSA 題目:

  • Easy:讀題 2 分鐘 + 作答 15 分鐘
  • Medium:讀題 2 分鐘 + 作答 25 分鐘
  • Hard:讀題 3 分鐘 + 作答 40 分鐘

今天的組合是 4 題 Medium + 1 題 Hard,依序作答(可以分兩節進行,但單題本身的計時一旦開始就不能中斷去查資料),總計時預算:

``text(2+25) + (2+25) + (2+25) + (2+25) + (3+40) = 151 分鐘(約 2.5 小時)``

每題計時內先不看任何提示/參考解——時間到才對照下方的 Brute Force→Bottleneck→Pattern→Optimal→Complexity 推導自我檢查,中途偷看會讓這次練習失去意義(呼應 Day 70「計時結束前不要往下看參考解」的規則)。

第 1 題 — K Closest Points to Origin(LeetCode 973,Medium,25 分鐘)

題目:給一個二維座標點陣列 points 與整數 k,找出離原點(0, 0) 最近的 k 個點(回傳順序不拘)。

Brute Force:計算每個點到原點的距離(或距離平方),把整個陣列排序,取前 k 個——時間 O(n log n)。

Bottleneck:當 n 遠大於 k(例如 n = 1,000,000k = 5)時,排序整個陣列做了大量不必要的工作——題目只需要「前 k 小」,不需要「完整排序」,把 999,995 個不會被用到的點的相對順序也精確排出來,是浪費的計算。

Pattern:Top-K 問題,對應 Day 17 Heap(Core Fundamentals)教過「維護一個大小為 k 的 heap,只保留目前看過的候選最佳 k 筆」的策略;也可以用 Quickselect(Day 24 Binary Search 的分治精神延伸、Day 25 Sorting 的 partition 技巧)在期望 O(n) 時間內完成。

Optimal Approach:維護一個大小為 k 的 Max-Heap(依距離)。依序處理每個點:heap 未滿 k 個就直接 push;heap 已滿 k 個,比較新點的距離與 heap 堆頂(目前 k 個候選裡最遠的那個)——若新點更近,pop 掉堆頂、push 新點;若新點更遠,直接跳過。走完全部 n 個點後,heap 裡剩下的 k 個就是答案。比較距離時用距離平方(x² + y²)而非開根號後的實際距離,避免不必要的浮點運算開銷(結果排序不受影響,因為平方是單調遞增函式)。

Complexity:時間 O(n log k)(n 個點各自最多一次 O(log k) 的 heap 操作),空間 O(k)(heap 大小固定為 k)——當 k 遠小於 n時,明顯優於 Brute Force 的 O(n log n)。Quickselect 版本:期望時間 O(n)(每次 partition 期望丟棄一半候選),最壞情況 O(n²)(可用隨機 pivot 降低觸發機率),空間 O(1)(in-place)。

Test Edge Casesk 等於 n(退化成排序全部);k = 1points 內含重複座標;points 內含負座標。

第 2 題 — Clone Graph(LeetCode 133,Medium,25 分鐘)

題目:給一個無向連通圖裡的其中一個節點(節點有 valneighbors 陣列),回傳整個圖的深拷貝(deep copy)。

Brute Force:直接遞迴複製每個節點,對每個 neighbor 遞迴呼叫 clone——若圖中有環(例如 A 的 neighbor 是 B、B 的 neighbor 是 A),會無窮遞迴(每次都嘗試建立新節點,永遠不會停)。

Bottleneck:沒有「哪些節點已經複製過」的記錄,導致兩個問題:(1) 環造成無窮遞迴;(2) 即使沒有環,同一個節點被多條路徑指到時也會被重複複製多次,破壞圖的共享結構——deep copy 後,兩個不同節點若原本指向同一個鄰居,應該指向同一個複製後的節點,而不是各自複製一份。

Pattern:圖走訪(DFS 或 BFS 皆可)疊加 HashMap 的「用 HashMap 避免重複計算/重複建立」技巧——這是 Day 20 Graph 與 Day 12 HashMap 兩個 Pattern 的組合題,也正是 Day 93 刻意選「組合題」而非單一 Pattern 孤立題目的示範。

Optimal Approach:用一個 HashMap<原節點, 複製節點> 記錄已經複製過的節點。DFS 版本:clone(node) 函式,若 node 已經在 HashMap 中,直接回傳 map[node](避免無窮遞迴與重複複製);否則,建立一個新節點放進 HashMap(在遞迴進入 neighbor 之前就放入),再遞迴 clone 每個 neighbor 並接上——這一步「先佔位再遞迴」是避免環造成無窮遞迴的關鍵:即使 neighbor 還沒複製完,遞迴進入時會發現這個節點已經在 HashMap 裡(哪怕它的 neighbors 欄位還沒填完),直接回傳既有的參照,不會再往下遞迴。BFS 版本:用 queue 取代遞迴堆疊,邏輯類似。

Complexity:時間 O(V+E)(每個節點與每條邊恰好被處理一次),空間 O(V)(HashMap + 遞迴堆疊/queue,最壞情況遞迴深度 O(V))。

Test Edge Cases:單一節點、無鄰居;只有兩個節點互相為鄰居(最小環);空圖(起始節點為 null)。

第 3 題 — Longest Increasing Subsequence(LeetCode 300,Medium,25 分鐘)

題目:給一個整數陣列 nums,找出最長嚴格遞增子序列(subsequence,不要求連續)的長度。

Brute Force:對每個元素遞迴嘗試「選」或「不選」,若選則必須比前一個被選中的元素大,窮舉全部 2ⁿ 種子序列組合,檢查每個是否遞增並取最長——指數時間。

Bottleneck:大量重複子問題——「從索引 i 開始、上一個選的元素是nums[j]」這個狀態,會被不同的遞迴路徑重複計算多次。

Pattern:Dynamic Programming(Day 29 教過的 State → Transition →Base Case → Order 四步驟)。今天是 DP 應用在一個 Day 29 陌生題(0/1 Knapsack)沒有直接示範過的「子序列」問題形狀——State 的定義方式跟 Knapsack 不同,這是 DP 能力能不能真正遷移到新問題形狀的考驗,不是背下同一題的解法。

Optimal Approach(O(n²) DP 版)Statedp[i] = 「以nums[i] 結尾的最長遞增子序列長度」。Transitiondp[i] =max(dp[j] + 1),對所有 j < inums[j] < nums[i];若找不到這樣的 jdp[i] = 1Base Case:每個 dp[i] 初始為 1(單一元素自己就是長度 1 的遞增子序列)。Orderi 從左到右遞增計算(因為dp[i] 依賴所有 j < idp[j],必須先算完前面的)。答案是max(dp)

進階優化(O(n log n) 版):維護一個陣列 tailstails[k]表示「長度為 k+1 的遞增子序列中,結尾最小的那個值」(貪婪維護最小結尾,讓未來更多元素有機會接上去,這個貪婪維護的正確性論證跟 Day 30 Greedy 段落的 exchange argument 是同一種思路)。對每個新元素 x,用 Binary Search(Day 24)在 tails 中找第一個 >= x 的位置,替換掉它(若 xtails 裡所有值都大,則直接 append);最終tails 的長度就是答案。這是 Day 24 Binary Search 知識在一個「看起來不像 Binary Search」的 DP 優化情境裡重新出現——能不能認出「tails陣列本身維持遞增、可以做二分搜尋」是這題進階解法真正的關鍵洞察。

Complexity:O(n²) DP 版:時間 O(n²)、空間 O(n)。O(n log n) 版:時間 O(n log n)(n 個元素各自一次 Binary Search),空間 O(n)(tails 陣列)。

Test Edge Cases:全部遞減的陣列(答案應為 1);全部相同數值(嚴格遞增要求下答案為 1);空陣列;只有一個元素。

第 4 題 — Combination Sum(LeetCode 39,Medium,25 分鐘)

題目:給一個無重複正整數陣列 candidates 與目標值 target,找出所有「元素可以重複使用」且總和等於 target 的組合(不可有重複的組合——元素相同、順序不同視為同一組合)。

Brute Force:對每個位置嘗試每個 candidate(含重複使用同一個),窮舉所有可能組合直到總和達到或超過 target

Bottleneck:(1) 沒有及早剪枝——總和一旦超過 target 就該立刻停止往下探索,不該走到底才發現失敗;(2) 沒有規則避免「順序不同但元素相同」的重複組合(例如 [2,3,2][2,2,3] 若不加規則會被當成兩種不同組合各自產生一次)。

Pattern:Backtracking(Day 28)。今天的組合題允許「重複使用同一元素」——這跟 Day 28 原本教的排列/組合(每個元素只用一次)在遞迴「下一步能不能選同一個元素」這件事上有關鍵差異,是 Backtracking 知識遷移到新約束條件的考驗。

Optimal Approach:遞迴函式 backtrack(start, remaining, path)start 參數限制「這一層只能選 index >= start 的 candidate」——確保不會選到「陣列中排在前面、已經跳過」的元素,天然避免順序不同的重複組合,不需要額外的去重邏輯。每次選中 candidates[i] 後,遞迴呼叫時 start 仍傳 i(不是 i+1)——這正是「允許重複使用同一元素」的具體實作方式:因為下一層的 start 還是 i,下一層依然可以再選到同一個 i。剪枝:remaining < 0 時立刻 return(不再往下探索,呼應 Bottleneck 第一點);remaining == 0 時,把目前 path 加入結果(找到一組合法解)。

Complexity:最壞情況是指數級(解空間大小依 targetcandidates 的具體數值而定,Backtracking 類問題通常用「解空間大小」而非傳統多項式大 O 描述複雜度);空間 O(target / min(candidates))(遞迴深度,最壞情況一路選最小的 candidate 直到湊滿 target)。

Test Edge Casescandidates 中有元素本身就大於 target(該元素在遞迴中應該直接被剪枝跳過);target = 0(需要自己先明確決定「回傳一組空組合」還是「不算合法解」這種題目未講清楚的邊界,並在推導時寫下自己選的解讀,這正是 Constraints 需要自己收斂的地方);candidates 只有一個元素。

第 5 題 — Trapping Rain Water(LeetCode 42,Hard,40 分鐘)

題目:給一個非負整數陣列 height 代表柱狀圖每根柱子的高度,計算下雨後這個柱狀圖總共能接住多少雨水。

Brute Force:對每個位置 i,該位置能接的水量 = min(左邊最高的柱子, 右邊最高的柱子) - height[i](若為負則算 0)。對每個 i 都分別往左、往右各掃一次找最大值——O(n²)。

Bottleneck:對每個 i 都重新掃一次左右兩側找最大值,做了大量重複計算——位置 i 的「左邊最高」跟位置 i+1 的「左邊最高」其實高度重疊,可以預先算好前綴/後綴最大值陣列,或者用兩根指標邊走邊維護目前已知的左右最大值,不需要每個位置各自重新掃描整段。

Pattern:Two Pointers(Day 22)與「用單一變數取代重複掃描」(呼應 Day 30 Greedy 段落 maxReach 的貪婪維護精神——雖然這題不屬於 Greedy 分類,但「維護目前已知最佳值、不重新掃描」是同一種思路)的組合;也可以用 Stack(Day 15)版本解(維護一個高度遞減的柱子 index stack,遇到比 stack 頂端更高的柱子時,逐一 pop 並計算每次 pop 時形成的「凹槽」接水量)。

Optimal Approach(雙指標版,O(1) 額外空間):維護 leftright 兩個指標分別從陣列兩端往中間移動,並各自維護 leftMaxleft 指標走過的最高值)、rightMaxright 指標走過的最高值)。核心推導(這題真正的難點):如果 leftMax < rightMax,代表位置left 的接水量上限由 leftMax 決定——不管 right 那一側實際多高,只要「right 指標走過的路徑上已經看到一個比 leftMax 更高的柱子」這件事成立,位置 left 的水位就已經被 leftMax 這個較矮的邊界卡住了,不需要精確知道 right 指標右邊全部區域真正的最大值,因為水位是由兩側「已確定範圍內」的較矮邊界決定的。於是可以安全地結算 left位置的水量(leftMax - height[left])並將 left 右移;反之若rightMax <= leftMax,對稱處理 right 位置後左移。

Complexity:時間 O(n)(雙指標各自最多走訪 n 次),空間 O(1)(只需要 4 個變數:left/right/leftMax/rightMax),優於 Brute Force 的 O(n²) 時間,也優於前綴陣列版本額外需要的 O(n) 空間。

Test Edge Cases:陣列長度小於 3(不可能接水,答案為 0);單調遞增或單調遞減陣列(答案為 0);中間有多個連續凹槽的情況。

Day 93 過關標準(DoD)—— DSA 特殊驗收格式(5 題)

```text Pattern: Top-K + Heap(K Closest Points to Origin)Why: 用大小固定為 k 的 Max-Heap,只保留『目前看過的最佳 k 筆』,不需要對全部 n 筆做完整排序,把 Top-K 問題從 O(n log n) 降到 O(n log k)。Mistake: 誤以為 Top-K 一定要先排序整個陣列才能取前 k 個,沒發現 heap 大小可以固定在 k、不隨 n 成長。Complexity: 時間 O(n log k);空間 O(k)。Quickselect 版:期望 O(n)時間、O(1) 空間。Reusable Insight: 看到『找出前/後 K 個』的題目,優先考慮固定大小 k 的 Heap 或 Quickselect,而不是完整排序;n 遠大於 k 時差距非常明顯。

Pattern: 圖走訪 + HashMap 去重(Clone Graph)Why: 用 HashMap 記錄『原節點 → 複製節點』的映射,且在遞迴進入 neighbor 之前就先佔位寫入 HashMap,同時解決『環造成無窮遞迴』與『同一節點被重複複製』兩個問題。Mistake: 只在複製完一個節點『之後』才寫入 HashMap(而非複製之前先佔位),遇到環時仍然會無窮遞迴,因為遞迴進入 neighbor 時 HashMap 裡還沒有這個節點的紀錄。Complexity: 時間 O(V+E);空間 O(V)。Reusable Insight: 圖走訪遇到『需要避免重複處理同一節點、且可能有環』的題目,先佔位再遞迴(而非複製完才登記)是避免無窮遞迴的標準手法。

Pattern: Dynamic Programming(Longest Increasing Subsequence)Why: State 定義為『以 nums[i] 結尾的最長遞增子序列長度』,Transition 掃描所有更早的合法前驅並取最大值 +1,這跟 0/1 Knapsack 的 State 定義方式不同,是 DP 遷移到『子序列』問題形狀的具體示範。Mistake: 把這題誤判成跟 0/1 Knapsack 同構直接套用同一套 State,沒有重新走一次 State→Transition→Base Case→Order 四步驟,導致 Transition 寫錯(例如漏掉『對所有更早的合法前驅取 max』,只看前一個元素)。Complexity: O(n²) DP 版:時間 O(n²)、空間 O(n)。優化版:時間 O(n log n)(用 Binary Search 維護 tails 陣列)、空間 O(n)。Reusable Insight: 看到『tails 陣列本身維持遞增』這個性質時,DP 問題也可能藏著一個可以用 Binary Search 優化的子結構,不要看到 DP 就只想到 O(n²)/O(n³) 的窮舉轉移。

Pattern: Backtracking,允許重複使用元素(Combination Sum)Why: 遞迴呼叫下一層時 start 參數傳 i(而非 i+1),讓同一個元素可以在後續遞迴中被再次選中,同時 start 限制『只能選 index>= start』天然避免了順序不同的重複組合。Mistake: 沿用 Day 28『每個元素只能選一次』的慣性,遞迴呼叫時誤寫成 i+1,導致無法產生『同一元素出現兩次以上』的合法解。Complexity: 解空間大小依 target 與 candidates 而定(非簡潔多項式大 O);空間 O(target / min(candidates))(遞迴深度)。Reusable Insight: Backtracking 題目『能不能重複選同一元素』只靠遞迴呼叫時 start 傳 i 還是 i+1 這一個參數決定,這是判斷一道新 Backtracking 題目該怎麼寫遞迴呼叫的第一個檢查點。

Pattern: Two Pointers + 貪婪維護已知邊界(Trapping Rain Water)Why: leftMax < rightMax 時,只需要知道『right 指標已經看過一個比 leftMax 高的柱子』就能安全結算 left 位置的水量,不需要精確算出 right 指標右邊全部區域的真正最大值。Mistake: 誤以為一定要先用前綴/後綴陣列把左右兩側的精確最大值全部算完才能開始結算,沒發現雙指標版本靠『已知一個比較邊界更高的柱子』這個較弱的條件就足夠正確,多用了 O(n) 額外空間。Complexity: 時間 O(n);空間 O(1)(優於前綴陣列版的 O(n) 空間)。Reusable Insight: 雙指標搭配『各自維護目前已知的較弱邊界條件』(不需要全局精確值),常常能把一個看似需要預先算完整個陣列的問題,壓縮到 O(1) 額外空間單趟掃描解決。

自我診斷: 本次 5 題涵蓋 Heap/Graph+HashMap/DP/Backtracking/Two Pointers,未涵蓋 Sliding Window(Day 23)、Binary Search 獨立成題(Day 24,本次只在 LIS 優化版間接出現)、Sorting 獨立成題(Day 25)、Trie(Day 19)。若計時作答時對這 5 題以外的 Pattern 明顯生疏(超過 5 分鐘想不出 Pattern),額外找 1 題對應 Pattern 的題目補練,不需要每輪 Mock 都覆蓋滿 12 個 Core Pattern——這正是 Day 93 用『組合題』取代『窮舉孤立題』的取捨。自評方式: 5 題中,能在時限內獨立想出正確 Pattern(不看提示)的題數 >= 4,視為過關;3 題以下視為需要額外複習,回頭精讀對應 Day 的教材(見上方「涵蓋範圍」),而不是直接跳過繼續往下一天走。```

  1. 依 00-master-curriculum.md 第 14–15 章與 00-knowledge-dependency-graph.md 第 2.10 節
  2. 依 00-master-curriculum.md 第 22 節「DSA 的特殊驗收」

Day 94 — Database Deep Dive:深分頁(OFFSET Pagination)的隱藏成本

學習目標

看完並完成今天內容後,能夠:

  1. 對一個 Day 31–40 沒有直接教過的查詢形狀(深分頁),追一次真實的EXPLAIN (ANALYZE, BUFFERS) 輸出,判斷效能瓶頸的真正成因。
  2. 具體解釋為什麼「這個查詢有 Index」跟「這個查詢的分頁方式本身有 O(offset) 的固有成本」是兩個獨立的問題,Index 解決不了後者。
  3. 把 OFFSET 分頁改寫成 Keyset(cursor-based)分頁,並說出這個改寫犧牲了什麼(不支援跳頁)換到了什麼(深分頁不再隨深度變慢)。

涵蓋範圍

  • T-025(Day 31–35:Page / Buffer / B-Tree 結構與高度)
  • T-026(Day 36–40:Index / Selectivity / Query Planner / MVCC /Isolation / Lock)

什麼叫「深入」:今天追的是一個全新的查詢形狀

Day 36–40 已經完整推導過 Selectivity(Day 37)、MVCC 可見性(Day 38)、Isolation 異常(Day 39)、Lock/Deadlock(Day 40)——今天不重複這些,而是拿一個 Day 31–40 沒有處理過的查詢形狀:深分頁(deep pagination),追一次真實的 EXPLAIN (ANALYZE, BUFFERS)輸出,把 Day 33–37 學過的 B-Tree Search / Index Scan 知識應用到一個新問題上——「Index Scan 沿著 B-Tree 找到起始位置後,往後走訪多少筆才停」這件事,Day 31–40 沒有直接展開過。

環境設置

```sql CREATE TABLE bench_feed_posts (id BIGSERIAL PRIMARY KEY,user_id INT NOT NULL,content TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL);

INSERT INTO bench_feed_posts (user_id, content, created_at)SELECT (random() * 100000)::int,'post body ' || g,NOW() - (random() * INTERVAL '365 days')FROM generate_series(1, 500000) g;

CREATE INDEX idx_feed_created_at ON bench_feed_posts (created_at DESC);ANALYZE bench_feed_posts;```

50 萬筆資料,模擬 Day 64 News Feed 場景裡「依時間排序、分頁瀏覽」的資料表,created_at 已建 Index。

為什麼 OFFSET 分頁會變慢:機制推導

SELECT * FROM bench_feed_posts ORDER BY created_at DESC OFFSET:n LIMIT 20 這種寫法,是最直覺的分頁做法——OFFSET n 代表「跳過前n 筆,從第 n+1 筆開始取」。問題在於 Planner 即使選擇 Index Scan(idx_feed_created_at 已經依 created_at DESC 排好序),仍然必須沿著 B-Tree leaf node 的順序走訪過前面 n 筆、逐一丟棄,才能開始回傳第 n+1 筆之後的 LIMIT 20——這不是「有沒有用到 Index」的問題(三種 OFFSET 值下 Planner 都會選 Index Scan),而是「Index Scan 走訪順序」本身的固有成本:OFFSET 值越大,要走訪並丟棄的筆數就越多,成本近似與 OFFSET 值成線性關係。

陌生題示範:情境——同一張 bench_feed_posts(50 萬筆),比較OFFSET 0 LIMIT 20(第一頁)與 OFFSET 400000 LIMIT 20(接近尾端的深分頁)。請推導兩者的 actual time 量級差異,以及「加更多 Index」能不能解決深分頁變慢的問題。

推導:OFFSET 0 幾乎不需要額外走訪——Index Scan 從 B-Tree 排序的起點開始,直接回傳前 20 筆,actual time 應該落在個位數毫秒等級。OFFSET 400000 則需要先走訪並丟棄 40 萬筆 leaf entry(外加對應的 heap fetch,確認每筆資料仍然可見——呼應 Day 38 MVCC 可見性檢查,每一筆被走訪到的 entry 都要做一次 visibility check,即使最終被OFFSET 丟棄也不能省略這一步),這個工作量跟 OFFSET 0 相比,是數量級的差距,且不會因為多加其他 Index 而改善——因為問題不是「找不到符合條件的資料要不要用 Index」,而是「已經用了 Index、但排序後的走訪路徑本身就長」,加更多 Index 對「要跳過多少筆」這件事沒有幫助,是文不對題的修法。

修法:Keyset(Cursor-based)分頁

把分頁條件從「跳過幾筆」改成「從哪個值之後開始」:

``sql-- 假設使用者已經翻到某一頁,最後一筆的 created_at 是 :cursor SELECT * FROM bench_feed_posts WHERE created_at < :cursor ORDER BY created_at DESC LIMIT 20;``

Index Scan 可以直接用 B-Tree Search(Day 33,O(樹高) 的 I/O 次數,Day 35 已推導過樹高與資料量的關係)跳到 :cursor 對應的位置,然後往後走訪 20 筆就結束——不需要走訪並丟棄任何前面的資料,成本是O(樹高 + limit),跟使用者翻到第幾頁(多深)完全無關,這正是 Day 62 URL Shortener 與 Day 64 News Feed 都選擇 cursor 分頁而非 offset 分頁的底層原因(今天把「為什麼」的機制補完)。

Trade-off

Keyset 分頁要求排序欄位本身穩定、單調、且最好唯一(created_at 若有重複值,兩筆時間戳完全相同的資料在分頁邊界可能被跳過或重複出現,需要加上次要排序鍵如 id 做 tiebreaker:ORDER BY created_at DESC, id DESCWHERE (created_at, id) < (:cursor_time,:cursor_id));且只能「從游標往下一頁」,不支援「直接跳到第 500 頁」這種任意頁碼跳轉——這是拿掉的功能,換到深分頁不再隨深度變慢的效能保證,是一個明確的產品層級取捨,不是免費的優化。

Failure Mode:常見的錯誤修法

看到「深分頁很慢」,直覺的錯誤反應往往是「再加一個 Index」或「把SELECT * 改成只選需要的欄位」——這些對深分頁的固有成本沒有幫助(Selectivity/欄位寬度不是這裡的瓶頸來源),正確的修法是改變 API 的分頁契約本身(從「第幾頁」改成「從游標之後」),這代表這類問題往往不是靠加 Index/調參數能解決,而是需要回頭改 API 設計——DB 效能問題不總是「資料庫層面」的問題。

Day 94 過關標準(DoD)—— Database 特殊驗收格式

```text Query: 建立 bench_feed_posts(50 萬筆,created_at 建索引),構造深分頁與淺分頁對照:SELECT * FROM bench_feed_posts ORDER BY created_at DESC OFFSET 0 LIMIT 20;SELECT * FROM bench_feed_posts ORDER BY created_at DESC OFFSET 400000 LIMIT 20;

EXPLAIN: 對兩句分別執行 EXPLAIN (ANALYZE, BUFFERS),記錄計畫節點(是否都是 Index Scan)、actual time、Buffers hit/read 的差異。

Hypothesis: 依今天教材,OFFSET 400000 的 actual time 應遠高於 OFFSET 0(差距量級應與 400000 這個 OFFSET 值大致成正比),即使兩句都用了同一個 Index。

Change: 把 OFFSET 400000 那句改寫成 Keyset 版本(先用 OFFSET 0 LIMIT 20 查出第一頁最後一筆的 created_at,模擬「已知游標」的狀態,再用它跑 Keyset 查詢):SELECT * FROM bench_feed_posts WHERE created_at < :cursor ORDER BY created_at DESC LIMIT 20;

Benchmark: 對比三句話(OFFSET 0 / OFFSET 400000 / Keyset 版)各自的 actual time 與 Buffers hit/read 數值。

Conclusion: 用自己的話寫一段,解釋為什麼「深分頁」的成本不是來自 Index 有沒有生效(三句話應該都會顯示用了 idx_feed_created_at),而是來自「Index Scan 走訪路徑本身要跳過多少筆」這件事;並回答:如果 Product 需求真的需要支援「跳到第 N 頁」這種任意頁碼跳轉,Keyset 分頁還適用嗎(提示:見上方 Trade-off 段落)。

自評方式: 能不能不看提示正確預測「OFFSET 越深、actual time 越高,且大致與 OFFSET 值成正比」;能不能正確寫出 Keyset 改寫版本並說出它跟 OFFSET 版本用的是同一個 Index;能不能正確指出 Keyset 分頁「不支援任意跳頁」這個限制,三項都能做到才算過關。建議時間盒:環境建置 15 分鐘 + EXPLAIN 比對 30 分鐘 + 改寫驗證 20 分鐘 +書寫 Conclusion 15 分鐘,約 80 分鐘。```

Day 95 — Concurrency Deep Dive:Lazy Initialization 的競態(陌生情境七問推導)

學習目標

看完並完成今天內容後,能夠:

  1. 對一個 Day 41–50 沒有直接教過的具體 Pattern(未加保護的 lazy singleton / double-checked locking),套用第 25 節標準七問完整重新推導一次,而不是重複 Day 44/49 已經做過的例子。
  2. 解釋 sync.Once 為什麼不只是「保證只執行一次」,還額外提供了 Happens-Before 保證,說出這個額外保證解決了什麼未加保護版本無法解決的問題。
  3. go test -race 實際觀察未加保護版本與 sync.Once 版本的 race detector 輸出差異。

涵蓋範圍

  • T-027(Day 41–45:Concurrency vs Parallelism / Process / Thread /Goroutine / Scheduler / Race Condition / Mutex / RWMutex / Atomic /Channel)
  • T-028(Day 46–50:Deadlock / Livelock / Starvation / G/M/P /Context / Concurrency Patterns)

什麼叫「深入」:今天推導的是沒被單獨教過的具體 Pattern

Day 44 的經典 race 例子是「多個 Goroutine 同時對同一個 int 變數做 ++」,Day 49 的 Capstone 是「Token Bucket 計數器」——兩者都是「反覆讀寫同一個數值」的 race。今天換一個沒有被單獨展開過的具體 Pattern:Lazy Initialization(延遲初始化)——只有第一個呼叫者觸發初始化,之後所有呼叫者重複使用同一份結果,這是一個在計數器之外另一種常見的共享狀態競態來源。

情境:延遲初始化一個昂貴的共享資源

一個服務要 lazy 初始化一個昂貴的共享資源(例如一個全域的 DB connection pool 或 cache client),只有第一個 request 觸發初始化,之後所有 request 重複使用同一個 client,不要每次都重新建立。

錯誤版本

```go var client *Client

func GetClient() *Client {if client == nil {client = expensiveInit()}return client}```

在併發下,多個 Goroutine 同時第一次呼叫 GetClient()client ==nil 的檢查與 client = expensiveInit() 的賦值不是一個 atomic 的複合操作,會出現兩層問題:(a) data race——多個 Goroutine 同時讀寫 client 這個指標,不同 Goroutine 可能各自判斷 client == nil為真,各自呼叫一次 expensiveInit()(重複初始化,浪費資源,甚至產生多個不同的 client instance 互相衝突,最後只有最後一次賦值「贏」,其餘建立過程中若有副作用如真的開了 TCP 連線,就洩漏了沒人負責關閉);(b) 更隱蔽的問題——沒有明確的同步點(synchronization point)保證「一個 Goroutine 完成 client = expensiveInit() 賦值」這件事,對另一個讀到非 nil 值的 Goroutine 而言是「完整可見」的:如果 expensiveInit() 回傳的物件內部欄位初始化與指標賦值之間沒有被恰當的同步機制排序,理論上可能讀到一個指標非 nil、但內部欄位還沒完全初始化完成的物件——這正是「double-checked locking」這個模式在許多語言裡出名地容易寫錯的原因。

正確版本

```go var once sync.Once var client *Client

func GetClient() *Client {once.Do(func() {client = expensiveInit()})return client}```

sync.Once.Do() 保證傳入的函式在整個程式生命週期內恰好執行一次——不只是「不會重複執行」,更重要的是它是一個明確的同步點,建立起 Happens-Before 關係:任何一個 Goroutine,只要它呼叫 Do() 之後成功 return,就保證能看到函式內部所有寫入的完整結果,不需要額外再加 Mutex 去保護 client 這個變數本身的讀取。

套用標準七問(第 25 節)

Where is shared state? 全域變數 client 這個指標(以及expensiveInit() 內部初始化的 Client 物件本身的所有欄位)。

Where is race? 錯誤版本:client == nil 的讀取與 client =expensiveInit() 的寫入,在多個 Goroutine 同時第一次呼叫時,構成對同一個記憶體位置的並發讀寫、且至少一個是寫入——這是 go run-race 能直接抓到的典型 data race。

What synchronizes it? 正確版本靠 sync.Once 內部的 Mutex + 一個用 atomic 操作檢查的 done 旗標,保證 init 函式只執行一次,且建立「看到 done = true 的 Goroutine,保證能看到 Do() 內部所有寫入」的 Happens-Before 關係。

What is the critical section? init 函式本身(client =expensiveInit() 這一步),被 Once.Do() 保護——只有第一個進入的 Goroutine 真正執行它,其餘 Goroutine 在 sync.Once 內部的 Mutex 上等待,直到第一個 Goroutine 完成才會一起返回。

What blocks? 錯誤版本本身不會 block——正是問題所在,它會「競速」執行多次初始化,而非正確地等待;正確版本中,除了第一個進入的 Goroutine 外,其餘同時呼叫 Do() 的 Goroutine 會阻塞,直到 init 完成才解除阻塞。

What happens under contention? 假設 100 個 Goroutine 同時第一次呼叫 GetClient():錯誤版本可能有數十個 Goroutine 都各自判斷client == nil 為真(因為檢查發生在別人賦值完成之前),各自呼叫一次expensiveInit(),造成資源重複建立;正確版本中,99 個 Goroutine 會阻塞等待,只有 1 個真正執行初始化,其餘 99 個等它完成後直接拿到同一份client,不會重複建立。

Can it deadlock? sync.Once 本身不會 deadlock,除非在傳入Do() 的函式內部又遞迴呼叫同一個 once.Do()——那會造成該 Goroutine 對自己已經持有的 Mutex 重複上鎖而卡死,這是 sync.Once文件明確警告的用法錯誤(Reentrant lock 類型的 deadlock),需要避免在 Once.Do() 傳入的函式裡再次呼叫同一個 onceDo()

Day 95 過關標準(DoD)—— Concurrency 特殊驗收格式(標準七問 + 實測對照)

```text Implementation: 依上方兩個版本各自寫成可執行的 Go 程式,各自用 100 個 Goroutine 併發呼叫 GetClient()(expensiveInit() 內部可以簡單地 sleep 幾毫秒模擬耗時初始化 + 遞增一個計數器記錄『init 函式實際被呼叫了幾次』)。

標準七問(第 25 節): 完整回答見上方「套用標準七問」段落,逐項對照自己寫的程式碼位置(哪一行是 shared state、哪一行是 critical section)。

Verify: 對錯誤版本執行 go run -race,記錄是否出現 DATA RACE 警告(預期:會,且警告位置應指向 client 變數的讀寫);記錄 expensiveInit() 實際被呼叫的次數(預期:可能大於 1,且每次執行結果不穩定,這正是 race 的具體症狀)。對正確版本重複同樣的測試(預期:go run -race 零警告;expensiveInit() 恰好被呼叫 1 次,且每次重跑結果穩定)。

Conclusion: 用自己的話寫一段,說明 sync.Once 除了『保證只執行一次』之外,還額外提供了什麼保證(Happens-Before),這個額外保證解決了『只用一個 bool 旗標手動加鎖檢查』(例如if !initialized { mu.Lock(); ...; initialized = true;mu.Unlock() })不容易正確寫對的哪個具體陷阱;並回答:如果 expensiveInit() 內部又需要用到 GetClient() 本身(遞迴呼叫同一個 once.Do()),會發生什麼,為什麼要避免。

自評方式: 錯誤版本能不能穩定重現 go run -race 的 DATA RACE 警告(若怎麼跑都沒有警告,代表測試程式的併發規模/時序不足以觸發 race,需要加大 Goroutine 數量或調整 expensiveInit() 內部的 sleep 時間讓時間窗更容易被觀察到);正確版本能不能穩定通過-race 且 init 函式恰好執行 1 次,兩項都成立才算過關。建議時間盒:實作兩版本 30 分鐘 + 執行與記錄 -race 輸出 20 分鐘 + 書寫七問與 Conclusion 20 分鐘,約 70 分鐘。```

Day 96 — Distributed Systems Deep Dive:Resharding 期間的分散式路由不同步

學習目標

看完並完成今天內容後,能夠:

  1. 對一個整合 Day 51–60 多個概念(Sharding / Consistent Hashing /Cache / Replication)、但沒有被任何單一 Day 單獨教過的失敗情境,重新推導根因與修法,而不是重複某一天已經做過的單一概念陌生題。
  2. 用 Day 55 的搬遷比例公式(新增節點數 ÷ 擴容後總節點數)算出一次擴容實際會影響多少比例的資料。
  3. 解釋為什麼「路由表」本身也是一種需要被同步的分散式共享狀態,而不是一個可以隨意各節點各自更新的本機設定。

涵蓋範圍

  • T-029(Day 51–55:CAP / Consistency 模型 / Replication / Sharding /Consistent Hashing)
  • T-030(Day 56–60:Cache 讀寫策略 / Cache Invalidation / Messaging /Failure)

什麼叫「深入」:整合多個概念的情境,而非重複單一 Day 的陌生題

Day 51–60 每一天各自針對單一概念都已經有完整的陌生題示範(Day 53 Replication failover、Day 55 Consistent Hashing 負載不均、Day 57 Cache stale data race……)。今天不重複任何一天已經做過的具體例子,而是設計一個同時需要 Sharding、Consistent Hashing、Cache 三個概念一起推導才能診斷出根因的整合情境——這種「多個已知機制組合出一個新的失敗模式」正是 Phase 10「重組/實測」的核心價值,比逐一複習 Day 51–60 更貼近真實生產事故的診斷過程(真實事故很少是單一機制的教科書式失敗)。

情境

一個電商庫存服務用 Consistent Hashing(Day 55)把商品資料分散在 8 個 Shard,每個 Shard 各自是 Primary + 1 個非同步 Replica(Day 53)。前面加一層 Cache Aside(Day 56),依 product_id 快取商品庫存,TTL 30 秒。

雙十一前,運維團隊把 Shard 數從 8 個擴容到 12 個(新增 4 台)。擴容採「滾動更新」:先啟動新的 4 台 Shard、把要遷移過去的資料複製到新 Shard,接著逐台更新 50 台 App Server 的「路由表」(記錄每個 hash ring 位置對應到哪個 Shard),讓它們開始把重新映射到新 Shard 的 key 路由過去;路由表本身透過一個設定中心(config service)以 Eventual Consistency 推送給各台 App Server,實測平均全部 50 台 App Server 都拿到最新路由表,需要約 20–30 秒。

擴容後 10 分鐘內,客服接到大量客訴:「同一件商品的庫存數字看起來時多時少,下單扣庫存後過幾秒又跳回原本的數字」。

推導:三個概念疊加出的根因

第一層:Consistent Hashing 擴容的搬遷比例——依 Day 55 的公式「搬遷比例 ≈ 新增節點數 ÷ 擴容後總節點數」,這次新增 4 個節點、擴容後共 12 個,約 4 / 12 ≈ 33% 的商品 key 會被重新映射到新 Shard(比 Day 55 陌生題示範的「8 → 10、20%」更高,因為這次是同一批擴容一次新增 4 台而非 2 台)。假設商品總數 2,400 萬件,約 800 萬件商品的歸屬 Shard 在這次擴容中改變。

第二層:路由表傳播延遲期間的分散式路由不同步(今天真正的新問題,Day 51–60 沒有單獨教過)——路由表本身是「每台 App Server 各自持有一份、需要跨節點保持一致」的分散式共享狀態,跟 Day 63 Rate Limiter 的計數器是同一類問題(多節點各自維護副本會不一致),但這裡不一致的不是一個數值,而是「同一個商品 key 現在該路由去哪個 Shard」這個更根本的判斷。在路由表傳播的 20–30 秒視窗內,50 台 App Server 裡有些已經更新到新路由表(把某商品路由去新 Shard),有些還停留在舊路由表(仍路由去舊 Shard)——同一個商品 key,不同 App Server 實例處理到的請求可能被路由到兩個不同的物理 Shard,兩邊各自獨立扣減「自己那一份」的庫存數字,彼此不知道對方的存在。

第三層:Cache Aside 放大不一致的可見程度——Cache 依 product_id快取查詢結果,不知道底層資料實際上被分裂成新舊兩個 Shard 各自維護一份;哪個 App Server 實例先查到就把哪個 Shard 的當前值寫進 Cache(TTL 30 秒內都會回傳這個值)。因為不同 App Server 實例路由到不同 Shard、扣減的是不同的庫存副本,使用者看到的庫存數字取決於「這次請求剛好被哪個 App Server 處理、讀到 Cache 裡哪次寫入的值」——這正是客訴「數字時多時少」的直接成因:不是隨機亂跳,而是兩個獨立真相來源(舊 Shard 的庫存、新 Shard 的庫存)透過 Cache 交替被看到

修法:遷移需要一個明確的 Cutover Barrier,不能滾動漸進切換路由

問題根源不在 Consistent Hashing、Cache、Replication 任何一個機制本身有 bug,而在於遷移過程缺少一個「路由表切換」的同步屏障——與其讓 50 台 App Server 各自在不確定的時間點各自切到新路由表(造成切換視窗內兩份路由表並存、各自獨立寫入兩個物理 Shard),正確做法是兩階段遷移:(1) 資料同步階段——先把要遷移的商品資料複製到新 Shard,但維持全部 App Server 仍路由到舊 Shard,新 Shard 這段時間只是被動接收複製,不接受任何直接寫入;(2) 原子切換階段——資料同步完成後,透過一個所有 App Server 都會讀到的單一版本號(例如把路由表版本存在一個所有 App Server 都直接查詢的權威來源,而非讓設定推送自然擴散),要求「同一個商品的所有請求,切換前一律走舊 Shard、切換後一律走新 Shard」,不允許介於中間的漸進狀態;切換的同時,主動失效(invalidate,而非等 TTL)這批遷移商品在 Cache 裡的所有 entry,確保切換後的第一次查詢一定重新從新 Shard 載入,不會殘留切換前寫入的舊值。

Day 96 過關標準(DoD)—— 具體情境分析

```text 情境:一個即時通訊 App 的「使用者目前連在哪台 Chat Server」連線註冊表(見 phase-06 Day 65),原本用 Consistent Hashing 把 500 萬個線上連線分散在 5 個 Redis 節點。運維把 Redis 節點從 5 個擴容到 8 個(新增 3 台),採跟本文相同的滾動更新路由表策略,全部 App Server(Chat Server)平均需要 15 秒才能拿到最新路由表。擴容後幾分鐘內,部分使用者回報「明明對方顯示在線,訊息卻送不到,過一陣子才忽然收到」。

分析:1. 搬遷比例:依 Day 55 公式,新增 3 個節點、擴容後共 8 個,約 3/8 = 37.5% 的使用者連線註冊記錄會被重新映射到新 Redis 節點。2. 根因與本文情境同構,但表現形式不同:這裡不一致的不是『庫存數字』,而是『訊息轉發目的地』——Day 65 教過,訊息轉發靠查 connections 表找出對方連在哪台 Chat Server,再透過 Redis Pub/Sub 轉發。在路由表傳播的 15 秒視窗內,發送方所在的 Chat Server 若已切到新路由表、去新 Redis 節點查詢對方的連線記錄,但對方的連線記錄當下還只存在於舊 Redis 節點(尚未完成遷移或尚未被任何人以新路由表寫入),會查到『找不到對方的連線記錄』,導致訊息被判斷成『對方離線』而走離線訊息路徑(寫入 DB 等對方下次上線才拉取)——這解釋了『對方顯示在線、訊息卻延遲送達』的症狀:Presence 顯示的『在線』可能來自另一個仍在用舊路由表、正確查到連線記錄的 Chat Server,但送訊息的這次請求剛好被路由到已經切換、查詢新 Redis 節點卻撲空的 Chat Server,兩者用的是不同時間點的路由判斷。3. 修法:與本文相同——資料同步階段先把連線註冊記錄複製到新 Redis 節點但不切換路由,全部 Chat Server 統一在同一個切換時間點之後才一起查新節點;此外,遷移期間查詢連線記錄若在新節點撲空,應該有一個 fallback:再查一次舊節點(雙查,直到確認遷移完全結束),而不是撲空就直接判斷『對方離線』——這是比本文『完全禁止漸進切換』更寬容、但同樣有效的緩解手段,代價是遷移期間每次查詢可能要查兩個節點,多付出一次網路往返,換取『不會誤判離線』的正確性。4. 結論:Consistent Hashing 解決的是『資料放哪裡』,不解決『所有節點什麼時候一致地知道資料放哪裡』——路由表本身的傳播,需要跟 Day 52 Consistency 模型、Day 53 Replication 同一套『多節點何時看到同一份最新狀態』的思路來設計,不能假設『把資料搬過去就結束了』,這正是本日整合 Day 51–60 三個概念(Sharding/Consistent Hashing/Cache 或本情境的 Pub/Sub 路由)才能診斷出來的根因,任何單一概念都無法獨立解釋『數字時多時少』或『在線卻收不到訊息』這類症狀。

自評方式: 能不能不看提示,獨立算出搬遷比例(3/8 = 37.5%);能不能正確指出『路由表本身也是需要同步的分散式狀態』這個根因(而不是停留在『Consistent Hashing 壞了』或『Cache 壞了』這種歸咎單一元件的錯誤診斷);能不能提出至少一種修法(Cutover Barrier 或雙查 fallback)並說出它的代價,三項都成立才算過關。建議時間盒:推導本文情境 30 分鐘 + 獨立完成 DoD 情境分析 30 分鐘,約 60 分鐘。```

Day 97 — System Design Mock:45–60 分鐘計時模擬(陌生題)

學習目標

看完並完成今天內容後,能夠:

  1. 在時間壓力下,靠自己(不翻教材)走完完整框架,覆蓋全部維度,不因為時間不夠就跳過 Capacity/Failure/Trade-off/Out of Scope 這類步驟。
  2. 面對一個沒有出現在第 11 章題型清單裡1、也不是 Day 70 陌生題(Flash Sale)的新題目,判斷它該用到 Day 61–70 教過的哪些機制組合。

涵蓋範圍

  • T-031(Day 61–65:12 步 Framework、URL Shortener / Rate Limiter /News Feed / Chat)
  • T-032(Day 66–70:Notification / File Storage / Job Scheduler、整合複習、計時模擬)

為什麼選這題當陌生題

今天的題目「即時排行榜(Leaderboard)系統」不在第 11 章列出的 7 個題型裡2,也不是 Day 70 已經用過的 Flash Sale——它需要的全部是 Day 61–70 已經教過的機制(Top-K/Heap 的延伸思路、Sharding、Cache、Eventual Consistency 的取捨),陌生題訓練的是「重新組合已知機制」的能力,不是要求你懂沒教過的新知識。

計時規則

沿用 Day 70 建立的節奏:

  1. 先花 2 分鐘讀完下方題目敘述(只讀一次,不要一邊讀一邊開始寫)。
  2. 接著設定 45–60 分鐘計時,開始寫。建議的步驟時間分配:Requirements+Constraints 5 分鐘、Capacity Estimation 8 分鐘、API+Data Model 8 分鐘、Architecture+Core Flow 15 分鐘、Scaling+Reliability+Consistency 10 分鐘、Failure+Trade-off+Out of Scope 10 分鐘(總計約 56 分鐘,可依個人狀況調整,但不要單一步驟超過 15 分鐘)。
  3. 計時結束前,不要往下看參考解——先寫完自己的十三欄紀錄,計時結束後才對照參考解檢查差異。
今天首次採用第 23 節目前的最新版本3,十三欄(第 13 欄 Out of Scope)——Day 61–70(Phase 07)當初撰寫時,master-curriculum 第 23 節還只有十二欄,Out of Scope 是後續才加入的第 13 欄,所以 Phase 07 既有 Day 61–70 的 DoD 仍是十二欄格式,是已知的既有差異,不在本任務範圍內回頭補改。

題目:即時排行榜(Leaderboard)系統

設計一個遊戲的即時排行榜系統:玩家完成一局遊戲後回報分數,系統更新該玩家的最高分;玩家可以查詢「全球排行榜 Top 100」與「自己目前的全球排名」。

自我檢查(十三欄,先自己填完再往下看參考解)

``text Requirement:Constraints:Capacity:API:Data Model:Architecture:Read Path:Write Path:Scaling:Reliability:Consistency:Failure:Trade-off:Out of Scope:``

參考解(先完成上方計時練習,再往下對照)

1. Requirements

玩家完成一局遊戲後回報分數,系統更新該玩家的「歷史最高分」;玩家可以查詢全球 Top 100 排行榜,以及自己目前的全球排名。Non-functional:查詢排行榜/自己排名的延遲要低(玩家會頻繁打開排行榜頁面);分數更新後,排名不要求絕對即時反映,但要在合理時間內(例如幾秒內)看得到。

2. Constraints

假設只用「歷史最高分」當排名依據(不是每一局都各自排名);假設先只做全球排行榜,不做好友排行榜/分群排行榜(依社交關係過濾排名需要額外的社交圖查詢,見 Out of Scope)。

3. Capacity Estimation

假設 1,000 萬活躍玩家,平均每人每天玩 5 局,每天約 5,000 萬次分數上報事件;但只有「這局分數比自己歷史最高分還高」才真正需要更新排名狀態,假設平均每個玩家每天有 20% 的機率創新高 → 每天約 200 萬次真正需要寫入的更新,平均 QPS ≈ 2,000,000 / 86400 ≈ 23,尖峰抓 5 倍≈ 115 QPS。查詢排行榜/自己排名的讀取遠比寫入頻繁:假設 30% 的活躍玩家每天查看排行榜 3 次 → 每天約 900 萬次讀取,平均 QPS ≈ 104,尖峰抓 5 倍 ≈ 520 QPS——讀取量遠高於寫入量,這個對比直接決定第 6 步 Architecture 要把讀取路徑的效能放在第一優先。

4. API

POST /scores {user_id, score}(回報一局分數,內部判斷是否創新高,只有創新高才觸發後續更新);GET /leaderboard/top?limit=100GET /leaderboard/rank?user_id=X

5. Data Model

player_scores(user_id PK, best_score, updated_at)——這是權威、持久化的來源。排名查詢不能直接對這張表做 ORDER BY best_score LIMIT 100(每次都要對 1,000 萬筆重新排序,成本太高,這正是這題的核心決策點):用 Redis Sorted Set(ZADD/ZREVRANGE/ZREVRANK,key = global_leaderboard,member = user_id,score =best_score)作為排名的即時權威索引,player_scores 表則負責持久化與其他查詢(例如個人歷史成績)。

6. High-Level Architecture

Client → LB → App Server → Redis(Sorted Set,即時排名)+ DB(player_scores,持久化)。

7. Core Flow

  • Read Path:查 Top 100 直接對 Redis 執行 ZREVRANGE global_leaderboard 0 99 WITHSCORES——Redis Sorted Set 底層用 skip list 實作,這個操作是 O(log n + 100),跟 Day 17 Heap 解決 Top-K 問題是同一類思路(不需要對全部 member 排序,只取需要的一段),但 Sorted Set 額外支援 Heap 做不到的「查任意一個 member 的排名」;查自己排名執行 ZREVRANK global_leaderboard user_id(O(log n))。
  • Write Path:App Server 收到分數上報,先確認是否創新高(可以先查 Redis 現有分數再比較,或讓 DB 的條件更新自己判斷);若沒有創新高,直接回應,不做任何寫入;若創新高,同時更新兩處:(a) DB 執行 UPDATE player_scores SET best_score = ? WHERE user_id = ?AND best_score < ?——用條件式更新而非無條件覆蓋,避免 Day 39 教過的 Lost Update 異常(例如兩個並發請求,一個帶著較舊的分數較晚寫入,若不做條件檢查會覆蓋掉較高的分數);(b) 對 Redis 執行ZADD global_leaderboard newScore user_id 更新即時排名索引。

8. Scaling

全部玩家的排名都存在單一 Redis Sorted Set key 裡,是這個設計刻意集中(而非分散)的一點——跟 Day 70 Flash Sale 的 Redis DECR 庫存是同一類設計取捨:全域排名本質上需要一個單一權威、全域有序的視圖,分散到多個節點反而需要額外合併排序的成本。1,000 萬個 member 的 skip list,每個 member 的記憶體開銷約 80–100 bytes(依 Redis 內部實作,含 skip list 指標與 member 資料),總記憶體約 800MB–1GB,單機 Redis 完全可以承受這個規模。App Server 水平擴展;DB 讀取(個人歷史成績等非排名查詢)用 Day 53 Read Replica 分散。若未來規模成長到單機 Redis 無法負荷(例如上億玩家),需要用 Day 54 Sharding 把排行榜依分數區間或地區切成多個 Sorted Set,但分片後「全域 Top 100」查詢就需要合併多個分片各自的 Top 100 再重新排序取前 100——這是為了寫入吞吐擴展性,犧牲讀取路徑簡單性的取捨,今天 1,000 萬規模尚未觸及這個門檻,Out of Scope 明確排除。

9. Reliability

Redis 若掛掉,即時排名查詢完全不可用;排行榜不是關鍵交易路徑(不像 Flash Sale 的庫存扣減),這裡選擇 Fail Open 式降級:Redis 不可用時退回 DB(例如 SELECT COUNT(*) FROM player_scores WHERE best_score > ? 估算排名,或直接顯示「排行榜暫時無法更新」的降級訊息),因為 DB 上直接做全表排名查詢很貴,只適合當短暫降級手段,不是長期方案(呼應 Day 59 Partial Failure「部分結果好過完全失敗」)。Redis 需要做 Replication 或定期 snapshot(RDB/AOF),避免真的資料遺失需要從 DB 全量重建 Sorted Set——1,000 萬筆重建有明確可估算的成本,是一個可接受的最壞情況恢復手段,不是資料永久遺失。

10. Consistency

DB 的 player_scores 與 Redis Sorted Set 是兩個獨立儲存,Write Path 是雙寫,可能出現短暫不一致(DB 更新成功但 Redis 更新失敗,或反過來)。選擇的一致性模型是 Eventual——排行榜顯示的名次容忍幾秒的落後,不要求每次分數更新都全域 Strong Consistency(呼應 Day 52:使用者體感上「幾秒後看到最新排名」完全可以接受,不像 Flash Sale 的庫存扣減完全不能接受任何不一致)。

11. Failure

若雙寫中 Redis ZADD 成功但 DB UPDATE 失敗(例如 DB 暫時不可用),會出現「排行榜顯示了新分數,但 player_scores 沒有對應紀錄」的不一致——處理方式:DB 寫入失敗要重試(進 Queue 由 Worker 補寫,呼應 Day 58 Retry),且這個操作天然 idempotent(重複執行UPDATE ... WHERE best_score < newScore 重試多次不會有副作用,第二次執行時條件已經不成立,直接跳過)。

12. Trade-offs

選擇 Redis Sorted Set 作為即時排名的權威來源、DB 作為持久化來源的雙儲存架構,而非「只用 DB,每次查排名都即時算」(1,000 萬筆全表排序不可能每次查詢都即時完成)或「只用 Redis,不落地 DB」(Redis 若沒有做好持久化配置,重啟/故障會遺失所有歷史分數資料,玩家分數這種需要長期保存的資料不能只放記憶體型儲存)——雙儲存的代價是要處理兩者之間的一致性與雙寫失敗情境(已在 Failure 段落展開),換到「查詢排名極快(O(log n))」與「資料持久安全」兩者兼顧。

13. Out of Scope

  1. 好友排行榜/分群排行榜(依社交關係過濾的排名)——需要額外的社交圖查詢與 join,是功能擴充而非本次核心,見 Constraints 假設。
  2. 反作弊/分數合法性驗證(防止使用者端竄改分數上報)——這是另一個獨立的安全問題(trust boundary 的驗證邏輯),不在「排名系統怎麼設計」這個題目範圍內。
  3. 歷史排名趨勢/多時間視窗排行榜(例如「本週榜」「本月榜」需要按時間視窗分別維護排名)——今天只設計「全時間最高分」這一種排行榜,多時間視窗排行榜是同一個架構模式(各自一個 Sorted Set,依時間視窗做 TTL 或定期 reset)的重複應用,可以是後續迭代但不影響今天的核心決策,故排除。

Day 97 過關標準(DoD)—— System Design 特殊驗收(十三欄,含 Out of Scope)

```text Requirement: 玩家回報分數更新歷史最高分,查詢全球 Top 100 與自己排名 Constraints: 只用歷史最高分排名;先不做好友/分群排行榜 Capacity: 寫入尖峰 QPS≈115(僅創新高才寫入);讀取尖峰 QPS≈520,讀遠大於寫 API: POST /scores {user_id,score};GET /leaderboard/top;GET /leaderboard/rank Data Model: player_scores(user_id,best_score) 持久化 + Redis Sorted Set 即時排名索引 Architecture: LB → App Server → Redis(Sorted Set) + DB(player_scores)Read Path: Top100 用 ZREVRANGE;自己排名用 ZREVRANK,皆 O(log n) 等級 Write Path: 創新高才寫入,DB 條件式 UPDATE(避免 Lost Update)+ Redis ZADD 雙寫 Bottleneck: 全域排名集中在單一 Redis Sorted Set key,1000萬玩家規模仍在單機可承受範圍 Scaling: App Server 水平擴展;規模再成長需依分數區間 Sharding,代價是合併排序 Reliability: Redis 不可用時退回 DB 估算排名或降級提示;Redis 需 Replication/snapshot Consistency: DB 與 Redis 雙寫,Eventual 足夠(排名容忍數秒落後)Failure: Redis 寫成功但 DB 寫失敗,靠 Queue 重試 + 條件更新天然 idempotent Trade-off: 雙儲存(Redis 即時查詢 + DB 持久化)換取查詢快與資料安全兩者兼顧 Out of Scope: 好友/分群排行榜、反作弊分數驗證、多時間視窗排行榜,三項各自理由見上方

自評方式: 能不能在計時內獨立寫出十三欄且沒有任何一欄空白(尤其 Out of Scope 不能因為時間不夠就跳過);能不能正確判斷出「讀遠大於寫」這個特性應該讓 Top-K 查詢走 Redis Sorted Set 而非每次對 DB 重新排序(呼應 Day 17 Heap 與 Day 62 Cache Aside 的適用條件判斷);能不能自己想到用條件式 UPDATE 避免 Lost Update(Day 39),而不是無條件覆蓋 best_score,三項都成立才算過關。若計時內卡在 Data Model/Architecture 那兩步超過 20 分鐘沒有想法,代表 Top-K/Sorted Set 這類「查詢效率結構」的判斷力生疏,回頭複習 Day 17 Heap 與 Day 62 URL Shortener 的 Index 段落,而非直接看參考解抄過去。```

Day 93–97 完成後,Phase 10 前半段(DSA Mock、Database/Concurrency/Distributed Systems Deep Dive、System Design Mock)全部涵蓋,對應第 2.10 節列出的 Day 93–97 依賴4(分別對應 Phase 03、Phase 04、Phase 05、Phase 06、Phase 07)。Day 98–100(Backend Architecture Challenge / Teach-back Exam /Final Boss)接續如下。

  1. 依 00-master-curriculum.md 第 11 章
  2. 依 00-master-curriculum.md 第 11 章
  3. 依 00-master-curriculum.md 第 23 節
  4. 依 00-knowledge-dependency-graph.md 第 2.10 節

Day 98 — Backend Architecture Challenge:AI Agent 驅動的 Incident 自動修復系統

學習目標

看完並完成今天內容後,能夠:

  1. 對一個同時需要 System Design、OS/Deployment、AI Agent 安全邊界三方一起介入才能做對的架構情境,套用 Phase 07 的 System Design Framework1完整推導一次,並清楚分辨「哪裡是純粹的系統設計取捨、哪裡是少了 OS/Deployment 或 Agent 安全邊界任一環節就會出真實事故的整合點」。
  2. 針對 Agent 會呼叫的每一種修復動作,套用 Day 86 Tool Permission 系統的三態判定與固定優先順序,寫出至少一條 deny 規則,並解釋為什麼這件事不能只靠 require_approval 交給值班工程師臨場判斷。
  3. 針對 Agent worker process 本身收到 SIGTERM(例如部署 Agent 自己的新版本、或縮減 replica 數)時,套用 Day 71 Graceful Shutdown 機制,判斷一個「已經送出但還沒確認結果」的修復動作該怎麼安全收尾,而不是被砍在半路留下不確定狀態。

涵蓋範圍

  • T-031/T-032(Phase 07,Day 61–70):System Design 固定 Framework、URL Shortener/Rate Limiter/News Feed/Job Scheduler 幾個題型教過的機制
  • T-059(Phase 08,Day 71–75):Process 生命週期/Signal/Graceful Shutdown、Service Manager Restart 策略、TimeoutStopSec
  • T-060(Phase 09,Day 86–88):Tool Permission 三態與優先順序、Approval Gate 兩種時間模型、Fail-closed/open、TOCTOU

為什麼是這個情境:兩個新面向不是加分題,而是不做就會出真實事故的必要環節

Day 61–70(Phase 07)教過的七個題型裡,沒有一個題目的「Agent 本身」會對生產環境下真正具破壞性的指令(重啟服務、擴縮容、回滾部署)——那些題目的 Write Path 頂多是「使用者資料被寫進資料庫」,出錯的代價是資料不一致,可以靠 Day 39 的交易語意或 Day 58 的重試機制收拾。今天的情境刻意選一個 Agent 系統本身的職責就是代替人類執行有真實副作用的維運操作——一旦一個修復動作因為權限設計疏漏被誤觸發(例如把生產環境回滾到一個已經不相容的舊版本),或者因為 Agent worker process 被粗暴砍掉而留下一個「不知道到底執行了沒有」的動作,造成的後果比一般 CRUD 系統的資料不一致嚴重得多。這正是為什麼 Backend Architecture Challenge 明確要求 T-059(OS/Deployment)與 T-060(AI Agent 安全邊界)至少各一個具體決策點——這兩者在「一個會自主操作生產基礎設施的 Agent」這個情境裡,不是錦上添花的加分項,而是設計本身站不站得住腳的關鍵。

情境

一個 SRE 團隊想要一個 AI Agent,自動處理三種最常見、修復方式已經標準化的 incident:某服務的錯誤率飆高(修法:重啟該服務的 pod,restart_service)、某服務的請求量突然升高導致延遲惡化(修法:水平擴容,scale_out)、某次部署後錯誤率明顯上升且與部署時間點高度相關(修法:回滾到上一個穩定版本,rollback_deployment)。Agent 監聽既有的告警系統,判斷符合這三種已知模式之一後,呼叫對應的修復工具;如果告警不符合任何已知模式,Agent 直接標記「需要人工判斷」,不嘗試自己推理陌生情境。

Agent 本身是一個長 running 的服務(Orchestrator + Worker),部署在 Kubernetes 上,跟其他微服務一樣需要能被正常滾動更新、也需要能水平擴展 Worker 數量以應付告警尖峰。

自我檢查(先自己填完再往下看參考解)

依 System Design 十三欄框架2(Day 97 已示範的完整格式),逐項填寫;額外加兩欄,逼自己不要把 OS/Deployment 與 Agent 安全邊界寫成事後補充:

``text Requirement:Constraints:Capacity:API:Data Model:Architecture:Read Path:Write Path:Scaling:Reliability:Consistency:Failure:Trade-off:Out of Scope:OS/Deployment 決策點(Agent worker 收到 SIGTERM 時,進行中的修復動作怎麼辦):AI Agent 安全邊界決策點(三種修復動作的 Tool Permission 規則 +Approval Gate 模型選擇):``

參考解

1. Requirement

Agent 監聽告警系統,針對「錯誤率飆高」「請求量升高導致延遲惡化」「部署後錯誤率上升」三種已知模式,各自呼叫對應的修復動作(restart_service/scale_out/rollback_deployment);rollback_deployment 因為具破壞性且可能牽涉資料相容性問題,需要人工核准才能真正執行;Agent 本身作為一個生產服務,需要能安全地被部署/重啟/擴縮容,不因為自己的生命週期事件而讓「正在執行中的修復動作」進入不確定狀態。

2. Constraints

只處理上述三種標準修復動作,告警不符合任何已知模式時一律轉人工,不做開放式的 root cause 推理(避免任務規模無限擴大,也避免 Agent 對陌生情境做出不可預期的操作);假設中型公司規模,約 200 個微服務。

3. Capacity Estimation

尖峰時期每小時最多約 5 次告警觸發修復流程,QPS 層級的效能不是這個系統的瓶頸來源(跟 Day 64 News Feed、Day 70 Flash Sale 那種高流量題型不同)——決策正確性與安全機制的完整度,才是這個系統真正的「容量」考驗,這也是這題刻意跟 Phase 07 常見高流量題型拉開差異的地方。

4. API

POST /incidents/webhook(告警系統呼叫,觸發 Agent 評估);GET /remediation-actions/{id}(查詢單一修復動作目前狀態);POST /remediation-actions/{id}/approvePOST /remediation-actions/{id}/reject(值班工程師核准/拒絕介面)。

5. Data Model

remediation_actions(id, incident_id, action_type, target_service,status, idempotency_key UNIQUE, approved_by, requested_snapshot,created_at, updated_at)——status 是一個有限狀態機(pendingpermission_checkawaiting_approvalexecutingcompleted/failed/unknown_needs_human);idempotency_keyincident_id + action_type 組合)加唯一約束,避免同一個告警重複觸發同一動作兩次(呼應 Day 58 的 idempotency 設計);requested_snapshot 記錄核准當下鎖定的目標服務狀態(部署版本、副本數),供下方 TOCTOU 決策點使用。

6. Architecture

告警系統 → Agent Orchestrator(判斷 incident 模式、選擇修復動作,寫入 remediation_actions 表)→ Tool Permission Gate(Day 86,依規則判定 allowed/denied/require_approval)→ allowed直接進 Worker Pool 佇列執行;require_approval 狀態轉為awaiting_approval,透過 Slack 通知值班工程師 → Worker 呼叫實際的基礎設施 API(Kubernetes API/服務網格控制面)執行修復、把結果寫回 remediation_actions

7. Read Path

值班工程師查詢單一動作狀態或 Dashboard 彙總,直接讀remediation_actions——尖峰查詢量低(每小時最多幾次告警),不需要額外的快取層,這跟 Day 62/64 那類讀取遠大於寫入的題型是相反的容量特徵,不能不假思索地照搬那些題型的 Cache 設計。

8. Write Path

告警進來 → Orchestrator 判斷模式並決定 action_type → 先以idempotency_key 寫入一筆 pending 記錄(若唯一約束衝突,代表同一個告警已經觸發過同一動作,直接回傳既有記錄,不重複處理)→過 Tool Permission Gate 判定 → 依判定結果分流(見上方 Architecture)→ Worker 執行時先把狀態改成 executing 並記錄 requested_snapshot,呼叫下游 API 後依回應更新為 completed/failed

9. Scaling

Orchestrator 層無狀態水平擴展;Worker Pool 依告警量水平擴展(因為量體很低,用簡單的資料庫佇列輪詢即可分攤負載,不需要 Phase 08 NATS 那種等級的訊息中介——這是刻意跟高流量題型的差異,基礎設施複雜度應該跟真實負載匹配,不是每個系統都要上全套 Messaging);DB 層因為資料量與寫入量都很低,不需要 Sharding/Read Replica,這本身就是一個明確的取捨判斷:不幫還沒遇到瓶頸的系統過度設計。

10. Reliability

下游 API(Kubernetes/服務網格)呼叫失敗時的重試,必須先確認這個動作是否是 idempotent(restart_service/scale_out 通常是宣告式操作,重試安全;rollback_deployment 也是宣告式指定目標版本,同樣可以安全重試),可以直接重試而不需要先做額外查詢;但如果是「呼叫已送出、但沒收到明確回應」(連線中斷、逾時),必須先查詢下游系統目前實際狀態,確認上一次呼叫到底有沒有生效,才能判斷該不該視為完成或該不該重試(呼應 Day 90 Verification 的精神)。

11. Consistency

remediation_actions 表的狀態轉移必須有單一權威來源——用資料庫的唯一約束(idempotency_key)加上狀態機的條件式更新(例如UPDATE ... WHERE status = 'pending'),確保就算 Orchestrator 意外跑了多個副本,同一個告警也只有一個副本能成功把狀態從pending 推進到下一步,其餘副本的條件式更新會直接失敗、安全地放棄(這正是 Day 39 Lost Update 防禦手法在這裡的重現)。

12. Failure

若 Worker 在呼叫下游 API 之後、寫回結果之前 crash,這筆記錄會卡在executing——需要一個定期掃描的 reconciler,找出「executing超過合理逾時」的記錄,依 Reliability 段落的查詢-確認流程判斷真實狀態並收尾,而不是讓它永遠卡住(呼應 Day 90 Recovery 掃描邏輯)。

13. Trade-off

選擇「Tool Permission Gate 判斷不確定時預設 deny」(Fail-closed,Day 88)而非放行(Fail-open),代價是遇到規則庫沒有明確涵蓋到的邊界案例時,Agent 會拒絕執行、退回人工處理,犧牲一部分自動化覆蓋率;換到的保證是規則涵蓋不到的情境,絕不會被 Agent 自己判斷放行去動生產環境,這對「錯誤代價是真實維運事故」的系統,是值得的取捨。

14. Out of Scope

  1. 開放式 root cause 推理(告警不屬於三種已知模式時的自動診斷)——見 Constraints,交由人工處理。
  2. 多叢集/多雲協調——假設所有服務都在同一個 Kubernetes 叢集內,跨叢集的修復協調是完全不同規模的問題。
  3. 修復動作的效果驗證(例如 restart 後錯誤率是否真的下降)——今天只設計「執行修復動作」這一段,判斷修復是否真的解決問題屬於另一個「效果監控回饋迴圈」的獨立系統,可以是後續迭代。

15. OS/Deployment 決策點——Agent worker 收到 SIGTERM 時,進行中的修復動作怎麼辦

依 Day 71 Graceful Shutdown 機制,Agent Worker process 收到SIGTERM(例如部署 Agent 自己的新版本、或 Kubernetes 縮減 replica)時的具體處理:

  1. 立刻停止從佇列拉取新的修復動作——類似 Day 71 教過「HTTP server 停止 Accept() 新連線」的第一步,讓這個 Worker instance 不再認領新工作,新告警轉由其他還活著的 Worker instance 處理。
  2. 對「已經開始但還沒完成」的動作分兩種情況處理,不能一律當成失敗重來:- 若動作已經呼叫了下游 API(例如 scale_out 的 K8s API 呼叫已送出)但還沒收到最終確認——不能直接判定失敗並重試,因為重試一個「其實已經生效」的操作可能造成副作用重複(scale_out 重試兩次可能多開出不必要的 replica)。正確做法是先做一次冪等查詢確認下游系統目前實際狀態(呼應上方 Reliability 段落),再決定要標記完成還是要真的重試。- 若動作還停留在 awaiting_approval(還沒有執行任何真正有副作用的操作,只是在等人核准)——可以安全地把這筆記錄留在原狀態,不需要特殊處理:新的 Worker instance 接手後可以直接繼續等待核准,因為還沒有執行任何不可逆動作,沒有半途而廢的問題。
  3. 逾時設定:對照 Day 72 TimeoutStopSec 的設計,給 Worker 一個合理的關閉逾時(例如 60 秒)讓它完成上述狀態確認;若逾時內無法確認真實狀態,記錄一筆 unknown_needs_human,而不是假裝執行成功或直接判定失敗——這正是 Day 88 Fail-closed 精神在 Deployment 層的重現:狀態真的不確定時,寧可標記「需要人工檢查」,也不要自動假設一個結果繼續往下走,錯誤地假設「成功」可能讓真正失敗的修復被誤判已解決、錯誤地假設「失敗」可能觸發不必要的重試造成副作用重複。

16. AI Agent 安全邊界決策點——三種修復動作的 Tool Permission 規則 + Approval Gate 模型選擇

依 Day 86 Tool Permission 系統設計規則庫:

  • restart_serviceallow——重啟本身影響範圍有限(單一服務、有 Graceful Shutdown 保護在途請求)且可逆,是最常見的修復動作,若每次都要人工核准會拖慢事故應變速度,這跟「限制傷害半徑」的精神並不衝突。
  • scale_outallow——增加 replica 幾乎沒有下行風險,頂多多花一點運算成本。
  • rollback_deploymentrequire_approval——回滾會讓使用者體驗回到舊版本,且可能牽涉資料相容性問題(例如新版本已經寫入新 schema 格式的資料,回滾後舊版本可能無法正確讀取),這是只有熟悉當下部署脈絡的人類才能判斷的風險,不能讓 Agent 自己決定。

額外加一條 deny 規則,示範縱深防禦:rollback_deployment 若目標服務被標記 has_irreversible_schema_migration: true(最近做過不可逆的 schema migration),不論是否核准,一律 deny——依 Day 86 固定優先順序 denied > require_approval > allowed,這條規則會直接蓋掉 require_approval 的判定。理由:值班工程師在半夜被 Slack 通知叫醒時,未必會記得或意識到這個服務最近做過不可逆遷移這件事,這種「這件事本身太危險,不能只交給人臨場判斷」的情境,正是 Day 86 陌生題強調的、deny 規則比依賴人類臨場判斷更可靠的地方。

Approval Gate 時間模型選擇(Day 87):這裡選非同步草稿佇列,而非同步阻塞(oneshot channel + timeout + 背景清理)。理由:incident 修復不是使用者即時互動情境,值班工程師可能要幾分鐘才會看到 Slack 通知並回覆;若用同步阻塞模型,逾時後 channel 會被清理、核准請求視為過期,值班工程師稍晚才回覆等於白白核准了一個已失效的請求,還要重新觸發一次。非同步草稿佇列把「等待核准」直接寫進 remediation_actions 的狀態機,不受限於單一 process 記憶體內某個 channel 的生命週期,不論值班工程師隔多久回覆都查得到這筆記錄——這正是 Day 87「核准時機不確定時該用非同步」判斷依據的具體套用。

TOCTOU 檢查(Day 88):核准當下看到的目標服務狀態,跟真正執行 rollback_deployment 那一刻的狀態,中間值班工程師可能花了 10 分鐘才點下核准,這 10 分鐘內服務可能又有了新的部署。決策:執行前重新查詢一次目標服務當下的部署版本,跟 requested_snapshot(核准當下鎖定的那份)比對,不一致就拒絕直接執行、退回awaiting_approval 重新走一次核准流程——這正是 Day 88「核准後執行的資料要來自審核當下鎖定的那份」的具體套用,沒有這一步,可能核准後又發生一次新部署,回滾動作仍然執行在已經過期的假設上。

Day 98 過關標準(DoD)

```text 1. 依上方十六項自己填完一次自我檢查(不看參考解),至少完成 Architecture/Failure/OS-Deployment決策點/Agent安全邊界決策點四項的完整推導,再對照參考解檢查差異。2. OS/Deployment 決策點需明確回答:Worker 收到 SIGTERM 時,一個「已送出但沒收到結果」的修復動作該怎麼處理,答案不能是「重試就好」(必須先確認下游真實狀態,再決定要不要重試/標記完成)。3. Agent 安全邊界決策點需寫出至少一條 deny 規則(不能三種動作都只寫 allow/require_approval),並說明為什麼這件事不能只靠 require_approval 讓人臨場判斷;並明確選定 Approval Gate 時間模型(同步或非同步)並說出判斷依據,不能隨意選一種。4. 針對 rollback_deployment,寫出 TOCTOU 檢查的具體實作方式(核准時鎖定哪份資料、執行前怎麼重新核對)。

自評方式: 三項都能不看提示答對(SIGTERM 處理的兩種情況分流、deny 規則存在且理由具體、Approval Gate 模型選擇有明確依據),才算過關;若 Agent 安全邊界決策點寫成「這三個動作都設 require_approval,安全至上」,代表只套用了規則本身、沒有真正理解 Day 86 Trade-off 段落「不是所有操作都該用最嚴格層級」這個設計判斷,需要回頭重讀 Day 86 再修正。建議時間盒:閱讀情境 10 分鐘 + 自行填十六項 45 分鐘 + 對照參考解 25 分鐘,約 80 分鐘。```

  1. 依 00-master-curriculum.md 第 11 章「固定 Framework」
  2. 依 00-master-curriculum.md 第 23 節

Day 99 — Teach-back Exam:把一個 Core Fundamentals 主題教到能回答追問

學習目標

看完並完成今天內容後,能夠:

  1. 完整執行一次「教別人」的操作流程(寫教學文件或錄口說),從教學過程中「講到一半答不出來」的地方,反推自己對這個概念的理解其實停留在哪一級(Level 2–4 之間),而不是已經到 Level 51
  2. 針對教學內容裡至少一個「講到一半發現自己其實講不清楚」的段落,回頭精讀對應 Day 的原始教材補完,再重新教一次那一段。
  3. 用 Level 5——「可以教別人,而且能回答追問」2——作為明確的完成判準,不是「講完了」就算數。

涵蓋範圍

本日不引入新主題,選定範圍是 Phase 01–09 任一被列為 Core Fundamentals(目標 Level 4)的主題;示範主題選用 Phase 04(Day 36–40)MVCC 可見性判斷。

「教別人」的具體操作方式:兩選一,但都要求同一件事——留下一份可以回頭檢查的紀錄

「我在腦中講過一遍、感覺講得通」不能算數,這正是 Level 4(自己想得通 trade-off/failure/design)跟 Level 5(這份想得通的內容要能被別人、或未來的自己驗證真的講得通)的本質差異。兩種方式都必須留下紀錄:

方式一:寫一份教學文件——對象設定為一個完全不知道這個主題的初級工程師(不能假設對方已經懂任何背景知識,包括自己過去覺得「這很基本」的東西)。文件必須包含:(a) 為什麼需要這個機制;(b)具體運作方式;(c) 至少一個自己重新想的新例子(不能照抄原教材的例子);(d) 三個自己預先設想「對方聽完後最可能追問」的問題,並針對每題寫出完整回答。寫完後,檢查方式是拿給任何人(同事/朋友,或至少讓自己隔一天重讀)試著問一個自己沒預先設想到的問題——答不出來,代表 Level 5 尚未達成,記下這個問題、回頭補完。

方式二:錄一段口說(不看稿)——限時 5 分鐘內,不看教材、不看筆記(只能看自己心裡想好的大綱)口頭講完整個主題,錄完後自己重聽、逐字寫成文字稿(或用語音轉文字工具)。檢查方式是逐字讀一遍自己的講稿,標記出「這裡如果被問『為什麼』我答得出來嗎」的每一句,特別注意含糊詞——「基本上」「大概」「應該是」這類content-standard.md 明確禁止出現在正式教材裡的用語,如果自己講出這些詞,代表這一段其實沒有真正想清楚,只是模糊帶過。

示範:用 MVCC 可見性判斷走一次完整流程

(a) 為什麼需要:想像兩個人同時在看同一份共用的訂單記錄——一個人在讀「這張訂單目前的狀態」,另一個人正在把這張訂單的狀態從「已付款」改成「已出貨」。如果資料庫沒有任何機制,讀的人可能讀到一個「一半是舊狀態、一半是新狀態」的中間結果(例如金額欄位已經被改了,但狀態欄位還沒),或者反過來,寫的人的修改可能被另一個同時發生的修改覆蓋掉。MVCC 要解決的正是這個問題:讓讀取的人永遠看到一個完整、一致的版本,不會因為別人正在寫而看到寫到一半的中間狀態,同時讓讀取完全不需要等待寫入完成

(b) 具體運作方式:MVCC 不是「鎖住整筆資料擋住其他人」,而是「每次修改不覆寫原本的資料,而是新增一個帶版本標記的新版本」。每一筆資料的每個版本都帶兩個隱藏欄位:xmin(建立這個版本的交易 ID)、xmax(讓這個版本失效的交易 ID,還沒被取代時是空的)。每個交易開始時會拿到一份「快照」——記錄當下「哪些交易已經確定提交」;之後這個交易讀任何一筆資料時,都要做可見性判斷:這個版本的xmin 對應的交易,在我的快照裡是不是已經提交?如果還沒提交,這個版本對我不可見;如果 xmax 已經被設定且對應交易已提交,代表這個版本已經被取代,同樣不可見。只有「xmin 對應交易已提交、且xmax 為空或對應交易尚未提交」的版本,才是這個快照看得到的版本。

(c) 自己重新想的新例子(不重複 Day 38 原本的銀行帳戶餘額例子):一個部落格系統,文章 id=42 目前的 view_count = 100。交易 A(xmin=200)正在把 view_count 改成 101(模擬一次瀏覽);在 A 提交之前,交易 B(讀取請求,快照建立時已知交易 200 尚未提交)讀取這篇文章——B 看到的版本是舊版本(xmin=190, xmax=NULL,view_count=100),因為新版本的 xmin=200 對應的交易在 B 的快照裡還沒提交,新版本對 B 不可見。等交易 A 提交之後,任何之後才建立快照的新交易 C 再讀取,才會看到 view_count=101 的新版本——但如果 B 是在同一個交易內用 Repeatable Read 隔離等級再讀一次,依然會看到 100(快照在交易一開始就固定),這正是可見性判斷「不是看資料現在長什麼樣,而是看這個版本相對於『我的快照』算不算已提交」的具體體現。

(d) 三個預先設想的追問與回答

  1. 「如果 A 提交之後,B 的交易還沒結束,B 再讀一次,B 用的是哪個隔離等級才會看到新的 101?」——Read Committed(PostgreSQL 預設):這個等級下快照是每一句語句開始時才重新建立,而不是整個交易開始時就固定一次,所以 B 第二次讀取會拿到新的快照,這時 A 已提交,B 會看到 101。Repeatable Read 或更嚴格的Serializable:快照在交易一開始就固定,B 兩次都只會看到 100,同一交易內看到不同結果(Non-repeatable Read)不會發生,但代價是 B 這個交易看到的資料可能已經不是「當下最新」的了。
  2. 「舊版本(xmin=190 那個版本)什麼時候真的從硬碟上消失?」——不是 xmax 被設定的當下就立刻刪除,而是等到沒有任何交易的快照還可能需要看到這個舊版本之後,由 VACUUM 背景清理回收空間。這意味著如果有一個交易長時間開著不提交,它的快照可能還「鎖住」很久以前的舊版本不能被清理,造成表膨脹(bloat)。
  3. 「這個機制對寫程式的人有什麼實際影響?」——如果應用程式用連線池搭配 ORM 開了一個交易,卻在交易裡面做一次很慢的外部 API 呼叫(例如打第三方付款服務),這個交易開多久,MVCC 就要多保留對應的舊版本多久,直接造成上一題提到的表膨脹問題;正確做法是把跟資料庫無關的慢操作移到交易之外執行。

自己走完這個示範後會發現:第 2、3 題其實是 Day 38 原教材裡「Failure Modes」與「Backend Applications」段落已經教過的內容,能不能不看教材、只憑自己對機制的理解就重新推導出這兩個答案,正是檢驗自己是不是真的到 Level 4(能解釋 trade-off/failure),而不只是 Level 2(能複述)。

Day 99 過關標準(DoD)

```text 1. 選定 Phase 01–09 任一 Core Fundamentals 主題(可以沿用 MVCC 示範,也可以換成自己覺得最生疏的主題——生疏的主題更適合這個練習,因為 Teach-back 最有價值的地方正是暴露「以為懂了、其實沒有」的落差)。2. 依方式一或方式二完成一次教學紀錄(教學文件或口說逐字稿),須包含:Why、具體運作機制、至少一個自己重新想的新例子、三個自己預先設想的追問與完整回答。3. 執行檢查步驟:找一個人問一個你沒預先設想到的問題(或自己隔一天重讀逐字稿標記含糊詞),記下答不出來或講得含糊的地方。4. 針對步驟 3 找到的至少一個弱點,回頭精讀對應 Day 的原始教材,寫一段「補完後的正確版本」附在同一份紀錄裡。

自評方式: 有沒有留下真正可被檢查的紀錄(文字稿/錄音逐字稿),而不是只在腦中複習過;步驟 3 有沒有真的找到至少一個講不清楚的地方(如果完全找不到,很可能代表問的追問不夠尖銳,或選的主題本身已經很熟、失去了這個練習「暴露落差」的意義,建議換一個更生疏的主題重做一次);步驟 4 的補完版本能不能通過「用自己的話重新講一次、不直接照抄原教材句子」的檢查,三項都成立才算過關。建議時間盒:選定主題 + 準備 15 分鐘、教學紀錄 30 分鐘、檢查步驟 15 分鐘、補完弱點 15 分鐘,約 75 分鐘。```

  1. 依 00-master-curriculum.md 第 18 節 Level 5 — Teaching
  2. 依 00-master-curriculum.md 第 18 節 Level 5 — Teaching

Day 100 — Final Boss:Design a Production-grade AI SaaS Platform

學習目標

看完並完成今天內容後,能夠:

  1. 完整綜合 Phase 01/04/05/06/07/08/091 全部知識點,針對一個涵蓋 Authentication/API/Persistent Data/Cache/Async Jobs/Messaging/AI Agent/Tool Calling/Retrieval/Task Status/Observability/Scaling 十二個組件的完整系統,逐一給出具體設計,不能只給組件名稱不給機制。
  2. 逐條回答文件列出的全部 18 個問題2,每題都要指出對應到 Phase 01–09 哪一天教過的哪個具體機制,而不是憑直覺作答。
  3. 額外針對 OS/Deployment 的 process 管理/graceful shutdown、AI Agent 安全邊界的 tool permission/approval gate 兩個新面向3,各自給出至少一個具體設計決策。

涵蓋範圍

Phase 01–09 全部——這是整個 100 天課程的收尾驗收,不引入任何新主題,純粹是「把已經學過的全部機制,組合成一個單一系統」的整合練習4

題目

Design a Production-grade AI SaaS Platform.

系統需要包含以下十二個組件:Authentication、API、Persistent Data、Cache、Async Jobs、Messaging、AI Agent、Tool Calling、Retrieval、Task Status、Observability、Scaling。整體架構草圖5

``text Client↓CDN↓Load Balancer↓Backend├── Auth├── Cache└── DB│Queue│NATS│Workers│AI Runtime├── LLM├── Tools└── Retrieval``

十二組件檢查表——逐項給出具體設計(不能只寫名詞)

1. Authentication

依 Phase 08 Day 76–78:Access Token(短效,攜帶在每次 API 請求,Server 端不需要查資料庫即可驗證簽章)搭配 Refresh Token(長效,只在 Access Token 過期時用來換發新的一組,且依 Day 77 的 Reuse Detection 機制——一個 Refresh Token 一旦被使用過,若再次出現同一個 token 被使用的請求,判定為 token 被竊取,撤銷整條 token family)。第三方登入走 OAuth 2.0/OIDC(Day 78)。

2. API

RESTful,公開端點(/agents/{id}/run/agents/{id}/tasks/{task_id})走 Access Token 驗證;內部 Worker 與 AI Runtime 之間的呼叫不直接暴露公開 API,走內部網路。

3. Persistent Data

關聯式資料庫存放權威資料:使用者/組織/Agent 設定(Day 76 Auth 相關)、任務執行記錄(呼應 Phase 09 Day 90 的 State/Checkpoint)、Tool Permission 規則庫(Day 86)。依 Day 39 的 Isolation Level 與條件式更新,避免併發寫入造成 Lost Update。

4. Cache

依 Day 56 Cache Aside,快取讀取頻繁但變動不頻繁的資料(例如 Agent 設定、Tool Schema),依 Day 57 處理 stale data 與 Cache Invalidation;不快取任務執行狀態這類需要強一致的資料(見 Task Status 段落)。

5. Async Jobs

長 running 的 Agent 任務(依 Phase 09 Day 89,單一任務可能執行數十分鐘)不能佔用 API 請求的同步生命週期,API 收到請求後立刻回傳一個 task_id,實際執行丟進 Async Job 交給 Worker 處理(呼應 Day 68 Job Scheduler 的立即執行/排程執行分工)。

6. Messaging

Backend 與 Worker 之間透過 NATS/JetStream(Phase 08 Day 79–80)解耦:API 收到請求後把任務發布到對應 Subject,Worker 訂閱處理;依 Day 80 JetStream 的 Persistence 保證訊息不因 Worker 短暫離線而遺失,Consumer 用 Queue Group 讓多個 Worker 實例分攤負載而不重複消費同一則任務。

7. AI Agent

依 Phase 09 Day 89 的 Loop 機制(Observe/Reason/Act),每個 Agent 任務的 State 依 Day 90 定期 Checkpoint 到 Persistent Data;process 中斷後的 Recovery 流程(Day 90)掃描「執行中但未完成」的任務,先做 Verification 才決定要不要繼續或重做。

8. Tool Calling

依 Day 85 的 Tool Schema/Execution/Validation/Failure/Retry 機制,每個工具呼叫先過 Schema 驗證參數合法性,再過下方 Agent 安全邊界段落的 Tool Permission Gate 判定,才真正執行。

9. Retrieval

依 Phase 09 Day 83–84,Agent 需要外部知識時走 RAG:Chunking 策略把文件切成適當大小、混合檢索(BM25 + 向量索引,用 RRF 合併排序)取回相關片段,組進 Context 交給 LLM。

10. Task Status

每個 Agent 任務的狀態(queued/running/awaiting_approval/completed/failed)存在 Persistent Data 裡,作為單一權威來源(不快取,避免使用者查詢到過期的執行狀態);GET /agents/{id}/tasks/{task_id} 直接查這張表。

11. Observability

依 Day 90/91,記錄每個任務目前執行到第幾輪迭代、每一步的成功/失敗與延遲;依 Day 91 Evaluation Pipeline,定期跑迴歸測試偵測 Agent 品質是否隨底層 Model 版本更新而悄悄劣化。

12. Scaling

API 層水平擴展(無狀態);Worker 依 NATS Queue Group(Day 80)水平擴展處理 Async Jobs;DB 讀取依 Day 53 Read Replica 分散;規模成長到單一 DB 無法負荷時,依 Day 54 Sharding 切分(例如依組織 ID)。

十八題檢查表——逐條回答,每題附上要對應到哪個組件/哪一天教過的機制

依文件第 15 章列出的問題清單6,逐條列成本日要回答的檢查表(不能只回答「看情況」,每題都要有具體依據):

``text 1. 如何 scale?——對應「12. Scaling」段落:API 無狀態水平擴展、Worker 依 NATS Queue Group 擴展、DB Read Replica/Sharding。2. DB 怎麼設計?——對應「3. Persistent Data」:關聯式資料庫存放使用者/Agent設定/任務記錄/Tool Permission規則庫,依 Day 39 Isolation Level 設計併發控制。3. Cache 怎麼設計?——對應「4. Cache」:Cache Aside,只快取低變動頻率資料(Agent設定/Tool Schema),不快取 Task Status。4. Cache consistency 怎麼處理?——依 Day 57 Cache Invalidation:資料變更時主動失效對應 Cache entry,而非依賴 TTL 被動過期。5. Transaction 怎麼處理?——依 Day 39:Tool Permission 規則變更、任務狀態轉移都用條件式 UPDATE(WHERE 帶狀態檢查),避免 Lost Update。6. Concurrency 怎麼控制?——依 Day 41-50:Worker 內部處理單一任務用 Goroutine,任務之間的隔離依賴任務 ID 分區,不共用可變狀態。7. Job duplicate 怎麼辦?——依 Day 58:Async Job 用 idempotency key(任務 ID + 動作類型)去重,NATS Queue Group 保證同一則任務只被一個 Worker 消費。8. Message lost 怎麼辦?——依 Day 80 JetStream Persistence:訊息持久化到 Ack 之後才視為處理完成,Worker crash 未 Ack 的訊息會被重新投遞。9. Worker crash 怎麼辦?——依 Day 90 Recovery:process 重啟後掃描「執行中但未完成」的任務,先 Verification 確認下游真實狀態,再決定續跑或重做,而非整個任務從頭開始。10. Agent crash 怎麼恢復?——依 Day 90 Checkpoint:每輪迭代確認完成後才落地 State,crash 後從最後一次 Checkpoint 續跑,最多損失一輪進度。11. Token 怎麼管理?——對應「1. Authentication」:Access/Refresh Token 分工 + Reuse Detection(Day 77);另外 Phase 09 Day 92 的 LLM token 預算依任務設定上限,逼近上限觸發提前收斂。12. 哪些資料需要 strong consistency?——Tool Permission 規則庫的判定結果、Task Status 的狀態轉移(不能容忍看到過期狀態導致重複執行或誤判完成)。13. 哪些可以 eventual consistency?——Observability 的彙總指標、Cache 裡的 Agent 設定(容忍幾秒內的傳播延遲)。14. 哪些地方需要 idempotency?——見第 7 題;另外所有 Tool Calling 本身(Day 85)都需要先判斷是否 idempotent 才能安全重試。15. 哪裡可能發生 race condition?——多個 Worker 副本同時嘗試認領同一則任務(靠 Queue Group 與資料庫條件式 UPDATE 避免);Tool Permission 規則庫被同時讀取與更新(靠版本欄位/條件式更新避免讀到不一致的規則組合)。16. 哪裡可能成為 bottleneck?——LLM 呼叫本身的延遲與成本(依 Day 92 的 Cost/Latency 預算機制管控);單一 DB 若不做 Sharding,高流量組織可能拖慢其他組織的查詢。17. 如何觀測?——對應「11. Observability」:任務執行進度、Evaluation 分數趨勢、各步驟失敗率。18. 如何降級?——依 Day 59 Partial Failure:Retrieval 逾時時降級成不帶外部知識、只靠 Model 自身知識回答(並標記回答可能不包含最新資訊);AI Agent 若因 Tool Permission Gate 判斷不出來(Fail-closed)拒絕執行,降級成回傳「需要人工協助」而非直接報錯中斷整個任務。``

額外面向一:OS/Deployment(process 管理/graceful shutdown)

Worker 與 AI Runtime 都是需要被 Kubernetes 正常滾動更新的長 running process,套用 Day 71 Graceful Shutdown 機制:收到SIGTERM 後,先停止從 NATS 佇列拉取新任務(讓其他還活著的 Worker 接手新任務)、讓正在執行中的 Agent 任務走到下一個安全的 Checkpoint 點才真正結束(依 Day 90 每輪迭代 Checkpoint 一次的頻率,逾時上限內最多等一輪迭代完成);若逾時內無法走到安全點,依 Day 88 Fail-closed 精神記錄「未完成,需要 Recovery 流程接手」而非直接砍斷造成 State 遺失。Service Manager/Kubernetes 的 Restart 策略(Day 72):Worker 若非預期崩潰(非收到 SIGTERM的正常關閉),走 Restart=on-failure 自動重啟,重啟後即進入上方「Agent crash 怎麼恢復」的 Recovery 流程。

額外面向二:AI Agent 安全邊界(tool permission/approval gate)

依 Day 86,每個 Tool 呼叫先過 Tool Permission Gate 判定三態(allowed/denied/require_approval),優先順序固定denied > require_approval > allowed;具實質外部副作用的工具(發送郵件、修改訂單、扣款,呼應 Phase 09 Day 92 的分類方式)預設標記 require_approval,唯讀查詢類工具(搜尋、讀取資料)預設 allowed,任何會刪除生產資料或跨組織存取的操作規則庫直接設 deny。Approval Gate 時間模型依 Day 87 判斷依據選擇:使用者在互動介面上等待即時回覆的情境(例如使用者親自確認一次性操作)用同步阻塞(oneshot channel + timeout);核准時機不可預期的情境(例如需要另一位主管事後審核的高風險操作)用非同步草稿佇列。依 Day 88 TOCTOU 原則,核准後執行工具呼叫時,重新核對執行當下的狀態是否與核准當下鎖定的那份一致,不一致就拒絕直接執行、退回重新核准。

Day 100 過關標準(DoD)——Final Boss 完整驗收

```text 1. 依上方十二組件逐項寫出自己的設計版本(可以沿用參考解的決策,但每一項都要能自己重新講一次「為什麼這樣設計」,不能只是抄一遍),特別是 Tool Calling/AI Agent/Task Status 三項不能只套用 Phase 09 已教過的內容,要明確講出它們在這個更大系統裡怎麼跟 Auth/DB/Messaging 串起來。2. 逐條回答十八題檢查表,每一題都要指出具體依據(對應哪個組件/哪一天教過的機制),不能只回答結論不給推導。3. OS/Deployment 面向:明確回答 Worker 收到 SIGTERM 時,「正在執行中、還沒走到 Checkpoint」的 Agent 任務該怎麼處理,答案要具體到「逾時內等一輪迭代完成,逾時仍未完成就交給 Recovery 流程」這個層次,不能只寫「優雅關閉」四個字。4. AI Agent 安全邊界面向:針對系統裡至少三個不同風險等級的工具(例如一個唯讀查詢、一個有外部副作用、一個具刪除性),各自判定 allow/require_approval/deny,並選定並說明 Approval Gate 時間模型的判斷依據。

自評方式: 十二組件裡有沒有任何一項只寫了名詞、沒有具體機制(比照 Day 60 明確點名的反面案例);十八題裡有沒有任何一題答不出具體依據、只能回答「看情況」;兩個額外面向的決策點有沒有具體到可以被拿去跟別人討論、而不是空泛的原則宣示——三項檢查都通過,才代表這 100 天的內容真正整合成一個能撐住「陌生系統設計」的 Personal System Design Framework7,而不是分散的九個 Phase 各自獨立的知識點。若有任何一項只停留在名詞層次,回頭找對應的 Day 精讀,而不是繼續往下走。建議時間盒:十二組件設計 60 分鐘 +十八題檢查表 40 分鐘 + 兩個額外面向 30 分鐘,約 130 分鐘(可分多節完成,這是 100 天最重的一天,不需要一次做完)。```

Phase 10(Day 93–100)至此全部完成,對應第 2.10 節列出的 Day 93–100 完整依賴8——Day 93–97 分別重組 Phase 03–07,Day 98 額外整合 Phase 08/09 的兩個具體決策點,Day 99 挑選任一 Core Fundamentals 主題完成 Level 5 驗收,Day 100 綜合 Phase 01–09 全部組件與問題清單。100 天課程至此全部涵蓋 Phase 01–10。

  1. 依 00-knowledge-dependency-graph.md 第 2.10 節
  2. 依 00-master-curriculum.md 第 15 章「Final Boss」
  3. 依 00-master-curriculum.md 第 14 章 Day 100 定義
  4. 依 00-master-curriculum.md 第 14 章
  5. 依 00-master-curriculum.md 第 15 章「Final Boss」
  6. 依 00-master-curriculum.md 第 15 章「Final Boss」
  7. 依 00-master-curriculum.md 第 23 節
  8. 依 00-knowledge-dependency-graph.md 第 2.10 節