news 2026/11/3 13:04:22

Rust重写Nostr中继:从零搭建高并发relay的工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Rust重写Nostr中继:从零搭建高并发relay的工程实践

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

第一次看到"buzz"这个项目名,加上关键词里的Nostr、relay、Rust、workspace,我大概能猜到这是一个用Rust语言实现的Nostr中继服务。Nostr这个协议圈子里的人应该不陌生——它本质上是一套极简的社交协议,核心就两样东西:客户端负责签名和展示,中继(relay)负责存储和转发事件。整个协议没有中心服务器,没有账号体系,你的身份就是一对密钥,你发的内容就是一条条被签名的event。

那为什么还要专门用Rust重写一个relay?我实际跑过几个不同语言实现的中继之后,体会很深。Nostr中继看起来简单——收event、存event、按订阅条件推event——但真正上线之后你会发现,瓶颈根本不在业务逻辑,而在连接管理、并发写入、内存占用和长时间运行的稳定性上。用Node.js或者Python写的中继,几百个并发WebSocket连接加上高频事件写入,内存曲线就开始往上飙,GC一抖,延迟就上来了。Rust在这个场景下的优势非常直接:没有GC停顿、内存占用可控、异步运行时(tokio)处理海量长连接是它的主场。

"buzz"这个项目名本身就带着一种"嗡嗡作响、持续不断"的意象,很贴合中继服务那种7x24小时不停接收和广播事件的状态。而关键词里的"workspace"则暗示这个项目大概率采用了Cargo workspace的组织方式——也就是把relay核心、协议类型定义、存储层、配置管理等拆成多个crate放在同一个workspace里统一管理。这种结构在Rust项目里很常见,好处是编译时可以并行、依赖可以共享、各模块边界清晰。

这篇文章我打算从一个实际动手的角度,把"用Rust从零搭一个Nostr relay"这件事拆开讲透。包括:Nostr relay的核心协议行为到底是什么、Rust workspace该怎么划分、WebSocket连接和订阅管理怎么写、事件存储选型怎么权衡、以及我在实际调试过程中踩过的那些坑。不管你是刚入门Rust想找个真实项目练手,还是已经在跑Nostr中继想换一套更稳的实现,这篇内容应该都能给你一些可以直接抄作业的东西。

2. Nostr relay的协议行为拆解:别被"简单"两个字骗了

2.1 一条event从进来到出去,中间到底发生了什么

很多人对Nostr relay的理解停留在"就是个转发服务器",但真正写起来你会发现,relay要处理的事情比想象中多。客户端通过WebSocket连上来之后,会发几种类型的消息,最核心的是这三种:

  • EVENT:客户端提交一条签名好的事件,relay要验证签名、验证事件ID、检查是否重复、然后决定存不存、转发给哪些订阅者。
  • REQ:客户端发起一个订阅,带一个subscription id和一组filter条件,relay要记住这个订阅,并且把历史匹配的事件推回去,之后有新事件也要实时推。
  • CLOSE:客户端关闭某个订阅,relay要把对应的订阅状态清理掉。

看起来就三个动作,但每个动作背后都有一堆细节。比如EVENT进来的时候,你得先算事件的sha256得到id,验证id和客户端声称的是否一致;然后用schnorr签名验证event的sig字段;再检查created_at是不是在合理范围内(防止有人伪造未来时间的事件把订阅流刷爆);还要看这个event的kind和tags是否命中某些relay的准入策略。

我见过不少新手写的relay,直接把收到的event往数据库一塞就完事,结果跑一段时间发现数据库里全是重复事件、垃圾事件,查询越来越慢。这就是没把协议行为吃透的后果。

2.2 filter匹配逻辑:Nostr relay最容易被写错的地方

REQ里的filter是Nostr协议里最需要仔细实现的部分。一个filter可以包含这些字段:ids、authors、kinds、since、until、limit,以及各种#<single-letter>形式的tag过滤(比如#e、#p)。

