07 · PostgreSQL 事件存储
PostgreSQL 是 Buzz 的权威事实层。它既保存 Nostr 事件,也保存为了权限、查询和长流程而建立的关系投影。buzz-db 对上提供 typed API,对下使用运行时 SQL;代码明确不使用 sqlx::query! 宏,以降低编译期数据库依赖,但把 schema 漂移检查压力转移给测试与迁移。
1. Schema 骨架
communities
├── channels ── channel_members
├── users / relay_members / invites / allowlist / bans
├── events (range partitioned)
│ ├── event_mentions
│ ├── thread_metadata
│ └── message_reactions
├── workflows ── runs ── approval_tokens
├── audit_log / moderation
└── push leases / wake outbox频道主键是 (community_id, id);事件主键包含 community 与时间/ID 维度;大量外键显式带 community。数据库不是只靠应用层 WHERE 来隔离租户。
2. 事件表为什么分区
events 按 created_at 做 range partition:
- 历史保留/归档可按时间窗口操作。
- 大表索引和 vacuum 压力可分散。
- 查询通常带时间排序/范围,能做 partition pruning。
分区管理器只允许固定表名 allowlist,并用 SQL-safe 类型/校验限制动态 DDL;这是因为表名不能用普通参数绑定,若直接字符串拼接会形成高危注入面。
3. 普通、Replaceable 与删除
写入不是简单 INSERT ON CONFLICT DO NOTHING:
| 类型 | 处理 |
|---|---|
| 普通 event | 以 event ID 幂等插入 |
| replaceable | 同 community + author + kind 只保留更新者 |
| parameterized replaceable | 再把 d tag 纳入 coordinate |
| deletion | 标记/关联删除目标,查询默认排除 deleted |
并发 replace 使用 PostgreSQL advisory lock,把逻辑 coordinate 串行化。锁 key 必须包含 community 语义,避免两个租户的相同 author/kind/d 互相阻塞或替换。
4. 关系投影不是缓存
channel_members、thread_metadata、workflow run 等表常直接参与授权或状态转移,不能随意删除后“以后再建”:
- 成员投影决定私有频道可见性。
- workflow run/approval token 决定动作是否可继续。
- Push lease generation 决定 endpoint 是否仍有效。
- relay members/ban 决定连接是否允许。
它们是从事件语义落地的服务端权威投影。有些可以由事件重放重建,但在线路径仍把当前表状态当授权依据。
5. 线程投影
接收回复时 Relay:
- 解析 NIP-10
etags 与 root/reply marker。 - 加载 parent,要求同 community、同 channel。
- 推导 root、parent、depth,最大深度 100。
- 插入 event 与 thread metadata。
- 更新 root/parent 的回复计数和活跃窗口。
这样读取线程无需每次从任意 e tag 图做递归遍历,也能快速返回回复数。
6. Generated FTS
事件表的 search_tsv 是 generated column,并有 GIN 索引。生成表达式排除私密或不适合全文搜索的 kind;但迁移为避免重写已有大表,可能让 brownfield 部署暂时保留旧表达式。
安全不能只依赖 generated expression。搜索还会带 community/access scope,命中后 hydrate 原事件,再执行 reader gate。这是“索引缩小候选、安全逻辑最终裁决”的通用模式。
7. 读副本与 read-your-write fence
Db 可以把读流量路由到 replica,但刚写完就读会遇到复制延迟。Buzz 的 replica fence 大致维护:
- writer 周期性记录 heartbeat/可见时间下界。
- 读请求解析当前 fence floor。
- 若 replica 未证明追上该 floor,就回主库或 fail closed,而不是假装“没有数据”。
write primary at T
│
├─ fence records lower bound
▼
next read
├─ replica proves >= bound → use replica
└─ cannot prove → primary / guarded fallback这种设计比固定“写后 2 秒读主库”更精确,但实现复杂,尤其要处理时钟精度、writer 崩溃、旧 heartbeat 和 PostgreSQL timestamp 微秒截断。
8. 审计 hash chain
buzz-audit 为每个 community 建独立序列/hash 链。写入时:
- 获取 community-scoped advisory lock。
- 在事务中读取前一条 hash/sequence。
- 对规范字段编码:community、seq、微秒精度时间、action、可选字段 presence byte、canonical JSON、prev hash。
- 插入记录并更新 chain head。
验证时从 genesis 开始重算。它提供 tamper-evidence,不等于数据库防删除:若攻击者可同时改所有记录和外部锚点,仍需额外备份/签名/导出策略。
9. 事务与 async 副作用
数据库事务内只放必须原子一致的事实与投影;Redis publish、订阅通知、workflow 执行等在 commit 后。判断标准是:
- 如果失败会让授权状态自相矛盾 → 事务内。
- 如果可由事实重新驱动/补偿 → commit 后。
- 如果是低延迟体验 → 尽力异步,但客户端须能补历史。
10. 迁移是兼容性协议
schema/schema.sql 表示新库目标形态,migrations/ 表示升级路径。两者都要读:
- 新库可直接建立理想 expression/index。
- 老库可能因锁表/重写成本分阶段迁移。
- 某些约束先
NOT VALID,后续再验证。 - 运维脚本承担无法在普通 migration 时间窗完成的维护。
只看 schema 会错过 brownfield 行为,只看 migration 又难看清最终模型。
11. 源码入口
schema/schema.sql:社区、频道、事件和投影基线。migrations/:26 个演进步骤。crates/buzz-db/src/lib.rs:Db、连接池、读路由和 typed API。crates/buzz-db/src/event.rs:event query/insert/replace/thread 参数。crates/buzz-db/src/thread.rs:线程投影。crates/buzz-db/src/replica_fence.rs:副本可见性围栏。crates/buzz-db/src/partition.rs:安全分区 DDL。crates/buzz-audit/src/lib.rs:每社区审计链。