Bạn thậm chí có cần một cơ sở dữ liệu?
Backend·Hacker News·0 lượt xem

Bạn thậm chí có cần một cơ sở dữ liệu?

Do you even need a database?

Chúng tôi đã xây dựng cùng một máy chủ HTTP trong Go, Bun và Rust bằng hai chiến lược lưu trữ: đọc tệp theo mọi yêu cầu hoặc tải mọi thứ vào bộ nhớ. Sau đó, chúng tôi chạy điểm chuẩn thực sự. Kết quả thú vị hơn bạn mong đợi.

Cơ sở dữ liệu chỉ là các tệp. SQLite là một tệp duy nhất trên đĩa. PostgreSQL là một thư mục chứa các tệp có một tiến trình ở phía trước chúng. Mọi cơ sở dữ liệu bạn từng sử dụng đều đọc và ghi vào hệ thống tệp, giống hệt như mã của bạn khi nó gọi open().

Vì vậy, câu hỏi không phải là có nên sử dụng tệp hay không. Bạn luôn sử dụng các tập tin. Câu hỏi đặt ra là nên sử dụng các tập tin của cơ sở dữ liệu hay của riêng bạn. Và đối với nhiều ứng dụng, đặc biệt là những ứng dụng ở giai đoạn đầu, câu trả lời có thể là: của riêng bạn.

Bây giờ, rõ ràng là chúng tôi yêu thích cơ sở dữ liệu. Chúng tôi đang xây dựng DB Pro, một ứng dụng cơ sở dữ liệu dành cho Mac, Windows và Linux. Nhưng câu trả lời trung thực cho "bạn có cần một cái không?" phụ thuộc vào quy mô của bạn và hầu hết các ứng dụng đều nhỏ hơn mọi người nghĩ. Chúng tôi đã thử nghiệm điều này. Chúng tôi đã xây dựng cùng một máy chủ HTTP trong Go, Bun và Rust, sử dụng hai chiến lược lưu trữ và xử lý chúng bằng wrk. Các con số trông như thế này.

Thiết lập

Ba tệp phẳng: users.jsonl, products.jsonl, orders.jsonl. Định dạng là JSON được phân tách bằng dòng mới (JSONL): một bản ghi trên mỗi dòng, được thêm vào khi ghi. Mỗi tệp chứa một loại thực thể.

Hai điểm cuối HTTP: POST /users để tạo, GET /users/:id để tìm nạp theo ID. Chúng tôi đã điểm chuẩn đường dẫn GET. Đọc là nơi các chiến lược khác nhau.

Phương pháp 1: Đọc tệp mọi lúc

Điều đơn giản nhất bạn có thể làm: khi có yêu cầu dành cho người dùng abc-123, hãy mở tệp, quét từng dòng, phân tích từng dòng dưới dạng JSON, kiểm tra ID. Quay lại khi bạn tìm thấy kết quả phù hợp.

Đi:

TypeScript (Bun):

Rỉ sét:

Đây là O(n). Mọi yêu cầu đều đọc toàn bộ tệp từ trên xuống dưới, trung bình quét một nửa tệp đó trước khi tìm thấy mục tiêu. Tệp càng lớn thì mọi yêu cầu càng chậm.

Phương pháp 2: Tải vào bộ nhớ

Khi khởi động, hãy đọc toàn bộ tệp một lần và lưu trữ mọi bản ghi trong bản đồ băm được khóa bằng ID. Viết vào cả bản đồ và tập tin. Các lần đọc là một lần tra cứu bản đồ.

Tệp là kho lưu trữ hỗ trợ lâu dài. Bản đồ là chỉ mục. Nếu quá trình khởi động lại, hãy tải lại từ tệp.

Đi:

TypeScript (Bun):

Rỉ sét:

Đường dẫn đọc hiện là O(1) ở mọi tỷ lệ. sync.RWMutex trong Go và RwLock trong Rust cho phép nhiều trình đọc tiến hành song song, do đó các yêu cầu đồng thời không chặn lẫn nhau.

Phương pháp 3: Tìm kiếm nhị phân trên đĩa

