PostgreSQL 在重負載下的設計與 SQL 注意事項
寫給耀達 Lite 雲端進銷存(2026-11-02)。接續 db-scaling.html、cache.html、alb.html、aurora-serverless.html。
前提:AWS 東京、Aurora PostgreSQL Serverless、C# ASP.NET Core+Npgsql,所有店家共用資料表、用 RLS 隔開(第 5 題已定)。資料表名稱都是示範,資料模型還沒定。
這份文件的可信度
- 沒有標記的事實,大多來自官方文件或 PostgreSQL 18 原始碼,來源在第 19 節。
- 少數通用經驗(例如 UUID v4 的寫入分散、連線數與 CPU 的關係、
NOT IN遇到 NULL)來自 Claude 的知識,沒有逐條對過文件。 - 已實測 表示 2026-11-02 在本機 Windows 上用 PostgreSQL 18.6、Npgsql 10.0.3 跑過。測試資料是一家大店(100 萬張單、10 萬個商品)加 2000 家小店。數字只能看量級,不代表 Aurora 上的實際速度。
- 未實測 表示文件或原始碼支持這個說法,但還沒跑過,多半要到 AWS 上才測得到。
- Claude 的看法 都還沒拍板,裡面的數字只是起始值。
- 文中主要的 SQL 與 C# 寫法都跑過,部分範例的表名和欄位略有差異。
1. 先講結論
重負載下最常出事的通常不是「SQL 不夠漂亮」,而是這三種情況:一筆交易開太久、一張表被太多人改同一列、查詢掃了太多不需要的資料。
- 主鍵與索引都以
store_id開頭,外鍵和唯一約束也要包含它。外鍵和唯一性檢查會略過 RLS,只靠 RLS 擋不住跨店參照(第 3 節)。 - 每筆交易開頭都設定店家編號:
set_config('app.store_id', …, true)。讀不到店家編號時要直接報錯,不能回傳空清單(第 3 節)。 - 交易越短越好,等待外部服務時不能開著交易。長交易會佔住連線、拖住鎖,也會讓 VACUUM 收不掉舊資料(第 9、11 節)。
- 庫存用一句
UPDATE直接加減,不要先讀出來、在 C# 算完再寫回去。一張單動到多個商品時,一律照固定順序鎖(第 9 節)。 - 清單分頁用鍵集分頁,不要用大的
OFFSET;查明細不要一列查一次(第 7 節)。 - 金額用
numeric,不要用浮點數。要注意 C# 的Math.Round預設不是四捨五入(第 4 節)。 - 三個跟 RLS 的衝突要先知道:
COPY FROM不能用;LIKE、numeric比較、lower()這類條件用不上索引;用略過 RLS 的帳號跑EXPLAIN看到的計畫,跟程式實際跑的不一樣(第 6、7 節)。 - 預備陳述式的計畫可能被大店帶壞:小店可能沿用大店的全表掃描。查詢裡明寫
store_id = $1就能避開(第 10 節)。 - 主鍵用
(store_id, bigint)比(store_id, UUID)省很多:實測 UUID v7 的主鍵索引是bigint的 2.3 倍,比完全亂數的 v4 還大(第 4 節)。 - 改資料表時一定設
lock_timeout。一個排隊中的ALTER TABLE可能卡住後面所有查詢(第 12 節)。 - 從第一天就留下量測工具:
pg_stat_statements、慢查詢紀錄、Database Insights。效能問題要靠量測找,靠猜很容易改錯地方(第 15 節)。
2. 先懂 PostgreSQL 的四個脾氣
後面幾乎每條規則都能從這四點推出來。
脾氣一:改一列,其實是多寫一列
PostgreSQL 用 MVCC 讓「讀」和「寫」不互相擋。UPDATE 不會直接蓋掉舊資料,而是寫一個新版本,舊版本留著給還在看它的交易。沒有人需要舊版本之後,要等 VACUUM 回收。DELETE 也一樣,只是把列標成不要了。
由此推出:常被改的欄位不要建索引;太久沒結束的交易會讓舊版本回收不掉;資料刪了,空間也不會還給作業系統,只會留給這張表自己重複使用。
脾氣二:寫入者只擋同一列的寫入者,不擋讀
同時改同一列的第二個人要排隊,等第一個人的交易結束。文件原話是:只要沒有偵測到死結,等鎖的交易會無限期等下去,所以長時間開著交易(例如等使用者輸入時)是壞主意。
脾氣三:快不快,主要看資料是否已在記憶體
資料以 8 KB 為一頁存放。在記憶體裡的頁幾乎不用等;不在的話要去儲存層讀,慢很多,在 Aurora 上還可能算 I/O 費。Aurora Serverless 縮小時會把記憶體中的資料擠掉,再變大時要重新讀回來,所以「最小 ACU 放不放得下常用資料」會直接影響速度和帳單(aurora-serverless.html 第 5 節)。
脾氣四:查詢規劃器靠估計,估錯就選錯路
PostgreSQL 依統計資料估計每種做法的成本,再選最便宜的。統計資料由 ANALYZE 取樣而來,會過期,也會不準。所有店家共用一張表時,大店和小店的資料量差很多,這個問題會特別明顯(第 10 節)。
3. 店家隔離:從請求到資料列的整條鏈
RLS 不是開了就好。下面每一段都可能出錯,出錯的後果是把甲店的資料給乙店看到,所以這節排在最前面。
3.1 帳號要分開
| 帳號 | 用途 | RLS |
|---|---|---|
app_user | 店家用的程式 | 受限。不能是資料表擁有者,也不能有 BYPASSRLS |
migration_owner | 建表、改表 | 擁有者預設會略過 RLS;開 FORCE ROW LEVEL SECURITY 後也受限 |
backoffice | 後台客服查單店、全站統計(deployment.md 已談) | BYPASSRLS,只給後台,並留稽核紀錄 |
| Aurora 主帳號 | 只用來建立上面這些帳號,平常不給程式用 | 它會不會略過 RLS,還沒查證(第 17 節) |
3.2 規則怎麼寫
ALTER TABLE sales_order ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales_order FORCE ROW LEVEL SECURITY;
CREATE POLICY store_isolation ON sales_order
USING (store_id = current_setting('app.store_id')::bigint)
WITH CHECK (store_id = current_setting('app.store_id')::bigint);
USING 決定看得到哪些列,WITH CHECK 決定能寫入哪些列。current_setting 在 PostgreSQL 18 目錄裡標為 STABLE,AWS 的多租戶指引也是用同樣的寫法,只是型別為 uuid。
忘了設店家編號時,要「大聲報錯」
這裡有一個不直覺的行為:自訂變數在這條連線上設定過一次之後,交易結束不會變回「不存在」,而是變成空字串。這個行為手冊沒有寫,郵件論壇有記錄。已實測
| 情況 | current_setting('app.store_id')::bigint | current_setting('app.store_id', true) |
|---|---|---|
| 這條連線從沒設定過 | 報錯:unrecognized configuration parameter | 回傳 NULL,規則比對不到,查詢回傳空清單 |
設定過,但交易已結束或已 ROLLBACK | 報錯:invalid input syntax for type bigint: "" | 回傳空字串,直接轉型會報錯 |
連線被 DISCARD ALL 或 RESET ALL 重設過 | 同上,報錯 | 回傳空字串 |
Claude 的看法:用不帶 true 的寫法。程式忘了設定時,空清單看起來就像「這家店沒有資料」,很難發現,報錯反而能馬上抓到。
3.3 每筆交易都要設定
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
await using (var set = new NpgsqlCommand(
"SELECT set_config('app.store_id', $1, true)", conn, tx))
{
set.Parameters.AddWithValue(storeId.ToString());
await set.ExecuteNonQueryAsync(ct);
}
// 這裡才開始做事。只讀也一樣要包在交易裡。
await tx.CommitAsync(ct);
- 為什麼不用 session 層的
set_config(…, false):Npgsql 把連線還回池後,下次使用時會先重設狀態,正常情況下設定不會留給下一位。但實測打開No Reset On Close後,下一位沒設定店家編號也直接讀到上一家店的 20 張單。這種錯法完全不會報錯,所以不要依賴這一層。已實測 - 沒開交易、分兩個指令送:
set_config(…, true)在第一個指令結束時就失效,第二個指令會報錯。這正是我們要的結果:寫錯會馬上發現,不會讀到別家。已實測 - 為什麼不能放在
UsePhysicalConnectionInitializer:它只在實體連線建立時執行一次,而連線之後會輪流服務不同店家。 - 可以省一次往返:
NpgsqlBatch把多條陳述式打包送出,沒有明確開交易時也有隱含交易。set_config加查詢用一個批次送出,店家編號在整個批次都有效,只要一次往返。已實測
3.4 外鍵和唯一約束:RLS 管不到的地方
PostgreSQL 文件明寫:外鍵、主鍵、唯一約束的檢查一律略過 RLS,並警告這是一條暗中探測資料的管道。實測結果如下:已實測
| 寫法 | 會發生什麼事 |
|---|---|
customer_id 參照 customer(id) |
甲店的訂單填乙店的客戶編號,資料庫接受了,而甲店用 SELECT 明明看不到那個客戶。 |
UNIQUE (code) |
乙店用了 A001,甲店再用就出現 duplicate key 錯誤。錯誤不會顯示內容,但甲店已經能從這個錯誤猜出別家店有這個編號。 |
正確的寫法是全部帶上 store_id:
CREATE TABLE customer (
store_id bigint NOT NULL,
id uuid NOT NULL,
code text NOT NULL,
PRIMARY KEY (store_id, id),
UNIQUE (store_id, code)
);
CREATE TABLE sales_order (
store_id bigint NOT NULL,
id uuid NOT NULL,
customer_id uuid NOT NULL,
PRIMARY KEY (store_id, id),
FOREIGN KEY (store_id, customer_id) REFERENCES customer (store_id, id)
);
-- 外鍵不會自動建索引,參照端要自己建
CREATE INDEX ON sales_order (store_id, customer_id);
改成複合外鍵後,同樣的寫入被擋下(violates foreign key constraint);兩家店也都能用 A001。已實測這也直接支援全站規則「被用到就不能刪,只能停用」:還被參照的客戶,資料庫本身就不讓刪。
3.5 其他會繞過 RLS 的路
- 檢視表(view):預設用檢視表擁有者的權限讀底下的資料表。實測在沒開
FORCE的表上,甲店透過檢視表看到了兩家店的資料。加WITH (security_invoker = true)或對表開FORCE ROW LEVEL SECURITY都只看到自己的。已實測 SECURITY DEFINER函式:同樣用函式擁有者的權限執行,除非必要否則不用。- 新資料表忘了開 RLS:Claude 的看法:在自動測試裡查系統目錄,凡是有
store_id欄位但沒開 RLS 的表,一律讓測試失敗。AWS 指引也建議所有含租戶資料的表都開 RLS。 - 備份工具:
pg_dump預設把row_security關掉,app_user直接跑會報錯,不會默默只匯出部分資料。實測設PGOPTIONS='-c app.store_id=7'再加--enable-row-security,只匯出 7 號店的資料。已實測這可以當第 9 題「只還原一家店」的候選做法之一,但完整流程還沒談。
4. 設計資料表
4.1 主鍵
主鍵都寫成 (store_id, id),同一家店的資料就能靠索引集中查詢。id 用什麼,主要看下面這個實測結果。
| 主鍵(200 萬列,100 家店輪流寫入) | 索引大小 | 重建索引後 | 寫入時間 |
|---|---|---|---|
(store_id, bigint),資料庫序列 | 61 MB | 60 MB | 6.8 秒 |
(store_id, bigint),依時間遞增,模擬程式產生 | 61 MB | 60 MB | 7.6 秒 |
(store_id, UUID v4),完全亂數 | 100 MB | 77 MB | 10.5 秒 |
(store_id, UUID v7),依時間遞增 | 138 MB | 77 MB | 11.0 秒 |
只有 UUID v7,沒有 store_id(只為了比較) | 60 MB | — | 10.8 秒 |
已實測 店家大小懸殊、隨機寫入時是 78/101/132 MB;每家一次寫 20 列時是 61/138 MB(bigint/UUID v7),結論相同。
為什麼依時間排序的 UUID v7 反而最大
每家店都在自己那一段的尾端寫入。B-tree 的一頁滿了要分裂時,PostgreSQL 有一個最佳化:認出「複合索引裡局部遞增」的模式,就把舊頁留得很滿。但原始碼(nbtsplitloc.c)規定,只有索引內容不超過兩個 bigint 寬時才啟用。(bigint, bigint) 剛好符合;(bigint, uuid) 太寬,每次都對半分裂,舊頁就一直只有半滿。
這不是 UUID v7 本身的問題:單獨當主鍵時它只有 60 MB。問題出在跟 store_id 組合之後。
| 做法 | 好處 | 代價 |
|---|---|---|
資料庫序列bigint GENERATED … IDENTITY |
索引最小、最簡單。JavaScript 也能直接處理這個大小的數字。AWS 的 RLS 文章也偏好序列數字。 | nextval 列在 RDS Proxy 的釘住清單;所有店家共用同一條序列,以後把店家搬到第二套資料庫時,要給序列分開號碼範圍。編號連續,對外網址要防猜(RLS 仍會擋)。 |
程式產生的 bigint時間+機器+計數 |
索引一樣小,不靠序列,存檔前就知道編號。 | 要自己管理每個容器的機器編號和時鐘誤差,錯了就撞號;數字超過 JavaScript 的安全整數範圍,傳給前端要轉字串。 |
UUID v7Guid.CreateVersion7()(.NET 9 起)或 uuidv7()(PostgreSQL 18 起) |
不用協調就不會撞號,存檔前就知道編號。C# 產生的值存進資料庫後版本和時間都正確。已實測 | 索引是 bigint 的 2.3 倍,而且每個外鍵欄位、次要索引都要多帶 8 位元組;編號裡看得出建立時間。 |
| UUID v4 | 不用協調,也看不出時間。 | 索引是 bigint 的 1.6 倍,寫入散落在整個索引,比較不利於快取。 |
Claude 的看法:這裡改變了看法。Claude 原本傾向 UUID v7,實測後改成傾向資料庫序列的 bigint:一個人維運時最簡單,索引也最小。它的兩個缺點都有解:RDS Proxy 第一版大概不用(第 13 節),而且要分片時可以分開號碼範圍。這跟 deployment.md 寫的「主鍵不依賴全庫共用流水號」方向不同,要一起重新討論,正式決定留到資料模型那一題。不管選哪一種,對使用者顯示的單號都另外編。
4.2 單號:序列做不到,要用計數列
LiteErp 的單號格式已定:單據字母+年+月日+當天第幾張(例如 E2610090001)。這是「每家店、每種單據、每天」各自從 1 開始,序列做不到;而且序列本身也不保證連號,交易失敗時取走的號碼不會退回。所以要用一張計數表:
INSERT INTO doc_counter (store_id, doc_type, doc_date, last_no)
VALUES ($1, $2, $3, 1)
ON CONFLICT (store_id, doc_type, doc_date)
DO UPDATE SET last_no = doc_counter.last_no + 1
RETURNING last_no;
- 好處:跟單據在同一筆交易裡。交易失敗,號碼一起復原,不會因為失敗而跳號。實測中間
ROLLBACK一次,下一張仍然是 2 號;同樣情況的序列則跳了號。已實測 - 代價:這一列會被鎖到交易結束,同一家店同時開同一種單據時要排隊。店內同時開單的人不多,應該還好,但這筆交易更要短。
- 順序:Claude 的看法:單據交易裡最後才取號,縮短鎖住的時間,也讓所有交易的取鎖順序一致。
- 還沒定的:作廢或刪除單據後能不能留下空號。這可能跟電子發票那一題有關。
4.3 欄位型別
| 資料 | 用 | 不要用 | 原因 |
|---|---|---|---|
| 金額、單價 | numeric | float、double precision | 浮點數是近似值。PostgreSQL 文件特別建議用 numeric 存金額 |
| 金額(另一個陷阱) | numeric | money | money 的輸出受 lc_monetary 影響,還原到不同設定可能失敗 |
| 數量 | numeric | float | 小數位數跟著單位走(0~4 位),顯示規則放程式 |
| 單據日期 | date | timestamptz | 老闆選的是「哪一天」,不是一個時間點,用 date 才不會差時區 |
| 建立、修改時間 | timestamptz | timestamp | 內部以 UTC 儲存,顯示時才換成台灣時間 |
| 文字 | text | char(n) | 文件明寫 char、varchar、text 效能幾乎相同,而且 char(n) 通常最慢。需要長度限制時用 CHECK 或 varchar(n) |
| 狀態 | text/smallint 加 CHECK | 沒有約束的字串 | 資料庫要能擋掉不存在的狀態 |
| 會查、會排序的欄位 | 正常的欄位 | jsonb | jsonb 的比較不是 leakproof,RLS 下用不上索引(第 6 節) |
四捨五入的陷阱:LiteErp 已定金額四捨五入到整數。
- PostgreSQL:
round(2.5::numeric)得到 3、round(-2.5::numeric)得到 -3,符合四捨五入。但round(2.5::double precision)得到 2。已實測 - C#:
Math.Round(2.5m)得到 2。要四捨五入必須寫Math.Round(x, MidpointRounding.AwayFromZero),得到 3。已實測
Claude 的看法:金額只在一個地方計算,算好存進資料庫。不要一邊在 C# 算、一邊在 SQL 算,否則同一張單的合計可能差 1 元。
4.4 時區:「今天」是哪一天
AWS 主機的時區不一定是台灣時間。如果用 now()::date 算日期,timestamptz 轉 date 會依連線的時區設定,台灣早上 8 點前的單據可能被算到前一天。實測連線時區是 UTC 時,台灣時間 11 月 2 日早上 7 點會算成 11 月 1 日。已實測
Claude 的看法:單據日期由程式依台灣時間決定,當成 date 參數傳進去。要在 SQL 裡換算時,明確寫 created_at AT TIME ZONE 'Asia/Taipei',不要依賴主機設定。
4.5 約束盡量放資料庫
程式會有 bug,多台容器也可能同時執行,只靠 C# 先查再寫擋不住重複。
- 該有值的欄位加
NOT NULL。 - 會重複的東西一定靠
UNIQUE(帶store_id)擋,C# 先查只是為了給好看的錯誤訊息。 - 數量、金額的範圍用
CHECK。 - 每張表都要有主鍵。沒有主鍵的表,在 Blue/Green 升級時不能做
UPDATE、DELETE。
4.6 會被頻繁更新的表
庫存餘額就是典型。整體作法是讓更新盡量走 HOT:
CREATE TABLE stock_level (
store_id bigint NOT NULL,
warehouse_id uuid NOT NULL,
product_id uuid NOT NULL,
qty numeric NOT NULL,
updated_at timestamptz NOT NULL,
PRIMARY KEY (store_id, warehouse_id, product_id)
);
qty、updated_at不要建索引,否則每次更新都不符合 HOT。「庫存低於安全存量」這種查詢,掃這家店的列再過濾就好。fillfactor:資料表預設 100(塞滿)。調低會在每頁留空位,讓新版本可以放在同一頁,但下面的實測顯示影響遠不如索引,第一版不用調。- 把常改的欄位跟很少改的欄位分開放,例如商品基本資料不要跟庫存數字擺在同一張表。
| 2 萬列,隨機更新 10 萬次 | HOT 比例 | 資料表 | 索引 |
|---|---|---|---|
fillfactor = 100,常改的欄位沒索引 | 98.5% | 2,000 kB | 456 kB |
fillfactor = 80,常改的欄位沒索引 | 100% | 2,320 kB | 456 kB |
fillfactor = 100,常改的欄位有索引 | 0% | 3,808 kB | 4,168 kB |
已實測 多一個索引,HOT 全部失效,索引變成 9 倍大。
4.7 自動存與寫入量
資料頁、設定頁改完自動存,單據要按儲存(LiteErp 2026-10-29 定的)。自動存的頁面要注意:UPDATE 就算寫入相同的值,也會產生一個新版本(實測列的位置從 (0,1) 變成 (0,2))。已實測所以前端只在值真的變動時才送,後端只更新有變動的欄位。
5. 設計索引
索引的基本原理見 db-scaling.html 第 2 節。這裡只講設計時的判斷。
5.1 欄位順序:等號在前,範圍和排序在後
-- 清單頁:某家店,依日期新到舊
SELECT id, doc_no, doc_date, total
FROM sales_order
WHERE store_id = $1
ORDER BY doc_date DESC, id DESC
LIMIT 50;
-- 對應的索引:順序跟查詢一致,連排序都省了
CREATE INDEX ON sales_order (store_id, doc_date DESC, id DESC);
多欄 B-tree 在最左邊的欄位有條件時效率最好。PostgreSQL 18 新增 skip scan,可以跳過前面的欄位,但不要把它當成可以亂排順序的理由。
5.2 索引不是免費的
- 每個索引都會拖慢寫入、佔空間、吃記憶體,還可能破壞 HOT。
- AWS 的等待事件文件也建議刪掉用不到和重複的索引,來減輕頁面競爭。
- Claude 的看法:一開始只建主鍵、唯一約束、外鍵、主要清單頁需要的索引,其他等
pg_stat_statements看到慢查詢再加。上線後定期查沒被用過的索引(第 15 節)。
5.3 幾種特別的索引
| 種類 | 用途 | 範例或注意 |
|---|---|---|
| 部分索引 | 只索引一部分的列。適合「大部分查詢只看沒停用的」 | CREATE INDEX ON product (store_id, name) WHERE NOT is_disabled; |
| 覆蓋索引 | 把要讀的欄位也放進索引,不用回資料表 | … (store_id, doc_date) INCLUDE (total);。要靠 VACUUM 維護 visibility map 才有效 |
| 運算式索引 | 索引運算結果 | … (store_id, lower(code));。在 RLS 下可能用不上(第 6 節) |
| GIN+pg_trgm | 一般用來加速 LIKE '%字%' |
在 RLS 下可能完全用不上(第 6 節) |
6. RLS 會讓某些索引條件失效
一般 PostgreSQL 教學通常不會提這件事,但它會直接影響搜尋功能的設計。本節的行為除了 jsonb 都實測確認了。已實測
為什麼會這樣
函式可能在錯誤訊息裡洩漏它看過的值。假設有一個條件在 RLS 檢查之前就先跑,別家店的資料就可能經由這個函式外洩。所以 PostgreSQL 規定:只有標記為 LEAKPROOF(保證不洩漏)的函式,才能排在 RLS 條件前面。
文件明寫:如果運算子底層的函式不是 LEAKPROOF,在有 RLS 的資料表上不能選用索引掃描來處理這個條件。原始碼也是同一個規則(indxpath.c 的 restriction_is_securely_promotable)。
哪些條件受影響
下表直接查自 PostgreSQL 18 的函式目錄(pg_proc.dat):
| 條件 | LEAKPROOF | RLS 下能當索引條件嗎 |
|---|---|---|
bigint、int 的 = < > | 是 | 能 |
uuid 的 = < > | 是 | 能 |
date、timestamptz 的 = < > | 是 | 能 |
text 的 = < > | 是 | 能 |
boolean 的比較 | 是 | 能 |
numeric 的 = < > | 否 | 不能。金額、數量都是 numeric |
LIKE、ILIKE | 否 | 不能。品名搜尋受影響 |
lower()、upper() | 否 | 不能。運算式索引受影響 |
jsonb 的 =、@> | 否 | 不能 |
鍵集分頁常用的 (doc_date, id) < ($1, $2) 不受影響:原始碼對列比較是逐欄判斷,date、uuid 都是 LEAKPROOF,實測也走索引。但如果比較的欄位裡有 numeric,那一欄就會造成問題。
實測結果
| 查詢 | 大店,app_user | 大店,略過 RLS | 小店,app_user |
|---|---|---|---|
| 金額大於某數(有索引) | 330 ms,全表掃描 | 0.08 ms,走索引 | 0.05 ms |
| 單價等於某數(有索引) | 133 ms,全表掃描 | 0.06 ms,走索引 | — |
品名 LIKE '%字%'(有 GIN+pg_trgm) | 37 ms,全表掃描 | 0.6 ms,走索引 | 0.04 ms |
lower(code) = …(有運算式索引) | 136 ms,全表掃描 | 0.03 ms,走索引 | — |
商品編號 code = …(text 等於) | 0.03 ms,走索引 | — | — |
品名範圍 name >= … AND name < … | 0.1 ms,走索引 | — | — |
| 日期區間+鍵集分頁 | 0.1~0.3 ms,走索引 | — | — |
資料不會錯,只是變慢。結果分成三點:
- 小店幾乎無感:資料庫靠
store_id的索引找出這家店的幾十列,再逐列過濾,不到 0.1 毫秒。 - 大店差很多:大店用不上第二個索引條件,逐列過濾。這裡大店佔整張表 96%,所以規劃器直接掃全表,連別家店的列也要跳過。真實環境大店佔比低時,應該會走
store_id索引再過濾,那部分沒有測。 - 查詢明寫
store_id = 42也救不回來:實測numeric、LIKE、lower()仍然不走對應的索引。
時間受快取和當時其他測試影響,同一個查詢重跑可以差兩三倍,只看量級。jsonb 的查詢因為條件命中太多,有無 RLS 都是全表掃描,這次沒有測出差別,表上是依函式目錄判斷。
可以怎麼處理
| 做法 | 好處 | 代價 |
|---|---|---|
| 接受逐列過濾(Claude 的看法,第一版) | 什麼都不用做。小店占多數,影響很小。實測 10 萬個商品的 LIKE 也只要幾十毫秒 | 極大的店會慢一些。金額這類條件要先用日期縮小範圍 |
| 搜尋前先縮小範圍 | 要求或預設帶日期區間、類別 | 介面要配合,也不一定每次都適用 |
拿掉前面的 %,改查 text 範圍 | 「開頭是」可以改成 name >= $1 AND name < $2,實測 0.1 毫秒走索引 已實測 | 只能查開頭。這次用 C 定序,中文定序還沒測 |
| 另外建搜尋服務 | 全文搜尋能力最好 | 多一個零件,一個人維運負擔大,而且要自己顧店家隔離 |
把函式標成 LEAKPROOF | 索引可以用 | 只有超級使用者能設,Aurora 上大概做不到(未查證);標錯的話,RLS 的保護就有破口。不建議 |
用 BYPASSRLS 帳號做搜尋 | 索引可以用 | 等於這類查詢放棄資料庫這一層保護,違反第 5 題的決定。不建議 |
最容易被騙的地方:用略過 RLS 的帳號跑 EXPLAIN,會看到漂亮的索引掃描(上表第三欄),但程式用 app_user 跑時是全表掃描(第二欄),實測差到上千倍。
檢查效能時一定要用 app_user,並設好店家編號。測試資料也要模擬一家大店,不能只有幾筆。
7. 下 SQL 時要注意的事
| 不要這樣 | 改成這樣 | 為什麼 |
|---|---|---|
OFFSET 100000 LIMIT 50 |
鍵集分頁(下面的 7.1) | 文件明寫:被 OFFSET 略過的列仍要在伺服器內算過。實測第 50 萬列處 405 ms,鍵集分頁 0.12 ms 已實測 |
| 查 50 張單,每張再查一次明細 | 一句查完:WHERE order_id = ANY($1) |
N+1 查詢。50 張單要跑 51 次往返,東京來回的延遲都會累積 |
SELECT * |
列出需要的欄位 | 多傳不需要的資料,也用不上覆蓋索引;之後加欄位還可能影響程式 |
WHERE created_at::date = $1 |
WHERE created_at >= $1 AND created_at < $2 |
把函式包在欄位上,就用不上那個欄位的索引。實測 138 ms 對 0.6 ms。這個轉型還跟連線時區有關 已實測 |
WHERE qty_int = 5.0(整數欄位比小數) |
參數型別跟欄位一致 | 欄位被轉成 numeric 再比較,索引用不上。實測 20 萬列 25 ms 對 0.02 ms。C# 把 decimal 傳給整數欄位時要小心 已實測 |
NOT IN (SELECT …) |
NOT EXISTS (SELECT 1 …) |
子查詢裡只要有一個 NULL,NOT IN 就不會回傳任何列,這是邏輯錯誤。實測 0 列對 2 列 已實測 |
每次開清單都算 count(*) |
只顯示「下一頁」,或快取、只在需要時算 | PostgreSQL 要實際檢查索引或資料列才能算出總數。實測 100 萬張單 44 ms(剛做完 VACUUM,只讀索引的最佳情況);資料常被更新時會更慢 已實測 |
幾千個值的超長 IN (…) |
= ANY($1),傳一個陣列參數 |
語句短、可以重複使用。若用 RDS Proxy,超過 16 KB 的語句也會造成釘住 |
一列一個 INSERT、一次一個往返 |
多列 INSERT、unnest 或批次 |
往返次數決定總時間 |
先 SELECT 確認沒有,再 INSERT |
INSERT … ON CONFLICT |
多台容器同時做時,先查再寫一定有空窗 |
7.1 鍵集分頁
-- 第一頁
SELECT id, doc_no, doc_date, total
FROM sales_order
WHERE store_id = $1
ORDER BY doc_date DESC, id DESC
LIMIT 50;
-- 下一頁:帶入上一頁最後一列的 doc_date 與 id
SELECT id, doc_no, doc_date, total
FROM sales_order
WHERE store_id = $1
AND (doc_date, id) < ($2, $3)
ORDER BY doc_date DESC, id DESC
LIMIT 50;
不管翻到多後面,速度都差不多,因為索引直接跳到那個位置。代價是不能直接跳到第 37 頁,這會影響畫面設計。Claude 的看法:單據清單改用「下一頁」或無限捲動,加上日期篩選;真的要頁碼的小型資料(例如倉庫清單)用 OFFSET 也沒關係。
7.2 匯入大量資料:COPY 不能用
通常大量寫入首選 COPY,文件也說它幾乎都比 INSERT 快。但文件同時明寫:有 RLS 的資料表目前不支援 COPY FROM,要改用 INSERT。Npgsql 的 BeginBinaryImport 走的就是 COPY,實測用 app_user 匯入會得到錯誤 0A000 COPY FROM not supported with row-level security。略過 RLS 的帳號可以用。已實測
LiteErp 的商品、客戶匯入可以改用陣列參數,一句寫入多列,語句長度固定。這句實測可用,也能同時更新已存在的編號:已實測
INSERT INTO product (store_id, id, code, name)
SELECT current_setting('app.store_id')::bigint, id, code, name
FROM unnest($1::uuid[], $2::text[], $3::text[]) AS t(id, code, name)
ON CONFLICT (store_id, code)
DO UPDATE SET name = EXCLUDED.name;
一次匯入很多列時,分成每批幾百到幾千列,每批一筆交易,避免單一交易開太久。批次大小要實測。
7.3 不用怕但要知道的
- CTE(
WITH):PostgreSQL 12 起,沒有副作用、只用一次的 CTE 會併進主查詢,不會變慢,可以放心拿來整理 SQL。 - JOIN:有對應索引時不用怕。關聯式資料庫就是做這件事的,不必把它全部拆回 C# 再組。
ON CONFLICT會消耗序列值:就算最後什麼都沒寫入,nextval也已經被呼叫。實測ROLLBACK一次、衝突一次之後,下一筆的編號是 4。已實測
7.4 跟 ORM 有關的注意
如果使用 EF Core,SQL 是它產生的,上面這些規則還是適用。Claude 的看法:單據、庫存、報表這些關鍵路徑直接寫 SQL 或看過產生的 SQL,一般資料頁再用 ORM。EF Core 怎麼跟每筆交易的 set_config 接起來,還沒查證(第 17 節)。
8. 看懂執行計畫
BEGIN;
SELECT set_config('app.store_id', '42', true);
EXPLAIN (ANALYZE)
SELECT id, doc_no FROM sales_order
WHERE doc_date >= '2026-10-01'
ORDER BY doc_date DESC, id DESC LIMIT 50;
ROLLBACK;
EXPLAIN ANALYZE 真的會執行語句,所以 UPDATE、DELETE 一定要包在 BEGIN…ROLLBACK 裡。PostgreSQL 18 起,它會自動附上 BUFFERS 資訊;舊版要自己寫 EXPLAIN (ANALYZE, BUFFERS)。
| 看到什麼 | 代表 | 可能的動作 |
|---|---|---|
Seq Scan | 掃整張表。在共用表上特別要小心 | 確認條件有 store_id、有對應的索引。也可能是第 6 節的 RLS 情況或第 10 節的計畫快取。小表用 Seq Scan 是正常的 |
Index Scan/Index Only Scan | 走索引/只讀索引 | 通常是好事,但要看 Rows Removed by Filter |
Rows Removed by Filter 很大 | 讀了很多列又丟掉 | 索引欄位不對,或是第 6 節的 RLS 情況 |
估計 rows=10、實際 rows=100000 | 統計資料不準,計畫可能選錯 | ANALYZE;考慮擴充統計(第 10 節) |
Buffers: shared read 很大 | 大量資料不在記憶體,要去儲存層讀 | 第一次跑通常偏慢;重複出現就要減少讀取量,或提高最小 ACU |
Sort Method: external merge | 排序用到磁碟,超出 work_mem | 用索引省掉排序,或處理更少的列 |
Nested Loop 的 loops 很大 | 內層重複執行很多次 | 檢查內層是否有索引、估計是否準確 |
9. 交易、鎖與庫存
進銷存最核心的同時更新問題就在這裡:兩張銷貨單同時扣同一個商品。
9.1 不要先讀再寫
-- 錯:兩個請求都讀到 10,都寫回 8,實際賣了 4 個,帳上只少 2 個
SELECT qty FROM stock_level WHERE …;
-- C# 算 qty - 2
UPDATE stock_level SET qty = 8 WHERE …;
-- 對:讓資料庫原子地扣
UPDATE stock_level
SET qty = qty - $1, updated_at = now()
WHERE store_id = $2 AND warehouse_id = $3 AND product_id = $4
RETURNING qty;
預設的 READ COMMITTED 隔離層級下,第二個 UPDATE 會等第一個交易結束,然後根據更新後的資料重新判斷條件、計算新值,所以不會少扣。
| 8 條連線各扣 1,000 次(應該少 8,000) | 實際少了 |
|---|---|
| 先讀再寫 | 995(約 88% 的扣減不見了,而且沒有任何錯誤) |
一句 UPDATE | 8,000 |
已實測
負庫存要不要擋還沒決定。如果要擋,加上 AND qty >= $1,然後看更新了幾列,0 列就表示不夠。實測庫存 100、8 條連線共搶 800 次,最後剛好是 0,不會變負數。已實測
9.2 一次動多個商品:照固定順序鎖
這是 ERP 最常見的死結:甲單先扣 A 再扣 B,乙單先扣 B 再扣 A,兩邊互等。文件的建議就是所有交易都照一致的順序取鎖。
-- 先依商品編號排序鎖好,再逐項更新
SELECT product_id, qty
FROM stock_level
WHERE store_id = $1 AND warehouse_id = $2 AND product_id = ANY($3)
ORDER BY product_id
FOR UPDATE;
實測兩筆交易用相反順序更新兩列,約 1 秒後其中一筆被取消(40P01);都先照順序 FOR UPDATE 時,兩筆都成功。已實測
Claude 的看法:銷貨、進貨、調撥、組合、盤點共用同一個「異動庫存」元件,順序固定為:先庫存(依倉庫、商品排序),最後取單號。調撥、組合這類一張單動到多個倉庫或商品的尤其要小心。
9.3 死結和序列化失敗要重試
- PostgreSQL 預設等 1 秒(
deadlock_timeout)才開始檢查死結,然後取消其中一筆交易,錯誤碼40P01。 - 用 REPEATABLE READ 或 SERIALIZABLE 時,可能收到
40001。 - 文件明寫:要重試整筆交易,不是只重試失敗的那一句。包含
set_config都要重來。 - Npgsql 的
PostgresException.IsTransient可以用來判斷。實測40001、40P01、55P03(等鎖逾時)、53300(連線數滿)、57P01(管理員關閉)是true;57014(語句逾時)、23505(重複鍵)、42501(權限不足)是false。這只是抽查,不是完整清單。已實測 - Claude 的看法:統一的重試元件,最多 3 次,每次隨機等一下。第一版用預設的 READ COMMITTED,不要全站開 SERIALIZABLE。
9.4 交易裡不能做的事
開著交易時,不要呼叫任何外部服務:電子發票、金流、寄信、S3 都不行。外部服務慢 30 秒,鎖就被抓 30 秒,連線也被佔 30 秒,排隊的人越來越多,最後整家店(甚至全站)卡死。
正確做法:先把資料寫好、提交;外部呼叫放到交易外,或寫進工作佇列交給背景處理。
| 8 條連線同時跑 8 秒 | 每秒幾筆 | 平均延遲 |
|---|---|---|
同一列,交易只有一句 UPDATE | 8,192 | 1 ms |
| 分散到不同列,交易裡多等約 15 ms | 514 | 16 ms |
| 同一列,交易裡多等約 15 ms | 64 | 125 ms |
| 同一列,交易裡多等約 60 ms | 16 | 500 ms |
已實測 這是資料庫在本機的數字,沒有網路延遲。腳本寫的是等 5、50 毫秒,Windows 上實際約 15、60 毫秒,時間是從每秒筆數換算的。同一列一次只能一個人改,所以交易每多開一點時間,上限就直接掉下來。熱門商品的庫存列就是這種情況。
9.5 用資料庫當工作佇列
背景工作(成本計價、匯出)可以先用一張表當佇列,第一版不一定要多加 SQS。FOR UPDATE SKIP LOCKED 讓多個工作者各拿不同的工作,不用互等,文件也寫它適合佇列型的表:
SELECT id, payload
FROM job_queue
WHERE status = 'pending'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;
注意:背景工作者要能拿到所有店的工作,所以這張佇列表的權限要另外設計。頻繁更新也會產生大量死資料,要跟第 11 節一起看。
9.6 一定要設逾時
下面這些設定預設全部是 0(不限制)。沒設的話,一個忘記結束的交易可以卡到天荒地老。
-- 數字只是起始值
ALTER ROLE app_user SET statement_timeout = '15s';
ALTER ROLE app_user SET lock_timeout = '5s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE app_user SET transaction_timeout = '60s'; -- PostgreSQL 17 起
| 設定 | 限制什麼 |
|---|---|
statement_timeout | 單一語句最多跑多久 |
lock_timeout | 等一個鎖最多等多久 |
idle_in_transaction_session_timeout | 開著交易卻沒在做事,最多多久(程式忘記提交的救命繩) |
transaction_timeout | 整筆交易最多多久 |
Claude 的看法:用 ALTER ROLE 設定,程式不必另外下 SET,也就不會多一個釘住的原因。報表、背景工作用另一個帳號,給比較長的時間。statement_timeout 要比 Npgsql 的 Command Timeout(預設 30 秒)短,讓資料庫自己先停下來。
10. 預備陳述式與統計資料
10.1 大店小店共用一個計畫的風險
預備陳述式的前 5 次都依實際參數規劃(custom plan),之後 PostgreSQL 會算一個通用計畫(generic plan),如果估計成本沒有明顯比較貴,就會改用它。而連線又會輪流服務不同店家。
實測發現:規劃器規劃時會讀當下的店家編號來估計列數(42 號店估 100 萬列、7 號店估 44 列),但存起來的計畫不會因為換了店家而重新規劃。所以第一個建立計畫的店家,會影響後面的店家:
| 情況(同一個預備陳述式) | 計畫 | 時間 |
|---|---|---|
| 小店沿用大店建立的計畫,查詢只靠 RLS | 全表掃描,跳過 104 萬列找 20 列 | 90 ms |
| 小店,強制 custom plan | 走索引 | 0.17 ms |
小店,查詢明寫 store_id = $1,大店先跑過 | 走索引 | 0.04 ms |
| 大店沿用小店建立的計畫 | 走索引讀完 100 萬列 | 209 ms |
| 大店,強制 custom plan | 全表掃描(平行) | 159 ms |
已實測 風險是不對稱的:大店用小店的計畫只慢一點,小店用大店的計畫會慢幾百倍。
明寫 store_id = $1 為什麼有效:通用計畫看不到參數值,只能用「平均一家店有多少列」估計,所以會選走索引,不會被大店帶偏。查詢明寫的值如果跟連線設定的店家不同,實測會直接回傳 0 列,不會讀到別家。
其他選擇是 plan_cache_mode = 'force_custom_plan'(每次重新規劃,多花約 0.1 毫秒),或不用預備陳述式。
- Npgsql 的預設:
Max Auto Prepare預設 0,自動預備是關的。手動Prepare()的語句,實測在連線還回池之後仍然存在,所以計畫也會留著。已實測 - Claude 的看法:所有查詢都明寫
store_id = $1,RLS 當第二道防線。這比原本只把它當保險多了一個效能上的理由。第一版不開自動預備,有壓力測試資料後再比較。 - Aurora 的工具:有 Query Plan Management(
apg_plan_mgmt)可以固定計畫、防止計畫變差。要rds_superuser安裝、設rds.enable_plan_management=1並重開。第一版用不到。
10.2 統計資料
- 預設只取樣這麼多:
default_statistics_target預設 100。實測 2000 家店的表,統計資料只單獨記錄了 42 號大店,並把店家數估成 900 家;其他店都用平均值。已實測必要時可以對單欄調高:ALTER TABLE sales_order ALTER COLUMN store_id SET STATISTICS 1000;。 - 欄位有關聯時估計會錯:規劃器預設假設欄位之間獨立。
store_id和warehouse_id明顯有關(倉庫只屬於一家店),估計可能差很多。可以建擴充統計:CREATE STATISTICS sales_order_store_wh (dependencies, mcv) ON store_id, warehouse_id FROM sales_order; ANALYZE sales_order; - 統計何時更新:autovacuum 預設在 10% 的列變動後才重新
ANALYZE。大量匯入後可以手動跑一次。
11. VACUUM、膨脹與交易編號
11.1 大表的預設門檻太鬆
autovacuum 預設的開始條件是:死資料列超過 50 + 20% × 總列數。
| 表的大小 | 累積多少死資料列才開始清 |
|---|---|
| 1 萬列 | 2,050 |
| 100 萬列 | 200,050 |
| 1 億列 | 約 2,000 萬 |
共用資料表會把所有店家放在同一張表,表變大的速度比一家一表快很多,所以需要逐表調低:
-- 數字只是示範,要看監控調整
ALTER TABLE stock_level SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.02
);
Claude 的看法:庫存餘額、計數表、工作佇列這幾張常被更新的小表最先調。不要把 autovacuum 關掉。
11.2 長交易會讓 VACUUM 白做
只要還有一筆舊交易可能需要舊版本,VACUUM 就不能回收它。實測開著一個 REPEATABLE READ 交易時更新 1 萬列,VACUUM 回報 0 removed, 10000 are dead but not yet removable;交易結束再跑一次,1 萬列都回收了。已實測
一個開了好幾小時的交易可能讓全庫的表一起膨脹,這也是第 9.6 節要設逾時的原因之一。讀取器上的長交易會不會造成同樣的效果,Aurora 的行為還沒查證。
11.3 刪資料不會讓空間變小
- 一般
VACUUM:空間留在表內重複使用,不還給作業系統。 VACUUM FULL:會重寫整張表,還要ACCESS EXCLUSIVE鎖,上線期間基本上不能用。pg_repack:Aurora 支援的擴充功能(PostgreSQL 14~18),可以線上重整。Aurora 的儲存費也要靠它才會下降(aurora-serverless.html第 7 節)。- 索引膨脹可以用
REINDEX INDEX CONCURRENTLY。
11.4 刪除整家店:分批慢慢刪
第 5 題接受的代價之一。不要一句刪完,要分批,每批一筆交易,中間留空檔讓 autovacuum 跟上:
DELETE FROM sales_order_line
WHERE (store_id, id) IN (
SELECT store_id, id FROM sales_order_line
WHERE store_id = $1
LIMIT 5000
);
-- 回傳 0 列才算刪完;有外鍵時先刪子表
11.5 交易編號回捲
PostgreSQL 的交易編號是 32 位元的循環數字,每張表都必須定期做「凍結」清理。達到 autovacuum_freeze_max_age(預設 2 億)時會強制進行。真的放著不管,資料庫最後會拒絕新交易來保護資料。
Aurora 預設開啟 adaptive autovacuum(rds.adaptive_autovacuum),MaximumUsedTransactionIDs 太高時會自動加強清理;但 AWS 文件仍明寫回捲還是有可能發生,建議自己設告警。
第 8 題要收的告警:CloudWatch 指標 MaximumUsedTransactionIDs。門檻留到第 8 題談。
12. 不停機改資料表
12.1 最危險的不是鎖本身,是排隊
SET lock_timeout = '2s';
ALTER TABLE sales_order ADD COLUMN note text;
-- 失敗就等一下再試,遷移工具要能重試
| 實測:另一個查詢持有鎖約 10 秒 | ALTER TABLE | 排在後面的簡單 SELECT |
|---|---|---|
沒設 lock_timeout | 等了 9.1 秒才成功 | 被擋 8.1 秒 |
設 lock_timeout = '1s' | 1 秒後報錯放棄 | 1 ms |
已實測 從 pg_blocking_pids 看得到 SELECT 是被 ALTER TABLE 擋住,ALTER TABLE 才是被長查詢擋住。第二組的長查詢持有鎖約 6 秒。
12.2 常見改動怎麼做
| 要做的事 | 安全的做法 | 原因 |
|---|---|---|
| 加欄位 | ADD COLUMN,要有預設值也可以用常數 |
PostgreSQL 11 起,非 volatile 的預設值不必重寫整張表。實測 100 萬列:常數預設值 3 ms、now() 1 ms;uuidv7() 是 volatile,會重寫整張表,要 3.1 秒 已實測 |
| 建索引 | CREATE INDEX CONCURRENTLY |
一般 CREATE INDEX 會擋寫入。CONCURRENTLY 不能放在交易區塊裡,失敗會留下 INVALID 索引,要刪掉重來。這兩件事都實測確認了 已實測。很多遷移工具預設把遷移包在交易裡,要特別處理 |
| 加外鍵 | 先 ADD CONSTRAINT … NOT VALID,再 VALIDATE CONSTRAINT |
驗證時只取 SHARE UPDATE EXCLUSIVE,不會擋讀寫 |
加 NOT NULL |
先加 CHECK (x IS NOT NULL) NOT VALID,再驗證,然後 SET NOT NULL,最後刪 CHECK |
有驗證過的 CHECK 時,SET NOT NULL 可以略過掃描整張表。實測 100 萬列 0.7 ms 已實測 |
| 改型別、改名 | 加新欄位 → 兩邊都寫 → 分批補舊資料 → 改讀新欄位 → 刪舊欄位 | 直接改型別可能重寫整張表;直接改名則新舊版程式無法並存 |
12.3 新舊版程式會同時跑
Fargate 滾動部署時,舊容器要排空(alb.html 第 8 節)才會關掉,所以有一段時間新舊程式同時連資料庫。因此:
- 每次資料表改動,舊版程式都要還能跑。
- 加東西:先改資料表,再部署程式。
- 刪東西:先部署不再使用的程式,下一次才刪。
12.4 大版本升級
Aurora PostgreSQL 支援 Blue/Green 部署,AWS 說切換通常不到一分鐘,但底層是邏輯複寫,有不少限制:
- 升級期間做 DDL 會讓複寫降級,要重建部署。
- 沒有主鍵的表不能
UPDATE、DELETE(第 4.5 節)。 - 不支援新增分割區,
pg_partman要在藍環境停用;綠環境的pg_cron要保持停用。 - 寫入量很大時,單執行緒套用可能追不上,AWS 建議改用其他方式。
- 切換後,時間點還原的歷史會重新起算。
12.5 補舊資料要用對帳號
第 3.1 節讓 migration_owner 也受 RLS 限制,所以它改結構沒問題,但跑 UPDATE 補資料時沒有設定店家編號,第 3.2 節的規則會讓它直接報錯。已實測
Claude 的看法:補資料用 BYPASSRLS 的帳號分批跑,或一家店一家店設定編號再跑。這也是規則要「報錯」而不是「回傳空結果」的另一個理由:如果是後者,補資料的語句會顯示更新了 0 列,看起來像成功,其實什麼都沒做。
13. 連線與 Npgsql
13.1 連線不是越多越好
AWS 文件明寫:連線就算閒置也會消耗記憶體和 CPU。同時能真正做事的連線數,大致只跟 CPU 核心數有關,超過之後只是在互搶資源。而且 Serverless 的連線上限是依最大 ACU 算的(aurora-serverless.html 第 9 節)。
算法:容器數上限 × 每個容器的 Maximum Pool Size + 背景工作和後台 < 連線上限
舉例:Fargate 最多 10 個容器,每個都用預設的 100,就是 1,000 條。最大 16 ACU 時預設上限是 3,443 條,算起來沒超過,但閒置連線一樣會吃記憶體和 CPU。
Claude 的看法:先把每個容器的 Maximum Pool Size 設成 20,用壓力測試調整。如果交易都夠短,這已經能撐很多請求:每秒 1,000 個請求、每個佔連線 5 毫秒,平均只需要 5 條連線(示範假設)。
13.2 Npgsql 預設值
已實測 下表數值從 Npgsql 10.0.3 讀出,跟文件一致。
| 參數 | 預設 | 意義 |
|---|---|---|
Maximum Pool Size | 100 | 每個資料來源的上限。超過時要排隊 |
Minimum Pool Size | 0 | 沒人用可以全部關掉 |
Timeout | 15 秒 | 等連線最多多久 |
Command Timeout | 30 秒 | 單一指令最多多久 |
Connection Idle Lifetime | 300 秒 | 閒置多久開始回收 |
Connection Pruning Interval | 10 秒 | 多久檢查一次閒置連線 |
Connection Lifetime | 3,600 秒 | 連線最長使用多久(9.0 起) |
Max Auto Prepare | 0 | 自動預備陳述式關閉 |
No Reset On Close | false | 還回池會重設狀態。保持預設 |
Keepalive | 0 | 不送保持連線的訊息 |
不要打開 Multiplexing:現行文件頁面已經查不到它;GitHub 上有一個標題為「Remove multiplexing」的 issue,屬於 11.0 里程碑(只看到標題,沒讀內文)。多筆交易共用連線也跟「交易內設定店家編號」的做法不一定合得來,未實測。
13.3 RDS Proxy:這次查到更多證據
第 7 題要用的資料。文件原文列出的,跟我們有關的釘住條件:
- 「Using
SETcommands.」 - 「Setting a parameter… Specifically, using
SETandset_configcommands to assign default values to session variables.」 - 新的一條:「If you use connection pooling libraries with
DISCARD ALLquery configured as a reset query, RDS Proxy pins your client connection on release.」
而 Npgsql 預設還回連線時就是做這件事。實測從資料庫紀錄看到:連線上沒有預備陳述式時送 DISCARD ALL;有的話送 SET SESSION AUTHORIZATION DEFAULT;RESET ALL;CLOSE ALL;UNLISTEN *;SELECT pg_advisory_unlock_all();DISCARD SEQUENCES;DISCARD TEMP。已實測
所以就算撇開 set_config,Npgsql 預設值加上 RDS Proxy 也可能造成釘住。這一步還要接上真正的 RDS Proxy 才能確認。未實測
文件仍然沒講的:PostgreSQL 上的 SET LOCAL、set_config(…, true) 會不會釘住;協定層的預備陳述式會不會釘住。要實測時看 CloudWatch 的 DatabaseConnectionsCurrentlySessionPinned。這些都強化了「第一版不用 Proxy」的看法,但還沒拍板。
13.4 寫入器和讀取器分兩個資料來源
Claude 的看法:開兩個 NpgsqlDataSource,一個連寫入器 endpoint、一個連讀取器 endpoint。Npgsql 也有多主機與 Target Session Attributes,但它的文件沒有提到 Aurora。第一版只有一台時,兩個可以指向同一台,以後加讀取器不用改程式。讀取器 endpoint 是在開連線時分配到各台主機,不是每一句分配。
14. Aurora 跟一般 PostgreSQL 不一樣的地方
網路上大部分 PostgreSQL 調校文章是寫給自己管主機的人。Aurora 的儲存層不同,有些建議不適用。
| 主題 | Aurora 是怎樣 | 對設計的影響 |
|---|---|---|
| 寫入 | 只把 redo log 寫到儲存層,AWS 部落格寫它「從不發出 checkpoint」,所以也省掉 full-page writes | 網路上調 checkpoint_timeout、shared_buffers、硬碟的文章大多不適用,不用自己調 |
| I/O 計費 | AWS 部落格:PostgreSQL 讀一頁 8 KB 算一次;寫入儘量以 4 KB 為單位。定價頁範例卻是假設讀 16 KB,兩邊不一致,以部落格為準 | 標準方案下,大量掃描會直接變成帳單。I/O 費達總費用 25% 以上,就考慮換 I/O-Optimized,約 30 天可切換一次 |
| 讀取器上的長查詢 | max_standby_streaming_delay 最多 30 秒。查詢跟寫入器重播的資料衝突時,可能被取消,訊息是「canceling statement due to conflict with recovery」 |
報表放讀取器不是萬無一失。補充 db-scaling.html 第 8 節:長報表要能重試,或改背景工作、用 fast clone |
| 讀取器延遲 | 通常遠低於 100 毫秒,寫入量大時會增加 | 存檔後馬上顯示的畫面要讀寫入器 |
| Serverless 記憶體 | 記憶體跟著 ACU 變動,AWS 建議最小值要放得下常用資料 | 冷資料會讓查詢忽快忽慢。大店的第一次查詢可能比較慢 |
| 順位 | 順位 0、1 的讀取器跟著寫入器變大;2~15 獨立變動 | 報表用的讀取器放順位 2 以上,但最小值太低可能讓複寫延遲變嚴重 |
| 監控工具 | Performance Insights 已在 2026-07-31 終止,使用者移到 CloudWatch Database Insights。Standard 預設、保留 7 天;Advanced 才有鎖診斷、執行計畫、每個查詢的統計 | 舊文章寫的 Performance Insights 要換著看。Advanced 的費用要到 CloudWatch 定價頁查 |
| 自動暫停 | 任何使用者連線都會擋住暫停,連閒置的也算 | 測試區要暫停,健康檢查不能連資料庫(下面說明) |
| 擴充功能 | pg_repack、pg_partman、pg_cron 在 PostgreSQL 14~18 都有 |
可以用。但暫停期間 pg_cron 不會把資料庫叫醒 |
零件之間的互動:連線池 × 自動暫停
閒置連線會擋住暫停,而 Npgsql 的閒置連線預設約 300 秒後才回收。所以測試區沒人用時,要先等連線被回收,再等暫停逾時(最短 300 秒),大約 10 分鐘以上才會暫停。未實測
如果 ALB 的健康檢查路徑會連資料庫,連線會一直有人用,資料庫永遠不會暫停。這也是 alb.html 第 6 節「健康檢查路徑要輕」的另一個理由。
15. 監控與查問題
這節提供工具,告警門檻留給第 8 題。
15.1 要打開的工具
| 工具 | 告訴你什麼 | 注意 |
|---|---|---|
pg_stat_statements | 每種查詢的次數、總時間、平均時間。最先看這個 | Aurora 支援。預設是否載入還沒查證;修改 shared_preload_libraries 要重開 |
log_min_duration_statement | 把慢查詢寫進紀錄 | AWS 範例設 1,000 毫秒,並提醒可能影響效能。紀錄裡可能含客戶資料,要管好權限與保留期 |
auto_explain | 自動記下慢查詢的執行計畫 | Aurora 的開法還沒查證。計畫是大店小店差異的主要線索 |
| Database Insights | 資料庫負載、等待事件 | 第 14 節。AWS 建議開 Performance Insights 時最小 2 ACU,改名後是否相同還沒查證 |
15.2 最常用的幾句查詢
已實測 以下都跑過。本機要先把 pg_stat_statements 放進 shared_preload_libraries、重開,再 CREATE EXTENSION。
-- 花最多時間的查詢
SELECT calls,
round(total_exec_time) AS total_ms,
round(mean_exec_time::numeric, 1) AS mean_ms,
rows,
shared_blks_read,
left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- 開最久的交易
SELECT pid, now() - xact_start AS xact_age, state, left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 20;
-- 誰被誰擋住
SELECT pid, pg_blocking_pids(pid) AS blocked_by,
wait_event_type, wait_event, left(query, 80) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- 死資料最多的表,以及 HOT 更新的比例
SELECT relname, n_live_tup, n_dead_tup, n_tup_upd, n_tup_hot_upd, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
-- 從沒用過的索引(主鍵、唯一索引除外)
SELECT s.relname, s.indexrelname, s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0 AND NOT i.indisunique AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- 建失敗留下的索引
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
-- 交易編號離回捲還有多遠
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;
15.3 等待事件:資料庫在等什麼
| 等待事件 | 意思 | AWS 建議的方向 |
|---|---|---|
IO:DataFileRead | 要的資料不在記憶體,在等儲存層 | 檢查過濾條件、補索引或分割、讀取移到讀取器、必要時加大容量 |
Lock:transactionid | 在等別人改同一列 | 降低並行、避免 idle in transaction 和長交易 |
Client:ClientRead | 資料庫在等程式下一步 | 減少來回次數、盯著 idle in transaction |
LWLock:BufferContent | 很多人同時改同一頁 | 減少外鍵、刪掉用不到的索引、清除膨脹 |
AWS 建議「減少外鍵」是針對這個等待事件。Claude 的看法:多租戶下複合外鍵是安全防線,第一版不要為了效能拿掉,除非量到它真的是瓶頸。
15.4 找問題的順序
- 先看 Database Insights:負載是 CPU、I/O,還是鎖?
- 從
pg_stat_statements找花最多總時間的查詢(不是最慢的那一句)。 - 用
app_user帶店家編號跑EXPLAIN (ANALYZE),大店小店各跑一次。 - 修查詢或索引,再量一次。
16. 對耀達 Lite 的意義
16.1 從第一天就要做對(事後很難改)
- 主鍵
(store_id, id);外鍵、唯一約束、索引都帶store_id。 - 三種帳號分開,
app_user不是擁有者。 - 所有資料存取都走共用元件:開交易、設定店家編號、重試;查詢明寫
store_id。 - RLS 規則讀不到店家編號時報錯;自動測試檢查每張表都開了 RLS。
- 金額、數量用
numeric,單據日期用date,四捨五入只在一個地方算。 - 每張表都有主鍵;約束放在資料庫。
- 交易裡不呼叫外部服務;設好四種逾時。
- 庫存只用原子更新,固定取鎖順序。
- 資料庫改動一律向後相容,遷移工具支援
lock_timeout、重試與 CONCURRENTLY。
16.2 上線前要做
- 壓力測試資料:一家超大店(例如 100 萬張單、10 萬個商品)加上幾千家小店。只有平均資料是測不出第 6、10 節問題的。
- 測試用
app_user跑,並開著pg_stat_statements。 - 隔離測試:以甲店身分讀寫乙店資料,每張表都要失敗;連線重複使用後讀不到上一家店的編號。
- 同時操作測試:同時賣同一個商品,庫存要對得上;反向順序開兩張單不會死結。
- 調整常被更新的表的 autovacuum 參數。
16.3 可以等監控數字再做
- 更多索引、自動預備陳述式。
- 分割表、分片(
db-scaling.html)。 - 讀取器、Query Plan Management、搜尋服務。
16.4 這份文件帶出的待決事項
| 事項 | 歸到哪裡 |
|---|---|
| RDS Proxy 用不用:Npgsql 預設重設也可能釘住 | 第 7 題 |
告警:MaximumUsedTransactionIDs、長交易、死資料列、釘住數 | 第 8 題 |
只還原一家店:pg_dump --enable-row-security 實測能只匯出一家,可以當候選做法 | 第 9 題 |
主鍵型別:實測後 Claude 傾向 bigint 序列,要跟 deployment.md「不依賴全庫共用流水號」一起重談;PostgreSQL 版本(17 或 18) | 資料模型 |
所有查詢是否一律明寫 store_id = $1 | 資料存取層 |
| 大店搜尋的對策 | 做搜尋時,或資料模型 |
| 負庫存要不要擋 | 單據(LiteErp 的懸題) |
| 單號能否有空號 | 單據、電子發票 |
| 清單頁要頁碼還是「下一頁」 | UI,會影響查詢寫法 |
17. 還沒查證的事
實測的範圍與限制
2026-11-02 在本機跑了第 1~15 節標 已實測 的項目:Windows、6 核心、PostgreSQL 18.6(shared_buffers 1 GB、C 定序)、Npgsql 10.0.3。
- 不是 Aurora:儲存層、Serverless 變動容量、網路延遲都不一樣。絕對數字不能拿來估正式環境,但「用不用得上索引」「計畫怎麼選」「鎖怎麼排隊」這類行為,引擎相同的部分應該一致。
- 測試資料很極端:大店佔整張表 96%,全表掃描比真實情況更容易出現;資料都在記憶體裡,幾乎沒有 I/O 成本。
- 多數查詢只跑一次,而且部分測試同時在跑,沒有取平均。
還要到 AWS 上才能確認的
- 第 13 節:RDS Proxy 下
SET LOCAL、set_config(…, true)、Npgsql 重設指令的釘住情況。 - 第 14 節:讀取器取消查詢的情況、連線池與自動暫停的時間、Aurora 上的 I/O 計費。
- 大店佔比低、資料超過記憶體時,RLS 下逐列過濾的實際成本。
查不到或只有二手資料的
hot_standby_feedback在 Aurora 的行為:第三方說「一律開啟、可能造成膨脹」,官方頁面沒查到。這決定讀取器上的長查詢會不會影響寫入器清理。- Aurora 主帳號(
rds_superuser)會不會略過 RLS、能不能設LEAKPROOF。 - Materialized view 跟 RLS 的互動:寫這份文件時沒查,彙總表不要假設會受到保護。
pg_stat_statements、auto_explain在 Aurora 的預設狀態與開法。- Database Insights 的最小 ACU 建議;Advanced 的實際費用;Query Plan Management 是否另外收費。
- Aurora 的儲存法定數(4/6):這次讀的官方頁面沒看到這個數字,文中不採用。
- Npgsql
IsTransient的完整錯誤碼清單(這次只抽查 8 個)、EF Core 重試的預設次數。
這次沒有研究的
- 中文排序與定序(collation):品名怎麼排才符合台灣習慣,以及它跟索引的關係。
- EF Core 的整合:怎麼在每筆交易注入
set_config(interceptor 或自己包)、AddDbContextPool的影響、遷移裡怎麼做 CONCURRENTLY。 - 分割表跟 RLS 合用的細節。
- 單號的法規或實務需求:是否必須連號。
18. 名詞對照
| 名詞 | 白話 |
|---|---|
| MVCC | 改資料時寫新版本、留著舊版本,讓讀的人不用等寫的人 |
| 死資料列(dead tuple) | 已經沒有人需要、等著被回收的舊版本 |
| VACUUM、autovacuum | 回收死資料列的工作;後者是自動進行的版本 |
| 膨脹(bloat) | 表或索引裡空洞太多,佔的空間比實際資料大很多 |
| HOT 更新 | 新版本放在同一頁、而且不用動索引的更新,成本低很多 |
| fillfactor | 每一頁先填到幾成,留空位給之後的更新 |
| RLS | 列層級安全性:由資料庫依規則決定每個連線看得到哪些列 |
| LEAKPROOF | 函式保證不會透過錯誤訊息等方式洩漏參數內容的標記 |
| 查詢規劃器(planner) | 決定用哪種方式執行查詢的元件 |
| 執行計畫 | 規劃器選出的做法,用 EXPLAIN 查看 |
| 預備陳述式(prepared statement) | 先送語句、之後只送參數,可以重複使用 |
| Custom/generic plan | 依實際參數規劃的計畫/不看參數的通用計畫 |
| 鍵集分頁(keyset pagination) | 用上一頁最後一列的值接著查,不用 OFFSET |
| N+1 查詢 | 先查 1 次清單,再為每一列各查 1 次 |
| 死結(deadlock) | 兩筆交易互相等對方放開鎖 |
| Idle in transaction | 交易開著,但沒在做事,還佔著鎖和連線 |
| DDL | 改資料庫結構的語句,例如 ALTER TABLE |
| 釘住(pinning) | RDS Proxy 不能把這條資料庫連線分給別人共用 |
| Redo log、WAL | 記錄資料異動的日誌,用來復原與複寫 |
| Blue/Green 部署 | 另建一套環境、持續同步,準備好後切換過去 |
| Fast clone | Aurora 快速複製一份叢集,一開始共用儲存,適合跑重報表或測試 |
19. 來源
PostgreSQL 文件(current,寫作時為 18)
- Row Security Policies(誰會略過、FORCE、外鍵與唯一性略過 RLS)
- Rules and Privileges(非 LEAKPROOF 運算子不能選用索引掃描)
- CREATE FUNCTION(LEAKPROOF 只有超級使用者能設)
- CREATE VIEW(
security_invoker) - Configuration Settings Functions(
set_config、current_setting) - pg_dump(
--enable-row-security) - COPY(有 RLS 的表不支援
COPY FROM) - Populating a Database(COPY 比 INSERT 快)
- Heap-Only Tuples(HOT 條件)
- CREATE TABLE、CREATE INDEX(fillfactor、CONCURRENTLY、INVALID)
- Constraints(外鍵不會自動建索引)
- Numeric Types、Monetary Types、Character Types、Date/Time Types
- Mathematical Functions(
round的進位規則) - Sequence Functions(不保證連號、ON CONFLICT 會消耗)
- PostgreSQL 18 Release Notes(
uuidv7()、skip scan、EXPLAIN 自動含 BUFFERS) - PostgreSQL 11 Release Notes(加欄位免重寫)
- LIMIT and OFFSET(被略過的列仍要算)
- Multicolumn Indexes、Index-Only Scans
- SELECT(CTE 併入、
SKIP LOCKED) - Using EXPLAIN、EXPLAIN
- Explicit Locking(鎖模式、死結、一致的取鎖順序、無限期等待)
- Transaction Isolation(READ COMMITTED 下的重新判斷)
- Serialization Failure Handling(40001、40P01、重試整筆交易)
- Client Connection Defaults(四種逾時)
- Lock Management(
deadlock_timeout) - PREPARE、Query Planning(前 5 次、
plan_cache_mode) - CREATE STATISTICS
- Vacuuming、Routine Vacuuming(門檻、回捲、空間不還、VACUUM FULL)
- ALTER TABLE(鎖等級、NOT VALID、SET NOT NULL 略過掃描)
- Resource Consumption(
work_mem)
PostgreSQL 原始碼(REL_18_STABLE)
- pg_proc.dat(每個函式的 LEAKPROOF 與揮發性標記)
- indxpath.c、restrictinfo.c(索引條件的安全檢查)
- clauses.c(列比較逐欄判斷)
- nbtsplitloc.c(
_bt_afternewitemoff:局部遞增的分裂最佳化與寬度限制)
PostgreSQL 郵件論壇與第三方(非官方手冊)
- pgsql-bugs:BUG #18544、SET LOCAL 後變數不會變回未定義
- GoCardless:Zero-downtime Postgres migrations(鎖排隊,本文另外實測)
- PGDay Lowlands 2025:Anatomy of Table-Level Locks
AWS
- Prescriptive Guidance:RLS 建議
- Multi-tenant data isolation with PostgreSQL RLS
- Planning I/O in Amazon Aurora
- Amazon Aurora storage
- Manage long-running read queries on Aurora PostgreSQL
- Replication with Aurora PostgreSQL、Aurora Replicas
- Working with autovacuum on Aurora PostgreSQL
- Query Plan Management
- CloudWatch Database Insights
- Slow SQL
- Aurora PostgreSQL 等待事件調校
- Managing Aurora PostgreSQL(連線、閒置連線)
- Performance and scaling for Aurora serverless
- How Aurora serverless works
- Scaling to Zero ACUs
- Blue/Green Deployments 的考量
- Aurora PostgreSQL 擴充功能版本
- Avoiding pinning an RDS Proxy(釘住條件原文)
Npgsql
- Connection String Parameters(各項預設值)
- Performance(連線重設、
DISCARD ALL) - Prepared Statements
- Basic Usage(
NpgsqlBatch) - COPY(
BeginBinaryImport) - Failover and Load Balancing
- EF Core:其他(
EnableRetryOnFailure) - NpgsqlConnector.cs(重設指令)