【從一台 Server 到分散式架構】第 16 篇:帳本不只一種——關聯式、文件型、搜尋引擎、時序資料庫

Sharding 解了容量與寫入的問題,小明他們終於把「資料庫快滿了」這個警報從儀表板上清掉了。

但有一天,產品經理小雯拿著一份用戶意見回饋找上門:

「用戶說我們的搜尋功能很爛。他打『Python 進階』,找不到叫『進階 Python 程式設計』的課;打『機器學習入門』,搜出來的課跟他想要的差很遠。能不能改善?」

小明拉出程式碼看了一眼,搜尋功能目前是這樣做的:

SELECT * FROM courses WHERE title LIKE '%Python 進階%';

問題很明顯:LIKE '%關鍵字%' 不支援同義詞、不懂語意、不會排序相關度,更不能做模糊匹配。這是關聯式資料庫的邊界——它不是為全文搜尋設計的。

「換個資料庫。」資深工程師小傑說。

小明第一個反應是:「換掉?整個搬走?」

「不是整個換掉,」小傑解釋,「是加一個專門做搜尋的。不同的資料需求,交給最適合的工具處理。」


生活化的比喻:辦公室裡的不同帳本

一間公司裡,不同部門會維護不同形式的紀錄:

  • 財務部:標準化的帳本,每一筆收入和支出都有欄位、有關聯、要能對帳(關聯式 DB)。
  • 行銷部:每個活動的執行文件,格式各不相同,有時是一頁 Word 有時是一份 Notion 頁面,欄位不固定(文件型 DB)。
  • 圖書館:藏書的全文索引,讓你搜一個字能找到書裡出現過這個字的所有段落(搜尋引擎)。
  • 設備監控室:每分鐘記錄每台機器的溫度和電量,要能快速查「過去一小時的趨勢」(時序 DB)。

這四種帳本,用途不同、形式不同,用同一本帳本硬做全部,都會出問題。系統設計裡的多種資料庫並存(Polyglot Persistence),就是這個道理。


四種儲存,各司其職

一張圖先看懂:四種資料庫在解什麼問題?

如果你只想用「最快的方式」建立直覺,可以先看這張對照表:它不是在比誰比較強,而是在比「資料的天然形狀」。

你遇到的需求(資料形狀)最像哪種資料?最適合的工具典型查詢長相為什麼它適合
欄位固定、要對帳、不能出錯(用戶 / 訂單 / 權限)表格 + 關聯關聯式 DBJOIN、Transaction交易保證 + 關聯查詢強
一筆資料就是一整包 JSON(課程頁:章節/課節嵌套)Document文件型 DB一次拿整份Schema 彈性 + 嵌套讀取順
輸入一段字,找最相關的結果(全文搜尋、同義詞、模糊)倒排索引搜尋引擎relevance ranking天生為「找字」與排序而生
每秒/每 10 秒來一筆、只追加不太改(心跳、指標、趨勢)時間序列時序 DBtime window 聚合為高頻寫入與時間聚合優化

比起「選型決策樹」,更有用的是把這四種儲存的機制邊界畫清楚:哪個負責「真實資料(source of truth)」,哪個負責「為了查得快而衍生出的索引」,以及它們之間怎麼同步。

這張圖想讓你一眼記住一句話:搜尋引擎多半是在存「索引」,不是存「真相」;而 RDB/文件型/時序 DB 才通常是你要回到的 source of truth。

一、關聯式資料庫(Relational DB)

代表:PostgreSQL、MySQL

擅長什麼: 結構化的資料、強一致性、複雜的表與表之間的關聯查詢(JOIN)、Transaction 保證。

它怎麼「存」?怎麼「查」?

理解關聯式資料庫,你只要先抓住一件事就好:資料拆成表(table),再用 key 把表接起來(JOIN)。先看最常見的兩張表:使用者和訂單。

1) 使用者表 users

id (PK)email
42alice@example.com
99bob@example.com

2) 訂單表 orders

orders.user_id 是外鍵,指向 users.id,代表「這筆訂單屬於誰」。

id (PK)user_id (FK → users.id)course_idamount
900142python-101999
900242ml-0011299
900399python-101499

