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 題已定)。資料表名稱都是示範,資料模型還沒定。

這份文件的可信度

1. 先講結論

重負載下最常出事的通常不是「SQL 不夠漂亮」,而是這三種情況:一筆交易開太久、一張表被太多人改同一列、查詢掃了太多不需要的資料。

  1. 主鍵與索引都以 store_id 開頭,外鍵和唯一約束也要包含它。外鍵和唯一性檢查會略過 RLS,只靠 RLS 擋不住跨店參照(第 3 節)。
  2. 每筆交易開頭都設定店家編號:set_config('app.store_id', …, true)。讀不到店家編號時要直接報錯,不能回傳空清單(第 3 節)。
  3. 交易越短越好,等待外部服務時不能開著交易。長交易會佔住連線、拖住鎖,也會讓 VACUUM 收不掉舊資料(第 9、11 節)。
  4. 庫存用一句 UPDATE 直接加減,不要先讀出來、在 C# 算完再寫回去。一張單動到多個商品時,一律照固定順序鎖(第 9 節)。
  5. 清單分頁用鍵集分頁,不要用大的 OFFSET;查明細不要一列查一次(第 7 節)。
  6. 金額用 numeric,不要用浮點數。要注意 C# 的 Math.Round 預設不是四捨五入(第 4 節)。
  7. 三個跟 RLS 的衝突要先知道:COPY FROM 不能用;LIKE、numeric 比較、lower() 這類條件用不上索引;用略過 RLS 的帳號跑 EXPLAIN 看到的計畫,跟程式實際跑的不一樣(第 6、7 節)。
  8. 預備陳述式的計畫可能被大店帶壞:小店可能沿用大店的全表掃描。查詢裡明寫 store_id = $1 就能避開(第 10 節)。
  9. 主鍵用 (store_id, bigint) 比 (store_id, UUID) 省很多:實測 UUID v7 的主鍵索引是 bigint 的 2.3 倍,比完全亂數的 v4 還大(第 4 節)。
  10. 改資料表時一定設 lock_timeout。一個排隊中的 ALTER TABLE 可能卡住後面所有查詢(第 12 節)。
  11. 從第一天就留下量測工具:pg_stat_statements、慢查詢紀錄、Database Insights。效能問題要靠量測找,靠猜很容易改錯地方(第 15 節)。

2. 先懂 PostgreSQL 的四個脾氣

後面幾乎每條規則都能從這四點推出來。

脾氣一:改一列,其實是多寫一列

PostgreSQL 用 MVCC 讓「讀」和「寫」不互相擋。UPDATE 不會直接蓋掉舊資料,而是寫一個新版本,舊版本留著給還在看它的交易。沒有人需要舊版本之後,要等 VACUUM 回收。DELETE 也一樣,只是把列標成不要了。

HOT 更新:改的欄位沒有索引,而且同一頁還有空位 索引 同一個資料頁 舊版本 qty=10 新版本 qty=8 索引不用改:從舊版本順著指標就找得到新版本。寫入少,也比較不會產生死資料。 一般更新:改到有索引的欄位,或同一頁沒有空位 索引 1 索引 2 資料頁 A 舊版本 資料頁 B 新版本 每個索引都多一筆。索引越多,更新越貴,也越容易膨脹。
示意圖。HOT 的兩個條件見 PostgreSQL 文件 Heap-Only Tuples。

由此推出:常被改的欄位不要建索引;太久沒結束的交易會讓舊版本回收不掉;資料刪了,空間也不會還給作業系統,只會留給這張表自己重複使用。

脾氣二:寫入者只擋同一列的寫入者,不擋讀

同時改同一列的第二個人要排隊,等第一個人的交易結束。文件原話是:只要沒有偵測到死結,等鎖的交易會無限期等下去,所以長時間開著交易(例如等使用者輸入時)是壞主意。

脾氣三:快不快,主要看資料是否已在記憶體

