☰
Rust重写Nostr中继:从workspace架构到高并发实践
2026/11/3 13:01:04 网站建设 项目流程

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

第一次看到"buzz"这个项目名,加上关键词里的Nostr、relay、Rust、workspace,我大概能猜到这是一个用Rust语言实现的Nostr中继服务。Nostr这个协议圈子里的人应该不陌生——它本质上是一套极简的、去中心化的社交协议,核心就两样东西:客户端用密钥对签名事件(event),中继(relay)负责接收和转发这些事件。协议本身简单到用一页纸就能说清楚,但真正要跑一个稳定、高效、能扛住并发的中继,坑一点都不少。

我接触过不少Nostr中继的实现,有Node.js写的,有Go写的,也有Python凑合能跑的。这些实现各有各的问题:Node.js的单线程模型在高并发WebSocket连接下容易吃满CPU;Go的生态虽然成熟,但内存占用和GC调优对普通部署者来说门槛不低;Python就更不用说了,性能天花板摆在那里。所以当我看到有人用Rust来写中继,第一反应是"这个选型是对的"——Rust的零成本抽象、无GC、内存安全,天然适合这种长连接、高吞吐、低延迟的网络服务场景。

但"buzz"这个项目有意思的地方不只是语言选型。从关键词里的"workspace"能看出来,这个项目大概率采用了Cargo workspace的组织方式,也就是说它不是一个大一统的二进制crate,而是拆成了多个子crate——可能包括协议解析、存储层、网络层、配置管理等模块。这种架构在Rust项目里很常见,好处是模块边界清晰、编译可以并行、测试可以分模块跑,但坏处是新手一上来容易被一堆Cargo.toml搞晕,不知道从哪个crate开始看。

这篇文章我想做的事情很明确:把这个项目从"能跑起来"到"能理解它为什么这么设计"的完整路径讲清楚。不管你是刚学Rust想找个真实项目练手,还是已经在运维Nostr中继想换个更稳的实现,或者纯粹是对Rust workspace架构感兴趣,下面这些内容应该都能给你一些可以直接抄作业的东西。我会尽量把每个设计决策背后的"为什么"讲透,而不是只丢一堆命令让你照着敲。

2. 把项目跑起来之前,先搞清楚Rust workspace的目录逻辑

2.1 为什么这类项目几乎一定会用workspace而不是单crate

很多人学Rust的时候写的都是单crate项目,一个src/main.rs加一个Cargo.toml就完事了。但一旦项目规模上去,尤其是像Nostr中继这种需要同时处理协议解析、数据库读写、WebSocket管理、配置加载的项目,单crate会迅速变成一个几千行的main.rs,改一处编译全量,测试跑一次等半天。

Cargo workspace解决的就是这个问题。它的核心机制是:在根目录放一个Cargo.toml,里面用[workspace]段声明成员crate,每个成员crate有自己的Cargo.toml和src/目录,但它们共享同一个Cargo.lock和同一个target/目录。这意味着依赖版本在整个workspace里是统一的,不会出现A crate用tokio 1.35、B crate用tokio 1.40这种版本分裂的情况;同时增量编译的缓存也是共享的,改一个crate不会导致所有依赖重新编译。

对于"buzz"这种项目,我推测它的workspace成员大概会包含这几类:

  • 协议层crate:负责Nostr event的序列化/反序列化、签名验证、过滤器(filter)匹配逻辑。这部分是纯计算,不涉及IO,单独拆出来方便写单元测试。
  • 存储层crate:封装对数据库的读写,可能是SQLite、Postgres或者自定义的KV存储。拆出来的好处是以后换存储后端不用动上层代码。
  • 网络层crate:处理WebSocket连接的生命周期、消息帧的解析、连接池管理。
  • 主程序crate:把上面几个组装起来,读取配置、启动服务、处理信号。

你在根目录执行cargo build的时候,Cargo会自动按依赖关系拓扑排序,能并行的crate会并行编译。这就是为什么workspace项目第一次编译慢,但后续改代码编译快——它只重编你改动的那个crate和依赖它的crate。

2.2 拿到项目后的第一步:读懂Cargo.toml的依赖树

我见过太多人拿到一个Rust项目,上来就cargo run,然后被一堆编译错误劝退。正确的做法是先花十分钟把依赖关系理清楚。

