Hotdry.

Article

用 Honker 在 SQLite 中构建持久化队列与流式处理

Honker 为 SQLite 带来 Postgres 风格的 NOTIFY/LISTEN、持久化流与任务队列,无需 Redis 或独立消息代理,探讨其工程实现与场景权衡。

2026-04-30systems

许多应用在使用 SQLite 作为主数据库后,很快就会面临一个经典抉择:要么引入 Redis + Celery 处理后台任务和事件流,要么在 Postgres 中利用其强大的消息队列功能。当业务只需要轻量级的任务调度或事件广播时,单独运维一套消息系统的成本往往超过了其带来的收益。Honker 试图回答这个问题:如果 SQLite 已经是主存储,为什么队列和流不能生活在同一个文件里?

从双重存储到单一文件

传统的任务队列架构需要在业务数据库和消息队列之间保持一致性。应用在创建订单的同时,需要向 Redis 或 RabbitMQ 发送一条任务消息;如果消息发送成功但订单因网络问题提交失败,就会产生状态不一致。这种双重写入问题需要额外的事务补偿或消息表轮询来解决。Honker 的核心思路是让队列本身成为数据库里的普通表行,与业务写入在同一个事务中提交或回滚。

具体实现上,Honker 是 Rust 编写的 SQLite 扩展,通过 PRAGMA data_version 每毫秒轮询一次来检测数据库提交事件。这个计数器在每次 commit 后单调递增,无论使用何种日志模式(WAL、DELETE、TRUNCATE)对所有连接都可见。检测到变化后,扩展通过共享通道唤醒每个订阅者,延迟可以控制在单数字毫秒级别。这意味着应用不再需要轮询任务表来「有没有新工作」,而是被动等待数据库变更通知。

三种原语的设计与适用场景

Honker 提供了三个核心抽象,分别对应不同的消息语义。

临时通知(Notify/Listen) 适用于跨进程的即时信号广播,类似于 Postgres 的 NOTIFY/LISTEN。发送方在事务中调用 notify(channel, payload),所有监听该 channel 的连接会收到通知。典型的使用场景是缓存失效:当订单状态变更时,通知所有工作进程刷新本地缓存。这种语义是「 firing and forgetting」,通知不会被持久化,监听者离线时会丢失。

持久化流(Stream) 提供带消费者偏移量的持久化发布订阅。每个具名消费者在 _honker_stream_consumers 表中独立记录自己的消费位置,支持从任意历史位置重放。流会自动按时间或事件数保存偏移量,避免高频写入时频繁更新消费者状态导致的写锁竞争。对于需要处理历史事件或支持多消费者独立消费日志的场景,这是合适的选择。

任务队列(Queue) 是最完整的功能封装,提供至少一次的投递语义。每个队列本质上是一张表,任务状态包括 pending、processing、dead。Claim 操作通过部分索引 (queue, priority DESC, run_at, id) WHERE state IN ('pending','processing') 高效定位可执行任务,ack 则删除对应行。队列支持配置化的可见性超时、重试次数、指数退避、延迟执行和死信队列。消费者进程崩溃后,任务会在可见性超时后自动重新可见;如果重试次数耗尽,任务移入死信表。

所有三种原语都支持在同一个事务中执行:插入业务数据的同时发布通知、写入流或入队任务,提交时一起持久化,回滚时一起丢弃。这种原子性是 Honker 相比外部队列系统的核心优势。

定时调度与延迟任务

除了即时队列,Honker 还提供了类似 Cron 的定时调度能力。通过 honker_scheduler_register 注册周期性任务,指定 crontab 表达式、执行命令和参数。调度器维护 next_fire_at 时间戳,工作进程调用 honker_scheduler_tick 获取当前应该触发的任务列表。结合队列的 run_at 延迟执行机制,应用可以实现「每天凌晨 3 点执行备份」这类定时任务,无需额外部署 cron 或外部调度系统。

性能特征与工程权衡

根据项目提供的基准测试,Honker 在现代笔记本电脑上可以处理每秒数千条消息,跨进程唤醒延迟的中位数在 1 到 2 毫秒。CPU 占用主要来自每毫秒一次的 PRAGMA data_version 查询,每次约 3.5 微秒,即使有 1000 个监听者,每秒的轮询开销也只有 3.5 毫秒。

但这不是没有代价的。首先,SQLite 本身的限制适用:单机器、单写者。如果应用需要多节点写入同一数据库,必须通过 Postgres 或其他方案实现,Honker 不提供多写者复制。其次,WAL 模式虽然推荐但非必须 —— 在 DELETE 模式下,轮询机制仍然有效,只是失去了并发读优于写的好处。第三,通知机制是「过度触发」的:每次数据库提交会唤醒所有监听者,即使它们关心的数据没有变化。Honker 的设计选择是宁可多唤醒几次也不能漏掉一次,因为漏掉意味着静默的正确性错误。

另一个需要考虑的是语言绑定与 ORM 的集成。Honker 提供了 Python、Node.js、Rust、Go、Ruby、Bun、Elixir 和 C++ 的绑定,也可以作为 SQLite 可加载扩展直接被任何支持 load_extension 的客户端调用。与 SQLAlchemy、Django、Drizzle、ActiveRecord、Ecto 等 ORM 的集成只需要在连接建立时加载扩展,然后在 ORM 的事务中调用 honker 函数即可。业务写入和任务入队在同一个数据库事务中完成,原子性由 SQLite 保证。

何时选择 Honker

Honker 适合以下场景:应用已经使用 SQLite 作为主存储,需要轻量级的任务队列或事件流,不希望引入额外的 Redis 或 RabbitMQ 运维成本,对单写者架构可以接受。典型的使用案例包括:后台邮件发送、图片处理、缓存失效通知、多进程间的状态同步、需要持久化重放的事件日志。

如果应用已经是 Postgres 后端,pg_notify + pg-boss 或 Oban 是更成熟的选择。如果需要多节点写入或跨地域复制,Honker 无法满足需求。如果任务量极大(每秒数万条以上),SQLite 的单写者限制可能成为瓶颈,此时 Redis 或 Kafka 仍是更合适的方案。

Honker 的价值在于简化:把消息队列折叠进已有的数据库文件中,消除了双重写入、状态不一致和额外运维的复杂性,同时保留了事务原子性和持久化保证。这种简化是有代价的 —— 它绑定在 SQLite 的架构约束上 —— 但对于许多中小型应用,这个 tradeoff 是值得的。


资料来源:Honker 项目 GitHub 仓库(russellromney/honker)及官方文档 honker.dev。

systems