這篇整理一張表的資料隨營運年數持續增加時,查詢與維運的成本從哪裡增加、設計當下能先決定哪些事。範例用 PostgreSQL 17,資料形狀取自一個行銷用短網址服務:一般連結很少,每次簡訊發送替每位收件人各建一條連結,所以連結表的列數由發送量決定;短碼由多個應用程式實例各自隨機產生,靠資料庫的唯一約束擋下重複。跨節點的分片(Database Sharding)只談它的觸發條件,實作不在這篇的範圍。

文中的頁數與耗時是在一台筆電的 Docker(postgres:17,預設設定)裡量的。頁數(EXPLAIN (ANALYZE, BUFFERS) 的 shared hit 加 read)不隨機器而變,可以直接比較;耗時只代表那台機器。

有保留期的表與不刪除的表

一張表最後會有多大,由每列的大小、每天新增的列數與保留多久三者相乘。三者裡保留期最常被漏掉,而它決定這張表的大小會不會停下來。

有保留期的表,總量停在「每天新增 × 保留天數」。短網址服務的點擊事件一年約 1,000 萬筆、逐筆保留 90 天,表裡固定約 250 萬筆,第十年和第一年一樣大。刪除過期資料本身的成本另外處理,見〈依時間分割與唯一約束〉。

不刪除的表,總量隨營運年數一直增加。連結不能刪:短碼一旦印進簡訊就還在流通,重新分配給別的連結會把舊簡訊的訪客導到新的目的地,所以短碼不重複使用,連結到期後仍然要能被查到並轉到首頁。每次發送替每位收件人建一條連結,一年 100 次發送、每次 50 萬位收件人,連結表一年增加 5,000 萬列,兩年就過一億;一般連結一年只有約 1.8 萬條,對總量沒有影響。

所以估算儲存時,逐表寫下它屬於哪一種;不刪除的那幾張,另外寫下三年、五年後的列數。後面每一節討論的成本,都集中在不刪除的表上。

查詢讀的量跟著回傳筆數還是表的大小

表變大會不會讓一條查詢變慢,取決於這條查詢讀的資料量由什麼決定。判斷標準是:這條查詢讀的頁數,是由它回傳幾筆決定,還是由表裡有幾列決定。 前一種在表成長十倍時讀的量不變;後一種跟著成長十倍。

讀的量由回傳筆數決定的常見寫法:

  • 依唯一鍵查一筆:B-tree 的高度隨列數的對數成長,每一頁能容納數百個鍵,所以一千萬列到一億列之間高度最多多一層,查一筆多讀一頁。
  • 接續標記分頁(Keyset Pagination):每一頁從上一頁最後一筆的鍵往後讀,讀的量是一頁的筆數。
  • 讀預先彙總好的表:統計查詢讀的是每小時一列的彙總,讀的量由查詢的時間範圍決定(彙總的做法見 1.18 高頻計數的寫入與彙總)。
  • 範圍受保留期限制的掃描:重算最近幾個小時的點擊,讀的量由那幾個小時的點擊數決定。

讀的量由表的大小決定的常見寫法:OFFSET 分頁(要先讀過前面所有的列才能跳過它們)、對整張表 count(*)、對全部歷史做彙總,以及篩選條件不在索引裡、又要排序取前幾筆的列表查詢。最後一種最不容易察覺,因為表小的時候它很快。

篩選欄位不在索引裡的列表

連結列表依建立者列出連結,依 id 由新到舊,每頁 50 筆,可以用活動名稱篩選。索引是 (created_by, id DESC),活動名稱不在裡面。下面的測試資料每 101 條連結有 1 條是一般連結,其餘是每次 10,000 條的發送連結;最早的三萬條裡的一般連結屬於活動 old:

 1-- PostgreSQL 17;:n 是總列數,用 psql -v n=2000000 帶入
 2CREATE TABLE links (
 3  id           bigint PRIMARY KEY,
 4  code         text   NOT NULL UNIQUE,
 5  original_url text   NOT NULL,
 6  campaign     text   NOT NULL,
 7  created_by   bigint NOT NULL,
 8  send_id      bigint,           -- 發送建立的連結填發送編號,一般連結是 NULL
 9  created_at   timestamptz NOT NULL DEFAULT now()
10);
11INSERT INTO links (id, code, original_url, campaign, created_by, send_id)
12SELECT i, 'c' || i,
13       'https://shop.example.com/products/autumn-sale-2026?utm_source=sms&utm_medium=sms&utm_campaign=autumn_2026&utm_content=' || (i / 10000),
14       CASE WHEN i % 101 = 0 THEN (CASE WHEN i <= 30000 THEN 'old' ELSE 'r' || (i % 7) END)
15            ELSE 'send' || (i / 10000) END,
16       1,
17       CASE WHEN i % 101 = 0 THEN NULL ELSE i / 10000 END
18FROM generate_series(1, :n) i;
19CREATE INDEX links_created_by_id_idx ON links (created_by, id DESC);
20VACUUM ANALYZE links;
21
22-- 查活動 old 的第一頁
23EXPLAIN (ANALYZE, BUFFERS)
24SELECT id FROM links WHERE created_by = 1 AND campaign = 'old' ORDER BY id DESC LIMIT 50;

這份資料只有一位建立者,規劃器改走主鍵由大到小讀,讀的順序和 (created_by, id DESC) 相同。同一句查詢在兩種表大小下:

表的列數讀過又丟掉的列(Rows Removed by Filter)讀的頁數
50 萬474,90314,185
200 萬1,974,90359,576

回傳都是 50 筆,讀的頁數跟著表變成四倍。活動 old 的連結全在最舊的那一段,查詢要從最新的一筆往回讀過所有比它新的連結,邊讀邊用 campaign 篩掉;表每長一年,這段距離就多一年。這條查詢在上線第一個月很快,量測要等到表變大才看得出來,所以要在設計時逐條問它讀的頁數由回傳筆數還是表的大小決定。

列表排除大宗資料:排除條件與部分索引的位置

上面的列表還有另一個問題:發送連結佔了九成九,建立者的列表第一頁全是同一次發送的收件人連結。發送已經有自己的列表與收件人匯出,連結列表預設只列一般連結,比較符合使用者要找的連結。排除的寫法有三種,讀的量差很多,以 200 萬列的表量第一頁:

寫法讀的頁數
send_id IS NULL 當篩選條件,走 (created_by, id DESC)158
排除條件在另一張表:NOT EXISTS (收件人對應表)15,174
部分索引 (created_by, id DESC) WHERE send_id IS NULL3

只當篩選條件時,讀過又丟掉的列數約等於「每頁筆數 × 發送連結與一般連結的比例」。測試資料的比例是 100 比 1,所以讀了約 5,000 列;它不跟著表的總列數成長,跟著比例成長。按前面的估算,實際的比例是一年 5,000 萬條對 1.8 萬條,約 2,800 比 1,第一頁平均要讀過約 14 萬列。測試資料把一般連結均勻穿插在發送連結之間;真實的發送一次連續建立上萬到幾十萬條,剛發送完的那段時間,第一頁至少要先讀過那一次發送的全部連結。

排除條件在另一張表的時候,連結表的索引裡沒有任何欄位能分出哪些是發送連結,每讀一列都要去收件人對應表查一次主鍵(實測讀了 5,049 列,每一列查一次,共 15,174 頁)。索引只能包含同一張表的欄位,所以「這一列屬於哪一類」要拿來篩選或排除時,那個欄位要放在被列出的那張表上。

部分索引(Partial Index)只收 send_id IS NULL 的列,查詢的條件帶著 send_id IS NULL 時規劃器才會用它。它直接跳過全部的發送連結,第一頁讀 3 頁;索引本身只有 624 kB,同一份資料的完整索引是 60 MB。

部分索引解決了第一頁,活動篩選還沒解決:

寫法(都帶 send_id IS NULL)查活動 old 讀的頁數
部分索引 (created_by, id DESC) WHERE send_id IS NULL19,630
部分索引 (created_by, campaign, id DESC) WHERE send_id IS NULL6

只有 id 的部分索引讓查詢讀過這位建立者所有比活動 old 新的一般連結(19,801 條裡的 19,504 條)才找到 50 筆,讀的量由一般連結的數量決定,發送連結再多也不影響。把 campaign 放進部分索引之後,查詢直接定位到這個活動。一般連結很少,這個索引也很小。

以「那一欄大多是 NULL」為理由不把分類欄位放進被列的表時,要先拿估算的列數驗證這句話:在這個服務裡九成九的列都有發送編號,而且沒有這一欄就建不出上面的部分索引。

表變大之後在查詢以外增加的成本

查詢都寫成讀的量由回傳筆數決定之後,不刪除的表變大仍然有成本,落在下面這些動作上,它們的耗時與資源跟著表的大小成長:

  • 記憶體:常被查的索引頁要留在記憶體(PostgreSQL 的 shared buffers 加作業系統的檔案快取,見 Buffer Pool)。短碼的唯一索引隨連結數成長,大到放不進記憶體時,快取沒命中的查詢會開始讀磁碟。
  • 清理:PostgreSQL 的更新與刪除留下舊版本的列,由 autovacuum 清理(見 Table Bloat)。表越大,一輪清理越久。
  • schema 變更:建索引要讀過整張表,改欄位型別要重寫整張表,耗時隨表的大小成長(PostgreSQL 11 起加一個帶預設值的欄位只改目錄、不重寫表,不在此列);CREATE INDEX CONCURRENTLY 不擋寫入,但耗時更長。
  • 備份、還原與重建唯讀副本:耗時與資料量成正比,決定出事時多久恢復得了。

