不想引入 Redis,我用一张 SQLite 表实现了消息队列
最近在做一个本地跑的 AI Agent 编排工具。多个 agent 异步执行,用户消息、执行结果、审批回调这些事件需要可靠地在系统里流转。
什么叫可靠?agent 跑到一半崩了,进程被杀了,机器重启了------回来之后,没处理完的事件必须还在,自动重试。丢一个事件 = 丢一次工作成果。
所以我需要一个持久化的消息队列。但问题是:这是一个本地开发者工具,不是云服务。用户 npm install 就应该能跑起来。让人家再装个 Redis?不现实。SQLite 已经在用了,而且事务保证够强。那能不能直接拿 SQLite 当队列用?
为什么不能直接用一张 jobs 表?
很多人第一反应是建个表加个 status 字段:
sql
SELECT * FROM jobs WHERE status = 'pending' ORDER BY created LIMIT 1;
-- 处理完:
UPDATE jobs SET status = 'done' WHERE id = ?;
简单场景没问题。但是:两个消费者拿到同一行怎么办?消费者 SELECT 完还没来得及 UPDATE 就崩了怎么办?于是你开始加 locked_by、locked_at、重试计数、超时检查......写着写着发现自己在重新发明消息队列,还发明得很糙。
Go 生态有个 goqite,就是干这个的------基于 SQLite 的持久消息队列,参考 Amazon SQS 的模型。但 TypeScript 生态没有类似的东西。所以我自己撸了一个:sqliteq。
核心设计
一张表搞定所有队列
所有队列共享一张 sqliteq 表,新增队列不需要建表、不需要迁移,传个名字就行:
ts
const emails = new Queue(db, 'emails')
const sync = new Queue(db, 'sync-jobs')
表结构长这样:
sql
create table if not exists sqliteq (
id text primary key default ('m_' || lower(hex(randomblob(16)))),
created text not null default (strftime('%Y-%m-%dT%H:%M:%fZ')),
queue text not null,
body text not null,
timeout text not null default (strftime('%Y-%m-%dT%H:%M:%fZ')),
received integer not null default 0,
priority integer not null default 0
) strict;
注意没有 status 字段。timeout 时间戳本身就是状态------在过去就是可消费的,在未来就是被锁定的。这就是 SQS 的可见性超时(visibility timeout)模型。
一条 SQL 原子抢占,不需要锁
接收消息就一条 SQL:
sql
update sqliteq
set timeout = ?, received = received + 1
where id = (
select id from sqliteq
where queue = ? and ? >= timeout and received < ?
order by priority desc, created
limit 1
)
returning id, body, received
子查询找到下一条可用消息,外层 UPDATE 把 timeout 推到未来完成锁定------原子操作。没有 advisory lock,没有先 SELECT 再 UPDATE 的竞态条件。SQLite 本身的单写者保证就够了。
用 received 做防误删(Fencing Token)
一个容易忽略的问题:消费者 A 拿到消息后处理太慢,超时了。消费者 B 拿到同一条消息(重投递)。这时候 A 处理完去删除------但这条消息已经是 B 在处理的了。
received 字段解决这个问题。每次被消费都 +1。删除时必须带上你拿到的 received 值:
ts
const msg = queue.receive()
// msg.received === 1
queue.delete(msg.id, msg.received)
// DELETE ... WHERE id = ? AND received = ?
如果消息已经被重投递了,received 对不上,DELETE 影响 0 行,返回 false。过期的消费者最多删除失败,永远不会误删别人正在处理的消息。这和分布式锁里的 fencing token 是一个思路。
死信队列
消息被消费超过 maxReceive 次(默认 3 次)后,不再投递,变成死信。还在表里,但不会再被消费。你可以查看、重新入队或清理:
ts
const dead = queue.deadLetters()
// [{ id, body, received: 3 }]
性能
MacBook Pro M 系列,文件数据库(不是 :memory:),better-sqlite3:
| 操作 | 吞吐量 | 延迟 |
|---|---|---|
| send + receive + delete | ~20,000 ops/sec | 49 μs/op |
| 纯 send | ~31,000 ops/sec | --- |
| receive + delete(10 万行,10 个队列) | ~18,000 ops/sec | 55 μs/op |
| sendBatch(100 条/事务) | ~120,000 ops/sec | --- |
对本地工具来说完全够用。实际场景里瓶颈是 LLM API 调用(秒级),队列操作是微秒级的。
快速上手
bash
npm install @minnzen/sqliteq better-sqlite3
ts
import Database from 'better-sqlite3'
import { Queue } from '@minnzen/sqliteq'
const db = new Database('app.db')
const q = new Queue(db, 'tasks')
// 发送
q.send({ type: 'process', payload: 'data' })
// 接收并确认
const msg = q.receive()
if (msg) {
console.log(msg.body)
q.delete(msg.id, msg.received)
}
也有 Processor 类做长驻消费者,支持并发和自动续期:
ts
const processor = new Processor(q, {
handler(msg) {
// 正常返回 = 自动删除
// 抛异常 = 等超时后自动重试
},
concurrency: 4,
})
processor.start()
最后
sqliteq,MIT 协议,零运行时依赖,支持 better-sqlite3 和 bun:sqlite,TypeScript 泛型支持(Queue<T>)。
这个队列目前在我的 AI Agent 编排项目里跑事件循环------agent 崩溃、超时、重试在那个场景里不是边缘情况,是常态。不需要额外进程、只靠一个 SQLite 文件就能搞定持久队列,对我来说是最合适的方案。
如果你也有"只想要一个本地可靠队列"的需求,欢迎试试,GitHub 上提 issue 交流。