后端系统工程 00:一份技术方案怎样接受验证

本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收

评审技术方案时,常见的提问是“这个设计能不能扛住”“消息会不会丢”。这些问题回答不了,原因通常不在于设计者不够仔细,而在于方案本身没有给出可以判定的陈述:“不丢消息”没有说明在哪一刻之后不丢,“能扛住”没有说明扛住的是什么负载、用什么去观察。一份能接受验证的方案,必须让每一句承诺都能落到一个观察位置和一个判定方法上,判定结果只有成立或不成立两种。

本篇为整个系列确定写方案的方法。对象是后面 04–11 篇要逐步实现的最小即时通信服务(下文简称“消息服务”),使用合成的团队协作场景:成员、会话、设备、消息,域名统一为 example.test。本篇产出三件东西:一张需求卡、一张状态表、一组设计决策记录,以及检查需求卡格式与证据对应关系的脚本 examples/design/reqcheck.py。

1. 方案里的句子分四类

一份方案中的句子,按它对系统的约束力,可以分成四类。分类决定了每句话需要什么样的证据。

类别 含义 需要的证据 示例
承诺(promise) 系统对调用方保证的行为,违反即为缺陷 可执行检查,结果为 PASS/FAIL 收到 sent 回复时消息已提交
假设(assumption) 承诺成立所依赖的外部条件,由系统之外保证 环境记录或配置核对 主库使用同步提交
未知(unknown) 影响设计但当前无法确定的量 标注缺口与获取方法,状态为 NOT_RUN 目标环境中每连接内存
排除(excluded) 明确不做的范围 无需证据,但必须写出 端到端加密、音视频

把句子归类的过程本身就是审查。很多“承诺”在归类时会暴露出它其实是假设:例如“消息持久化后不会丢”依赖存储介质和复制配置,对单库方案来说,它只在“提交成功且磁盘不损坏”的前提下成立。这个前提应该单独写成假设 A1,而承诺缩小为“回复 sent 时事务已经提交”。承诺缩小以后反而可以检查:在提交前后分别终止进程,看重试结果是否符合预期(第 06 篇)。

未知项不能用乐观估计顶替。在方案里写“每连接约 20 KiB”,读者无从判断这个数字来自测量、文档还是猜测。合规的写法是把它列为 U1,给出获取方法(目标环境压测),状态保持 NOT_RUN,并说明哪些结论依赖它:容量篇(第 02 篇)的网关内存估算就必须把它当作输入参数,而不是结论。

排除项的价值在于阻止范围在评审中悄悄扩大。消息服务排除了跨会话全局顺序:系统只在单个会话内给出顺序(第 07 篇),因此任何依赖“A 会话的消息一定早于 B 会话的消息”的需求都必须被拒绝或另行设计。

2. 承诺必须带观察位置

一条承诺至少要回答三个问题:

  1. 何时成立:承诺绑定在哪个可观察事件之后。“消息不丢”改写为“客户端收到 sent{seq} 之后,该消息在其会话的保留期内始终可以通过 sync 读到”。
  2. 在哪里观察:用什么状态来判定。上例的观察位置是 events 表中 (conv_id, seq) 的行,以及客户端收到的回复。
  3. 怎样判定:一个可执行的检查。上例对应第 06 篇的三个崩溃点实验和第 04 篇的并发重放实验。

观察位置要选在状态的权威来源上。“设备已收到”的权威来源是设备上报的送达游标(device_cursors),而不是网关日志里的“已写入套接字”:写入套接字只说明数据进了内核发送缓冲区,对端可能从未读取。第 05 篇的慢接收端实验会直接展示这种差异:网关向一个不读数据的客户端连续推送,发送方 120 条消息全部得到 sent,接收方一条也没有确认。

1
2
3
4
5
6
7
8
9
10
图 0-1:一条承诺从陈述到证据的映射(R1 为例)

陈述 "回复 sent 时消息已提交,且同一 (conv, sender, cmid) 只对应一条消息"
│
├─ 何时成立 ──> 客户端收到 {t:"sent", seq, mid}
├─ 观察位置 ──> events 行 (conv_id, seq);唯一索引 events_idempotency
└─ 判定方法 ──> im-04/04-concurrent-replay-one-row
im-06/06-crash-send.before_insert
im-06/06-crash-send.before_commit
im-06/06-crash-api.after_send_commit

3. 消息服务的需求卡

需求卡保存在 examples/design/requirements.json,下面是它的内容摘要。每条承诺都引用了后续章节的检查编号;检查编号是实验代码中 lab.check(...) 的第一个参数,结果写在 evidence/im-NN/result.json。

编号 类别 陈述 观察位置 检查
R1 承诺 回复 sent 时消息已提交,同一 (conv, sender, cmid) 只对应一条消息 events + 回复 04、06
R2 承诺 会话内已提交事件的 seq 从 min_seq 起连续 events 主键 + next_seq 04、11
R3 承诺 成员只能读取自己成员区间内的事件,权限在读取时判定 membership_periods 08、11
R4 承诺 设备送达游标单调且不超过 head;已读位置单调且与送达游标独立 device_cursors、read_positions 06、09
R5 承诺 设备重连后,保留期内的消息全部可通过 sync 补齐 客户端本地 vs events 05、10
R6 承诺 撤回后服务端不再返回原文;离线设备同步后看到撤回状态 events.body、recalled_by 11
R7 承诺 设备同一时刻只有一个当前路由;迟到的旧注册不能覆盖新归属 routes、route_floor 05、10
A1 假设 单个 PostgreSQL 主库,synchronous_commit=on,无副本 配置 环境记录
A2 假设 客户端持久保存待发消息与 cmid,重启后用同一 cmid 重发 客户端状态文件 06
A3 假设 负载参数取 02 篇的基线值,均为教学假设 capacity.py 02
U1 未知 目标环境中每连接内存 — NOT_RUN
U2 未知 主库故障切换期间已确认消息是否丢失 — NOT_RUN
X1 排除 端到端加密、音视频、附件存储、跨会话全局顺序、推送通知渠道 — —

需求卡里没有“高可用”“低延迟”这样的条目。不是这些目标不重要,而是在单库教学工程里它们没有可执行的检查。延迟目标需要真实负载、真实网络和分位数统计,本批实验均在一台主机的回环网络上运行,测得的毫秒数只能说明检测逻辑生效,不能当作服务目标的证据。可用性相关的唯一可检查内容是 U2,它被如实标为未知。

4. 状态表:确认分阶段

消息服务里最容易混淆的是“成功”的含义。方案中用一张状态表把各阶段分开,每个阶段对应一个独立的持久状态或回复:

阶段 观察对象 由谁推进 权威位置 说明
已受理 网关读到请求帧 网关 无(内存) 不对外承诺任何事
已持久 sent{seq, mid} 回复 服务端事务提交 events 行 R1 的成立点
已送达(某设备) device_cursors.delivered_seq ≥ seq 设备上报 ack device_cursors 设备把消息写入本地存储之后才上报
已读(某用户) read_positions.read_seq ≥ seq 用户在任一设备上报 read read_positions 用户级,跨设备共享
已撤回 events.recalled_by 非空 发送者 recall events 行 只影响服务端可返回的内容

这张表表达了两条不变量,后续章节都依赖它们:

  • 阶段之间没有自动推导。已读不意味着某台设备已送达(第 09 篇的 09-read-does-not-fake-delivery),已送达也不意味着已读。
  • 每个阶段只能由它的权威来源推进。网关无法替设备确认送达,也无法替用户确认已读。

5. 设计决策记录

方案的第三个组成部分是设计决策记录(ADR)。这种文档形式由 Michael Nygard 在 2011 年提出,包含上下文、决策和后果三个部分。本系列在其基础上增加两个字段:被拒绝的方案及拒绝理由,以及使决策失效的条件。后者特别重要,它告诉后来的读者什么时候应该重新评估。

下面是消息服务的两条决策记录。

ADR-01:会话序号在主库事务内分配

  • 上下文:R2 要求会话内序号连续;R1 要求重放请求返回原序号。
  • 决策:发送事务先以 SELECT ... FOR UPDATE 锁定会话行,读取 next_seq,在同一事务中检查幂等键、写入事件、推进 next_seq。
  • 被拒绝的方案:PostgreSQL 序列(nextval)。PostgreSQL 16 文档明确说明序列值在事务回滚时不会归还,因此序列不能用来产生无空洞的编号;第 04 篇的对照实验 04-control-sequence-gaps 观察到 10 次分配中有 3 次回滚,最大值为 10 而行数为 7。
  • 后果:同一会话的发送被串行化,单会话写入吞吐受一行锁的持有时间限制。
  • 失效条件:单个会话的写入率接近锁吞吐上限(需要测量),或者需要跨分片的会话。

ADR-02:投递失败通过补拉修复,而不是可靠推送

  • 上下文:R5 要求重连后补齐;推送路径会因为网关重启、慢接收端、通知丢失而中断。
  • 决策:推送只是加速手段。设备以连续游标调用 sync 补齐;网关可以丢弃推送(例如有界队列溢出时直接关闭连接),由补拉负责正确性。
  • 被拒绝的方案:网关为每台设备维护持久的待投递队列,直到收到确认。这样会把正确性绑在网关的本地状态上,网关故障后还要迁移队列。
  • 后果:补拉需要按 (conv_id, seq) 高效范围读取;重连潮会转化为数据库读压力(第 02 篇和第 10 篇)。
  • 失效条件:出现必须由服务端主动送达的离线场景(例如系统推送通道),那属于本批排除的通知渠道。

6. 用脚本检查需求卡

需求卡是结构化数据,就可以用程序检查。reqcheck.py 做两件事:

  1. 格式检查:每个条目有编号、类别、陈述、观察位置;类别只能是四种之一;编号不重复;承诺必须引用至少一个形如 im-NN 检查编号 的可执行检查,且不能只写 NOT_RUN;未知项不能使用“保证”“一定”“绝不”这类承诺性措辞。
  2. 证据解析:读取 evidence/im-*/result.json,把每条承诺引用的检查编号(支持 * 通配)映射到实际结果。任一检查缺失或失败,该承诺记为 OPEN。
1
2
3
4
5
6
7
8
9
10
# examples/design/reqcheck.py(节选)
if kind == "promise":
if not obs.get("where") or not obs.get("how"):
problems.append(f"{iid}: promise without observation point")
elif "NOT_RUN" in obs.get("how", ""):
problems.append(f"{iid}: promise whose only check is NOT_RUN")
if not CHECK_REF.search(obs.get("how", "")):
problems.append(f"{iid}: promise does not reference an executable check id")
if kind == "unknown" and re.search(r"保证|一定|绝不", item.get("text", "")):
problems.append(f"{iid}: unknown phrased as a guarantee")

正常用例

对真实需求卡运行,并解析 04–11 的实验结果:

1
2
3
4
5
6
7
$ python3 reqcheck.py requirements.json --evidence ../../evidence
items: {"promise": 7, "assumption": 3, "unknown": 2, "excluded": 1}
R1 PASS im-04/04-concurrent-replay-one-row:PASS(1) im-06/06-crash-*:PASS(3)
R2 PASS im-04/04-seq-gapless:PASS(1) im-11/11-purge-keeps-contiguity:PASS(1)
...
R7 PASS im-05/05-old-close-keeps-new-route:PASS(1) im-10/10-late-old-route-rejected:PASS(1)
exit=0

完整输出保存在 evidence/design-00/reqcheck-real.txt。这个结果的含义很窄:每条承诺引用的检查都存在并通过了。它不证明检查本身写得充分,那需要每篇文章说明检查的设计和对照实验。

边界与失败用例

bad-card.json 是一张故意写坏的需求卡:一条承诺没有观察位置,一条承诺只写 NOT_RUN,一条未知项写成了“主库切换一定不丢数据”,还有重复编号和非法类别。

1
2
3
4
5
6
7
8
9
$ python3 reqcheck.py bad-card.json
LINT B1: promise without observation point
LINT B1: promise does not reference an executable check id
LINT B2: promise whose only check is NOT_RUN
LINT B2: promise does not reference an executable check id
LINT B3: unknown phrased as a guarantee
LINT B3: duplicate id
LINT B3: bad kind 'maybe'
exit=1

还有一类失败要能被发现:引用的检查编号拼写错误或被删除。此时证据解析会输出 MISSING,承诺状态变为 OPEN,脚本以非零码退出。这能防止实验代码重构时悄悄丢掉某条承诺的证据。

脚本的局限要写清楚:措辞检查只是关键词匹配,无法判断语义;它也不能判断检查是否真的测到了承诺。脚本只是一道低成本的门槛,评审仍然需要人工完成。

7. 规模假设放在哪里

规模参数属于假设类条目,但它们驱动容量结论,所以单独放在第 02 篇的模型脚本里,需求卡只引用(A3)。这里先约定写法:

  • 每个参数带单位和来源标记(“教学假设”“测量值”“文档值”)。本批所有业务规模参数都是教学假设。
  • 结论以“在参数 X 下,瓶颈是 Y”的形式陈述,并附上改变一个参数后瓶颈如何移动的敏感性表。
  • 本地实验的数据规模(例如第 10 篇的 60 台设备)只用来验证机制,不外推到生产规模。

8. 验证分级

后续每个实验都要声明自己属于哪一级,证据不能越级使用:

级别 运行方式 能支持的结论 不能支持的结论
模型回放 纯程序模型,固定种子 算法与状态机在模型假设下的不变量 数据库隔离、网络、崩溃恢复
真实组件 真实 PostgreSQL、真实 TCP/WebSocket 约束、事务、协议行为 多机故障、跨可用区
本地多进程 多个网关 JVM + 数据库,同一主机 进程崩溃、路由收敛、重连流程 网络分区、真实延迟分布
生产验证 目标环境 容量、延迟、可用性 —

本批没有生产验证。第 03 篇的回放器属于模型级,第 04 篇属于真实组件级,第 05–11 篇是本地多进程级。

9. 本篇交付与限制

  • 交付:examples/design/requirements.json(需求卡)、reqcheck.py(检查脚本)、bad-card.json(负向样例)、本文中的状态表和两条决策记录。
  • 证据:evidence/design-00/reqcheck-real.txt、reqcheck-bad-card.txt,环境见 evidence/design-03/environment.txt。
  • 限制:需求卡中的承诺全部 PASS,只说明在本地多进程条件下引用的检查通过;U1、U2 仍为 NOT_RUN;脚本无法评判检查质量。

下一篇把承诺落到接口层:发送、查询、撤回三个操作的请求格式、错误码,以及重放、越权和过期操作分别返回什么。

参考资料