先看根目录的Cargo.toml,找到[workspace]段里的members列表,这就是所有子crate的清单。然后逐个打开每个子crate的Cargo.toml,重点看三样东西:[dependencies]里引了哪些外部crate、[features]里定义了哪些可选特性、以及crate之间的相互依赖关系。

以Nostr中继这类项目为例,你大概率会看到这些依赖:

依赖类别常见crate作用
异步运行时tokio提供async/await的调度器和IO驱动
WebSockettokio-tungstenite基于tokio的WebSocket实现
序列化serde + serde_jsonevent的JSON编解码
密码学secp256k1 或 k256Schnorr签名验证
数据库sqlx 或 rusqlite事件持久化
日志tracing + tracing-subscriber结构化日志
配置config 或 figment多格式配置加载
错误处理anyhow + thiserror应用层和库层的错误类型

看到secp256k1这个依赖你就要知道,这个项目涉及椭圆曲线签名验证,这是Nostr协议的核心——每个event都必须由发布者的私钥签名,中继收到后要用公钥验证签名,防止伪造。看到sqlx就要知道它大概率用了异步数据库驱动,配置里会有数据库连接串。

提示:如果你在Cargo.toml里看到[patch]段或者[replace]段,说明项目对某个依赖做了本地覆盖,这通常是为了修上游bug或者用fork版本,编译前要确认这些本地路径存在。

2.3 编译环境的准备:Rust工具链和系统依赖

Rust的安装本身不复杂,但这类网络服务项目往往有系统级依赖。在Linux上,tokio-tungstenite需要libssl-dev和pkg-config;如果用rusqlite的bundled特性,需要cc编译器;如果用sqlx连Postgres,需要libpq-dev。

我一般会先跑一遍cargo check而不是cargo build,因为check只做类型检查不生成机器码,速度快很多,能快速暴露依赖缺失的问题。如果check过了再build,基本不会卡在编译阶段。

还有一个容易被忽略的点:Rust的版本。项目根目录如果有rust-toolchain.toml文件,里面会指定工具链版本,比如channel = "1.75.0"。如果你本地装的是更老的版本,编译会直接报错。用rustup show可以看当前目录生效的工具链版本,用rustup update可以升级。

3. Nostr relay的核心工作流:一条event从进来到落库经历了什么

3.1 客户端连接建立与NIP-11中继信息暴露

Nostr中继对外的接口其实就一个WebSocket端点,客户端连上来之后,双方通过JSON数组格式的消息通信。但在这之前,有个很多人不知道的细节:Nostr定义了一个NIP-11,规定中继应该在自己的HTTP端点上,当请求头带Accept: application/nostr+json时,返回一段JSON描述中继的名称、描述、支持哪些NIP、限制策略等。

这个设计的意义在于,客户端可以在建立WebSocket连接之前,先用普通HTTP请求探测中继的能力,避免连上一个不支持自己所需功能的中继。对于"buzz"这种用Rust实现的中继,NIP-11的响应通常是在WebSocket升级之前,由HTTP层直接处理的。如果你用axum或者hyper做HTTP层,就是在路由里加一个handler,检查请求头然后返回对应的JSON。

这里有个实操中容易踩的坑:NIP-11的响应必须带正确的Content-Type: application/nostr+json,而且CORS头要允许跨域,否则浏览器端的客户端拿不到这个信息。我在调试的时候用curl -H "Accept: application/nostr+json" http://localhost:8080来验证,如果返回的不是JSON或者缺CORS头,就要去检查HTTP层的中间件配置。

3.2 消息帧的解析:EVENT、REQ、CLOSE、EOSE四种主要类型

WebSocket连上之后,客户端和中继之间传的消息都是JSON数组,第一个元素是消息类型字符串。Nostr协议里客户端发给中继的主要有这几种:

  • ["EVENT", <event对象>]:客户端发布一条事件,中继验证后决定是否存储和转发。
  • ["REQ", <订阅ID>, <filter1>, <filter2>, ...]:客户端发起订阅,中继要返回匹配的历史事件,并在之后有新事件时实时推送。
  • ["CLOSE", <订阅ID>]:客户端取消某个订阅。
  • ["COUNT", <订阅ID>, <filter>]:NIP-45定义的计数请求,中继返回匹配事件的数量而不返回事件本身。