資料以 8 KB 為一頁存放。在記憶體裡的頁幾乎不用等;不在的話要去儲存層讀,慢很多,在 Aurora 上還可能算 I/O 費。Aurora Serverless 縮小時會把記憶體中的資料擠掉,再變大時要重新讀回來,所以「最小 ACU 放不放得下常用資料」會直接影響速度和帳單(aurora-serverless.html 第 5 節)。

脾氣四:查詢規劃器靠估計,估錯就選錯路

PostgreSQL 依統計資料估計每種做法的成本,再選最便宜的。統計資料由 ANALYZE 取樣而來,會過期,也會不準。所有店家共用一張表時,大店和小店的資料量差很多,這個問題會特別明顯(第 10 節)。

3. 店家隔離:從請求到資料列的整條鏈

RLS 不是開了就好。下面每一段都可能出錯,出錯的後果是把甲店的資料給乙店看到,所以這節排在最前面。

① 從登入資訊確認是哪一家店 ② 從連線池借一條連線 ③ BEGIN 開交易 ④ set_config(…, true) ⑤ 執行 SQL,RLS 過濾每一列 ⑥ COMMIT,再還回連線池 店家編號只能來自伺服器驗證過的登入資訊,不能信前端傳的 這條連線上一次可能是別家店在用 第三個參數 true:只在這筆交易有效, 交易結束就消失,下一位用這條連線的人讀不到 外鍵與唯一性檢查不會經過 RLS,要靠複合外鍵擋 Npgsql 下次使用這條連線時會先重設狀態 連線上有預備陳述式時,改送不清除它們的重設指令
Claude 的看法:每個請求都走這條路,把 ②~④ 包在共用的資料存取層,不讓各功能自己寫。

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')::bigintcurrent_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);

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 的路

4. 設計資料表

4.1 主鍵

主鍵都寫成 (store_id, id),同一家店的資料就能靠索引集中查詢。id 用什麼,主要看下面這個實測結果。

主鍵(200 萬列,100 家店輪流寫入)索引大小重建索引後寫入時間
(store_id, bigint),資料庫序列61 MB60 MB6.8 秒
(store_id, bigint),依時間遞增,模擬程式產生61 MB60 MB7.6 秒
(store_id, UUID v4),完全亂數100 MB77 MB10.5 秒
(store_id, UUID v7),依時間遞增138 MB77 MB11.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 v7
Guid.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;

4.3 欄位型別

資料用不要用原因
金額、單價numericfloat、double precision浮點數是近似值。PostgreSQL 文件特別建議用 numeric 存金額
金額(另一個陷阱)numericmoneymoney 的輸出受 lc_monetary 影響,還原到不同設定可能失敗
數量numericfloat小數位數跟著單位走(0~4 位),顯示規則放程式
單據日期datetimestamptz老闆選的是「哪一天」,不是一個時間點,用 date 才不會差時區
建立、修改時間timestamptztimestamp內部以 UTC 儲存,顯示時才換成台灣時間
文字textchar(n)文件明寫 char、varchar、text 效能幾乎相同,而且 char(n) 通常最慢。需要長度限制時用 CHECK 或 varchar(n)
狀態text/smallint 加 CHECK沒有約束的字串資料庫要能擋掉不存在的狀態
會查、會排序的欄位正常的欄位jsonbjsonb 的比較不是 leakproof,RLS 下用不上索引(第 6 節)

四捨五入的陷阱:LiteErp 已定金額四捨五入到整數。

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# 先查再寫擋不住重複。

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)
);
2 萬列,隨機更新 10 萬次HOT 比例資料表索引
fillfactor = 100,常改的欄位沒索引98.5%2,000 kB456 kB
fillfactor = 80,常改的欄位沒索引100%2,320 kB456 kB
fillfactor = 100,常改的欄位有索引0%3,808 kB4,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 索引不是免費的

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):

條件LEAKPROOFRLS 下能當索引條件嗎
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,走索引——

資料不會錯,只是變慢。結果分成三點:

