Máy chủ có nhiều lõi CPU, vậy sao không cho nhiều lõi cùng quét một bảng lớn? PostgreSQL làm đúng vậy: với truy vấn quét bảng đủ lớn, nó chia việc cho nhiều tiến trình worker chạy song song, rồi gom kết quả lại. Nghe như cứ thêm worker là nhanh thêm ngần ấy lần. Nhưng khi đo, con số tăng tốc khiêm tốn hơn kỳ vọng nhiều — và hiểu vì sao nó khiêm tốn quan trọng hơn con số.
Chia việc cho worker, rồi Gather
Khi PostgreSQL quyết định chạy song song một truy vấn, kế hoạch xuất hiện hai nút mới. Parallel Seq Scan: mỗi worker quét một phần của bảng, không dẫm lên nhau. Gather: tiến trình chính (leader) thu kết quả từng phần từ các worker và gộp lại thành kết quả cuối. Với count(*), mỗi worker đếm phần của mình, Gather cộng các phần lại. Không phải mọi truy vấn đều song song hóa được: một số hàm, một số kiểu truy vấn (ví dụ ghi dữ liệu, hay hàm bị đánh dấu không an toàn cho song song) buộc chạy tuần tự. Song song chủ yếu giúp cho các phép đọc nặng — quét lớn, tổng hợp, join lớn — nơi công việc chia được thành các mảnh độc lập.
Số worker không phải bạn muốn bao nhiêu cũng được. Nó bị chặn bởi ba thứ: max_parallel_workers_per_gather (mặc định 2), một giới hạn toàn cục về tổng worker, và — quan trọng — cỡ bảng: PostgreSQL tính số worker theo lô-ga-rít kích thước bảng. Bảng càng lớn càng nhiều worker, nhưng theo đường cong phẳng dần, không tuyến tính. Và bảng nhỏ thì không được worker nào cả.
Đo: 3 tiến trình không cho 3 lần nhanh
Tôi dựng một bảng 5 triệu hàng (365 MB) và chạy một truy vấn quét cả bảng: SELECT count(*) FROM big WHERE s LIKE '%abc%'. Đầu tiên tắt hẳn song song (max_parallel_workers_per_gather = 0):
tuần tự (1 tiến trình): 324 ms
Rồi bật lại (mặc định 2 worker — tức 3 tiến trình tính cả leader):
2 worker (3 tiến trình): 126 ms (~2.6 lần nhanh hơn)
Đây chính là chỗ tôi suýt viết sai. Ba tiến trình cùng làm, tôi trông đợi nhanh gấp 3. Nhưng đo ra chỉ 2.6 lần. Tôi tăng lên 4 worker (5 tiến trình):
4 worker (5 tiến trình): 100 ms (~3.2 lần nhanh hơn)
Năm tiến trình, mà chỉ 3.2 lần — vẫn còn xa con số 5. Đây là bài học then chốt: song song không nhân tuyến tính theo số tiến trình. Có ba lý do rút ra từ chính kế hoạch. Thứ nhất, bước Gather — gom kết quả từ các worker — là chi phí thêm không có trong bản tuần tự. Thứ hai, luôn có phần tuần tự không chia được (khởi động worker, gộp cuối), đúng như định luật Amdahl: phần không song song hóa được đặt trần cho tốc độ. Thứ ba, và thường bị quên: các worker cùng đọc một cái đĩa — I/O là tài nguyên dùng chung, nên thêm CPU không giúp nếu nút cổ chai là băng thông đĩa. Cái sai của tôi là đếm số tiến trình rồi nhân, quên rằng chúng chia sẻ những thứ không nhân được.
Xin nhiều worker không có nghĩa được nhiều
Tôi thử đẩy xa hơn: đặt max_parallel_workers_per_gather = 6 và mong 6 worker. Nhưng EXPLAIN ANALYZE cho thấy:
Workers Planned: 4
Workers Launched: 4
Chỉ 4 worker, dù tôi cho phép 6. Lý do: PostgreSQL cấp số worker theo cỡ bảng, không theo con số tôi đặt — cái setting chỉ là trần, không phải lệnh. Bảng 365 MB được planner tính ra 4 worker là đủ; cho phép nhiều hơn cũng không dùng tới. Đây là lý do phải đọc dòng Workers Launched trong EXPLAIN ANALYZE để biết thực tế bao nhiêu worker chạy — đừng tin con số max_parallel_workers_per_gather bạn đặt, cũng đừng tin số lõi của máy.
Bảng nhỏ: song song còn hại
Còn một trường hợp nữa đáng đo: bảng nhỏ. Tôi tạo một bảng 20.000 hàng và chạy đúng truy vấn đó với song song bật. Kế hoạch:
Seq Scan on small (không có Gather, không worker nào)
PostgreSQL không dùng song song cho bảng nhỏ, dù được phép. Vì bảng nằm dưới min_parallel_table_scan_size (mặc định 8 MB), planner biết rằng chi phí khởi động worker và Gather sẽ lớn hơn phần tiết kiệm được từ việc chia nhỏ — song song sẽ làm chậm, không nhanh. Đây cũng là lý do một truy vấn nhỏ chạy đơn lẻ không bao giờ thấy worker, dù máy chủ rảnh cả chục lõi: planner cân nhắc từng truy vấn theo cỡ dữ liệu của nó, không theo số CPU đang rỗi. Đây là một quyết định thông minh của planner: song song không miễn phí, và với việc nhỏ, cái phí cố định của nó nuốt hết lợi ích. Như bài kết nối đã đo, tạo một tiến trình là việc tốn công thật; khởi động worker cũng vậy.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng kỳ vọng song song nhân tốc độ theo số lõi. Nó là một sự tăng tốc có trần, thường 2–4 lần cho các truy vấn quét lớn, và cái trần đó do Gather, phần tuần tự và I/O chung đặt ra — không phải do số CPU. Nếu bạn cần nhanh hơn thế, một index tốt (biến quét cả bảng thành đọc vài trang) thường thắng xa việc song song hóa một lần quét toàn bảng. Như bài quét chỉ mục đã đo, tra index đọc vài trang thay vì cả bảng — đó là cắt giảm bậc độ lớn, còn song song chỉ chia phần việc đó cho vài tiến trình. Chia một việc lớn cho 4 vẫn thua việc làm nó nhỏ đi hàng nghìn lần. Song song là công cụ cho khi bạn bắt buộc phải quét nhiều dữ liệu (một báo cáo tổng hợp toàn bảng chẳng hạn), không phải cách thay cho một index còn thiếu.
Hệ quả thứ hai: song song chỉ đáng cho bảng lớn, và planner tự biết điều đó. Bạn hiếm khi cần chỉnh tay; PostgreSQL bật song song khi bảng đủ lớn và tắt khi không. Con số mang theo: kế hoạch song song chia quét bảng lớn cho nhiều worker rồi Gather — 5 triệu hàng: tuần tự 324ms, 2 worker (3 tiến trình) 126ms (~2.6 lần), 4 worker (5 tiến trình) 100ms (~3.2 lần) — không nhân tuyến tính vì Gather, phần tuần tự và I/O đĩa chung (Amdahl); xin 6 worker chỉ được 4 vì planner cấp theo cỡ bảng, và bảng nhỏ 20k hàng thì không song song vì overhead lớn hơn lợi. Đọc Workers Launched trong EXPLAIN để biết thực tế, và nhớ rằng thêm worker có giới hạn thu hồi vốn rất nhanh.
Thử ba mươi giây
Trên một bảng đủ lớn (vài trăm MB), chạy EXPLAIN ANALYZE SELECT count(*) FROM bảng_lớn; và tìm dòng Workers Launched cùng nút Gather — đó là kế hoạch song song đang chạy. Ghi lại thời gian đo được. Giờ chạy SET max_parallel_workers_per_gather = 0; rồi EXPLAIN ANALYZE lại: kế hoạch chuyển về Seq Scan một tiến trình, và bạn sẽ thấy thời gian tăng lên — nhưng chia thời gian tuần tự cho thời gian song song, tỉ số gần như luôn nhỏ hơn số worker cộng một. Nửa phút đó cho bạn thấy song song giúp thật, nhưng theo một hệ số khiêm tốn mà số lõi máy không tiên đoán được.