Bài giao dịch lo chữ A (Atomicity), rồi ta đo chữ I (Isolation). Giờ tới chữ cuối — D trong ACID: Durability (độ bền). Khi COMMIT trả về "thành công", dữ liệu phải sống sót qua cả mất điện. Cơ chế làm điều đó là WAL, và nó có một cái giá — cái giá mà cả sê-ri vẫn nhắc bằng câu "nhanh vô nghĩa nếu chưa hỏi bền chưa". Bài này đo chính cái giá đó, và vấp một con số khiến tôi suýt kết luận sai về độ bền.
WAL: ghi nhật ký trước, rồi mới báo thành công
PostgreSQL không ghi thẳng thay đổi vào file bảng khi bạn COMMIT. Thay vào đó, nó dùng WAL (Write-Ahead Log — nhật ký ghi-trước): trước khi đụng tới trang dữ liệu thật, nó ghi mô tả thay đổi vào một file log tuần tự. Và khi COMMIT, nó fsync phần WAL đó xuống đĩa — ép hệ điều hành thực sự viết xuống vật lý — trước khi trả về "thành công" cho bạn.
Đó là điều làm cho một COMMIT thành bền: khi lệnh trả về OK, thay đổi đã nằm an toàn trong WAL trên đĩa. Nếu mất điện một giây sau đó, lúc khởi động lại PostgreSQL phát lại (replay) WAL và khôi phục mọi commit đã báo thành công. Trang dữ liệu trong file bảng có thể chưa được cập nhật (chúng được ghi sau, theo checkpoint) — nhưng không sao, WAL là nguồn sự thật để dựng lại. Ghi log tuần tự (nhanh) trước, cập nhật trang ngẫu nhiên (chậm) sau: đó là mẹo cốt lõi khiến vừa bền vừa không quá chậm.
Đo: cái giá của fsync
fsync mỗi commit là an toàn nhưng tốn — phải đợi đĩa xác nhận đã viết. PostgreSQL cho một nút chỉnh: synchronous_commit. Bật (mặc định) thì đợi fsync xong mới báo commit; tắt thì báo commit ngay, để fsync diễn ra sau. Tôi đo thông lượng bằng pgbench, 3000 commit đơn lẻ (mỗi commit một INSERT):
| Chế độ | Commit/giây |
|---|---|
synchronous_commit = on (fsync mỗi commit) |
13.238 |
synchronous_commit = off (không đợi fsync) |
26.276 |
Tắt fsync nhanh hơn khoảng 2 lần. Rồi tôi đo một cách khác: nhét 3000 hàng vào một giao dịch duy nhất thay vì 3000 commit riêng. Cả lô chỉ mất ~2 mili giây — vì nó chỉ cần một fsync cho toàn bộ, thay vì 3000. So với 3000 commit riêng (~227 ms ở chế độ on), gộp lại nhanh hơn khoảng 100 lần. Số fsync, chứ không phải số hàng, mới là thứ quyết định chi phí ghi. Chi tiết này giải thích một hiện tượng hay gặp: nạp một triệu hàng bằng một triệu câu INSERT tự-commit có thể mất hàng phút, trong khi cùng số hàng nạp trong vài giao dịch lớn (hoặc bằng COPY) chỉ mất vài giây — khác biệt không nằm ở lượng dữ liệu mà ở số lần phải đợi đĩa xác nhận.
Một lần tôi đo hớ: fsync "rẻ" vì đĩa ảo
Cái bẫy nằm ở con số 2 lần. Tôi tắt synchronous_commit và chờ đợi một cú nhảy vọt — fsync nổi tiếng là nút cổ chai của commit đơn lẻ, sách vở nào cũng nói bỏ fsync sẽ nhanh hơn rất nhiều. Nhưng đo ra chỉ 2 lần. Tôi suýt viết kết luận: "độ bền gần như miễn phí, fsync chẳng tốn mấy đâu".
May là kỷ luật của sê-ri kéo tôi lại: một con số thấp hơn kỳ vọng nhiều cũng đáng nghi như một con số quá cao — hỏi ngay "có biến ẩn nào không?". Và có: đĩa của container. Phép đo này chạy trong một container trên máy ảo, nơi "đĩa" thực chất là một đĩa ảo rất nhanh (gần như bộ nhớ). Trên đĩa ảo đó, một fsync gần như tức thì — nên bỏ nó đi chỉ tiết kiệm được chút ít, ra 2 lần. Trên một ổ cứng quay thật, một fsync tốn ~5-10 mili giây (phải đợi đầu đọc quay tới đúng chỗ và xác nhận ghi), nên synchronous_commit=on sẽ bị giới hạn ở khoảng vài trăm commit/giây, còn off vẫn hàng chục nghìn — chênh nhau hàng chục tới hàng trăm lần, không phải 2.
Cái tôi đo hớ là để môi trường đo giấu mất cái giá thật của độ bền. Đây đúng là bài học xuyên suốt sê-ri hệ điều hành trước: môi trường ảo hóa (ở đây là tốc độ đĩa) là một biến ẩn khổng lồ, và một phép đo tốc độ ghi mà không biết mình đang chạy trên đĩa gì thì gần như vô nghĩa để ngoại suy. Con số 2 lần đúng cho môi trường này, nhưng sai hoàn toàn nếu tôi mang nó đi kết luận về một máy chủ chạy đĩa cứng thật. Bài học đo lường: khi đo chi phí I/O, tốc độ tầng lưu trữ bên dưới là điều đầu tiên phải biết, nếu không con số bạn đo chỉ nói về máy của bạn, không về hệ thống thật.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên, và trấn an: synchronous_commit = off không làm hỏng cơ sở dữ liệu. Nhiều người sợ tắt nó vì tưởng "mất fsync là mất an toàn". Không phải: off chỉ nghĩa là commit không đợi fsync, nên nếu máy chủ crash đúng khoảnh khắc, bạn có thể mất vài commit cuối cùng (những cái chưa kịp fsync). Nhưng cơ sở dữ liệu vẫn nhất quán — không hỏng, không rách nửa chừng, chỉ lùi lại vài giao dịch cuối. Với nhiều ứng dụng (log, phân tích, dữ liệu tái tạo được), đánh đổi đó rất đáng để lấy thông lượng gấp nhiều lần. Chỉ đừng nhầm nó với fsync = off — cái đó thật sự nguy hiểm, có thể làm hỏng dữ liệu khi crash.
Hệ quả thứ hai là mẹo tối ưu ghi mạnh nhất: gộp nhiều thao tác ghi vào một giao dịch. Vì chi phí thật là số lần fsync, không phải số hàng, chuyển từ 3000 commit riêng thành một giao dịch 3000 hàng cắt từ 3000 fsync xuống 1 — nhanh gấp cả trăm lần mà vẫn hoàn toàn bền. Đây là lý do nạp dữ liệu hàng loạt phải bọc trong giao dịch (hoặc dùng COPY), và vì sao một vòng lặp INSERT từng dòng tự-commit là cách nạp dữ liệu chậm nhất có thể — mỗi dòng một fsync, mỗi fsync một lần đợi đĩa.
Hệ quả thứ ba là bài học đo lường, cũng là câu hỏi trung tâm của cả sê-ri. Con số mang theo: COMMIT chỉ bền khi WAL đã fsync xuống đĩa; fsync mỗi commit là cái giá của độ bền, và cái giá đó phụ thuộc HOÀN TOÀN vào tốc độ đĩa — ở container này chỉ 2 lần, trên ổ cứng thật là hàng chục lần. "Nhanh" luôn phải đi kèm "bền tới mức nào" và "đo trên đĩa gì"; một con số thông lượng ghi tách khỏi tầng lưu trữ bên dưới là con số không thể ngoại suy.
Thử ba mươi giây
Trong psql, kiểm hai thứ: SHOW synchronous_commit; (đang bật hay tắt) và SELECT pg_current_wal_lsn(); (vị trí hiện tại trong WAL — một địa chỉ tăng dần). Chạy vài INSERT/UPDATE rồi gọi lại pg_current_wal_lsn() — bạn sẽ thấy nó tiến lên, đó là WAL đang lớn dần theo mỗi thay đổi. Rồi thử đo: chạy một vòng 1000 INSERT từng dòng tự-commit, và so với 1000 hàng trong một BEGIN...COMMIT — cái sau nhanh hơn hẳn vì gộp fsync. Và nhớ: nếu bạn đo trên máy ảo hay container, con số ghi có thể "đẹp" một cách giả tạo vì đĩa ảo nhanh — muốn biết thật, phải đo trên đúng tầng lưu trữ mà production dùng.