2026年8月4日 2 分钟阅读

不想为一个异步邮件任务再起 Redis:用 Honker 把事务性 outbox 放进 SQLite

tinyash 0 条评论

很多小型服务的异步需求并不复杂:用户下单后发一封邮件、生成一份导出文件、同步到第三方 API。业务数据已经放在 SQLite,队列却常常被拆到 Redis、Celery 或另一套消息系统中。系统看似更“标准”,但随之而来的不是只有一个进程:还会多出备份策略、监控面、凭证、部署顺序,以及最棘手的双写窗口。

所谓双写,是指应用先写入 orders,再投递任务;或者先投递任务,再写订单。两个动作跨越两个持久化系统,就无法天然共享一次提交。进程在中间崩溃时,可能出现订单已经存在却没发邮件,也可能任务已经被消费者取走、订单却最终回滚。靠重试可以缓解,却很难从根源说明“这条任务为什么该执行”。

Honker 的切入点是:如果 SQLite 本来就是主数据存储,那么队列、事件流和跨进程通知也应保留在同一个数据库文件里。它是 SQLite 可加载扩展及多语言绑定,提供持久化队列、流、定时调度、锁与限流。项目采用 Apache-2.0 或 MIT 双许可证;不过当前仍明确标注为 Alpha,下面的取舍比“少一个依赖”更重要。

先解决真正的问题:把业务写入和投递变成一次提交

最值得关注的是事务性 outbox 模式。业务事务里既插入订单,也插入待执行的任务;只有事务提交,消费者才能看到它。若事务回滚,订单和任务会一起消失。对于“数据库记录必须先成立,才允许产生副作用”的场景,这比在应用层用两个客户端顺序调用更容易审计。

Python 绑定的最小示例如下。一个容易忽略的细节是:队列句柄要在打开事务前创建。Honker 的文档说明,首次打开队列可能初始化 schema;把它放进外层事务会触发单写者死锁保护。

pip install honker
import honker

def add_order_and_email(user_id: int, address: str) -> None:
    db = honker.open("app.db")
    emails = db.queue("emails")  # 在事务外创建队列句柄

    with db.transaction() as tx:
        tx.execute(
            "INSERT INTO orders (user_id, status) VALUES (?, ?)",
            [user_id, "paid"],
        )
        emails.enqueue(
            {"to": address, "template": "order-confirmed"},
            tx=tx,
        )

提交后,另一个进程中的 worker 才会醒来并取走工作:

async def email_worker(db):
    emails = db.queue("emails")
    async for job in emails.claim("mailer-1"):
        try:
            send_confirmation(job.payload["to"], job.payload["template"])
            job.ack()
        except TransientMailError:
            # 不确认任务,让队列按其重试与可见性超时机制处理
            raise

这里不能把语义说成“恰好一次”。Honker 的队列是 at-least-once:worker 在发信成功后、ack() 之前崩溃时,任务仍可能在可见性超时后再次被领取。因此副作用端必须可幂等。例如给邮件服务传递稳定的消息 ID,或在本地保存已投递键;支付、扣库存、外部写入等不可重复操作更应设计去重键和补偿路径。事务性 outbox 保证的是“已提交业务记录不会丢掉待处理意图”,而不是替外部系统消除重复执行。

没有服务端 push,为什么仍能让 worker 被唤醒?

SQLite 没有 PostgreSQL 那样的服务端 LISTEN/NOTIFY 通道。Honker 的稳定实现使用共享 watcher,默认每 1 ms 读取一次 PRAGMA data_version。数据库文件有提交后,订阅者和 worker 再用带索引的查询读取相关状态。也就是说,它不是向某个端口常驻推送消息,也不需要额外 broker daemon;“推送感”来自对 SQLite 提交变化的低成本探测与随后重读。

这种设计也解释了一个现象:如果多个队列复用同一个数据库文件,任意提交都可能唤醒 worker,即使本次没有它关心的任务。官方将其视为有意的 over-trigger:一次索引查询通常比漏掉唤醒更便宜。队列表的领取使用 UPDATE ... RETURNING,并以 pending/processing 状态的部分索引服务;确认任务则删除对应记录。旧任务历史不会线性拖慢待领任务的路径。

如果团队更偏向 SQL 而非绑定 API,也可以在可加载扩展的 SQLite 客户端中初始化 schema 并直接领取、确认批次:

.load ./libhonker_ext
SELECT honker_bootstrap();

