☰
Rust重写Nostr中继:workspace架构与异步网络实践
2026/11/3 4:39:33 网站建设 项目流程

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

第一次看到"buzz"这个项目名,加上关键词里的Nostr、relay、Rust、workspace,我大概能猜到这是一个用Rust语言实现的Nostr中继服务。Nostr这个协议圈子里的人应该不陌生——它本质上是一套极简的分布式社交协议,核心就两样东西:客户端签名的事件(event)和中继节点(relay)负责存储转发。协议本身简单到用几百行代码就能跑一个能用的relay,但"能跑"和"能扛"之间隔着一条巨大的鸿沟。

我接触过不少Nostr relay的实现,早期很多是Node.js或者Go写的,跑起来快,但一旦并发连接数上去、事件量堆积,内存和CPU的表现就开始飘。buzz选择用Rust来做,这个决策本身就值得聊。Rust在这个场景下的优势不是"性能好"这么笼统的说法,而是具体到:零成本抽象让事件序列化/反序列化几乎不产生额外开销,所有权模型让多连接并发下的状态管理不需要靠GC兜底,异步运行时(大概率是tokio)能撑住大量长连接而不会线程爆炸。

这篇文章适合谁看?如果你是想自己搭一个Nostr relay、或者想研究一个生产级Rust异步服务的工程结构,buzz这个项目是个很好的样本。它涉及的核心技术点包括:Rust workspace多crate组织、tokio异步网络编程、WebSocket连接管理、事件存储与索引、以及Nostr协议里NIP规范的落地。我会把这些拆开讲,同时补充大量从实际搭建和调试中总结出来的经验,这些是你看README和源码注释不一定能直接拿到的。

需要先说明一点:buzz的项目正文和关键词给得很简略,所以下面关于具体实现细节的部分,我会基于"Nostr relay + Rust + workspace"这个组合下最合理的工程实践来展开,并明确标注哪些是通用做法、哪些是需要你对照源码验证的地方。这样你读的时候心里有数,不会把补充内容当成项目原文档。

2. Rust workspace的组织方式:为什么relay项目不该只有一个crate

2.1 单crate起步的诱惑与它的天花板

很多人写Rust项目,一开始都是一个cargo new然后所有代码往src/里塞。对于小工具这没问题,但Nostr relay这种项目,你很快会遇到几个必须拆分的信号。第一个信号是编译时间:当你把协议类型定义、存储层、网络层、配置管理全放在一个crate里,改一行配置解析的代码,整个项目重新编译,等得你想砸键盘。第二个信号是依赖污染:网络层需要tokio和tungstenite,存储层可能需要sqlx或者sled,配置层可能用serde加toml,这些依赖全堆在一个Cargo.toml里,版本冲突和特性开关(feature flag)的管理会变成噩梦。

buzz用了workspace,这是Rust官方推荐的多crate管理方式。一个典型的relay workspace大概长这样:

# 根目录 Cargo.toml [workspace] members = [ "crates/buzz-core", "crates/buzz-relay", "crates/buzz-storage", "crates/buzz-config", ] resolver = "2"

这里有个细节很多人会忽略:resolver = "2"。Rust 2021 edition之后,workspace默认的依赖解析器版本需要显式指定为2,否则特性开关的合并行为还是旧逻辑,可能导致某个crate开启了tokio的full特性,结果所有crate都被迫拉进一堆用不到的东西。这个坑我在一个项目里踩过,编译产物莫名其妙大了几十兆,排查半天才发现是resolver的问题。

2.2 按职责切分crate的边界怎么定

拆crate最怕的是拆得不对,导致crate之间循环依赖,或者拆完之后发现每个crate都得互相引用,那还不如不拆。我的经验是按"变化频率"和"复用可能性"两个维度来切。

buzz-core放的是Nostr协议的核心类型:Event结构体、签名验证逻辑、NIP-01定义的基本字段。这部分变化频率最低,因为协议本身稳定,而且任何其他crate都要用它,所以它应该是最底层、依赖最少的。它的Cargo.toml里应该只有serde、serde_json、secp256k1这类基础依赖,绝对不能引入tokio或者数据库相关的库。

buzz-storage负责事件的持久化和查询。这个crate的边界在于:它对外暴露的应该是trait而不是具体实现。比如定义一个EventStoretrait,然后分别用内存、SQLite、Postgres去实现它。这样做的好处是测试的时候可以用内存实现,跑得飞快,生产环境再换成数据库实现。我见过有人把存储逻辑直接写死在relay主逻辑里,后来想加个缓存层,发现要改的地方散落在十几个文件里,非常痛苦。

