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

相关推荐
阿菜ACai8 小时前
Claude 和 Codex 在审计 Skill 上性能差异探究
ai·代码审计
SharpCJ9 小时前
Android 开发者为什么必须掌握 AI 能力?端侧视角下的技术变革
android·ai·aigc
科技小花10 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸10 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain10 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希10 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神10 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员10 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java11 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿11 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb