后端系统工程 32:点赞事实与计数校准
点赞数是派生数据。真相源是“谁赞了哪个条目”这组事实,计数只是对事实的汇总;为了让读取不必每次 count(*),计数被单独存放、异步更新。破坏两者一致的路径有五条:用户重复点击,赞与取消交错,事件被重复投递,消费者在提交与确认之间崩溃,以及修复用的批量重算与在线增量同时改同一行。前三条会把计数推高或拉低;消费者崩溃会让已生效的事件被再投一次;批量重算若与在线增量直接冲突,修复本身就会制造新的偏差。
本章代码在 examples/social 的 social.like 包,全部用例跑在真实 PostgreSQL 16.15 上,包括独立 JVM 的 kill -9 与数据库 immediate 停机。验收目标:点赞/取消交错与事件重复之后,计数与事实记录一致;计数允许落后于事实,落后多少有明确定义,并且在任何时刻都能算出误差上界。
需求与范围
操作有三个:点赞、取消点赞、读取条目的点赞数。业务不变量写成可以用 SQL 检查的形式:
- 每个 (user, item) 至多一行事实,
liked表示当前是否已赞,version每次状态变化加一。 - 每次状态变化恰好写一条 Outbox 事件,与事实变化在同一事务;事件的
delta是 +1 或 −1,由事务内读到的旧状态决定。状态不变的请求不写事件。 - 事件带全局序号,序号没有空洞,序号顺序等于提交顺序。
- 每条事件对计数恰好生效一次:要么被消费者加进计数(APPLIED),要么已经包含在重算基线里(COVERED)。
- 任意一致快照中,计数加上尚未处理、且序号大于基线的事件增量之和,等于事实数;排空事件后,计数等于事实数。
排除项:多种表情回应、反作弊、条目删除、缓存、跨库分片、外部消息组件。本章的“投递”由测试线程或子进程直接读 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 | |
这条语句给计数行加行锁并持有到事务结束(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 | |
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 | |
去重的保证来自 processed_event 的主键与计数更新同在一个事务:主键插入成功和增量生效要么一起提交,要么一起回滚。成立条件是二者共享事务;观察位置是 Audit.readSide 中的 exact_violations;失败边界是把计数更新和去重登记拆进两个事务,或者把去重记录放在数据库之外,此时崩溃会留下“已登记未加”或“已加未登记”。
滞后的定义与读侧误差
本章把滞后定义为:
1 | |
两个量在同一条 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 | |
误差有两层界。条目级:|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 | |
偏差有两个来源:重发的点赞或取消各多加减一次,对未赞过的条目取消也会减一次,计数甚至变成负数。保证来自写事务里“读旧状态、比较、再决定增量”这一步,与有没有事务无关;setNaive 同样运行在事务里。
负向对照二:消费者不去重
32-G 先写出 243 条事件,让 3 个竞争消费者都从当前水位拉取。拉取发生在加锁之前,批次会重叠;之后再重投 29 条旧事件。去重消费者得到 applied=243,其余投递全部记为 dup,计数一致。清空派生侧,用 dedup(false) 的消费者重放同一批事件:
1 | |
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 | |
消费者遇到 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 | |
按基线安装之后再重投 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 | |
91 次采样中,恒等式、条目误差界、“未处理数 ≤ lag”三项违反数都是 0;每条事件恰好一次 APPLIED 或 COVERED(79 + 1123 = 1202);排空后计数等于事实数。这一轮消费者落后较多,大部分事件在被消费前已经被某次重算的快照覆盖,所以 COVERED 远多于 APPLIED;比例与最大 lag 随调度变化,不是性能数据。采样是抽查,不能覆盖所有中间状态。
崩溃与恢复
32-K 用独立 JVM 运行消费者 ConsumerProc,投递位点存放在本地文件里,只在数据库提交之后才写入,用来代表外部传输的消费位点。第一次让子进程在第 2 批(序号 26…50)的事务内停住,然后 kill -9:
1 | |
客户端进程消失后,服务端在下一次读 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 | |
已经向调用方确认的 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)。
恢复流程按下面的顺序检查:
- 消费者重启后从自己的位点或数据库水位重新拉取,重复部分由
processed_event吸收,不需要人工判断哪些已经生效。 - 用
Audit.writeSide检查写侧:last_seq = outbox_rows、版本不符的对数为 0、增量和不符的条目数为 0。写侧不一致说明有绕过写入口的修改,重算不能修复,要先查事实表。 - 用
Audit.readSide检查读侧恒等式。恒等式违反(例如 32-G 的不去重消费者)说明派生侧被写错,运行Rebuild.rebuildAll重新安装基线,再让消费者继续。 - 排空后
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/目录,与快照包内布局一致。






