☰
Rust重写Nostr中继:buzz项目的高并发架构与实战
2026/11/3 4:37:06 网站建设 项目流程

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

第一次看到"buzz"这个项目名,加上关键词里的Nostr、relay、Rust、workspace,我脑子里第一反应是:又是一个用Rust重写基础设施的项目。这几年Rust在服务端领域的渗透速度确实快,从数据库到消息队列,从代理到中继服务,几乎每个需要高并发、低延迟、强类型保障的场景,都能看到Rust的身影。Nostr这个协议本身不复杂,核心就是客户端把签名后的事件(event)发到多个中继(relay),中继负责存储和转发。但真正要做一个能扛住实际流量的relay,问题就全冒出来了:连接管理、事件去重、订阅匹配、存储层设计、背压处理,每一项都能让一个看似简单的服务变成泥潭。

buzz这个项目,从名字和关键词组合来看,定位就是一个用Rust实现的Nostr relay。它要解决的问题很明确:Nostr生态里大量relay是用Go、Node.js甚至Python写的,能跑,但在高并发订阅、大量长连接维持、事件过滤匹配这些场景下,性能和资源占用往往不尽如人意。Rust的异步运行时加上零成本抽象,理论上能在这个场景里做出更好的表现。适合读这篇内容的人,包括正在学Rust想找实战项目的开发者、对Nostr协议感兴趣想自己跑一个relay的人,以及已经在维护relay但想看看Rust方案怎么设计的工程师。

我自己的背景是写了几年后端,Go和Rust都用,Nostr协议从早期就开始关注。这篇文章不会给你一个"复制粘贴就能跑"的完整代码库,因为buzz本身还在演进,但我会把这类项目从零到一的核心设计决策、Rust workspace的组织方式、relay的关键实现细节、以及实际部署时会踩的坑,全部拆开讲清楚。你看完之后,应该能自己动手搭一个可用的relay,并且知道每个设计选择背后的理由。

2. 为什么Nostr relay值得用Rust重写:性能账和工程账

2.1 长连接场景下Go和Node.js的真实瓶颈

Nostr relay的本质是一个WebSocket服务器,每个客户端会建立一条长连接,然后通过订阅(subscription)告诉relay"我对哪些事件感兴趣"。relay收到新事件后,要遍历所有活跃订阅,判断哪些订阅的过滤器(filter)匹配这个事件,然后推送给对应的连接。这个模型听起来简单,但实际负载特征很极端:连接数可能上万,订阅数可能是连接数的几倍,每个事件到达时要做N次过滤器匹配。

Go的goroutine模型在这个场景下其实不差,但GC压力是真实存在的。每个连接、每个订阅、每个事件都会产生堆分配,当事件吞吐上来之后,GC的STW虽然很短,但频繁的GC周期会吃掉可观的CPU。Node.js的问题更明显,单线程事件循环在过滤器匹配这种CPU密集型操作上容易成为瓶颈,虽然可以用worker threads,但连接状态和订阅状态的跨线程共享又带来新的复杂度。

Rust的方案是:用tokio做异步运行时,每个连接一个task,订阅状态用Arc加锁或者更精细的并发数据结构管理。没有GC,内存分配可控,过滤器匹配可以做到零拷贝或者最小拷贝。这不是说Rust一定快多少倍,而是在高负载下表现更可预测,延迟抖动更小。

2.2 Rust workspace的组织方式:把relay拆成可测试的模块

buzz这类项目用Rust workspace来组织是合理的选择。一个Nostr relay可以拆成几个crate:协议层(事件结构、签名验证、过滤器解析)、存储层(事件持久化、索引)、网络层(WebSocket处理、连接管理)、以及可选的配置和CLI层。workspace的好处是每个crate可以独立测试,协议层的单元测试不需要启动网络,存储层的测试可以用内存后端,网络层的集成测试再单独跑。

我自己的习惯是至少分四个crate:buzz-core放协议和类型定义,buzz-store放存储抽象和具体实现,buzz-relay放网络和业务逻辑,buzz-bin放可执行入口。这样buzz-core可以被其他工具复用,buzz-store可以换后端而不影响网络层。Cargo.toml里用workspace dependencies统一版本,避免依赖冲突。

