当我们谈论消息队列时,Redis、RabbitMQ、Kafka 几乎是默认选项。这些成熟的中间件提供了可靠的投递保证、持久化和集群能力,但同时也带来了额外的运维复杂度:需要单独部署服务、处理双写问题、维护备份策略。Honker 作为 SQLite 的扩展,给出了一种完全不同的思路 —— 将消息队列、事件流和发布订阅直接嵌入到同一个数据库文件中。本文聚焦其 pub/sub 消息广播的实现原理,对比传统消息队列的架构差异,并给出工程落地的关键参数。
从 Postgres NOTIFY/LISTEN 到 SQLite
PostgreSQL 的 NOTIFY/LISTEN 机制允许会话之间进行轻量级的事件通信:一方发送 NOTIFY,另一方通过 LISTEN 订阅同一个通道名,数据库负责将通知推送给所有监听者。这种模式的核心优势在于无需轮询,事件到达是即时的。Honker 的目标正是将这套语义移植到 SQLite 环境中。
SQLite 本身并不支持 NOTIFY/LISTEN,其原生的进程间通信能力非常有限。Honker 采用了两种技术手段来模拟这一行为:第一,利用 SQLite 的 WAL(Write-Ahead Logging)模式作为变更检测机制;第二,通过一个后台轮询线程监控 PRAGMA data_version 的变化。每当有任何连接提交事务时,data_version 就会递增,这是一个单调计数器,读取只需要约 3 微秒。Honker 的 poller 线程每秒轮询 1000 次(每毫秒一次),一旦检测到版本变化,就向所有订阅者分发新消息。
表结构设计:三種原語的存儲模型
Honker 在同一个 .db 文件中维护了三类原语,分别是 queue(持久化任务队列)、stream(带偏移量的持久化发布订阅)和 notify(瞬态发布订阅)。这三者本质上都是表中的行,区别在于消费模式和持久化策略。以 publish 和 subscribe 为例,核心表结构大致如下:
-- notify 表:瞬态消息,无持久化
CREATE TABLE _honker_notify (
id INTEGER PRIMARY KEY,
channel TEXT NOT NULL,
payload TEXT,
created_at INTEGER DEFAULT (strftime('%s','now'))
);
-- stream 表:持久化发布订阅,带消费者偏移
CREATE TABLE _honker_stream (
id INTEGER PRIMARY KEY,
channel TEXT NOT NULL,
payload TEXT,
consumed_by TEXT, -- 记录已消费的消费者标识
created_at INTEGER DEFAULT (strftime('%s','now'))
);
当 publisher 调用 notify(channel, payload) 时,Honker 实际上执行的是向 _honker_notify 表插入一行,同时轮询线程会立即将这条记录推送给所有监听该 channel 的订阅者。stream 的区别在于消息会保留,直到所有订阅者都标记为已消费,这与 Kafka 的 consumer group 偏移量管理思路相似,但实现更为轻量 —— 每条消息记录了一个 consumed_by 字段来标记哪些消费者已经处理过。
消费者端的实现依赖于触发器。当订阅者首次监听某个 channel 时,Honker 会在该 channel 上创建一个 AFTER INSERT 触发器,后续任何对该 channel 的新 INSERT 操作都会自动触发回调逻辑。这种设计避免了传统轮询模式下需要不断执行 SELECT 查询的开销,消息到达延迟控制在亚毫秒级。
與傳統消息隊列的架構對比
將 Honker 與 Redis Pub/Sub、RabbitMQ、Kafka 等傳統方案進行橫向對比,能更清晰地看到其定位與適用場景。
從部署模式來看,Redis 和 RabbitMQ 都是獨立的服務進程,需要與應用程序分開部署和運維。Redis 提供了 Pub/Sub 命令,但消息是純內存的,斷電即丟;RabbitMQ 基於 AMQP 協議,支持持久化隊列但 Topology 配置相對複雜。Kafka 更是需要 ZooKeeper 或 KRaft 來管理元數據,部署門檻最高。相比之下,Honker 將所有功能封裝為一個 SQLite 加載擴展(.so 或 .dll 文件),業務代碼只需調用 SELECT load_extension('honker_ext') 即可獲得完整的消息能力,無需啟動額外進程。
從事務整合角度來看,傳統方案普遍面臨雙寫問題:業務表的 INSERT 與消息隊列的 enqueue 需要分開執行,無法保證原子性。當業務事務回滾時,消息已經入隊的情況並不罕見,反之亦然。Honker 的核心優勢正在於此 ——queue.enqueue () 可以在業務事務的回調中執行,消息行與業務行在同一次提交中持久化。假設我們在處理訂單創建時需要發送郵件通知:
with db.transaction() as tx:
tx.execute("INSERT INTO orders (id, total) VALUES (?, ?)", [42, 99])
q.enqueue({"to": "alice@example.com", "order_id": 42}, tx=tx)
這兩條 INSERT 屬於同一個事務。一旦訂單創建失敗,整個事務回滾,郵件任務也會一併撤銷,不存在「訂單沒創建成功但郵件已發送」的狀態不一致。
從水平擴展能力來看,Honker 明確針對單節點場景設計。由於所有訂閱者共享同一個 poller 線程,訂閱者數量增加不會線性增加查詢開銷,這是因為輪詢基於共享的 data_version 而非每個訂閱者獨立查詢。但如果需要跨多台機器共享同一個隊列,仍然需要考慮 SQLite 的文件鎖機制與 WAL 的並發讀寫優化。官方建議在單機多進程或單進程多線程場景下使用,這與 LiteFS 這類分布式 SQLite 方案的定位不同。
工程落地的關鍵參數
在生產環境中部署 Honker 作為消息廣播層時,以下幾個參數值得特別關注。
輪詢間隔默認為 1 毫秒,即每秒 1000 次 data_version 檢查。這在大多数场景下足够,但对于要求极低延迟的实时交互场景,可以考虑将轮询间隔缩短至 500 微秒(通过修改源碼中的 sleep 時間),代价是 CPU 佔用略微上升。延迟敏感度不高的场景可以反向上調至 2-3 毫秒,以降低空轮询的资源消耗。
消息体的体积应控制在合理范围内。Honker 的 payload 以 TEXT 形式存储,虽然 SQLite 对 BLOB 和 TEXT 的大小限制相对宽松(单字段最大 1GB),但考虑到 WAL 的追加写入特性,過大的消息會影響輪詢線程的序列化效率。官方建議單條消息不超過 64KB,超大 payload 建議使用對象存儲並在消息中只傳遞引用 URL。
消費者並發控制方麵,queue 默認採用「搶占式」分配 —— 多個 worker 同時調用 claim () 時,誰先搶到算誰的,任務不會被重複執行。如果需要更精細的控制(例如避免同一 worker 重複處理),可以在 claim 時指定 worker ID,Honker 會自動過濾。任務的超時與重試次數可以在 enqueue 時通過參數指定,例如 q.enqueue(payload, retries=3, timeout_s=30)。
監控層面,建議將 _honker_notify 和 _honker_stream 表的記錄數納入監控。對於 notify 表,消息消費後即被拋棄,表中積壓通常意味著沒有消費者在線;對於 stream 表,如果某些 channel 的消息長期處於未消費狀態,可能需要排查訂閱者是否存活或是否存在阻塞。
適用場景與局限
Honker 最適合的場景是單機或單服務器架構的應用,特別是那些已經將 SQLite 作為主數據庫、但不願意為了異步任務而引入 Redis 或其他中間件的團隊。Bluesky 的 PDS、Fly 的 LiteFS 等項目已經證明 SQLite 可以承擔真實的生產流量,而 Honker 填補了這類架構中消息處理能力的空白。
然而,如果你的系統需要跨多個節點共享同一個消息主題,或者需要極強的順序保證與分區能力,Honker 並非首選。這種情況下,Kafka 或 RabbitMQ 仍然是更穩妥的選擇。Honker 的設計目標從來不是替代這些成熟的分布式消息中間件,而是為單節點 SQLite 應用提供一種「不需要引入第二個數據源」的輕量解決方案。
從架構師的角度來看,Honker 的價值在於重新定義了「消息隊列」這四個字的邊界。過去我們默認消息系統必須是獨立的服務,必須有專門的運維團隊負責其可用性。Honker 證明了只要數據模型設計得當,一張表、幾個觸發器、一個後台線程,就能實現足夠好用的發布訂閱。這種「數據庫即消息隊列」的思路,或許會在未來的工具設計中佔據更重要的位置。
資料來源
- Honker 官方文檔:https://honker.dev/
- SQLite PRAGMA data_version 文檔:https://www.sqlite.org/pragma.html