Jepsen, dự án của Kyle Kingsbury chuyên kiểm chứng độ an toàn của database, vừa công bố báo cáo về ParticleDB, một database đa mô hình viết bằng Rust và hướng đến workload AI. Trang chủ của nó từng ghi "Full ACID", tương thích Postgres, nhanh hơn Postgres nhiều bậc và được "formally proven" bằng TLA+. Kết quả kiểm thử từ phiên bản 1.1.4 đến 1.1.18.11.rc-22 là 96 lỗi, và hầu hết không cần đến lỗi phần cứng hay mạng để xuất hiện: chỉ cần một server và một client.
Có vài điều cần nói cho công bằng. Nghiên cứu này do chính Orkes, công ty đứng sau ParticleDB, tài trợ. Đến rc-22 thì tất cả trừ mười vấn đề có vẻ đã được xử lý. Orkes tuyên bố bản 1.1.19-rc.13 giải quyết hết phần còn lại, nhưng Jepsen chưa đánh giá bản đó. Các phiên bản được test cũng là bản sau một đợt refactor lớn; bản 1.0.0 mà Orkes và khách hàng đang chạy chỉ được Jepsen đánh giá sơ bộ và trông tốt hơn nhiều.
Dù bạn không bao giờ dùng ParticleDB, báo cáo vẫn đáng đọc vì hai lý do: nó là danh sách kiểm tra khi đánh giá một database mới, và nó cho thấy một kỹ thuật test rất rẻ nhưng hiệu quả.
Những gì Jepsen tìm thấy
Một vài ví dụ đại diện từ báo cáo:
WHERE 4 = 5trả về các dòng có primary key là 5, cònWHERE TRUEkhông khớp dòng nào.- Ràng buộc
NOT NULLvàUNIQUEkhông có tác dụng, primary key có thể trùng hoặc null. - Bảng đã
DROPcó thể xuất hiện lại dưới dạng méo mó. - Transaction bị abort vẫn có thể commit, hoặc thất bại mà hiệu ứng vẫn nhìn thấy được.
- Hai transaction đồng thời có thể làm server deadlock, một số bản tự crash sau vài phút chạy mà không có log lỗi.
- Ghi đè lên WAL hoặc mô phỏng đọc ghi sai vị trí có thể gây mất một phần, thậm chí toàn bộ dữ liệu.
Về transaction, mỗi chế độ đều dính gần như mọi bất thường mà công cụ phát hiện được. Ở bản cuối được test, chế độ occ mặc định vẫn mất dữ liệu đã commit trong vận hành bình thường. Chế độ serializable an toàn hơn đáng kể nhưng vẫn cho phép stale read, và khi process bị kill thì xuất hiện lost update.
Khuyến nghị của Jepsen rất thẳng: dừng triển khai thêm và di chuyển sang database khác càng sớm càng tốt.
Ôn lại vài khái niệm isolation
Báo cáo dùng nhiều thuật ngữ từ lý thuyết isolation. Ba khái niệm dễ gặp nhất trong thực tế:
- Lost Update (P4): hai transaction cùng đọc rồi cùng ghi một giá trị, một bên âm thầm đè mất thay đổi của bên kia. Ví dụ điển hình là hai request cùng trừ tồn kho.
- Aborted Read (G1a): đọc được dữ liệu của một transaction mà sau đó đã bị rollback.
- Stale Read: đọc được giá trị cũ dù một transaction khác đã commit xong trước đó. Serializable thuần túy cho phép điều này; Strong Serializable thì không.
Snapshot Isolation cũng không phải là "fully isolated", dù nó ngăn được nhiều thứ. Đó là lý do Jepsen thấy lạ khi "Full ACID" được đặt ngay trên dòng ghi "snapshot isolation".
Với transaction trừ tồn kho, code dưới đây an toàn hay không hoàn toàn tùy mức isolation và độ tin cậy của database:
BEGIN;
SELECT stock FROM products WHERE id = 42;
-- ứng dụng tính new_stock = stock - 1
UPDATE products SET stock = :new_stock WHERE id = 42;
COMMIT;
Nếu không chắc chắn, hãy viết phép cập nhật nguyên tử SET stock = stock - 1 WHERE id = 42 AND stock > 0, hoặc khóa dòng rõ ràng bằng SELECT ... FOR UPDATE.
Differential testing: 2.000 dòng tìm ra rất nhiều lỗi
Phần hay nhất với dev là workload compare. Ý tưởng rất đơn giản: sinh ngẫu nhiên schema và câu SQL, chạy trên cả ParticleDB và PostgreSQL, rồi so kết quả. Khi khác nhau, công cụ tự thu nhỏ về chuỗi câu lệnh ngắn nhất còn tái hiện được lỗi.
Chính Kingsbury viết rằng cách này "không đáng lẽ phải hiệu quả": hai database tương thích Postgres có thể khác nhau hợp lệ ở nhiều điểm, không gian SQL lại khổng lồ. Vậy mà khoảng hai nghìn dòng generative test đã tìm được rất nhiều lỗi, kèm ví dụ dễ tái hiện.
Bạn có thể áp dụng tư duy này cho một parser, hàm tính giá hay bản viết lại của một service cũ. Phiên bản tối giản viết bằng Python với hypothesis có thể trông thế này:
from hypothesis import given, strategies as st
ints = st.integers(min_value=-100, max_value=100)
@given(st.lists(ints, max_size=20), ints)
def test_filter_matches_reference(rows, needle):
reset_tables(new_db, reference_db, rows)
sql = "SELECT v FROM t WHERE v = %s ORDER BY v"
assert query(new_db, sql, needle) == query(reference_db, sql, needle)
reset_tables và query là helper bạn tự viết. Hệ thống tham chiếu có thể là một database đã trưởng thành, bản code cũ khi đang migrate, hoặc một mô hình đơn giản trong bộ nhớ. hypothesis cũng có sẵn khả năng shrink nên ví dụ thất bại thường rất ngắn.
Báo cáo cũng nêu giới hạn của kỹ thuật này. Khi lỗi lớn đã sửa xong và chỉ còn các khác biệt nhỏ nhưng hợp lệ, test thường dừng lại ở chúng rồi tốn thời gian thu nhỏ, có thể bỏ lỡ lỗi nghiêm trọng hơn. Ví dụ, Postgres kiểm tra unique theo từng dòng khi update, còn ParticleDB kiểm tra sau khi cập nhật xong. Muốn dùng lâu dài thì bạn phải loại trừ các khác biệt đã biết. Ngoài ra nó chỉ kiểm tra hành vi tuần tự, nên không thay thế test đồng thời.
Câu hỏi về quy trình và tài liệu
Kingsbury đặt ra câu hỏi rất cơ bản: làm sao thêm được NOT NULL mà không có test nào thử chèn NULL? Orkes nói đã chạy test harness Postgres của Jepsen và SQLancer, trong khi harness đó thất bại ngay trên bản 1.1.4. Báo cáo chỉ dừng ở phỏng đoán: có thể test không được chạy trước khi phát hành, không chạm đến chức năng cơ bản, hoặc việc kiểm thử đã được giao cho LLM. Nguyên nhân thật sự không được xác nhận.
Khuyến nghị thì áp dụng được cho mọi đội: có bộ test ví dụ cho từng tính năng, quan sát test thất bại khi tính năng chưa có để biết test thật sự đo được gì, chạy trước mỗi lần phát hành, rồi mới đến generative test. Có thể cố ý làm hỏng cơ chế an toàn để xem test còn bắt được không.
Tài liệu cũng là một phát hiện. Chế độ transaction được mô tả mơ hồ kiểu "safe under concurrency", ví dụ dùng username mặc định sai, dependency Java không tồn tại, và không có đặc tả TLA+ nào được công bố trong suốt quá trình hợp tác. Tài liệu trông trau chuốt không còn là dấu hiệu đáng tin của độ trưởng thành. Điều này càng đáng lưu ý khi việc sinh ra tài liệu dài và đẹp ngày càng rẻ.
Checklist khi đánh giá database mới
Trước khi đưa một database mới, đặc biệt là loại "thay thế Postgres và nhanh hơn nhiều lần", vào production:
- Tìm định nghĩa isolation cụ thể. "Safe" hay "ACID" chưa đủ; cần tên mức isolation và các hiện tượng bị cấm. Xem thử có báo cáo Jepsen hoặc test độc lập nào không.
- Tự chạy test ngày đầu. Thử
NOT NULL,UNIQUE, rollback,NULL,NaN, số thực lớn và chuỗi có độ dài cố định với chính schema của bạn. - So sánh với hệ thống đang chạy. Ghi lại truy vấn thật, phát lại song song và so kết quả.
- Thử kill process. Dùng
kill -9lúc đang ghi rồi kiểm tra mọi transaction đã nhận COMMIT còn nguyên hay không. - Hỏi ý nghĩa của "fsync". Tùy chọn WAL mặc định là gì,
COMMITcó chờ flush thật không. Riêng ParticleDB, nguồn tài liệu còn mâu thuẫn nhau về điểm này. - Đọc số benchmark cùng với cấu hình. Chế độ không fsync, không isolation lúc nào cũng cho con số đẹp. Con số đó đáng giá ra sao còn tùy dữ liệu có còn đó hay không.
Bài viết được AI của DevViet viết lại bằng tiếng Việt từ một bài viết/thảo luận tiếng Anh trên Lobsters, có thể chưa chính xác tuyệt đối. Nguồn: Jepsen: ParticleDB 1.1.4