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可以独立测试协议层的单元测试不需要启动网络存储层的测试可以用内存后端网络层的集成测试再单独跑。我自己的习惯是至少分四个cratebuzz-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是序列化后的SHA256sig是对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查询可以用HashMapString, 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 sparsehttps://mirrors.ustc.edu.cn/crates.io-index/这个配置能显著加快依赖下载。注意sparse协议需要较新的cargo版本如果你用的是老版本去掉sparse前缀。依赖方面tokio是必须的tungstenite或者tokio-tungstenite做WebSocketserde和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: VecVecString, 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是VecVecString序列化时是嵌套数组这个格式必须严格匹配。3.3 存储层抽象先用SQLite跑通再考虑换后端buzz-store定义一个trait把存储操作抽象出来#[async_trait] pub trait EventStore: Send Sync { async fn insert(self, event: Event) - Resultbool, StoreError; async fn query(self, filters: [Filter]) - ResultVecEvent, 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: Arcdyn EventStore) { let ws tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut write, mut read) ws.split(); let mut subscriptions: HashMapString, VecFilter 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 BB验证后存储。这可以用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这类项目跑个原型用真实负载验证一下再决定是否投入生产。
阅读完成 · 觉得有帮助?