Show HN: 基于Erlang/OTP的轻量级任务队列,SQLite存储,无过度设计

Hacker News Top 工具

摘要

EZRA 是一个轻量级、持久化的任务队列,基于 Erlang/OTP 构建,使用 SQLite 存储。它对外提供兼容 Redis 的接口,使任何 Redis 客户端都可以无需 Redis 服务器即可推送和弹出任务。

搭建 Kafka 等企业级软件,配合它们的集群或专用服务器,既沉重又麻烦,导致大多数小团队或个人开发者完全跳过它,转而使用内存队列作为折中方案。<p>我想要的是一种折中方案:一个持久化队列,运行简单(一个二进制文件,生成一个 sqlite 数据库),借助 Elixir 实现真正的故障隔离和崩溃恢复,易于检查(在任何 SQLite 浏览器中打开 ezra.db 就能看到每个任务),并且无需新的客户端库——它使用 Redis Streams 线协议,因此任何语言中的任何 Redis 客户端都能直接开箱即用。<p>非常简短的演示视频:[<a href="https://www.youtube.com/watch?v=MLYyD3DVWmE" rel="nofollow">https://www.youtube.com/watch?v=MLYyD3DVWmE</a>]
查看原文
查看缓存全文

缓存时间: 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

Reddit r/LocalLLaMA

MiMo-V2.6-Flash-MOPD 是对 MiMo-V2.6-Flash-RL 模型的升级,它采用来自领域专用教师的策略内蒸馏,以提升性能并缓解智能体任务中的工具调用重复问题。

postmarketOS 品牌重塑:Nura

Hacker News Top

开源操作系统 postmarketOS 已更名为 Nura,该名称源自古代撒丁岛建筑,旨在提升影响力并便于商标注册。