Rust workspace 实战:从零构建高并发 Nostr relay 的架构与优化

发布时间:2026/11/3 15:23:11
Rust workspace 实战:从零构建高并发 Nostr relay 的架构与优化

1. 从 "buzz" 这个名字说起:一个 Rust workspace 里的 Nostr relay 到底在做什么

第一次看到 "buzz" 这个项目名,加上 Nostr、relay、Rust、workspace 这几个关键词,我脑子里第一反应是:又一个用 Rust 写的 Nostr 中继服务。这个判断不是拍脑袋,Nostr 生态里 relay 实现本来就多,用 Rust 重写或者新写一个 relay,是这两年被讨论得非常多的一条路线。原因也很直接——relay 本质上是一个高并发、长连接、消息扇出密集的服务端程序,对内存安全、并发模型、单机吞吐的要求都很硬,而 Rust 的 async 生态(tokio + axum 这一套)恰好是干这个的。

先把概念对齐一下,不然后面聊不下去。Nostr 是一套去中心化的消息协议,核心就三样东西:客户端(client)、中继(relay)、事件(event)。客户端把用户签好名的事件(一条笔记、一个资料、一次点赞都算事件)用 WebSocket 发给一个或多个 relay,relay 负责校验签名、存下来、再转发给订阅了对应条件的其他客户端。整个协议里 relay 不掌握用户身份,也不负责内容审核之外的复杂逻辑,它更像一个"带过滤条件的消息广播站"。所以一个 relay 要干的事其实很聚焦:接 WebSocket、解析 JSON 事件、验签、按订阅条件匹配、存储、扇出。

那 "buzz" 作为一个 Rust workspace 形态的项目,它的定位就清楚了:它不是单个 main.rs 一把梭的小玩具,而是拆成多个 crate 的工程化实现。Rust 里 workspace 的意义就是把一个项目拆成若干个互相依赖的 crate,共享一份 Cargo.lock 和 target 目录,统一编译、统一版本管理。一个正经的 Nostr relay 拆成 workspace,通常是因为它天然就有几块职责边界很清晰的东西:协议层(事件结构、NIP 实现)、存储层(数据库读写)、网络层(WebSocket 服务、连接管理)、配置与启动层。这几块拆开,各自能单独测试,也能单独替换——比如今天用 SQLite 存,明天想换 Postgres,只动存储 crate 就行。

我为什么会对这类项目感兴趣?因为 relay 这个位置特别能暴露工程能力。你写一个 demo relay,几百行就能跑起来,能收能发。但真放到有并发连接、有大量订阅、有历史事件回填的场景下,问题会成堆冒出来:连接数一上来内存怎么控、订阅匹配怎么做才不拖垮 CPU、事件去重和替换逻辑(Nostr 里有些事件类型是"可替换"的,同一个人同一类只保留最新一条)怎么保证正确、断线重连后客户端要补历史事件怎么高效查。这些才是 relay 的硬骨头,也是我看 "buzz" 这类项目时最想扒开看的部分。

这篇文章我打算按一个真实 relay 项目的拆解思路来写:先讲整体架构为什么这么设计,再钻进核心细节(事件模型、验签、订阅匹配、存储),然后是完整的实操——怎么把这个 workspace 跑起来、怎么配、怎么压测,最后是我踩过的坑和排查经验。适合谁看?如果你正在学 Rust、想找一个真实项目练手,或者你本来就在做 Nostr 相关的东西、想自己搭一个 relay,那这篇应该能省你不少时间。如果你只是听说过 Nostr 想搞明白 relay 到底怎么回事,前面几节也能让你把概念理顺。

2. 整体架构拆解:为什么 relay 要用 workspace 而不是单 crate

2.1 单 crate 写 relay 会在什么时候崩掉

我先说说为什么很多人一开始会选单 crate。简单啊,一个main.rs,加几个mod,cargo run就跑起来了,改起来也快。我早期写类似服务的时候也这么干过,前两周很爽,等到功能堆到一定程度就开始难受了。

