Cách và lý do dùng SQLite on Rails để đạt hiệu năng tối ưu
(fractaledmind.github.io)- SQLite vẫn chưa đủ để dùng nguyên cấu hình mặc định của Rails trong môi trường production, nhưng nếu thêm một số thiết lập thì có thể xây dựng ứng dụng hiệu năng cao và có khả năng phục hồi tốt
- Khi có tải ghi đồng thời, khóa ghi toàn cục của SQLite sẽ gây ra
SQLITE_BUSY, và luồng transaction ghi thông thường của Rails không thật sự phù hợp với chế độ deferred mặc định - Từ
sqlite3-ruby1.6.9, có thể đổi chế độ transaction mặc định sang IMMEDIATE trongdatabase.yml, giúp việc chờ và thử lại khi không lấy được khóa ghi an toàn hơn busy_timeoutvẫn giữ Ruby GVL trong lúc chờ nên làm giảm khả năng song song của Puma; nếu dùngsleeptrongbusy_handlerđể nhả GVL và thử lại theo khoảng 1ms thì có thể giảm độ trễ đuôi dài- Chế độ WAL, transaction IMMEDIATE, busy handler có nhả GVL, cấu hình SQLite mặc định của Rails 7.1, và tùy chọn tách connection pool đọc/ghi là các trụ cột chính để cải thiện hiệu năng SQLite on Rails
Những giới hạn bộc lộ trong cấu hình Rails + SQLite mặc định
- Để vận hành ứng dụng Rails trên SQLite theo cách vừa hiệu quả vừa bền bỉ, cấu hình mặc định hiện tại vẫn chưa đủ
- Mục tiêu là biến trải nghiệm mặc định của SQLite trong Rails 8 thành trạng thái sẵn sàng cho production
- Ứng dụng demo là Lorem News
- Đây là bản clone cơ bản kiểu Hacker News, gồm người dùng, bài viết và bình luận
- Nội dung được tạo bằng Lorem Ipsum
- Việc kiểm thử tải dùng oha load testing CLI và benchmarking routes được tích hợp trong ứng dụng
- Khi gửi request tuần tự tới endpoint
post#createtrong 5 giây, RPS ổn định và mọi request đều thành công - Khi gửi 4 request đồng thời tới cùng endpoint trong 5 giây, một số request trả về 500 response
SQLITE_BUSY và transaction IMMEDIATE
- Vấn đề đầu tiên thấy trong log là exception
SQLITE_BUSY - SQLite dùng khóa ghi trên cơ sở dữ liệu để chỉ cho phép một thao tác ghi diễn ra tại một thời điểm
- Mỗi lần chỉ một connection có thể giữ khóa ghi
- Nếu connection mới cố lấy khóa ghi khi connection khác đang giữ khóa,
SQLITE_BUSYsẽ xảy ra
- Khi ứng dụng Rails chịu tải đồng thời lớn hơn, tỷ lệ request thất bại do
SQLITE_BUSYcũng tăng lên - Chế độ transaction mặc định của SQLite là deferred, nghĩa là không lấy khóa cho đến khi thao tác ghi thực sự xảy ra
- Điều này có lợi về hiệu năng trong môi trường chỉ có một connection hoặc có nhiều transaction chỉ đọc
- Nhưng ứng dụng Rails production dùng nhiều connection trên nhiều thread, và Rails bọc các truy vấn ghi trong transaction, nên mặc định này xung đột với cách hoạt động đó
- Nếu thất bại trong việc lấy khóa ghi ở giữa transaction, SQLite không thể thử lại truy vấn đó một cách an toàn mà không làm hỏng mức cô lập serializable, nên sẽ ném exception ngay lập tức
IMMEDIATEtransaction cố lấy khóa ghi ngay từ lúc bắt đầu transaction- SQLite có thể đưa truy vấn ghi vào hàng đợi và thử lấy khóa lại sau
- Cách này phù hợp hơn deferred với kiểu transaction ghi của Rails
- Gem
sqlite3-rubytừ bản 1.6.9 đã hỗ trợ cấu hình chế độ transaction mặc định- Rails truyền các khóa cấp cao nhất trong
database.ymlvào quá trình khởi tạo cơ sở dữ liệu củasqlite3-ruby - Có thể cấu hình trong
database.ymlđể transaction SQLite của Rails chạy ở chế độ IMMEDIATE
- Rails truyền các khóa cấp cao nhất trong
- Sau thay đổi này, trong bài test tải đơn giản, hệ thống xử lý gần như không còn lỗi 500 dưới tải đồng thời, nhưng với 16 request đồng thời thì vẫn xuất hiện lại một số lỗi
busy_timeout, GVL và busy_handler tùy biến
- Điểm nghẽn tiếp theo là khi số request đồng thời tiến gần hoặc vượt quá số Puma worker, độ trễ p99 tăng vọt
- Thời gian xử lý request thực tế vẫn ổn định ngay cả khi tải đồng thời gấp 3 lần số Puma worker, nhưng khi bắt đầu xuất hiện các request mất khoảng 5 giây thì cũng đồng thời xuất hiện các response 500 do
SQLITE_BUSY - Mốc 5 giây này khớp với cấu hình timeout trong
database.yml, được ánh xạ tới thiết lậpbusy_timeoutcủa SQLite busy_timeoutkhông ném exceptionBUSYngay, mà chờ trong số mili giây đã chỉ định và tiếp tục thử lấy lại khóa ghi- SQLite thử lấy lại khóa theo một dạng exponential backoff
- Chỉ khi không lấy được khóa trong thời gian timeout thì mới ném exception
BUSY - Ứng dụng web có thể mở nhiều connection nhưng không cần tự điều phối thứ tự ghi, mà giao việc đó cho SQLite
- Điểm nghẽn xuất phát từ việc SQLite được nhúng trong tiến trình Ruby, và
sqlite3-rubykhông nhả Ruby GVL khi gọi mã C của SQLite- Một Puma worker vẫn giữ GVL trong lúc chờ truy vấn cơ sở dữ liệu trả về
- Khi đó các Puma worker khác thậm chí cũng khó gửi truy vấn ghi tới SQLite cùng lúc
- Tính chất ghi tuần tự của SQLite khiến việc xử lý request trong Rails cũng trở nên tuyến tính hơn, làm giảm mạnh throughput
- SQLite cung cấp hook
busy_handlerở mức thấp hơnbusy_timeoutbusy_timeoutthực chất là một triển khaibusy_handlercụ thể do SQLite cung cấpsqlite3-rubycó binding cho hàm Csqlite3_busy_handler, nên có thể tạo Ruby callback được gọi khi truy vấn vào hàng đợi
- Nếu dùng
Kernel.sleeptrong callback Ruby, có thể nhả GVL trong lúc truy vấn chờ thử lại khóa ghi - Cách này cải thiện đáng kể độ trễ p99 dưới tải đồng thời, nhưng ở độ trễ p99.99 thì vẫn còn hiện tượng request chậm nhất ngày càng chậm hơn khi tải đồng thời tăng lên
Đuôi dài của exponential backoff và thử lại mỗi 1ms
- Logic
busy_timeoutcủa SQLite khi được viết lại bằng Ruby tạo ra cấu trúc khiến các truy vấn chờ lâu bị bất lợi hơn - Ở các lần thử lại đầu tiên, thời gian chờ còn nhỏ, nhưng số lần callback tăng thì thời gian chờ cũng tăng theo
- Lần chờ đầu tiên là 1ms
- Ở lần gọi thứ 10 sẽ chờ 50ms
- Từ lần thứ 12 trở đi sẽ chờ 100ms mỗi lần
- Nếu tổng thời gian chờ vượt quá timeout 5000ms thì exception sẽ xảy ra
- Nếu liên tục có truy vấn ghi mới đi vào, các truy vấn mới có thể thử lấy khóa thường xuyên hơn nhờ thời gian chờ ngắn hơn
- Trong lúc một truy vấn cũ đã chờ ba lần đang ngủ 10ms, một truy vấn mới có thể thử lại ba lần với các khoảng 1ms, 2ms, 5ms
- Kiểu backoff tăng dần này làm tăng khả năng truy vấn cũ không lấy được khóa ghi và bị timeout
- Giải pháp là để mọi truy vấn thử lại với cùng tần suất, bất kể “tuổi” của truy vấn
- Trong nhánh main của
sqlite3-rubyđã có thay đổi này- Tại thời điểm bài viết được viết, tính năng này vẫn chưa có trong bản tagged release
- Ruby callback dùng
sleepđể nhả GVL trong lúc chờ - Luôn chỉ
sleep1ms
- Sau thay đổi này, biểu đồ độ trễ p99.99 trong benchmark trở nên phẳng hơn
- Vẫn còn một bước nhảy khi mức đồng thời vượt quá một nửa số Puma worker
- Nhưng sau đó độ trễ đuôi dài ổn định quanh mức khoảng 0,5 giây
Chế độ WAL và tách connection pool đọc/ghi
- Bốn điều kiện cần cho hiệu năng SQLite on Rails là transaction IMMEDIATE, busy handler có nhả GVL, cấu hình SQLite phù hợp, và chế độ WAL
- write-ahead log cho phép SQLite xử lý nhiều thao tác đọc đồng thời
- rollback journal mode mặc định chỉ cho phép một truy vấn tại một thời điểm, dù là đọc hay ghi
- Chế độ WAL cho phép nhiều reader cùng lúc, nhưng writer vẫn chỉ có một tại một thời điểm
- Từ Rails 7.1, Rails áp dụng cấu hình mặc định tốt hơn cho cơ sở dữ liệu SQLite
- Những cấu hình này rất quan trọng để SQLite hoạt động tốt trong bối cảnh ứng dụng web
- Chi tiết cấu hình và lý do được trình bày trong bài blog riêng
- Đòn bẩy hiệu năng thứ năm, mang tính tùy chọn, là tách pool chỉ đọc và pool chỉ ghi
- Chế độ WAL của SQLite hỗ trợ nhiều connection đọc và một connection ghi
- Nếu connection pool Active Record bị bão hòa bởi các connection ghi, tác vụ đọc có thể bị chặn không cần thiết
- Có thể dùng hỗ trợ multiple databases của Rails để trỏ cấu hình reader và writer tới cùng một cơ sở dữ liệu SQLite
- Trên thực tế đây không phải các cơ sở dữ liệu riêng biệt, mà cùng trỏ tới một cơ sở dữ liệu duy nhất
- Kết quả là có thể tạo ra các connection pool và cấu hình connection tách biệt với nhau
- Pool connection reader gồm các connection chỉ đọc, còn pool writer chỉ có một connection
- Các model Active Record được cấu hình để kết nối tới pool phù hợp theo vai trò
- Dùng automatic role switching của Rails để mặc định mọi web request dùng pool reader, và chỉ chuyển sang pool writer khi cần ghi vào cơ sở dữ liệu
- Vì cùng dùng một cơ sở dữ liệu nên không cần
delayđể bảo đảmread your own writes - Vá phương thức
transactioncủa adapter ActiveRecord để transaction gắn với cơ sở dữ liệu writer
- Vì cùng dùng một cơ sở dữ liệu nên không cần
- Sự kết hợp giữa “deferred requests” và các connection pool được cô lập này cho thấy cải thiện hiệu năng theo chỉ số RPS đơn thuần trong bài test endpoint tạo comment
Các cải tiến được đóng gói thành gem
- Không cần tự triển khai toàn bộ các cải tiến này trực tiếp trong ứng dụng Rails
- Có thể cài
activerecord-enhancedsqlite3-adapterđể áp dụng các cải tiến liên quan - Tính năng connection pool tách biệt là chức năng thử nghiệm mới hơn, nên cần thêm cấu hình để opt-in
- Cách tiếp cận này gói lại các công cụ, kỹ thuật và giá trị mặc định để dùng SQLite nhanh và linh hoạt trong môi trường production của Rails
- Rails là một web application framework rất phù hợp để làm việc với SQLite, và hệ sinh thái công cụ cùng gem liên quan đang tiếp tục phát triển
1 bình luận
Các ý kiến trên Hacker News
Nếu bạn định dùng SQLite + Rails, dự án Litestack của Oldmoe (X/GitHub) rất đáng xem
Litestack là một Ruby gem tận dụng tính nhúng của SQLite cho các ứng dụng Ruby và Ruby on Rails, cung cấp trong một gói duy nhất cơ sở dữ liệu SQL, cache nhanh, hàng đợi tác vụ, message broker, công cụ tìm kiếm toàn văn và nền tảng chỉ số
Tôi đang dùng trong dự án hiện tại và rất hài lòng; link là https://github.com/oldmoe/litestack
Không thể tưởng tượng nổi để viết một bài chi tiết đến mức này thì đã mất bao nhiêu ngày
Đây là bài hữu ích cho những ai đang suy nghĩ về việc mở rộng ứng dụng web SQLite, không chỉ trong Rails
Bất kỳ ai làm việc với SQLite, bất kể ngôn ngữ hay framework sử dụng, đều nên đọc bài này
Vài năm trước tôi đã phải tự mình tìm ra phần lớn những điều này, nên cảm ơn vì đã tổng hợp lại
Tôi đang xây dựng một hệ thống phân tích FOSS và vì cần dễ cài đặt, tôi muốn gửi dữ liệu sự kiện vào một cơ sở dữ liệu SQLite riêng, tách khỏi dữ liệu ứng dụng chính
Ngay cả một website bận rộn ở mức trung bình cũng có thể phát sinh hơn 1000 sự kiện mỗi giây, nên tôi lo về khả năng mở rộng
Tôi thắc mắc liệu cách gom sự kiện trong bộ nhớ máy chủ rồi mỗi 1 giây thực hiện một lần ghi theo lô có phải là cách hợp lý để tránh giới hạn nhiều tác vụ ghi của SQLite không, hay có ý tưởng nào tốt hơn không
Điều này cũng có thể chứng minh dễ dàng bằng benchmark, và cũng có thể đặt toàn bộ các mục trong lô vào một transaction duy nhất
Với xử lý theo lô, thực tế sẽ có một thread lấy batch và thực hiện ghi thật sự, nên cũng rất phù hợp với giới hạn chỉ 1 tác vụ ghi đồng thời của SQLite
Tuy nhiên độ phức tạp sẽ tăng lên một chút. Cần quyết định sẽ làm gì nếu một lần ghi theo lô không kết thúc trong vòng 1 giây, có để kích thước queue lưu trong bộ nhớ là không giới hạn không, nếu không giới hạn thì có chắc khi server quá tải nó sẽ không chết vì OOM không, nếu đặt giới hạn thì có chấp nhận mất mục không, nếu bỏ thì sẽ bỏ mục nào, giới hạn queue là bao nhiêu
Những câu hỏi như vậy chắc chắn sẽ xuất hiện trong hầu hết mọi hệ thống cần queue, và hữu ích không chỉ cho hiện tại mà cả cho các tình huống sau này
Nếu khó tin tưởng hiệu năng đồng thời transaction của SQLite, bạn có thể tuần tự hóa toàn bộ thao tác ghi trong một thread hoặc process cụ thể
Nếu dùng tính năng async-insert[0] của ClickHouse, bạn không cần lo việc xử lý sự kiện theo lô ở phía ứng dụng
Nếu đang tìm một giải pháp nhúng, có thể dùng chDB dựa trên ClickHouse
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
Có thể có bao nhiêu worker đọc tùy ý, còn writer nhận input từ thứ gì đó như queue
Chỉ cần làm như vậy, hiệu năng thường thật sự đáng kinh ngạc
Tôi vẫn chưa thật sự hiểu xu hướng dùng SQLite làm cơ sở dữ liệu backend cho production
SQLite rất tuyệt với vai trò cơ sở dữ liệu ứng dụng phía client nhỏ gọn, có thể nhúng, như danh bạ điện thoại, nhưng chính các nhà phát triển của nó cũng luôn từ chối mở rộng vượt ra ngoài phạm vi đó
Ví dụ, họ không thêm các kiểu native hữu ích như ngày/giờ hay UUID, vì làm vậy sẽ khiến mã và kích thước đối tượng nhúng phình to, và kết quả là bị kẹt trong trạng thái “mọi thứ đều là chuỗi”
Có thể bật tính toàn vẹn tham chiếu, nhưng các tùy chọn ràng buộc cũng rất hạn chế
Tôi không hiểu vì sao cứ cố nhét nó vào một vai trò không phù hợp và cũng không được hỗ trợ đúng cách như vậy
Lý do thì đơn giản. Với các mẫu truy cập thiên về đọc, SQLite cực kỳ nhanh, đủ nhanh để đơn giản hóa mã truy cập cơ sở dữ liệu, và chẳng hạn các truy vấn N+1 trong nhiều trường hợp thực tế không còn là vấn đề
Ngoài ra, SQLite loại bỏ một tầng trong kiến trúc N-tier, qua đó cũng giảm bớt những thứ có thể hỏng. Nếu từng tự vận hành Postgres hay MySQL, bạn sẽ biết thực tế có rất nhiều thứ có thể trục trặc
Nó không phải lựa chọn hoàn hảo cho mọi ứng dụng, thậm chí là cho phần lớn ứng dụng, nhưng xu hướng hiện nay giống một sự điều chỉnh cân bằng trước quan niệm rõ ràng là sai rằng SQLite chỉ phù hợp với “cơ sở dữ liệu ứng dụng client nhúng nhỏ”
Khi nhìn như vậy, cấu trúc có thêm một DBMS khác bên cạnh DBMS xử lý cùng dữ liệu trở nên khá buồn cười
Vì thế hướng đi tách ra thành để client kết nối trực tiếp tới Postgres, hoặc bỏ Postgres và tập trung hơn vào DBMS tự xây của mình
Nếu chọn phương án thứ 2, SQLite là một engine dễ xây dựng bên trên. Không hoàn hảo, nhưng đó là công cụ ta đang có
Việc nhận ra điều này lan rộng trên quy mô lớn mới xảy ra tương đối gần đây, nên đang có nhiều thử nghiệm để xem cái gì làm được và không làm được
Đó là vòng tuần hoàn tự nhiên của điện toán, nơi những thứ cũ lại trở nên mới
Có thể đọc Postgres thành MySQL, MSSQL, Oracle hoặc DBMS khác cũng được
Sự đơn giản hóa này mang lại nhiều lợi ích không chỉ cho nhà phát triển SQLite hay phần cứng cấu hình thấp, mà cả cho nhà phát triển ứng dụng
Tài liệu đơn giản hơn, đường cong học tập ngắn hơn, bề mặt lỗi và kích thước binary cũng giảm
Phần mềm ngày nay có xu hướng thêm sự cồng kềnh và phức tạp vào mọi thứ, nên thật tuyệt khi có vài dự án như SQLite đang chống lại điều đó
Bài viết rất hay, và tôi tự hỏi liệu có tài liệu tương tự cho Django không
ArchiveBox dùng SQLite thông qua Django, và chúng tôi khá thường gặp đúng những vấn đề giống như bài viết mô tả với Rails
Sẽ thật tốt nếu có một giải pháp tầng SQLite không bắt buộc phải tuần tự hóa mọi thao tác ghi qua những đường khác của ứng dụng
Gem
sqlite3-rubytheo thiết kế không nhả GVL trong khi gọi SQLite; nhìn vào bình luận issue được liên kết https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss... thì có vẻ đã có nghi ngờ rằng chi phí giành lại lock là lớn, nhưng chưa được kiểm chứngNghĩ tới tất cả các cách đi vòng này thì thấy hơi đáng ngờ
Nếu là văn hóa extension của Python thì có lẽ thiết kế sẽ ngược lại, nên tôi tò mò thực tế bên đó làm thế nào
Ngoài ra, trong issue được liên kết còn có bình luận rằng “gem extralite là một client SQLite thay thế có nhả GVL trong khi bị blocking, và nội dung liên quan đến concurrency nằm ở https://github.com/digital-fabric/extralite?tab=readme-ov-fi.... Nhìn chung nó nhanh hơn gem này nhiều và cũng không có vấn đề về concurrency”
Đã xác minh rằng nếu chỉ đơn giản nhả GVL ở mọi
stepcủa máy ảo SQLite thì hiệu năng đơn luồng sẽ tệ đi đáng kểTìm điểm cân bằng giữa hiệu năng đơn luồng và đa luồng là việc khó
Trong Rails, do có connection pool nên biết chắc là đa luồng, nhưng gem cấp thấp cũng được dùng nhiều trong các thư viện và công cụ khác chạy trong môi trường đơn luồng
Một vài giá trị tinh chỉnh tôi duy trì cho web service cá nhân làm chơi của mình như sau
PRAGMA journal_mode = WAL;PRAGMA busy_timeout = 5000;PRAGMA synchronous = NORMAL;PRAGMA cache_size = 1000000000;PRAGMA foreign_keys = true;PRAGMA temp_store = memory;Và tôi dùng transaction BEGIN IMMEDIATE
https://kerkour.com/sqlite-for-servers
cache_sizevàmmap_size?Tôi thích cả SQLite lẫn Rails, nhưng chuyện này trông khá giống việc dùng MS Access trong môi trường production
Hơn nữa, so với vài chục năm trước khi Access còn được dùng nhiều hơn, máy tính và tốc độ đĩa hiện nay đã nhanh hơn rất nhiều
Nếu thiên về đọc, không chỉ SQLite mà cả Jet cũng có thể khá dễ dàng đạt tới hàng chục nghìn request mỗi giây
Phần lớn ứng dụng không có tới hàng trăm nghìn người dùng đồng thời, nên SQLite có thể rất phù hợp
Một điểm mạnh nữa của SQLite là gần như nền tảng và ngôn ngữ nào cũng có client
Lưu trữ, sao lưu và tính di động cũng là các trường hợp sử dụng rất hợp với SQLite. Tôi đã từng thúc đẩy mạnh việc dùng SQLite theo từng hộp trong một dự án có dữ liệu nhập chỉ giới hạn trong một khoảng thời gian nhất định, và đến giờ vẫn cảm thấy như vậy lẽ ra tốt hơn
Thay vì tạo schema phức tạp và các tính năng xuất/lưu trữ tùy chỉnh, có thể chỉ cần sao chép một file để làm bản lưu trữ hoặc bản sao lưu, và cũng không cần phải cân nhắc quá sâu các thay đổi schema phát sinh theo thời gian
Tùy tình huống, nhưng đây là một lời giải khá ổn cho nhiều vấn đề. Nó giống như việc trong phần lớn ứng dụng, PostgreSQL hoặc một RDBMS khác thường là lựa chọn tốt hơn các tùy chọn NoSQL có khả năng mở rộng cao hơn
Trước đây chúng ta có xu hướng thiết kế quá mức, còn giờ đây hiệu năng tính toán và I/O đang tiến đến mức ngày càng khó biện minh cho những nỗ lực đó
Bài viết rất hữu ích và được viết tốt
Tôi thắc mắc vì sao phương thức
busy_timeoutmặc định lại có độ trễ theo cấp số nhân để phạt các truy vấn cũLý do gì khiến điều này hợp lý làm mặc định?