有了這些表,你就能做關聯式最經典的一件事:JOIN(把兩張表用 key 接起來)

例如「查 alice 的訂單明細」:

如果你把這個 JOIN 的結果「攤平成一張表」,它看起來會像這樣(把 users.emailorders.* 合在同一列):

order_iduser_idemailcourse_idamount
900142alice@example.compython-101999
900242alice@example.comml-0011299

對應的 SQL 大概長這樣:

SELECT
  o.id AS order_id,
  u.id AS user_id,
  u.email,
  o.course_id,
  o.amount
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.id = 42;

除了 JOIN 之外,關聯式資料庫另一個很常用的能力是 GROUP BY(分組統計)

例如「每個使用者買了幾筆訂單、總共花了多少」:

SELECT
  u.id AS user_id,
  u.email,
  COUNT(*) AS order_count,
  SUM(o.amount) AS total_amount
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email
ORDER BY total_amount DESC;

如果用上面示例資料跑完,結果會長這樣(把「多筆訂單」濃縮成「一列統計」):

user_idemailorder_counttotal_amount
42alice@example.com22298
99bob@example.com1499

(如果某個 user 完全沒有訂單,因為這裡用的是 JOIN,他會不出現在結果裡;要「連沒下單的人也列出來」,會用 LEFT JOIN。)

而 Index(索引)你可以先用「目錄」理解就好:它的目的就是讓資料庫不用把整張表從頭翻到尾,能更快找到 user_id=42 對應的那些 row。

對應的課程平台需求

  • 用戶資料:姓名、信箱、密碼雜湊、訂閱方案——欄位固定,有唯一性需求,要和訂單關聯。
  • 訂單與金流:一筆訂單關聯一個用戶、一門課程、一筆付款紀錄。這些資料絕對不能出錯,需要 Transaction(下單、扣款、開通權限,要嘛一起成功,要嘛一起失敗)。

不擅長什麼: 全文搜尋(LIKE 很慢且功能有限)、高度彈性的 Schema(欄位常常變動時很麻煩)、極高頻率的時序寫入。


二、文件型資料庫(Document DB)

代表:MongoDB、DynamoDB(AWS)

擅長什麼: 把一筆複雜的資料存成一整份 JSON(Document),不需要固定 Schema,適合「每筆資料結構可能不太一樣」的場景,讀取時一次拿到整份資料,不需要多個 JOIN。

它怎麼「存」?怎麼「查」?

當你面對的是「內容型資料」——產品把課程內容越做越豐富之後,資料長得越來越像一份會一直擴充的內容文件——這時候就很適合把它交給文件型資料庫處理。

以課程平台來說,最典型的就是「課程詳細頁」:

  • 課程是一個整體
  • 課程底下有章節,章節底下有課節
  • 這一頁的使用情境幾乎都是「一次把整頁資料載好」(read-heavy)

如果用關聯式 DB 來做,做法通常是把它拆成多張表(例如 courseschapterslessons),然後用 JOIN 把它拼回來。

這會帶來兩個很實際的問題:

  1. 讀取一頁要拼很多次:你要的其實是一個「樹狀結構」,但資料是分散在多張表裡。資料越多、關聯越深,JOIN 的成本就越明顯。
  2. 欄位常變時,表結構會很痛:課程型態一多(直播課、實體課、錄播課),不同課程可能多出不同欄位,你會開始在 schema 上做很多折衷(加欄位、加表、加 nullable 欄位),維護成本上升。

比較好的做法不是「把關聯式丟掉」,而是把這種「天生就像一包 JSON」的資料,交給文件型資料庫來存:一筆資料就是一份文件(Document),常見操作就是把整包讀出來

對應的課程平台需求

課程的「詳細介紹頁」是個很好的例子:

{
  "course_id": "python-101",
  "title": "Python 入門到進階",
  "description": "...",
  "chapters": [
    {
      "title": "第一章:環境設定",
      "lessons": [
        { "title": "安裝 Python", "duration": 600, "video_url": "..." },
        { "title": "Hello World", "duration": 300, "video_url": "..." }
      ]
    },
    {
      "title": "第二章:基本語法",
      "lessons": [...]
    }
  ],
  "tags": ["Python", "程式設計", "入門"],
  "prerequisites": ["無需先備知識"]
}

