首页 Feed 要回答一个问题:读者当前关注的作者里,最近发了哪些读者有权看的帖子,按什么顺序排,翻到第二页时从哪里接着读。工程上的争议集中在“候选什么时候算出来”。发帖时把帖子写进每个粉丝的收件箱(推),代价落在写路径上,并且和作者粉丝数成正比;读 Feed 时现场到每个被关注作者的时间线里取(拉),代价落在读路径上,和读者关注的作者数成正比;按作者粉丝数分档(混合),则把两种代价按阈值切开。粉丝数呈重尾分布时,少数热点作者决定了推方案的写入峰值,也决定了拉方案里被反复读取的那几条时间线。

另一层要求来自第 30 篇:关注关系是一种授权。收件箱里的行是发帖时刻算出的候选,读者之后取消关注、被拉黑、帖子改成仅自己可见,这些行不会自己消失。如果读路径直接把物化的收件箱行返回给读者,撤销就被绕过了。

本章工程在 examples/social 的 social.feed 包中,复用第 30 篇 social.rel 的关系表、RelationService 与 AccessCheck,全部用例跑在真实 PostgreSQL 16.15 上。验收目标有两条:在同一张合成关注图上比较推、拉、混合三种方案的读写放大;撤销权限之后,旧候选无法绕过访问检查。

需求与范围

Feed 的契约写成可以逐条检查的形式。读者 R 的 Feed 由满足以下全部条件的帖子 P 组成:

  1. R 到 P 作者的关注关系当前为 ACTIVE;
  2. R 与作者之间任一方向都不存在拉黑记录;
  3. P 的可见性不是 SELF;
  4. 按 (created_at, post_id) 降序排列,分页时用上一页最后一条的这对值作为游标。

条件 1 是 Feed 的成员资格,条件 2、3 加上第 30 篇的 FOLLOWERS 规则构成访问权限。二者需要分开理解:PUBLIC 帖子即使不在 Feed 里,读者直接打开时 AccessCheck 仍然允许(只看拉黑);而 FOLLOWERS 和 SELF 帖子出现在不该出现的读者面前,就是越权读取。正确实现要同时满足两者,所以本章把 Feed 返回的每一条再交给 AccessCheck 的 TRUTH 模式复核一次,作为测试里的独立判定。

排除项:排序算法与个性化推荐、广告插入、帖子删除后的物理回收、跨库分片、异步扇出队列、缓存层。扇出与发帖在同一个数据库事务里同步完成,这是教学上为了把正确性论证集中在一处所作的选择,热点作者发帖时这个事务会很大,代价在方案比较一节列出。

教学规模假设

以下参数都是教学假设,用固定种子 312026 生成,不对应任何真实产品:1200 个用户,每人关注 5 到 40 个账号;被关注者按随机排定的“人气名次”以 Zipf 权重 1/(rank+1) 无放回抽取,因此粉丝数呈重尾分布;3000 条帖子,作者均匀抽取,每 3 条共用一个 created_at,post_id 与时间顺序无关;可见性约 85% 公开、12% 仅粉丝、3% 仅自己。读取侧固定抽 100 个读者,每人从顶部读最多 3 页、每页 20 条。

生成结果由程序打印:

1
2
3
note graph users=1200 edges=26937 outDegree=5..40 zipf_s=1.0
note followers p50=8 p90=37 p99=311 max=1090 zeroFollowers=5 authors>=50: 92 authors>=200: 20
note posts=3000 visibility={FOLLOWERS=331, PUBLIC=2578, SELF=91} readers=100 pages=3x20

中位数作者只有 8 个粉丝,粉丝最多的 user-0719 有 1090 个,几乎覆盖全部用户。这个形状足以让三种方案拉开差距,但规模只有千级,下文所有计数都只对这份输入成立。

接口与数据模型

对外只有两个操作:

1
2
3
publish(postId, author, createdAt, visibility) -> PUSH | PULL | NOOP
page(reader, cursor, limit) -> [ (createdAt, postId, author) ... ]
cursor = TOP | (createdAt, postId) // 上一页最后一条

