Honker - Tiện ích mở rộng triển khai Postgres NOTIFY/LISTEN trên SQLite
(github.com/russellromney)- Tích hợp hàng đợi bền vững, stream, pub/sub, bộ lập lịch vào trong một tệp SQLite duy nhất, cho phép xử lý tác vụ bất đồng bộ mà không cần broker riêng như Redis hay Celery
- Dùng
PRAGMA data_versionđể polling mỗi 1ms nhằm đạt độ phản hồi giữa các tiến trình ở mức vài mili giây, không cần polling ở cấp ứng dụng hay daemon notify(),stream(),queue()đều được ghi trong transaction của bên gọi, nên sẽ commit cùng hoặc rollback cùng với phần ghi nghiệp vụ, giúp giảm vấn đề dual-write- Hàng đợi tác vụ bao gồm retry, ưu tiên, chạy trễ, dead-letter, scheduler, named lock, rate limiting; stream hỗ trợ phân phối at-least-once bằng cách lưu offset theo từng consumer
- Trong môi trường dùng SQLite làm kho lưu trữ chính, có thể gộp ứng dụng và xử lý bất đồng bộ vào một tệp cơ sở dữ liệu để giảm độ phức tạp vận hành
- Cung cấp ba primitive cốt lõi
- queue(): hàng đợi tác vụ at-least-once — retry, ưu tiên, tác vụ trì hoãn, dead-letter, visibility timeout
- stream(): pub/sub bền vững — theo dõi offset theo từng consumer, replay at-least-once
- notify(): pub/sub tạm thời — fire-and-forget, không có replay lịch sử
- Hỗ trợ biến hàm thành tác vụ hàng đợi bằng decorator
@queue.task()kiểu Huey, cùng tác vụ định kỳ dựa trêncrontab()+ scheduler có leader election - Schema của hàng đợi áp dụng partial index trên bảng
_honker_live; thao tác claim chỉ cần mộtUPDATE … RETURNING, ack chỉ cần mộtDELETE, nên hiệu năng ổn định независимо от số lượng dòng chết - Là tiện ích mở rộng SQLite có thể nạp động (
libhonker_ext), cho phép mọi client SQLite 3.9+ truy cập cùng một bảng — worker Python có thể claim tác vụ được push từ ngôn ngữ khác - Cung cấp hướng dẫn tích hợp với các ORM phổ biến như SQLAlchemy, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto
- Kể cả transaction bị dừng bởi SIGKILL vẫn an toàn nhờ ACID của SQLite; khi worker crash, tác vụ sẽ tự động được reclaim sau khi visibility timeout hết hạn
- Cung cấp binding cho 8 ngôn ngữ: Python, Node.js, Rust, Go, Ruby, Bun, Elixir, C++, mỗi gói được phát hành độc lập trên PyPI, npm, crates.io, Hex, RubyGems
- Được triển khai bằng Rust (
honker-core+honker-extension) - Giấy phép Apache 2.0
1 bình luận
Ý kiến trên Hacker News
Tôi đã tự làm cái này. Honker thêm NOTIFY/LISTEN liên tiến trình vào SQLite, để truyền sự kiện kiểu push với độ trễ chỉ vài ms bằng chính file SQLite sẵn có, không cần daemon hay broker
SQLite không có server như Postgres, nên điểm mấu chốt là chuyển nguồn polling sang
stat(2)nhẹ trên file WAL thay vì query theo chu kỳ. SQLite vẫn hiệu quả ngay cả khi bắn nhiều query nhỏ (https://www.sqlite.org/np1queryprob.html), nên khó gọi đây là một nâng cấp khổng lồ, nhưng việc chỉ cần theo dõi WAL và gọi hàm SQLite khiến nó không phụ thuộc ngôn ngữ là điều khá thú vịTôi cũng thêm ephemeral pub/sub, durable work queue có retry và dead-letter, cùng event stream có offset theo từng consumer. Cả ba đều chỉ là các row trong file
.dbcủa ứng dụng hiện có, nên có thể commit nguyên tử cùng với ghi nghiệp vụ, và nếu rollback thì cả hai cùng biến mấtBan đầu nó tên là litenotify/joblite, nhưng sau khi mua
honker.devcho vui thì thấy Oban, pg-boss, Huey, RabbitMQ, Celery, Sidekiq đều có tên khá ngộ nên tôi cứ chốt tên này. Hy vọng nó hữu ích hoặc ít nhất là buồn cười, và cảnh báo đây là phần mềm alpha vẫn hoàn toàn đúngVới Java/Go/Clojure/C# thì SQLite vốn dĩ đã là single writer, nên có vẻ đơn giản và gọn hơn nếu ứng dụng tự quản lý writer đó rồi dùng concurrent queue ở cấp ngôn ngữ để biết có ghi nào xảy ra và chỉ đánh thức các thread liên quan
Dù vậy, cách tận dụng WAL sáng tạo như thế này vẫn rất thú vị, và với các ngôn ngữ như Python/JS/TS/Ruby, nơi đồng thời dựa trên tiến trình là phổ biến, nó có vẻ khá hợp làm cơ chế notify
Trên phần cứng của tôi, mỗi lần gọi còn chưa đến 1μs, nên kiểu polling này dùng chưa đến 0,1% CPU
PRAGMA data_versioncó tốt hơnstat(2)không nhỉhttps://sqlite.org/pragma.html#pragma_data_version
Nếu là C API thì còn có
SQLITE_FCNTL_DATA_VERSIONtrực tiếp hơn nữahttps://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
Tôi tò mò liệu cái này có thể dùng như Kafka hạng nhẹ cho luồng thông điệp bền vững hay không. Cũng muốn biết liệu có hỗ trợ ngữ nghĩa kiểu replay toàn bộ thông điệp quá khứ + thời gian thực từ một timestamp nào đó cho một topic cụ thể không
Có lẽ vẫn có thể giả lập bằng polling như pub/sub, nhưng như bạn nói thì chắc không phải tối ưu
Nếu lưu vị trí đọc, tên queue, bộ lọc v.v. thì thay vì mỗi khi
stat(2)đổi là đánh thức mọi subscription thread để mỗi thread tự chạy một SELECT với N=1, polling thread có thểEvents INNER JOIN Subscribersvà chỉ đánh thức những subscriber thật sự khớpCảm ơn phản hồi. Tôi đã mở PR phản ánh các đề xuất đó
https://github.com/russellromney/honker/pulls/1
Giờ nó đã chuyển sang cấu trúc polling 3 tầng:
PRAGMA data_versionmỗi 1ms,statmỗi 100ms, và xử lý reconnect khi có lỗiPRAGMA data_versionđể thay thế việc phát hiện thay đổi size/mtime dựa trênstattrước đây. Đây là commit counter của chính SQLite nên đơn điệu tăng, không bị ảnh hưởng bởi clock skew, và xử lý đúng cả WAL truncation lẫn rollback. Nó là một query nonblocking khoảng 3µs, và tôi đổi không phải vì hiệu năng mà vì độ chính xác. Thậm chí nó còn chậm hơn một chút. Rủi ro truncation hóa ra cũng thực tế hơn tôi tưởngKhi test thì C API
SQLITE_FCNTL_DATA_VERSIONkhông hoạt động giữa các connection. Vì vậy hiện giờ tôi vẫn phải chấp nhận chi phí đi qua VFS layer, và đang chủ động chấp nhận tradeoff đódata_versionthất bại thì sẽ thử reconnect, giả định các trường hợp như lỗi đĩa tạm thời, NFS hiccup, connection corruption, và để phòng ngừa thì cũng đánh thức subscriberstatđể so sánh(dev, ino)với giá trị lúc startup nhằm phát hiện thay thế file. Đây là các trường hợp như atomic rename, litestream restore, volume remount;data_versionđi theo fd đang mở nên nếu file đã bị thay thì nó vẫn tiếp tục nhìn inode cũ và không bắt được việc nàyNhờ vậy Honker tốt hơn hẳn và tôi cũng học được rất nhiều
Nhân tiện quảng bá nhẹ, trong PostgreSQL 19 sắp tới, LISTEN/NOTIFY đã được tối ưu để scale tốt hơn nhiều cho selective signaling
Đây là bản vá nhắm vào trường hợp có nhiều backend cùng listen các channel khác nhau
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9
Tôi tự hỏi sao không theo dõi thay đổi WAL bằng inotify hoặc wrapper đa nền tảng để khỏi polling
statthì đơn giản là chạy ở khắp mọi nơiĐiều hấp dẫn hơn so với IPC riêng là nó được commit nguyên tử với dữ liệu nghiệp vụ
Với cơ chế chuyển thông điệp bên ngoài lúc nào cũng có vấn đề kiểu “đã gửi thông báo nhưng transaction lại rollback”, và mọi thứ rất nhanh trở nên rối rắm
Tôi có một thắc mắc về WAL checkpoint. Khi SQLite truncate WAL về lại 0 thì polling bằng
stat()có xử lý đúng không. Tôi có cảm giác sẽ có một khoảng trống làm mất sự kiệnTrước đây tôi từng rất khổ với tổ hợp Postgres+SQS vì trigger gửi enqueue trước khi commit trên connection khác trở nên nhìn thấy được. Phải thêm retry logic, thêm polling phía worker, cuối cùng lại phải đưa enqueue vào trong transaction, mà làm vậy thì rốt cuộc chỉ là dựng lại điều Honker đang làm nhưng với nhiều moving part hơn
Các lỗi kiểu “notification đã gửi nhưng row vẫn chưa commit” thường im lặng và phụ thuộc timing, nên truy vết thực sự rất đau đầu
Tuy nhiên phần này tôi vẫn chưa có test nên cần kiểm tra thêm. Đây là một điểm hay, tôi sẽ xem kỹ
Cảm ơn
Các ứng dụng nhỏ dựa trên SQLite đã tăng lên rất nhiều, và phần lớn đều cần queue và scheduler
Tôi đã tự vận hành vài thứ, nhưng lúc nào cũng thấy thiếu sự tao nhã của các giải pháp kiểu Postgres
Tôi định sẽ thử cái này ngay
Nếu gặp vấn đề thì rất mong bạn mở PR hoặc issue trong repo
Ở đây tôi rất muốn dùng kqueue/FSEvents, nhưng tôi nhớ là Darwin sẽ làm rơi thông báo từ cùng một tiến trình
Nếu publisher và listener ở cùng tiến trình thì có trường hợp listener hoàn toàn không được đánh thức, nên việc truy vết khá bẩn. Polling bằng
statnhìn thì xấu nhưng rốt cuộc lại là thứ thực sự chạy được ở mọi nơiTôi cũng tò mò liệu khi WAL checkpoint làm file nhỏ lại thì có phát sinh wakeup không, hay poller lọc luôn trường hợp giảm kích thước
Sự kiện VNODE của kqueue sẽ được gửi miễn là tiến trình đó có quyền truy cập file, không có bộ lọc nào loại vì cùng tiến trình cả
Tôi sẽ kiểm tra rồi báo lại
Rất hay. Tôi tò mò khi có tải thì nút thắt cổ chai chủ yếu là throughput ghi của SQLite, hay là lớp thông báo WAL
Nó cũng thay đổi khá nhiều tùy journal mode và synchronous mode
Phần notification thì cực kỳ rẻ, dù là cách cũ dùng
stat(2)hay cách mới dựa trênPRAGMA. Ở bình luận khác cũng đã nóistat(2)cỡ khoảng 1µsDự án hay đấy. Tôi cũng đang làm một thứ đẩy SQLite đi xa hơn rất nhiều so với cách nó thường được dùng
Thật đáng khích lệ khi thấy ngày càng nhiều người khám phá xem SQLite thực sự có thể làm được đến đâu
Tôi tò mò liệu có thể tích hợp cả khi dùng SQLAlchemy hay không
Nhìn hiện tại thì có vẻ nó muốn tự tạo DB connection riêng