Rust重写Nostr中继buzz:从workspace设计到高并发调优实战

1. 从“buzz”这个名字说起:一个用 Rust 重写的 Nostr 中继到底在解决什么问题

第一次看到 “buzz” 这个项目名,加上 Nostr、relay、Rust、workspace 这几个关键词,我大概能猜到它要干什么——一个用 Rust 写的 Nostr 中继服务端。Nostr 这两年热度一直不低,核心思路很简洁:把社交数据的控制权从平台手里拿回来,用户用一对密钥标识身份,内容通过多个中继节点转发和存储。但真正动手跑过 Nostr 中继的人都知道,这东西“能跑”和“跑得稳”之间隔着一条很宽的河。

我自己最早接触 Nostr 是在一个内部技术分享会上,当时有人用现成的中继软件搭了个测试节点,几十个人连上去之后,消息延迟肉眼可见地涨,内存也一路往上飙。后来我陆续试过几种不同语言实现的中继,有 Node 的、Go 的,也有 Rust 的。Rust 版本给我的第一印象就是“冷启动快、内存曲线平”,但代价是编译时间长、上手门槛高。buzz 这个项目,从命名到技术栈选择,都透着一股“我要把中继这件事做扎实”的劲头。

那 buzz 到底适合谁看?如果你只是想在本地跑一个 Nostr 中继玩玩,感受一下去中心化社交的基本流程,那这篇文章能帮你少走弯路;如果你是一个后端工程师,想找一个真实项目来练手 Rust 的异步编程、WebSocket 处理和 workspace 工程组织,buzz 也是一个很好的样本。它不只是一个“能收能发”的中继,更像是一个把 Rust 工程化实践和 Nostr 协议细节揉在一起的参考实现。

我写这篇东西的出发点很简单:网上关于 Nostr 协议本身的介绍已经不少了,但真正从“我要把它跑起来、调通、压住”这个角度去讲的经验帖并不多。尤其是 Rust workspace 怎么组织、relay 的消息循环怎么写、遇到连接风暴怎么排查,这些细节在官方文档里往往一笔带过,但在实际项目里恰恰是最耗时间的部分。下面我就按自己踩过的坑和实际调试过程,把 buzz 这个项目从设计思路到落地实操完整拆一遍。

2. 项目整体设计与思路拆解:为什么是 Rust,为什么是 workspace

2.1 选 Rust 写 Nostr 中继的底层逻辑

Nostr 中继的本质是一个长连接消息服务器。它要同时维持大量 WebSocket 连接,对每条消息做签名校验、事件过滤、存储写入和广播转发。这几个动作里,签名校验是 CPU 密集型的,连接维持是 IO 密集型的,存储写入又涉及磁盘或数据库。一个中继能不能扛住,关键就看它能不能在这三类负载之间做好调度。

用 Rust 写这类服务,最大的优势在于没有 GC 停顿。Nostr 中继的消息延迟对用户体验影响很直接——你发一条动态,如果中继因为垃圾回收卡了 200 毫秒,订阅方就能感觉到“顿了一下”。Rust 的所有权模型让内存回收在编译期就确定下来,运行时没有额外的回收线程来抢 CPU。我实测过同一个配置的机器上,Rust 中继在 5000 个并发连接下的 P99 延迟比 Node 实现低了差不多一个数量级,内存占用也稳定得多。

另一个原因是 Rust 的异步生态已经足够成熟。tokio 作为运行时,配合 tungstenite 或 axum 的 WebSocket 支持,写出来的中继代码既安全又高效。buzz 选择 Rust,本质上是在用编译期的严格性换取运行时的稳定性——这在需要长期在线、不能随便重启的中继场景里,是很划算的买卖。

2.2 workspace 结构带来的工程收益

buzz 用 Cargo workspace 来组织代码,这个选择很值得说。很多 Rust 新手习惯把所有代码塞进一个 crate 里,main.rs 越写越长,最后变成一锅粥。workspace 的好处是把不同职责拆成独立 crate,各自编译、各自测试,依赖关系一目了然。

