点赞数是派生数据。真相源是“谁赞了哪个条目”这组事实,计数只是对事实的汇总;为了让读取不必每次 count(*),计数被单独存放、异步更新。破坏两者一致的路径有五条:用户重复点击,赞与取消交错,事件被重复投递,消费者在提交与确认之间崩溃,以及修复用的批量重算与在线增量同时改同一行。前三条会把计数推高或拉低;消费者崩溃会让已生效的事件被再投一次;批量重算若与在线增量直接冲突,修复本身就会制造新的偏差。

本章代码在 examples/social 的 social.like 包,全部用例跑在真实 PostgreSQL 16.15 上,包括独立 JVM 的 kill -9 与数据库 immediate 停机。验收目标:点赞/取消交错与事件重复之后,计数与事实记录一致;计数允许落后于事实,落后多少有明确定义,并且在任何时刻都能算出误差上界。

需求与范围

操作有三个:点赞、取消点赞、读取条目的点赞数。业务不变量写成可以用 SQL 检查的形式:

  1. 每个 (user, item) 至多一行事实,liked 表示当前是否已赞,version 每次状态变化加一。
  2. 每次状态变化恰好写一条 Outbox 事件,与事实变化在同一事务;事件的 delta 是 +1 或 −1,由事务内读到的旧状态决定。状态不变的请求不写事件。
  3. 事件带全局序号,序号没有空洞,序号顺序等于提交顺序。
  4. 每条事件对计数恰好生效一次:要么被消费者加进计数(APPLIED),要么已经包含在重算基线里(COVERED)。
  5. 任意一致快照中,计数加上尚未处理、且序号大于基线的事件增量之和,等于事实数;排空事件后,计数等于事实数。

排除项:多种表情回应、反作弊、条目删除、缓存、跨库分片、外部消息组件。本章的“投递”由测试线程或子进程直接读 like_outbox 表完成,重投的事件由测试代码按固定种子挑选,不验证任何中间件的确认语义。热点条目的分片计数行只做讨论,没有实现。

教学规模假设

以下是教学假设,不对应任何实际产品,也没有测量:条目 10⁷ 个,点赞数呈重尾分布,少数条目有 10⁵ 量级的赞;读取点赞数的次数远多于点赞写入。在这个假设下,读路径必须是主键点查一行计数,不能对事实表做 count(*);写路径上,同一热点条目在短时间内会收到大量并发点赞。

实验规模远小于此:至多 6 个条目、60 个用户、每轮至多 2400 个请求,只用来检查不变量。

接口与返回语义

表 32-1:点赞接口

操作 CHANGED NOOP REJECTED
like(u, i) 未赞→已赞,版本加一,写 delta=+1 事件 已是已赞 条目不存在(外键 23503)
unlike(u, i) 已赞→未赞,版本加一,写 delta=−1 事件 本来未赞 —
readCount(i, allowedLag) 返回 n、lag、本条目未处理事件数、stale 标记 — —

点赞与取消都是“设成目标状态”的语义,重复请求得到 NOOP,客户端超时后重发是安全的。32-B 中重复点赞返回 NOOP(liked,v1,already liked),Outbox 行数仍为 1;对从未赞过的条目取消返回 NOOP(unliked,v0,not liked)。

数据模型

like_fact 以 (user_id, item_id) 为主键,item_id 外键引用 item。like_outbox 以 seq 为主键,另有 UNIQUE (user_id, item_id, version),同一对的同一个版本不可能写出两条事件。派生侧有三张表:like_count(item_id, n, base_seq),processed_event(seq, how) 记录每条已处理事件是 APPLIED 还是 COVERED,counter_watermark 记录连续水位,即最大的 w 使序号 1…w 全部已处理。

全局序号没有用 PostgreSQL 序列。PostgreSQL 16 文档 9.17 节说明,nextval 取得的值在事务中止后不回收,中止或崩溃都会留下空洞,序列不能提供无空洞编号。另外,序列在 nextval 时分配,先拿到小号的事务可能后提交,所以分配顺序也不等于提交顺序(这是本章的推断,第 34 篇的 NC4 实测到了这种情形)。本章改用单行计数:

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

