后端系统工程 00:一份技术方案怎样接受验证
后端系统工程 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. 承诺必须带观察位置
一条承诺至少要回答三个问题:
- 何时成立:承诺绑定在哪个可观察事件之后。“消息不丢”改写为“客户端收到
sent{seq}之后,该消息在其会话的保留期内始终可以通过sync读到”。 - 在哪里观察:用什么状态来判定。上例的观察位置是
events表中(conv_id, seq)的行,以及客户端收到的回复。 - 怎样判定:一个可执行的检查。上例对应第 06 篇的三个崩溃点实验和第 04 篇的并发重放实验。
观察位置要选在状态的权威来源上。“设备已收到”的权威来源是设备上报的送达游标(device_cursors),而不是网关日志里的“已写入套接字”:写入套接字只说明数据进了内核发送缓冲区,对端可能从未读取。第 05 篇的慢接收端实验会直接展示这种差异:网关向一个不读数据的客户端连续推送,发送方 120 条消息全部得到 sent,接收方一条也没有确认。
1 | |
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 做两件事:
- 格式检查:每个条目有编号、类别、陈述、观察位置;类别只能是四种之一;编号不重复;承诺必须引用至少一个形如
im-NN 检查编号的可执行检查,且不能只写 NOT_RUN;未知项不能使用“保证”“一定”“绝不”这类承诺性措辞。 - 证据解析:读取
evidence/im-*/result.json,把每条承诺引用的检查编号(支持*通配)映射到实际结果。任一检查缺失或失败,该承诺记为 OPEN。
1 | |
正常用例
对真实需求卡运行,并解析 04–11 的实验结果:
1 | |
完整输出保存在 evidence/design-00/reqcheck-real.txt。这个结果的含义很窄:每条承诺引用的检查都存在并通过了。它不证明检查本身写得充分,那需要每篇文章说明检查的设计和对照实验。
边界与失败用例
bad-card.json 是一张故意写坏的需求卡:一条承诺没有观察位置,一条承诺只写 NOT_RUN,一条未知项写成了“主库切换一定不丢数据”,还有重复编号和非法类别。
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;脚本无法评判检查质量。
下一篇把承诺落到接口层:发送、查询、撤回三个操作的请求格式、错误码,以及重放、越权和过期操作分别返回什么。
参考资料
- Michael Nygard, “Documenting Architecture Decisions”, Cognitect blog, 2011-11-15. https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions (ADR 的上下文/决策/后果结构)
- PostgreSQL 16 文档,9.17 Sequence Manipulation Functions. https://www.postgresql.org/docs/16/functions-sequence.html (序列值不回收,不能产生无空洞序列)
- RFC 2119 / RFC 8174,需求级别关键词。https://www.rfc-editor.org/rfc/rfc8174 (本系列中文“必须/不得”的使用参照)