匹配规则是:同一个filter内部,不同字段之间是AND关系;同一个字段的多个值是OR关系。举个例子:

{ "authors": ["pubkey_a", "pubkey_b"], "kinds": [1, 7], "since": 1700000000 }

这条filter的意思是:作者是a或b,并且kind是1或7,并且created_at大于等于1700000000。三个条件同时满足才算命中。

而一个REQ可以带多个filter,多个filter之间是OR关系——只要命中任意一个filter,这条event就该推给这个订阅。

我踩过的坑就在这里:早期实现的时候我把多个filter的匹配写成了"必须全部命中",结果客户端订阅了"我关注的人发的帖子"和"提到我的消息"两组filter,实际只收到了同时满足两组的event,几乎什么都收不到。这个bug非常隐蔽,因为单filter测试完全正常,只有多filter场景才暴露。

2.3 订阅的生命周期与背压问题

订阅不是一次性的。客户端发一个REQ之后,这个订阅会一直活着,直到客户端发CLOSE或者连接断开。这意味着relay要为每个连接维护一个订阅表,每个订阅对应一组filter。

这里有个很容易被忽略的问题:背压(backpressure)。如果某个客户端网络很慢,relay还在拼命往它的WebSocket写事件,写缓冲区就会堆积,最终吃光内存。正确的做法是给每个连接的发送队列设一个上限,超过就丢弃或者断开这个连接。Rust的tokio里可以用mpsc::channel配合一个有界队列来实现,写满之后要么await等待,要么直接drop。

提示:NIP-11(relay信息文档)里其实建议relay声明自己的限制,比如最大订阅数、最大filter数、最大事件大小。这些限制不是摆设,是保护relay不被单个客户端拖垮的关键。

3. 用Cargo workspace组织项目:模块边界怎么划才不别扭

3.1 为什么这个项目适合用workspace而不是单crate

一个Nostr relay如果全塞进一个crate里,代码量上去之后会非常难受。协议类型定义、存储抽象、WebSocket处理、配置解析、CLI入口,这些东西的依赖和编译特性都不一样。比如协议类型那部分你可能希望它是纯数据、不依赖tokio;存储层你可能想同时支持内存和SQLite两种后端;WebSocket层则强依赖tokio和网络库。

Cargo workspace的价值就在这里:每个crate独立编译、独立测试,依赖关系显式声明。我一般会这样划分:

crate名称职责关键依赖
buzz-core协议类型、event验证、filter匹配serde, secp256k1, sha2
buzz-store存储抽象trait + 内存/SQLite实现rusqlite或sqlx, async-trait
buzz-relayWebSocket服务、连接与订阅管理tokio, tokio-tungstenite
buzz-config配置结构体与加载serde, toml
buzz-bin可执行入口,组装以上模块以上全部

这样划分之后,buzz-core可以单独写单元测试,不需要起网络;buzz-store可以针对trait写两套实现,测试时用内存版,生产用SQLite版;buzz-relay只关心连接和消息路由,不关心数据存哪。

3.2 workspace的Cargo.toml怎么写才干净

根目录的Cargo.toml用[workspace]声明成员,同时可以用[workspace.dependencies]统一管理依赖版本,避免各个crate里版本号打架:

[workspace] members = ["buzz-core", "buzz-store", "buzz-relay", "buzz-config", "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"] } sha2 = "0.10"

然后在子crate里这样引用:

[dependencies] tokio = { workspace = true } serde = { workspace = true }

这个做法我强烈推荐。以前我在多个crate里各自写tokio版本,结果一个用1.35一个用1.38,编译时Cargo会拉两份tokio进来,编译时间翻倍不说,还可能因为feature不一致出现诡异问题。统一到workspace级别之后,整个项目只有一份依赖图。

3.3 编译缓存与增量构建的实操心得

workspace有个很实际的好处:target目录是共享的。你在根目录跑cargo build,所有crate的产物都放在同一个target下,切换crate编译时不会重复编译公共依赖。

