关注关系看起来只是一张两列的表,难点在于它同时是一种授权。用户 A 关注私密账号 B 并获批后,A 就能读 B 的“仅粉丝可见”内容;B 拉黑 A 之后,这项授权必须收回,而且收回之后 B 新发的内容不能再被 A 看到。关系表之外通常还有粉丝列表、粉丝计数、下一篇要讲的 Feed 收件箱等派生视图,它们靠消费关系事件异步更新。重复点击关注、关注与取消交错、事件乱序和重复投递、消费者丢失进度,都会让派生视图与真相源出现差距;差距一旦落在权限上,就是越权读取。

本章的工程放在 examples/social 的 social.rel 包中,全部用例跑在真实 PostgreSQL 16.15 事务上。验收目标有两条:重复关注、取消与事件乱序不会让已经取消的关系“复活”;内容入口与派生视图在权限判断上给出一致的结论,或者明确声明二者允许相差的窗口。

需求与范围

操作只有六个:关注、取消关注、私密账号审批(同意或拒绝)、拉黑、解除拉黑、发布带可见性的内容。可见性分三档:PUBLIC 任何未被拉黑的人可读,FOLLOWERS 只有有效粉丝可读,SELF 只有作者本人可读。

业务不变量写成可以用 SQL 检查的形式:

  1. 每个有序对 (follower, followee) 至多一行关系,状态取 ACTIVE、PENDING、REMOVED 之一,不能自关注。
  2. 关系每改变一次状态,版本加一,并且恰好写一条事件;不改变状态的操作不写事件。
  3. 只要 block 表中存在 A、B 任一方向的拉黑记录,A→B 与 B→A 两行关系都必须是 REMOVED。
  4. 解除拉黑只删除拉黑记录,不恢复拉黑前的关系。
  5. 派生视图在消费完全部事件后,有效粉丝集合与计数都和真相源一致。

排除项:账号在公开与私密之间切换、关注上限、批量导入、跨库分片、事件经由外部消息组件传输。本章的“事件投递”由测试代码从 relation_event 表读出后打乱、复制再交给消费者,模拟至少一次且无序的传输语义,不验证任何消息中间件。

教学规模假设

以下数字只是教学假设,用来说明结构选择,不对应任何实际产品,也没有做过性能测量:账号 10⁶ 个,平均每人关注 200 个账号,粉丝数呈重尾分布,少数账号的粉丝数在 10⁵ 量级;关系写入远少于内容读取。在这个假设下,“按被关注者列出粉丝”和“判断 A 是否是 B 的粉丝”都是高频查询,前者需要反向索引,后者需要主键点查;粉丝数大的账号如果每次读取都对关系表做 count(*),代价随粉丝数增长,因此计数要放进派生视图。

本章实验的实际规模远小于此:关系对数百个、事件一千多条,只用来检查不变量。

接口与返回语义

每个写操作都返回三类结果之一,调用方能据此区分“这次请求改变了状态”“状态本来就是目标值”和“请求不被允许”。

表 30-1:关系操作接口与结果

操作 前置条件 CHANGED NOOP REJECTED
follow(a, b) a≠b,双方无拉黑 REMOVED→ACTIVE(公开)或 REMOVED→PENDING(私密) 已是 ACTIVE 或 PENDING 自关注、任一方向存在拉黑
unfollow(a, b) 无 ACTIVE/PENDING→REMOVED 已是 REMOVED —
decide(b, a, ok) 关系为 PENDING PENDING→ACTIVE 或 REMOVED — 非 PENDING
block(b, a) b≠a 写入拉黑记录,双向非 REMOVED 关系置 REMOVED 已拉黑且双向均为 REMOVED 自拉黑
unblock(b, a) 无 删除拉黑记录 不存在拉黑记录 —

重复关注返回 NOOP 而不是错误,这让客户端重试成为安全操作:重试不会写出第二条事件,也不会把版本再加一。用例 30-B 中,私密账号的第二次关注请求输出 NOOP(PENDING,v1,already PENDING)。

数据模型