Điều gì sẽ xảy ra nếu bạn cần các lần đọc không tải mọi thứ vào RAM nhưng cũng không quét toàn bộ tệp? Nền tảng ở giữa: sắp xếp tệp dữ liệu theo ID, xây dựng chỉ mục có chiều rộng cố định dọc theo nó và tìm kiếm nhị phân lập chỉ mục bằng cách sử dụng ReadAt. Mỗi lần tra cứu thực hiện tìm kiếm O(log n) (khoảng 20 cho 1 triệu bản ghi), sau đó đọc chính xác một bản ghi từ tệp dữ liệu.

Định dạng chỉ mục rất đơn giản: một dòng trên mỗi bản ghi, chính xác là 58 byte: <36-char UUID>:<Độ lệch byte 20 chữ số trong tệp dữ liệu>\n. Chiều rộng cố định có nghĩa là bạn có thể chuyển đến bất kỳ mục nhập nào chỉ bằng một ReadAt(buf, entryIndex * 58).

Tệp dữ liệu phải được sắp xếp theo ID trước khi tạo chỉ mục. Chúng tôi sắp xếp một lần vào thời điểm ban đầu hoặc dưới dạng bước di chuyển một lần trên tệp JSONL hiện có. Việc thêm các bản ghi mới sẽ phá vỡ sự sắp xếp, do đó, trong hệ thống thực, bạn sẽ xây dựng lại chỉ mục theo định kỳ hoặc giữ một bộ đệm ghi trước chưa được sắp xếp và hợp nhất nó vào. Mẫu hợp nhất đó chính là chức năng của cây LSM.

Điểm chuẩn

Chúng tôi đã tạo ba tập dữ liệu (bản ghi 10k, 100k và 1M) và sử dụng wrk để chạy 10 giây tải đối với mỗi máy chủ: 4 luồng, 50 kết nối đồng thời, các yêu cầu GET ngẫu nhiên chọn từ danh sách ID thực được lấy mẫu.

Tất cả máy chủ đều chạy trên cùng một máy (Apple M1 Mac mini, macOS 15). Tới 1.26, Bun 1.3, Rust 1.94 (bản phát hành).

Chúng tôi cũng đã thử nghiệm thêm hai phương pháp tiếp cận trong Go: tìm kiếm nhị phân dựa trên tệp được sắp xếp trên đĩa và SQLite bằng cách sử dụng modernc.org/sqlite (thuần Go, không có CGO). Tìm kiếm nhị phân sử dụng tệp chỉ mục có chiều rộng cố định (58 byte cho mỗi mục nhập: <uuid>:<offset>) để thực hiện cuộc gọi O(log n) ReadAt, sau đó tìm kiếm trực tiếp đến bản ghi phù hợp. Không có dữ liệu nào được tải vào RAM.

Kết quả

Số yêu cầu mỗi giây (càng cao càng tốt)

10k bản ghi100k bản ghi1M bản ghi
Đi: tuyến tính quét7838523
Đi: tìm kiếm nhị phân (đĩa)45.74241.66138.866
SQLite (Đi)26.00025.50725.085
Đi: trong bộ nhớ bản đồ97.04098.27797.829
Bun: tuyến tính quét4696119
Bun: trong bộ nhớ bản đồ106.064107.058105.367
Rỉ sét: quét tuyến tính2,88325152
Rỉ sét: trong bộ nhớ bản đồ163.687155.364169.106

Độ trễ trung bình trên mỗi yêu cầu (càng thấp càng tốt)

10k bản ghi100k bản ghi1M hồ sơ
Đi: quét tuyến tính84 mili giây552 mili giây1.010 mili giây
Đi: tìm kiếm nhị phân (đĩa)1,2 mili giây1,4 mili giây1,4 mili giây
SQLite (Đi)2,0 mili giây2,0 mili giây2,1 mili giây
Đi: trong bộ nhớ bản đồ497µs571µs584µs
Bun: tuyến tính quét101 mili giây754 mili giây1.060 mili giây
Bánh bao: trong bộ nhớ bản đồ449µs443µs463µs
Rỉ sét: tuyến tính quét17 mili giây195 mili giây753 mili giây
Rỉ sét: trong bộ nhớ bản đồ231µs482µs221µs