中继发给客户端的主要有:

  • ["EVENT", <订阅ID>, <event对象>]:推送一条匹配订阅的事件。
  • ["OK", <eventID>, <bool>, <message>]:对客户端EVENT消息的确认,bool表示是否接受,message是拒绝原因。
  • ["EOSE", <订阅ID>]:End of Stored Events,表示历史事件已经发完,之后都是实时推送。
  • ["NOTICE", <message>]:中继给客户端的提示信息,比如"你的消息格式不对"。

在Rust里解析这些消息,通常的做法是定义一个enum,用serde的#[serde(tag = "...")]或者手动实现Deserialize。我见过比较优雅的写法是用serde_json::Value先解析成通用JSON,再根据第一个元素做分发,这样容错性好,遇到未知消息类型可以返回NOTICE而不是直接断开连接。

注意:EOSE这个机制是很多新手实现中继时容易漏掉的。如果中继在返回完历史事件后不发EOSE,客户端会一直以为历史事件还没发完,界面上的加载状态就永远转圈。我在测试的时候会专门用一个订阅去查一个很大的时间范围,看中继是否正确地在最后发了EOSE。

3.3 事件验证:签名、ID、时间戳三道关卡

一条event进来,中继在存储之前必须做验证,这是防止垃圾数据和伪造事件的第一道防线。验证主要看三样:

第一是ID验证。Nostr的event ID是序列化后的event(不含id和sig字段)的SHA-256哈希。中继要重新计算一遍,和客户端给的id比对,不一致就拒绝。这一步能防止客户端篡改event内容但复用旧id。

第二是签名验证。用event里的pubkey、id和sig,通过Schnorr签名算法验证。这一步用的是secp256k1曲线。在Rust里,secp256k1crate提供了verify_schnorr方法,但要注意它需要的是32字节的message hash,而Nostr的id正好是32字节的SHA-256结果,可以直接用。

第三是时间戳合理性检查。协议没有强制规定,但实践中中继通常会拒绝时间戳偏离当前时间太多的事件,比如超过15分钟未来的事件。这是为了防止有人用未来时间戳把事件顶到订阅列表最前面。

这三步验证在Rust里做起来很自然,因为类型系统会强制你处理验证失败的情况。我建议把验证逻辑单独写成一个函数,返回Result<(), ValidationError>,这样单元测试好写,也方便以后加新的验证规则。

3.4 存储层的设计取舍:为什么很多中继最终选了SQLite

Nostr中继的存储需求其实不复杂:按id存事件、按pubkey查事件、按kind查事件、按时间范围查事件、按tag查事件。听起来用关系型数据库很合适,但实际选型的时候要考虑几个因素。

Postgres功能强、并发好,但部署重,对小规模自建中继来说运维成本高。SQLite轻量、零配置、单文件,但写并发是它的短板——默认的rollback journal模式下,写操作会锁整个数据库。不过SQLite有WAL模式,开启之后读写可以并发,对于中继这种读多写少的场景够用了。

我在实际项目里见过用rusqlite(同步)和sqlx(异步)两种方案。rusqlite的好处是简单直接,但它是同步API,在tokio运行时里直接调用会阻塞worker线程,需要用tokio::task::spawn_blocking包一层。sqlx是原生异步的,和tokio配合更好,但编译期需要连数据库做查询检查,对CI环境有要求。

如果让我给建议:小规模自建、单机部署,用rusqlite加WAL模式,配合spawn_blocking,简单可靠;如果要水平扩展、多实例共享存储,那还是上Postgres,用sqlx。

存储层还有一个设计点是索引。Nostr的filter查询维度很多,如果每个维度都建索引,写入会变慢;如果不建,查询会全表扫描。我的经验是至少给pubkey、kind、created_at建索引,tag查询如果频繁的话可以考虑把tag单独抽一张表做关联。

4. 用Rust写中继时那些文档不会告诉你的坑

4.1 tokio任务泄漏:连接断了但任务还在跑

这是我在实际运维中遇到最多的问题。WebSocket连接建立后,通常会为每个连接spawn一个tokio任务来处理消息循环。如果客户端异常断开(比如网络抖动、进程被杀),而你的代码没有正确处理WebSocket的关闭帧,那个任务就会一直挂在read上,永远不会退出。连接数一多,任务越积越多,内存和文件描述符都会被耗尽。