publish 对同一 post_id 的重复调用返回 NOOP,不会二次扇出。page 返回不超过 limit 条,少于 limit 条表示已到末尾。

关系真相源 relation、block 与第 30 篇共用,放在 public schema。四种方案(推、拉、阈值 50 的混合 h50、阈值 200 的混合 h200)各有一个 schema,里面是各自的 post 和 inbox;连接用 SET search_path TO feed_xxx, public 打开,未限定 schema 的表名按搜索路径取第一个匹配(PostgreSQL 16 文档 5.9.3 节),所以第 30 篇的 AccessCheck 不用改就能读到当前方案的 post。

表 31-1:Feed 相关的表与索引

表 主键 / 索引 作用
post post_id;部分索引 (author, created_at DESC, post_id DESC) WHERE delivery = ‘PULL’ 帖子与它的投递方式,拉取档作者的时间线
inbox (reader, post_id);索引 (reader, created_at DESC, post_id DESC) 推送的候选,主键保证同一读者同一帖至多一行
follower_count author 粉丝数快照,混合方案在发帖时用来分类
relation、block 见第 30 篇 当前权限的真相源

post.delivery 是本章设计里最关键的一列。混合方案在发帖时读一次 follower_count,决定这条帖子走推还是走拉,并把决定写在帖子行上;读路径据此找候选,不再关心作者此刻有多少粉丝。这一列的作用在阈值跨越一节有反例说明。

帖子的投递与可见性状态

一条帖子有两个相互独立的属性。投递方式在发布事务里确定,之后不变;可见性可以随时修改,每次读取时按当前值判断。收件箱行只是一份候选,是否返回由读取时刻的关系、拉黑和可见性决定。

图 31-1:帖子从发布到被读取的判定路径(前置条件与拒绝路径)

stateDiagram-v2
    [*] --> 分类: publish(post_id 不存在)
    [*] --> NOOP: publish(post_id 已存在),不扇出
    分类 --> PUSH: 推方案,或混合方案且发帖时 followers 小于阈值
    分类 --> PULL: 拉方案,或混合方案且发帖时 followers 不小于阈值
    PUSH --> 候选: 同一事务写 inbox(ACTIVE 粉丝各一行)
    PULL --> 候选: 留在作者时间线(部分索引)
    候选 --> 返回: 读取时 relation ACTIVE 且无拉黑且 visibility 不是 SELF
    候选 --> 过滤: 任一条件不满足(行仍保留,条件恢复后可再次返回)

“过滤”不删除候选。重新关注、改回公开之后,候选会再次满足条件;解除拉黑不恢复关注(第 30 篇的规则),所以被拉黑过的作者的帖子在解除拉黑后依然被过滤,直到读者重新关注。

正常路径与关键实现

发布在一个 Read Committed 事务里完成分类、写帖子行、扇出:

1
2
3
4
5
6
7
if (kind == Kind.PUSH) delivery = "PUSH";
else if (kind == Kind.PULL) delivery = "PULL";
else {
cost.writeQueries++;
long n = Db.queryLong(cx, "SELECT coalesce((SELECT followers FROM follower_count WHERE author = ?), 0)", author);
delivery = n >= threshold ? "PULL" : "PUSH";
}

走推送的帖子用一条 INSERT ... SELECT 把当前 ACTIVE 粉丝写进收件箱,ON CONFLICT DO NOTHING 让主键 (reader, post_id) 吸收重复:

1
2
3
int n = Db.update(cx, "INSERT INTO inbox (reader, created_at, post_id, author) " +
"SELECT r.follower, ?, ?, ? FROM relation r WHERE r.followee = ? AND r.state = 'ACTIVE' " +
"ON CONFLICT DO NOTHING", createdAt, postId, author, author);

读一页时,推送部分是一条收件箱查询,拉取部分先查“读者关注的作者里谁有 PULL 帖子”,再对每个这样的作者查一次时间线,最后多路归并取前 limit 条。每条子查询都带同一段当前权限条件:

