Block 开源多 Agent 通信平台 buzz 数据层怎么改表不停机
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
**Block 开源的多 Agent 通信平台 buzz,数据层最值得抄的地方不是某张表设计得多巧,而是它把「这个库正在被人用、不能停」这个前提,翻译成了迁移脚本里一条条会主动失败的硬约束。**改表这件事在它这里不是一串补丁流水账,而是一层能在进程启动时自我检查、检查不过就拒绝往下走的网。你如果在维护一个已经上线、还在天天加功能的系统,这套做法比表结构本身更有参考价值。
站内另外三篇和本篇分工不同:数据库设计怎么让 AI 参与 讲的是从零设计表结构时的取舍,重试与幂等 讲的是调用层怎么把重复请求收敛成一次,Agent 状态机与自由发挥 讲的是编排形态的选择;本篇只盯一件事——一个已经在跑的消息系统,表结构怎么往前挪而不掉数据、不停服务。
一、这套数据层在解决什么问题
先做几句消歧。buzz 是 Block 开源的一个项目(仓库 github.com/block/buzz,Apache-2.0,Copyright 2026 Block, Inc.),不是「热度」也不是「蜂鸣」,仓库 README 是这样定位自己的:一个人和 Agent 一起干活的工作区,跑在你自己拥有的中继上;README 还说明了每条消息、每个反应、每一步工作流都是同一条日志里的一条签名事件,作者是人还是程序在结构上没区别。它建在 Nostr 协议之上:Nostr 里的一条消息叫 event(事件),是一条带作者公钥和签名的记录;relay(中继)是负责存储和转发事件的服务器,你可以自己跑一个;用户的身份就是那对密钥,私钥自己拿着,所以丢了私钥等于丢了身份,没有客服能帮你找回。
这些概念直接映射到表上。migrations/0001_initial_schema.sql 里的 events 表有 pubkey、sig、kind、tags、content、created_at 这几列,一一对应事件的公钥、签名、类型号、标签、正文和声明时间。kind 是一个整数类型号,具体常量定义在 crates/buzz-core/src/kind.rs,比如 KIND_STREAM_MESSAGE = 9、KIND_REACTION = 7、KIND_GIFT_WRAP = 1059。协议扩展规范放在 docs/nips/ 下,那里有 15 份 NIP 规范的 md 加 2 份 fixtures json(另有一份 crates/buzz-core/src/pairing/NIP-AB.md)。
数据层本身是 crates/buzz-db 这个 crate(全仓 crates/ 下共 28 个 crate),SQL 都在根目录的 migrations/ 里,一共 27 份,从 0001_initial_schema.sql 到 0027_channels_id_lookup_index.sql。另外 schema/schema.sql 是一份期望态 schema,迁移测试会去里面核对新加的对象有没有同步。
这套 schema 的第一性约束是多租户。0001 的注释把它叫做 conformance「row zero」:一个请求属于哪个 community,由服务端从连接的 host 解析出来,客户端永远不能自己传;每一张租户作用域的表都带一列不可变的 community_id。理解了这条,后面 26 份迁移的很多写法才讲得通。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
run_migrations 入口 | 前置守卫 → 跑 sqlx 迁移 → 校验触发器目录 | crates/buzz-db/src/migration.rs | 启动且开了 BUZZ_AUTO_MIGRATE 时 |
| 27 份 SQL | 表、索引、触发器的唯一真源 | migrations/0001_initial_schema.sql 至 0027_channels_id_lookup_index.sql | 每次加字段、加索引 |
| 迁移 lint | 强制 community_id 打头、禁止改写 channels.community_id | crates/buzz-db/src/migration.rs 的 #[cfg(test)] mod tests | 新加表忘了带租户列,测试变红 |
| 期望态 schema | 与迁移对齐的目标结构 | schema/schema.sql | 新建库、对账 |
| 分区管理器 | 给 events / delivery_log 按月造分区 | crates/buzz-db/src/partition.rs | 进程启动与每月的 cron |
| 副本栅栏 | 证明读副本已经重放到某个时间点 | crates/buzz-db/src/replica_fence.rs | 打开读副本路由时 |
| 提交时 floor 触发器 | 把 created_at 下界变成提交时不变量 | migrations/0021_created_at_fence_floor.sql | 做历史数据回填时 |
| 心跳单行表 | 可移植的读侧观测点 | migrations/0026_replica_heartbeat.sql | 用隐藏了 WAL 位置的托管库时 |
二、27 份迁移:additive 规则与失败关闭的前置守卫
迁移是用 sqlx 嵌进二进制的,migration.rs 里就一行:sqlx::migrate!("../../migrations")。真正有意思的是 run_migrations 的三步顺序——先跑一个前置守卫,再跑 sqlx 的迁移,最后再校验一次触发器目录。三步都可能失败,任何一步失败进程都不会带着半成品 schema 继续跑。
第一条纪律叫 additive:0001 是一份合并好的初始 schema,从它之后的每一份都是纯增量,绝不允许把新东西折回 0001。原因写在测试注释里——折回去会改变 0001 的 checksum,已经装了数据的库启动时 sqlx 会报 VersionMismatch。这条规则不是靠人记,而是被写成断言钉死在 embedded_migrator_contains_consolidated_initial_schema 这个测试里:它断言迁移总数是 27、逐个版本断言里面必须出现什么,同时反向断言 0001 里不能出现 git_repo_names、icon、44200、30350、product_feedback、push_leases、idx_events_tags_gin 这些后来才加的东西。一份迁移一旦发布就是冻结的 SQL,改它的唯一方式是再写一份。
第二条纪律是让代价只落在新库上。0005 要把 kind 44200 排除出全文检索,做法是把生成列 search_tsv 整个 drop 掉再按新表达式重建——这在空库上是零成本,在装满 events 的库上就是一次堆重写。于是 0008 换了个写法:整份迁移包在 IF NOT EXISTS (SELECT 1 FROM events LIMIT 1) 里,只有空库才切换到正向白名单(CASE WHEN kind IN (0, 9, 40002, 45001, 45003) ... ELSE NULL::tsvector)。已装数据的库保持原策略不动,等于「新库拿新语义,老库不付重写代价」。这是改表不停机最朴素的一招:能不动存量就不动。
第三条纪律是宁可不改也不改一半。0007 要做 NIP-RS(读状态,kind 30078)的历史清理,属于会真删数据的迁移。它的写法是:进事务先 LOCK TABLE events IN SHARE ROW EXCLUSIVE MODE(读继续可用,写等到迁移提交),先建 parameterized_event_watermarks 把替换排序需要的水位播种下来,再删 payload;中间还有一段 DO $$ 检查异常数据,发现「已删事件排序高于存活头部」就直接 RAISE,报错文案是 NIP-RS retention blocked: deleted event outranks live head。
更狠的是 0007 之前那道守卫。reject_legacy_nip_rs_cardinality_ambiguity 在 sqlx 开自己的迁移事务之前跑:它先看 _sqlx_migrations 表在不在、最大已应用版本是不是小于 7,是的话就扫一遍 kind 30078 且 d_tag 匹配 ^read-state:[0-9a-f]{32}$ 的行,检查它们的 d/t 标签基数是否唯一明确。只要发现有歧义就返回错误,文案是「repair or remove those nonconforming rows before retrying」。配套的集成测试把这个行为验到了骨头缝里:阻断之后 _sqlx_migrations 里的版本列表一行没变、那条源数据的 tags 和 content 与阻断前逐字节相同,操作者手工修好 tags 之后重跑,一路成功到版本 27。
第四条纪律是把 schema 约定写成可执行的 lint。migration.rs 的测试模块里有一小套 SQL 解析器——拆语句、剥注释、找配对括号、切顶层逗号——它把 27 份迁移拼接后解析,然后跑三条断言:不在 _operator_global_tables 允许清单里的表必须有 NOT NULL community_id;这些表上的主键、唯一约束、外键和唯一索引必须以 community_id 打头(唯一的显式豁免是 delivery_log 的 PRIMARY KEY (delivered_at, id),因为它是分区键,代码里用 is_allowed_partition_primary_key_exception 单独列出);以及任何迁移语句都不许 UPDATE、ALTER、DROP 掉 channels.community_id,而且必须存在一个 BEFORE UPDATE 触发器拦住改租户的尝试。
这套 lint 有意思的地方在于它管的是「未来的迁移」。你新写一份 SQL、忘了带 community_id,红的是测试而不是线上。想让一张表豁免,得显式往 _operator_global_tables 里插一行并写清理由——0001 里只登记了三张(communities、rate_limit_violations、_operator_global_tables 自己),后来的 0015、0017、0026 各自登记并附了一句为什么它是部署级而非租户级。这比「在 linter 里硬编码一个白名单」更难糊弄,因为理由和 schema 躺在同一个 diff 里。
如果你想让 AI 帮你写这类迁移,先把这几条纪律写成检查项再交给它,参考 让 AI 写数据库迁移脚本 里的约束写法。
三、一层分区:events 和 delivery_log 按月切
events 表在 0001 里就是分区表:PARTITION BY RANGE (created_at),主键 (community_id, created_at, id)。0001 直接预建了一批分区——events_p_past 覆盖 MINVALUE 到 2026-01-01,然后 events_p2026_01 到 events_p2026_06 六个月度分区,最后 events_p_future 从 2026-07-01 到 MAXVALUE 兜底。delivery_log 同样按月分区,分区键是 delivered_at。
往后每个月的分区由 crates/buzz-db/src/partition.rs 造。这个模块小得可以一口气读完,但它的防御性值得学:
- 可分区的表是一份常量允许清单
PARTITIONED_TABLES = &["events", "delivery_log"],注释直说这是为了防 DDL 注入——DDL 的标识符没法用绑定参数。 - 分区后缀必须只含数字和下划线,日期字符串必须严格是
YYYY-MM-DD十个字符、第 5 和第 8 位是连字符。两个校验函数的单测直接拿"2026_03; DROP TABLE events--"和"2026-03-01; DROP TABLE events--"当反例。 - 建之前先查目录:
pg_catalog.pg_classjoinpg_namespace,条件是relname = $1 AND c.relispartition = true,已存在就直接返回。 - 真正的建表语句是
CREATE TABLE IF NOT EXISTS {partition_name} PARTITION OF {table_name} FOR VALUES FROM (...) TO (...),命名规则是{表名}_p{后缀}。
最能体现工程判断的是它的错误处理。因为新库自带了 *_p_future 这个右边界兜底分区,本月的范围可能已经被它盖住,这时建分区会报 42P17 并带上 would overlap partition。代码对这一种错误单独放行:
Err(sqlx::Error::Database(db_err))
if db_err.code().as_deref() == Some("42P17")
&& db_err.message().contains("would overlap partition") =>
{
info!(partition_name, "partition range already covered by an existing partition");
Ok(())
}
判断的依据是「表还能写」,那就不该让启动失败。调用点也是同一个态度:crates/buzz-relay/src/main.rs 在迁移之后调 db.ensure_future_partitions(3),失败只打一条 error! 日志,不中断启动。partition.rs 的文档注释写明这个函数要在启动时调一次、每月再由 cron 调一次。
对你意味着什么:兜底分区让「忘了跑 cron」不表现为写入失败,而表现为所有新数据堆进 events_p_future。症状很隐蔽——没有报错,只有查询慢慢变慢,分区裁剪的收益悄悄归零。所以这个月度作业得单独监控,不能只靠启动那一次。
四、一道副本栅栏:读副本什么时候可以被信任
这是三块里最费脑子的一块,但它回答的问题很实在:游标翻页能不能交给读副本?副本有复制延迟,如果一页里应该出现的某一行还没被重放过去,用户看到的就是一个静悄悄的空洞。buzz 的答案不是「延迟一般很小所以问题不大」,而是给出一个可以证伪的时间点:早于这个点的行,我能证明副本上一定有。
证明由两半拼成。
上半是提交时的 floor 守卫(migrations/0021_created_at_fence_floor.sql)。它是一个 DEFERRABLE INITIALLY DEFERRED 的约束触发器,在 COMMIT 处理过程中才跑,用的是 clock_timestamp() 而不是 now()——后者在事务开始时就冻住了,量不出「提交那一刻」。函数体很短:
IF floor_secs IS NOT NULL
AND floor_secs > 0
AND NEW.channel_id IS NOT NULL
AND NEW.created_at < clock_timestamp() - make_interval(secs => floor_secs)
THEN
RAISE EXCEPTION ... USING ERRCODE = 'check_violation';
END IF;
三个细节都是设计决策。它只管 channel_id IS NOT NULL 的行,因为只有这些行会出现在频道窗口和线程分页里;floor_secs 来自会话级 GUC buzz.created_at_floor,没设或为空时整个守卫是 no-op,给 pg_restore 和历史回填留了路;触发器声明成 AFTER INSERT OR UPDATE OF created_at, channel_id,因为一次 UPDATE 可以把原本豁免的 channel 为 NULL 的行搬进受管集合。武装动作在 crates/buzz-db/src/lib.rs 的 connect_pool 里,写连接池的 after_connect 每建一条连接就 set_config('buzz.created_at_floor', ...),值取自常量 CREATED_AT_FLOOR_SECS(960 秒)。
下半是有序的心跳握手(crates/buzz-db/src/replica_fence.rs 加 migrations/0026_replica_heartbeat.sql)。replica_heartbeat 是一张只有一行的表,行数由 CHECK (id = 1) 保证,两列关键字段是 token bigint 和 epoch uuid。探针在一条固定连接上按顺序做三件事,而且必须是三条分别 await 的语句——写成一条 SELECT 的话,子表达式的求值顺序没有保证,这个顺序正是证明的地基:
SELECT clock_timestamp()取样本时刻 S;- 扫
pg_stat_activity求其他后端最老的xact_start,并用least()与pg_prepared_xacts.prepared取小(两阶段提交的事务已经离开了活动视图,但还能在 token 之后提交); - 最后才
UPDATE replica_heartbeat SET token = token + 1 WHERE id = 1 RETURNING token, epoch。
单行 UPDATE 是天然的串行点,所以多个 pod 各自的探针拿到的 token 是全局按提交顺序排的。一次握手能证明的时间墙是 min(oldest_xact_start, S) - floor - clock_margin,其中 FENCE_CLOCK_MARGIN_SECS 是 5。读侧的用法是:路由开一个 REPEATABLE READ, READ ONLY 事务,第一条语句就读心跳行,拿到 (token, epoch) 去 resolve——epoch 不匹配返回 EpochMismatch,token 低于环里所有条目返回 TokenBehind,两者都回落写库;匹配上就取「不大于观测 token 的最大那一条」的墙。环容量 RING_CAPACITY 是 120,探针间隔 PROBE_INTERVAL 是 500 毫秒,陈旧门 FENCE_STALENESS 是 30 秒,而且源码里有一条编译期 assert! 保证环保留的历史一定长于陈旧门。
这块和前两块是咬合在一起的。run_migrations 的最后一步 verify_floor_guard_catalog 会查 pg_trigger,对 events 父表和它的每一个分区逐个核对:触发器名是 events_created_at_floor、函数是 events_created_at_floor_guard、tgdeferrable 与 tginitdeferred 都为真、tgtype 的位是行级 + AFTER + 同时管 INSERT 和 UPDATE。少一个就整个迁移失败。原因写在 run_migrations 的注释里:CREATE TABLE .. PARTITION OF 会克隆父表触发器,但用 ATTACH PARTITION 挂进来的分区、或者旧代码路径造出来的分区会悄悄绕过守卫。分区轮换和栅栏证明必须一起校验,否则每个月都可能开一个洞。
启动时还有一层行为验证。spawn_fence_probe 在开探针前先跑 verify_floor_guard_behavior:在一个最后会回滚的事务里 SHOW buzz.created_at_floor 确认池子确实武装了,SET CONSTRAINTS ALL IMMEDIATE 让延迟触发器逐语句触发,然后依次验证——老的带 channel 插入必须抛 23514、新鲜的必须过、把 created_at 改老必须抛、channel 为 NULL 的老行豁免但翻成非 NULL 必须抛。目录检查只能证明触发器名字和形状对,证明不了函数体没被掏空,这一步补的就是这个缺口。main.rs 的注释说得很直白:这一步失败是响亮但不致命的,栅栏保持关闭,所有游标页回写库。
五、边界与代价
**它彻底绑死在 Postgres 上。**允许清单里的目录查询、pg_class / pg_trigger / pg_stat_activity / pg_prepared_xacts、advisory lock、生成列、DEFERRABLE INITIALLY DEFERRED 约束触发器,没有一样是可移植的。换数据库不是改方言的事,是整层重写。同样地,全文检索用的是 to_tsvector('simple', content) 生成列,simple 配置意味着不做词干和停用词处理,语义被这一行框住了。
**checksum 冻结的代价是文件只增不减。**历史上的判断失误只能再写一份迁移改回来。0009 建了 guard_nip_rs_watermark 这个触发器函数,0010 和 0011 又各自 CREATE OR REPLACE 把它重写了一遍——同一个判断改了三版,只能靠三份文件叠出来;0022 上线了频道 TTL 刷新触发器,0024 又把它里面的行锁换成共享 advisory lock 来解决提交串行化。27 这个数字里有相当一部分是「修上一份」。
**栅栏是失败关闭的,代价是容量而不是正确性。**探针查询出错、pg_stat_activity 被屏蔽(错误变体叫 MaskedActivity,提示探针角色需要 pg_monitor)、副本上读不到心跳行、epoch 对不上、token 落后于整个环——这些情况一律回落写库。它不承诺副本一定会被用上,只承诺用上的时候不会缺行。
它明确不管的事也写在注释里。0021 直说:没设 GUC 的会话、以及 session_replication_role = replica 的恢复流程,都在证明之外,需要操作者在整个过程里把栅栏按住。栅栏也只覆盖 channel_id IS NOT NULL 的行——推送租约、profile 与发现类快照本来就带客户端签名的历史时间戳,不进 keyset 窗口,也不在证明范围内。
迁移不是无锁的。0027 的注释自己写明:没有用 CONCURRENTLY,因为 sqlx 把每份迁移放在一个事务里跑,而 CREATE INDEX CONCURRENTLY 不能在事务里执行。它会在 channels 上拿 SHARE 锁挡住写入。注释同时给了现实出路:大库上操作者可以手工先 CREATE INDEX CONCURRENTLY 建好同名索引,迁移里的 IF NOT EXISTS 就变成 no-op。所谓不停机不是「没有锁」,而是把锁的位置、时长和绕行方案写清楚。
**几条和安全相关的事实要如实说。**身份就是那对密钥,事件的 sig 和 pubkey 存在库里,私钥不在服务端,丢了没有找回路径。自建中继意味着所有数据落在你自己的 Postgres 上:events.content 是明文列,生成的 search_tsv 会把它切词存一份,只有 gift wrap(kind 1059)等几个隐私敏感的 kind 被排除在检索之外——但行本身仍然在库里。开了读副本,这些数据同样在副本上。Agent 和人共用同一张消息网络,Agent 写的事件走的是同一张 events 表,同样会触发下游(0018 里的匹配队列触发器对 NEW.kind IN (7, 9, 1059, 40007, 46010) 生效)。给 Agent 发消息的权限,就是给了它往这张表写行、并触发下游投递的能力。
六、上手与避坑清单
1. 别改已发布的迁移文件。 为什么会踩:本地改一行看着无害,测试也过。怎么避:sqlx 会校验 checksum,已上线的库启动直接 VersionMismatch。永远新增一份,哪怕只是 CREATE OR REPLACE 同一个函数——仓库里 0009 到 0011 就是这个模式。
2. 新加表别忘 community_id。 为什么会踩:新表往往从一个「全局」需求长出来,写的时候感觉不需要租户列。怎么避:先想清楚它是不是真的部署级;是的话要显式往 _operator_global_tables 插一行并写理由,不是的话就带上 community_id UUID NOT NULL。all_non_operator_global_tables_have_not_null_community_id 这条测试会把漏网的表名字打出来。
3. 唯一索引要以 community_id 打头。 为什么会踩:CREATE UNIQUE INDEX ... ON t (slug) 是肌肉记忆,但在多租户库里它意味着一个租户占了 slug 别的租户就不能用了——跨租户可观测。怎么避:主键、唯一约束、外键、唯一索引全部 (community_id, ...) 开头;delivery_log 那个例外是分区键要求,代码里单列了函数专门放行。
4. 别以为分区会自动出现。 为什么会踩:ensure_future_partitions 只在启动和 cron 里被调,main.rs 里它失败也只打日志。怎么避:把月度作业当独立任务监控。同时记住兜底分区的存在,症状不是写失败而是数据全进 events_p_future,需要主动去查各分区行数才能发现。
5. 别用 ATTACH PARTITION 手工挂分区。 为什么会踩:手工挂看起来和 CREATE TABLE ... PARTITION OF 等价,但父表触发器不会被克隆,floor 守卫就在这个分区上消失了。怎么避:一律走 PARTITION OF;真挂错了也不至于静默——verify_floor_guard_catalog 会在下次迁移时把缺触发器的关系名列出来并让迁移失败。
6. 历史回填别用 relay 的写连接池。 为什么会踩:那个池子每条连接都武装了 buzz.created_at_floor,你插一批 created_at 很老的 channel 行,会在 COMMIT 时 23514 失败,而且是在事务末尾才失败,前面的工作全白做。怎么避:按 0021 注释的说法,用一条不带该 GUC 的连接跑,并且从回填事务开始之前就把栅栏按住,直到这批 WAL 在副本上重放完再放开。
7. 探针角色权限不够会静默削弱路由。 为什么会踩:权限不足时 pg_stat_activity 会把其他会话的 state、xact_start、甚至 backend_type 一起屏蔽掉;代码特意注明不能先按 backend_type = 'client backend' 过滤,否则被屏蔽的行会被过滤掉从而失败开放。怎么避:给探针角色 pg_monitor;同时盯着 heartbeat_age 这类观测指标,别让「栅栏一直没开」变成没人知道的常态。
8. 从备份恢复之后栅栏会自己关,这是设计。 为什么会踩:恢复把 token 回退了,你看到路由全走写库以为出故障。怎么避:理解处理逻辑——同 epoch 内检测到 token 倒退,环会被清空、探针接着把 epoch 轮换成新的 UUID,所有旧 epoch 的观测一律判 EpochMismatch。等新一轮握手攒够条目,路由自己会回来。
收束:三个自检问题
把 buzz 这套东西压缩成能带走的判断,就是三个问题:
其一,你的每一份迁移是不是纯增量、并且能在一个已经装满数据的库上跑完?如果某份迁移只在空库上验证过,它就是一颗定时炸弹。
其二,你的破坏性步骤有没有「宁可失败也不改一半」的前置守卫,并且守卫失败时不留任何痕迹?buzz 的做法是把守卫放在事务之外先跑,跑挂了连版本号都不动。
其三,你的读副本路由有没有一个可证伪的观测点?「延迟一般很小」不是观测点,「这个会话观测到的 token 不小于 M,所以早于某时刻的行一定在」才是。
想继续往下读代码的话,顺序建议是这样:先看 crates/buzz-db/src/migration.rs 的 #[cfg(test)] mod tests,那是把 schema 约定写成断言的完整样板;再读 migrations/0021_created_at_fence_floor.sql 和 migrations/0026_replica_heartbeat.sql 的注释头,这两份 SQL 的设计论证比代码本身长得多;最后回到 crates/buzz-db/src/lib.rs 里的 connect_pool 和 spawn_fence_probe,看武装与启动顺序是怎么串起来的。这三处读完,改表不停机这件事就从口号变成了一串可以照抄的检查项。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源 buzz 的审计链:多 Agent 平台里谁改了什么 和 拆解 Block 开源多 Agent 通信平台 buzz 的一轮主循环。