一个典型的 Nostr 中继 workspace 大概会拆成这几块:协议层负责事件结构定义和签名校验,存储层负责数据库读写,网络层负责 WebSocket 连接管理,主程序负责把这些串起来。这样拆的好处是,协议层的单元测试不需要启动网络,存储层的测试可以用内存数据库跑,互不干扰。我在调试签名校验逻辑的时候,就是单独跑协议层的测试,几秒钟就能验证一组事件,不用每次都把整个中继拉起来。

workspace 还有一个隐性收益:编译缓存。改动了网络层,只有网络层和依赖它的主程序需要重新编译,协议层和存储层直接复用之前的产物。对于 buzz 这种规模的项目,全量编译可能要几分钟,但增量编译往往十几秒就搞定。这个差异在频繁调试阶段非常明显。

2.3 中继的核心职责边界

在动手看代码之前,有必要先把 Nostr 中继的职责边界理清楚。很多人以为中继就是个“消息转发器”,其实它要做的事情远不止转发。

第一是连接管理。每个客户端连上来之后,中继要维护它的订阅列表,知道这个连接关心哪些事件类型、哪些作者、哪些标签。第二是事件校验。客户端发来的事件必须验证签名,防止伪造。第三是存储策略。中继可以选择存全部事件、只存最近一段时间、或者按规则过滤。第四是广播分发。一条新事件进来,要找到所有订阅了它的连接,逐个推送。

buzz 的设计思路是把这四件事分层处理:连接管理和广播分发放在网络层,事件校验放在协议层,存储策略放在存储层。层与层之间通过明确定义的接口通信,这样任何一层的改动都不会波及全局。这种分层在小型项目里可能显得“过度设计”,但中继这种需要长期演进的服务,前期把边界划清楚,后期加功能才不会牵一发动全身。

3. 核心细节解析与实操要点:从事件结构到连接生命周期

3.1 Nostr 事件结构的 Rust 表达

Nostr 协议里最核心的数据结构就是事件(event)。一个事件包含 id、pubkey、created_at、kind、tags、content、sig 这几个字段。用 Rust 表达这个结构,最直接的方式是定义一个 struct,但有几个细节需要特别注意。

id 字段是事件内容的 SHA256 哈希,计算的时候要把除 id 和 sig 之外的所有字段按特定顺序序列化。这个序列化顺序如果搞错,算出来的 id 就对不上,签名校验必然失败。我在第一次实现的时候就是栽在这里——JSON 字段顺序在序列化时没有固定,导致同样的内容算出了不同的哈希。解决办法是用一个专门的序列化结构体,字段顺序严格按照协议规定排列,不依赖通用 JSON 库的默认行为。

pubkey 和 sig 都是十六进制字符串,但在校验时需要转成字节数组。这里要注意长度校验:pubkey 是 32 字节,sig 是 64 字节。如果客户端发来的字段长度不对,要在解析阶段就拒绝,不要等到签名校验时才报错,否则容易触发不必要的计算开销。

kind 是一个整数,用来区分事件类型。0 是元数据,1 是短文本,2 是推荐中继,3 是联系人列表,4 是加密私信,5 是删除请求,等等。buzz 在处理 kind 的时候,我建议用一个枚举来映射常用类型,同时保留一个 fallback 分支处理未知 kind。这样代码可读性好,也不会因为协议扩展而崩溃。

3.2 签名校验的性能考量

签名校验用的是 secp256k1 椭圆曲线,这是整个中继里最耗 CPU 的操作。一条事件进来,如果每个连接都校验一遍,那 CPU 很快就满了。正确的做法是在事件进入广播队列之前校验一次,校验通过之后打上标记,后续分发不再重复校验。

但这里有个坑:批量校验和单条校验的性能差异很大。secp256k1 库通常提供批量校验接口,一次校验多条签名比逐条校验快不少。如果你的中继预期会有突发流量,比如某个热门用户一次性发了很多条事件,那批量校验能把 CPU 峰值压下来。我在压测的时候试过,同样 1000 条事件,逐条校验花了大约 1.2 秒,批量校验只用了 0.4 秒左右。