真相源是 relation 与 block 两张表。relation 以 (follower, followee) 为主键,CHECK (follower <> followee) 排除自关注,version 是该行自己的单调计数。按被关注者查粉丝用一个部分索引 relation_by_followee ON relation (followee, follower) WHERE state = 'ACTIVE',只索引有效关系,REMOVED 占位行不进入索引(PostgreSQL 16 文档 11.8 节描述了部分索引只包含满足谓词的行)。

事件表 relation_event 的主键是全局序号 seq,另有 UNIQUE (follower, followee, version) 约束,保证同一对的同一个版本不可能写出两条事件。序号由单行计数表 event_seq 分配:

1
UPDATE event_seq SET last = last + 1 WHERE id = 1 RETURNING last

这条语句给计数行加上行锁并持有到事务结束,下一个写关系的事务必须等前一个提交或回滚后才能拿到下一个序号。由此得到两个性质:序号没有空洞(回滚会连同计数一起撤销),序号顺序等于提交顺序。第二个性质是后面权限令牌的基础。代价也很直接,所有关系写入在这一行上串行化,这是教学实现为了换取简单的连续水位而接受的限制。

派生视图有四张表:proj_follower 是按被关注者组织的粉丝列表,每行带着它最后接受的版本;proj_count 是粉丝计数;proj_seen 记录已经处理过的事件序号;proj_watermark 记录连续水位,即最大的 s,使 1…s 都已经出现在 proj_seen 中。内容表 post 的 acl_token 字段保存发布时已提交的最高关系事件序号。

状态机

关系行的状态只在写事务中改变,每次改变都伴随版本加一和一条事件。REMOVED 同时充当“从未关注”的占位状态,版本为 0 的占位行由加锁步骤插入,不产生事件。

图 30-1:关系行状态机(标出前置条件与拒绝路径)

stateDiagram-v2
    [*] --> REMOVED: 首次加锁插入占位行 v0(无事件)
    REMOVED --> ACTIVE: follow,公开账号,且无拉黑
    REMOVED --> PENDING: follow,私密账号,且无拉黑
    PENDING --> ACTIVE: decide(同意),当前为 PENDING
    PENDING --> REMOVED: decide(拒绝) / unfollow / block
    ACTIVE --> REMOVED: unfollow / block
    REMOVED --> REMOVED: follow 遇到拉黑 → REJECTED(无事件)
    ACTIVE --> ACTIVE: 重复 follow → NOOP(无事件)
    PENDING --> PENDING: 重复 follow → NOOP(无事件)

取消之后再关注不会复用旧版本。用例 30-C 对同一对依次执行关注、取消、关注,第三次得到 CHANGED(ACTIVE,v3,seq8)。版本只增不减,是消费端识别旧事件的唯一依据。

正常路径与锁顺序

一次关注在一个 Read Committed 事务中完成四步:确保关系行存在并锁住,检查拉黑,按当前状态决定是否转移,转移时写事件。关键在第一步和第二步的先后。

1
2
3
4
5
cur = lockPair(follower, followee);
hook.at("follow.afterLock");
// 拉黑事务也必须先锁这一行,所以此处读到的 block 不会在提交前失效
if (blockedEitherWay(follower, followee))
return new Result(Outcome.REJECTED, cur[0], Long.parseLong(cur[1]), 0, "blocked");

lockPair 先执行 INSERT ... ON CONFLICT DO NOTHING 插入占位行,再用 SELECT ... FOR UPDATE 锁住并读出状态和版本。16 个线程同时关注同一对时(用例 30-D),只有一个线程把状态从 REMOVED 改为 ACTIVE,其余 15 个读到的是已提交的新版本,返回 NOOP:输出为 CHANGED=1 NOOP=15 errors=0,关系版本为 1,事件为 1 条。这依赖 PostgreSQL 16 文档 13.2.1 节描述的 Read Committed 行为:SELECT FOR UPDATE 遇到被并发事务更新或锁定的行时,等待对方结束;对方提交后,锁住并返回更新后的版本。同一节还说明,ON CONFLICT DO NOTHING 可能因为另一个尚未对本快照可见的事务而不插入;随后的 SELECT ... FOR UPDATE 是新语句、取新快照,因此能读到那一行。

拉黑事务要改两行关系,它按 (follower, followee) 的字典序依次加锁,再写拉黑记录,最后把非 REMOVED 的行转为 REMOVED:

1
2
3
4
5
6
7
String[][] pairs = blocker.compareTo(blocked) < 0
? new String[][]{{blocker, blocked}, {blocked, blocker}}
: new String[][]{{blocked, blocker}, {blocker, blocked}};
String[][] cur = new String[2][];
for (int i = 0; i < 2; i++) cur[i] = lockPair(pairs[i][0], pairs[i][1]);
hook.at("block.afterLock");
int ins = Db.update(cx, "INSERT INTO block(blocker, blocked) VALUES (?, ?) ON CONFLICT DO NOTHING", blocker, blocked);

全系统的锁顺序固定为:关系行(字典序)→ event_seq。关注只锁一行关系,拉黑锁两行且按同一顺序,事件序号总在最后获取,因此不会形成锁环。PostgreSQL 16 文档 13.3.4 节把“所有事务按一致顺序获取多个对象上的锁”作为避免死锁的首要办法;social.common.Tx 仍然对 40P01 和 40001 整体重试,作为兜底。本次运行输出 tx retries observed (40001/40P01): 0,即这批用例没有触发重试,这个计数不能推广到其他负载。

拉黑判断为什么必须放在加锁之后?因为保证来自“读 block 的时刻”与“拉黑事务写 block 的时刻”之间的先后关系。关注事务持有关系行锁时,任何拉黑事务都还卡在同一行的锁上,尚未写入拉黑记录;关注事务提交后,拉黑事务拿到锁,读到的是关注已生效的新版本,接着把它转为 REMOVED。反过来,如果拉黑先提交,关注事务的 blockedEitherWay 是一条新语句,Read Committed 下取新快照,必然看到拉黑记录。两个方向都落在不变量 3 之内。

方案比较

表 30-2:关系写入与派生视图的候选做法

做法 能防住什么 防不住什么 本章观察
先查拉黑再锁关系行 串行请求 检查与加锁之间提交的拉黑 30-I 负向对照:拉黑已提交,关系仍 ACTIVE
先锁关系行再查拉黑 并发拉黑与关注 绕过本入口直接写表 30-H、30-J 通过
消费者按到达顺序覆盖 无乱序、无重复的传输 乱序与重复投递 30-G 负向对照:大量已取消关系复活
消费者按行版本取大并去重 乱序、重复、并发消费者 事件丢失(由水位暴露) 30-F、30-G、30-L 通过
权限只读派生视图 视图已追平的时刻 视图滞后期间的撤销 30-K 负向对照:被拉黑者读到新内容
权限每次读真相源 全部情形 真相源读负载随访问量增长 30-K 中 TRUTH 两篇均拒绝
带令牌的派生视图读取 令牌声明的新鲜度 令牌之外的滞后窗口 30-K 中两种令牌各自符合声明

消费端:版本取大与连续水位

消费者 Projector.apply 在一个事务里完成去重、版本比较、计数增量和水位推进:

1
int fresh = Db.update(cx, "INSERT INTO proj_seen(seq) VALUES (?) ON CONFLICT DO NOTHING", e.seq());

fresh == 0 表示序号已经处理过,判为 dup。否则锁住 proj_follower 中对应的行,比较版本:

1
2
3
4
5
6
if (e.version() <= oldVer) {
verdict = "stale";
} else {
Db.update(cx, "UPDATE proj_follower SET state = ?, version = ? WHERE followee = ? AND follower = ?",
e.state(), e.version(), e.followee(), e.follower());
int delta = (e.state().equals("ACTIVE") ? 1 : 0) - (oldState.equals("ACTIVE") ? 1 : 0);

计数增量由“被替换掉的旧状态”和“新状态”共同决定,而不是由事件类型决定。一条 ACTIVE 事件如果覆盖的是 PENDING,计数加一;覆盖的是另一条版本更低的 ACTIVE,计数不变。这样,proj_count 与 proj_follower 的修改在同一事务里提交,计数始终等于该被关注者在视图中的 ACTIVE 行数,不会因为重复投递多加。

去重集合 proj_seen 与版本比较各管一件事。版本比较已经足以挡住旧事件和重复事件对状态的影响,proj_seen 的作用是支撑连续水位:消费者在同一事务末尾锁住 proj_watermark,从当前水位往后数连续出现的序号。四个消费者并发时,后拿到水位锁的事务在前一个提交之后才执行查找 proj_seen 的语句,能看到前者写入的序号,所以水位不会漏推。水位的含义是“1…s 的事件都已应用”,它是下一节权限令牌比较的对象。

故障反例

乱序与重复把已取消的关系复活

用例 30-F 是最小反例:同一对先关注(v1)后取消(v2),事件按 v2、v1、v1 到达。

图 30-2:单对事件乱序到达时版本化消费者的判定

sequenceDiagram
    participant T as relation(真相源)
    participant Q as 投递
    participant P as Projector
    T->>Q: seq a: ACTIVE v1
    T->>Q: seq b: REMOVED v2
    Q->>P: REMOVED v2
    P-->>P: v2 > v0,applied,视图 REMOVED
    Q->>P: ACTIVE v1(迟到)
    P-->>P: v1 ≤ v2,stale,视图不变
    Q->>P: ACTIVE v1(重复)
    P-->>P: seq a 已在 proj_seen,dup

输出为 arrivals v2,v1,v1 -> [applied, stale, dup],视图保持 REMOVED。如果消费者按到达顺序直接覆盖,迟到的 v1 会把关系写回 ACTIVE,这就是“复活”。

用例 30-G 放大这一反例:在前面用例的基础上追加 300 对关系,每对执行 2 到 4 次关注或取消,全部事件按 30% 的比例复制后用固定种子打乱。同一份投递序列先交给 applyNaive(按到达顺序覆盖、不比版本、不去重),再清空视图交给 4 个并发消费者线程执行版本化消费:

1
2
3
4
note events=1485 deliveries=1942 seed=302026
note naive: resurrected=100 missing=37 wrongCounts=43
ok negative control exposes: 按到达顺序应用时已取消的关系复活 (100 对)
note versioned: verdicts={applied=580, dup=457, stale=905} resurrected=0 missing=0 wrongCounts=0

resurrected 是视图为 ACTIVE 而真相源不是 ACTIVE 的行数,missing 是反方向的差异,wrongCounts 是计数不一致的被关注者个数,三者由 Projector.diff 的三条 SQL 直接对比两边得到。事件总数包含前面并发用例 30-E 产生的事件,后者由线程调度决定,所以这几个数在每次运行之间会变化;断言检查的是负向对照出现复活、正确实现三项差异为 0、水位等于事件总数。

这项保证的成立条件是:每对关系的版本由真相源在行锁下单调递增,事件携带该版本。观察位置是 Projector.diff。失败边界有两个:如果有写入绕过 RelationService 直接改 relation 而不写事件,视图将永远追不上;如果事件丢失,版本比较本身察觉不到,只能由水位停在缺口前暴露出来。

拉黑与关注并发

用例 30-H 用插桩点构造确定的交错:关注事务锁住 user-0070→user-0071 的关系行后暂停,拉黑事务启动,测试线程通过另一条连接查询 pg_locks,直到看到拉黑事务所在后端存在 granted = false 的锁记录(PostgreSQL 16 文档 54.12 节说明 granted 为假表示该进程正在等待这把锁),才让关注继续。

图 30-3:先锁后查时拉黑与关注的时间线(用例 30-H)

sequenceDiagram
    participant F as 关注事务
    participant R as relation 行 (0070→0071)
    participant B as 拉黑事务
    F->>R: INSERT 占位 + SELECT FOR UPDATE(持锁)
    B->>R: lockPair(等待,pg_locks granted=false)
    F->>F: 查 block:无记录
    F->>R: REMOVED→ACTIVE v1,写事件,提交
    R-->>B: 获得锁,读到 ACTIVE v1
    B->>B: 写 block,ACTIVE→REMOVED v2,写事件,提交

输出为 follow result=CHANGED(ACTIVE,v1,seq1486) final relation=REMOVED block rows=1 blockTxWaitedOnRowLock=true:关注先成功,随后被拉黑撤销,最终状态符合不变量 3。

负向对照 30-I 打开 checkBeforeLock:关注事务先查拉黑(此时无记录),在加锁前暂停,等拉黑事务完整提交之后再加锁、转移。拉黑事务提交时这对关系还是 REMOVED,无事可撤;关注事务随后凭着过期的检查结果把它改成 ACTIVE。输出 final relation=ACTIVE block rows=1,违反不变量 3。两组用例的 SQL 完全一样,差别只在检查与加锁的先后,说明保证来自“在锁内读拉黑”这一步,事务本身并不提供它。

用例 30-J 不加插桩,40 轮里每轮用真实线程同时发起 A 关注 B、B 关注 A、B 拉黑 A,偶数轮的 B 是私密账号。输出 rounds=40 blockRows=40、errors=0 violations=0。这是随机调度下的抽查,能发现常见交错下的违规,但不能穷举所有交错;穷举性的论证由上一段的锁顺序给出,30-H 和 30-I 分别固定了两种关键交错。

撤销之后的新内容:新敌人问题

Zanzibar 论文(USENIX ATC 2019)用过 new enemy problem 与 zookie 两个术语,本环境取不到全文。本章用“新敌人问题”指:权限撤销之后发布的内容被刚被撤销的人看到。本环境取不到该论文全文,下文的令牌规则是本章自行设计的,只借用问题名称,不代表该系统的实现。

用例 30-K 的事件序列:私密账号 user-0300 审批 user-0342 的关注,发布 P1,视图追平到水位 1614;随后 user-0300 拉黑 user-0342(事件序号 1615),再发布 P2,P2 的 acl_token 记为 1615;此时故意不运行消费者,视图水位停在 1614。四种检查模式的结论:

1
2
3
4
note TRUTH           P1=DENY/truth P2=DENY/truth
note PROJECTION_ONLY P1=ALLOW/projection P2=ALLOW/projection
note CONTENT_TOKEN P1=ALLOW/projection P2=DENY/fallback-truth
note FRESH_TOKEN P1=DENY/fallback-truth P2=DENY/fallback-truth

PROJECTION_ONLY 让被拉黑者读到了拉黑之后发布的 P2,这就是新敌人问题的本地复现。CONTENT_TOKEN 要求视图水位不低于内容的 acl_token,否则回退真相源:P2 的令牌 1615 高于水位,回退后被拒;P1 的令牌 1614 不高于水位,仍按视图放行。它保证的是:发帖事务读取 event_seq 时已经提交的撤销一定生效。与发帖事务并发提交的撤销不在保证范围内:createPost 在 READ COMMITTED 下不加锁地读取 last,若拉黑事务已拿到序号 k+1 但尚未提交,发帖读到 k,拉黑随后可能先于帖子提交,令牌 k 覆盖不到它。若要把保证扩大到“帖子提交前已提交的撤销”,可以改用 SELECT last FROM event_seq WHERE id = 1 FOR SHARE,让在途的关系写入先结束;本章没有实现和测试这一改法,30-K 是串行构造,覆盖不到这个交错。对旧内容,对旧内容则允许视图滞后,这个窗口在 30-K 中被显式断言为预期行为,视图追平后 P1 也被拒(after drain watermark=1615 P1 CONTENT_TOKEN=DENY/projection)。FRESH_TOKEN 取检查时刻已提交的最高序号作为令牌,视图未追平就回退,结论与 TRUTH 相同。

令牌比较之所以成立,依赖序号顺序等于提交顺序:内容事务读到 event_seq.last = k 时,序号不大于 k 的事件都已提交;水位达到 k 时,这些事件都已应用到视图。如果换成允许空洞或乱序提交的序号(例如普通 sequence 加并发事务),“水位 ≥ k”就不再蕴含“序号不大于 k 的事件都已应用”,令牌失效。

入口与派生视图的一致性由同一个 AccessCheck.canRead 承担:本章只有 AccessCheck.canRead 一个判定入口;任何读路径(直接打开、从列表进入)只要都调用它并使用同一模式,结论就一致。本章没有单独实现列表入口。在 FRESH_TOKEN 下,两条路径的结论都不早于检查开始时已提交的关系状态;在 CONTENT_TOKEN 下,二者可能在旧内容上相差一个消费延迟,这个差距是该模式声明的边界。PUBLIC 内容不看关注关系,只读 block 表,因为拉黑记录本身不进入视图;用例 30-M 显示非粉丝在四种模式下都能读公开内容,拉黑后四种模式都拒绝(DENY/public-block-check),SELF 内容对有效粉丝也拒绝。

恢复流程

派生视图可以随时从事件表重建,所以消费者故障的恢复手段是重放。用例 30-L 清空视图后,先按打乱顺序投递 60% 的事件,模拟消费者在中途失去进度;再由新的消费者连接从头投递全量事件,并带 30% 重复:

1
2
note after partial: watermark=3/1615 diff=72,39,53
note after restart replay: watermark=1615 diff=0,0,0

中途的水位为 3,原因是序号 4 恰好不在先投递的那一部分里,连续水位停在缺口之前,这正是它的用途:水位低于事件总数,说明视图尚不可用于令牌比较,权限检查会回退真相源。重放时已处理的事件被判 dup 或 stale,不会重复计数。恢复的成立条件是事件表完整保留、不被截断;如果事件表按保留期清理,重建需要先从真相源做一次快照,再从快照对应的序号往后重放,本章没有实现这一步。

恢复的检查顺序:先看 proj_watermark.applied_upto 是否等于 event_seq.last;不等就继续消费,或者清空后重放;等于之后用 Projector.diff 的三条 SQL 确认差异为 0,才把视图重新用于权限判断。真相源这一侧没有异步状态,崩溃后由 PostgreSQL 的事务原子性保证关系行、拉黑记录、事件三者要么一起提交、要么一起消失。本章没有对数据库做 immediate 停机实验,这一点只来自事务语义,未经本地崩溃验证。

证据与限制

运行入口是 examples/social/run.sh 30,它启动端口 55430 的 b3-30 集群、重建 social30 库、编译运行 social.rel.Ch30Main,把输出写入 evidence/30/output.txt,最后停止集群。本次运行的汇总行是 SUMMARY passed=58 failed=0,退出码为 0。

表 30-3:用例与保证的对应

用例 类别 检查的保证 观察位置
30-A、30-B 真实组件 正常关注、私密审批、PENDING 不计数 relation、proj_count
30-C 真实组件 自关注、空取消、版本递增、解除拉黑不恢复 relation_event 行数、relation.state
30-D、30-E 真实组件,真实线程 重复关注只写一条事件;版本连续且等于事件数 relation_event 的 count 与 max(version)
30-F、30-G 真实组件,并发消费者 乱序与重复不复活;负向对照复活 Projector.diff
30-H、30-I、30-J 真实组件,插桩交错与随机并发 拉黑存在时双向关系均为 REMOVED;负向对照违反 pg_locks、relation 与 block 的连接查询
30-K、30-M 真实组件 撤销后新内容不可读;令牌模式边界;可见性边界 AccessCheck 返回的来源标记
30-L 真实组件,消费者重启 重放后视图与真相源一致 proj_watermark、Projector.diff

限制写在 evidence/30/LIMITS.md,要点如下。所有用例在单机、单个 PostgreSQL 实例上运行,不证明跨库分片、主从复制延迟下的行为;读副本上的权限检查需要另行论证令牌与复制位置的关系。事件投递由测试代码模拟,不验证任何消息组件的确认语义。消费者“丢失进度”由新建连接从头投递模拟,没有使用独立 JVM 进程和 kill -9。全局单行序号把所有关系写入串行化,本章没有测量它的吞吐,也不建议把这一结构直接用于高写入场景。30-J 是随机抽查,不是穷举。

本批导航

  • 上一篇:29 订单、支付模拟与库存补偿
  • 下一篇:31 Feed 的扇出成本与热点
  • 本批索引:仓库 batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md
  • 运行入口:batch-3/examples/social/run.sh,证据目录:batch-3/evidence/30/
  • 本篇工程与证据快照:backend-systems-30.tar.gz(SHA-256 见同目录 backend-systems-30.tar.gz.sha256)。正文中的 examples/、evidence/、SOURCES.md 路径都相对仓库的 batch-3/ 目录,与快照包内布局一致。