所以擴充的觸發條件要寫成上面每一項成本本身的量測值。「列數到一億」這種條件說明不了哪一項成本到了上限:一億列的表,查詢照樣可能很快。量測的寫法:

1-- 表與它全部索引的大小;每月記一次,算出成長率
2SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total,
3       pg_size_pretty(pg_indexes_size(relid)) AS indexes
4FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;
5
6-- 索引頁的命中率:idx_blks_read 是從 shared buffers 以外讀進來的頁數
7SELECT indexrelname, idx_blks_hit, idx_blks_read,
8       round(idx_blks_hit::numeric / nullif(idx_blks_hit + idx_blks_read, 0), 4) AS hit_ratio
9FROM pg_statio_user_indexes ORDER BY idx_blks_read DESC LIMIT 10;

autovacuum 每一輪的耗時從 log 讀:log_autovacuum_min_duration 在 PostgreSQL 15 起預設是 10min,只記超過十分鐘的清理(更早的版本預設 -1,完全不記),要看較短的清理就調低,例如 1min。建索引與還原的耗時,在每次做的時候記下來,下一次就有對照。命中率從 idx_blks_read 讀:它算的是不在 shared buffers 裡的頁,這些頁有些由作業系統的檔案快取供應、沒有讀磁碟,所以它是讀磁碟次數的上限,要搭配查詢延遲一起看。

這幾項指標的其中一項逼近上限時,對應的處置不同:記憶體不夠先加記憶體或縮小索引(部分索引、拿掉沒有查詢在用的索引);清理與 schema 變更太慢,才考慮把表切小,也就是分割或分片。

依時間分割與唯一約束

把表切小,最常見的做法是依時間分割(Table Partitioning,其他中文資料也寫作分區):每月一個分割區,過了保留期的分割區整個刪掉,比逐筆刪除快得多,也不留下要清理的舊版本。它適合有保留期的事件表。套到連結表上會碰到一個限制:

1CREATE TABLE plinks (code text NOT NULL, created_at timestamptz NOT NULL, UNIQUE (code))
2  PARTITION BY RANGE (created_at);
3-- ERROR:  unique constraint on partitioned table must include all partitioning columns
4-- DETAIL:  UNIQUE constraint on table "plinks" lacks column "created_at" which is part of the partition key.
5
6CREATE TABLE hlinks (code text NOT NULL, created_at timestamptz NOT NULL, UNIQUE (code))
7  PARTITION BY HASH (code);
8-- 建立成功

PostgreSQL 的唯一約束由每個分割區各自的索引執行,所以約束要包含分割欄位,同一個值才一定落在同一個分割區、由同一個索引檢查。依時間分割之後,唯一約束只能寫成 (code, created_at),兩個不同時間建立的連結就可以拿到同一個短碼。短網址服務靠短碼的唯一約束防止重複:多個應用程式實例各自產生短碼時,資料庫的唯一索引是它們共用的仲裁者,這個仲裁者在依時間分割的表上不存在。

要依時間分割又保住唯一性,另建一張不分割的短碼登記表(code 當主鍵、記對應的連結),建立連結時先寫它,由它擔任仲裁者,連結表照樣依時間分割。代價是每次建立多寫一張表、轉址要先查登記表才知道去哪個分割區找,而登記表本身也不刪除、一樣一直變大,只是每列很窄。

依短碼的雜湊分割保得住唯一約束,它把索引與清理切成幾個比較小的單位;但它不區分新舊,過期的連結和新連結混在每一個分割區裡,沒辦法整個分割區刪掉,而依建立者列連結的查詢沒有帶短碼,每一個分割區都要查一次。把很久以前到期的連結搬到另一張「冷」表,也會碰到唯一性的問題:建立新短碼時要同時查兩張表才能確認沒有重複,除非同樣另建登記表。這幾種做法都要等〈表變大之後在查詢以外增加的成本〉那幾項指標逼近上限,再拿實際的瓶頸決定。

同一批建立的資料共用的欄位

一次發送建立一萬條連結,這一萬條的目的網址與活動名稱都相同,因為它們都來自同一個發送請求。把這些值在每一列各存一份,佔的空間與「一列只存短碼與發送編號,共用的值存在發送表」相比:

表200 萬列的表本身(不含索引)
每列各存網址與活動名稱386 MB
發送連結的網址與活動名稱只存在發送表141 MB

