Skip to content

07 · PostgreSQL 事件存储

PostgreSQL 是 Buzz 的权威事实层。它既保存 Nostr 事件,也保存为了权限、查询和长流程而建立的关系投影。buzz-db 对上提供 typed API,对下使用运行时 SQL;代码明确不使用 sqlx::query! 宏,以降低编译期数据库依赖,但把 schema 漂移检查压力转移给测试与迁移。

1. Schema 骨架

text
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. 事件表为什么分区

eventscreated_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_membersthread_metadata、workflow run 等表常直接参与授权或状态转移,不能随意删除后“以后再建”:

  • 成员投影决定私有频道可见性。
  • workflow run/approval token 决定动作是否可继续。
  • Push lease generation 决定 endpoint 是否仍有效。
  • relay members/ban 决定连接是否允许。

它们是从事件语义落地的服务端权威投影。有些可以由事件重放重建,但在线路径仍把当前表状态当授权依据。

5. 线程投影

接收回复时 Relay:

  1. 解析 NIP-10 e tags 与 root/reply marker。
  2. 加载 parent,要求同 community、同 channel。
  3. 推导 root、parent、depth,最大深度 100。
  4. 插入 event 与 thread metadata。
  5. 更新 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,而不是假装“没有数据”。
text
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 链。写入时:

  1. 获取 community-scoped advisory lock。
  2. 在事务中读取前一条 hash/sequence。
  3. 对规范字段编码:community、seq、微秒精度时间、action、可选字段 presence byte、canonical JSON、prev hash。
  4. 插入记录并更新 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. 源码入口

独立源码研究笔记 · 非 Buzz 官方文档