但这里有个坑:如果你在workspace根目录跑cargo build,它会编译所有成员;如果你只想编译某个crate,用cargo build -p buzz-relay。我刚开始用workspace的时候,每次改一行relay代码都跑全量build,等得想砸键盘。后来改成-p指定包,配合cargo check先做类型检查,开发效率高了一大截。

另外,cargo check比cargo build快很多,因为它不生成最终二进制,只做类型检查。日常开发我基本是cargo check -p buzz-relay循环,只有要实际跑起来的时候才cargo run -p buzz-bin。

4. WebSocket连接与订阅管理:tokio下的并发模型设计

4.1 每个连接一个task,还是全局事件循环

这是设计relay时第一个要做的架构决策。两种常见方案:

方案A:每个连接spawn一个tokio task。每个WebSocket连接独立处理读写,订阅状态存在这个task的局部变量里。新事件到来时,通过一个全局的broadcast channel通知所有连接task,各task自己判断是否命中订阅。

方案B:全局单事件循环。所有连接注册到一个中心结构里,事件到来时由中心统一分发。

我实际两种都写过,最后倾向方案A。原因是方案A的并发模型更简单,每个连接的状态是局部的,不需要锁;连接断开时task自然结束,资源回收干净。方案B虽然分发逻辑集中,但中心结构会成为锁竞争的热点,而且连接状态和中心状态分离,容易出bug。

方案A的关键是用tokio::sync::broadcast做事件广播。relay收到一条合法event后,往broadcast channel发一份,所有连接task都订阅了这个channel,各自收到后用自己的订阅filter判断要不要推给客户端。

let (tx, _rx) = tokio::sync::broadcast::channel::<Arc<Event>>(1024); // 每个连接task里 let mut rx = tx.subscribe(); loop { tokio::select! { Some(msg) = ws_stream.next() => { /* 处理客户端消息 */ } Ok(event) = rx.recv() => { if matches_any_subscription(&event, &subscriptions) { ws_stream.send(to_client_msg(&event)).await?; } } } }

4.2 broadcast channel的容量选择与lag处理

broadcast::channel的容量参数很关键。设太小,慢消费者会收到RecvError::Lagged,也就是它跟不上了,中间的事件被丢弃。设太大,内存占用高。

我的经验值是1024到4096之间。Nostr的事件流在热门relay上可能每秒几十到几百条,1024的缓冲能扛住短时突发。收到Lagged的时候不要直接panic,正确做法是记录日志,然后让这个连接重新拉一次历史(或者干脆断开让它重连)。因为一旦lag了,这个连接的事件流就出现空洞了,继续推反而会让客户端状态不一致。

