
strfry数据库设计原理PackedEvent零拷贝编码LMDB复合索引如何榨干查询性能【免费下载链接】strfrya nostr relay项目地址: https://gitcode.com/gh_mirrors/st/strfrystrfry 是一个用 C 编写的高性能nostr relay中继服务器它的所有事件数据都存放在本地 LMDB 嵌入式数据库中不依赖 MySQL、PostgreSQL 等外部数据库。这篇文章带你彻底看懂它的数据库设计原理PackedEvent 零拷贝编码与LMDB 复合索引是如何让按作者/ID/标签/时间范围查事件这类查询快到几乎零开销的。如果你想知道一个开源 nostr relay 如何榨干查询性能答案就藏在这两套设计里。为什么 nostr relay 要自己设计数据库大多数应用会直接选一个通用数据库但 strfry 选择了一条更极客的路自研查询引擎 嵌入 LMDB 文件数据库src/apps/relay/。原因很简单查询模式是固定的。nostr 协议的查询就是 NIP-01 定义的过滤器字段ids、authors、kinds、tags、since、until、limit。既然查询类型有限就可以为每种类型预置最优索引做到几乎所有查询都有索引可用永远不走全表扫描。没有 SQL 就没有解析开销。所有查询计划在设计时就已确定没有 SQL 生成/解析也没有注入风险。读取路径零锁、零系统调用。LMDB 通过mmap映射进内存读取直接从页缓存拿数据多核并发下线性扩展。数据库的表结构和索引声明集中在 golpe.yaml 中用不到 50 行 YAML 定义了全部索引——这本身就是设计即文档的范例。PackedEvent88 字节定长头 变长 Tags 的零拷贝编码 每条 nostr 事件的完整 JSON 可能有几 KB但其中可被索引的信息其实只有很少一部分事件 ID、作者公钥、时间戳、kind、过期时间、以及索引化的 tag。strfry 把这些信息压进一个紧凑的二进制结构PackedEvent布局见 src/PackedEvent.h偏移字节字段长度说明0id32事件 SHA-256 摘要32pubkey32作者公钥64created_at8时间戳uint64 原样字节72kind8事件类型80expiration8过期时间戳88tags[]变长每个 tag 1 字节名称 1 字节长度 值这个设计的精妙之处固定 88 字节头部id、pubkey是定长 32 字节created_at/kind/expiration是定长 8 字节。取任意一个字段就是从偏移处切一段内存不需要反序列化。零拷贝读取核心类PackedEventView只是包了一个std::string_viewid()、pubkey()、kind()等方法直接返回缓冲区的子视图。从 LMDB 页缓存里读到的字节就是程序拿到的对象——中间没有任何解码、拷贝或堆分配。瘦身到极致签名、非索引字段在入库前全部剥掉原始 JSON 单独存放下文会讲。记录越小页缓存命中率越高这是小数据换大性能的典型取舍。tag 变长编码每个索引化 tag 只占1 1 值长度字节单 tag 值上限 255 字节遍历 tag 只需按长度步进同样零拷贝。LMDB 复合索引每个字段都夹带created_at 真正榨干性能的是 golpe.yaml 中Event表定义的那组复合索引。看这份索引清单索引名键结构特点created_at时间戳全库按时间排序的主扫索引id事件ID created_at按 ID 精确查、去重pubkey公钥 created_at按作者查kindkind created_at按类型查pubkeyKind公钥 kind created_at组合精确查tagtag名tag值 created_at多值索引每 tag 一条记录replace公钥 d-tag kind参数可替换事件去重deletion事件ID 公钥NIP-09 删除标记expiration过期时间戳定时清理注意规律除了极少数辅助索引每个业务字段的键后面都追加了 8 字节的created_at构建逻辑在 golpe.yaml 的indexPrelude中makeKey_StringUint64(packed.pubkey(), indexTime)。这就是复合索引的精髓LMDB 的 B 树按键的自然顺序排列键前缀相同的记录会物理连续存放且按时间戳递增。所以某作者在 1 月 1 日到 1 月 2 日之间的事件不再是先按作者捞出全部再逐条比对时间而是直接在键空间里定位一个连续区间——since/until过滤几乎免费。扫描器可以从until对应的上界直接开始反向扫遇到since立即停止中途还能随时挂起后文详述。由于created_at就在键里很多查询只需要读索引就能完成index-only scan连 88 字节的 PackedEvent 本体都不用碰。DBScan 查询引擎按过滤器自动挑选最优索引 有了索引怎么用最聪明答案是 src/DBQuery.h 中的DBScan引擎。它收到一个 nostr 过滤器后按一套简单启发式选择唯一最优索引有ids→ 用id索引ID 是 256 位摘要选择性最高。有tags→ 选值数量最少的 tag 组走tag索引。同时有authors和kinds且组合数 1000→ 走pubkeyKind复合索引。只有authors→ 走pubkey索引。只有kinds→ 走kind索引。都没有→ 兜底走created_at时间索引。多个过滤值则开多个扫描游标ScanCursor各自沿索引反向扫描把候选事件按created_at降序归并输出——保证新事件先发给用户符合 nostr 的交互直觉。更妙的是可挂起/恢复设计长查询不会霸占 CPU。每次扫描都消耗工作预算超过时间预算如 10ms就把游标位置resumeKeyresumeVal只有几百字节存下来排队让位给新查询下次轮到自己时从断点无缝继续。这意味着慢连接永远不会拖累整个 relay 的延迟而这一切不需要任何数据库线程池。原始 JSON 单独存放levId 是连接索引与正文的钥匙 索引值里存的不是事件本体而是一个自增主键levIdLocal Event ID。完整事件 JSON 存在另一张裸表EventPayload中键是levId值带一个类型字节0 原始 JSON1 zstd 字典压缩数据。这套索引与正文分离带来三个好处O(1) 正文定位命中索引后按levId一次整数键查找即可取回正文见 src/events.h 中的lookupEventByLevId。按需压缩默认明文存储保查询速度磁盘紧张时用strfry dict训练 zstd 字典后压缩正文索引不压缩、查询路径不受影响。去重与幂等插入时先查id索引判断是否重复天然实现幂等写入。再配合单写者线程的架构——relay 只有确认事件真正提交进 LMDB 之后才会向客户端回OKdurable writes写入路径也做到了不丢一条。性能收益小结设计点带来的收益PackedEvent 定长头 string_view读取字段零反序列化、零拷贝复合索引夹带created_atsince/until变成键空间连续区间扫描可提前终止索引与正文分离levId大量查询 index-only不碰正文正文可独立压缩DBScan 最优索引启发式每个查询只走一条最窄的索引路径扫描可挂起/恢复慢查询不阻塞新查询整体延迟稳定LMDBmmap 单写者读路径无锁写路径批量摊销 fsync想读源码关键文件清单 想亲手验证这些设计仓库git clone https://gitcode.com/gh_mirrors/st/strfry里这几个文件值得按顺序读src/PackedEvent.h—— 零拷贝编码的完整布局与读写 API不到 100 行建议第一个读。golpe.yaml—— 全部 LMDB 表、索引与键构建逻辑的声明。src/DBQuery.h——DBScan索引选择启发式与可挂起扫描游标。src/events.h/src/events.cpp—— 事件解析、验签、levId查找与写入。src/apps/relay/—— Writer、ReqWorker、ReqMonitor 等线程如何围绕这套数据库协作。README.md—— 架构总览Architecture 章节与本文互相印证。从 88 字节的紧凑编码到夹带时间戳的复合索引strfry 的数据库设计给所有查询模式已知的实时系统上了一课当你能放弃通用性时性能会自己找上门。【免费下载链接】strfrya nostr relay项目地址: https://gitcode.com/gh_mirrors/st/strfry创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考