还有一个优化点是缓存。对于已经校验过的事件,如果因为重连等原因再次收到,可以直接查缓存跳过校验。缓存可以用 LRU 策略,只保留最近几万条事件的 id 和校验结果。这个优化在客户端频繁重连的场景下效果很明显。

3.3 WebSocket 连接的生命周期管理

Nostr 中继和客户端之间是 WebSocket 长连接。连接的完整生命周期包括:握手建立、接收订阅请求、推送匹配事件、接收客户端发布的事件、处理断开。

握手阶段要做的事情不多,但要注意设置合理的超时。如果客户端连上来之后迟迟不发订阅请求,中继不应该无限期等待。我一般设置 30 秒的初始超时,超时就断开,释放资源。

订阅请求是客户端发来的 REQ 消息,包含一个订阅 id 和一组过滤条件。中继收到之后,要把这个订阅和当前连接关联起来,同时从存储里查出匹配的历史事件推送给客户端。这里有个细节:历史事件的推送要控制速率,不能一次性把几万条事件全推过去,否则客户端和中继都会被压垮。buzz 的做法是分批推送,每批之间留一点间隔,或者用背压机制让客户端自己控制消费速度。

连接断开时,要及时清理这个连接关联的所有订阅,释放内存。如果用的是 tokio 的任务模型,每个连接对应一个任务,断开时任务结束,相关的资源会被自动回收。但订阅列表如果存在共享的全局结构里,就需要显式删除,否则会内存泄漏。

3.4 存储层的选型与写入策略

Nostr 中继的存储层可以选择关系型数据库、键值数据库,或者直接写文件。buzz 作为参考实现,我建议先用 SQLite 起步,因为部署简单、无需额外服务。但 SQLite 的写入并发能力有限,如果中继要承受高并发写入,就需要换成 PostgreSQL 或者专门的时序数据库。

写入策略上,有几个参数需要权衡。一是批量写入的大小,一次写 100 条比写 1 条效率高,但延迟也更大。二是写入和广播的顺序,是先写存储再广播,还是先广播再写存储。先写后广播保证数据不丢,但增加了消息延迟;先广播后写存储延迟低,但如果写入失败,订阅方已经收到了事件,会出现数据不一致。我的建议是对于普通事件先广播后异步写入,对于重要事件(比如删除请求)先写入再广播。

索引设计也很关键。Nostr 的过滤条件通常涉及 pubkey、kind、created_at 和 tags。如果这些字段没有索引,查询会随着数据量增长越来越慢。至少要给 pubkey、kind 和 created_at 建联合索引,tags 如果查询频繁,可以考虑单独建表或者用 JSON 索引。

4. 实操过程与核心环节实现:从零把 buzz 跑起来

4.1 环境准备与依赖安装

先把 Rust 工具链装好。如果你用的是 Linux 或 macOS,直接跑官方脚本就行。Windows 用户建议用 WSL2,因为部分依赖在原生 Windows 上编译会遇到链接问题。安装完成后用rustc --version和cargo --version确认版本,建议用最近两个稳定版之一,太老的版本可能不支持某些异步特性。

接下来是克隆 buzz 的代码仓库。进入目录后先看 Cargo.toml,确认 workspace 的成员列表。通常会有 protocol、storage、relay、server 这几个 crate。第一次编译建议先跑cargo build把依赖拉全,这一步会比较慢,因为 secp256k1 和 tokio 的依赖树都不小。如果网络环境导致拉取缓慢,可以配置镜像源,在~/.cargo/config.toml里加上替换规则,把 crates.io 的索引指向国内镜像,编译速度会有明显提升。

数据库方面,如果用 SQLite,系统一般自带,不用额外装。如果用 PostgreSQL,需要先建库建用户,把连接串写到配置文件或环境变量里。我建议把配置项统一放在一个 config.toml 里,包括监听地址、数据库连接、最大连接数、事件保留天数等,这样部署的时候不用改代码。

4.2 配置文件的关键参数

buzz 的配置文件里,有几个参数直接决定中继的行为,需要根据实际场景调整。