正确的做法是在消息循环里用tokio::select!同时监听WebSocket的读、一个关闭信号、以及一个心跳超时。当任何一路触发退出条件时,任务要主动清理资源并返回。我一般会加一个心跳机制:服务端每隔30秒发一个Ping帧,如果连续两次没收到Pong,就主动关闭连接。

loop { tokio::select! { msg = ws_stream.next() => { match msg { Some(Ok(Message::Text(text))) => { /* 处理消息 */ } Some(Ok(Message::Close(_))) | None => break, Some(Err(e)) => { /* 记录错误 */ break; } _ => {} } } _ = heartbeat_interval.tick() => { if last_pong.elapsed() > Duration::from_secs(70) { break; } ws_stream.send(Message::Ping(vec![])).await?; } } }

这段代码的关键是break之后任务会走到函数末尾,所有在这个任务里创建的变量都会被drop,连接被关闭,资源被释放。如果你用的是tokio::spawn,任务返回后JoinHandle会变成完成状态,不会泄漏。

4.2 订阅过滤器的内存爆炸:一个REQ可能匹配百万条事件

Nostr的filter支持很多维度:ids、authors、kinds、since、until、limit、以及各种tag。一个恶意的或者不小心的客户端可能发一个{"kinds": [1], "limit": 1000000}这样的订阅,如果你的中继老老实实去数据库查一百万条然后全部推给客户端,内存直接爆掉。

防御措施有几层。第一层是强制limit上限,不管客户端要多少,中继最多返回比如500条。第二层是流式返回,不要一次性把结果collect到Vec里再发,而是用数据库的游标逐条读、逐条发,发完一条drop一条。第三层是限制单个连接的活跃订阅数,比如最多20个,超过就拒绝新的REQ。

在Rust里做流式返回,如果用sqlx可以用fetch返回一个Stream,配合while let Some(row) = stream.next().await逐条处理。如果用rusqlite,可以用query_map返回迭代器,但要注意它是在阻塞上下文里跑的,得放在spawn_blocking里。

提示:limit的语义是"最多返回这么多条",但排序应该是按created_at倒序,也就是最新的优先。很多实现忘了加ORDER BY,导致返回的是数据库的物理顺序,客户端拿到的历史事件顺序是乱的。

4.3 签名验证的性能:别在热路径上重复分配

secp256k1的签名验证是CPU密集型操作,一次验证大概几十微秒。单看不多,但如果中继每秒收到几千条event,验证就会成为瓶颈。我做过一个粗略的测试,在普通云主机上,单核每秒大概能验证2000到3000次Schnorr签名。

优化的方向有几个。第一是复用context对象,secp256k1crate的Secp256k1::verification_only()创建的context可以复用,不要每次验证都新建。第二是避免不必要的堆分配,比如把event的id和sig用固定大小的数组而不是Vec来传递。第三是如果有多核,可以用rayon或者tokio的spawn_blocking把验证分散到多个线程。

但要注意,验证失败的event不要直接丢弃,应该返回一个OK消息告诉客户端为什么被拒。这既是协议要求,也方便客户端调试。

4.4 配置加载的优先级:环境变量、配置文件、默认值怎么排

一个可运维的中继,配置系统必须清晰。我推荐的优先级是:命令行参数 > 环境变量 > 配置文件 > 内置默认值。这样在容器化部署的时候可以用环境变量覆盖,本地开发用配置文件,紧急情况下用命令行参数临时改。

在Rust里实现这个,可以用configcrate,它支持从多种来源合并配置。也可以手写,用clap解析命令行,用envy从环境变量读,用serde从文件读,然后手动合并。手写的好处是逻辑透明,出问题好排查。

配置项至少要包括:监听地址和端口、数据库路径或连接串、最大连接数、单连接最大订阅数、事件最大大小、允许的kind白名单(如果需要)、日志级别。把这些都做成可配置的,而不是硬编码在代码里,是项目能不能真正用于生产的分水岭。

5. 从能跑到好用:中继的监控、限流与灰度发布

5.1 用tracing做结构化日志,而不是println

