Show HN: 基于Erlang/OTP的轻量级任务队列,SQLite存储,无过度设计
摘要
EZRA 是一个轻量级、持久化的任务队列,基于 Erlang/OTP 构建,使用 SQLite 存储。它对外提供兼容 Redis 的接口,使任何 Redis 客户端都可以无需 Redis 服务器即可推送和弹出任务。
查看缓存全文
缓存时间: 2026/06/13 14:53
entGriff/ezra 源码: https://github.com/entGriff/ezra
Exchange via Zero-loss Relay Agent (通过零丢失中继代理进行交换)
EZRA 是一个持久的任务队列。多个服务可以向其中推送任务,多个工作器从其中拉取任务并在完成后确认。每个任务都会保持可见并被明确追踪,直到某个工作器标记它完成——不会静默丢失,不会即发即忘。它由 SQLite 支撑,由 Erlang/OTP 运行时驱动。工作器可以使用任意语言的任何 Redis 客户端(无需 Redis 本身)进行连接——无需新的 SDK。
本项目由单个作者维护,不接受 pull requests。欢迎提交 bug 或问题相关的 issue。
Demo:
目录
快速开始
docker run -d --name ezra \
-p 42002:42002 \
-v ezra_data:/data \
ghcr.io/entgriff/ezra
这就是完整的服务器设置。现在,在任何能访问该端口的机器上:
生产者 —— 推送一个任务
import redis
r = redis.Redis(host="localhost", port=42002, decode_responses=True, protocol=3)
# 将任务推入 "emails" 队列。
# 队列无需预先创建——第一次推送即自动创建。
r.xadd("emails", {"payload": '{"to": "[email protected]"}'})
工作器 —— 弹出并处理任务
import redis
r = redis.Redis(host="localhost", port=42002, decode_responses=True, protocol=3)
while True:
# 向 Ezra 请求 "emails" 的下一个任务。
# "workers" —— 消费者组名,协议需要但 Ezra 忽略。
# "worker-1" —— 此工作器的标识(每个进程需唯一名称)。
# {"emails": ">"} —— 给我这个队列中下一个未交付的任务。
# block=0 —— 无限等待;Ezra 会在任务到达时立即交付。
results = r.xreadgroup("workers", "worker-1", {"emails": ">"}, count=1, block=0)
if results:
for task_id, fields in results["emails"][0]:
send_email(fields["payload"]) # 你的处理代码
# 确认成功。不加此确认,Ezra 会在可见性超时(默认 30 秒)后重新交付任务。
r.xack("emails", "workers", task_id)
任何语言(Python、Node.js、Go、Ruby、Java)的 Redis 客户端都可以按同样方式工作。将客户端指向端口 42002 而不是 Redis。
可运行的 Docker Compose 示例(Python 和 Node.js)参见 github.com/entGriff/ezra-examples (https://github.com/entGriff/ezra-examples/)。
整体架构
EZRA 概览
服务和工作器可以在任意机器上以任意语言运行。工作器在就绪时主动拉取任务——Ezra 会立即交付可用任务,如果暂无则保持连接直到有任务到达。所有数据持久化到服务器上的 ezra.db 文件。
| 每个连接的工作器内存占用 | ~2 KB(非 OS 线程) |
| 基线内存 | ~20 MB |
| 典型云 VM(SSD)上的吞吐量 | ~15k–30k 任务/秒 |
| NVMe 上的吞吐量 | ~40k–80k 任务/秒 |
| 二进制体积 | ~20 MB,独立 |
吞吐量受 SQLite 写入速度限制,而写入速度取决于磁盘。引擎本身每次调用增加约 1–5 μs 的开销。
为什么存在这个项目?
几乎每个应用在某个时刻都需要在请求周期之外完成一些工作——发送邮件、生成 PDF、调用慢速 API。你希望立即向用户返回响应,同时在后台处理这些任务,失败时重试,并且服务器重启时不丢失它们。这正是任务队列的用武之地。
已经有了一些经过实战检验的方案,但如果你没有百万级 RPS,它们会带来真正的开销和过度设计:需要配置集群、维护专用服务器、获取运维知识,或者依赖云服务商。大多数团队最终完全跳过持久队列,使用内存中的任务,在重启时静默丢失工作。
EZRA 是另一个选择,它不显得沉重。一个二进制文件,一个 SQLite 文件,任何语言的任意 Redis 客户端。无需代理,无需集群,无需预先配置。运行它,你就可以用任何 SQLite 浏览器打开数据库,查看队列中的确切内容。
工作原理
EZRA 使用 RESP3 —— 即 Redis 使用的相同网络协议。每种语言的每个 Redis 客户端库都已经知道如何与之通信。将客户端指向 EZRA 的端口而不是 Redis,它就能正常工作,无需修改。
EZRA 实现的具体命令来自 Redis Streams —— Redis 围绕“消息必须显式确认才算完成”这一理念构建的部分:
- XADD —— 将任务推入命名队列
- XREADGROUP —— 弹出下一个任务并以工作器身份认领;支持阻塞模式,因此工作器无需轮询
- XACK —— 确认任务已成功处理
- XDEL —— 报告失败;EZRA 会将任务返回进行重试,而不是删除它
- XNACK —— 与 XDEL 相同,适用于 SDK 能直接发送任意命令的客户端
所有其他 Redis 支持的命令(GET、SET、pub/sub 等)都会返回错误。EZRA 并不是要成为 Redis。
任务生命周期
stateDiagram-v2
[*] --> available : 推送
available --> in_flight : 弹出
in_flight --> available : 崩溃、超时或 nack(还有重试次数)
in_flight --> done : ack
in_flight --> dead : nack 或超时,无剩余重试次数
dead --> [*] : 可通过 queue::dead 读取
任务永远不会被静默丢失。 任务会一直留在队列中,直到有工作器显式声明它已完成。如果 EZRA 重启,在接收任何新工作之前,所有正在处理的任务都会立即恢复到 available 状态。
nack 之后,同一个工作器会再次拿到同一个任务吗? 会的。当任务被 nack 后,它回到 available 状态,下一次弹出——无论来自哪个工作器,也包括同一个——都可以认领它。如果你希望避免紧循环重试,可以在工作器失败后与下一次弹出之间加入短暂的睡眠。last_error 字段会存储 nack 的原因以供检查。
出错了怎么办
工作器在任务处理过程中崩溃或断开连接。 任务保持 in_flight 状态。调度程序会在 visibility_timeout 秒(默认:30)后回收它,将其放回 available,并增加 attempts 计数。
EZRA 自身崩溃。 工作器会看到 TCP 连接断开。重启后,EZRA 会立即将所有正在处理的任务重置回 available,然后才接受新连接。崩溃时正在处理的任务可能会再次运行——这是设计上的至少一次交付,而不是故障模式。不会有任务丢失。
任务反复失败。 超过 max_attempts(默认:3)后,任务会进入 dead 状态,落在 ::dead 中,可通过相同的 XREADGROUP 接口查询。不会静默丢弃。
工作器运行缓慢。 如果某个工作器在 visibility_timeout 内未能确认,任务会被回收并重新交付给另一个工作器。请根据工作负载为每个队列设置合适的 visibility_timeout——按最坏情况而非平均情况来设定。
多个工作器和生产者
EZRA 通过 TCP 暴露网络 API。任何能访问该端口的机器都可以推送任务或弹出任务。无需注册,无需按客户端配置——只需连接即可使用。参见整体架构的可视化概览。
- 任意数量的生产者客户端可以同时向同一队列推送
- 任意数量的工作器客户端可以从同一队列弹出——每个任务只会分配给一个工作器,不会重复
- 工作器通过你提供的唯一名称(
worker-1、worker-2等)来标识 - EZRA 使用这些名称来追踪哪些任务正在被哪个进程处理
- 阻塞弹出会保持连接打开,并在任务到达时立即交付——无需轮询
工作按需分配:哪个工作器最先完成,就请求下一个任务并立即获得。通过运行更多工作器来扩展——无需协调,也无需在 EZRA 中更改任何配置。
关于 SQLite 和远程访问的说明。 没有人会远程连接 SQLite。只有 EZRA 内部引擎会访问该文件,且与 EZRA 在同一台机器上运行。外部客户端通过 TCP 与 EZRA 通信。真正的限制是 EZRA 本身是单节点的:所有数据都存储在其运行的同一台机器上。
权衡
- 单节点。 所有数据存储在一台机器上。如果该机器不可用,队列也不可用。数据本身是安全的——SQLite 是一个普通文件,容易通过任何标准文件同步工具(rsync、litestream、文件系统快照)进行备份或复制。可用性取决于主机,而非 EZRA。
- 至少一次,而非恰好一次。 如果工作器在确认之前可见性超时到期,一个任务可能会运行多次。这是一个有意为之的设计选择——跨网络的恰好一次交付需要双方进行分布式事务协调,队列本身无法保证。请正确设置
visibility_timeout,并设计工作器以处理重复任务。 - 可见性超时不是即时的。 崩溃工作器的任务会在
visibility_timeout秒后回收,而不是在断开连接时立即回收。(可能在后续版本中修复) - 任务会累积。 除非为队列设置了
retention_seconds,否则已完成的任务会永久保留。如果不设置,数据库会无限增长。 - 无扇出。 一个任务只会分配给一个工作器。对于广播模式,请为每个订阅者推送一个任务。
- 无优先级队列。 变通方案:使用不同的队列名称(
jobs.high、jobs.low),工作器同时消费多个队列。 - 无延时任务。 任务在推送后立即可用。不支持计划延迟交付。(可能在后续版本中修复)
- 建议有效载荷不超过 100KB。 SQLite 可以处理更大的 BLOB,但性能会下降。请将大块数据存储在外部,并在有效载荷中放入引用。
- 无跨队列事务。 不支持原子性地向两个队列推送。
EZRA 适合你吗?
适合的场景
- 后台作业:邮件发送、PDF 生成、图片缩放、Webhooks
- 需要可靠性但对亚毫秒延迟没有要求的任何异步工作
- 多语言团队——每个服务使用自己的语言,共享同一个队列
- 早期产品,运行 Kafka 或 RabbitMQ 显得大材小用
- 单机或单 VM 部署,SQLite 的单节点限制可以接受
不适合的场景
- 多节点高可用,不允许停机窗口
- 同一消息必须到达多个消费者的 pub/sub 或扇出模式
- 吞吐量超过 SQLite 的写入上限(~30-80k/秒,取决于磁盘)
- 事件溯源或审计日志,流本身就是主要数据模型
- 在代理层进行复杂路由、过滤或转换
安装
预编译二进制文件:github.com/entGriff/ezra/releases (https://github.com/entGriff/ezra/releases)
更喜欢容器?参见 docs/usage.md 中的 Docker 部分。
# macOS (Apple Silicon)
curl -Lo ezra https://github.com/entGriff/ezra/releases/latest/download/ezra-macos_arm64
chmod +x ezra
# Linux x86_64
curl -Lo ezra https://github.com/entGriff/ezra/releases/latest/download/ezra-linux_x86_64
chmod +x ezra
# Linux arm64
curl -Lo ezra https://github.com/entGriff/ezra/releases/latest/download/ezra-linux_arm64
chmod +x ezra
无需运行时。二进制文件是自包含的(~20 MB)。
运行
./ezra --data-dir /var/ezra
EZRA 会在首次运行时在数据目录中创建 ezra.db。后续每次启动都会打开现有文件——你的任务正好停留在你上次离开的地方。
选项也可以通过环境变量设置:
EZRA_DATA_DIR=/var/ezra EZRA_PORT=42002 ./ezra
发送 SIGTERM 或按 Ctrl+C 停止。EZRA 会完成正在进行的操作并干净地关闭。
完整的选项参考、Docker 部署示例、各语言客户端代码片段和 systemd 设置,请参见 docs/usage.md。
Elixir
如果你正在构建一个 Elixir 应用程序,EZRA 可以嵌入到你的进程中运行——你自己的工作器无需经过 TCP 跳转。
# mix.exs
{:ezra, "~> 0.1"}
# application.ex
children = [
{Ezra, name: :ezra, data_dir: "priv/ezra"}
]
# 直接的进程内调用,无需网络
{:ok, id} = Ezra.push(:ezra, "emails", payload)
{:ok, task} = Ezra.pop(:ezra, "emails", worker_id: "w1", block: 30_000)
:ok = Ezra.ack(:ezra, task.id)
完整指南请参见 docs/elixir-client.md。
术语
push —— 向队列中添加一个新任务。
pop —— 取出下一个要处理的任务。该任务不会被删除——它只是被临时借出。你必须在你完成后进行确认。
ack (acknowledge) —— 告诉 EZRA “我完成了这个任务”。它会被标记为 done,不再分配给任何人。已完成的任务会保留在数据库中——它们会随时间累积,除非你为队列配置了 --retention-seconds,或者在推送任务时使用了 ttl_seconds 选项。
nack (negative acknowledge) —— 告诉 EZRA “我失败了”。EZRA 会将其放回队列,供其他工作器在 max_attempts 次数内重试。
in_flight —— 一个已被弹出但尚未确认的任务。如果工作器失去响应,EZRA 会在 visibility_timeout 秒后回收它。
延伸阅读
- github.com/entGriff/ezra-examples (https://github.com/entGriff/ezra-examples/) —— 可运行的 Docker Compose 示例(Python、Node.js)
- docs/usage.md —— 语言客户端、完整使用示例、Docker、选项参考、systemd
- docs/architecture.md —— 存储模式、模块图、网络协议、遥测
- docs/elixir-client.md —— Elixir 库模式参考
相似文章
MiMo v2.6 Flash MOPD
MiMo-V2.6-Flash-MOPD 是对 MiMo-V2.6-Flash-RL 模型的升级,它采用来自领域专用教师的策略内蒸馏,以提升性能并缓解智能体任务中的工具调用重复问题。
postmarketOS 品牌重塑:Nura
开源操作系统 postmarketOS 已更名为 Nura,该名称源自古代撒丁岛建筑,旨在提升影响力并便于商标注册。
我们发布了VeriLoop E2(27B,Apache-2.0)。其背后的设计问题是:LLM是否应该被允许提交自己的状态?
VeriLoop E2 的发布,这是一个基于 Qwen3.8-27B 进行后训练的 27B LLM,采用了 VeriLoop-Governed Recurrence (VGR) 来管理状态转换并生成训练信号。
@yibie: https://x.com/yibie/status/2104217838953087098
本文介绍了如何在Databricks平台上使用SQL运行开源决策模型SemIf-OpenJev,实现数据不出平台即可调用模型进行批量处理。
关于动态图形的Opus 5.5帖子很酷,但Qwen 27B在4090上做了这个
本文讨论了使用Qwen 27B模型在4090 GPU上创建动态图形,突出了受Opus 5.5启发的本地AI能力。