Bạn tối ưu một truy vấn dashboard, test thấy 25 mili giây, hài lòng đẩy lên production. Rồi giờ cao điểm tới, người dùng than dashboard "quay mãi không xong". Bạn chạy lại đúng truy vấn đó — vẫn 25 mili giây. Vấn đề không nằm ở truy vấn khi chạy một mình, mà ở chuyện gì xảy ra khi hàng chục người cùng chạy nó. Đây là một loại nút thắt chỉ xuất hiện dưới tải đồng thời, và cần cách đo khác. Bài này đo thật sự suy giảm, chẩn đoán, và tìm ra một cách sửa khá bất ngờ.

Đo triệu chứng: cùng query, nhiều mức đồng thời

Chìa khoá là đo dưới tải, không phải đo một lần. Tôi lấy đúng truy vấn dashboard (tổng hợp trên 200 nghìn dòng) và chạy nó bằng pgbench ở số client tăng dần:

pgbench -n -f dashboard.sql -c 1  -T 8   # giờ vắng
pgbench -n -f dashboard.sql -c 8  -T 8
pgbench -n -f dashboard.sql -c 32 -T 8   # giờ cao điểm

Ảnh chụp đoạn mã SQL nền tối minh hoạ dashboard nhanh lúc vắng chậm giờ cao điểm tìm nút thắt dưới tải PostgreSQL 16 một query 25ms chạy một mình có thể thành 266ms khi 32 người cùng mở, triệu chứng đo cùng query ở nhiều mức đồng thời pgbench chạy đúng query dashboard với c tăng dần pgbench -n -f dashboard.sql -c 1 -T 8 giờ vắng -c 8 -c 32 giờ cao điểm latency mỗi query tăng dần dù query không đổi nghẽn tài nguyên, chẩn đoán lấy mẫu wait_event khi đang tải cao SELECT coalesce wait_event_type wait_event CPU FROM pg_stat_activity WHERE state active CPU nhiều nghẽn CPU query nặng nhân nhiều người Lock nhiều tranh chấp khoá IO nhiều đĩa cache, bẫy parallel query nhân tiến trình dưới tải cao mỗi query song song sinh thêm worker ví dụ 2 32 người nhân 3 bằng 96 tiến trình tranh 10 core coordination cộng context switch đè bẹp lợi ích SET max_parallel_workers_per_gather 0 thử tắt với query nhỏ, hướng chữa tối ưu chính query index index-only bớt dữ liệu quét tắt parallel cho query nhỏ chạy đồng thời cao cache kết quả dashboard materialized view cache tầng app đưa đọc dashboard sang read replica

Hình 1: Đo cùng query ở nhiều mức đồng thời để thấy latency tăng dần; chẩn đoán bằng lấy mẫu wait_event; cái bẫy parallel query nhân tiến trình dưới tải cao; và các hướng chữa.

Đo thật: chậm 10 lần ở giờ cao điểm

Ảnh chụp bảng kết quả đo thật nền tối dashboard query dưới tải đồng thời PostgreSQL 16 10 core, cùng một query tăng số client latency mỗi query tăng dần số client đồng thời 1 giờ vắng latency 25,7 ms tps 39 8 client 76,1 ms 105 16 client 137,4 ms 116 32 client giờ cao điểm 266,3 ms 120 query không đổi nhưng chậm 10 lần tps bão hoà khoảng 120 từ 16 client 10 core đã cạn, chẩn đoán wait_event ở 32 client CPU 752 mẫu IPC MessageQueueReceive 13 IO BufFileWrite 12 nghẽn CPU query nặng nhân nhiều người cộng parallel worker coordination cộng temp file, sửa tắt parallel cho query nhỏ chạy đồng thời cao 32 client parallel bật mỗi query 3 tiến trình 266,3 ms 120 tps parallel tắt mỗi query 1 tiến trình 158,2 ms 202 tps tắt parallel nhanh 1,7 lần và throughput 1,7 lần parallel giúp khi chạy một mình nhưng 32 nhân 3 bằng 96 tiến trình tranh 10 core thì coordination đè bẹp lợi ích

Hình 2: Cùng query — 1 client 25,7 ms, 8 client 76 ms, 16 client 137 ms, 32 client 266,3 ms (chậm 10 lần). tps bão hoà ~120 từ 16 client (10 core cạn). wait_event: CPU-bound. Tắt parallel ở 32 client: 266→158 ms, tps 120→202.

Con số thật (máy 10 core):

  • 1 client: 25,7 ms/query.
  • 8 client: 76,1 ms.
  • 16 client: 137,4 ms.
  • 32 client: 266,3 ms — chậm hơn 10 lần so với khi chạy một mình!

Đáng chú ý: throughput (tps) bão hoà quanh 120 từ 16 client trở đi — nghĩa là 10 core đã cạn, thêm client chỉ làm mỗi query xếp hàng lâu hơn chứ tổng công việc không tăng. Đây chính là "nhanh lúc vắng, chậm giờ cao điểm": query không hề đổi, chỉ là tài nguyên bị chia cho nhiều người.

Chẩn đoán: hệ thống đang chờ gì

Lấy mẫu wait_event khi 32 client đang chạy (kỹ thuật ở bài wait events): áp đảo là CPU (752 mẫu) — hệ thống nghẽn CPU, đúng như dự đoán cho một query tính toán nặng nhân với nhiều người. Kèm theo là IPC/MessageQueueReceive (điều phối parallel worker) và IO/BufFileWrite (temp file từ tổng hợp).

Chi tiết IPC/MessageQueueReceive là manh mối cho cách sửa bất ngờ tiếp theo.

Cách sửa bất ngờ: tắt parallel query

max_parallel_workers_per_gather = 2, nghĩa là mỗi query dashboard sinh thêm 2 worker — thành 3 tiến trình mỗi query. Ở 32 client, đó là 32 × 3 = 96 tiến trình tranh nhau 10 core. Chi phí điều phối worker và context switch giữa 96 tiến trình lớn hơn lợi ích của song song hoá. Tôi thử tắt parallel cho query này:

SET max_parallel_workers_per_gather = 0;   -- mỗi query chỉ 1 tiến trình

Đo lại ở 32 client: latency 266,3 ms → 158,2 ms (nhanh 1,7 lần), throughput 120 → 202 tps (cũng 1,7 lần). Bất ngờ nhưng hợp lý: parallel query giúp khi chạy một mình (chia việc cho nhiều core rảnh), nhưng dưới tải đồng thời cao khi mọi core đã bận, nó chỉ nhân số tiến trình lên và thêm chi phí điều phối. Với query nhỏ chạy đồng thời cao, một tiến trình mỗi query là tối ưu hơn.

Đánh đổi và các hướng chữa khác

Tắt parallel không phải luôn đúng — chỉ cho query nhỏ dưới tải cao. Một query phân tích lớn chạy đơn lẻ (báo cáo ban đêm) vẫn hưởng lợi từ parallel. Đây là lý do PostgreSQL cho SET ở mức phiên/truy vấn — bạn tắt cho dashboard OLTP đồng thời cao, giữ bật cho báo cáo nặng đơn lẻ. Đừng tắt parallel toàn hệ thống theo phản xạ.

Tối ưu chính query vẫn là gốc. Query dashboard này tính lại tổng hợp trên 200 nghìn dòng mỗi lần load. Cách bền hơn: cache kết quả — MATERIALIZED VIEW làm mới định kỳ, hoặc cache ở tầng ứng dụng. Dashboard thường chấp nhận dữ liệu trễ vài phút, nên tính một lần rồi phục vụ nhiều người rẻ hơn nhiều so với tính lại cho từng người.

Phân tải sang read replica. Như bài read replica đã đo, đưa các truy vấn dashboard (chỉ đọc, chấp nhận trễ) sang replica giải phóng primary và cộng thêm năng lực. Đây là cách xử lý "giờ cao điểm" ở tầng kiến trúc, bổ sung cho việc tối ưu từng query.

Ba ý mang về

  1. Nút thắt "chậm giờ cao điểm" chỉ lộ ra dưới tải đồng thời — phải đo bằng nhiều client (pgbench -c tăng dần), không phải đo một lần: đo thật một query 25,7 ms (1 client) thành 266,3 ms (32 client), throughput bão hoà ~120 tps khi 10 core cạn.
  2. Chẩn đoán bằng lấy mẫu wait_event dưới tải: ở đây CPU áp đảo (nghẽn CPU do query nặng × nhiều người) cùng chi phí điều phối parallel worker — chỉ ra cả nút thắt lẫn hướng sửa.
  3. Cách sửa bất ngờ: tắt parallel query cho query nhỏ chạy đồng thời cao (32×3=96 tiến trình tranh 10 core) cho nhanh 1,7 lần và throughput 1,7 lần — parallel giúp khi chạy một mình nhưng hại dưới tải; kèm các hướng bền hơn là cache kết quả (materialized view) và phân tải sang read replica.

Phần sau ta xử một tình huống vận hành khó nhất: Phần sau thực hiện một migration lớn trên bảng đang chạy mà không gây downtime — thêm cột, thêm index, đổi kiểu dữ liệu một cách an toàn trên hệ thống production đang phục vụ.