buzz-relay是网络层,处理WebSocket连接、客户端消息的解析和路由。它依赖core和storage,但不应该被它们依赖。buzz-config独立出来是因为配置解析经常需要根据部署环境调整,单独一个crate方便写单元测试,也方便其他工具复用。

2.3 workspace下的依赖统一管理

workspace有个很实用的特性:可以在根Cargo.toml里定义[workspace.dependencies],各个子crate引用时用workspace = true。这样版本号只在一个地方维护,升级依赖的时候不用挨个改。

# 根 Cargo.toml [workspace.dependencies] tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tungstenite = "0.21"
# crates/buzz-relay/Cargo.toml [dependencies] tokio = { workspace = true } serde = { workspace = true } buzz-core = { path = "../buzz-core" }

注意:features = ["full"]这种写法在workspace依赖里要谨慎。tokio的full特性会拉进所有子功能,包括你可能根本用不到的process、signal等。生产项目里建议按需开启,比如只要rt-multi-thread、net、macros、time就够了。这个优化能让编译时间和二进制体积都有明显下降。

3. Nostr relay的核心工作流:从WebSocket连接到事件落库

3.1 连接建立与NIP-01的消息格式

Nostr relay和客户端之间走的是WebSocket。客户端连上来之后,双方交换的是JSON数组格式的消息。这个格式在NIP-01里定义得很清楚,但实际实现的时候有几个容易出错的点。

客户端发来的消息主要有几种类型。第一种是["EVENT", <event>],客户端要把一个签名好的事件发给relay。第二种是["REQ", <subscription_id>, <filter>...],客户端要订阅符合某些条件的事件。第三种是["CLOSE", <subscription_id>],取消订阅。relay回给客户端的消息也有几种:["EVENT", <subscription_id>, <event>]推送事件,["OK", <event_id>, <bool>, <message>]确认事件接收状态,["EOSE", <subscription_id>]表示历史事件推送完毕,["NOTICE", <message>]发通知。

用Rust处理这些消息,最自然的做法是定义一个enum,然后用serde的tagged或者untagged模式来反序列化。但这里有个坑:Nostr的消息是数组不是对象,serde默认的derive对数组的支持需要额外处理。我一般会先反序列化成serde_json::Value,然后手动匹配第一个元素来判断类型,再分别反序列化。这样虽然多一步,但错误处理更清晰,不会因为某个字段格式不对导致整个消息解析失败。

#[derive(Debug)] enum ClientMessage { Event(Event), Req { sub_id: String, filters: Vec<Filter> }, Close(String), } fn parse_client_message(raw: &str) -> Result<ClientMessage, ParseError> { let value: serde_json::Value = serde_json::from_str(raw)?; let arr = value.as_array().ok_or(ParseError::NotArray)?; let msg_type = arr.get(0).and_then(|v| v.as_str()).ok_or(ParseError::NoType)?; match msg_type { "EVENT" => { let event: Event = serde_json::from_value(arr.get(1).cloned().ok_or(ParseError::MissingField)?)?; Ok(ClientMessage::Event(event)) } "REQ" => { /* 解析订阅 */ } "CLOSE" => { /* 解析取消订阅 */ } _ => Err(ParseError::UnknownType), } }

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

收到EVENT消息后,relay不能直接存,必须先验证。验证分三步,顺序很重要。

第一步是验证事件ID。Nostr的事件ID是序列化后的[0, pubkey, created_at, kind, tags, content]这个数组做SHA-256得到的。注意序列化的时候不能有多余空格,字段顺序必须严格按规范来。这一步如果不过,说明客户端算ID的方式有问题,直接拒绝。

第二步是验证签名。用secp256k1对事件ID做Schnorr签名验证。Rust里用secp256k1这个crate,开启schnorr特性。这里有个性能考量:签名验证是CPU密集操作,如果relay收到大量事件,验证会成为瓶颈。我的做法是把验证放到tokio的spawn_blocking里,避免阻塞异步运行时的工作线程。实测下来,单核每秒能验证几千个签名,对于大多数relay场景够用了。