这条语句给计数行加行锁并持有到事务结束(13.3.2 节),下一个点赞事务要等前一个提交或回滚后才能拿到序号。回滚会把加一一起撤销,所以没有空洞;拿到序号 k 的事务提交之前,没有事务能拿到 k+1,所以序号顺序就是提交顺序。代价是所有点赞写入在这一行上串行化,这和第 30 篇关系事件的做法相同,是教学实现为了得到连续水位而接受的限制。

delta 写死在事件里有一个直接后果:增量满足交换律,消费者以任何顺序应用同一组事件,和都相同。乱序因此不需要版本比较,威胁只剩重复。乱序期间 n 可能暂时为负或偏离事实,误差仍受本条目未处理事件数约束;对外展示时可以夹紧到 0,或在 stale 时附带误差上界。本章的 readCount 没有做夹紧。这一点与第 30 篇的关系投影不同,那里事件携带的是目标状态,覆盖顺序会影响结果,必须比较版本。

状态机

事实行在第一次请求时以“未赞、版本 0”的占位行出现,占位不写事件。之后只有两个状态之间的迁移。

图 32-1:互动事实的状态与拒绝路径(32-B、32-C 验证)

stateDiagram-v2
    [*] --> Unliked: 首次请求插入占位行 v0(不写事件)
    [*] --> [*]: 条目不存在 → REJECTED(23503,无任何行)
    Unliked --> Liked: like,版本+1,写 delta=+1
    Liked --> Unliked: unlike,版本+1,写 delta=−1
    Liked --> Liked: like → NOOP,不写事件
    Unliked --> Unliked: unlike → NOOP,不写事件

每条事件在派生侧的处理结果由两项检查决定:processed_event 主键是否已存在,事件序号是否不大于该条目的 base_seq。

图 32-2:一条事件被投递时的处理分支(32-E、32-H、32-K 验证)

flowchart TD
    D[投递事件 e] --> L[锁水位行,再锁 like_count 行]
    L --> B{e.seq ≤ base_seq?}
    B -- 是 --> C1{processed_event 插入成功?}
    C1 -- 是 --> COV[COVERED:只登记,不加增量]
    C1 -- 否 --> DUP[dup:不变]
    B -- 否 --> C2{processed_event 插入成功?}
    C2 -- 是 --> APP[APPLIED:n += delta]
    C2 -- 否 --> DUP
    COV --> W[推进连续水位,提交]
    APP --> W
    DUP --> W

正常路径

写事务的步骤是:插入占位行(ON CONFLICT DO NOTHING),SELECT ... FOR UPDATE 锁住事实行并读出旧状态,比较目标状态,不同才改状态、加版本、取序号、写 Outbox。锁顺序固定为事实行 → like_seq。

1
2
3
4
5
6
7
8
9
Db.update(cx, "INSERT INTO like_fact(user_id, item_id, liked, version) VALUES (?, ?, false, 0) " +
"ON CONFLICT DO NOTHING", user, item);
// SELECT liked, version FROM like_fact WHERE user_id = ? AND item_id = ? FOR UPDATE
if (liked == want) return new Result(Outcome.NOOP, liked, ver, 0, want ? "already liked" : "not liked");
long v = ver + 1;
Db.update(cx, "UPDATE like_fact SET liked = ?, version = ? WHERE user_id = ? AND item_id = ?", want, v, user, item);
long seq = Db.queryLong(cx, "UPDATE like_seq SET last = last + 1 WHERE id = 1 RETURNING last");
Db.update(cx, "INSERT INTO like_outbox(seq, user_id, item_id, delta, version) VALUES (?, ?, ?, ?, ?)",
seq, user, item, want ? 1 : -1, v);

16 个线程同时对同一对点赞时,后到者先在占位行的 INSERT ... ON CONFLICT 上等待先到者结束,随后的 FOR UPDATE 锁住并读到更新后的行(Read Committed,13.2.1 节),于是看到“已赞”并返回 NOOP。32-C 的输出是 CHANGED=1 NOOP=15,只有 1 条事件。8 个线程在另一对上随机交错 320 次赞/取消后,CHANGED 次数、事件数、事实版本三者相等(本次为 146),增量和等于当前是否已赞。