這份資料天生就是嵌套的 JSON:課程有章節、章節有課節,欄位可能隨著課程類型不同而不同(實體課有地點、線上課有影片連結)。

如果用關聯式 DB,需要拆成 courseschapterslessons 三張表,每次取完整課程資訊就要 JOIN 三次。用文件型 DB,一次 findOne 就能拿到整份資料,讀取效率高得多。

不擅長什麼: 強一致性 Transaction(跨多份 Document 的原子操作比較麻煩)、複雜的關聯查詢(資料散在不同 Document 時要手動組合)。


三、搜尋引擎(Search Engine)

代表:Elasticsearch、OpenSearch(AWS 版)

擅長什麼: 全文搜尋、模糊匹配、同義詞、相關度排序(最相關的結果排最前面)、多維度篩選(Faceted Search,例如「Python 相關 + 入門難度 + 有字幕」同時過濾)。

它怎麼「存」?怎麼「查」?

回到這篇一開始的痛點:如果用關聯式 DB 的 LIKE '%關鍵字%' 來做搜尋,你其實是在做「字串包含」——它不懂斷詞、不懂同義詞、不會算相關度,也很難做出「最像你要的」排序。

搜尋引擎之所以適合,是因為它的核心儲存結構不是 table,而是 倒排索引(Inverted Index):把「文章/課程」(文件)先拆成詞,再做成「詞 → 出現在哪些文件」的索引。

先看被索引的 3 個課程標題(文件):

doc_idtitle
1進階 Python 程式設計
2Python 入門到進階
3機器學習入門:Python

索引建立後,倒排索引會長得像一張「反過來的目錄」:

token(詞)出現在哪些文件(doc_id 列表)
Python[1, 2, 3]
進階[1, 2]
入門[2, 3]
機器學習[3]

接著看一次查詢「Python 進階」時,搜尋引擎大概怎麼走:

  1. 斷詞:把查詢拆成 tokens,例如 Python進階
  2. 找候選:到倒排索引把兩個 token 的文件清單取出來
    • Python → [1,2,3]
    • 進階 → [1,2]
  3. 排序:在候選集合裡算相關度(scoring),把比較像的排前面(例如標題同時包含兩個 token、出現位置更靠前、或有其他權重)

用這個例子,你會得到候選結果 [1,2],再依相關度排序後回傳給使用者。

對應的課程平台需求

用戶在搜尋框打「Python 進階機器學習」,背後發生的事:

  1. 搜尋引擎把「Python」「進階」「機器學習」三個詞分開,各自去全文索引裡查。
  2. 找出包含這些詞的課程,依照相關度(詞頻、位置、用戶評分等因素)排序。
  3. 用戶設定的篩選條件(難度、語言、是否有字幕)進一步縮小結果。

這些功能,很難只靠 LIKE 查詢把它們做好。

原因很簡單:LIKE '%keyword%' 只是「字串包含」,沒有倒排索引來支援斷詞、同義詞與相關度排序。

Elasticsearch 的運作方式

資料不是直接儲存給 Elasticsearch 的,而是從關聯式 DB 或文件 DB 同步過去:課程上架時,後端同時把課程資料寫入 PostgreSQL(主要儲存)和 Elasticsearch(搜尋索引)。搜尋請求打 Elasticsearch,其他查詢打 PostgreSQL。

不擅長什麼: 不適合當主要的資料儲存(資料模型不適合事務處理)、資料一致性的保證弱(最終一致)、儲存成本比關聯式 DB 高。


四、時序資料庫(Time-Series DB)

代表:InfluxDB、TimescaleDB(PostgreSQL 擴充)、ClickHouse

擅長什麼: 以時間為軸的高頻寫入,以及「查過去一段時間的趨勢、聚合」這類查詢。

它怎麼「存」?怎麼「查」?