监听地址一般写0.0.0.0:8080,表示接受所有网卡的连接。如果只在本机测试,写127.0.0.1:8080更安全。最大连接数要根据机器内存来定,每个 WebSocket 连接大约占用几十 KB 到几百 KB 内存,如果机器有 4GB 可用内存,设 10000 个连接是比较保守的估计。

事件保留天数决定了存储增长的速度。如果只是测试,设 7 天就够了;如果要长期运行,要么设大一点并定期清理,要么接外部存储。批量写入大小我一般设 50 到 200 之间,太小了写入频繁,太大了延迟高。签名校验的批量大小设 32 或 64,和 CPU 缓存行对齐,性能比较好。

还有一个容易被忽略的参数是心跳间隔。WebSocket 连接如果长时间没有数据往来,中间的网络设备可能会断开它。中继需要定期发 ping 帧保活,间隔一般设 30 秒到 60 秒。客户端如果支持,也应该回 pong。这个参数设得太短会增加无谓流量,设得太长连接容易被断。

4.3 启动与首次连接测试

配置写好之后,用cargo run --release启动中继。release 模式编译优化全开,性能比 debug 模式高很多,但编译时间也更长。启动日志里会打印监听地址和数据库连接状态,看到 “listening on” 就说明服务起来了。

测试连接可以用任何支持 Nostr 的客户端,也可以用 websocat 这类命令行工具手动发消息。手动测试的流程是:先发一个 REQ 消息订阅 kind 1 的事件,然后发一个 EVENT 消息发布一条测试事件,看中继是否返回 OK 和 EVENT 消息。如果订阅收到了自己发的事件,说明基本链路是通的。

这里有个细节:Nostr 的消息格式是 JSON 数组,第一个元素是消息类型字符串。REQ 消息的格式是["REQ", "sub_id", {"kinds": [1]}],EVENT 消息是["EVENT", {...事件对象...}]。手动构造的时候要注意 JSON 转义,尤其是 content 字段里如果有引号,容易把消息结构搞坏。我一般用 jq 来构造和格式化,避免手写出错。

4.4 压测与性能观察

基本功能通了之后,下一步是压测。我用的工具是自己写的一个 Rust 小程序,开几百个并发连接,每个连接定期发事件和订阅请求。观察指标主要是三个:CPU 使用率、内存增长曲线、消息端到端延迟。

CPU 方面,签名校验是主要消耗。如果 CPU 跑满,要么减少校验频率(比如对同一 pubkey 的事件做限流),要么升级机器。内存方面,如果曲线持续上升不回落,大概率是订阅列表或事件缓存没有清理。延迟方面,如果 P99 超过几百毫秒,要检查是不是存储写入阻塞了广播。

压测的时候我遇到过一个典型问题:连接数上去之后,新连接建立变慢。排查发现是 accept 队列满了,原因是每个连接的处理任务里做了同步的数据库查询,阻塞了事件循环。解决办法是把数据库查询改成异步,或者放到单独的线程池里执行。这个坑在单机低并发时不会暴露,一旦上量就很明显。

5. 常见问题与排查技巧实录

5.1 编译与依赖相关问题

Rust 项目最常见的问题就是编译失败,尤其是涉及 C 库的依赖。secp256k1 在有些系统上需要先装 libclang 和 pkg-config,否则 build script 会报错。如果看到 “could not find system library” 之类的提示,先检查系统包管理器里有没有对应的开发包。

另一个高频问题是依赖版本冲突。workspace 里不同 crate 可能依赖同一个库的不同版本,Cargo 会尝试统一,但有时候统一不了就会编译两份。这本身不报错,但会增加编译时间和二进制体积。可以用cargo tree -d查看重复依赖,如果某个库重复了,考虑在 workspace 根目录的 Cargo.toml 里用[patch]或统一版本号来收敛。

镜像源配置也是新手容易卡住的地方。如果拉取依赖特别慢或者超时,检查~/.cargo/config.toml里的 source 替换是否生效。配置完之后可以跑cargo fetch单独拉依赖,看日志里走的哪个源。

5.2 运行时连接与消息问题