消费者一批事件一个事务:先 FOR UPDATE 锁水位行,使消费者之间串行;再按条目逐条锁计数行、登记 processed_event、加增量;最后推进连续水位。

1
2
3
4
5
6
7
8
9
} else {
int fresh = Db.update(cx, "INSERT INTO processed_event(seq, how) VALUES (?, 'APPLIED') ON CONFLICT DO NOTHING", e.seq());
if (fresh == 1) {
Db.update(cx, "UPDATE like_count SET n = n + ? WHERE item_id = ?", e.delta(), e.item());
s.applied++;
} else {
s.dup++;
}
}

去重的保证来自 processed_event 的主键与计数更新同在一个事务:主键插入成功和增量生效要么一起提交,要么一起回滚。成立条件是二者共享事务;观察位置是 Audit.readSide 中的 exact_violations;失败边界是把计数更新和去重登记拆进两个事务,或者把去重记录放在数据库之外,此时崩溃会留下“已登记未加”或“已加未登记”。

滞后的定义与读侧误差

本章把滞后定义为:

1
lag = like_seq.last − counter_watermark.applied_upto

两个量在同一条 SQL 里读出,所以来自同一快照。last 是已提交的最大事件序号(序号顺序等于提交顺序,快照里看到的 last 之前的事件都已提交),applied_upto 是连续已处理前缀的末尾。未处理的事件全部在 (applied_upto, last] 区间内,因此“未处理事件数 ≤ lag”。lag 是条数,不是时间;换算成时间需要知道写入速率,本章不做这种换算。

读接口 Audit.readCount 同时返回 n、lag 和本条目未处理事件数,并按调用方给出的允许滞后标记 stale。允许滞后取 16 条是教学设定。32-D 先写入 30 个赞(item-lab-01 上 20 个),不消费,再消费前 20 条:

1
2
3
note no consumer yet: item-lab-01 n=0 lag=30 pendingForItem=20 stale=true truth=20
note after 20 events: applied=20 covered=0 dup=0 watermark=20 | item-lab-01 n=14 lag=10 pendingForItem=6 stale=false | ...
note drained: item-lab-01 n=20 lag=0 pendingForItem=0 stale=false

误差有两层界。条目级:|n − 事实数| ≤ 本条目未处理事件数(上例 |14 − 20| = 6)。更强的是一条恒等式:n 加上“未处理且序号大于 base_seq”的事件增量之和,恰好等于事实数。Audit.readSide 用一条 SQL 在同一快照里对所有条目检查这两条,输出 exact_violations 与 bound_violations。成立条件:事实与事件同事务提交,计数与去重登记同事务提交,序号无空洞且按提交顺序分配。失败边界:若改用序列分配序号,快照中可能缺少一个较小序号的事件而包含较大的,last 就不再说明“之前的都已提交”,lag 的含义和恒等式都会失效。

方案比较

表 32-2:计数维护方式

方案 计数何时正确 重复请求 重复投递 热点条目
A 请求路径直接加减,不查事实 从不保证 多加 不涉及 计数行每请求一锁
B 事实与计数同事务更新 提交即正确 由事实比较吸收 不涉及 热点计数行在每个点赞事务里持锁到提交
C 事实 + Outbox,异步去重消费(本章) 排空后正确,之前有界 由事实比较吸收 由 processed_event 吸收 计数行只被消费者锁;写路径仍经过全局序号行
D 只做定期 count(*) 重算 重算瞬间正确 由事实吸收 不涉及 每次重算扫描热点条目的全部赞

方案 B 最简单,代价是热点条目的计数行锁进入用户请求路径。方案 C 把这把锁移到消费者,但本实现的写路径仍要经过 like_seq 这一行,串行点没有消失,只是从“每条目一个”变成“全局一个”。若要去掉它,可以改为每条目独立序号与每条目水位,滞后的定义也随之变成每条目一个量;本章没有实现。