時序資料庫要解的問題,可以用課程平台的兩個場景來理解:

  1. 線上人數:每 30 秒寫一筆「現在有多少人在線」
  2. 觀看心跳:每 10 秒寫一筆「誰在看哪門課、看到第幾秒」

這兩種資料有共同特徵:一直新增(append)、很少更新舊資料、而且查詢幾乎都在問「某段時間的趨勢」

如果把它硬塞進一般表格裡,過一陣子你會遇到兩個現實:

  • 資料量大到很快:因為你每 10 秒就寫一筆,一天就是上百萬筆、上千萬筆
  • 你想看的不是每一筆細節,而是聚合後的曲線:例如「過去 1 小時每分鐘平均在線人數」

時序 DB 的核心做法是:把同一類資料按時間切成一箱一箱(chunk / partition)。每一箱對應一段時間(按月/按日/按小時),查詢時只打開落在時間範圍內的那些箱子。

怎麼存:按時間分箱

怎麼查:只掃需要的時間箱子,再做聚合

例如你要的結果不是「每 10 秒一筆」,而是「每分鐘一點」的趨勢圖:

minuteavg_online_users
11:011023
11:021048
11:03990

查「過去 1 小時」時,時序 DB 會盡量做到兩件事:

  • 時間裁切(time range pruning):只掃 11:00~11:59 這一箱(或幾箱)
  • 時間窗聚合(time-window aggregation):直接在資料庫內把大量原始點聚合成你要的粒度(每分鐘/每 5 分鐘)

這裡有一個很容易搞混的點:

  • **箱子(chunk/partition)**是在決定資料怎麼「切」:目的是讓查詢可以先把不在時間範圍內的資料整批跳過,不用掃全世界。
  • **趨勢圖的顆粒度(每分鐘一點 / 每 5 分鐘一點)**是在決定查詢結果怎麼「聚合」:通常是查詢時把箱子裡的原始點彙整成你要的粒度。

所以就算你是「每小時一箱」在存,依然可以查出「每分鐘一點」的趨勢圖;不需要為了顆粒度,把箱子切到每分鐘。

只有在資料量大到「每次查詢都現算聚合太慢」,而且儀表板常常重複查同樣的時間窗時,才會另外做一份預先彙總(rollup/materialized view),例如把原始資料再整理成「每分鐘聚合」或「每小時聚合」,讓查詢更快。

對應的課程平台需求

課程平台有很多「時序資料」的需求:

  • 每分鐘的線上人數:每 30 秒記一筆「現在有多少人在線」。
  • 課程觀看心跳:每 10 秒記一筆「用戶 X 正在看課程 Y 的第 Z 秒」,用來記錄學習進度、計費。
  • 系統監控指標:CPU、記憶體、請求數,每秒或每 10 秒一筆(第 19 篇監控會用到)。

這些資料的特性是:寫入量極大、幾乎不會修改、查詢模式高度雷同(「過去 24 小時每分鐘的平均觀看人數是多少?」)。

如果用關聯式 DB 存每秒的心跳資料,一天下來可能幾千萬筆,普通的 SELECTGROUP BY 查趨勢會非常慢。時序 DB 的核心就是針對這個模式做了深度優化:高效壓縮時序資料、自動依時間分區、提供高效的時間聚合函數。

不擅長什麼: 不適合任意的關聯查詢、資料一旦寫入通常不會再修改(append-only 設計)、不適合存非時序的業務資料。


多種儲存並存的架構長什麼樣子?

🏗️ 本篇結束後的架構 — 不同的資料特性,交給最適合的儲存工具負責

如果把它換成「請求路由」的角度,你會更直覺理解:同一個產品,查不同東西,本來就會打不同系統。


多種儲存並存的代價

使用多種資料庫,不是沒有代價的:

1. 資料同步的複雜度

同一份課程資料,可能要同時存在 PostgreSQL(權限)、MongoDB(詳細內容)、Elasticsearch(搜尋索引)三個地方。如果課程名稱改了,三個地方都要更新,而且要保證最終一致。

常見做法:把「更新課程」的操作發一個事件到訊息佇列(第 8 篇的 Message Queue),由各個下游系統各自訂閱更新。但這代表有一段時間,三個系統的資料可能不一致。

