1. 从“buzz”这个名字说起:一个用Rust重写的Nostr中继到底在折腾什么
第一次看到“buzz”这个项目名,我脑子里蹦出来的其实是两件事:一是Nostr协议里那种“消息像电流一样在节点间嗡嗡传递”的画面感,二是Rust社区一贯的命名风格——短、干脆、带点拟声。后来翻了下它的定位,果然,这是一个用Rust实现的Nostr relay(中继服务器)。如果你对Nostr还比较陌生,可以把它理解成一套“去中心化的社交协议”:用户自己生成密钥对,把内容签名后发到多个relay上,relay负责存储和转发事件(event)。它不依赖某个平台账号,你的身份就是那把私钥。
那为什么有人要专门用Rust再写一个relay?我自己的判断是三点。第一,Nostr relay本质上是一个高并发、长连接、低延迟的消息枢纽,Rust在异步IO和内存安全上的优势非常契合这个场景。第二,现有不少relay是用Go、Node.js甚至Python写的,功能没问题,但在单机扛连接数、长时间运行的内存占用上,Rust往往能压得更低。第三,Rust生态里tokio+axum+sqlx这套组合已经相当成熟,写一个WebSocket服务加数据库持久化,代码量和可维护性都在可接受范围内。
这篇文章我打算按一个真实开发者的视角,把“buzz”这类Rust版Nostr relay从设计到落地讲透。适合谁看?如果你已经会一点Rust,想找一个能同时练到异步、WebSocket、数据库、协议解析的项目,那这个题材非常合适;如果你只是想搭一个自己的relay跑起来,我也会给出可复现的步骤和配置。文中涉及的具体实现细节,部分是基于Nostr协议规范和Rust常见工程实践做的合理补全,我会明确标注哪些是通用做法、哪些是我个人的经验判断。
2. 整体架构设计:为什么是Rust + axum + tokio这套组合
2.1 Nostr relay的核心职责拆解
在动手写代码之前,得先把relay到底要干什么想清楚。Nostr的客户端和relay之间走的是WebSocket,消息是JSON数组格式。客户端发过来的主要有三类:EVENT(发布事件)、REQ(订阅,带过滤条件)、CLOSE(关闭订阅)。relay回给客户端的主要是:EVENT(推送匹配的事件)、EOSE(End of Stored Events,表示历史事件发完了)、OK(对发布事件的确认)、NOTICE(提示信息)。
所以一个relay的最小闭环是:接受WebSocket连接 → 解析客户端消息 → 对EVENT做签名校验和存储 → 对REQ做过滤查询并推送 → 维护订阅关系,新事件到达时实时分发给匹配的订阅。这里面最容易被低估的是订阅匹配:每个连接可能持有多个订阅,每个订阅有一组过滤条件(作者、事件类型、时间范围、标签等),一条新事件进来要快速判断该推给哪些订阅。如果实现得粗糙,连接一多就会变成性能瓶颈。
2.2 为什么选axum而不是actix-web
Rust的Web框架里,actix-web资历更老、benchmark数据也漂亮,但我个人更倾向axum,原因很实际。axum是tokio官方团队维护的,和tokio、tower、hyper的整合非常顺,WebSocket升级在axum里就是WebSocketUpgrade提取器一句话的事。而且axum的中间件模型基于tower,限流、超时、追踪这些都能直接复用现成组件。actix-web有自己的actor模型和运行时,功能强但心智负担更重,对于relay这种“连接管理 + 消息路由”的场景,axum的简洁性更划算。
至于数据库,Nostr事件是append-only的,查询模式以“按作者/类型/时间过滤”为主,用SQLite做单机relay完全够用,配合sqlx可以在编译期校验SQL。如果要做多实例、高写入,可以换PostgreSQL,sqlx同样支持。我下面以SQLite为例,因为它部署最简单,一个文件搞定。
2.3 项目目录结构规划
一个能长期维护的Rust项目,目录结构最好一开始就定清楚。我习惯这样分:
buzz/ ├── Cargo.toml ├── src/ │ ├── main.rs // 启动入口,初始化配置和服务器 │ ├── config.rs // 配置读取(端口、数据库路径、限制参数) │ ├── state.rs // 全局共享状态 AppState │ ├── ws/ │ │ ├── mod.rs // WebSocket handler │ │ ├── message.rs // 客户端/服务端消息类型定义 │ │ └── subscription.rs // 订阅管理与匹配逻辑 │ ├── event/ │ │ ├── mod.rs │ │ ├── model.rs // Event 结构体与序列化 │ │ └── verify.rs // 签名校验 │ ├── db/ │ │ ├── mod.rs │ │ └── queries.rs // 事件存取与过滤查询 │ └── error.rs // 统一错误类型 └── migrations/ └── 0001_init.sql // 建表语句这么分的好处是:协议相关的(event)、传输相关的(ws)、存储相关的(db)各自独立,改一处不影响其他。很多新手把所有逻辑堆在main.rs里,写到后面WebSocket handler几百行,改一个过滤条件要翻半天,非常痛苦。
3. 核心细节解析:事件模型、签名校验与订阅匹配
3.1 Event结构体与NIP-01规范
Nostr的事件结构由NIP-01定义,核心字段是这几个:id(事件内容的SHA256)、pubkey(作者公钥)、created_at(Unix时间戳)、kind(事件类型,0是元数据、1是文本笔记、3是关注列表等)、tags(二维字符串数组)、content(内容)、sig(签名)。id的计算方式是把[0, pubkey, created_at, kind, tags, content]序列化成紧凑JSON后做SHA256,注意是紧凑格式,不能有多余空格,否则算出来的id对不上。
在Rust里定义这个结构体,用serde做序列化,字段名要和协议一致。这里有个坑:tags是Vec<Vec<String>>,序列化时没问题,但反序列化时如果客户端发来的tags里混了非字符串元素,会直接报错。稳妥的做法是用serde_json::Value先接住,再手动校验,或者用#[serde(deserialize_with = ...)]做宽松处理。我倾向于严格校验,因为relay本来就应该拒绝格式不合规的事件。
3.2 签名校验:别自己造轮子
签名校验是relay的第一道防线。Nostr用的是secp256k1的Schnorr签名,Rust里用secp256k1这个crate就能搞定,它底层是libsecp256k1的绑定,性能和正确性都有保障。校验流程是:先用事件字段算出id,确认客户端传来的id和算出来的一致;再用pubkey、id、sig做Schnorr验签。
这里必须强调:不要自己实现secp256k1的数学运算。我见过有人为了“减少依赖”手写椭圆曲线,结果要么性能差几个数量级,要么在边界情况上出错。直接用成熟crate,几行代码的事。校验失败的事件要回OK消息,带上false和原因,比如invalid: signature verification failed,方便客户端排查。
3.3 订阅匹配的性能考量
订阅匹配是relay的性能核心。每个订阅有一组过滤条件,NIP-01里叫filter,字段包括ids、authors、kinds、since、until、limit、#<tag>等。一条新事件进来,要遍历所有活跃订阅,判断是否匹配。如果订阅数量是几百、事件速率是每秒几十条,朴素的双层循环还能扛;但连接上千、订阅上万时,就必须优化。
我的做法是给订阅建索引:按kind和author做倒排。具体来说,维护一个HashMap<u64, Vec<SubscriptionId>>,key是kind,value是订阅了该kind的订阅列表;再维护一个按author的类似结构。新事件到达时,先按kind和author各查一次,取交集或并集(取决于订阅条件),大幅减少需要逐个匹配的订阅数。时间范围过滤放在最后做,因为它计算最便宜。这个优化在订阅量大时效果非常明显,实测能把匹配耗时降一个数量级。
4. 实操过程:从零把buzz跑起来
4.1 环境准备与依赖安装
先把Rust工具链装好。如果你还没装,去官网下rustup,然后rustup install stable。装完用rustc --version和cargo --version确认。国内网络环境下,建议配置crates镜像源加速依赖下载,在~/.cargo/config.toml里加上镜像配置即可,这个网上教程很多,不展开。
然后是git。虽然这个项目本身不一定需要git操作,但你要拉代码、管理版本,git是绕不开的。Windows上装git,一路默认选项基本没问题,装完在终端里git --version能出版本号就行。如果提示无法将“git”项识别为 cmdlet,说明PATH没配好,重新装一遍并勾选“添加到PATH”,或者手动把git的bin目录加进环境变量。macOS上一般自带,没有的话brew install git。装完记得配一下用户名和邮箱,git config --global user.name和user.email,不然commit会报错。
4.2 创建项目与Cargo.toml配置
用cargo new buzz创建项目,然后编辑Cargo.toml。核心依赖我列一下:
[package] name = "buzz" version = "0.1.0" edition = "2021" [dependencies] tokio = { version = "1", features = ["full"] } axum = { version = "0.7", features = ["ws"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "chrono"] } secp256k1 = { version = "0.29", features = ["rand", "global-context"] } sha2 = "0.10" hex = "0.4" tracing = "0.1" tracing-subscriber = "0.3" anyhow = "1" thiserror = "1"这里解释几个选择。tokio开full是为了省事,生产环境可以按需裁剪feature减小体积。axum的wsfeature是WebSocket支持。sqlx选runtime-tokio和sqlite,chrono是为了时间类型。secp256k1开global-context可以用全局上下文,避免每次验签都创建context,性能更好。tracing做日志,比println!专业得多,能带结构化字段。
4.3 数据库表设计与迁移
建一张events表,字段对应Event结构。SQL大致这样:
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_events_pubkey ON events(pubkey); CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind); CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);tags存成JSON字符串,查询时如果需要按tag过滤,SQLite的JSON函数可以处理,但性能一般。如果tag查询是高频需求,可以再建一张tags关联表,把(event_id, tag_name, tag_value)拆开存,加索引。我建议先简单实现,等真有性能问题再拆,过早优化没必要。id做主键天然去重,重复事件插入会失败,正好符合Nostr“同一事件只存一份”的语义。
4.4 WebSocket handler的骨架
axum里处理WebSocket升级大概是这样:
async fn ws_handler( ws: WebSocketUpgrade, State(state): State<AppState>, ) -> impl IntoResponse { ws.on_upgrade(|socket| handle_socket(socket, state)) } async fn handle_socket(socket: WebSocket, state: AppState) { let (mut sender, mut receiver) = socket.split(); // 为每个连接创建一个订阅表 let subscriptions: Arc<Mutex<HashMap<String, Vec<Filter>>>> = Arc::new(Mutex::new(HashMap::new())); // 读循环:解析客户端消息 // 写循环:推送事件 // 用 tokio::select! 同时处理 }关键点是每个连接要有独立的订阅状态,用Arc<Mutex<...>>共享给读写两个任务。读任务负责解析EVENT/REQ/CLOSE,写任务负责把匹配的事件推给客户端。两者通过channel通信,避免直接共享socket。这里用tokio::select!同时监听读消息和内部推送channel,是处理双向WebSocket的常见模式。
4.5 事件发布与订阅的完整流程
发布流程:客户端发["EVENT", event]→ relay解析 → 校验id和签名 → 存入数据库 → 回["OK", event_id, true, ""]→ 同时把事件分发给所有匹配的活跃订阅。
订阅流程:客户端发["REQ", sub_id, filter1, filter2...]→ relay解析 → 先查数据库返回历史匹配事件 → 发["EOSE", sub_id]→ 把该订阅注册到活跃订阅表,后续新事件实时推送。
关闭流程:客户端发["CLOSE", sub_id]→ relay从活跃订阅表移除。
这里有个细节:历史事件查询要尊重filter里的limit,默认可以设个上限比如500,防止客户端一次拉太多把内存打爆。另外since和until是闭区间还是开区间,NIP-01规定since是>=,until是<=,实现时别搞反。
5. 常见问题与排查技巧实录
5.1 签名校验总是失败怎么办
这是新手最容易卡的地方。排查顺序:第一,确认id计算用的是紧凑JSON,serde_json::to_string默认就是紧凑的,但如果你用了to_string_pretty就错了。第二,确认字段顺序是[0, pubkey, created_at, kind, tags, content],顺序错了id就错。第三,确认pubkey是32字节的x-only公钥的hex,sig是64字节的hex。第四,确认secp256k1的Schnorr验签用的是verify_schnorr而不是ECDSA的verify。我当初就在第四点上栽过,用错API导致所有签名都验不过,查了半天。
5.2 WebSocket连接频繁断开
常见原因有几个。一是没处理ping/pong,很多反向代理和客户端会定期发ping,relay不回pong就会被判定超时。axum的WebSocket默认会处理ping/pong,但如果你自己接管了底层,要记得回。二是消息太大超过限制,axum默认的WebSocket消息大小有限制,可以在WebSocketUpgrade上配置max_message_size。三是数据库操作阻塞了异步运行时,sqlx的SQLite驱动在某些情况下会阻塞,建议用spawn_blocking包一下,或者用连接池。
5.3 订阅推送重复或丢失
重复推送通常是因为同一事件匹配了多个filter,而你没做去重。解决方法是推送前用HashSet记录已推送的event_id。丢失则可能是写任务的channel满了被丢弃,要给channel设足够容量,或者用try_send失败时记录日志。还有一种情况是订阅注册和历史查询之间有竞态:历史查询期间到达的新事件可能既不在历史里也没被实时推送。稳妥做法是先注册订阅再查历史,查完后对期间收到的事件去重。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 签名校验失败 | id计算格式错、字段顺序错、API用错 | 打印中间hex对比 |
| 连接被断开 | 未回pong、消息超限、阻塞运行时 | 查代理日志、调大限制 |
| 推送重复 | 多filter匹配未去重 | 加event_id去重集合 |
| 推送丢失 | channel满、竞态 | 调大容量、先订阅后查询 |
| 数据库锁 | SQLite并发写 | 用WAL模式、连接池 |
| 内存持续增长 | 订阅未清理、事件缓存无上限 | 断连时清订阅、缓存加LRU |
5.5 几个我踩过的坑
第一个坑是SQLite的并发。默认模式下多个写会锁库,relay场景下写入频繁,很容易database is locked。解决办法是开启WAL模式,PRAGMA journal_mode=WAL;,并且用连接池限制写连接数。第二个坑是created_at用i64还是u64,Nostr规范里是Unix秒,用i64更安全,避免负数问题。第三个坑是日志打太多,每条事件都打debug日志,高并发下IO成瓶颈,生产环境记得调日志级别。
6. 性能调优与后续扩展方向
6.1 连接数与内存的平衡
relay的内存主要花在三处:每个连接的缓冲区、订阅表、事件缓存。连接数上万时,每个连接的读写缓冲如果各占几十KB,加起来就是几百MB。可以通过调小max_message_size和max_frame_size来压缩。订阅表用前面说的倒排索引,避免每个事件遍历所有订阅。事件缓存可以只缓存热点事件,冷数据落库,用LRU控制上限。
6.2 水平扩展的思路
单机relay有上限,要扩展可以走两条路。一是多实例加共享数据库,每个实例独立处理连接,事件写同一个PostgreSQL,订阅推送通过数据库的LISTEN/NOTIFY或者消息队列广播。二是按事件类型或作者分片,不同relay负责不同范围,客户端根据NIP-65的relay列表自己路由。前者实现简单但数据库是瓶颈,后者扩展性好但客户端要配合。
6.3 可以继续加的功能
基础的relay跑通后,可以按NIP逐步加功能。NIP-09的事件删除、NIP-11的relay信息文档(客户端访问时返回relay名称、管理员pubkey、支持的NIP列表)、NIP-42的客户端认证、NIP-50的搜索。每加一个NIP,relay的实用性就上一个台阶。我个人建议先做NIP-11,因为很多客户端会先请求这个来判断relay是否可用,没有的话体验会差一截。
6.4 测试与压测建议
功能测试可以用nak这类命令行工具发事件、订阅,验证基本流程。压测可以用websocat或者自己写脚本开几千个连接狂发事件,观察CPU、内存、数据库IO。我实测下来,单机SQLite在写入每秒几百条时还稳,再高就要考虑换PostgreSQL或者批量写入。压测时特别要注意观察是否有连接泄漏,跑完一轮后连接数应该回落到基线。
最后分享一个我在调试订阅匹配时的小技巧:给每个订阅生成一个短id,在日志里带上这个id,这样一条事件推给了哪些订阅、为什么匹配,一目了然。比对着代码猜快得多。这个项目后续还可以往“多租户relay”方向做,让一个实例服务多个独立社区,通过配置隔离数据和订阅,这也是Nostr生态里比较实际的需求。