1
2
3
4
5
6
static final String VISIBLE = """
p.visibility <> 'SELF'
AND EXISTS (SELECT 1 FROM relation r WHERE r.follower = ? AND r.followee = p.author AND r.state = 'ACTIVE')
AND NOT EXISTS (SELECT 1 FROM block b WHERE (b.blocker = ? AND b.blocked = p.author)
OR (b.blocker = p.author AND b.blocked = ?))
""";

过滤写在 SQL 里、位于 LIMIT 之前,因此被过滤掉的候选不会让一页变短。如果先取 limit 行再在应用里过滤,一页可能不满,客户端会误以为到了末尾。一页的全部子查询放在同一个 Repeatable Read 事务里执行:Read Committed 下相继两条语句可能看到不同的已提交数据,Repeatable Read 下事务内所有语句共享第一条语句开始时的快照(PostgreSQL 16 文档 13.2.1、13.2.2 节),这样一页里不会出现“收件箱按撤销前过滤、作者时间线按撤销后过滤”的混合结果。

归并的正确性来自一个简单事实:全局第 k 新的帖子必然在它所在那一路的前 k 条里,所以每路取 limit 条再归并,就能得到游标之后的全局前 limit 条。

用例 31-A 把这套实现放到四个方案上,对 100 个读者各读 3 页,结果与一个完全不访问数据库的内存模型逐条比对,并把返回的每一条交给 AccessCheck:

1
2
3
ok   push 100 个读者的前 3 页与内存模型逐条一致 (expected=0, actual=0)
ok push 返回的 4601 条全部通过 AccessCheck(TRUTH) (expected=0, actual=0)
ok 四种方案对每个读者返回相同的帖子序列

结果相同是比较读写代价的前提:先确认四个方案回答的是同一个问题,比较成本才有意义。

方案比较:同一输入下的读写放大

表 31-2 摘自 31-A 的 table 行。写入行数包括帖子行和收件箱行,读取行数是返回给应用的行数(关注列表行加各路候选行),语句数含混合方案发帖时读计数的那一条。这里只有计数,没有耗时。

表 31-2:3000 帖、100 个读者 × 最多 3 页(共 256 页)下的计数

方案 写语句 写入行 单帖最大扇出 收件箱行 读语句 读取行 单页最多语句
push 6000 67917 1090 64917 256 4601 1
h200 8953 47892 195 44892 2102 8952 14
h50 8768 32334 49 29334 3439 12264 24
pull 3000 3000 0 0 5891 16410 37

写入行数对四个方案都等于内存模型给出的“帖子数 + 推送帖子作者的粉丝数之和”,例如 push 为 67917。推方案每页只要 1 条语句,代价是存了 64917 行收件箱,并且一条热点帖子要写 1090 行。拉方案几乎不写额外行,但一页最多发出 37 条语句,读取行数是推方案的 3.6 倍:每一路都要取满 limit 条,最后只留下其中 20 条。阈值 200 只把 20 个作者划进拉取档,就把单帖最大扇出从 1090 压到 195,写入行少了约三成,读语句增加到 push 的 8 倍多。阈值 50 再往拉的方向推一步,两边的变化方向不变。

热点作者的影响在 31-B 单独放大:user-0719 连续发 10 帖。

1
2
note push burst writeQueries=20 writeRows=10910 maxFanoutRows=1090 | follower first page readQueries=1 readRows=10
note h200 burst writeQueries=20 writeRows=10 maxFanoutRows=0 | follower first page readQueries=8 readRows=43

推方案为这 10 帖写了 10910 行,每个发布事务都要插入 1090 行收件箱;混合方案只写 10 行帖子。热点在混合方案里没有消失,它从写路径移到了读路径:31-A 中 h200 的 1590 次作者时间线查询里有 239 次落在 user-0719 上,pull 方案里同样是 239 次,只是总查询数更多(5635 次),热点作者的占比因此更低。256 页中有 239 页的读者关注了它,同一条时间线被大量读者反复读取,可以考虑放进缓存;本章没有实现缓存,只给出计数。

选阈值时要同时看这几列:单帖最大扇出决定发布事务的大小和尾部写入量,单页语句数决定读请求的扇出。表 31-2 只说明这份输入下两者此消彼长,不能外推出一个通用的最佳阈值。

稳定分页

重复时间戳

游标条件写成行构造器比较 (created_at, post_id) < (?, ?)。PostgreSQL 16 文档 9.24.5 节规定这种比较从左到右逐对进行,遇到第一对不相等的元素就由它决定结果,所以 created_at 相等时由 post_id 决胜。7.6 节也要求带 LIMIT 的查询用 ORDER BY 约束出唯一顺序,否则每次取到的子集不可预测;post_id 是主键,加上它之后排序是全序。

用例 31-C 让 30 帖中的 20 帖共用一个 created_at,每页 7 条,页边界落在同值区间中间。四个方案用键集游标都得到 30 条、严格降序、不漏不重。负向对照只比较 created_at < ?:

1
2
note push TS_ONLY cursor returned=14 missing=16
ok negative control exposes: push 只比较 created_at 的游标在同值边界漏掉 16 帖

第一页取走 7 条同值帖后,游标变成 created_at < 3000000,剩下 13 条同值帖一次性被跳过;后面又漏掉 3 条,共 16 帖。

翻页期间有新帖

用例 31-D 先读第 1 页(8 条,游标停在 created_at=4000008),然后发布三类新帖,再接着往下翻:

图 31-2:键集游标翻页期间插入三类新帖的时间线(用例 31-D)

sequenceDiagram
    participant C as 读者客户端
    participant F as Feed.page
    participant W as 发布者
    C->>F: page(TOP, 8)
    F-->>C: 第 1 页,游标 (4000008, 710016)
    W->>F: 3 帖 created_at=4100000..(比第 1 页顶部更新)
    W->>F: 1 帖 created_at=4000010(落在已读区间内)
    W->>F: 1 帖 created_at=4000003(在游标之下)
    C->>F: page(游标, 8) 直到不满一页
    F-->>C: 原有 24 帖全部、无重复;含 4000003;不含 4000010 与更新的 3 帖
    C->>F: 从 TOP 重新读取
    F-->>C: 29 帖,无重复

输出为 traversal size=25 sawBelowCursor=true sawInsideReadRange=false sawNewer=false,刷新后 29 帖、无重复。这项保证可以准确表述为:一次从顶部开始的遍历中,遍历开始前已存在且未被撤销的帖子恰好出现一次;遍历期间插入的帖子,键小于当前游标的会在本轮出现,键大于游标的本轮不出现,要等下一次从顶部刷新。键落在已读区间里的情形,在发布方用自己的时钟赋 created_at 且时钟落后时就会发生,本章把它列为声明的边界,没有修复。

同一用例里的 OFFSET 负向对照先读 OFFSET 0 LIMIT 8,在顶部插入 3 帖后再读 OFFSET 8 LIMIT 8,第 2 页重复了第 1 页的 3 帖(OFFSET page2 repeated 3 items of page1)。OFFSET 按位置计数,顶部每多一行,后面的位置就整体后移一行;7.6 节还指出被 OFFSET 跳过的行仍要在服务端算出来,翻得越深越浪费。

故障反例

撤销之后的旧候选

用例 31-E 的读者 user-8301 关注三个作者:A(粉丝少,推送档)、H(计数设为 10000,在混合方案里属于拉取档)、S。四帖为 A 的一条 FOLLOWERS 和一条 PUBLIC、H 的一条 FOLLOWERS、S 的一条 PUBLIC。随后依次执行:读者取消关注 A;H 拉黑读者;S 的帖子改为 SELF。四个方案的过滤读取都返回 0 帖。负向对照绕过过滤,直接返回收件箱行:

1
2
note push raw inbox rows=4 : 730004=DENY/self-only 730003=DENY/truth 730002=ALLOW/public-block-check 730001=DENY/truth
ok negative control exposes: push 直接返回收件箱行时,3 帖是 AccessCheck 拒绝的内容

