Một thủ quỹ nhận tiền có thể làm hai cách. Cách bền: mỗi lần nhận, ghi ngay vào cuốn sổ cái rồi đóng dấu xác nhận — chắc chắn không mất, nhưng đóng dấu tốn thời gian. Cách nhanh: cứ nhận và hứa "lát nữa đóng dấu một thể" — nhanh hơn, nhưng nếu cháy nhà đúng lúc đó, vài khoản chưa đóng dấu bay mất. Điều thú vị: cái dấu đóng nhanh hay chậm quyết định khoảng cách giữa hai cách. Dấu mực khô tức thì thì cách bền gần như không thua; dấu sáp phải chờ nguội thì cách bền chậm hơn hẳn. Cơ sở dữ liệu đóng dấu độ bền bằng lệnh fsync, và bài này là lần tôi đo tốc độ đóng dấu trên một cái dấu khô tức thì, rồi suýt kết luận nhầm cho mọi cái dấu.

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. Đó chính là cú đóng dấu xác nhận vào cuốn sổ cái.

Đó 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.

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ì — cái dấu mực khô ngay — 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 trật 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. 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ề đến code hằng ngày

Điều đầ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.

Điều 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.

Và cái mẫu hình WAL — ghi log tuần tự bền trước, cập nhật cấu trúc thật sau — không phải sáng chế riêng của PostgreSQL; nó là một trong những ý tưởng nền tảng nhất của hệ thống đáng tin cậy, và bạn đã dùng nó dù có biết hay không. Filesystem của Linux (ext4, XFS) là journaling filesystem — ghi journal trước rồi mới sửa cấu trúc thư mục, đúng để sống sót qua mất điện. Redis có AOF (append-only file) với đúng lựa chọn appendfsync always/everysec/no y hệt synchronous_commit. Kafka bền được là nhờ ghi log tuần tự rồi fsync. Ngay cả một BufferedWriter bạn quên flush() cũng là phiên bản thu nhỏ của cùng câu hỏi "dữ liệu đã thật sự xuống chưa". Mẫu số chung đáng mang theo: độ bền luôn đến từ một lần fsync tường minh xuống lưu trữ tuần tự, và cái giá của nó do tốc độ tầng lưu trữ quyết định — nên khi cân nhắc đánh đổi bền-đổi-nhanh, câu hỏi đầu tiên không phải "bật hay tắt" mà là "đĩa bên dưới nhanh cỡ nào", vì chính nó định đoạt đánh đổi đáng giá tới đâu. Gói lại bằng số: 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; gộp nhiều ghi vào một giao dịch cắt số fsync và nhanh gấp trăm lần mà vẫn bền.

Muốn tự thấy thì 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.