Trước PostgreSQL 16, việc theo dõi I/O của database khá thô: bạn biết tổng số block đọc và trúng cache, nhưng không biết ai gây ra, vì sao, hay I/O đó là đọc thường, quét lớn, hay dọn dẹp VACUUM. Phiên bản 16 thêm một view mới thay đổi hẳn cuộc chơi: pg_stat_io. Nó tách I/O theo ba chiều, cho cái nhìn chi tiết chưa từng có về việc đĩa đang bị dùng vào việc gì. Bài này đo thật vài kịch bản để thấy sức mạnh của nó.

Ba chiều phân loại

Mỗi dòng trong pg_stat_io được phân loại theo ba chiều — trả lời ai, cái gì, vì sao:

  • backend_type — AI gây ra I/O: client backend (truy vấn người dùng), autovacuum worker, checkpointer, background writer, ...
  • object — CÁI GÌ: relation (bảng/index thường) hay temp relation (bảng tạm).
  • context — VÌ SAO: normal (truy cập thông thường), bulkread (quét lớn), bulkwrite (ghi hàng loạt như COPY), vacuum (dọn dẹp).

Cùng với đó là các cột đo lường: reads (đọc đĩa), hits (trúng cache), writes, extends (mở rộng file), evictions, reuses, fsyncs, và các cột *_time.

SELECT pg_stat_reset_shared('io');   -- reset để đo một khoảng
SELECT backend_type, context, reads, hits, extends, reuses
FROM pg_stat_io WHERE reads>0 OR writes>0 OR extends>0;

Ảnh chụp đoạn mã SQL nền tối minh hoạ pg_stat_io PostgreSQL 16 I/O tách theo ai cái gì vì sao view mới của PG16 lần đầu thấy chính xác đĩa đang bị dùng vào việc gì, ba chiều phân loại mỗi dòng backend_type ai gây ra client backend autovacuum checkpointer object cái gì relation bảng index hay temp relation bảng tạm context vì sao normal bulkread quét lớn bulkwrite vacuum, các cột đo lường reads hits đọc từ đĩa trúng cache hit_pct hits chia reads cộng hits writes ghi trang bẩn ra đĩa extends mở rộng file khi bảng lớn dần INSERT evictions đẩy trang khác ra khỏi cache reuses tái dùng buffer trong ring bulkread bulkwrite fsyncs time số fsync và thời gian I/O nếu track_io_timing bật, truy vấn thường dùng SELECT pg_stat_reset_shared io reset để đo một khoảng SELECT backend_type context reads hits extends reuses FROM pg_stat_io WHERE reads lớn hơn 0, lưu ý sort hash tràn đĩa không nằm trong temp relation temp relation là bảng tạm CREATE TEMP TABLE dùng local buffer file tràn của sort hash xem ở EXPLAIN BUFFERS temp read written và pg_stat_database temp_bytes không phải pg_stat_io

Hình 1: pg_stat_io phân loại mỗi dòng theo backend_type (ai) × object (cái gì) × context (vì sao), với các cột reads/hits/writes/extends/reuses. Lưu ý sort/hash tràn đĩa không nằm trong "temp relation".

Đo thật: quét lớn dùng ring buffer

Kịch bản đầu tiên hé lộ một cơ chế mà trước đây gần như vô hình. Tôi quét tuần tự bảng pgbench_accounts 925 MB (lớn hơn shared_buffers 128 MB) sau khi reset thống kê I/O:

Ảnh chụp bảng kết quả đo thật nền tối pg_stat_io PostgreSQL 16 shared_buffers 128MB, quét tuần tự pgbench_accounts 925 MB 1 luồng context bulkread context bulkread reads đĩa 96203 hits cache 7111 reuses 96166 hit 6.9 phần trăm context normal reads 1 hits 221 hit 99.5 phần trăm, quét lớn dùng ring buffer 96166 reuses thay vì evictions không xoá sạch cache hit chỉ 6.9 phần trăm vì bảng lớn hơn cache nhưng nhờ ring các query khác vẫn giữ được buffer, INSERT 500000 dòng vào bảng mới extends file lớn dần context normal extends 7147 hits 514976 reads 2 7147 lần mở rộng file bảng lớn thêm khoảng 56 MB hits cao vì ghi vào trang mới đang trong cache, các backend_type khác cũng lộ ra tách theo AI và context autovacuum worker vacuum reads 3673 I/O của VACUUM tách riêng checkpointer normal writes 5397 flush trang bẩn định kỳ background writer normal writes 24245 dọn trang bẩn nền thấy chính xác I/O đến từ đâu query VACUUM checkpoint hay bulk scan