第一个崩点是编译时间。Rust 的编译本来就慢,单 crate 意味着你改一行存储层的代码,整个二进制包括网络层、协议层全部重编。一个中等规模的 relay,依赖 tokio、axum、serde、sqlx 或者 rocksdb 这类重库,全量编译动辄几分钟。你调试一个订阅匹配的 bug,改一次等三分钟,一天下来光等编译就废了。

第二个崩点是测试隔离。协议层的单元测试本来不需要起数据库、不需要开网络端口,但单 crate 里所有东西耦合在一起,你想测一个事件验签函数,链接的时候会把整个数据库驱动也拉进来,测试跑得又慢又重。workspace 拆开之后,协议 crate 的测试就是纯函数测试,毫秒级跑完。

第三个崩点是依赖冲突和特性开关。存储层可能想用某个库的 blocking 特性,网络层想用它的 async 特性,单 crate 里特性是全局合并的,很容易互相污染。拆成独立 crate 后,每个 crate 声明自己需要的特性,边界清楚。

所以 "buzz" 用 workspace,本质上是承认了一件事:relay 不是一个"小脚本",它是一个有明确分层的中型服务端程序,值得用工程化的方式组织。

2.2 一个典型 relay workspace 的 crate 划分

基于我见过的多个 Rust relay 实现,一个合理的 workspace 划分大概是这样几层,我把它整理成表格,方便你对照自己的项目:

crate 名称(示意)职责关键依赖是否可独立测试
protocol/core事件结构定义、NIP 实现、签名验签、序列化secp256k1、serde、serde_json是,纯逻辑
storage事件持久化、查询、索引、替换逻辑sqlx / rocksdb / sled是,可用内存库测
relay/serverWebSocket 接入、连接管理、订阅匹配、扇出tokio、axum / tungstenite部分,需集成测试
config配置解析、环境变量、默认值serde、figment / config是
bin/app组装各层、启动入口、优雅关闭上面全部集成测试

这个划分的关键在于依赖方向是单向的:bin依赖relay,relay依赖storage和protocol,storage依赖protocol,protocol谁都不依赖(除了密码学库)。这条依赖链一旦定下来,你就不会写出"协议层反过来调用网络层"这种循环依赖的烂代码。Rust 的模块系统其实不强制你这么做,但 workspace 的 crate 边界是物理隔离的,跨 crate 循环依赖直接编译不过,这比靠自觉强太多。

2.3 为什么是 axum + tokio 这套组合

热词里出现了rust axum,这不是偶然。Rust 写 Web 服务,现在主流就是 axum、actix-web、warp 这几家。relay 这个场景特殊在它主要跑 WebSocket,而不是普通 HTTP 请求响应。那为什么还选 axum?

因为 axum 对 WebSocket 的支持是内建的,axum::extract::ws::WebSocketUpgrade直接把升级握手处理掉了,你拿到的是一个WebSocket流,可以split成 sender 和 receiver 两半,分别丢进不同的 task。这个模型和 relay 的需求完美契合:每个连接一个读 task 负责收客户端消息,一个写 task 负责推事件,中间用 channel 通信。axum 底层是 hyper 和 tower,生态成熟,中间件、超时、限流这些都能直接复用 tower 的组件。

tokio 就更不用说了,Rust async 运行时的事实标准。relay 是典型的 IO 密集型 + 高并发场景,tokio 的多线程调度器能把连接分散到多个 worker 上。这里有个经验点:relay 的瓶颈通常不在 CPU 而在连接数和内存,所以 tokio 的 worker 线程数不一定要拉满,反而要关注每个连接占用的内存。我见过有人把 worker 设成 CPU 核数,结果连接一多内存爆掉,问题不在 worker 数量,而在每个连接缓冲没做背压。

提示:relay 的 WebSocket 写端一定要做背压处理。如果某个客户端消费慢,你还拼命往它的发送队列里塞事件,内存会无声无息地涨上去。正确做法是给每个连接的发送 channel 设一个有界容量,满了就丢弃或者断开这个慢客户端。

3. 核心细节解析:事件、验签、订阅匹配这三块硬骨头

3.1 Nostr 事件模型与签名验证的正确姿势

Nostr 事件的结构是固定的,一个事件包含这些字段:id、pubkey、created_at、kind、tags、content、sig。其中id是事件序列化后的 SHA-256 哈希,sig是作者用私钥对id的 Schnorr 签名。relay 收到事件后必须做两件事:重算id看是否匹配,验证sig是否由pubkey对应的私钥签出。这两步任何一步不过,事件直接拒绝。

这里有个非常容易踩的坑:id的计算方式是有严格规范的,它是对一个特定顺序的 JSON 数组做序列化,格式是[0, pubkey, created_at, kind, tags, content],注意第一个元素是固定的0,而且序列化时不能有多余空格、字段顺序不能变、转义规则要一致。很多新手 relay 实现直接用 serde 把整个事件结构序列化去算哈希,结果算出来的id永远对不上,因为 serde 的字段顺序和转义跟规范不一致。正确做法是手动构造那个数组,用serde_json::to_string生成紧凑 JSON,再算 SHA-256。

验签用的是 secp256k1 的 Schnorr 签名(BIP340)。Rust 里用secp256k1crate,开rand和global-context特性。验签本身很快,但要注意:验签是 CPU 操作,如果 relay 收到大量事件,验签会成为热点。我的经验是验签这一步不要放在持有锁的地方做,也不要在单个 task 里串行做,可以丢进tokio::task::spawn_blocking或者用一个专门的验签线程池,避免阻塞 async 运行时。

还有一个细节:created_at的时间校验。relay 通常会拒绝时间戳偏离当前时间太多的事件(比如超过上下 15 分钟),这是防重放和防垃圾的一个手段。但这个窗口不能设太窄,否则客户端时钟稍微不准就被拒,用户体验很差。我一般设成上下 15 到 30 分钟,并且把这个值做成可配置。

3.2 订阅匹配:从朴素遍历到索引优化

订阅是 relay 的核心功能。客户端发一个REQ消息,带一个订阅 id 和一组过滤条件(filter),filter 里可以指定作者列表、事件 kind 列表、时间范围、标签匹配等。relay 要做的是:对每一条新来的事件,找出所有匹配的活跃订阅,把事件推给对应的连接。

最朴素的做法是遍历所有活跃订阅,逐个判断 filter 是否匹配。连接少的时候没问题,但订阅数一多,每条事件都要过一遍全部订阅,CPU 直接起飞。我实测过一个场景:几千个活跃订阅,每秒几百条事件,朴素遍历直接把一个核跑满。