2. 維運複雜度上升

每多一種資料庫,就多一套要備份、監控、調效能、升版的基礎建設。三個月前只要顧一個 PostgreSQL,現在要顧 PostgreSQL + MongoDB + Elasticsearch + TimescaleDB + Redis。小團隊要謹慎評估是否真的需要引入每一種。

3. 工程師的學習曲線

每種資料庫有自己的查詢語言、資料模型、效能調優方式。引入新的儲存,代表團隊要有人懂得怎麼用好它。


小明的選型原則

小明和小傑在這次討論之後,立下了一個選型原則:

只在現有工具真的無法勝任時,才引入新的儲存工具。

具體來說:

需求先考慮的解法真的不夠才引入
搜尋功能很爛加索引、改 QueryElasticsearch
課程內容結構複雜PostgreSQL JSONB 欄位MongoDB
監控指標存查太慢加時間索引、資料保留策略TimescaleDB

PostgreSQL 其實有個厲害的地方:它支援 JSONB 欄位(存半結構化 JSON)和 pg_trgm 擴充(基礎的模糊搜尋)。在需求不大的時候,一個 PostgreSQL 可以撐很長一段時間,不需要立刻引入 MongoDB 或 Elasticsearch。工具越少,系統越好維護——這個道理在系統設計裡永遠成立。


小結與預告

這篇我們認識了四種主要的儲存工具:

類型代表適合的場景
關聯式 DBPostgreSQL、MySQL結構化資料、Transaction、多表 JOIN
文件型 DBMongoDB、DynamoDB嵌套 JSON、彈性 Schema、整份讀取
搜尋引擎Elasticsearch全文搜尋、模糊匹配、相關度排序
時序 DBInfluxDB、TimescaleDB高頻時序寫入、時間聚合查詢

核心觀念只有一句話:不同的資料有不同的「天然形狀」,讓它住到最適合它形狀的容器裡。

現在,小明的課程平台已經有了完整的資料儲存策略。但還有一個功能一直缺席:即時通知。用戶上傳作業後,老師要即時收到通知;老師回覆後,學生要馬上看到。這種「推播」的需求,靠輪詢 REST API 是做不到的。

下一篇,我們來談讓伺服器主動推訊息給客戶端的技術——WebSocket 與 Pub/Sub

相關推薦

2026-04-07
【從一台 Server 到分散式架構】第 31 篇:從面試題反推架構——用本系列思維拆解系統設計考題
「設計一個 URL 短網址服務」「設計一個訊息系統」——系統設計面試問的不是背答案,而是思考方式。這篇整理了一套可以重複使用的面試框架,並用兩道例題示範如何從需求出發,一步步推導出架構、說清楚取捨。
2026-04-06
【從一台 Server 到分散式架構】第 30 篇:限流、排隊與降級實戰——開賣與直播場景
平常流量是 1000 QPS,但「開賣」的那一刻,瞬間湧入 50 萬個請求——系統要怎麼活下來?這篇用課程平台的限量課程開賣和直播開播場景,把第 13 篇學到的限流、降級、熔斷,落地到一個真實的高流量設計,走過每個防護層是怎麼工作的。
2026-04-05
【從一台 Server 到分散式架構】第 29 篇:用同樣的思維看 ChatGPT——AI 聊天系統架構
ChatGPT 看起來像一個聊天視窗,背後卻有幾個特殊的設計挑戰:回應是串流的、推理非常耗資源、每輪對話要記住上下文、系統要支援幾千萬用戶同時使用。這篇用熟悉的架構思維,拆解 AI 聊天系統的關鍵設計。
2026-04-04
【從一台 Server 到分散式架構】第 28 篇:用同樣的思維看 Twitter——社群動態與時序設計
Twitter 的核心功能看起來很簡單:發推文、看動態、按讚留言。但「動態牆」背後藏著一個棘手的設計問題:你追蹤 200 個人,每個人都可能隨時發文——你打開 App 時,那條時序動態要怎麼快速組出來?這篇來看社群動態系統的兩種策略:推(Fan-out on Write)與拉(Fan-out on Read)。