第三步是时间戳检查。NIP-01建议relay拒绝created_at偏离当前时间太远的事件,比如超过15分钟。这个检查是为了防止有人伪造未来时间的事件来污染时间线。但这里要小心:如果你的relay面向全球用户,时区差异和客户端时钟不准的情况很常见,阈值设太严会误伤。我一般设成前后各15分钟,并且把拒绝原因通过["OK", id, false, "invalid: timestamp too far off"]返回给客户端,方便调试。

3.3 订阅匹配与实时推送的并发模型

订阅是relay最复杂的部分。一个客户端可以同时开多个订阅,每个订阅有自己的filter。当新事件到达时,relay要遍历所有活跃订阅,检查哪些filter匹配这个事件,然后推送给对应的客户端。

这里的并发模型设计直接影响性能。最朴素的做法是用一个全局的HashMap<SubscriptionId, (Sender, Vec<Filter>)>,每次新事件来了就遍历一遍。但这样在订阅数多的时候会有锁竞争。更好的做法是每个连接一个task,连接内部维护自己的订阅列表,新事件通过一个广播通道(broadcast channel)分发给所有连接task,每个task自己判断是否匹配。

// 每个连接一个task,持有自己的订阅状态 async fn handle_connection(ws: WebSocketStream, event_rx: broadcast::Receiver<Event>) { let mut subscriptions: HashMap<String, Vec<Filter>> = HashMap::new(); loop { tokio::select! { msg = ws.next() => { /* 处理客户端消息,更新subscriptions */ } Ok(event) = event_rx.recv() => { for (sub_id, filters) in &subscriptions { if filters.iter().any(|f| f.matches(&event)) { // 推送事件给这个客户端 } } } } } }

这个模型的好处是订阅匹配的逻辑完全在连接task内部,不需要跨task加锁。broadcast channel的容量要设合理,太小会丢事件,太大会占内存。我一般设成1024,配合RecvError::Lagged的处理逻辑,当接收方跟不上时记录日志并考虑断开慢客户端。

4. 存储层选型:内存、SQLite还是Postgres,别拍脑袋决定

4.1 三种存储方案的适用场景对比

Nostr relay的存储需求有个特点:写入频繁、读取以范围查询为主(按时间、按作者、按kind)、单条事件不大但总量可能很大。不同的部署规模适合不同的方案。

方案适用规模优点缺点
纯内存测试/小圈子极快,零配置重启丢数据,内存受限
SQLite个人/小团队单文件,无需服务,够快并发写入受限,不适合高并发
Postgres生产/公开relay并发强,索引灵活,可扩展需要运维,配置复杂

我个人的建议是:如果你只是自己玩或者给一个小社区用,SQLite完全够,而且备份就是复制一个文件,非常省心。但要注意SQLite的写入并发问题——它默认是串行写入的,如果你的relay写入量大,需要开启WAL模式,并且把写入操作集中到一个专门的task里,避免多个连接同时写导致database is locked。

4.2 索引设计:决定查询性能的关键

不管用哪种存储,索引设计都是重中之重。Nostr的filter支持按id、authors、kinds、since、until、tags等多个维度查询。你不可能给每个组合都建索引,所以要抓主要矛盾。

最常用的查询模式是"某作者在某时间之后的事件"和"某kind在某时间范围内的事件"。所以至少要有这几个索引:(pubkey, created_at)、(kind, created_at)、(created_at)。如果支持tag查询(比如NIP-12的通用tag查询),还需要考虑给常用tag建索引,但tag是变长的,全建索引不现实,一般只给e和p这两个最常用的tag建。

CREATE TABLE events ( id TEXT PRIMARY KEY, pubkey TEXT NOT NULL, created_at INTEGER NOT NULL, kind INTEGER NOT NULL, tags TEXT NOT NULL, content TEXT NOT NULL, sig TEXT NOT NULL ); CREATE INDEX idx_events_pubkey_created ON events(pubkey, created_at DESC); CREATE INDEX idx_events_kind_created ON events(kind, created_at DESC); CREATE INDEX idx_events_created ON events(created_at DESC);

提示:created_at用INTEGER存Unix时间戳,不要用TEXT存ISO字符串。时间戳比较是整数运算,快得多,而且排序天然正确。这个细节在数据量上百万之后差距非常明显。

4.3 事件去重与替换逻辑

Nostr里有几类特殊事件需要特殊处理。NIP-09的删除事件(kind 5)要删除被引用的事件。NIP-16的可替换事件(kind 10000-19999)和参数化可替换事件(kind 30000-39999),同一个作者同一kind只保留最新的。这些逻辑如果放在存储层实现,会让存储层变得很复杂;如果放在业务层,又可能导致并发问题。