Hình 2: Seq scan lớn dùng context bulkread: 96.203 reads đĩa, chỉ 6,9% hit, nhưng 96.166 reuses (ring buffer) thay vì evictions. INSERT tạo 7.147 extends. Và I/O của autovacuum/checkpointer/background writer tách riêng.

Kết quả thật:

  • context bulkread: 96.203 reads, chỉ 7.111 hits — hit ratio 6,9% — và 96.166 reuses.
  • context normal: 1 read, 221 hits — 99,5%.

Con số reuses lớn tiết lộ điều quan trọng: khi quét một bảng lớn hơn bộ nhớ, PostgreSQL không nạp toàn bộ vào shared_buffers (làm vậy sẽ đẩy hết dữ liệu nóng khác ra ngoài). Thay vào đó nó dùng một ring buffer nhỏ và tái dùng (reuse) các buffer trong ring đó — 96.166 lần. Vì thế một seq scan khổng lồ không xoá sạch cache của bạn. Hit ratio thấp (6,9%) là chuyện đương nhiên với bảng lớn hơn ring, nhưng đổi lại các truy vấn khác vẫn giữ được buffer của chúng. Trước pg_stat_io, cơ chế này hoàn toàn ẩn.

Extends và I/O của từng loại tiến trình

Extends — khi bảng lớn dần. Tôi INSERT 500.000 dòng vào một bảng mới: pg_stat_io ghi extends = 7.147 (mở rộng file 7.147 lần, bảng lớn thêm ~56 MB), với hits cao (514.976) vì ghi vào các trang mới vẫn đang trong cache. Đây là cách nhìn ra một bảng đang tăng trưởng nhanh qua I/O.

Tách theo tiến trình. Điểm mạnh nhất của pg_stat_io là thấy I/O đến từ ai: autovacuum worker với context vacuum (I/O dọn dẹp, tách riêng khỏi truy vấn), checkpointer (flush trang bẩn định kỳ), background writer (dọn trang bẩn nền). Nếu đĩa bị quá tải, bạn biết ngay thủ phạm là truy vấn người dùng, VACUUM, checkpoint, hay một bulk scan — thay vì đoán mò như trước.

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

"temp relation" không phải là temp file của sort. Đây là điểm dễ nhầm. object = 'temp relation' theo dõi I/O trên bảng tạm (CREATE TEMP TABLE, dùng local buffer). Còn file tràn đĩa của sort/hash khi thiếu work_mem không nằm ở đây — đo thật một sort tràn 117 MB ra đĩa cho thấy pg_stat_io temp relation vẫn bằng 0. Loại I/O đó xem ở EXPLAIN (BUFFERS) (dòng temp read/written) và pg_stat_database.temp_bytes.

Cần track_io_timing để có thời gian I/O. Các cột read_time, write_time, fsync_time chỉ có số liệu khi bật track_io_timing. Nó có chi phí nhỏ (đo thời gian mỗi thao tác I/O) nhưng cho thông tin quý — thời gian chờ I/O, không chỉ số lượng. Cân nhắc bật trên hệ thống cần chẩn đoán sâu.

Thống kê tích luỹ, reset để đo khoảng. Như mọi view pg_stat_*, số liệu cộng dồn từ lần reset gần nhất. Dùng pg_stat_reset_shared('io') trước khi đo một khoảng cụ thể — ví dụ trước và sau một job nạp dữ liệu — để thấy đúng I/O của khoảng đó thay vì con số tích luỹ từ khi khởi động.

Ba ý mang về

  1. pg_stat_io (mới ở PG16) tách I/O theo ba chiều: ai gây ra (backend_type), trên cái gì (object), vì sao (context) — cho phép thấy chính xác đĩa bị dùng vào việc gì (truy vấn thường, quét lớn, VACUUM, checkpoint), thay vì chỉ có tổng số đọc/trúng cache như trước.
  2. Đo thật hé lộ cơ chế ring buffer cho quét lớn: seq scan bảng 925 MB dùng context bulkread với 96.166 reuses thay vì evictions và hit ratio 6,9% — chứng minh vì sao một seq scan lớn không xoá sạch cache của các truy vấn khác, điều trước đây gần như vô hình.
  3. Chú ý hai điểm dễ nhầm: temp relation là bảng tạm chứ không phải file tràn của sort/hash (loại đó xem ở EXPLAIN BUFFERS và pg_stat_database.temp_bytes), và cần bật track_io_timing để có các cột thời gian I/O; nhớ reset để đo đúng khoảng.

Phần sau ta quay lại bài toán thực dụng nhất của tối ưu: Phần sau tìm truy vấn chậm theo thời gian bằng pg_stat_statements — cách xác định câu lệnh nào tốn tổng thời gian nhiều nhất trên hệ thống, chứ không chỉ chậm một lần.