热点条目常见的做法是把一行计数拆成 (item, shard) 多行,写时按某个规则选一行,读时求和。在本章的设计里,分片会影响重算安装:base_seq 必须对同一条目的全部分片一次写定,安装事务要按固定顺序锁住所有分片行,否则一部分分片用新基线、一部分用旧基线,COVERED 判断会出错。社交图存储把关联计数作为一等查询的设计可参见 TAO(USENIX ATC 2013);本环境只取得该文的会议摘要页,未据此论述其实现。

负向对照一:请求路径直接加一

32-F 把同一组 400 个请求交给 8 个线程,其中 136 个请求被客户端重发一次,模拟响应丢失后的重试。事实入口一侧,重发和重复点击全部变成 NOOP(本次 347 个),排空后 4 个条目计数都等于事实数。负向对照 setNaive 每个请求直接按方向加减计数,不读旧状态:

1
2
3
note naive: items with drift=4 of 4, sum |naive − facts|=60
note item-lab-02 naive=0 facts=19
note item-lab-03 naive=-12 facts=15

偏差有两个来源:重发的点赞或取消各多加减一次,对未赞过的条目取消也会减一次,计数甚至变成负数。保证来自写事务里“读旧状态、比较、再决定增量”这一步,与有没有事务无关;setNaive 同样运行在事务里。

负向对照二:消费者不去重

32-G 先写出 243 条事件,让 3 个竞争消费者都从当前水位拉取。拉取发生在加锁之前,批次会重叠;之后再重投 29 条旧事件。去重消费者得到 applied=243,其余投递全部记为 dup,计数一致。清空派生侧,用 dedup(false) 的消费者重放同一批事件:

1
2
note no-dedup consumers: applied=758 covered=0 dup=0 watermark=243 (applications=758 for 243 events)
note item-lab-01 count=38 facts=12

758 次应用对应 243 条事件,4 个条目全部偏离。偏离之后用批量重算校准:重算把四个条目分别从 38、63、44、53 改回 12、19、14、18,此后再来 50 个请求,由去重消费者增量维护,排空后仍一致。重算因此承担两个角色:修复派生侧已经发生的错误,以及给在线增量提供新的基线。

批量重算与在线增量

直接用快照里的 count(*) 覆盖计数,会与在线消费者冲突。快照之前提交、但消费者尚未应用的事件,其效果已在快照里,消费者之后再加一次就重复;快照之后提交、消费者已经应用的事件,其效果不在快照里,覆盖就把它丢了。

本章的重算分两步。第一步在一个 Repeatable Read 事务里读出 S = like_seq.last 与每个条目的事实数 C(S);RR 事务内所有语句看到同一快照(13.2.2 节),而序号顺序等于提交顺序,所以这个快照恰好包含序号 1…S 的全部事件效果。第二步逐条目开短事务,锁计数行,写入基线:

1
2
3
4
5
long after = Db.queryLong(cx,
"SELECT coalesce(sum(o.delta), 0) FROM processed_event p JOIN like_outbox o USING (seq) " +
"WHERE o.item_id = ? AND p.seq > ? AND p.how = 'APPLIED'", item, snap.seq());
long n = snap.counts().getOrDefault(item, 0L) + after;
Db.update(cx, "UPDATE like_count SET n = ?, base_seq = ? WHERE item_id = ?", n, snap.seq(), item);

消费者遇到 seq ≤ base_seq 的事件只登记 COVERED,不加增量。若该条目已有 base_seq ≥ S,说明更新的重算已安装,本次跳过。

32-H 构造了确定的交错,时间线如下(单条目 item-lab-01):

表 32-3:重算与在线增量的交错(32-H)

步骤 动作 按基线安装 直接覆盖
1 写入事件 1…6,消费者应用 1…3 n=3 n=3
2 重算快照:S=6,C(S)=6 — —
3 写入事件 7、8,消费者先应用 7 n=4 n=4
4 安装 n=6+1=7,base_seq=6 n=6
5 消费者应用 4、5、6、8 4…6 COVERED,8 APPLIED,n=8 全部 APPLIED,n=10
6 事实数 8 8
1
2
note naive overwrite: ... item-lab-01:overwritten(n 4->6, base_seq=0, appliedAfterBase=0); applied{4,5,6,8} applied=4 ... n=10 truth=8
note naive: double-counted {4,5,6}=+3, lost {7}=-1, error=2