INSERT INTO orders (user_id, status) VALUES (42, 'paid');
SELECT honker_enqueue(
  'emails',
  '{"to":"alice@example.com"}',
  NULL, NULL, 0, 3, NULL
);

SELECT honker_claim_batch('emails', 'mailer-1', 32, 300);
SELECT honker_ack_batch('[1,2,3]', 'mailer-1');

真实项目里应让订单写入与 honker_enqueue() 位于同一个 SQLite 事务,而不是把这段 SQL 当作两个独立请求执行。不同语言绑定与扩展共享同一套表结构,因此 Python 服务可以入队,Node、Rust 或其他支持的绑定可以消费;这对渐进式迁移尤其有用。

失败不是例外:把状态机和可观测性一起设计

采用同库队列后,最常见的误区是把“事务原子性”误解成“worker 永远不会失败”。前者只覆盖 SQLite 内的状态;消费者在调用邮件、对象存储或支付网关时,仍会遇到超时、限流和进程退出。实际落地时,应把任务 payload 保持为能独立重放的最小数据,例如订单 ID 和模板版本,而不是把一次 HTTP 响应或临时文件路径塞进去。worker 领取任务后,从业务表重新读取所需状态,既能避免陈旧 payload,也能让审计记录围绕稳定的业务主键展开。

还要区分可重试错误和永久错误。网络超时、第三方 429、短暂 DNS 故障通常可以等待后重试;地址不合法、订单已取消、模板版本不存在则应记录原因并进入人工处理或死信路径,不能无限循环。Honker 提供重试、延迟任务、可见性超时和 dead-letter rows,但“重试多少次、多久后重试、谁来处理失败记录”依然是应用的领域决策。把这些决定写进日志、指标和告警,才能在用户问“为什么这封邮件没发”时,从订单、任务状态到最后错误形成完整链路。

对于升级中的服务,建议先挑一个可幂等且低风险的异步动作试点:例如生成缩略图或发送内部通知。记录入队数、成功确认数、重试数、死信数和领取延迟,并人为制造一次 worker 在外部调用后退出的故障。若去重键、重试和告警都能给出预期结果,再逐步迁移更关键的任务。这个演练比只确认 enqueue() 能运行,更能验证 outbox 的设计是否真的闭环。

何时合适,何时应继续用专门 broker?

Honker 的边界非常明确:单机、文件型 SQLite。它不是多机分布式队列,也不提供跨机器分布式锁、多写副本、任务 DAG、chain 或 chord。不要把同一个 .db 放到 NFS 上,再让多台服务器同时写入;SQLite 的锁模型以及 Honker 的部署假设都不支持这种做法。内存数据库 :memory: 同样不适合跨进程唤醒。

它适合以下情况:单机部署的 SaaS、小型内部工具、桌面应用、本地优先服务,或者已经以 SQLite 为核心并希望减少基础设施的项目。此时,把订单、任务和消费状态纳入同一份备份与恢复流程,会比维护“SQLite + Redis + worker”更直观。队列还提供延迟任务、重试、优先级、死信记录、流消费者 offset、cron 与 @every 调度,足以覆盖不少后台作业。

反过来,如果任务吞吐和水平扩展已经要求多个主机协同写入,或者你需要跨地域可用性、复杂工作流编排、成熟的运营隔离与租户调度,Redis、RabbitMQ、Kafka、SQS 或数据库专用队列仍是更合适的边界。不要因为能少部署一个组件,就把单机工具扩展成分布式系统。

落地前的检查清单

  1. 先定义幂等键:任何 ack() 前可能成功的外部操作,都要能安全重放。
  2. 把任务创建放进业务事务:不是先提交订单再 enqueue,也不是反过来。
  3. 评估唤醒频率和空闲 CPU:默认 watcher 为 1 ms;低延迟不是免费午餐,可按空闲资源提高间隔。
  4. 验证备份恢复:队列与业务表共用文件是优点,但也意味着恢复演练必须确认任务状态是否符合预期。
  5. 按 Alpha 软件对待:在生产前运行项目提供的测试与基准,压测真实 worker 数、磁盘、WAL 配置和失败重试,而非套用“每秒数千消息”的泛化描述。

把异步任务从第二个数据系统搬回 SQLite,不会自动让架构简单;幂等、失败处理和监控仍然存在。但在单机 SQLite 的适用范围内,Honker 把“业务事实”和“稍后执行的意图”收进同一事务,为最难解释的双写失败提供了清晰的证据链。这比单纯少起一个 Redis 更有价值。

相关链接

发表评论

你的邮箱地址不会被公开,带 * 的为必填项。