在分布式系统的构建中,发布订阅(Pub/Sub)模式是实现服务间解耦的核心基础设施。当系统以 PostgreSQL 为数据存储时,NOTIFY/LISTEN 机制为开发者提供了原生的进程间通信能力 —— 发送方无需知道谁在监听,接收方也无须轮询数据库。然而,当项目选择 SQLite 作为主数据库时,开发者往往面临一个尴尬的现实:SQLite 协议本身不提供任何通知机制。
Honker 的核心价值在于将 PostgreSQL 的 NOTIFY/LISTEN 语义完整移植到 SQLite 环境中。与传统轮询方案不同,它通过 WAL(Write-Ahead Logging)文件状态监控实现毫秒级的事件推送,让 SQLite 也能拥有「推送」能力。本文不展开 WAL stat polling 的底层实现(昨日已有讨论),而是聚焦于上层的发布订阅抽象 —— 即 Postgres 兼容的语义如何在 SQLite 上工程化落地。
三种核心原语的设计哲学
Honker 提供了三种不同的消息原语,分别对应不同的使用场景。它们并非功能的简单堆叠,而是经过精心设计以覆盖异步通信的典型需求。
** ephemeral notify(临时通知)** 是最轻量的模式。它模拟 PostgreSQL 中经典的 NOTIFY/LISTEN 语义:发送方在事务中调用 notify(channel, payload),所有监听该 channel 的进程会收到通知。通知是即发即弃的,不持久化存储,历史上发送过的消息不会被重放。如果你的场景是「只关心当前正在发生的事件」,notify 是开销最小的选择。典型的应用场景包括:缓存失效通知、配置热重载、前端实时更新推送等。
** durable stream(持久流)** 在 notify 的基础上增加了持久化和回溯能力。与 notify 不同的是,stream 会将每条消息写入表 _honker_stream_messages,每个消费者独立记录自己的消费偏移量。这意味着:即使消费者程序重启,已发送的消息不会丢失;从上次断开的位置继续消费,不会漏掉任何一条消息。对于需要「至少一次」语义的场景 —— 如事件溯源、审计日志、跨服务状态同步 ——stream 是更合适的选择。官方提供的参数 save_every_n 和 save_every_s 允许控制偏移量持久化的频率,默认为每 1000 条或每 1 秒持久化一次,这个数值在大多数高吞吐场景下是合理的。
** work queue(任务队列)** 则是完全不同的抽象。它不仅仅是消息的传递,更包含消费确认、重试机制、优先级队列、死信队列等完整的任务调度能力。queue 的设计借鉴了 Postgres 生态中 pg-boss 和 Oban 的成熟经验,但在 SQLite 层面做了轻量化实现。每一条入队任务都对应 _honker_live 表中的一行记录,claim 操作通过部分索引 (queue, priority DESC, run_at, id) WHERE state IN ('pending','processing') 快速定位可执行任务_ack 操作则从表中删除记录。关键参数包括:visibility_timeout_s(任务可见性超时,默认 300 秒)、max_attempts(最大重试次数,默认 3 次)、backoff(指数退避系数,默认 2.0)。
事务耦合:业务写入与消息发送的原子性
Honker 最重要的设计决策之一是将消息发送与业务写入放在同一个事务中完成。这看似简单的设计,实际上解决了分布式系统中的大难题。
考虑一个典型的订单创建场景:业务需要将订单写入 orders 表,同时向任务队列投递一封确认邮件。在传统架构中,这两个操作往往涉及不同的数据存储 —— 订单写入主库,任务写入 Redis 或 RabbitMQ。问题在于:如果订单写入成功但任务投递失败,系统就陷入了不一致状态;要么人工介入,要么依赖额外的补偿机制。
Honker 的方案是将任务队列本身变成数据库中的一张表。当你在同一个事务中执行 INSERT INTO orders 和 queue.enqueue({...}) 时,数据库的原子性保证了两者要么同时提交,要么同时回滚。代码表现上只需要传入可选的 tx 参数:
with db.transaction() as tx:
tx.execute("INSERT INTO orders (user_id, total) VALUES (?, ?)", [user_id, total])
emails.enqueue({"to": email, "order_id": order_id}, tx=tx)
这就是事务性发件箱模式(Transactional Outbox Pattern)的默认实现 —— 无需额外部署发件箱表,无需单独的分发进程,消息行本身就是业务事务的一部分。
跨进程推送的实现机制
为什么 Honker 能在不轮询数据库的情况下实现跨进程推送?答案是对 WAL 文件的状态监控。
SQLite 在 WAL 模式下,每次提交都会追加到 .db-wal 文件,导致文件大小和修改时间发生变化。Honker 在每个 Database 实例中启动一个专用的 stat 线程,以 1 毫秒的间隔轮询 .db-wal 文件的 (size, mtime) 元数据。当检测到变化时,通过有界通道将「tick」事件分发给所有订阅者。每个订阅者随后执行 SELECT ... WHERE id > last_seen 从增量表中拉取新消息。
这个设计的核心优势在于:唤醒信号是文件级别的,不涉及数据库锁的竞争。100 个监听者共享同一个 stat 线程,而不是各自轮询数据库。idle 状态下完全不执行 SQL 查询,只有在 WAL 变化时才触发一次轻量级的索引扫描。
需要注意的是,stat 轮询存在约 1 毫秒的理论延迟上限。在实际的 M 系列芯片测试中,中位数延迟约为 1–2 毫秒。对于大多数异步任务投递场景,这个延迟是完全可以接受的。
实际使用中的关键参数
在生产环境中部署 Honker 时,以下参数需要根据业务负载调优。
队列相关:claim_batch(worker_id, n) 支持批量领取任务以减少数据库往返次数,但每次领取的任务数量直接影响单个事务的锁持有时间和失败时的重放成本,建议根据任务的平均执行时间在 10–50 之间调整。visibility_timeout_s 设置过短会导致 worker 处理超时后任务被重新投递,造成重复执行;设置过长则会影响故障转移的及时性,300 秒在大多数场景下是合理的起点。
流相关:save_every_n 和 save_every_s 控制消费者偏移量的持久化频率。如果设置过于频繁,每次持久化都会触发一次写事务,可能成为吞吐瓶颈;如果过于稀疏,程序崩溃时可能重复消费较多消息。高吞吐场景下建议保持默认值,每 1000 条或每秒持久化一次。
连接配置:wal_autocheckpoint 默认值 10000 表示每 10000 页(约 40MB)执行一次检查点。这个值越大,写入吞吐越高,但崩溃恢复时间越长。如需更频繁的检查点以缩短恢复窗口,可设置为 1000 或更低。
限制与适用边界
Honker 并非万能解药,其设计有明确的边界约束。首先,它要求数据库必须运行在 WAL 模式下,这对共享文件系统场景(如 NFS)不友好,因为多个服务器写入同一 SQLite 文件会导致数据损坏。其次,它是单写者架构 ——SQLite 本身的锁模型决定了同一时刻只能有一个活跃写入者,如果你的架构需要多节点写入同一数据库,请直接使用 PostgreSQL。
对于不需要实时推送的场景,或者可以接受数秒轮询延迟的情况,直接使用传统轮询可能是更简单的选择。Honker 的价值在于:当你已经以 SQLite 为主数据库,又需要毫秒级的跨进程响应时,它提供了无需额外部署 Redis + Celery 的轻量替代。
资料来源:
- Honker 官方仓库:https://github.com/russellromney/honker