按基线安装之后再重投 1…8,8 次全部记为 dup,n 保持 8。

第二步与消费者争的是同一行计数。32-I 让消费者在提交前停住,此时它持有 item-lab-01 计数行的锁;重算安装在 pg_locks 中出现一条 granted = false 的等待(54.12 节)。放行消费者后,安装的 FOR UPDATE 拿到锁,随后的求和语句看到消费者刚提交的事件 5,安装值为 4+1=5,之后事件 1…4 全部记为 COVERED。保证来自两处:安装与消费者都先锁计数行再读 processed_event;Read Committed 下锁等待之后的新语句看到最新已提交数据。失败边界:安装若改在 RR 事务里执行,等锁之后的 FOR UPDATE 会因计数行已被消费者更新而报 40001(13.2.2 节),需要整体重试;真正会漏计的写法是先在锁外读出 processed_event 的求和,再去锁计数行写入。

全部并发

32-J 让 8 个写线程各发 300 个请求(item-lab-01 占一半流量),同时运行 3 个竞争消费者、1 个随机重投旧事件的线程、1 个每 100 毫秒做一次全量重算的线程,以及 1 个采样线程,每次采样执行 Audit.readSide。本次运行:

1
2
3
note requests=2400 CHANGED=1202 events=1202 rebuild rounds=9 redelivered=280
note consumers+redeliver: applied=79 covered=1123 dup=2265 watermark=1202 covered rows=1123
note sampler (values vary by run): samples=91 max lag=634 max |n-truth|=16

91 次采样中,恒等式、条目误差界、“未处理数 ≤ lag”三项违反数都是 0;每条事件恰好一次 APPLIED 或 COVERED(79 + 1123 = 1202);排空后计数等于事实数。这一轮消费者落后较多,大部分事件在被消费前已经被某次重算的快照覆盖,所以 COVERED 远多于 APPLIED;比例与最大 lag 随调度变化,不是性能数据。采样是抽查,不能覆盖所有中间状态。

崩溃与恢复

32-K 用独立 JVM 运行消费者 ConsumerProc,投递位点存放在本地文件里,只在数据库提交之后才写入,用来代表外部传输的消费位点。第一次让子进程在第 2 批(序号 26…50)的事务内停住,然后 kill -9:

1
2
3
note child1| PAUSED_IN_TX batch=2 seq=26..50
note child1 backend state idle in transaction = 1
note kill -9 child1 exit=137 backendStillListed=0 watermark=25 processed=25 offsetFile=25

客户端进程消失后,服务端在下一次读 socket 时发现连接断开(client_connection_check_interval 默认关闭,20.3.1 节),会话结束,打开的事务回滚(55.2.9 节)。水位停在 25,第 2 批的登记一条也没有留下。第二次让子进程提交第 2 批(51…75)之后、写位点文件之前被杀:数据库水位是 75,位点文件仍是 50。第三次正常启动,从 50 开始重投,51…75 共 25 条全部记为 dup,最终 applied=45 dup=25 watermark=120,计数一致。这个“已提交、未确认”的窗口在任何把确认放在业务提交之后的传输里都存在,去重只能在业务事务内部完成。

32-L 在 6 个写线程和 1 个消费者运行时执行 pg.sh restart-immediate。immediate 模式直接中止全部服务进程,下次启动进入崩溃恢复(pg_ctl 参考页)。7 个线程全部以 08006 结束。恢复后:

1
2
3
note acked CHANGED before crash=344; threads ended by SQLException=7 states=[08006, consumer:08006]
note after recovery write-side: last_seq=344 outbox_rows=344 version_mismatch_pairs=0 delta_sum_mismatch_items=0
note after recovery read-side: last_seq=344 watermark=180 lag=164 unprocessed=164 count_mismatch_items=3 max_abs_err=3 exact_violations=0 bound_violations=0