[workspace] members = ["buzz-core", "buzz-store", "buzz-relay", "buzz-bin"] resolver = "2" [workspace.dependencies] tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" secp256k1 = { version = "0.29", features = ["rand", "global-context"] }

这里有个细节:secp256k1的global-context特性可以避免每次验证签名时重复初始化上下文,对性能有实际帮助。很多人第一次写Nostr相关代码时会忽略这个,导致签名验证成为热点。

2.3 事件模型和过滤器匹配的核心逻辑

Nostr事件的结构是固定的:id、pubkey、created_at、kind、tags、content、sig。id是序列化后的SHA256,sig是对id的Schnorr签名。relay收到事件后第一件事是验证id和签名,验证不通过直接拒绝。这一步在Rust里可以用secp256k1库做,速度比纯JS实现快一个数量级。

过滤器匹配是relay的核心热路径。一个过滤器包含ids、authors、kinds、since、until、limit,以及各种tag查询(比如#e、#p)。匹配逻辑是:事件必须满足过滤器里所有非空条件。实现时要注意,tag查询的语义是"事件的tags里存在至少一个匹配的tag值",而不是全部匹配。这个细节如果搞错,订阅行为就会不符合预期。

我在实现时会把过滤器预编译成更高效的结构,比如把authors和kinds转成HashSet,把since/until转成时间戳比较,避免每次匹配都做字符串比较。对于tag查询,可以用HashMap<String, HashSet >来索引。这些优化在连接数少的时候看不出差别,但订阅数上千之后,每次事件到达的匹配开销就会明显影响吞吐。

3. 用Rust workspace搭起buzz的骨架:从Cargo.toml到第一个可跑的中继

3.1 环境准备:Rust工具链和依赖的版本选择

先把Rust装好,用rustup管理工具链。建议用stable,但如果你要用一些较新的异步特性,可以跟到最新stable。安装命令很简单:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

装完之后确认版本,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/"

这个配置能显著加快依赖下载。注意sparse协议需要较新的cargo版本,如果你用的是老版本,去掉sparse+前缀。

依赖方面,tokio是必须的,tungstenite或者tokio-tungstenite做WebSocket,serde和serde_json做序列化,secp256k1做签名验证,sha2做哈希。存储层如果用SQLite,加rusqlite;如果用PostgreSQL,加sqlx或tokio-postgres。我建议初期用SQLite,部署简单,性能对中小规模relay足够。

3.2 定义buzz-core:事件结构和验证逻辑

buzz-core是整个项目的基础,定义Event结构体和相关的验证函数。Event的字段顺序很重要,因为id的计算依赖于序列化后的字节序列。Nostr规范里序列化格式是[0, pubkey, created_at, kind, tags, content],用JSON序列化后做SHA256。

use serde::{Deserialize, Serialize}; use sha2::{Sha256, Digest}; use secp256k1::{Secp256k1, XOnlyPublicKey, schnorr::Signature}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Event { pub id: String, pub pubkey: String, pub created_at: i64, pub kind: u64, pub tags: Vec<Vec<String>>, pub content: String, pub sig: String, } impl Event { pub fn compute_id(&self) -> String { let serialized = serde_json::json!([ 0, self.pubkey, self.created_at, self.kind, self.tags, self.content ]); let bytes = serde_json::to_vec(&serialized).unwrap(); let mut hasher = Sha256::new(); hasher.update(&bytes); hex::encode(hasher.finalize()) } pub fn verify(&self) -> Result<(), String> { if self.compute_id() != self.id { return Err("id mismatch".into()); } let secp = Secp256k1::verification_only(); let pubkey = XOnlyPublicKey::from_str(&self.pubkey) .map_err(|e| format!("bad pubkey: {e}"))?; let sig = Signature::from_str(&self.sig) .map_err(|e| format!("bad sig: {e}"))?; let msg = hex::decode(&self.id).map_err(|e| format!("bad id hex: {e}"))?; secp.verify_schnorr(&sig, &msg, &pubkey) .map_err(|e| format!("sig verify failed: {e}")) } }

这里有个容易踩的坑:JSON序列化时数字的表示。created_at和kind在JSON里是数字,但serde_json默认会把u64序列化成数字,这没问题。但如果你用其他语言实现,要注意不要序列化成字符串。另外tags是Vec<Vec<String>>,序列化时是嵌套数组,这个格式必须严格匹配。

3.3 存储层抽象:先用SQLite跑通,再考虑换后端

buzz-store定义一个trait,把存储操作抽象出来:

#[async_trait] pub trait EventStore: Send + Sync { async fn insert(&self, event: &Event) -> Result<bool, StoreError>; async fn query(&self, filters: &[Filter]) -> Result<Vec<Event>, StoreError>; async fn delete(&self, id: &str) -> Result<(), StoreError>; }

SQLite实现里,建表语句要包含id、pubkey、created_at、kind、content、sig,以及tags的单独表或者JSON字段。查询时根据filter构造SQL,注意用参数绑定避免注入。对于tag查询,如果tags存成JSON,可以用SQLite的json_each函数做匹配,但性能一般。更好的做法是建一个event_tags表,每行是(event_id, tag_name, tag_value),加索引。

我实测下来,SQLite在几万事件量级下查询延迟可以接受,但写入并发高时会有锁竞争。如果relay要处理大量写入,考虑用WAL模式,并且把写入批量提交。连接池用r2d2或者deadpool都可以。

3.4 网络层:tokio-tungstenite处理WebSocket连接

buzz-relay里用tokio-tungstenite接受WebSocket连接。每个连接spawn一个task,循环读取消息。Nostr客户端发来的消息是JSON数组,第一个元素是消息类型:["EVENT", event]、["REQ", sub_id, filter...]、["CLOSE", sub_id]。

async fn handle_connection(stream: TcpStream, store: Arc<dyn EventStore>) { let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut write, mut read) = ws.split(); let mut subscriptions: HashMap<String, Vec<Filter>> = HashMap::new(); while let Some(msg) = read.next().await { let msg = match msg { Ok(m) => m, Err(_) => break }; if !msg.is_text() { continue; } let text = msg.to_text().unwrap(); let parsed: serde_json::Value = match serde_json::from_str(text) { Ok(v) => v, Err(_) => continue, }; // 根据消息类型分发处理 } }

这里的关键是订阅状态的管理。每个连接有自己的subscriptions map,当新事件到达时,需要遍历所有连接的订阅。如果连接数很多,这个遍历会成为瓶颈。优化方案是用一个全局的订阅注册表,按filter的某些维度(比如kind或author)做索引,减少不必要的遍历。但初期可以先做简单实现,等有性能数据再优化。

4. 中继跑起来之后才会暴露的问题:订阅匹配、背压和事件去重

4.1 过滤器匹配的语义细节和常见实现错误

过滤器匹配看起来简单,但有几个语义细节容易搞错。第一,空过滤器匹配所有事件,但limit字段要单独处理。第二,多个过滤器之间是OR关系,一个事件只要匹配任意一个过滤器就应该推送。第三,tag查询里,#e和#p这些是单字母tag,但Nostr也支持多字母tag,实现时要通用处理。

我见过的一个典型错误是:把authors和kinds的匹配写成了AND,导致订阅收不到预期的事件。正确的逻辑是,过滤器内的不同字段之间是AND,同一字段的多个值是OR。比如{"authors": ["a", "b"], "kinds": [1]}表示作者是a或b,且kind是1。

另一个坑是since和until的边界。since是"大于等于",until是"小于等于",这个在规范里有明确定义,但实现时容易写成严格不等号。测试时要用边界值验证。

4.2 背压处理:当客户端消费速度跟不上推送速度

WebSocket连接的写入端如果客户端处理慢,会积压消息。如果不做背压,内存会持续增长直到OOM。tokio-tungstenite的Sink在poll_ready返回Pending时就是背压信号。处理方式有两种:一是给每个连接的发送队列设上限,超过就丢弃或者断开连接;二是用有界channel,发送端await,自然形成背压。

我倾向于用有界channel,容量设个合理值比如1024。当channel满时,发送任务会await,这样事件推送的速度就被最慢的客户端限制。但这会带来一个问题:一个慢客户端可能拖慢整个relay的事件分发。所以更好的做法是每个连接独立的发送任务,事件分发时用try_send,满了就记录并可能断开该连接。

4.3 事件去重和替换:kind 0、kind 3和kind 10000系列的特殊处理

Nostr里有些kind是可替换的(replaceable),比如kind 0是用户元数据,kind 3是联系人列表,kind 10000系列是替换able列表。这些事件同一个pubkey同一个kind只保留最新的。relay在插入时要检查是否已存在同pubkey同kind的事件,如果存在且新事件的created_at更早,就拒绝;如果更新,就替换旧事件。

这个逻辑如果搞错,用户更新了元数据但relay还返回旧数据,体验会很差。实现时要在存储层做原子操作,避免并发插入导致状态不一致。SQLite可以用INSERT OR REPLACE配合唯一索引,但要注意created_at的比较。

另外,kind 5是删除事件,它引用要删除的事件id。relay收到kind 5后要删除对应事件,并且后续查询不应该返回被删除的事件。这个删除是软删除还是硬删除,取决于存储设计。硬删除简单,但丢失审计信息;软删除保留记录但查询时过滤。我建议软删除,加一个deleted字段。

5. 部署和运维buzz relay时我踩过的那些坑

5.1 连接数上不去:文件描述符和内核参数

第一次部署relay,测试时发现连接数到一千左右就上不去了。排查后发现是文件描述符限制。Linux默认的nofile可能是1024,对relay来说远远不够。修改/etc/security/limits.conf,加上:

* soft nofile 65535 * hard nofile 65535

然后确认systemd服务的LimitNOFILE也设了足够大的值。另外,内核的net.core.somaxconn和net.ipv4.tcp_max_syn_backlog也影响连接接受队列,高并发场景下要调大。

还有一个容易忽略的点:tokio的运行时默认worker线程数等于CPU核心数,但如果连接处理里有阻塞操作(比如同步的SQLite调用),会阻塞worker线程。解决办法是用tokio::task::spawn_blocking包装阻塞操作,或者用异步的数据库驱动。

5.2 内存增长:订阅泄漏和事件缓存

跑了一段时间后发现内存持续增长。用heaptrack或者valgrind排查,发现是订阅没有正确清理。客户端断开连接时,如果subscriptions map没有随连接销毁,就会泄漏。确保连接task结束时,相关的订阅状态被drop。

另一个内存来源是事件缓存。如果relay在内存里缓存最近的事件加速查询,缓存大小要有上限。用LRU或者按时间窗口清理。我见过一个实现把无限量的事件放在Vec里,跑几天就OOM了。

5.3 日志和监控:别等出问题才加

relay这种长运行服务,日志和监控必须一开始就加。至少记录:连接建立和断开、事件接收和拒绝、订阅创建和关闭、查询延迟。用tracing crate做结构化日志,配合tracing-subscriber输出JSON格式,方便后续分析。

监控指标用prometheus crate暴露,关键指标包括:活跃连接数、每秒事件数、查询延迟分位数、存储层错误数。这些指标能帮你在问题变大之前发现异常。

6. 从buzz这个项目能延伸出的几个方向

6.1 多relay联邦和事件同步

单个relay的能力有限,Nostr的设计本来就是多relay。如果要让buzz支持relay之间的同步,需要实现一个机制:relay A把事件推给relay B,B验证后存储。这可以用Nostr协议本身做,relay之间互相订阅。但要注意避免循环推送和事件风暴。

6.2 用Rust做Nostr客户端库

buzz-core里的协议实现可以复用到客户端。Rust的Nostr客户端库目前不多,如果你把buzz-core整理好,可以单独发布成一个crate,供其他Rust项目使用。客户端需要额外处理密钥管理、事件签名、relay连接池等。

6.3 性能压测和调优的实操方法

要验证relay的性能,需要压测工具。可以写一个Rust程序模拟大量客户端连接和事件发送,或者用现有的WebSocket压测工具。压测时关注:连接建立速率、事件吞吐、查询延迟、CPU和内存占用。根据瓶颈位置做针对性优化,比如存储层慢就加索引或换后端,网络层慢就调tokio参数。

我个人在实际操作中的体会是,Rust写relay最大的优势不是峰值性能,而是行为可预测。没有GC的停顿,内存占用稳定,延迟抖动小。这对于需要长期稳定运行的基础设施来说,比跑分更重要。如果你正在选型,不妨先用buzz这类项目跑个原型,用真实负载验证一下,再决定是否投入生产。

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

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

立即咨询