Bạn có một tác vụ ghi nhiều: cập nhật hàng loạt bản ghi, xử lý một hàng đợi công việc. Mỗi thay đổi một câu lệnh, mỗi câu một giao dịch. Nếu nó chậm hơn mong đợi, thủ phạm thường không phải việc ghi dữ liệu, mà là một chi tiết ẩn: mỗi lần COMMIT phải đồng bộ (fsync) nhật ký giao dịch xuống đĩa. Số giao dịch mỗi giây của bạn bị chặn không phải bởi tốc độ ghi, mà bởi số lần fsync đĩa chịu được. Bài này đo nút cổ chai đó, và ba cách vượt nó — một trong số đó là cái bẫy tôi suýt sa vào.
Mỗi commit là một lần fsync
Để một giao dịch bền — đã báo thành công thì mất điện vẫn còn — PostgreSQL phải chắc chắn nhật ký của nó (WAL) đã nằm thật sự trên đĩa trước khi báo về cho client. Việc "chắc chắn đã xuống đĩa" đó là lệnh fsync, và với synchronous_commit=on (mặc định), mỗi COMMIT chờ một fsync hoàn tất. Như bài WAL và độ bền đã đo, fsync là điểm mà mọi thứ phải chờ đĩa vật lý xác nhận.
Hệ quả: nếu mỗi thao tác ghi là một giao dịch riêng, throughput của bạn = số fsync mỗi giây mà đĩa cho phép. Trên một ổ đĩa quay, đó có thể chỉ vài trăm; trên SSD tốt, vài nghìn. Việc ghi dòng dữ liệu vào bộ nhớ nhanh hơn thế rất nhiều — cái chậm là chờ đĩa vật lý xác nhận sau mỗi commit. Đây là một nút cổ chai đặc biệt vì nó không giảm khi bạn tối ưu truy vấn hay thêm RAM: nó là giới hạn cơ học của việc ghi bền xuống một thiết bị lưu trữ, và chỉ giảm khi bạn fsync ít lần hơn.
Đo: fsync mỗi commit chặn throughput
Tôi dùng pgbench chạy các giao dịch một-câu-lệnh, đo số giao dịch mỗi giây (tps). Đầu tiên, một luồng, synchronous_commit=on:
1 luồng, sync=on: 11835 tps (mỗi commit một fsync)
Rồi tắt fsync mỗi commit bằng synchronous_commit=off:
1 luồng, sync=off: 24726 tps (~2.1 lần nhanh hơn)
Gấp đôi throughput chỉ bằng đổi một dòng cấu hình, không sửa một dòng code nào. Đây chính là lúc tôi suýt viết sai cả bài.
Một lần tôi đo hớ: "tắt cho nhanh" là đánh đổi độ bền
Thấy con số nhảy từ 11835 lên 24726 tps, phản xạ đầu tiên của tôi là "vậy cứ đặt synchronous_commit=off cho nhanh". Nhưng đó là kết luận thiếu một vế sống còn: off không miễn phí — nó đổi độ bền lấy tốc độ. Với off, PostgreSQL báo commit thành công ngay, rồi mới fsync WAL sau đó một chút. Nếu máy sập trong khoảng đó, những giao dịch đã được báo "thành công" cho client nhưng chưa kịp xuống đĩa sẽ biến mất — dù client tưởng chúng đã an toàn.
Đây đúng là bài học xuyên suốt sê-ri: "nhanh" vô nghĩa nếu chưa hỏi "đúng và bền chưa". Một con số tps cao hơn không phải lúc nào cũng tốt hơn; nó có thể là bạn vừa lặng lẽ bỏ một đảm bảo mà ứng dụng đang dựa vào. Với dữ liệu chịu mất được vài giây cuối khi sập (log phân tích, đếm lượt xem), off là đánh đổi hợp lý. Với tiền bạc, đơn hàng, giao dịch tài chính — mất một commit đã xác nhận là lỗi nghiêm trọng, và off là sai.
May thay, có cách vừa nhanh vừa giữ độ bền. Tôi đo tiếp: cũng synchronous_commit=on, nhưng cho 8 luồng commit đồng thời:
8 luồng, sync=on: 37689 tps (~3.2 lần nhanh hơn — mà vẫn bền!)
Cao hơn cả off một luồng, mà không hề hy sinh độ bền — một kết quả thoạt nghe nghịch lý cho tới khi hiểu cơ chế bên dưới. Bí mật là group commit: khi nhiều giao dịch cùng đợi commit một lúc, PostgreSQL gom WAL của tất cả chúng vào một lần fsync duy nhất. Chi phí fsync — vốn là nút cổ chai — được chia sẻ giữa nhiều commit thay vì mỗi commit một lần. Càng nhiều luồng ghi đồng thời, mỗi fsync càng phục vụ nhiều commit, throughput càng cao — tất cả trong khi vẫn on, vẫn bền.
Cách thứ ba, khi bạn kiểm soát được cấu trúc công việc: gom nhiều thay đổi vào ít giao dịch hơn. Nếu 20.000 thay đổi có thể nằm trong một giao dịch, đó là một fsync thay vì 20.000. Cùng độ bền, chi phí commit chia cho 20.000. Đây chính là một phần lý do COPY và INSERT gom giao dịch nhanh hơn hàng chục lần so với INSERT lẻ: chúng biến hàng vạn commit+fsync thành một. Nhìn từ góc này, ba kỹ thuật — gom giao dịch, group commit, và nạp khối — đều là cùng một ý tưởng: đừng trả phí fsync cho từng dòng.
Một lưu ý trung thực về môi trường
Các con số trên đo trong container, nơi đĩa là đĩa máy ảo và fsync rất rẻ. Vì thế khoảng cách on so với off ở đây chỉ ~2 lần. Trên một đĩa vật lý thật, nơi mỗi fsync tốn vài mili giây, khoảng cách này lớn hơn nhiều — synchronous_commit=on một luồng có thể bị chặn ở vài trăm tps (đúng bằng số fsync/giây của đĩa), trong khi off hay group commit vẫn chạy hàng nghìn. Nghĩa là trên phần cứng thật, cả sức cám dỗ tắt fsync lẫn lợi ích của group commit đều lớn hơn hẳn con số tôi đo. Đây là lý do đo trên đúng phần cứng đích quan trọng: một VM có fsync rẻ sẽ giấu đi cái nút cổ chai mà máy chủ thật gặp phải.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: khi ghi nhiều, giảm số fsync, đừng bỏ fsync. Gom nhiều thay đổi vào một giao dịch, hoặc để nhiều luồng ghi đồng thời cho group commit làm việc — cả hai tăng throughput mà giữ nguyên độ bền. Đây gần như luôn là lựa chọn đúng trước khi nghĩ tới việc tắt đảm bảo.
Hệ quả thứ hai: chỉ dùng synchronous_commit=off khi bạn thật sự chấp nhận mất vài giao dịch cuối. Nó là một công cụ hợp lệ cho dữ liệu không quan trọng từng bản ghi, nhưng phải là quyết định có ý thức về độ bền, không phải một mẹo tăng tốc vô hại. Con số mang theo: mỗi commit với synchronous_commit=on phải fsync WAL, nên throughput bị chặn bởi số fsync/giây của đĩa — sync=off cho gấp đôi (24726 so 11835 tps) nhưng ĐÁNH ĐỔI độ bền, còn group commit (8 luồng sync=on: 37689 tps) và gom nhiều thay đổi vào ít commit đều nhanh hơn mà VẪN bền. Trước khi tắt một đảm bảo để lấy tốc độ, hỏi xem có cách nào nhanh mà không phải bỏ nó không — thường là có.
Thử ba mươi giây
Nếu có pgbench, chạy pgbench -i để khởi tạo rồi so hai lần: pgbench -c 1 -T 5 (mặc định synchronous_commit=on) và sau khi đặt ALTER SYSTEM SET synchronous_commit=off; SELECT pg_reload_conf(); chạy lại — xem tps chênh bao nhiêu. Rồi bật lại on và chạy pgbench -c 8 -j 8 -T 5: bạn sẽ thấy tps với 8 luồng đồng thời (group commit) vượt xa một luồng, mà vẫn on. Ba phép đo đó, trong nửa phút, cho bạn thấy fsync mỗi commit là nút cổ chai — và group commit gỡ nó mà không đụng tới độ bền, trong khi off thì gỡ bằng cách bỏ độ bền.