四行候选都还在,其中三条是 AccessCheck 明确拒绝的内容:仅粉丝可见的 730001 和 730003,以及仅自己可见的 730004。730002 是公开帖,读者直接打开也被允许,它出现在 Feed 里违反的是成员资格,而不是访问权限。混合方案的收件箱少一行,因为 H 的帖子没有推送。这里的保证来自读取时那段 VISIBLE 条件;收件箱行的存在与否不影响它。成立条件是所有读路径都经过这段条件,观察位置是 Feed.page 的返回值与 AccessCheck 的复核;如果有另一条读路径(例如导出接口、通知摘要)直接读 inbox,保证对那条路径无效。

恢复动作的结果也在 31-E 中断言:H 解除拉黑、读者重新关注 A、S 改回公开之后,Feed 为 [730004, 730002, 730001]。H 的帖子没有回来,因为解除拉黑不恢复关注。

取消关注时清理收件箱,清理不到在途行

一个看似更“干净”的做法是取消关注时删除收件箱里该作者的行。31-F 用插桩构造确定交错,说明清理只能回收空间,不能当作撤销的保证:

图 31-3:扇出事务与取消关注清理的交错(用例 31-F)

sequenceDiagram
    participant P as 发布事务(作者 user-8411)
    participant I as inbox
    participant U as 取消关注 + 清理(另一连接)
    P->>I: INSERT ... SELECT:为 user-8401 写 740002(未提交)
    U->>U: unfollow(user-8401 -> user-8411),提交
    U->>I: DELETE WHERE reader=user-8401 AND author=user-8411
    I-->>U: 删除 1 行(已提交的 740001),看不到 740002
    P->>I: 提交,740002 可见

输出为 cleanup deleted=1 raw inbox after publish commit=[740002]。PostgreSQL 16 文档 13.2.1 节写明,Read Committed 下 DELETE 只能找到语句开始时已提交的目标行,未提交的扇出行不在它的视野里;扇出语句读的是取消关注之前的关系快照,它写下的行在清理之后才变得可见。过滤读取仍返回 0 帖,AccessCheck 也拒绝 740002。

31-G 用真实线程重复这类竞争:每轮一个线程连发 5 帖,另一个线程同时让作者拉黑读者,push 和 h50 各 20 轮。拉黑提交后,过滤读取与 AccessCheck 都没有放行任何一帖;收件箱残留行数取决于调度,本次为 push 52 行、h50 41 行。这是随机抽查,交错的完备性由上面的论证保证:无论残留多少行,读取都要重新检查当前关系。

粉丝数跨越阈值

粉丝数会变,混合方案的分类因此会在作者身上来回切换。31-H 让作者 user-8701 的计数先为 3(推)、再为 60(拉)、再回到 3(推),各发帖若干:

1
2
3
note delivery: 760001=PUSH 760002=PUSH 760003=PULL 760004=PULL 760005=PUSH
note count now=3 per-post delivery=[760005, 760004, 760003, 760002, 760001] classify-at-read=[760005, 760002, 760001] dup=0 missing=2
note count now=60 per-post delivery=[760005, 760004, 760003, 760002, 760001] classify-at-read=[760005, 760005, 760004, 760003, 760002, 760002, 760001, 760001] dup=3 missing=0

按帖记录投递方式时,无论计数此刻是多少,5 帖各出现一次。负向对照在读取时按当前计数决定拉不拉这个作者,并且拉取时不区分投递方式:计数低于阈值时不拉,两条 PULL 帖丢失;计数高于阈值时,收件箱里的 3 条推送帖又被作者时间线返回一次,出现重复。由此还能得到一个推论:follower_count 过期只会让分类偏离本意、改变成本,不会影响结果是否正确。本章的计数是测试代码维护的快照,正好依赖这一点。

先发帖后关注

31-I 记录了一个契约差异:读者在作者发帖之后才关注。拉方案读时取作者时间线,两条旧帖都在;推方案的收件箱里没有这两行,Feed 为空;混合方案只看到拉取档作者的旧帖。AccessCheck 对缺失的帖子是允许的,所以缺失与权限无关,原因是推送不回填。关注之后的新帖,四个方案都出现在首页顶部。如果产品要求新关注立即看到历史,推送方案需要在关注时回填最近若干条,本章没有实现。

恢复流程