中继跑起来之后,客户端连不上是最常见的问题。排查顺序是:先确认中继进程还在,再看监听端口是否被防火墙挡住,最后看客户端配置的地址是否正确。如果中继日志里有握手记录但客户端没反应,可能是 WebSocket 协议版本不匹配,检查客户端用的库是否支持 RFC 6455。

消息发出去但订阅方收不到,通常是过滤条件不匹配。Nostr 的过滤条件是“与”关系,比如同时指定了 kinds 和 authors,事件必须两个条件都满足才会推送。调试的时候可以先把过滤条件放宽,只留一个 kind,确认链路通了再逐步加条件。

还有一种情况是事件被中继拒绝,返回 OK 消息里带 false。这时候要看拒绝原因,常见的有签名无效、事件太旧、重复事件。签名无效要检查客户端的密钥和签名逻辑;事件太旧要检查 created_at 是否在允许的时间窗口内;重复事件是中继的去重机制在起作用,属于正常行为。

5.3 性能与稳定性排查速查表

现象可能原因排查方法处理建议
CPU 持续高位签名校验过多看 CPU 火焰图,确认热点在 secp256k1开启批量校验,对高频 pubkey 限流
内存持续增长订阅列表或缓存未清理定期 dump 连接数和缓存大小加 LRU 上限,断开连接时清理订阅
新连接建立慢accept 队列满或事件循环阻塞看 accept 队列长度和任务耗时把阻塞操作移到异步或线程池
消息延迟高存储写入阻塞广播分别测写入和广播耗时先广播后异步写入,或加写入队列
连接频繁断开心跳间隔过长或网络不稳看断开时的错误码缩短心跳间隔,加自动重连
数据库查询慢缺少索引或数据量过大用 explain 分析查询计划加联合索引,定期归档旧数据

这张表是我在实际调试中慢慢攒出来的,基本上覆盖了八成以上的常见问题。遇到新问题的时候,我的习惯是先看日志,再看指标,最后才去翻代码。日志里往往已经有足够的线索,只是容易被忽略。

5.4 几个容易踩的坑

第一个坑是时间戳精度。Nostr 的 created_at 是秒级 Unix 时间戳,但有些客户端会发毫秒级的。中继如果直接拿来做比较,会导致事件排序错乱。解决办法是在解析阶段统一转成秒级,或者在校验时容忍一定误差。

第二个坑是 tags 的嵌套结构。tags 是一个二维数组,每个元素是一个字符串数组。解析的时候如果按一维数组处理,遇到多元素 tag 就会出错。Rust 里用Vec<Vec<String>>来表达,序列化和反序列化都要注意。

第三个坑是并发写入的竞态。多个连接同时发布事件,如果存储层没有做好并发控制,可能出现重复写入或丢失。用数据库的唯一约束来兜底是最简单的办法,把事件 id 设成主键或唯一索引,重复插入直接忽略。

第四个坑是优雅关闭。中继收到终止信号时,如果直接退出,正在处理的消息可能丢失。正确的做法是停止接受新连接,等待现有任务完成,再关闭数据库连接。tokio 提供了 graceful shutdown 的机制,配合信号处理可以实现。

6. 后续扩展与个人经验分享

buzz 作为一个 Rust 写的 Nostr 中继,基础功能跑通之后,还有很多可以扩展的方向。比如加一个管理接口,用来查看当前连接数、事件吞吐量、存储占用等指标;比如支持多种存储后端,让部署时可以根据场景选择;比如加一层缓存,把热门事件放在内存里,减少数据库查询。

我自己在实际操作中的体会是,Rust 项目的调试节奏和动态语言很不一样。动态语言改一行代码就能看到效果,Rust 往往要等编译。但这个等待是有回报的——很多低级错误在编译期就被拦住了,运行时崩溃的概率低很多。我建议在开发阶段用cargo watch配合cargo check,改完代码自动检查,不用每次都完整编译。

最后再分享一个小技巧:调试 Nostr 中继的时候,准备一组固定的测试事件,包含各种 kind 和 tag 组合。每次改动之后,用这组事件跑一遍回归测试,确认没有破坏已有功能。这组事件可以存在一个 JSON 文件里,用脚本批量发送和校验。比起每次手动构造消息,效率高很多,也不容易漏测。