已经向调用方确认的 344 个 CHANGED 都能在 Outbox 中按序号和版本找到;序号无空洞,每对的版本等于事件数,每个条目的增量和等于事实数,说明事实与事件同进同退。计数此时落后 164 条,3 个条目不一致,但恒等式成立,排空后计数等于事实数。这一项依赖 fsync=on、synchronous_commit=on(均为默认值)与 WAL 先于数据页落盘(30.3 节),单机单实例,不涉及复制。本次 Outbox 行数恰好等于已确认数;用例只断言前者不小于后者,因为停机可能发生在提交已持久化、确认尚未送达之间,这样的请求在调用方看来是结果未知。按表 32-1 的语义重发同一目标状态,若期间没有其他变化,会得到 NOOP。

线程结束时看到的是 08006 这个原始错误(共用的 social.common.Tx 把回滚时的后续异常附为 suppressed,不覆盖原始错误,修改记录见 evidence/32/LIMITS.md)。

恢复流程按下面的顺序检查:

  1. 消费者重启后从自己的位点或数据库水位重新拉取,重复部分由 processed_event 吸收,不需要人工判断哪些已经生效。
  2. 用 Audit.writeSide 检查写侧:last_seq = outbox_rows、版本不符的对数为 0、增量和不符的条目数为 0。写侧不一致说明有绕过写入口的修改,重算不能修复,要先查事实表。
  3. 用 Audit.readSide 检查读侧恒等式。恒等式违反(例如 32-G 的不去重消费者)说明派生侧被写错,运行 Rebuild.rebuildAll 重新安装基线,再让消费者继续。
  4. 排空后 lag = 0 且计数不符条目数为 0,恢复结束。

证据与限制

运行入口是 examples/social/run.sh 32。约定端口 55432 在本机被一个与本批无关的集群占用,run.sh 改用 55532,并写进 env.txt。脚本启动 b3-32 集群,重建 social32 库,编译运行 social.like.Ch32Main,输出写入 evidence/32/output.txt,最后停止集群。本次运行汇总为 SUMMARY passed=128 failed=0,退出码 0,40001/40P01 重试次数为 0。复跑时可加 LAB_BUILD=/tmp/batch3-build-32,与第 30、31 篇的编译目录分开。

表 32-4:用例与保证的对应

用例 类别 检查的保证 观察位置
32-A、32-B 真实组件 正常路径;NOOP、外键拒绝、中止不留痕、序号无空洞 like_fact、like_outbox、like_seq
32-C 真实组件,真实线程 同一对并发只一条事件;版本 = 事件数 CHANGED 计数、version
32-D 真实组件 lag 定义、stale 标记、误差界 Audit.readCount、readSide
32-E 真实组件,确定投递序列 重复与乱序投递后一致 applied、dup、恒等式
32-F、32-G 真实组件,负向对照 直接加减偏离;不去重偏离;重算校准 naiveDrift、readSide
32-H、32-I 真实组件,确定交错 重算基线与在线增量不重不漏;直接覆盖的负向对照;锁等待 n、COVERED、pg_locks
32-J 真实组件,真实线程 全部并发下采样恒等式成立 Audit.readSide
32-K 多进程,kill -9 事务内被杀回滚;提交后被杀重投被去重 水位、位点文件、dup
32-L 多进程,immediate 停机 已确认事件持久;事实与事件同进同退 writeSide、readSide

限制写在 evidence/32/LIMITS.md,要点如下。规模至多 6 个条目、2400 个请求,计数只对这份输入成立,不报告任何耗时或吞吐。全局序号行与水位行是两处串行点,没有测量其容量。没有实现分片计数行。读接口统计未处理事件数要扫描 Outbox,只适合教学规模。Outbox 与 processed_event 不清理,没有验证保留期之后的去重边界。投递与重投由测试代码完成,位点文件是本地替身。32-F、32-G、32-J、32-L 中的具体数值随调度变化,断言只检查不变量。

本批导航

  • 上一篇:31 Feed 的扇出成本与热点
  • 下一篇:33 通知渠道的结果未知
  • 本批索引:仓库 batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md
  • 运行入口:batch-3/examples/social/run.sh,证据目录:batch-3/evidence/32/
  • 本篇工程与证据快照:backend-systems-32.tar.gz(SHA-256 见同目录 backend-systems-32.tar.gz.sha256)。正文中的 examples/、evidence/、SOURCES.md 路径都相对仓库的 batch-3/ 目录,与快照包内布局一致。