Một số điều đáng lưu ý.

Quét tuyến tính suy giảm tuyến tính. Với 1 triệu bản ghi, Go đang phân phát 23 yêu cầu mỗi giây và mỗi yêu cầu Bun trung bình mất hơn một giây. Tại thời điểm đó, bạn không điều chỉnh hiệu suất mà đang giải thích cho người dùng lý do trang không tải.

Tìm kiếm nhị phân trên đĩa nhanh và phẳng. 45k req/s ở 10k bản ghi, 38k req/s ở 1 triệu bản ghi. Đó chỉ là mức giảm 15% khi tập dữ liệu tăng 100 lần. Bộ đệm của trang hệ điều hành thực hiện rất nhiều công việc ở đây: sau một thời gian khởi động, tệp chỉ mục 566KB cho các bản ghi 10k hoàn toàn nằm gọn trong bộ đệm. Đối với các bản ghi 1M, chỉ mục là ~55 MB, nhưng các cấp cao nhất của tìm kiếm nhị phân luôn đạt cùng độ lệch ở gần giữa tệp, do đó các trang đó luôn nóng bất kể bạn đang tìm kiếm khóa nào. Mỗi lần tra cứu thực hiện ~20 lệnh gọi ReadAt trên chỉ mục cộng với một Seek vào tệp dữ liệu.

Tìm kiếm nhị phân đánh bại SQLite. Điều này thật bất ngờ. Các tệp được sắp xếp đơn giản với chỉ mục được cuộn bằng tay sẽ hoạt động tốt hơn cây B của SQLite khoảng 1,7 lần ở mọi tỷ lệ. SQLite thực hiện nhiều công việc hơn cho mỗi lần tra cứu so với tìm kiếm nhị phân cuộn bằng tay, ngay cả đối với việc đọc khóa chính đơn giản. Chi phí đó đáng giá khi bạn cần các tính năng. Để tra cứu ID thuần túy, bạn đang trả tiền cho máy mà bạn không sử dụng.

SQLite nhanh và nhất quán. 25.000 đến 26.000 yêu cầu/giây bất kể kích thước tập dữ liệu, với độ trễ trung bình là 2 mili giây. Chỉ mục cây B có nghĩa là thời gian tra cứu hầu như không thay đổi khi các bản ghi tăng từ 10 nghìn lên 1 triệu.

Bản đồ trong bộ nhớ là mức trần. 97 nghìn yêu cầu/giây với độ trễ dưới một phần nghìn giây ở mọi tỷ lệ. Nếu tập dữ liệu của bạn vừa với RAM thì không có gì trên đĩa phù hợp với nó.

Bun (JavaScript) đánh bại Go trên cách tiếp cận bản đồ. Máy chủ HTTP của Bun đạt trung bình khoảng 106k req/s so với 97k của Go. Bun sử dụng JavaScriptCore làm công cụ JS và triển khai lớp HTTP nguyên bản trong Zig thông qua uWebSockets, bỏ qua hoàn toàn libuv. Ngôn ngữ không quan trọng bằng thời gian chạy.

Rust giành chiến thắng khi quét tuyến tính với biên độ rộng. Với bản ghi 10 nghìn, Rust thực hiện 2.883 yêu cầu/giây so với 783 của Go và 469 của Bun. Tốc độ quét tệp đơn giản nhanh hơn gấp 3-6 lần, có thể là sự kết hợp giữa chi phí I/O thấp hơn và quá trình giải tuần tự hóa JSON nhanh hơn thông qua serde. Về cách tiếp cận bản đồ, Rust dẫn đầu nhưng khoảng cách được thu hẹp đáng kể.

Chọn theo trường hợp sử dụng:

Trường hợp sử dụngNgười chiến thắng
Nhanh nhất tuyệt đối thông lượngRust: bản đồ trong bộ nhớ (169k req/s)
Nhanh nhất mà không cần tải mọi thứ vào RAMGo: tìm kiếm nhị phân trên đĩa (~40 nghìn yêu cầu/giây)
Cần truy vấn SQL sauSQLite (Go) (25 nghìn yêu cầu/giây, SQL đầy đủ khi bạn cần nó)
Nhanh nhất gửiĐi: quét tuyến tính (không có chỉ mục, không thiết lập, ~20 dòng mã)

25.000 yêu cầu mỗi giây thực sự có nghĩa là gì?

Trước khi nói về thời điểm bạn cần cơ sở dữ liệu, hãy đặt những con số này vào ngữ cảnh. Bởi vì "25.000 yêu cầu mỗi giây" nghe có vẻ nhiều, thực tế là như vậy, nhưng sẽ hữu ích khi nghĩ về loại sản phẩm nào tạo ra loại tải đó.

Giao thông không đồng đều. Người dùng thức vào ban ngày và ngủ vào ban đêm. Hướng dẫn lập kế hoạch năng lực cho ứng dụng web thường giả định tỷ lệ từ đỉnh đến trung bình là khoảng 1,5 đến 2,0 cho các sản phẩm B2B và B2C (ByteByteGo, Geek Culture). Hãy sử dụng tỷ lệ 2:1, nghĩa là một sản phẩm có tốc độ trung bình 12.500 yêu cầu/giây trong ngày có thể tăng vọt lên 25.000 yêu cầu/giây trong giờ bận rộn nhất của nó.

Bây giờ hãy thực hiện ngược lại. Giả sử một người dùng đang hoạt động kích hoạt khoảng 10 lần tra cứu cơ sở dữ liệu mỗi giờ - tải hồ sơ của họ, tìm nạp dữ liệu của họ, những việc tương tự. Đó là một con số thô; ứng dụng của bạn có thể cao hơn hoặc thấp hơn. Giả sử 10% người dùng hoạt động hàng ngày của bạn trực tuyến cùng lúc trong thời gian cao điểm.

Yêu cầu/giây cao nhất = DAU × 0,10 × (10 tra cứu/giờ ÷ 3600 giây/giờ) = DAU × 0,000278

Lật xung quanh để tìm DAU bão hòa từng phương pháp:

Phương pháp tiếp cậnCông suất tối đaDAU để bão hòa nó
Đi: quét tuyến tính (10k bản ghi)783 yêu cầu/giây2,8 triệu người dùng
Go: tìm kiếm nhị phân (đĩa)40.000 req/s144M người dùng
SQLite (Go)25.000 req/s90M người dùng
Đi: bản đồ trong bộ nhớ97.000 req/s349M người dùng
Bun: bản đồ trong bộ nhớ106.000 req/s381M người dùng
Rust: bản đồ trong bộ nhớ169.000 req/s608M người dùng

Quét tuyến tính xảy ra ở quy mô sản phẩm thực: khoảng 3 triệu người dùng hoạt động hàng ngày với tệp bản ghi 10 nghìn. Đó là một con số có ý nghĩa.

Mọi thứ khác? Bạn sẽ cần hàng chục triệu người dùng hoạt động hàng ngày để thúc đẩy mạnh mẽ các phương pháp tiếp cận này. Instagram có 400 triệu người dùng hoạt động hàng ngày và vẫn chạy PostgreSQL làm kho dữ liệu chính của họ (Instagram Engineering). Hầu hết các sản phẩm không bao giờ đạt được điều đó.

Để đưa ra một ví dụ có căn cứ hơn: một SaaS với 10.000 khách hàng trả tiền trong đó mỗi khách hàng sử dụng ứng dụng một lần mỗi ngày sẽ tạo ra khoảng 3 req/s cao nhất theo các giả định này. Một ứng dụng tiêu dùng có 100.000 DAU tạo ra khoảng 30 req/s ở mức cao nhất theo các giả định này. Cả hai đều không gần bằng bất kỳ phương pháp nào mà chúng tôi đã thử nghiệm.