時間受快取和當時其他測試影響,同一個查詢重跑可以差兩三倍,只看量級。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 不用怕但要知道的

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% 的扣減不見了,而且沒有任何錯誤)
一句 UPDATE8,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 死結和序列化失敗要重試

9.4 交易裡不能做的事

開著交易時,不要呼叫任何外部服務:電子發票、金流、寄信、S3 都不行。外部服務慢 30 秒,鎖就被抓 30 秒,連線也被佔 30 秒,排隊的人越來越多,最後整家店(甚至全站)卡死。

正確做法:先把資料寫好、提交;外部呼叫放到交易外,或寫進工作佇列交給背景處理。

8 條連線同時跑 8 秒每秒幾筆平均延遲
同一列,交易只有一句 UPDATE8,1921 ms
分散到不同列,交易裡多等約 15 ms51416 ms
同一列,交易裡多等約 15 ms64125 ms
同一列,交易裡多等約 60 ms16500 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 毫秒),或不用預備陳述式。

10.2 統計資料

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 刪資料不會讓空間變小

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 最危險的不是鎖本身,是排隊

① 報表查詢跑了 5 分鐘持有這張表的鎖 ② ALTER TABLE要 ACCESS EXCLUSIVE,開始等 ③ 後面所有查詢連簡單的 SELECT 都排在 ② 後面 結果:ALTER TABLE 本身只要幾毫秒,這張表的查詢卻可能全部卡住 5 分鐘。 解法:ALTER TABLE 前設短的 lock_timeout。等不到就放棄、讓 ③ 繼續,稍後再試。 官方手冊只寫到「會一直等」,排隊擋住後面查詢的行為這次實測確認了。
示意圖,5 分鐘是舉例。
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 節)才會關掉,所以有一段時間新舊程式同時連資料庫。因此:

  1. 每次資料表改動,舊版程式都要還能跑。
  2. 加東西:先改資料表,再部署程式。
  3. 刪東西:先部署不再使用的程式,下一次才刪。

12.4 大版本升級

Aurora PostgreSQL 支援 Blue/Green 部署,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 Size100每個資料來源的上限。超過時要排隊
Minimum Pool Size0沒人用可以全部關掉
Timeout15 秒等連線最多多久
Command Timeout30 秒單一指令最多多久
Connection Idle Lifetime300 秒閒置多久開始回收
Connection Pruning Interval10 秒多久檢查一次閒置連線
Connection Lifetime3,600 秒連線最長使用多久(9.0 起)
Max Auto Prepare0自動預備陳述式關閉
No Reset On Closefalse還回池會重設狀態。保持預設
Keepalive0不送保持連線的訊息

不要打開 Multiplexing:現行文件頁面已經查不到它;GitHub 上有一個標題為「Remove multiplexing」的 issue,屬於 11.0 里程碑(只看到標題,沒讀內文)。多筆交易共用連線也跟「交易內設定店家編號」的做法不一定合得來,未實測。

13.3 RDS Proxy:這次查到更多證據

第 7 題要用的資料。文件原文列出的,跟我們有關的釘住條件:

而 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 找問題的順序

  1. 先看 Database Insights:負載是 CPU、I/O,還是鎖?
  2. 從 pg_stat_statements 找花最多總時間的查詢(不是最慢的那一句)。
  3. 用 app_user 帶店家編號跑 EXPLAIN (ANALYZE),大店小店各跑一次。
  4. 修查詢或索引,再量一次。

16. 對耀達 Lite 的意義

16.1 從第一天就要做對(事後很難改)

16.2 上線前要做

16.3 可以等監控數字再做

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。

還要到 AWS 上才能確認的

查不到或只有二手資料的

這次沒有研究的

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 cloneAurora 快速複製一份叢集,一開始共用儲存,適合跑重報表或測試

19. 來源

PostgreSQL 文件(current,寫作時為 18)

PostgreSQL 原始碼(REL_18_STABLE)

PostgreSQL 郵件論壇與第三方(非官方手冊)

AWS

Npgsql

.NET