Worker崩溃了怎么办:honker At-Least-Once语义、崩溃恢复与故障注入完全指南
【免费下载链接】honkerSQLite extension + bindings for Postgres NOTIFY/LISTEN semantics with durable queues, streams, pub/sub, and scheduler项目地址: https://gitcode.com/gh_mirrors/ho/honker
你的任务队列 Worker 突然崩溃了,正在处理的消息丢了吗?honker 是一款为 SQLite 提供 Postgres NOTIFY/LISTEN 语义的扩展,内置持久化队列、流、发布/订阅与调度器。它采用At-Least-Once(至少一次)投递语义:Worker 崩溃、进程被强杀、甚至磁盘写满,任务都不会凭空消失,而是通过可见性超时、重试预算和死信表自动恢复。本文将带你彻底理解这套机制,以及项目如何用真实的SIGKILL和故障注入测试来证明它。
为什么需要 At-Least-Once 语义
任务队列的核心难题是:Worker 拿到任务后崩溃了,任务怎么办?
honker 的回答是:
| 机制 | 说明 |
|---|---|
| 可见性超时 | 任务被认领后有claim_expires_at截止时间,超时未确认则重新可见 |
| 重试预算 | max_attempts限制重试次数,防止无限循环 |
| 死信表 | 重试耗尽的任务进入_honker_dead表,附带last_error原因 |
| 同事务入队 | enqueue与业务写入在同一事务提交,回滚则一起消失 |
核心思想只有一句话:任务行就写在 SQLite 文件里,崩溃改变不了已经提交的事实,而没提交的写入会随进程一起回滚。
三种典型崩溃场景与恢复机制
场景一:Worker 认领后直接"猝死"
这是最常见的场景。Worker 通过claim()拿到任务,还没执行ack()就挂了。honker 的恢复流程是:
- 任务的
claim_expires_at到期后,行变回可认领状态 - 其他 Worker(或重启后的同一 Worker)再次认领,
attempts计数 +1 - 若
attempts超过max_attempts,任务不再被认领,而是移入死信表,last_error标记为max attempts exceeded
这套逻辑在回归测试中被逐行验证:tests/test_max_attempts_reclaim.py 中模拟了 Worker 认领后不ack就"死亡"的过程,断言第三次认领时任务已被死信而非重新投递——这修复过一个真实 bug:早先claim的回收路径不检查max_attempts,导致任务被无限回收。
场景二:进程在事务中途被 SIGKILL
比崩溃更狠的是内核级强杀。项目测试 tests/test_crash_recovery.py 的做法堪称教科书:
# 子进程开启 BEGIN IMMEDIATE 并写入一条任务,然后父进程直接杀它 with db.transaction() as tx: q.enqueue({"i": 999}, tx=tx) print("READY", flush=True) time.sleep(60) # 父进程在这里 SIGKILL 我们强杀之后,一个全新进程打开同一个.db文件,验证四件事(见 test_sigkill_mid_enqueue_tx_leaves_db_clean):
- ✅ 文件未损坏:
PRAGMA integrity_check返回ok - ✅ 被杀掉的写入没有泄漏:任务表里零残留行
- ✅ 崩溃后 enqueue → claim → ack 完整链路照常工作
- ✅ 数据库不卡在写锁状态:新写者能立即获取锁(WAL 模式自动恢复)
还有一个精妙细节:被强杀的、携带notify()通知的事务不会产生幽灵通知——回滚的 INSERT 从未离开 WAL,新挂上的监听器看不到任何来自已死事务的消息(test_sigkill_mid_honk_tx_delivers_no_notification)。
场景三:Worker 收到任务但处理失败
对于"活着但处理出错"的场景,Worker 应显式调用job.retry(delay_s=..., error=...)。典型的 Worker 循环长这样(完整示例见 packages/honker/examples/worker.py):
async for job in emails.claim("worker-1"): try: await send_email(job.payload) job.ack() except Exception as e: job.retry(delay_s=0, error=str(e)) # 重试耗尽后自动进死信注意max_attempts同时约束主动重试和可见性超时回收——两条路径共享同一个重试预算,任务不会从任何一个口子绕过死信机制。
故障注入:沉默失败是持久化库的最大罪
honker 的测试哲学写在 tests/test_fault_injection.py 的注释里:"对持久化库来说,最坏的结果是沉默失败——一个不报错就丢任务的队列,或者卡在不可写 WAL 上的监听器。"每个故障模式都必须抛出清晰、可向上传播的错误。
项目实测的故障清单:
| 故障 | 期望行为 | 测试位置 |
|---|---|---|
| 数据库文件损坏(头信息被毁) | 首次使用即抛出not a database类错误,绝不伪装成空库 | test_corrupted_db_file_raises_on_first_use |
| 只读目录 | 打开时明确报unable to open | test_readonly_directory_raises_clear_error |
| 只读 .db 文件 | 首次写入报readonly database,绝不静默丢弃 | test_readonly_db_file_raises_on_write |
| 父目录不存在 | 立即报错,不静默建目录也不挂起 | test_nonexistent_parent_dir_raises |
| 磁盘写满(ENOSPC) | 挂载 1MB tmpfs 后持续写入,必须抛SQLITE_FULL | test_enqueue_on_full_filesystem_raises_disk_full |
最后一项尤其硬核:测试在 Linux 上挂载一个只有 1MB 的 tmpfs,把数据库放上去,然后不断 enqueue 大负载直到磁盘写满,断言第 N 次写入必须抛出可识别的错误——而不是挂起,更不是静默吞掉任务。
生产环境实践清单
基于 honker 的语义,你的 Worker 服务只需要记住这几点:
enqueue与业务数据放同一事务——INSERT INTO orders和queue.enqueue(...)同提交同回滚,不存在双写不一致。- 认领任务前先想好可见性超时——
visibility_timeout_s应大于任务最长执行时间,否则慢任务会被其他 Worker 抢走。 - 监控死信表——定期查询
_honker_dead,last_error字段直接告诉你任务死因(如max attempts exceeded)。 - 重启无需手工清理——崩溃的 Worker 不
ack即可,可见性超时会把它手中的任务自动收回队列。 - 用文件型 SQLite,别用
:memory:——跨进程唤醒依赖PRAGMA data_version计数器变化,内存库没有这条路径。
总结
honker 把"Worker 崩溃"从噩梦变成了确定性问题:已提交的任务在文件里,崩溃的写入随事务回滚,超时未确认的任务自动回收,重试耗尽的任务进入死信表可审计。这一切不是口头承诺——项目用真实子进程的SIGKILL、损坏的文件头、只读目录和 1MB 的满盘 tmpfs 逐一验证了每种故障下的行为(测试入口见 tests/,快速跑法为make test)。
如果你正在用 SQLite 做主存储并需要一个可靠的任务队列,这套"崩溃后依然正确"的设计值得参考。更多用法可浏览 examples 目录 与 BINDINGS.md 中的各语言绑定支持矩阵。
【免费下载链接】honkerSQLite extension + bindings for Postgres NOTIFY/LISTEN semantics with durable queues, streams, pub/sub, and scheduler项目地址: https://gitcode.com/gh_mirrors/ho/honker
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考