我的做法是在存储层提供一个save_event接口,内部处理去重和替换,但把"是否可替换"的判断逻辑通过参数传进来。这样存储层不需要理解Nostr的kind语义,只需要执行"删除旧版本,插入新版本"这个原子操作。用数据库事务保证原子性,避免出现同一作者同一kind有两条记录的中间状态。

5. 编译、调试与部署中那些文档不会告诉你的事

5.1 Rust开发环境的镜像源配置

国内做Rust开发,第一个拦路虎往往是依赖下载慢。cargo默认从crates.io拉包,速度不稳定。配置镜像源是基本操作,在~/.cargo/config.toml里加上:

[source.crates-io] replace-with = 'mirror' [source.mirror] registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"

用sparse协议比git协议快很多,因为不需要克隆整个索引仓库。这个配置配好之后,cargo build的依赖下载阶段能从几分钟降到几十秒。如果你用的是VSCode加rust-analyzer,记得改完配置后重启一下rust-analyzer,否则它可能还在用旧的源。

5.2 workspace下的增量编译优化

Rust的增量编译在workspace下有个特点:如果你只改了一个crate,其他crate不会重新编译,这很好。但如果你改了根Cargo.toml的workspace依赖,所有crate都会重编。所以依赖版本尽量稳定,不要频繁动。

另外,cargo build和cargo check要分开用。日常开发用cargo check,它只做类型检查不生成代码,快很多。只有要跑测试或者实际运行的时候才cargo build。我见过有人每次改代码都cargo build,然后抱怨Rust编译慢,其实换个命令就能省一半时间。

还有个技巧是配置[profile.dev]的opt-level。默认dev profile是0,调试信息全开,编译快但运行慢。如果你在开发relay这种网络服务,运行时的性能也会影响调试体验(比如压测的时候),可以把dev的opt-level设成1,编译时间增加不多,但运行性能有明显提升。

[profile.dev] opt-level = 1 [profile.dev.package."*"] opt-level = 2

第二段是让所有依赖包用opt-level 2编译,这样tokio、serde这些库跑起来更快,而你自己的代码还是opt-level 1保持编译速度。这个配置是我试过很多组合之后觉得最平衡的。

5.3 用日志和指标定位线上问题

relay跑起来之后,出问题的时候你不可能连上去调试,只能靠日志和指标。Rust里用tracing这个库比log更适合异步场景,因为它能自动关联同一个请求的日志。

关键要记录的指标包括:当前活跃连接数、每秒接收事件数、每秒推送事件数、事件验证失败率、存储写入延迟。这些指标用tracing的span和event记录,然后通过tracing-subscriber输出成结构化日志,方便后续用工具分析。

我踩过的一个坑是:日志打太多导致磁盘写满。特别是调试阶段,如果把每个事件的完整内容都打出来,量大了之后日志文件增长飞快。正确做法是只记录事件的id和关键字段,内容只在必要时记录,并且用日志级别控制。生产环境默认info级别,需要排查问题时临时调到debug。

6. 从buzz看Rust做网络服务的几个真实体会

用Rust写Nostr relay这类网络服务,最大的感受是"前期慢,后期稳"。前期你要花时间理解所有权、生命周期、异步运行时的约束,写一个能编译通过的版本可能比Go多花一倍时间。但一旦编译通过,运行时的表现非常可预测——不会有意外的GC停顿,不会有空指针,不会有数据竞争。对于relay这种需要长时间稳定运行的服务,这个特性价值很大。

另一个体会是错误处理。Rust的Result强制你处理每一个可能失败的操作,这在写网络服务时特别重要,因为网络操作失败是常态而不是异常。用thiserror定义清晰的错误类型,用anyhow在应用层做错误传播,这套组合能让你在出问题的时候快速定位。

最后说一个关于workspace的心得:不要为了拆而拆。我见过有人把项目拆成十几个crate,结果每个crate就几百行代码,crate之间的接口定义比实现还多,维护成本反而上升。拆crate的标准应该是"这个模块是否会被独立测试、独立复用、或者有独立的依赖需求",三个都不满足就别拆。buzz的workspace结构如果拆得合理,应该是在4到6个crate之间,再多就要审视一下是不是过度设计了。

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

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

立即咨询