1. 从"buzz"这个名字说起:一个用Rust重写的Nostr中继到底想解决什么
第一次看到"buzz"这个项目名,加上关键词里的Nostr、relay、Rust、workspace,我脑子里第一反应是:又一个Nostr中继实现。Nostr生态里的relay实现已经不少了,有Go写的、有Node.js写的、也有Python写的,为什么还要用Rust再写一个?这个问题如果不搞清楚,后面所有的技术选型和代码结构都无从谈起。
先把背景交代清楚。Nostr是一个去中心化的社交协议,核心思路非常简洁:用户自己生成一对密钥,所有内容用私钥签名后,通过WebSocket发送到一个或多个relay(中继服务器),relay负责存储和转发事件(event)。客户端从relay拉取事件,在本地验证签名。整个协议里,relay不掌握用户身份,也不负责内容审核的最终裁决,它就是一个"消息中转+存储"的角色。
那问题来了:relay看起来逻辑很简单,为什么实现质量差异这么大?我实际跑过几个不同语言写的relay之后发现,瓶颈根本不在协议逻辑本身,而在于并发连接管理和事件存储吞吐这两件事。一个中等规模的relay,同时维持几千到上万个WebSocket连接是常态,每个连接随时可能推送新事件、发起订阅请求、拉取历史数据。这种场景下,语言运行时的内存管理模型和并发模型直接决定了relay能不能扛住。
Rust在这个场景下的优势就很明显了。没有GC停顿,意味着在事件洪峰到来时不会出现"突然卡住几百毫秒"的情况;所有权模型让并发状态管理在编译期就被强制检查,避免了数据竞争;tokio异步运行时处理大量并发连接是它的强项。buzz选择Rust,本质上是在赌"长期运行稳定性"和"高并发下的可预测延迟"这两件事,而不是追求开发速度。
至于workspace这个词出现在关键词里,我判断buzz采用的是Cargo workspace的多crate组织结构。这在Rust中大型项目里非常常见:把协议解析、存储层、网络层、配置管理拆成独立crate,各自独立编译和测试,最后在一个workspace里统一管理依赖版本。这种结构对relay这种"协议+存储+网络"三层分明的项目来说,几乎是标准做法。
所以buzz这个标题背后,真正值得拆解的是三件事:Nostr relay的核心工作模型是什么、Rust在这个场景下具体解决了哪些工程问题、以及一个workspace结构的Rust项目应该怎么组织代码。下面我按自己实际搭建和调试relay的经验,把这几件事一件件说透。
2. Nostr relay的工作模型:不是"服务器",更像"带索引的消息池"
2.1 relay到底存什么、转发什么
很多人第一次接触Nostr,会下意识把relay理解成"社交平台的服务器"。这个类比会误导你。传统社交平台的服务器掌握账号体系、内容审核、推荐算法,而Nostr relay做的事情要窄得多,它只处理一种东西:event。
一个event的结构大致包含这些字段:id(事件内容的哈希)、pubkey(发布者公钥)、created_at(时间戳)、kind(事件类型)、tags(标签数组)、content(内容)、sig(签名)。relay收到event后,第一件事是验证id是否等于内容哈希、签名是否匹配pubkey。验证通过才存储,然后转发给所有订阅了匹配条件的连接。
这里有个关键设计:relay不信任客户端。客户端说"这是我发的事件",relay必须自己验签。这意味着每个event在入库前都要做一次椭圆曲线签名验证,这是CPU密集型操作。用Rust做这件事的优势在于,签名验证库(比如secp256k1的Rust绑定)性能稳定,而且可以安全地放到线程池里并行处理,不会因为GC或者解释器开销拖慢整体吞吐。
2.2 订阅模型:REQ和EVENT的往返逻辑
relay和客户端之间的交互围绕几种消息类型展开。客户端发REQ消息发起订阅,里面包含一组filter(过滤器),filter可以指定作者、事件类型、时间范围、标签等条件。relay收到REQ后,先返回所有已存储的匹配事件,然后保持这个订阅活跃,后续有新事件匹配就实时推送。
这个"先查历史、再推实时"的模型,对存储层提出了双重要求:既要有高效的条件查询能力(按pubkey、kind、时间范围组合过滤),又要有实时写入+广播能力。我见过一些早期relay实现,历史查询用SQL的LIKE模糊匹配,事件一多查询就慢得离谱。正确的做法是给常用过滤字段建索引,pubkey、kind、created_at这三个字段基本是必建索引的。
还有一个容易被忽略的点:订阅数量限制。一个客户端可以发起多个REQ,如果不限制,恶意客户端可以发起成千上万个订阅,每个订阅都占用relay的内存和CPU。成熟的relay实现都会设置单连接最大订阅数、单订阅最大返回事件数这些参数。buzz如果要做生产级relay,这些限制必须在配置层暴露出来。
2.3 为什么"删除"和"替换"事件让存储层变复杂
Nostr里有几类特殊事件会改变存储语义。比如kind 5是删除请求,kind 0是用户元数据(可替换事件,同pubkey同kind只保留最新一条),kind 3是关注列表(同样是可替换的)。这意味着存储层不能简单地"来一条存一条",而要处理替换和逻辑删除。
替换事件的逻辑是:新事件created_at比旧事件新,就覆盖旧的。这里有个坑,如果两个事件created_at相同怎么办?协议里通常用id的字典序做兜底比较。这个细节如果处理错,会导致用户元数据出现"回滚"现象——明明更新了昵称,客户端拉到的还是旧的。我在调试类似逻辑时踩过这个坑,最后是靠对比事件id才定位到问题。
逻辑删除更麻烦:删除请求本身也是一个event,relay收到后要把被删除的事件标记为不可见,但删除请求本身要保留(否则无法向其他relay同步删除状态)。这就不是简单的DELETE语句能搞定的,通常需要一张"删除标记表"或者在事件表上加软删除字段。
3. Rust在这个项目里到底解决了哪些具体问题
3.1 并发连接:tokio的任务模型为什么适合relay
relay的核心工作负载是"大量长连接+频繁小消息"。这种负载用线程池模型处理会很痛苦,因为线程上下文切换开销大,而且线程数受限于系统资源。tokio的异步任务模型在这里几乎是天然匹配:每个WebSocket连接对应一个轻量级task,task之间通过channel通信,运行时用少量线程调度海量task。
我实测过一个对比:同样处理5000个并发WebSocket连接,每个连接每秒收发几条消息,异步模型下的内存占用和CPU占用都明显低于"一连接一线程"的模型。Rust的async/await在编译后会生成状态机,没有额外的运行时开销,这是它比某些动态语言异步模型更高效的原因。
但异步不是银弹。有个必须注意的点:不要在async任务里做阻塞操作。比如签名验证如果直接在主任务里同步执行,会阻塞整个运行时线程。正确做法是用tokio::task::spawn_blocking把CPU密集的验签操作丢到阻塞线程池。这个坑我见过太多人踩,表现就是"连接数一上来,延迟突然飙升",本质是运行时线程被阻塞操作占满了。
3.2 内存安全与数据竞争:编译期就拦住并发bug
relay里有一类状态是多个连接共享的:比如"当前活跃订阅表"、"已连接客户端列表"。这些状态在并发读写时,如果语言层面不提供保护,很容易出现数据竞争。Rust的Arc<Mutex<T>>或者Arc<RwLock<T>>把这件事变成了编译期检查——你忘记加锁,代码根本编译不过。
这看起来是"麻烦",但对relay这种长期运行的服务来说,是巨大的收益。我维护过一个用动态语言写的relay,上线后偶发崩溃,排查了两周才发现是两个协程同时修改订阅表导致的。Rust在编译期就把这类问题挡掉了,虽然写代码时要多想一步,但省下的是线上排查的深夜。
不过要注意锁的粒度。如果用一个全局Mutex保护所有状态,高并发下会变成串行瓶颈。合理的做法是按功能拆分锁:订阅表一把锁、连接表一把锁、存储层自己管理自己的并发。buzz如果采用workspace结构,很可能就是把存储层单独做成一个crate,内部用自己的并发策略,对外暴露异步接口。
3.3 存储层选型:嵌入式数据库还是外部数据库
Rust生态里做relay存储,常见选择有几类:SQLite(通过rusqlite或sqlx)、嵌入式KV(如sled)、以及外部数据库(PostgreSQL等)。选哪个取决于relay的定位。
如果目标是"单机可跑、零外部依赖",SQLite是很务实的选择。它支持完整的SQL查询,建索引方便,单文件存储便于备份。缺点是写入并发有限,SQLite同一时刻只允许一个写事务。对于写入量大的relay,这可能成为瓶颈,解决办法是批量写入——把多个事件攒一批,用一个事务提交。
如果目标是"高写入吞吐",sled这类嵌入式KV更合适,它是为写优化设计的。但KV的代价是查询能力弱,复杂filter需要自己在应用层实现。我个人的经验是:中小规模relay用SQLite足够,配合WAL模式(Write-Ahead Logging)能显著提升并发读写表现;大规模relay才需要考虑更复杂的存储方案。
这里有个实操细节:SQLite的WAL模式需要显式开启,而且要注意busy_timeout参数。默认情况下,如果写锁被占用,新的写请求会直接报错而不是等待。设置一个合理的busy_timeout(比如5000毫秒),能让并发写入平滑很多。
4. Cargo workspace结构:把relay拆成几个crate才合理
4.1 为什么中大型Rust项目几乎都用workspace
单crate项目在代码量上来之后会遇到几个问题:编译时间长(改一行代码全量重编)、依赖版本难统一、模块边界靠自觉维护。workspace解决的就是这些。它允许你把项目拆成多个crate,每个crate独立编译,共享一份Cargo.lock保证依赖版本一致。
对relay来说,天然的分层是:协议层(event定义、签名验证、消息序列化)、存储层(数据库操作、索引管理)、网络层(WebSocket处理、订阅管理)、配置层(配置解析、命令行参数)。这四层拆成四个crate,依赖关系清晰:网络层依赖协议层和存储层,存储层依赖协议层,配置层被所有层依赖。
这样拆的好处是编译增量明显。改网络层的代码,协议层和存储层不需要重编。对于动辄几万行的relay项目,这个收益很实在。
4.2 一个可落地的workspace目录结构
我按自己的经验给一个具体的结构参考:
buzz/ ├── Cargo.toml # workspace根配置 ├── crates/ │ ├── buzz-core/ # 协议层:event、filter、签名验证 │ │ ├── Cargo.toml │ │ └── src/lib.rs │ ├── buzz-store/ # 存储层:数据库、索引、查询 │ │ ├── Cargo.toml │ │ └── src/lib.rs │ ├── buzz-relay/ # 网络层:WebSocket、订阅管理 │ │ ├── Cargo.toml │ │ └── src/lib.rs │ └── buzz-config/ # 配置层 │ ├── Cargo.toml │ └── src/lib.rs └── src/ └── main.rs # 二进制入口,组装各层根Cargo.toml里用[workspace]声明成员,并且可以统一声明依赖版本:
[workspace] members = ["crates/*", "."] resolver = "2" [workspace.dependencies] tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1"子crate里引用时写tokio = { workspace = true },这样所有crate用的都是同一个版本,避免"依赖地狱"。
4.3 各层的接口设计要点
协议层的核心是event的序列化和验签。这里建议把"验证"和"解析"分开:解析只负责把JSON转成结构体,验证负责检查id和签名。分开的好处是,解析可以在网络层做(快速拒绝格式错误的消息),验证可以放到阻塞线程池做(CPU密集)。
存储层对外应该暴露异步接口,内部实现可以是同步的SQLite操作包在spawn_blocking里。接口设计上,至少需要这几个方法:store_event、query_events(按filter查)、delete_event、replace_event。注意query_events要支持分页,否则一个大filter可能返回几十万条事件,直接把内存打爆。
网络层的核心是连接管理和订阅管理。每个连接维护自己的订阅列表,新事件到来时,遍历所有连接的订阅,匹配filter后推送。这里有个性能优化点:如果连接数很多,每次事件都遍历所有连接会很慢。可以用"按filter条件建倒排索引"的方式优化,但实现复杂度高。中小规模relay直接遍历就够了,先跑通再优化。
5. 从零跑起来:环境准备和第一个可用的relay
5.1 Rust环境安装与镜像源配置
Rust环境安装本身不复杂,官方推荐用rustup。但国内网络环境下,直接拉取crates.io的依赖会很慢,配置镜像源是必须的。在~/.cargo/config.toml里加上:
[source.crates-io] replace-with = 'mirror' [source.mirror] registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"配置完之后,cargo build的依赖下载速度会有质的提升。这个配置对workspace项目尤其重要,因为workspace往往依赖数量多,第一次构建要拉几十个crate。
还有一个容易忽略的点:Rust工具链版本。workspace里如果用了较新的语言特性(比如let-else、GAT等),需要确保rust-toolchain.toml里指定的版本足够新。我建议在项目根目录放一个rust-toolchain.toml,锁定工具链版本,避免不同机器上编译结果不一致。
5.2 最小可运行relay的核心代码骨架
一个能跑的最小relay,核心逻辑就是"接受WebSocket连接、解析消息、处理REQ和EVENT"。用tokio和tokio-tungstenite可以快速搭起来。核心处理循环大致是这样:
async fn handle_message( msg: ClientMessage, store: Arc<Store>, subscriptions: Arc<Mutex<HashMap<String, Vec<Filter>>>>, ) -> Result<(), Error> { match msg { ClientMessage::Event(event) => { // 验签放到阻塞线程池 let valid = tokio::task::spawn_blocking(move || event.verify()).await??; if valid { store.store_event(event).await?; } } ClientMessage::Req { sub_id, filters } => { let events = store.query_events(&filters).await?; // 先返回历史事件,再注册订阅 subscriptions.lock().await.insert(sub_id, filters); } ClientMessage::Close(sub_id) => { subscriptions.lock().await.remove(&sub_id); } } Ok(()) }这段代码里最关键的是验签用spawn_blocking包起来。我见过有人直接在主任务里调event.verify(),本地测试没问题,一上压力测试延迟就崩了。
5.3 配置项该暴露哪些
一个能用的relay,配置项至少要覆盖这几类:监听地址和端口、数据库路径、单连接最大订阅数、单订阅最大返回事件数、事件最大长度限制、允许的事件kind白名单(可选)。这些参数不暴露,relay就没法根据实际负载调优。
配置格式建议用TOML,Rust生态里configcrate或者figment都能很好地处理"配置文件+环境变量+命令行参数"的优先级合并。我个人的习惯是:配置文件放默认值,环境变量覆盖敏感项(比如数据库路径),命令行参数覆盖临时调试项。
6. 实测中暴露的问题和排查思路
6.1 连接数上去之后延迟飙升
这是最典型的问题。表现是:连接数几百时一切正常,上千后消息延迟从几毫秒涨到几百毫秒。排查思路按这个顺序走:
第一步,确认是不是阻塞操作占满了运行时线程。在验签、数据库写入这些地方打点计时,如果发现某个操作耗时远超预期,基本就是它。解决办法是全部挪到spawn_blocking。
第二步,检查锁竞争。如果用了全局Mutex,用tokio-console或者简单的计时打点看锁等待时间。锁等待时间长,说明锁粒度太粗,需要拆分。
第三步,检查数据库。SQLite在WAL模式下,读操作不阻塞写,但写操作之间是串行的。如果写入频率很高,考虑批量提交。
6.2 事件重复推送
客户端收到重复事件,通常是订阅匹配逻辑写错了。比如一个连接有多个订阅,某个事件同时匹配两个订阅,如果推送逻辑没有去重,客户端就会收到两次。正确做法是:推送前按event id去重,或者干脆在协议层约定"同一连接同一事件只推一次"。
还有一种情况是历史查询和实时推送的边界处理不当。客户端发REQ后,relay先查历史再注册订阅,如果这两步之间来了新事件,这个事件既不在历史结果里,注册订阅时又已经错过了,就会丢事件。解决办法是先注册订阅、再查历史,然后对两边结果去重。
6.3 数据库文件膨胀
relay跑一段时间后,数据库文件可能涨到几个GB。这里面有正常增长(事件确实多),也有异常膨胀。异常膨胀常见原因有两个:一是没有定期做VACUUM(SQLite删除数据后文件不会自动缩小),二是索引建得太多导致写入放大。
我的做法是:定期(比如每周)执行一次VACUUM,同时监控数据库大小和事件数量的比例。如果平均每条事件占用空间超过几KB,就要检查是不是索引过多或者有大量冗余数据。
7. 几个我认为值得提前想清楚的设计取舍
7.1 要不要做事件过期清理
Nostr协议本身不强制relay永久存储事件。实际上,很多公共relay会设置保留策略,比如"只保留最近30天的事件"。这个策略对控制存储成本很关键,但实现时要注意:清理不能影响可替换事件的语义。比如用户元数据(kind 0)如果被清理了,客户端拉不到,用户体验会很差。通常的做法是对可替换事件永久保留,对普通事件按时间清理。
7.2 单机还是分布式
buzz如果定位是单机relay,那存储层用SQLite就够了,架构简单,部署方便。但如果要做成可水平扩展的relay集群,存储层就得换成外部数据库,而且要考虑多个relay实例之间的事件同步。这个复杂度是数量级的提升,我建议先把单机版做扎实,分布式作为后续演进方向。
7.3 要不要支持NIP扩展
Nostr生态里有大量NIP(Nostr Implementation Possibilities)扩展,比如NIP-42的客户端认证、NIP-50的搜索、NIP-65的中继列表。支持哪些NIP,直接决定了relay的定位。我的建议是:核心NIP(事件存储、订阅、删除、可替换事件)必须支持,认证类NIP按需支持,搜索类NIP如果存储层不支持全文索引就先别碰。
8. 我在实际搭建中总结的几条经验
第一条,先把单连接跑通,再上并发。很多人一上来就搭集群、调并发参数,结果基础逻辑有bug,排查起来极其痛苦。正确的顺序是:单连接收发正常、验签正确、存储正确,然后再逐步加压。
第二条,日志要打够,但别打太多。relay的日志如果每个事件都打一条,高并发下日志本身就成了瓶颈。我的做法是:正常事件不打日志,只在验签失败、存储失败、订阅异常这些地方打。同时用结构化日志(比如tracing crate),方便后续过滤和分析。
第三条,压力测试要用真实客户端行为。用脚本模拟"连接后什么都不做"和"真实客户端频繁发REQ、拉历史、发事件"是两回事。后者才能真正暴露问题。我一般用几个真实Nostr客户端连上去,同时用脚本模拟批量事件写入,这样测出来的数据才有参考价值。
第四条,配置要有合理默认值。relay的很多参数(最大订阅数、最大返回事件数)如果默认值太宽松,容易被滥用;太严格又影响正常使用。我的经验值是:单连接最大订阅数设20左右,单订阅最大返回事件数设500左右,事件最大长度设64KB。这些值可以根据实际使用情况调整。
第五条,监控比调试重要。relay是长期运行的服务,出问题时往往不是崩溃,而是"变慢"。提前把连接数、事件写入速率、查询延迟、数据库大小这些指标暴露出来,比事后排查高效得多。Rust生态里metricscrate配合Prometheus是很成熟的方案。
最后说一个我踩过的坑:workspace里如果某个crate的feature开关没对齐,会出现"本地编译通过、CI编译失败"的情况。原因是不同crate对同一个依赖开启了不同的feature,Cargo在合并feature时行为可能和预期不一致。解决办法是在workspace根Cargo.toml里统一声明依赖和feature,子crate只引用不重复声明。这个坑不常遇到,但遇到一次能折腾半天。