【從一台 Server 到分散式架構】第 16 篇:帳本不只一種——關聯式、文件型、搜尋引擎、時序資料庫
Sharding 解了容量與寫入的問題,小明他們終於把「資料庫快滿了」這個警報從儀表板上清掉了。
但有一天,產品經理小雯拿著一份用戶意見回饋找上門:
「用戶說我們的搜尋功能很爛。他打『Python 進階』,找不到叫『進階 Python 程式設計』的課;打『機器學習入門』,搜出來的課跟他想要的差很遠。能不能改善?」
小明拉出程式碼看了一眼,搜尋功能目前是這樣做的:
SELECT * FROM courses WHERE title LIKE '%Python 進階%';問題很明顯:LIKE '%關鍵字%' 不支援同義詞、不懂語意、不會排序相關度,更不能做模糊匹配。這是關聯式資料庫的邊界——它不是為全文搜尋設計的。
「換個資料庫。」資深工程師小傑說。
小明第一個反應是:「換掉?整個搬走?」
「不是整個換掉,」小傑解釋,「是加一個專門做搜尋的。不同的資料需求,交給最適合的工具處理。」
生活化的比喻:辦公室裡的不同帳本
一間公司裡,不同部門會維護不同形式的紀錄:
- 財務部:標準化的帳本,每一筆收入和支出都有欄位、有關聯、要能對帳(關聯式 DB)。
- 行銷部:每個活動的執行文件,格式各不相同,有時是一頁 Word 有時是一份 Notion 頁面,欄位不固定(文件型 DB)。
- 圖書館:藏書的全文索引,讓你搜一個字能找到書裡出現過這個字的所有段落(搜尋引擎)。
- 設備監控室:每分鐘記錄每台機器的溫度和電量,要能快速查「過去一小時的趨勢」(時序 DB)。
這四種帳本,用途不同、形式不同,用同一本帳本硬做全部,都會出問題。系統設計裡的多種資料庫並存(Polyglot Persistence),就是這個道理。
四種儲存,各司其職
一張圖先看懂:四種資料庫在解什麼問題?
如果你只想用「最快的方式」建立直覺,可以先看這張對照表:它不是在比誰比較強,而是在比「資料的天然形狀」。
| 你遇到的需求(資料形狀) | 最像哪種資料? | 最適合的工具 | 典型查詢長相 | 為什麼它適合 |
|---|---|---|---|---|
| 欄位固定、要對帳、不能出錯(用戶 / 訂單 / 權限) | 表格 + 關聯 | 關聯式 DB | JOIN、Transaction | 交易保證 + 關聯查詢強 |
| 一筆資料就是一整包 JSON(課程頁:章節/課節嵌套) | Document | 文件型 DB | 一次拿整份 | Schema 彈性 + 嵌套讀取順 |
| 輸入一段字,找最相關的結果(全文搜尋、同義詞、模糊) | 倒排索引 | 搜尋引擎 | relevance ranking | 天生為「找字」與排序而生 |
| 每秒/每 10 秒來一筆、只追加不太改(心跳、指標、趨勢) | 時間序列 | 時序 DB | time window 聚合 | 為高頻寫入與時間聚合優化 |
比起「選型決策樹」,更有用的是把這四種儲存的機制邊界畫清楚:哪個負責「真實資料(source of truth)」,哪個負責「為了查得快而衍生出的索引」,以及它們之間怎麼同步。
這張圖想讓你一眼記住一句話:搜尋引擎多半是在存「索引」,不是存「真相」;而 RDB/文件型/時序 DB 才通常是你要回到的 source of truth。
一、關聯式資料庫(Relational DB)
代表:PostgreSQL、MySQL
擅長什麼: 結構化的資料、強一致性、複雜的表與表之間的關聯查詢(JOIN)、Transaction 保證。
它怎麼「存」?怎麼「查」?
理解關聯式資料庫,你只要先抓住一件事就好:資料拆成表(table),再用 key 把表接起來(JOIN)。先看最常見的兩張表:使用者和訂單。
1) 使用者表 users
| id (PK) | |
|---|---|
| 42 | alice@example.com |
| 99 | bob@example.com |
2) 訂單表 orders
orders.user_id 是外鍵,指向 users.id,代表「這筆訂單屬於誰」。
| id (PK) | user_id (FK → users.id) | course_id | amount |
|---|---|---|---|
| 9001 | 42 | python-101 | 999 |
| 9002 | 42 | ml-001 | 1299 |
| 9003 | 99 | python-101 | 499 |
有了這些表,你就能做關聯式最經典的一件事:JOIN(把兩張表用 key 接起來)。
例如「查 alice 的訂單明細」:
如果你把這個 JOIN 的結果「攤平成一張表」,它看起來會像這樣(把 users.email 跟 orders.* 合在同一列):
| order_id | user_id | course_id | amount | |
|---|---|---|---|---|
| 9001 | 42 | alice@example.com | python-101 | 999 |
| 9002 | 42 | alice@example.com | ml-001 | 1299 |
對應的 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_id | order_count | total_amount | |
|---|---|---|---|
| 42 | alice@example.com | 2 | 2298 |
| 99 | bob@example.com | 1 | 499 |
(如果某個 user 完全沒有訂單,因為這裡用的是 JOIN,他會不出現在結果裡;要「連沒下單的人也列出來」,會用 LEFT JOIN。)
而 Index(索引)你可以先用「目錄」理解就好:它的目的就是讓資料庫不用把整張表從頭翻到尾,能更快找到 user_id=42 對應的那些 row。
對應的課程平台需求:
- 用戶資料:姓名、信箱、密碼雜湊、訂閱方案——欄位固定,有唯一性需求,要和訂單關聯。
- 訂單與金流:一筆訂單關聯一個用戶、一門課程、一筆付款紀錄。這些資料絕對不能出錯,需要 Transaction(下單、扣款、開通權限,要嘛一起成功,要嘛一起失敗)。
不擅長什麼:
全文搜尋(LIKE 很慢且功能有限)、高度彈性的 Schema(欄位常常變動時很麻煩)、極高頻率的時序寫入。
二、文件型資料庫(Document DB)
代表:MongoDB、DynamoDB(AWS)
擅長什麼: 把一筆複雜的資料存成一整份 JSON(Document),不需要固定 Schema,適合「每筆資料結構可能不太一樣」的場景,讀取時一次拿到整份資料,不需要多個 JOIN。
它怎麼「存」?怎麼「查」?
當你面對的是「內容型資料」——產品把課程內容越做越豐富之後,資料長得越來越像一份會一直擴充的內容文件——這時候就很適合把它交給文件型資料庫處理。
以課程平台來說,最典型的就是「課程詳細頁」:
- 課程是一個整體
- 課程底下有章節,章節底下有課節
- 這一頁的使用情境幾乎都是「一次把整頁資料載好」(read-heavy)
如果用關聯式 DB 來做,做法通常是把它拆成多張表(例如 courses、chapters、lessons),然後用 JOIN 把它拼回來。
這會帶來兩個很實際的問題:
- 讀取一頁要拼很多次:你要的其實是一個「樹狀結構」,但資料是分散在多張表裡。資料越多、關聯越深,JOIN 的成本就越明顯。
- 欄位常變時,表結構會很痛:課程型態一多(直播課、實體課、錄播課),不同課程可能多出不同欄位,你會開始在 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,需要拆成 courses、chapters、lessons 三張表,每次取完整課程資訊就要 JOIN 三次。用文件型 DB,一次 findOne 就能拿到整份資料,讀取效率高得多。
不擅長什麼: 強一致性 Transaction(跨多份 Document 的原子操作比較麻煩)、複雜的關聯查詢(資料散在不同 Document 時要手動組合)。
三、搜尋引擎(Search Engine)
代表:Elasticsearch、OpenSearch(AWS 版)
擅長什麼: 全文搜尋、模糊匹配、同義詞、相關度排序(最相關的結果排最前面)、多維度篩選(Faceted Search,例如「Python 相關 + 入門難度 + 有字幕」同時過濾)。
它怎麼「存」?怎麼「查」?
回到這篇一開始的痛點:如果用關聯式 DB 的 LIKE '%關鍵字%' 來做搜尋,你其實是在做「字串包含」——它不懂斷詞、不懂同義詞、不會算相關度,也很難做出「最像你要的」排序。
搜尋引擎之所以適合,是因為它的核心儲存結構不是 table,而是 倒排索引(Inverted Index):把「文章/課程」(文件)先拆成詞,再做成「詞 → 出現在哪些文件」的索引。
先看被索引的 3 個課程標題(文件):
| doc_id | title |
|---|---|
| 1 | 進階 Python 程式設計 |
| 2 | Python 入門到進階 |
| 3 | 機器學習入門:Python |
索引建立後,倒排索引會長得像一張「反過來的目錄」:
| token(詞) | 出現在哪些文件(doc_id 列表) |
|---|---|
| Python | [1, 2, 3] |
| 進階 | [1, 2] |
| 入門 | [2, 3] |
| 機器學習 | [3] |
接著看一次查詢「Python 進階」時,搜尋引擎大概怎麼走:
- 斷詞:把查詢拆成 tokens,例如
Python、進階 - 找候選:到倒排索引把兩個 token 的文件清單取出來
Python → [1,2,3]進階 → [1,2]
- 排序:在候選集合裡算相關度(scoring),把比較像的排前面(例如標題同時包含兩個 token、出現位置更靠前、或有其他權重)
用這個例子,你會得到候選結果 [1,2],再依相關度排序後回傳給使用者。
對應的課程平台需求:
用戶在搜尋框打「Python 進階機器學習」,背後發生的事:
- 搜尋引擎把「Python」「進階」「機器學習」三個詞分開,各自去全文索引裡查。
- 找出包含這些詞的課程,依照相關度(詞頻、位置、用戶評分等因素)排序。
- 用戶設定的篩選條件(難度、語言、是否有字幕)進一步縮小結果。
這些功能,很難只靠 LIKE 查詢把它們做好。
原因很簡單:LIKE '%keyword%' 只是「字串包含」,沒有倒排索引來支援斷詞、同義詞與相關度排序。
Elasticsearch 的運作方式:
資料不是直接儲存給 Elasticsearch 的,而是從關聯式 DB 或文件 DB 同步過去:課程上架時,後端同時把課程資料寫入 PostgreSQL(主要儲存)和 Elasticsearch(搜尋索引)。搜尋請求打 Elasticsearch,其他查詢打 PostgreSQL。
不擅長什麼: 不適合當主要的資料儲存(資料模型不適合事務處理)、資料一致性的保證弱(最終一致)、儲存成本比關聯式 DB 高。
四、時序資料庫(Time-Series DB)
代表:InfluxDB、TimescaleDB(PostgreSQL 擴充)、ClickHouse
擅長什麼: 以時間為軸的高頻寫入,以及「查過去一段時間的趨勢、聚合」這類查詢。
它怎麼「存」?怎麼「查」?
時序資料庫要解的問題,可以用課程平台的兩個場景來理解:
- 線上人數:每 30 秒寫一筆「現在有多少人在線」
- 觀看心跳:每 10 秒寫一筆「誰在看哪門課、看到第幾秒」
這兩種資料有共同特徵:一直新增(append)、很少更新舊資料、而且查詢幾乎都在問「某段時間的趨勢」。
如果把它硬塞進一般表格裡,過一陣子你會遇到兩個現實:
- 資料量大到很快:因為你每 10 秒就寫一筆,一天就是上百萬筆、上千萬筆
- 你想看的不是每一筆細節,而是聚合後的曲線:例如「過去 1 小時每分鐘平均在線人數」
時序 DB 的核心做法是:把同一類資料按時間切成一箱一箱(chunk / partition)。每一箱對應一段時間(按月/按日/按小時),查詢時只打開落在時間範圍內的那些箱子。
怎麼存:按時間分箱
怎麼查:只掃需要的時間箱子,再做聚合
例如你要的結果不是「每 10 秒一筆」,而是「每分鐘一點」的趨勢圖:
| minute | avg_online_users |
|---|---|
| 11:01 | 1023 |
| 11:02 | 1048 |
| 11:03 | 990 |
查「過去 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 存每秒的心跳資料,一天下來可能幾千萬筆,普通的 SELECT 加 GROUP BY 查趨勢會非常慢。時序 DB 的核心就是針對這個模式做了深度優化:高效壓縮時序資料、自動依時間分區、提供高效的時間聚合函數。
不擅長什麼: 不適合任意的關聯查詢、資料一旦寫入通常不會再修改(append-only 設計)、不適合存非時序的業務資料。
多種儲存並存的架構長什麼樣子?
🏗️ 本篇結束後的架構 — 不同的資料特性,交給最適合的儲存工具負責
如果把它換成「請求路由」的角度,你會更直覺理解:同一個產品,查不同東西,本來就會打不同系統。
多種儲存並存的代價
使用多種資料庫,不是沒有代價的:
1. 資料同步的複雜度
同一份課程資料,可能要同時存在 PostgreSQL(權限)、MongoDB(詳細內容)、Elasticsearch(搜尋索引)三個地方。如果課程名稱改了,三個地方都要更新,而且要保證最終一致。
常見做法:把「更新課程」的操作發一個事件到訊息佇列(第 8 篇的 Message Queue),由各個下游系統各自訂閱更新。但這代表有一段時間,三個系統的資料可能不一致。
2. 維運複雜度上升
每多一種資料庫,就多一套要備份、監控、調效能、升版的基礎建設。三個月前只要顧一個 PostgreSQL,現在要顧 PostgreSQL + MongoDB + Elasticsearch + TimescaleDB + Redis。小團隊要謹慎評估是否真的需要引入每一種。
3. 工程師的學習曲線
每種資料庫有自己的查詢語言、資料模型、效能調優方式。引入新的儲存,代表團隊要有人懂得怎麼用好它。
小明的選型原則
小明和小傑在這次討論之後,立下了一個選型原則:
只在現有工具真的無法勝任時,才引入新的儲存工具。
具體來說:
| 需求 | 先考慮的解法 | 真的不夠才引入 |
|---|---|---|
| 搜尋功能很爛 | 加索引、改 Query | Elasticsearch |
| 課程內容結構複雜 | PostgreSQL JSONB 欄位 | MongoDB |
| 監控指標存查太慢 | 加時間索引、資料保留策略 | TimescaleDB |
PostgreSQL 其實有個厲害的地方:它支援 JSONB 欄位(存半結構化 JSON)和 pg_trgm 擴充(基礎的模糊搜尋)。在需求不大的時候,一個 PostgreSQL 可以撐很長一段時間,不需要立刻引入 MongoDB 或 Elasticsearch。工具越少,系統越好維護——這個道理在系統設計裡永遠成立。
小結與預告
這篇我們認識了四種主要的儲存工具:
| 類型 | 代表 | 適合的場景 |
|---|---|---|
| 關聯式 DB | PostgreSQL、MySQL | 結構化資料、Transaction、多表 JOIN |
| 文件型 DB | MongoDB、DynamoDB | 嵌套 JSON、彈性 Schema、整份讀取 |
| 搜尋引擎 | Elasticsearch | 全文搜尋、模糊匹配、相關度排序 |
| 時序 DB | InfluxDB、TimescaleDB | 高頻時序寫入、時間聚合查詢 |
核心觀念只有一句話:不同的資料有不同的「天然形狀」,讓它住到最適合它形狀的容器裡。
現在,小明的課程平台已經有了完整的資料儲存策略。但還有一個功能一直缺席:即時通知。用戶上傳作業後,老師要即時收到通知;老師回覆後,學生要馬上看到。這種「推播」的需求,靠輪詢 REST API 是做不到的。
下一篇,我們來談讓伺服器主動推訊息給客戶端的技術——WebSocket 與 Pub/Sub。