发帖与扇出在同一事务中,失败的处理就是重试。31-J 让热点作者 user-0719 在推方案下发帖,扇出语句执行之后、提交之前,另一条连接调用 pg_terminate_backend(pid, 5000) 终止发布会话(带超时的调用会等到进程真正退出才返回 true,见 PostgreSQL 16 文档 9.27.2 节):

1
2
3
4
note inside tx: inbox rows written but uncommitted=1090
note pg_terminate_backend(1851809, 5000ms)=true backendStillListed=0
note publish failed (last exception seen by caller): 57P01 FATAL: terminating connection due to administrator command
note retry=PUSH inboxRows=1090 second retry=NOOP inboxRows=1090

会话终止后,帖子行和 1090 条收件箱行都不存在,符合 3.4 节对事务全有或全无的描述。调用方看到的是服务端终止会话时发出的 57P01。公共事务执行器 Tx 在回滚和恢复 autocommit 时还会碰到已关闭的连接,这些后续异常作为 suppressed 附在原始异常上,不覆盖它。判断终止确实发生,另一个依据是 pg_stat_activity 中已经查不到该 pid。对调用方而言,这属于结果未知:它不知道提交是否到达过服务端。重试是安全的,因为 post_id 是主键、扇出有 ON CONFLICT DO NOTHING;第一次重试完成扇出,第二次返回 NOOP,收件箱行数不变。

恢复的检查顺序为:发布失败或结果未知时,用同一个 post_id 重试,直到得到 PUSH、PULL 或 NOOP 之一;NOOP 表示之前的某次提交已经成功。收件箱是派生数据,全部内容都可以由 post(delivery = ‘PUSH’)与当前 relation 重新算出;本章没有实现批量重建,也没有在数据库 immediate 停机或 JVM kill -9 下做验证,这两项只来自事务语义,未经本地崩溃实验。

证据与限制

运行入口是 examples/social/run.sh 31。它在端口 55431 启动 b3-31 集群,重建 social31 库,编译运行 social.feed.Ch31Main,把输出写入 evidence/31/output.txt,最后停止集群。本次运行汇总为 SUMMARY passed=93 failed=0,退出码 0。复跑时可加 LAB_BUILD=/tmp/batch3-build-31,把编译目录与第 30、32 篇分开。

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

用例 类别 检查的保证 观察位置
31-A、31-B 真实组件,确定性计数 四方案结果相同且等于内存模型;写入行数等于模型 Cost 计数、Graph.expectedFeed、AccessCheck
31-C、31-D 真实组件 键集游标不漏不重;插入期间的可见性边界;两个负向对照 遍历结果与期望序列
31-E、31-F 真实组件,插桩交错 撤销后过滤读取为空;直接读收件箱泄露;清理漏掉在途行 rawInboxPage、AccessCheck
31-G 真实组件,真实线程 拉黑与发布并发后不放行 Feed.page、AccessCheck
31-H、31-I 真实组件 跨阈值不重不漏;读时分类的负向对照;推送不回填 post.delivery、遍历结果
31-J 真实组件,会话终止 发布原子性;重试幂等 post、inbox 行数,pg_stat_activity

限制写在 evidence/31/LIMITS.md,要点如下。规模是千级用户、数万条关注,计数只对这份输入成立;返回行数和语句数不等于服务端扫描的页数,本章没有用 EXPLAIN 测量,也不报告任何耗时。单机单实例,不覆盖分片、读副本、缓存和异步扇出。关注图由批量写入装载,不经过第 30 篇的写入口;follower_count 是测试维护的快照。31-G 的残留行数随调度变化。没有多进程崩溃实验。翻页保证只针对一次遍历,已读区间内迟到的帖本轮不可见。

本批导航

  • 上一篇:30 关注关系与可撤销订阅
  • 下一篇:32 点赞事实与计数校准
  • 本批索引:仓库 batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md
  • 运行入口:batch-3/examples/social/run.sh,证据目录:batch-3/evidence/31/
  • 本篇工程与证据快照:backend-systems-31.tar.gz(SHA-256 见同目录 backend-systems-31.tar.gz.sha256)。正文中的 examples/、evidence/、SOURCES.md 路径都相对仓库的 batch-3/ 目录,与快照包内布局一致。