測試的網址是 120 個字元,pg_column_size 量到每個值佔 121 bytes,也就是沒有被壓縮。PostgreSQL 只在一列超過約 2 kB 時才壓縮或搬出長欄位(TOAST),一兩百字元的網址照原樣存。帶 UTM 參數的行銷網址常常就是這個長度。

各存一份的另一個代價是這些副本沒有一致性的來源:這個服務的設計要求發送連結的網址與活動和發送相同,資料庫裡沒有任何約束保證這件事;之後有一段程式只改了發送表、或只改了連結,兩邊就對不上,而查詢讀哪一邊就得到哪一個答案。只存在發送表時,這個問題不存在。

只存在發送表的代價在讀取路徑上。轉址依短碼查到連結之後,要再依發送編號查發送表才拿得到網址。實測只查連結表讀 4 頁;連到發送表的那一次讀 9 頁(測試資料只有 201 次發送,規劃器直接掃完整張發送表,多出 5 頁;發送表大了以後改走主鍵,多出的是兩三頁)。轉址前面通常有快取,快取存的是組好的轉址目標,只有快取沒命中的那一次多讀這幾頁。每一段讀網址的程式都要實作同一條規則:連結自己有值就用自己的,沒有就用發送的(COALESCE(links.original_url, sends.original_url))。

事後才改要付的代價,量一次把既有的列改成只存在發送表。同一份 200 萬列的資料複製成兩份,一份沒有任何索引,一份帶著主鍵、短碼的唯一索引、(created_by, id DESC)、列表的部分索引與發送編號的索引,各跑同一句 UPDATE(帶索引的那一份要先 ALTER COLUMN original_url DROP NOT NULL):

1UPDATE links SET original_url = NULL WHERE send_id IS NOT NULL;  -- 198 萬列
動作沒有索引帶五個索引
UPDATE 198 萬列77 秒,表 386 MB → 536 MB277 秒,表 386 MB → 536 MB,索引 253 MB → 314 MB
接著 VACUUM未量127 秒,表 536 MB、索引 314 MB,兩者都沒有變小
接著 VACUUM FULL未量56 秒,表 156 MB、索引 168 MB

兩份表的 n_tup_hot_upd 都只有 2(198 萬次更新裡),也就是幾乎每一個新版本都寫到別的頁。大量寫入之後頁面是滿的,新版本放不進原來那一頁,HOT 更新(Table Bloat 裡說明的、新版本留在同一頁而不必寫進索引的更新)沒有機會發生,於是每一個新版本都要在五個索引各寫一筆:耗時是沒有索引的 3.6 倍,索引也多了 61 MB。這份資料在同一台機器上的另一次量測,沒有索引的 UPDATE 只花 13.9 秒,耗時隨機器當下的負載差了五倍以上;能直接比的是同一次量測裡兩份表的比例與大小。依這次 277 秒線性換算,一億列約四小時,所以上億列的表上這一步要以小時估,而且通常要分批跑、讓批與批之間的 VACUUM 回收空間。

PostgreSQL 的 UPDATE 寫入每一列的新版本、舊版本留到清理,所以表先變大(新版本的網址是 NULL、比原列窄,所以增加的不到一倍);一般的 VACUUM 讓那些空間可以被之後的寫入重用,檔案通常不會變小;VACUUM FULL 重寫整張表並重建索引才拿回空間(索引因此比更新前的 253 MB 還小:逐筆寫入時長出來的索引頁沒有填滿,重建的索引是一次排序後緊密寫入的),執行期間整張表被鎖住、讀寫都要等,而且要有約一份表加索引大小的空閒磁碟。縮回檔案大小要排停機做 VACUUM FULL,或改用 pg_repack 這類線上重寫的工具。

比較便宜的事後做法是只改新寫入的列:從某一天起新的發送不再寫網址,舊的列不動;讀取的程式照樣要處理兩種形式,而已經寫進去的一億列永遠是寬的。

所以這個決定的時機落在表還沒有資料的時候:那時改只是 schema 與讀取程式的寫法,之後改要付一次重寫或永遠保留舊列的空間。決定時另外確認兩件事:

  • 哪些欄位每一列可能不同:到期時間與停用狀態可以逐條修改,留在連結表。
  • 共用的值以後會不會變成逐條不同:例如將來要讓每位收件人轉到個人化的頁面。連結表保留網址欄位、允許它是 NULL,約束只要求一般連結一定有網址(CHECK (send_id IS NOT NULL OR original_url IS NOT NULL));發送連結有值就用自己的,是讀取規則負責的事。到時候逐條填值,讀取規則不用改。

延伸閱讀:表寬、查詢反模式、計數的彙總與資料庫轉換