不想引入 Redis,我用一张 SQLite 表实现了消息队列

不想引入 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_bylocked_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 交流。

相关推荐
冷小鱼2 小时前
Milvus 向量数据库完全指南:开源架构与生产级部署实战
数据库·开源·milvus
Curvatureflight2 小时前
Redis实战:缓存设计与高频场景全解析
数据库·redis·缓存
前端摸鱼匠2 小时前
面试题2:Transformer的Encoder、Decoder结构分别包含哪些核心组件?
人工智能·深度学习·ai·面试·职场和发展·transformer
1688red2 小时前
基于Canal实现MySQL到Elasticsearch的数据同步
数据库·mysql·elasticsearch
m0_750580302 小时前
用Python生成艺术:分形与算法绘图
jvm·数据库·python
稻草猫.2 小时前
MyBatis进阶:动态SQL与MyBatis Generator插件使用
java·数据库·后端·spring·mvc·mybatis
华农DrLai2 小时前
什么是Prompt模板?为什么标准化的格式能提高稳定性?
数据库·人工智能·gpt·nlp·prompt
腾视科技TENSORTEC2 小时前
安全驾驶 智在掌控|腾视科技ES06终端,为车辆运营赋能
大数据·人工智能·科技·安全·ai·车载系统·车载监控
2301_819414302 小时前
Python入门:从零到一的第一个程序
jvm·数据库·python