Rust的tracingcrate是写服务端日志的标准选择。和log相比,它的优势是支持结构化字段,比如你可以写info!(event_id = %id, pubkey = %pk, "event accepted"),输出的日志里就带了这些字段,方便后续用日志系统做聚合和查询。

对于中继来说,我建议至少记录这几类事件:连接建立和断开(带客户端IP和连接ID)、event接收和验证结果(带event id和拒绝原因)、订阅创建和关闭(带订阅ID和filter)、以及所有错误。日志级别用tracing_subscriber的EnvFilter来控制,生产环境默认info,排查问题时临时调到debug。

一个实操技巧:给每个连接分配一个唯一的connection ID,然后在所有这个连接相关的日志里都带上这个ID。这样当用户报告"我的消息没发出去"时,你可以用connection ID把整个连接的生命周期日志串起来看。

5.2 限流:保护中继不被单个客户端拖垮

限流要分几个维度做。连接级限流:单个IP最多同时建立N个WebSocket连接,超过就拒绝。消息级限流:单个连接每秒最多发M条EVENT,超过就返回NOTICE并可能断开。订阅级限流:单个连接最多同时有K个活跃订阅。带宽级限流:单个连接每秒最多传输多少字节。

在Rust里实现限流,可以用governorcrate做令牌桶,也可以用tokio::time自己实现滑动窗口。我倾向于用现成的crate,因为限流的边界条件(比如突发流量怎么处理)自己写容易出bug。

限流的参数没有标准答案,要根据你的硬件和用户群体调。我的经验值是:单连接每秒20条EVENT、最多10个订阅、单IP最多50个连接。这些值在配置里暴露出来,让运维的人可以按需调整。

5.3 灰度发布:怎么在不中断服务的情况下换版本

中继服务不像Web应用可以随便重启,因为客户端可能正连着。灰度发布的思路是:新版本先在一个新端口启动,用反向代理把一部分流量导过去,观察一段时间没问题再全量切换。

具体操作上,如果你的中继是无状态的(所有状态都在数据库里),那灰度很简单,起两个进程,前面挂个nginx或者haproxy做负载均衡。但如果中继在内存里维护了订阅状态(比如哪些连接订阅了哪些filter),那切换的时候这些状态会丢,客户端需要重连。

一个折中方案是:新版本启动后,先不接受新连接,只处理已有连接的迁移。等老版本的连接自然减少到零,再停掉老版本。这需要中继支持优雅关闭——收到SIGTERM后不再接受新连接,给已有连接发一个NOTICE说明要重启,等几秒后关闭。

在Rust里处理信号,用tokio::signal::unix::signal(SignalKind::terminate())来监听SIGTERM,然后在主循环里select这个信号,触发关闭流程。

6. 关于这个项目后续可以怎么玩的一些个人想法

把"buzz"跑起来只是第一步。如果你真的想用它做点事情,我觉得有几个方向值得试试。

一个是把它当成学习Rust异步编程的活教材。Nostr中继这个场景几乎涵盖了tokio的所有核心概念:多任务并发、channel通信、select、超时、优雅关闭。你可以试着改一改它的并发模型,比如把每个连接一个任务改成用actor模式,看看性能和代码复杂度的变化。

另一个是扩展它的存储后端。默认的SQLite适合单机,但如果你想做多实例部署,可以试着加一个Postgres或者ScyllaDB的后端。这个过程会让你深入理解存储层的抽象边界在哪里——哪些操作是存储无关的,哪些是特定数据库才有的。

还有就是NIP的扩展。Nostr的NIP列表一直在增长,从NIP-01的基础协议到NIP-50的搜索、NIP-65的中继列表,每个NIP都对应一组新功能。挑一个你感兴趣的NIP实现一下,是熟悉这个协议生态最好的方式。

我自己在折腾这类项目的时候,最大的体会是:不要一上来就追求功能全,先把核心路径跑通——连接、验证、存储、订阅、推送这五步走顺了,再往上加东西。很多中继项目烂尾,都是因为一开始摊子铺太大,结果核心路径都没跑稳。Rust的类型系统和所有权模型虽然前期学习曲线陡,但一旦编译通过,运行时的问题会比动态语言少很多,这对网络服务这种需要长期稳定运行的程序来说,是值得的投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询