优化的方向是建索引。常见的做法是按维度拆:维护一个kind -> 订阅集合的映射,一个pubkey -> 订阅集合的映射,事件来了先按 kind 和 pubkey 快速缩小候选集,再对候选集做精确匹配。标签匹配(比如#e、#p这类)比较难索引,通常放在最后做精确过滤。这个思路和数据库的索引是一个道理:先用便宜的维度过滤掉大部分,再用贵的精确匹配处理剩下的。

订阅匹配里还有个容易忽略的点:filter 的语义是"或"还是"与"。一个 REQ 里可以带多个 filter,事件只要匹配其中任意一个 filter 就算命中这个订阅。但单个 filter 内部的多个条件是"与"——比如同时指定了 authors 和 kinds,事件必须作者在列表里且 kind 在列表里才算匹配。这个语义搞反了,订阅就会返回一堆不该返回的事件。我见过有人把 filter 内部条件也做成"或",结果客户端收到大量无关事件,还以为是 relay 有 bug。

3.3 存储层:可替换事件与去重逻辑

Nostr 里有几类特殊事件需要特别处理。kind: 0是用户元数据,kind: 3是关注列表,kind: 10000往后的某些 kind 是"可替换事件"(replaceable),还有一类是"参数化可替换事件"(parameterized replaceable)。这些事件的语义是:同一个作者、同一个 kind(参数化的话还要同一个d标签),只保留created_at最新的那一条,旧的应该被替换掉。

这个逻辑如果实现错了,会出现很诡异的现象:用户改了昵称,但别人看到的还是旧的;或者关注列表更新了,relay 里却存了好几份。正确做法是在写入时判断:如果新事件是可替换类型,先按(pubkey, kind)或者(pubkey, kind, d_tag)查出已有事件,比较created_at,新的更新就删旧存新,旧的更新就直接丢弃。

去重是另一回事。同一个事件可能被客户端重复发送,也可能从多个 relay 同步过来。id是唯一的,所以去重就是按id判断是否已存在。这里建议给id建唯一索引,写入时用INSERT OR IGNORE或者先查后插,避免重复存储。但要注意并发场景:两个连接同时发同一个事件,先查后插会有竞态,唯一索引 + 冲突忽略是更稳的做法。

存储选型上,SQLite 适合单机小规模,配置简单,一个文件搞定;Postgres 适合需要多实例共享存储或者数据量大的场景;RocksDB 这类 KV 存储适合写入吞吐极高的场景,但查询灵活性差,标签查询要自己建二级索引。我的建议是先用 SQLite 把功能跑通,等真的遇到性能瓶颈再换,别一上来就上重型方案。

4. 完整实操:把 buzz 这个 workspace 跑起来并压测

4.1 环境准备与 Rust 工具链配置

先把地基打好。Rust 安装用 rustup,这是官方推荐的方式,能方便地管理多个工具链版本。装完之后确认一下版本,relay 这类项目一般要求比较新的 stable,因为 async 生态更新快。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source "$HOME/.cargo/env" rustc --version cargo --version

国内网络环境下,cargo 拉依赖经常慢得让人抓狂,所以配置镜像源是第一步。在~/.cargo/config.toml里加上镜像配置,把 crates.io 的源替换成国内镜像,速度能快一个数量级。这个配置对所有 Rust 项目都通用,不只是 buzz。

[source.crates-io] replace-with = 'mirror' [source.mirror] registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"

配好之后cargo build拉依赖会顺畅很多。如果你用的是 VSCode 做开发,装 rust-analyzer 插件,它比老的 RLS 好用太多,代码补全、跳转、内联类型提示都很到位。workspace 项目里 rust-analyzer 能正确识别各个 crate 的边界,跨 crate 跳转也没问题。有时候 rust-analyzer 会卡在 "loading workspace",多半是 target 目录太大或者依赖没下全,cargo check跑一遍通常就好了。

4.2 编译与启动:workspace 项目的正确打开方式

拿到一个 workspace 项目,先看根目录的Cargo.toml,里面会有[workspace]段,列出所有成员 crate。编译的时候在根目录跑cargo build,它会按依赖顺序把所有 crate 编出来。如果你只想编某一个 crate,用cargo build -p <crate名>,这在调试单个模块时很有用,能省不少时间。

# 全量编译(debug) cargo build # 只编译某个 crate cargo build -p buzz-relay # release 编译,压测必须用这个 cargo build --release # 运行主程序 cargo run --release --bin buzz

这里强调一下:压测一定要用 release 编译。debug 版本的 Rust 代码性能可能只有 release 的十分之一甚至更低,你拿 debug 版本压测得出的结论全是错的。我早期就犯过这个错,debug 下 QPS 上不去,以为是架构问题,换成 release 直接翻了好几倍。

启动前先看配置。relay 的配置一般通过环境变量或者配置文件传入,常见的配置项包括监听地址、端口、数据库路径、最大连接数、事件大小限制、时间窗口等。我习惯用环境变量,部署的时候方便,本地开发用.env文件加载。

# 典型的环境变量配置 export BUZZ_LISTEN_ADDR="0.0.0.0:8080" export BUZZ_DATABASE_URL="sqlite://./buzz.db" export BUZZ_MAX_CONNECTIONS="10000" export BUZZ_MAX_EVENT_SIZE="65536" export BUZZ_CREATED_AT_WINDOW="900"

启动之后,先用一个简单的 WebSocket 客户端连上去,发一条测试事件,确认能收能发。别急着上压测,先把功能链路走通。

4.3 用 websocat 和脚本做功能验证

功能验证我一般分三步走。第一步用websocat这种命令行 WebSocket 工具手动发消息,看 relay 的响应。第二步写个小脚本模拟完整的事件发布和订阅流程。第三步才是压测。

websocat连上 relay 之后,你可以手动发["REQ", "sub1", {"kinds": [1]}]订阅 kind 1 的文本笔记,然后从另一个连接发一条["EVENT", {...}],看第一个连接能不能收到。这个手动过程能帮你快速确认协议实现对不对。

# 连接 relay websocat ws://127.0.0.1:8080 # 订阅 kind 1 事件 ["REQ", "sub1", {"kinds": [1], "limit": 10}] # 发布事件(需要真实签名的事件,这里示意结构) ["EVENT", {"id": "...", "pubkey": "...", "created_at": 1700000000, "kind": 1, "tags": [], "content": "hello buzz", "sig": "..."}]

写脚本验证的时候,事件签名是个麻烦事,你得用私钥签。可以用 Rust 写个小工具,或者用现成的 Nostr 客户端库生成测试事件。我一般会准备一批预签名好的测试事件,压测的时候直接复用,避免压测过程中把 CPU 耗在签名上。

4.4 压测方案与关键指标

压测 relay 和压测普通 HTTP 服务不一样,因为它是长连接 + 双向消息。你需要模拟大量客户端同时保持连接、发订阅、发事件。工具选择上,可以用 k6 的 WebSocket 支持,或者自己用 Rust/Go 写压测客户端,后者更灵活,能精确控制消息模式。

压测要关注的指标,我列个表:

指标含义健康范围参考
并发连接数同时保持的 WebSocket 连接取决于内存,单连接约几十 KB
事件写入 QPS每秒成功存储的事件数受存储层限制
事件扇出延迟从发布到订阅者收到的时间越低越好,理想 < 100ms
内存占用进程 RSS随连接数线性增长需警惕
CPU 使用率各核负载验签和匹配是热点

压测的时候要分场景:纯连接保持、纯写入、纯订阅扇出、混合负载。分开测才能定位瓶颈在哪。我一般先测纯连接,看能撑多少连接不断;再测纯写入,看存储层吞吐;最后测混合,看真实场景下的表现。

注意:压测客户端本身也可能成为瓶颈。如果你用单机跑压测客户端,客户端 CPU 先跑满了,你测出来的 relay 性能就是假的。压测机和被测机最好分开,或者至少确认压测客户端没到瓶颈。

5. 常见问题与排查技巧实录

5.1 连接建立失败与握手问题

最常见的问题就是连不上。排查顺序我一般是这样的:先确认进程在跑、端口在听(ss -tlnp或者netstat),再确认防火墙没挡,最后看 relay 日志有没有握手报错。WebSocket 握手失败经常是因为路径不对或者缺少必要的 header。有些 relay 实现要求特定的路径(比如/或者/nostr),客户端连错路径就直接 404。

还有一种情况是反向代理配置问题。如果你在 relay 前面放了 Nginx 或者 Caddy 做 TLS 终止,WebSocket 需要显式配置升级头透传。Nginx 里要加proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection "upgrade";,少了这两行,握手永远失败,而且报错信息很隐晦,容易查半天。

5.2 事件被拒绝的几类原因

客户端发事件被 relay 拒绝,返回["OK", event_id, false, "reason"],reason 里会说明原因。常见的拒绝原因我整理成速查表:

拒绝原因可能问题排查方向
invalid: id mismatchid 计算方式不对检查序列化格式和字段顺序
invalid: signature签名验证失败检查 pubkey 和 sig 是否匹配
invalid: created_at too far时间戳超窗口检查客户端时钟或放宽窗口
blocked: rate limited触发限流检查限流配置
error: storage存储写入失败检查数据库连接和磁盘空间

id mismatch是最常见的,九成是序列化格式问题。我建议在协议层写一个专门的测试,用已知的测试向量(官方 NIP 文档里有示例事件)验证你的 id 计算和验签逻辑,这样能第一时间发现问题。

5.3 内存泄漏与连接堆积

relay 跑一段时间内存持续上涨,这是最让人头疼的问题。原因通常有几个:连接断开后没有正确清理订阅和发送 channel;事件缓存没有上限;某个慢客户端导致发送队列无限堆积。

排查内存问题,我一般用tokio-console看 task 数量,如果 task 数只增不减,说明连接清理有问题。再用heaptrack或者valgrind这类工具看堆分配热点。实践中发现最多的是发送 channel 没做背压,慢客户端把队列撑爆。解决办法就是前面说的,有界 channel + 超时断开。

连接堆积还有个隐蔽原因:TCP 半开连接。客户端网络断了但没发 FIN,服务端以为连接还在。这需要配置 TCP keepalive,让内核定期探测,超时后自动关闭。tokio 的 TcpStream 可以设置 keepalive,别用默认值,默认可能两小时才探测一次。

5.4 订阅匹配的性能陷阱

订阅匹配慢,通常是因为 filter 写得太宽泛。比如一个 filter 只指定了{"limit": 5000}不带任何其他条件,那它匹配所有事件,每次事件来了都要往这个订阅推。这种订阅多了,扇出量爆炸。

从 relay 角度,可以对订阅做限制:限制单个连接的最大订阅数、限制 filter 的复杂度、对宽泛 filter 做特殊处理。从客户端角度,应该尽量把 filter 写精确,指定 kinds、authors、时间范围,减少不必要的事件推送。这是协议使用习惯问题,但 relay 端做点防护能避免被个别客户端拖垮。

还有个性能点是历史事件查询。客户端订阅时如果带limit,relay 要查历史事件返回。这个查询如果没索引,全表扫描会非常慢。确保created_at、kind、pubkey这些常用查询字段都有索引,标签查询如果频繁也要考虑建索引或者用专门的标签表。

6. 我在这类项目上踩过的坑和一些个人习惯

聊了这么多架构和实操,最后说几个我自己的习惯和踩坑经历,这些是文档里不会写的。

第一个坑是过早优化存储。我一开始就想着上 RocksDB,觉得 KV 存储写入快,结果标签查询要自己维护二级索引,写了一大堆代码,最后发现 SQLite 加好索引完全够用,而且查询灵活得多。后来我给自己定了个规矩:先用最简单的方案把功能跑通,压测出真实瓶颈再优化,别凭想象优化。

第二个坑是忽略优雅关闭。relay 重启的时候,如果直接 kill,正在处理的事件可能丢,客户端连接突然断开。正确做法是监听关闭信号,停止接受新连接,给现有连接发关闭帧,等正在处理的事件写完再退出。Rust 里用tokio::signal配合CancellationToken能比较优雅地实现。这个功能平时看不出价值,但生产环境重启时能救命。

第三个习惯是给协议层写足测试向量。Nostr 的 NIP 文档里有标准测试事件,我把它们全部做成测试用例,每次改协议层代码都跑一遍。这样能保证 id 计算、验签、序列化这些基础逻辑不会因为重构而悄悄坏掉。这类 bug 最恶心,因为它不报错,只是行为不对,靠人工测很难发现。

第四个习惯是日志分级要清楚。relay 的日志量很大,连接建立、事件收发、订阅匹配如果都打 info,日志会被淹没。我的做法是:连接生命周期打 info,单个事件收发打 debug,匹配细节打 trace,错误和拒绝打 warn 或 error。这样生产环境开 info 能看清整体状态,排查问题时临时开 debug 或 trace。

关于 buzz 这类项目的后续扩展,我觉得有几个方向值得做:一是加指标暴露,用 Prometheus 格式把连接数、事件数、延迟这些打出来,配合 Grafana 做监控;二是做多实例部署,存储层换成共享的 Postgres,前面加负载均衡;三是实现更多的 NIP,比如 NIP-42 的客户端认证、NIP-50 的搜索,这些能让 relay 支持更丰富的场景。不过这些都是后话,先把核心链路做稳,比堆功能重要得多。

如果你也在写自己的 relay,我的建议是别一上来就追求功能全,先把"收事件、验签、存、按订阅推"这条主链路做扎实,把并发和内存这两条线守住,剩下的慢慢加。relay 这东西,稳定性比功能数量重要一个数量级。