Một truy vấn đơn giản trên PostgreSQL chạy trong phần nghìn của một mili giây. Từ con số đó, rất dễ nhẩm ra năng lực của hệ thống: "truy vấn 0,03ms thì máy làm được ba chục nghìn cái mỗi giây". Nhưng phép nhẩm ấy bỏ quên một chi phí ẩn mà tôi đã tự vấp phải: mỗi kết nối tới PostgreSQL không hề miễn phí, và nếu ứng dụng mở một kết nối mới cho mỗi truy vấn, chính cái chi phí kết nối — chứ không phải truy vấn — mới quyết định thông lượng thật. Bài này đo tách bạch hai thứ đó.
Mỗi kết nối là một tiến trình riêng
PostgreSQL dùng mô hình một tiến trình cho mỗi kết nối. Khi một client kết nối, tiến trình chính (postmaster) fork ra một tiến trình backend riêng để phục vụ đúng client đó suốt phiên. Điều này khác với mô hình nhiều luồng trong một tiến trình mà nhiều hệ khác dùng, và nó có hệ quả trực tiếp về chi phí: mở một kết nối không chỉ là bắt tay mạng, mà là tạo hẳn một tiến trình mới của hệ điều hành, và fork một tiến trình là việc tốn công thật của nhân.
Mở một kết nối vì thế gồm mấy bước: postmaster fork một backend, hai bên bắt tay và xác thực, backend dựng bộ nhớ phiên và nạp các catalog cần thiết. Tất cả xong xuôi rồi truy vấn đầu tiên mới chạy được. Với một truy vấn nặng, chi phí dựng này chẳng đáng kể. Với một truy vấn nhẹ chạy trong 0,03ms, nó lại lớn hơn chính truy vấn nhiều lần — và đó là chỗ tôi tính nhầm.
Một lần tôi đo hớ: quên mất chi phí mở kết nối
Tôi bắt đầu bằng đo một truy vấn nhẹ nhất có thể — SELECT 1 — trong khi tái dùng một kết nối cho tất cả các lần chạy. Dùng pgbench chạy 5000 giao dịch trên một kết nối giữ mở:
tái dùng 1 kết nối: 0,028 ms/truy vấn, 35557 tps
Ba mươi lăm nghìn truy vấn mỗi giây. Tôi định chốt luôn: "vậy ứng dụng gọi vài chục nghìn lần mỗi giây là thoải mái". Nhưng con số này chỉ đúng với điều kiện tôi đo — kết nối đã mở sẵn và dùng lại. Đời thật của nhiều ứng dụng viết cẩu thả không như vậy: chúng mở một kết nối, chạy một truy vấn, đóng, rồi lần sau mở lại từ đầu.
Nên tôi đo lại đúng kịch bản đó bằng cờ -C của pgbench — mở một kết nối mới cho mỗi giao dịch:
mở kết nối mới mỗi lần: 0,667 ms/truy vấn, 1499 tps
Cùng một SELECT 1, cùng một máy, mà thông lượng rơi từ 35557 xuống 1499 tps — chậm gần 23 lần. Truy vấn vẫn là 0,028ms; phần chênh ~0,64ms mỗi lần là chi phí mở kết nối: fork tiến trình, bắt tay, xác thực, dựng phiên. Chi phí đó lớn gấp khoảng 23 lần chính truy vấn. Tôi đã đo đúng truy vấn nhưng quên đo cái đắt nhất: việc mở đường tới truy vấn đó.
Điều đáng nói thêm: đây là đo qua socket nội bộ (unix socket) trong cùng một máy — trường hợp rẻ nhất. Một ứng dụng thật kết nối qua TCP, thường kèm bắt tay TLS, thì chi phí mở còn lớn hơn nữa. Nên con số 23 lần này là cận dưới; trên mạng thật khoảng cách rộng hơn.
max_connections không phải nút "mở rộng"
Nếu mỗi kết nối là một tiến trình, thì có bao nhiêu kết nối cùng lúc cũng có giá. PostgreSQL mặc định giới hạn max_connections = 100. Mỗi backend đang mở chiếm một slot trong giới hạn đó và một lượng bộ nhớ — đo bằng ps, mỗi backend rảnh hiện RSS khoảng 6,4 MB (con số này gồm cả phần shared_buffers ánh xạ dùng chung, nên phần riêng nhỏ hơn, nhưng mỗi tiến trình vẫn là chi phí thật của hệ điều hành).
Điểm mấu chốt: một kết nối rảnh vẫn tốn. Nó giữ một tiến trình, một slot, một phần bộ nhớ, dù không chạy gì. Nên phản xạ "hết kết nối thì cứ tăng max_connections lên 500, 1000" là sai hướng: mỗi kết nối thêm là thêm một tiến trình để hệ điều hành phải lập lịch và cấp bộ nhớ, và hàng trăm tiến trình tranh nhau CPU làm mọi thứ chậm đi vì chuyển ngữ cảnh, chứ không nhanh lên. Con số kết nối tối ưu thường nhỏ hơn nhiều so với số request đồng thời — nó nên xấp xỉ số lõi CPU cộng số đĩa, không phải số người dùng.
Lời giải: pool kết nối
Cả hai vấn đề — chi phí mở lặp lại và số kết nối phình to — có chung một lời giải: connection pool (bể kết nối). Pool giữ sẵn một ít kết nối đã mở; ứng dụng mượn một cái khi cần, dùng xong trả lại thay vì đóng. Không có fork nào cho mỗi truy vấn, nên tránh được cái phí ~0,64ms tôi đo ở trên. Và vì cả trăm request của ứng dụng chia nhau một nhúm kết nối, số kết nối thật tới CSDL không bao giờ vượt trần max_connections, dù ứng dụng có bao nhiêu luồng đi nữa.
Pool có thể nằm trong ứng dụng (như HikariCP của Java trên Spring), hoặc là một tiến trình riêng đứng trước CSDL (như PgBouncer). Cách nào cũng biến pattern "mở–truy vấn–đóng" đắt đỏ thành "mượn–truy vấn–trả" gần như miễn phí. Đây là lý do gần như mọi ứng dụng nghiêm túc đều dùng pool, và tại sao mở kết nối thủ công cho mỗi request là một trong những lỗi hiệu năng phổ biến nhất.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng mở một kết nối cho mỗi truy vấn. Một script nhỏ chạy một lần thì không sao, nhưng trong vòng lặp xử lý hàng nghìn item, hay trong một handler web gọi CSDL, mở–đóng mỗi lần biến chi phí kết nối thành nút thắt. Dùng pool, hoặc ít nhất giữ một kết nối cho suốt một đơn vị công việc.
Hệ quả thứ hai: đo cả đường đi, không chỉ điểm đến. Bài học đo lường ở đây rất giống các bài trước của sê-ri: một con số nhanh chỉ đúng cho điều kiện nó được đo. 0,028ms là thật, nhưng nó là chi phí truy vấn khi kết nối đã sẵn — không phải chi phí một lần gọi CSDL từ ứng dụng nếu ứng dụng đó mở kết nối mới. Con số mang theo: mỗi kết nối PostgreSQL là một tiến trình phải fork; tái dùng kết nối cho 0,028ms mỗi truy vấn (35557 tps), nhưng mở kết nối mới mỗi lần đẩy nó lên 0,667ms (1499 tps) — chi phí kết nối ~0,64ms lấn át truy vấn ~23 lần, và đây mới là socket nội, qua TCP/TLS còn hơn; giải pháp là pool kết nối, không phải tăng max_connections. Trước khi tin một hệ "làm được N nghìn truy vấn mỗi giây", hỏi xem N đó đo với kết nối tái dùng hay mở mới.
Thử ba mươi giây
Nếu có psql và pgbench (đi kèm PostgreSQL), thử ngay: echo "SELECT 1;" > q.sql, rồi chạy pgbench -n -c 1 -t 2000 -f q.sql (tái dùng kết nối) và pgbench -n -C -c 1 -t 2000 -f q.sql (cờ -C mở kết nối mới mỗi lần). So hai dòng tps — bạn sẽ thấy phiên bản -C chậm hàng chục lần, và khoảng cách đó chính là chi phí mở kết nối mà một truy vấn đơn lẻ không bao giờ cho bạn thấy. Nếu không có pgbench, chỉ cần time psql -c "SELECT 1" vài lần và để ý phần lớn thời gian không nằm ở truy vấn mà ở việc dựng kết nối.