Câu trả lời trung thực cho "bạn có cần cơ sở dữ liệu không?" là: có lẽ là chưa. Và khi bạn làm vậy, SQLite chạy từ một tệp phẳng sẽ xử lý 90 triệu người dùng hoạt động hàng ngày trên một máy chủ.

Khi nào bạn thực sự cần cơ sở dữ liệu?

Để tra cứu theo ID: bản đồ trong bộ nhớ xử lý ~97k req/s, tìm kiếm nhị phân trên đĩa xử lý ~40k req/s và xử lý SQLite ~25k req/s. Cả ba đều vượt xa mức mà hầu hết các ứng dụng sẽ thấy từ một máy chủ.

Các trường hợp bạn sẽ phát triển nhanh hơn các tệp phẳng:

Tập dữ liệu của bạn không vừa với RAM. Phương pháp bản đồ trong bộ nhớ yêu cầu tải mọi thứ khi khởi động. Với vài triệu bản ghi nhỏ thì ổn. Với hàng chục triệu, bạn đang xem hàng gigabyte RAM chỉ để làm chỉ mục. Bạn cần một cách để phân trang dữ liệu vào và ra. Cơ sở dữ liệu thực hiện việc này cho bạn.

Bạn cần truy vấn theo nhiều trường. Hiện tại, thao tác nhanh duy nhất là "tìm theo ID". Nếu bạn cần "tìm tất cả đơn đặt hàng cho người dùng X" hoặc "tìm tất cả sản phẩm có giá dưới 50 USD", bạn cần quét tệp hoặc duy trì bản đồ bổ sung. Khi bạn có ba hoặc bốn trong số đó, bạn đã xây dựng được một công cụ truy vấn.

Bạn cần tham gia. Việc kết hợp các đơn đặt hàng với người dùng và sản phẩm trong một phản hồi duy nhất có nghĩa là tải từ ba tệp và tập hợp kết quả thành mã ứng dụng. SQL thực hiện việc này hiệu quả hơn.

Nhiều quy trình cần ghi cùng lúc. RwLock trong các máy chủ này bảo vệ quyền truy cập đồng thời trong một quy trình. Ngay khi bạn chạy hai phiên bản của máy chủ, cả hai đều có bản đồ trong bộ nhớ riêng, chúng sẽ phân kỳ. Bạn cần một nguồn sự thật bên ngoài. Cơ sở dữ liệu là như vậy.

Bạn cần ghi nguyên tử trên các thực thể. Việc tạo đơn hàng trong khi giảm lượng hàng tồn kho cần phải thành công hoặc cả hai đều thất bại. Với các tệp riêng biệt, bạn phải tự mình thực hiện nhật ký giao dịch. Cơ sở dữ liệu giải quyết vấn đề này bằng đảm bảo ACID.

Không có ràng buộc nào trong số này áp dụng cho nhiều ứng dụng. Rất nhiều công cụ nội bộ, dự án phụ và sản phẩm ở giai đoạn đầu sẽ không bao giờ có tập dữ liệu không vừa với RAM của một máy chủ, không bao giờ cần kết hợp giữa các bảng khi chịu tải nặng và không bao giờ chạy nhiều hơn một phiên bản. Đối với những ứng dụng đó, phương pháp này hiệu quả.

Tệp vẫn còn ở đó nếu bạn cần di chuyển sau này. JSONL có thể được nhập vào bất kỳ cơ sở dữ liệu nào một cách dễ dàng. Bạn không bị khóa.


Mã máy chủ cho cả ba ngôn ngữ đều được nhúng ở trên. Tập lệnh gốc, trình chạy điểm chuẩn và tập lệnh Lua wrk không được hiển thị nội tuyến — tải xuống toàn bộ dự án để tự chạy:

Tải xuống mã điểm chuẩn (.zip)

Tệp zip chứa go-server/, bun-server/, rust-server/, seed.tsrun_bench.sh. Tập lệnh điểm chuẩn tạo dữ liệu theo ba tỷ lệ, tạo tập lệnh Lua với ID thực được lấy mẫu, khởi động từng máy chủ, chạy wrk và phá bỏ nó.

Bắt đầu nhanh:

Tác giả: upmostly

#discussion