1. 从“buzz”这个名字说起:一个用 Rust 写的 Nostr 中继到底在解决什么问题
第一次看到buzz这个项目名,很多人会以为是某个消息推送库或者音频处理工具,但把它和Nostr、relay、Rust、git这几个词放在一起,方向就清楚了:这是一个用 Rust 实现的 Nostr 中继(relay)服务端。Nostr 这套协议的核心思路很朴素——用户身份靠密钥对,内容靠事件(event)签名,事件通过一个个中继节点做存储和转发。中继本身不掌握用户身份,也不负责内容审核的最终裁决,它更像是一个“公共布告栏 + 转发站”。而buzz要做的,就是把这个布告栏用 Rust 重新实现一遍,追求更高的并发吞吐、更低的内存占用,以及更可控的部署方式。
我接触 Nostr 相关项目有一段时间了,早期很多中继是用 Node.js 或者 Go 写的,跑起来确实快,但一旦连接数上去、事件写入量变大,内存和 CPU 的曲线就有点难看。Rust 在这个场景下的优势非常直接:没有 GC 停顿、异步运行时成熟(tokio)、序列化性能强(serde + JSON)、单二进制部署不需要额外运行时。buzz选择 Rust,本质上是在赌“中继这种长连接 + 高频写入 + 广播分发的场景,Rust 的确定性延迟更值钱”。
这篇文章适合三类人看:一是想自己搭一个 Nostr 中继、但不想被复杂配置劝退的开发者;二是正在学 Rust、想找一个真实项目练手的入门者;三是已经用过其他中继、想对比一下 Rust 实现到底强在哪的老玩家。我会从项目结构、核心模块、实操部署、常见坑几个角度拆开讲,尽量把“为什么这么设计”讲透,而不是只丢一堆命令让你抄。
需要先说明一点:下面涉及的具体目录结构、配置项命名、命令参数,是基于 Rust 异步服务端项目的常见实践做的合理补全,buzz的实际实现可能在某些细节上不同,但整体思路和排查方法是可以直接迁移的。你照着做的时候,遇到不一致的地方,按同样的逻辑去对应即可。
2. 项目整体设计与思路拆解
2.1 为什么是 Rust + tokio,而不是 Go 或 Node
先把这个选择讲清楚,因为后面所有的代码结构和部署方式都建立在这个前提上。Nostr 中继的工作模型其实很典型:每个客户端一条 WebSocket 长连接,客户端发来的是一堆 JSON 事件,服务端要做的三件事是——验证签名、落库、广播给订阅了对应条件的其他连接。这三件事里,验证签名是 CPU 密集型(secp256k1 椭圆曲线运算),落库是 IO 密集型,广播是内存和网络密集型。
Go 的 goroutine 模型处理长连接很舒服,但 GC 在高频分配场景下会产生尾延迟;Node.js 单线程事件循环在签名验证这种 CPU 操作上容易阻塞。Rust + tokio 的组合,恰好把“每连接一个轻量任务 + 无 GC + 同步 CPU 计算不阻塞运行时”这几点都占了。具体到buzz,我推测它的任务模型是这样的:主线程跑 tokio runtime,每个 WebSocket 连接 spawn 一个 task,事件验证可以放在spawn_blocking或者直接用 rayon 线程池处理,避免阻塞异步调度器。
这里有个经验点:很多人写 Rust 异步服务,习惯把所有逻辑都塞进 async 函数里,结果一个签名验证耗时 200 微秒,几千个连接同时来就把 runtime 卡住了。正确做法是把纯 CPU 的密码学运算丢到tokio::task::spawn_blocking,或者用专门的线程池。buzz如果做得好,这一点应该是处理过的。
2.2 数据模型:事件、订阅、过滤器三件套
Nostr 协议里最核心的三个概念,理解了它们,整个中继的逻辑就通了一半。
事件(Event)是一条签名过的 JSON,包含id、pubkey、created_at、kind、tags、content、sig这些字段。id是序列化后做 SHA256 得到的,sig是用私钥对id做的 Schnorr 签名。中继收到事件后,第一件事就是重算id、验证sig,任何一步不对就直接拒绝。
订阅(Subscription)是客户端发来的一条REQ消息,带一个订阅 ID 和一组过滤器。中继要记住这个连接订阅了什么,之后每来一条新事件,都要拿它去匹配所有活跃订阅。
过滤器(Filter)是匹配条件,可以按ids、authors、kinds、since、until、#e、#p等维度筛选。多个过滤器之间是“或”的关系,单个过滤器内部多个字段是“与”的关系。
buzz的设计难点就在这里:如果每次来新事件都遍历所有订阅、逐个匹配,连接数一多就是 O(N×M) 的复杂度。合理的做法是给订阅建索引,比如按kind分桶、按author建哈希表,新事件来了先查索引缩小候选集,再做精确匹配。这是 Rust 实现能拉开性能差距的地方,因为索引结构可以用紧凑的数据类型,内存开销比动态语言小很多。
2.3 存储选型:SQLite 还是 Postgres
中继的存储层选择,直接决定了它的定位。用 SQLite 的话,单机部署极简,一个文件搞定,适合个人中继和小团队;用 Postgres 的话,支持更高并发写入和复杂查询,适合公共中继。
从buzz这种追求轻量的命名和 Rust 生态的常见搭配来看,我倾向于它默认走 SQLite(通过sqlx或rusqlite),同时留出 Postgres 的适配接口。SQLite 在 WAL 模式下,单机写入性能其实不差,配合busy_timeout和批量事务,撑住几千连接的中继没问题。关键是要开 WAL、设置合理的synchronous级别,并且把写入集中到一个任务里串行化,避免多连接并发写导致database is locked。
提示:SQLite 的
database is locked是中继部署里最常见的报错之一,根源通常是多个写事务并发。解决办法不是加大 timeout,而是把写操作收敛到单一 writer task,用 channel 传递待写入事件。
2.4 与 git 的关系:版本管理与部署流水线
热词里出现了git、git worktree、git commit --amend这些,说明这个项目的开发和使用过程高度依赖 git。这很合理——Rust 项目用 cargo 管理依赖,源码用 git 管理,部署时经常是git clone到服务器再cargo build --release。
我自己的习惯是用git worktree来管理多个分支的并行开发:主分支跑稳定版,feature 分支单独开一个工作目录,不用来回git stash。部署的时候,服务器上保留一个 bare 仓库,通过git push触发构建,比直接git clone再git pull更干净。这些技巧后面会展开讲。
3. 核心细节解析与实操要点
3.1 事件验证:签名校验不能省,但可以批量化
事件验证是整个中继的安全底线。buzz收到一条事件,必须做这几步检查:
- 字段完整性:
id、pubkey、sig是否都是 64 位十六进制字符串。 id重算:把[0, pubkey, created_at, kind, tags, content]序列化成规范 JSON,做 SHA256,和声明的id比对。- 签名验证:用
pubkey对id做 Schnorr 签名验证。 - 时间戳合理性:
created_at不能太离谱(比如未来时间),否则容易被刷。
第 2 步和第 3 步是性能热点。单条验证大概几十到几百微秒,看起来不多,但一秒来几千条事件就顶不住了。优化手段有两个:一是用secp256k1crate 的批量验证接口,把多条签名攒一批一起验;二是把验证任务分发到多核,用 rayon 或者 tokio 的 blocking 线程池。
// 伪代码示意:批量验证的思路 let events: Vec<Event> = receive_batch().await; let results: Vec<bool> = events .par_iter() .map(|e| verify_event(e)) .collect();注意:批量验证虽然快,但一旦某条失败,要能定位到具体是哪条,否则客户端会收到莫名其妙的拒绝。建议批量验证失败后回退到逐条验证,把失败的那条挑出来。
3.2 订阅匹配:索引结构决定上限
订阅匹配是另一个性能关键点。假设有 5000 个活跃连接,平均每个连接 3 个订阅,那就是 15000 个过滤器。每来一条新事件,如果全量遍历,就是 15000 次匹配,一秒 1000 条事件就是 1500 万次匹配,纯浪费。
合理的索引设计是这样的:
| 索引维度 | 数据结构 | 用途 |
|---|---|---|
| kind | HashMap<u64, Vec > | 按事件类型快速筛选 |
| author | HashMap<Pubkey, Vec > | 按作者筛选 |
| tag #e | HashMap<EventId, Vec > | 按引用事件筛选 |
| tag #p | HashMap<Pubkey, Vec > | 按提及用户筛选 |
| 全局 | Vec | 无过滤条件的订阅 |
新事件来了,先按kind和author查出候选订阅集合,取交集或并集,再做精确的since/until/limit判断。这样大部分情况下候选集很小,匹配开销可以忽略。
这里有个坑:订阅是动态增删的,索引要跟着更新。连接断开时,必须把该连接的所有订阅从索引里摘掉,否则内存泄漏会越来越严重。我见过有中继跑几天内存涨到几个 G,最后发现就是断开的订阅没清理。
3.3 广播分发:背压处理是生死线
匹配到订阅之后,就要把事件推给对应的连接。这一步最容易出问题的地方是背压:某个客户端网络慢,发送缓冲区满了,如果你还往里塞,内存就会爆。
Rust 里处理背压的标准做法是给每个连接一个mpsc::channel,容量设一个上限(比如 256)。广播任务往 channel 里try_send,如果满了就丢弃这条消息或者断开该连接。丢弃对 Nostr 来说是可接受的,因为客户端重连后会重新拉取历史事件。
// 伪代码:带背压的发送 match tx.try_send(msg) { Ok(_) => {} Err(TrySendError::Full(_)) => { // 缓冲满,记录并考虑断开慢客户端 metrics.slow_client.inc(); } Err(TrySendError::Closed(_)) => { // 连接已关闭,清理订阅 } }提示:不要用
send().await在广播路径上等待,那会让一个慢客户端拖垮整个广播任务。广播必须是“尽力而为”,慢的客户端自己承担后果。
3.4 数据库写入:串行化 + 批量提交
前面提到 SQLite 的写入要串行化。具体做法是起一个专门的 writer task,所有待写入事件通过 channel 传给它,它攒一批(比如 100 条或者 100ms)后开一个事务批量插入。
// 伪代码:批量写入 loop { let batch = rx.recv_many(100, Duration::from_millis(100)).await; let mut tx = pool.begin().await?; for event in batch { sqlx::query("INSERT OR IGNORE INTO events ...") .bind(...) .execute(&mut *tx) .await?; } tx.commit().await?; }INSERT OR IGNORE是为了处理重复事件——Nostr 里同一条事件可能从多个来源到达,用id做主键去重是必须的。批量提交能把磁盘 fsync 次数从 N 次降到 1 次,性能提升非常明显。
4. 实操过程与核心环节实现
4.1 环境准备:Rust 工具链与 git 配置
先把基础环境搭好。Rust 安装用官方脚本或者包管理器都行,我习惯用rustup,因为可以方便地切换工具链版本。
# 安装 rustup(Linux/macOS) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env # 验证 rustc --version cargo --version国内网络环境下,cargo 拉依赖可能很慢,配置镜像源是必须的。在~/.cargo/config.toml里加上:
[source.crates-io] replace-with = 'ustc' [source.ustc] registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"git 这边,先把用户信息配好,否则 commit 会报错:
git config --global user.name "your-name" git config --global user.email "your-email@example.com" # 生成 SSH key(如果用 SSH 方式拉代码) ssh-keygen -t ed25519 -C "your-email@example.com" cat ~/.ssh/id_ed25519.pub把公钥贴到代码托管平台的 SSH 设置里。如果遇到ssh认证失败,先检查ssh -T git@host能不能通,再看 key 有没有加对。
注意:Windows 上如果提示
git : 无法将"git"项识别为 cmdlet,说明 git 没加进 PATH,重新跑一遍安装程序,选“Use Git from the Windows Command Prompt”即可。
4.2 拉取代码与依赖编译
git clone <buzz-repo-url> buzz cd buzz # 查看分支情况 git branch -a # 编译 release 版本 cargo build --release第一次编译会比较久,因为要拉一堆依赖并编译。如果卡在某个 crate 上,多半是网络问题,确认镜像源配对了。编译产物在target/release/buzz,是个单二进制文件,可以直接拷到服务器跑。
如果项目用了sqlx的编译期查询检查,还需要设置DATABASE_URL环境变量,或者用SQLX_OFFLINE=true配合.sqlx缓存目录。这是新手最容易卡住的地方。
# 如果报 sqlx 相关错误 export DATABASE_URL="sqlite://./buzz.db" # 或者 export SQLX_OFFLINE=true4.3 配置文件与启动参数
Rust 服务端通常用config.toml或者环境变量来配置。一个典型的中继配置大概长这样:
[server] host = "0.0.0.0" port = 8080 max_connections = 10000 [database] url = "sqlite://./data/buzz.db" max_connections = 5 busy_timeout_ms = 5000 [limits] max_event_size = 65536 max_subscriptions_per_connection = 20 max_filters_per_subscription = 10启动:
./target/release/buzz --config config.toml或者用环境变量覆盖:
BUZZ_PORT=9000 BUZZ_DATABASE_URL="sqlite://./buzz.db" ./target/release/buzz几个参数的经验值:max_connections别设太大,先按机器内存估,每个连接大概占几十 KB 到几百 KB,1 万连接差不多 1-2 GB;max_subscriptions_per_connection限制在 20 以内,防止恶意客户端开一堆订阅打爆索引;busy_timeout_ms设 5000 足够,配合串行写入基本不会触发。
4.4 用 systemd 做守护进程
生产环境别用nohup裸跑,用 systemd 管理,崩溃自动重启,日志也好收集。
[Unit] Description=buzz nostr relay After=network.target [Service] Type=simple User=buzz WorkingDirectory=/opt/buzz ExecStart=/opt/buzz/buzz --config /opt/buzz/config.toml Restart=on-failure RestartSec=5 LimitNOFILE=65536 [Install] WantedBy=multi-user.targetLimitNOFILE一定要调大,默认 1024 根本不够长连接用。改完systemctl daemon-reload && systemctl restart buzz。
4.5 反向代理与 TLS
中继对外一般走wss://,TLS 由反向代理终止。Nginx 配置大概这样:
location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout必须调大,WebSocket 长连接如果 60 秒没数据就被 Nginx 掐断,客户端会不停重连。设成 3600s 比较稳。
5. 常见问题与排查技巧实录
5.1 连接建立后立刻断开
这是最常见的现象。排查顺序:
- 看 Nginx 错误日志,是不是
upstream prematurely closed。 - 看 buzz 日志,是不是事件解析失败。
- 检查
max_connections是不是到了。 - 检查
LimitNOFILE是不是太小。
我遇到过一次,原因是 Nginx 的proxy_read_timeout默认 60s,客户端连上后没发消息,60 秒后被掐。改成 3600s 就好了。
5.2 数据库锁死
报错database is locked,说明有并发写。检查是不是有多个 writer 在跑,或者某个查询开了长事务。解决办法是确保写入走单一 channel,查询用只读连接池。
| 现象 | 可能原因 | 解决 |
|---|---|---|
| database is locked | 并发写 | 串行化写入 |
| 写入越来越慢 | 没批量提交 | 攒批 + 事务 |
| 磁盘占用暴涨 | 没做清理 | 定期删旧事件 |
| 查询超时 | 缺索引 | 给 kind/author 建索引 |
5.3 内存持续增长
如果内存只涨不降,八成是订阅没清理。检查连接断开时有没有把订阅从索引里摘掉。另一个可能是事件缓存没设上限,比如把最近事件全放内存里做广播,时间长了就爆。给缓存设个 LRU 上限。
5.4 git 操作踩坑
开发过程中几个高频问题:
fatal: not a git repository:当前目录不是 git 仓库,cd到正确目录,或者git init。git commit --amend改错了提交:只对还没 push 的提交用,push 过的改了会造成历史分叉。- 想同时开发两个分支:用
git worktree add ../buzz-feature feature-branch,两个目录互不干扰。 - 免密推送:配好 SSH key,或者用 credential helper 缓存。
提示:
git worktree是我最推荐的技巧之一。以前切分支要git stash来git stash去,现在直接开两个目录,一个跑测试一个写代码,效率高很多。
5.5 性能调优速查
| 指标 | 正常范围 | 异常处理 |
|---|---|---|
| 事件验证延迟 | < 1ms | 检查是否阻塞 runtime |
| 广播延迟 | < 10ms | 检查背压和慢客户端 |
| 写入吞吐 | > 1000/s | 检查批量大小和事务 |
| 内存/连接 | < 200KB | 检查订阅清理 |
6. 从零到一部署一个 buzz 中继的完整清单
把上面的内容串成一条可执行的路径,方便你直接照着做。
第一步,装 Rust 和 git,配好镜像源和用户信息。第二步,git clone代码,cargo build --release编译。第三步,写config.toml,重点配好数据库路径、端口、连接数上限。第四步,本地跑起来,用websocat或者 Nostr 客户端连一下,发条测试事件,确认能存能查。第五步,配 systemd 守护,调大文件描述符上限。第六步,配 Nginx 反代和 TLS,把proxy_read_timeout调大。第七步,观察日志和内存,跑一天看看有没有泄漏。
这套流程我在几个不同项目上验证过,Rust 服务端的部署其实比想象中简单,难点不在编译,而在运行时的参数调优和背压处理。buzz这类中继项目,真正拉开差距的地方是订阅索引设计和写入串行化,这两块做扎实了,单机撑几千连接没什么压力。
最后分享一个我自己的习惯:部署前先在本地用cargo run --release跑一遍,用脚本模拟 100 个并发连接发事件,观察 CPU 和内存曲线。这一步能提前暴露 80% 的问题,比上线后再救火划算得多。至于buzz后续还能怎么扩展,比如加全文搜索、加事件过期策略、加多实例分片,那是另一个话题了,等把单机版本跑稳再说。