CTE — mệnh đề WITH — làm truy vấn dài trở nên dễ đọc, chia thành các bước có tên. Nhiều lập trình viên dùng nó vô tư như một cách "làm sạch code". Nhưng cách PostgreSQL thực thi CTE đã thay đổi hoàn toàn giữa PG11 và PG12, và không biết điều đó có thể khiến truy vấn chậm hàng nghìn lần. Bài này đo thật sự khác biệt, và làm rõ khi nào bạn muốn materialize một cách chủ ý.

Thay đổi lớn ở PostgreSQL 12

Điểm mấu chốt về CTE:

  • PostgreSQL ≤ 11: mọi CTE luôn được materialize — tính toàn bộ kết quả, lưu vào một bảng tạm, rồi truy vấn ngoài mới dùng. Đây là một rào chắn tối ưu (optimization fence): filter và điều kiện ở truy vấn ngoài không đẩy được vào CTE.
  • PostgreSQL ≥ 12: CTE đơn giản (chỉ đọc, tham chiếu một lần, không hàm volatile) được inline mặc định — planner gộp CTE vào truy vấn chính, đẩy filter xuống, dùng được index. Thường nhanh hơn rất nhiều.
WITH t AS (SELECT * FROM sp)         -- PG12+: inline
SELECT * FROM t WHERE ma='SP12345';  -- filter đẩy vào → Index Scan

Ảnh chụp đoạn mã SQL nền tối minh hoạ CTE WITH materialize hay không thay đổi lớn ở PostgreSQL 12, CTE làm truy vấn dài dễ đọc nhưng cách thực thi đổi hẳn giữa PG11 và PG12, PG11 trở xuống mọi CTE luôn materialize tính xong lưu bảng tạm rồi mới dùng đây là rào chắn tối ưu filter bên ngoài không đẩy vào được, PG12 trở lên CTE đơn giản inline mặc định planner gộp vào truy vấn chính đẩy được filter xuống dùng index thường nhanh hơn nhiều, WITH t AS SELECT từ sp PG12 inline SELECT từ t WHERE ma bằng SP12345 filter đẩy vào Index Scan, ép rõ ràng WITH t AS MATERIALIZED ép dựng bảng tạm rào chắn WITH t AS NOT MATERIALIZED ép inline kể cả khi dùng nhiều lần, khi nào cần MATERIALIZED chủ ý CTE chứa hàm volatile random nextval dùng nhiều lần cần kết quả nhất quán giữa các tham chiếu, CTE data-modifying INSERT UPDATE RETURNING luôn materialize, khi bạn đo thấy inline cho kế hoạch tệ và muốn ép một rào chắn

Hình 1: PG12 đổi CTE từ luôn-materialize (rào chắn) sang inline mặc định. MATERIALIZED/NOT MATERIALIZED để ép rõ ràng; khi nào cần fence chủ ý.

Đo thật: chậm hơn 4.700 lần

Truy vấn WITH t AS (SELECT * FROM sp) SELECT * FROM t WHERE ma='SP12345' trên bảng 3 triệu dòng, so CTE inline (mặc định) với ép MATERIALIZED:

Ảnh chụp bảng kết quả đo thật nền tối WITH t AS SELECT từ sp WHERE ma bằng SP12345 3 triệu dòng PostgreSQL 16, CTE mặc định inline Index Scan using idx_ma filter đẩy vào CTE 0,068 mili giây, CTE MATERIALIZED CTE Scan Seq Scan cả 3 triệu dòng rồi mới lọc 324 mili giây MATERIALIZED chậm hơn khoảng 4700 lần nó dựng bảng tạm 3 triệu dòng trước rào chắn khiến filter ma bằng SP12345 không đẩy xuống dùng index được, CTE nặng dùng lại 2 lần có điều kiện lọc được WITH t AS gia nhỏ hơn 500 SELECT t a JOIN t b inline 83 mili giây đẩy filter xuống mỗi tham chiếu rẻ MATERIALIZED 2579 mili giây tính md5 cho cả 3 triệu dòng rồi mới lọc, khi MATERIALIZED thật sự cần hàm volatile dùng nhiều lần WITH t AS SELECT id random từ sp WHERE gia nhỏ hơn 100000 SELECT count từ t count từ t inline random chạy 2 lần hai tham chiếu thấy giá trị khác nhau bug MATERIALIZED chạy 1 lần cả hai thấy cùng kết quả đúng nhất quán

Hình 2: CTE inline: Index Scan, 0,068 ms (filter đẩy vào dùng index). CTE MATERIALIZED: dựng bảng tạm 3 triệu dòng rồi mới lọc, 324 ms — chậm hơn ~4.700 lần.

  • CTE inline (mặc định PG12+): Index Scan using idx_ma — điều kiện ma='SP12345' được đẩy vào CTE nên dùng index, 0,068 ms.
  • CTE MATERIALIZED: CTE Scan trên một Seq Scan toàn bộ 3 triệu dòng — nó dựng cả bảng tạm 3 triệu dòng trước, rồi mới lọc, 324 ms.

Chậm hơn ~4.700 lần. Đây chính là cái bẫy của các phiên bản ≤ PG11: mọi CTE là rào chắn, nên viết WITH cho dễ đọc vô tình phá hết khả năng đẩy filter xuống index. PG12 sửa điều này bằng cách inline mặc định.

Kể cả khi CTE được dùng lại nhiều lần với điều kiện lọc được, inline vẫn thường thắng: đo WITH t AS (... gia<500 ...) SELECT ... t a JOIN t b — inline 83 ms so với MATERIALIZED 2.579 ms, vì inline đẩy gia<500 xuống mỗi tham chiếu thay vì tính md5 cho cả 3 triệu dòng.

Khi nào cần MATERIALIZED chủ ý

Inline là mặc định tốt, nhưng có ba trường hợp bạn cần ép MATERIALIZED:

Hàm volatile dùng nhiều lần, cần nhất quán. Nếu CTE chứa random(), nextval(), now() và được tham chiếu nhiều lần, inline sẽ tính lại mỗi lần — hai tham chiếu thấy giá trị khác nhau. Đo thật: WITH t AS (SELECT id, random()... ) SELECT (count FROM t), (count FROM t) — inline chạy random() hai lần, MATERIALIZED chạy một lần nên hai tham chiếu thấy cùng kết quả. Khi tính đúng đắn phụ thuộc điều này, MATERIALIZED là bắt buộc.

CTE data-modifying luôn materialize. WITH x AS (INSERT ... RETURNING ...) SELECT ... — CTE chứa INSERT/UPDATE/DELETE luôn được thực thi một lần và materialize, không bao giờ inline. Đây là hành vi cố định, không đổi.

Ép rào chắn khi bạn đã đo. Đôi khi inline khiến planner chọn kế hoạch tệ (ước lượng sai lan truyền qua CTE). Nếu bạn đo thấy MATERIALIZED cho kế hoạch tốt hơn, ép nó là hợp lý — nhưng chỉ sau khi đo, không phải theo thói quen.

Đánh đổi cần cân nhắc

Nâng cấp từ PG11 lên PG12+ có thể đổi kế hoạch. Một truy vấn dựa (vô tình hoặc cố ý) vào hành vi materialize cũ có thể đổi hành vi sau nâng cấp. Nếu một truy vấn chậm đi sau khi lên PG12+, kiểm xem CTE có bị inline gây ước lượng sai không — thêm MATERIALIZED để khôi phục hành vi cũ.

Đừng dùng CTE chỉ để "ép thứ tự thực thi". Trước PG12, nhiều người dùng CTE như một fence để kiểm soát planner. Mẹo đó không còn đáng tin từ PG12 (CTE inline). Muốn fence thì khai MATERIALIZED rõ ràng.

CTE vẫn tốt cho tính dễ đọc và đệ quy. Inline nghĩa là CTE giờ gần như "miễn phí" về hiệu năng cho trường hợp đơn giản — cứ dùng để code sạch. Và WITH RECURSIVE là công cụ không thể thay thế cho dữ liệu phân cấp (cây, đồ thị).

Ba ý mang về

  1. PG12 đổi CTE từ luôn-materialize (rào chắn) sang inline mặc định: đo thật, CTE inline dùng được index (0,068 ms) còn MATERIALIZED dựng cả bảng tạm 3 triệu dòng rồi mới lọc (324 ms) — chậm hơn ~4.700 lần vì filter không đẩy xuống được.
  2. Inline thường thắng vì đẩy filter xuống, kể cả khi CTE dùng lại nhiều lần (83 ms so với 2.579 ms) — nên với truy vấn thường, cứ dùng WITH cho dễ đọc mà không lo hiệu năng.
  3. Ép MATERIALIZED chủ ý khi cần: hàm volatile dùng nhiều lần (cần nhất quán), CTE data-modifying (luôn materialize), hoặc khi đo thấy inline cho kế hoạch tệ — và nhớ hành vi đổi khi nâng cấp từ PG11.

Phần sau ta xét một dạng truy vấn con dễ viết mà tốn kém âm thầm: Phần sau đo subquery tương quan — vì sao nó chạy lại một lần cho mỗi dòng ngoài, và cách viết lại thành JOIN hay hàm cửa sổ.