match rx.recv().await { Ok(event) => { /* 正常推送 */ } Err(broadcast::error::RecvError::Lagged(n)) => { tracing::warn!("connection lagged, dropped {} events", n); // 可选:触发重新同步或断开 } Err(broadcast::error::RecvError::Closed) => break, }

4.3 订阅表的并发访问:什么时候需要锁

在方案A里,订阅表是连接task的局部变量,读写都在同一个task里,天然不需要锁。但有个例外:如果relay需要支持"服务端主动关闭某订阅"或者"统计每个订阅的推送量",就可能需要跨task访问。

我的建议是尽量把订阅表保持在连接task内部,跨task的需求通过消息传递解决,而不是共享内存加锁。比如要统计推送量,连接task自己维护一个计数器,定期通过一个mpsc channel上报给统计模块。这样整个系统里几乎没有共享可变状态,调试起来轻松很多。

注意:Rust的借用检查器在写这种并发代码时其实是帮手。如果你发现某段代码非要加Arc<Mutex<>>才能编译过,往往说明你的状态划分有问题,值得停下来重新想想。

5. 事件存储选型:内存、SQLite还是别的

5.1 先想清楚relay的定位再选存储

存储选型没有标准答案,取决于你这个relay是干什么的。我把它分成三类:

  • 个人relay:只服务自己,事件量小,内存存储完全够用,重启丢数据也无所谓。
  • 社区relay:服务一小群人,事件量中等,需要持久化,SQLite是甜点区。
  • 公共relay:开放注册,事件量大,需要正经的数据库,PostgreSQL之类。

"buzz"这种项目如果目标是做一个可长期运行的中继,SQLite是很务实的选择。它单文件、零运维、支持并发读,写入用WAL模式之后性能也够看。关键是Rust生态里rusqlite和sqlx都很成熟。

5.2 存储抽象trait的设计

为了让存储可替换,我会在buzz-store里定义一个trait:

#[async_trait] pub trait EventStore: Send + Sync { async fn insert(&self, event: &Event) -> Result<bool>; // 返回是否新插入 async fn query(&self, filters: &[Filter]) -> Result<Vec<Event>>; async fn delete(&self, id: &EventId) -> Result<()>; async fn count(&self) -> Result<u64>; }

insert返回bool表示是否是新的(去重),这个设计很重要,因为relay收到重复事件时不应该重复广播。

内存实现用一个RwLock<HashMap<EventId, Event>>加一个按时间排序的索引就够了。SQLite实现则要注意建索引:created_at、kind、pubkey这几个字段经常出现在filter里,不建索引查询会全表扫描。

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_created_at ON events(created_at); CREATE INDEX idx_events_pubkey ON events(pubkey); CREATE INDEX idx_events_kind ON events(kind);

5.3 tag过滤的存储难题

Nostr的tag过滤(#e、#p等)是存储层最头疼的部分。因为tags是变长的数组,存在一个字段里,查询时要么全表扫描解析JSON,要么单独建一张tag表。

我推荐单独建表:

CREATE TABLE event_tags ( event_id TEXT NOT NULL, tag_name TEXT NOT NULL, tag_value TEXT NOT NULL ); CREATE INDEX idx_tags_lookup ON event_tags(tag_name, tag_value);

查询#p过滤时,先在这张表里查出匹配的event_id集合,再和主表的其他条件做交集。这样虽然多一次join,但比解析JSON快得多。实测在十万级事件量下,tag查询从秒级降到毫秒级。

提示:写入event和写入tags要放在同一个事务里,否则可能出现event写进去了但tags没写全的情况,导致这条event永远查不到。

6. 调试与压测中暴露的真实问题

6.1 签名验证的性能陷阱

secp256k1的schnorr签名验证是CPU密集操作,单次大概几十到几百微秒。看起来不多,但如果relay每秒收到上千条event,光验证签名就能吃满一个核。

我实测下来,用secp256k1crate的global-context特性可以复用上下文,比每次新建上下文快不少。另外可以考虑把签名验证放到tokio::task::spawn_blocking里,避免阻塞异步运行时。但要注意spawn_blocking有线程池开销,事件量不大的时候反而更慢,需要根据实际负载权衡。

还有一个优化点:先验证event id(sha256,很快),id不对直接拒绝,根本不用走到签名验证。这样能挡掉大量格式错误或恶意构造的事件。

6.2 重复事件的判定与竞态

去重看起来简单——查一下id存不存在就行。但在并发场景下,两个连接同时提交同一条event,两个task同时查数据库都发现不存在,然后都去插入,就会触发主键冲突。

处理方式有两种:一是依赖数据库的主键约束,插入失败就当作重复;二是在内存里维护一个近期event id的LRU集合,先查内存再查库。我倾向两者结合:内存LRU挡掉绝大部分重复,数据库约束兜底。

// 插入时用 INSERT OR IGNORE,根据 affected rows 判断是否新插入 let affected = conn.execute( "INSERT OR IGNORE INTO events (...) VALUES (...)", params![...], )?; Ok(affected > 0)

6.3 连接数暴涨时的文件描述符耗尽

这个坑我踩得很惨。压测的时候模拟几千个并发连接,跑一会儿relay就报"too many open files"。原因是Linux默认的fd上限是1024,每个WebSocket连接占一个fd,加上数据库连接、日志文件等,很快就到顶了。

解决办法是调整系统的ulimit -n,生产环境一般设到65535甚至更高。同时relay本身也要设一个最大连接数限制,超过就拒绝新连接,而不是硬扛到崩溃。

ulimit -n 65535

另外tokio的运行时线程数也要注意。默认它按CPU核数起worker线程,连接数极高的时候可以考虑适当调大,但不要盲目,因为线程切换本身也有成本。

6.4 日志与可观测性:别等出问题才想起来

relay这种长跑服务,日志是命根子。我用tracing+tracing-subscriber,按级别输出,生产环境默认info,需要排查时临时开debug。

关键要记录的东西:连接建立与断开(带连接id)、订阅创建与关闭、事件接收与拒绝(带拒绝原因)、广播lag事件、存储错误。这些日志在排查"为什么某条消息没收到"这类问题时是决定性的。

tracing::info!(conn_id = %id, "connection established"); tracing::debug!(conn_id = %id, sub_id = %sub, "subscription created"); tracing::warn!(event_id = %eid, reason = "invalid sig", "event rejected");

7. 从能跑到好用:几个值得投入的优化方向

7.1 NIP-11 relay信息文档

客户端连上relay之前,通常会先发一个HTTP GET请求,带Accept: application/nostr+json,期望拿到relay的元信息。这个就是NIP-11。返回内容包括relay名称、描述、管理员pubkey、支持的NIP列表、限制参数等。

实现起来不难,就是在WebSocket升级之前拦截HTTP请求,判断Accept头,返回一段JSON。但它的价值很大:客户端能据此知道你的relay支持哪些功能、有什么限制,用户体验直接上一个台阶。

async fn handle_request(req: Request<Body>) -> Result<Response<Body>> { if req.headers().get("accept").map(|v| v.as_bytes()) == Some(b"application/nostr+json") { let info = RelayInfo { name: "buzz", /* ... */ }; return Ok(Response::new(Body::from(serde_json::to_string(&info)?))); } // 否则走WebSocket升级 }

7.2 限制策略:把保护做在前面

一个开放的relay如果不设限制,很容易被单个客户端拖垮。我建议至少设这几项:

限制项建议值说明
最大连接数按机器配置超过直接拒绝
单连接最大订阅数20防止订阅表爆炸
单filter最大limit5000防止一次拉全库
最大事件大小64KB防止超大content
单IP连接数10防止单点刷连接

这些限制通过NIP-11暴露出去,客户端会自觉遵守。不遵守的客户端直接拒绝,不用客气。

7.3 优雅关闭:别让数据丢在最后一秒

relay重启的时候,如果直接kill进程,正在写入的事件可能丢,正在推送的消息可能断。正确做法是监听SIGTERM,收到后停止接受新连接,等待现有写入完成,再退出。

tokio里可以用tokio::signal::unix::signal监听信号,配合一个CancellationToken通知所有task收尾。这个功能平时看不出价值,但每次部署更新的时候都会感谢自己写了它。

let mut sigterm = signal(SignalKind::terminate())?; tokio::select! { _ = sigterm.recv() => { tracing::info!("shutting down gracefully"); cancel.cancel(); // 等待task结束 } }

8. 我在这个项目里最想分享的几条经验

写到这里,把几个我认为最值钱的经验单独拎出来说说。

第一,协议实现一定要对着NIP逐条核对。Nostr的NIP文档写得很细,但很多人只看个大概就动手,结果filter匹配、event验证这些地方全是偏差。我建议把相关的NIP打印出来,实现一个功能就勾掉一条,别嫌麻烦。

第二,Rust的workspace不是越大越好。我一开始恨不得把每个小功能都拆成crate,结果crate之间循环依赖、trait定义到处飞,反而更难维护。后来收敛到五六个crate,边界清晰,编译也快。拆分的标准应该是"这个模块会不会被独立测试或独立替换",而不是"它看起来像不像一个独立的东西"。

第三,压测要趁早。我第一版relay功能全对,单连接测试完美,结果一上并发就各种问题。后来养成习惯,功能写完先跑一轮简单压测(比如用脚本模拟几百个连接持续发事件),很多并发bug在早期就暴露了,修起来成本低得多。

第四,存储层的去重和索引是性能命门。relay跑久了慢,十有八九是存储层的问题。去重没做好,数据库里全是重复数据;索引没建对,查询全表扫描。这两件事在项目初期就要做对,后期补代价很大。

最后说个小的:cargo clippy和cargo fmt一定要配到CI里。Rust的clippy能揪出很多你意识不到的写法问题,比如不必要的clone、可以简化的match、潜在的性能陷阱。我每次提交前跑一遍clippy,长期下来代码质量提升非常明显。这个习惯看起来不起眼,但坚持半年之后回头看,会庆幸自己做了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 13:03:53

8通道PCIe DMA设计原理:空间换确定性的硬实时实现

简介&#xff1a;本资源是一份面向FPGA开发工程师与高速接口设计人员的PCIe多通道DMA控制器IP技术手册&#xff0c;聚焦8通道SGDMA/QDMA/RDMA/CDMA核心子系统在高性能数据搬移场景中的实现原理与应用规范。手册详细阐述了基于Xilinx PCIe Integrated Block构建的QDMA&#xff0…

作者头像 李华
网站建设 2026/11/3 13:03:50

SQL注入攻防实战:从靶场到企业级防御

1. 从靶场实战认识SQL注入的本质第一次接触sqli-labs靶场时&#xff0c;那个看似简单的登录界面背后隐藏的数据库操作让我震惊。输入 or 11 -- 后直接获取所有用户数据的瞬间&#xff0c;彻底颠覆了我对Web应用安全的认识。这个开源靶场用最直观的方式展示了SQL注入如何通过构造…

作者头像 李华
网站建设 2026/11/3 13:03:28

Paperclip:React与OpenClaw之间的本地AI协议桥接器

1. 项目概述&#xff1a;Paperclip 不是回形针&#xff0c;而是一个被严重低估的 AI 工具链枢纽 你搜“paperclip”时&#xff0c;第一反应可能是办公桌抽屉里那个银色小金属件——但最近半年&#xff0c;在 Node.js 和 React 开发者圈子里&#xff0c;“paperclip”正以一种近…

作者头像 李华
网站建设 2026/11/3 13:03:17

Logisim MIPS CPU设计实战:单周期/多周期/微程序全实现

简介&#xff1a;本资源是华中科技大学网络安全学院《计算机组成原理》课程的完整实验报告&#xff0c;面向计算机类、信息安全专业本科生及硬件设计初学者&#xff0c;聚焦CPU底层实现能力培养。报告系统覆盖单周期与多周期MIPS CPU的数据通路设计、硬布线控制器与微程序控制器…

作者头像 李华
网站建设 2026/11/3 13:02:48

代码命名规范与最佳实践指南

1. 为什么代码命名如此重要&#xff1f; 我第一次接手同事的代码时&#xff0c;面对满屏的a、b、c、temp这样的变量名&#xff0c;花了整整三天才理清逻辑。好的命名能让代码自解释&#xff0c;差的命名则像迷宫一样让人迷失。命名是程序员最重要的基本功之一&#xff0c;却也是…

作者头像 李华
网站建设 2026/11/3 13:02:31

东华OJ复试二刷:动态规划与图论算法优化实战

1. 东华OJ复试二刷的价值与意义 作为计算机专业考研复试的重要环节&#xff0c;东华大学的在线评测系统&#xff08;OJ&#xff09;一直是考生展示编程能力的关键战场。经历过初试的考生往往会在复试前对OJ题库进行二刷甚至多刷&#xff0c;这种重复训练绝非简单的题海战术&…

作者头像 李华