后端系统工程 01:API 契约与业务不变量

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

第 00 篇把消息服务的承诺写成了需求卡 R1–R7。需求卡说明系统保证什么,接口契约说明调用方在每一种回复下可以相信什么。两者之间有一个容易被忽略的落差:服务端内部即使做到了“同一请求只写一行”,如果回复格式不能让客户端区分“首次成功”“重复请求的成功”“请求被拒绝且不应重试”“结果未知、应当重试”,客户端仍然会做出错误决定,例如对一条已经提交的消息弹出“发送失败”。

本篇定义消息服务的请求格式、回复格式和错误码,重点处理三类异常请求:重放、越权和过期。所有判定都落到可执行检查上,检查编号来自第 04、06、11 篇的实验。

1. 契约要回答的问题

调用方每发出一次请求,最终处在以下四种状态之一:

  1. 收到成功回复:操作已生效,回复中的标识(seq、mid)可以长期使用。
  2. 收到终态错误:操作没有生效,并且用同样的参数重试也不会生效。客户端应把这件事告诉用户或修正参数。
  3. 收到可重试错误:操作没有生效,或者是否生效未知;服务端保证用同一个请求键重试是安全的。
  4. 没有收到回复(超时、连接断开):操作是否生效未知。

第 3 和第 4 种状态在客户端看来处理方式相同:保留请求键,稍后原样重发。契约的核心任务是让第 4 种状态可以安全地退化为“重试一次”,这要求服务端对重复请求给出与首次相同的结果。用一句可检查的话表述:对同一个请求键,无论重发多少次、以何种并发方式到达,系统中最多产生一个事件,所有成功回复携带同一个 seq 与 mid。 这就是需求卡 R1 在接口层的形式。

2. 接口层不变量

编号 不变量 观察位置 检查
C1 一个请求键 (conv, sender_user, cmid) 至多对应一个事件;重复请求返回原 seq、mid,dup=true events 部分唯一索引 + 回复 04-concurrent-replay-one-row、06-lost-reply-retry
C2 成功回复只在事务提交之后发出 崩溃点前后的行数 06-crash-*
C3 同一请求键配不同内容时拒绝,不覆盖原消息 回复码 + 原行内容 04-idempotency-mismatch
C4 对非成员,会话“不存在”和“不可见”返回同一错误码 回复码 04-non-member-rejected、04-unknown-conv-same-error
C5 原消息被保留策略清理后,迟到的重发不产生新消息 idem_tombstones + 回复码 11-retention-purge、11-control-no-tombstone
C6 错误码分为终态与可重试两类,客户端只对可重试错误和无回复情况重发 Client.isTerminal 06-crash-*
C7 读接口返回的游标只在客户端应用整页之后才可保存,且不会让客户端跳过可见事件 sync 回复的 next_after 07-paging-under-writes(第 07 篇展开)

C2 与 C1 共同决定了“没有收到回复”时的处理:回复没到,可能是提交前崩溃(没有行),也可能是提交后崩溃(已有行)。C2 保证不会出现“回复说成功但没有行”,C1 保证重发不会出现“两行”。两者缺一,客户端都无法正确处理超时。

3. 规模与输入边界

接口层的边界值是契约的一部分,因为越界请求必须得到确定的拒绝,而不是在存储层失败后返回含糊的错误。

字段 约束 越界时
cmid 1–64 个字符,由客户端生成,建议使用 UUID 文本 INVALID_ARGUMENT
body 1–4000 个字符(Store.MAX_BODY_CHARS) INVALID_ARGUMENT
sync.limit ≥1,服务端截断到 200(Store.MAX_PAGE) 小于 1 时 INVALID_ARGUMENT
sync.after ≥0,且不大于会话当前最大序号 负数 INVALID_ARGUMENT;超过 CURSOR_AHEAD
WebSocket 消息 单条不超过 64 KiB(Gateway.MAX_FRAME) 关闭连接(第 05 篇)

cmid 的作用域是“同一用户在同一会话”,不区分设备。这意味着同一用户的两台设备如果生成了相同的 cmid,会被当作同一请求。设计上这是有意的:用户在手机上发出一条消息、随后在电脑上重发同一草稿的场景不在范围内,而“同一设备重启后重发”必须被识别。代价是客户端必须用足够随机的方式生成 cmid,计数器式的 cmid(例如每台设备从 1 开始)会在设备之间碰撞并触发 IDEMPOTENCY_MISMATCH。

4. 请求与回复格式

传输层是 WebSocket 文本帧(第 05 篇),每帧一个 JSON 对象。请求带类型字段 t 和可选的请求编号 rid,回复原样带回 rid,客户端据此把回复与请求对应起来。rid 只用于匹配,不参与幂等判定;同一条消息的两次重发可以使用不同的 rid,但必须使用相同的 cmid。

请求 t 必填字段 成功回复 t 回复字段 详见
send conv、cmid、body sent conv、seq、mid、dup、epoch 04、06
sync conv、after,可选 limit、epoch page events、next_after、has_more、head、min_seq、trimmed、epoch 07
ack conv、seq acked delivered 06、09
read conv、seq read_ok read 09
recall conv、target、cmid recalled seq、dup 11
convs — convs 每个会话的 head、delivered、read、unread 09
fetch conv、seq fetched ev 08、11
sub — subscribed — 05

服务端主动下发的帧不带 rid:event(携带完整事件的推送)、hint(只告诉客户端某会话的最新序号,客户端自行 sync)、resync(要求客户端对所有会话补拉)、goaway(要求客户端换一个网关重连)。

错误回复统一为:

1
{"t":"error","code":"NOT_MEMBER","msg":"conversation not visible","rid":"u-ana-d1-7"}

msg 是给人看的诊断文本,客户端逻辑只能依据 code。

下面是一次正常发送的帧序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
图 1-1  send 请求的正常路径与回复时机
客户端 网关(Api.dispatch) PostgreSQL
| {"t":"send","rid":"r1", | |
| "conv":"c-ab","cmid":"k1", | |
| "body":"..."} | |
|----------------------------->| BEGIN |
| | SELECT ... FOR UPDATE (会话行) |
| | 查请求键 / 墓碑 / 成员 |
| | INSERT events; UPDATE next_seq |
| | pg_notify; COMMIT ------------->|
| |<----------------------- 提交成功 |
| {"t":"sent","rid":"r1", | |
| "seq":3,"mid":"…","dup":false} |
|<-----------------------------| |

5. 错误码与重试语义

错误码 类别 含义 客户端动作
INVALID_ARGUMENT 终态 参数格式或范围错误 修正参数,不重试
UNAUTHENTICATED 终态 设备凭据无效 重新登录
NOT_MEMBER 终态 会话不存在,或调用者在相关序号上不可见 从本地会话列表移除或提示
IDEMPOTENCY_MISMATCH 终态 同一 cmid 配了不同内容 视为客户端缺陷,换新 cmid 前需确认用户意图
ORIGINAL_EXPIRED 终态 原请求已被执行,但原消息已被保留策略清理 标记为“已发送、已过期”,不得用新 cmid 自动重发
NOT_SENDER 终态 只有发送者可以撤回 提示
ALREADY_RECALLED 终态 目标已被另一个撤回请求撤回 本地标记为已撤回
RECALL_WINDOW_EXPIRED 终态 超过撤回时限(以数据库时钟判断) 提示
NOT_FOUND 终态 fetch/recall 的目标不存在或已清理 刷新本地状态
CURSOR_AHEAD 终态 客户端游标超过服务端最大序号 丢弃本地游标,从可信位置重建(第 07 篇)
EPOCH_CHANGED 终态 会话纪元变化,旧游标失效 重建该会话本地状态(第 07 篇)
STALE_INSTALL 终态 设备已重装,旧安装的确认无效 停止使用旧安装状态(第 09 篇)
UNAVAILABLE 可重试 数据库不可用、连接池耗尽、注入故障 退避后用同一请求键重发

UNAVAILABLE 必须是可重试的,而且重试必须使用同一请求键。原因在于它覆盖了一种结果未知的情形:事务的 COMMIT 已在服务端完成,但数据库连接在返回提交结果之前断开,JDBC 抛出异常,网关只能回复 UNAVAILABLE。此时消息已经存在,如果客户端换一个新 cmid 重发,就会产生第二条消息;用原 cmid 重发,则会得到 dup=true 和原 seq。客户端代码中的判定只有一行:

1
2
3
4
5
// examples/im/src/im/Client.java
static boolean isTerminal(Map<String, Object> r) {
Object code = r.get("code");
return "error".equals(r.get("t")) && code != null && !"UNAVAILABLE".equals(code);
}

终态错误和成功回复都会把请求从本地持久发件箱中移除;可重试错误与超时都保留,等待重连后 resendPending 按原 cmid 重发。

6. 三类异常请求的判定

6.1 重放

重放指同一请求键再次到达。它可能来自客户端超时重发、网关重启后客户端重发,也可能来自同一请求被并发提交了两次(例如用户连点)。判定依据是请求键与请求指纹:

  • 请求键:(conv_id, sender_user, client_msg_id),由部分唯一索引 events_idempotency 约束。
  • 请求指纹:sha256(conv_id || '\0' || body),存放在 events.request_hash。撤回请求的指纹是 sha256(conv_id || '\0recall:' || target_seq)。
到达的请求 已有记录 回复
键相同、指纹相同 有 sent,原 seq、原 mid,dup=true
键相同、指纹不同 有 IDEMPOTENCY_MISMATCH,原行不变
键相同 无记录,但有墓碑 ORIGINAL_EXPIRED
键相同 无记录、无墓碑 按新请求处理

指纹不同时为什么不能“以最新内容为准”?因为键相同说明客户端认为这是同一次操作。内容不同只可能来自客户端缺陷(例如复用了 cmid 生成器的状态)或请求被篡改。静默覆盖会让其他成员已经看到的内容发生变化,而第 07 篇的同步协议假定已提交事件不可变(撤回是追加新事件,见第 11 篇)。

IETF HTTPAPI 工作组的草案 draft-ietf-httpapi-idempotency-key-header-07 对同一问题给出了 HTTP 层的写法:同一 Idempotency-Key 配不同的请求负载时返回 422,同一键的首个请求仍在处理时再次到达返回 409。该草案已于 2026-04-18 过期,本系列只借用其中的“键加指纹”的判定结构,不把它当作标准依据。本服务与草案有一处差异:并发到达的相同请求不会收到 409,而是在会话行锁上排队,后到者等前者提交后读到已有记录,返回 dup=true。之所以选择排队,是因为同一会话的写入本来就在会话行锁上串行(第 04 篇),排队不引入额外等待;而 409 会把一个本可以直接回答的问题推给客户端,让它再重试一次。

6.2 越权

越权分两种:调用者根本不是会话成员;调用者是成员,但无权执行特定操作。

对第一种,契约要求“会话不存在”和“调用者不是成员”返回同一个错误码 NOT_MEMBER。如果两者返回不同的码(例如 404 与 403),任何已登录用户都可以通过枚举会话标识,判断某个会话是否存在。检查 04-unknown-conv-same-error 用一个从未创建过的会话标识发送消息,断言得到 NOT_MEMBER。

“是否成员”按序号判定,而不是按当前状态判定。消息服务用成员区间 [from_seq, to_seq] 描述成员资格(第 04、08 篇)。读取接口检查的是“事件的序号是否落在调用者的某个区间内”,因此已退群用户仍可读取自己在群期间的历史,但看不到退群之后的事件;写入接口检查的是“当前是否存在开放区间”。

对第二种,撤回只能由发送者执行,返回 NOT_SENDER。这时调用者已经是成员,能看到目标消息,返回一个更具体的错误码不会泄露额外信息。

检查顺序本身是契约的一部分。Store.send 的顺序是:锁会话行 → 查请求键 → 查墓碑 → 查开放成员区间 → 插入。请求键检查放在成员检查之前,会产生一个需要明确说明的结果:用户在群内发送消息并已提交,但回复丢失;随后用户被移出群;客户端重连后按原 cmid 重发。按照当前顺序,重发得到 dup=true 和原 seq,而不是 NOT_MEMBER。这是有意为之:消息确实已经提交并对其他成员可见,回复 NOT_MEMBER 会让客户端把一条已经存在的消息显示为“发送失败”,违反了“回复不少报”的要求。这条回复泄露的只是调用者自己那条消息的序号,没有越权读取其他内容。

6.3 过期

过期有两种来源:操作时限已过(撤回窗口),以及原数据被保留策略清理。

撤回窗口用数据库时钟判断:clock_timestamp() > created_at + recall_window_ms。如果改用网关的本地时钟,多个网关之间的时钟偏差会让同一请求在不同网关上得到不同结果;把判断放在持有行锁的事务里,还能保证“判断未过期”与“写入撤回事件”之间不会被其他撤回请求插入。

保留策略清理会删除旧事件(第 11 篇)。如果只删除事件而不保留请求键,一个在清理之后才到达的重发请求会因为查不到原记录而被当作新请求,产生一条内容相同、序号更大的新消息。为此清理过程在删除事件的同一事务中把请求键写入 idem_tombstones,后到的重发得到 ORIGINAL_EXPIRED。客户端收到此码时应当把本地草稿标记为“已发送、已过期”,不得自动换新 cmid 重发,否则就重新制造了墓碑想要阻止的重复。

7. 方案比较:请求键放在哪里

方案 做法 能防住的重复 不能防住的重复 结论
A. 服务端生成标识,不接受客户端键 每次请求分配新 mid 无 任何超时重发 不满足 C1
B. 内存去重缓存,带过期时间 网关或缓存服务记录最近的 cmid 短时间内、命中同一缓存的重发 缓存过期后的重发;缓存与数据库之间的崩溃窗口 只能作为优化
C. 数据库唯一约束,键为全局 cmid UNIQUE(client_msg_id) 全部 — 要求客户端生成全局唯一值,跨用户碰撞会误拒
D. 数据库唯一约束,键为 (conv, sender_user, cmid) 部分唯一索引 + 指纹 全部,直到消息被清理 清理后(由墓碑补上) 采用

方案 B 的问题不在于缓存会丢数据,而在于它和消息行不在同一个事务里。写缓存在提交之前,提交失败会留下一个指向不存在消息的键;写缓存在提交之后,两者之间的崩溃会留下一条没有键的消息。方案 D 把键和消息放在同一行、同一事务中,消除了这个窗口。键的存续期与消息相同,再由墓碑覆盖清理后的时段。

8. 关键实现

请求分发只做格式转换,不做业务判断,所有判定都在 Store 的单个事务内完成:

1
2
3
4
5
6
7
8
9
// examples/im/src/im/Api.java(节选)
case "send": {
Store.SendResult r = gw.store.send(who, Json.str(req, "conv"),
Json.str(req, "cmid"), Json.str(req, "body"));
Faults.at("api.after_send_commit"); // 第 06 篇:提交后、回复前的崩溃点
Map<String, Object> m = r.toJson();
m.put("t", "sent");
return m;
}

Store.send 中与契约直接相关的部分:

1
2
3
4
5
6
7
8
// examples/im/src/im/Store.java(节选)
SELECT next_seq, epoch FROM conversations WHERE conv_id=? FOR UPDATE -- 不存在 → NOT_MEMBER
SELECT seq, message_id, request_hash FROM events
WHERE conv_id=? AND sender_user=? AND client_msg_id=?
-- 有记录且 hash 相同 → 返回原结果 dup=true;hash 不同 → IDEMPOTENCY_MISMATCH
SELECT 1 FROM idem_tombstones WHERE ... -- 有 → ORIGINAL_EXPIRED
SELECT 1 FROM membership_periods WHERE ... AND to_seq IS NULL -- 无 → NOT_MEMBER
INSERT INTO events(...) VALUES (...); UPDATE conversations SET next_seq=?

查请求键在锁住会话行之后进行。即使没有行锁,部分唯一索引也会拒绝第二次插入;行锁的作用是让后到的请求读到前者已提交的行,从而返回 dup=true,而不是在插入时撞上唯一约束、再经过一轮异常处理。第 04 篇的负对照会展示去掉唯一约束、只靠“先查再插”时的结果。

9. 契约测试矩阵

类型 用例 预期 检查编号与结果
正常 成员首次发送 sent,dup=false,seq 为会话下一个序号 04-normal-history PASS(seq=3)
正常 回复丢失后用同一 cmid 重发 原 seq,dup=true,表中 1 行 06-lost-reply-retry PASS
边界 非成员发送 NOT_MEMBER,会话最大序号不变 04-non-member-rejected PASS(head=3)
边界 向不存在的会话发送 NOT_MEMBER(与非成员相同) 04-unknown-conv-same-error PASS
边界 同一 cmid、不同正文 IDEMPOTENCY_MISMATCH 04-idempotency-mismatch PASS
边界 非发送者撤回 / 重复撤回 / 超时撤回 NOT_SENDER / ALREADY_RECALLED / RECALL_WINDOW_EXPIRED 11-recall-rejections PASS
边界 相同撤回请求重发 原撤回事件序号,dup=true 11-recall-idempotent PASS
失败 32 个相同请求并发到达 1 行、1 个 mid、恰好 1 个 dup=false 04-concurrent-replay-one-row PASS
失败 提交前后三个崩溃点 崩溃后重发都只得到 1 行;只有提交后崩溃的重发为 dup=true 06-crash-* PASS(3 项)
失败 清理后迟到的重发 ORIGINAL_EXPIRED,不产生新行 11-retention-purge PASS
负对照 清理时不写墓碑 迟到的重发产生新消息(seq=11) 11-control-no-tombstone PASS(对照现象出现)

结果来自 evidence/lab-all.stdout.txt 的同一次全量运行,属于本地多进程级别:真实 PostgreSQL 16 与真实 WebSocket 连接,运行在同一台主机上。负对照的 PASS 表示“预期的错误现象确实出现”,用来证明对应检查有能力发现问题。

10. 故障与恢复

故障 客户端看到的 服务端状态 恢复方式
网关在插入前崩溃 连接断开,无回复 无行 重连后原 cmid 重发,得到新 seq,dup=false
网关在提交前崩溃 连接断开,无回复 事务回滚,无行 同上
网关在提交后、回复前崩溃 连接断开,无回复 有行 重发得到原 seq,dup=true
回复帧丢失 超时 有行 同上
数据库提交结果未返回 UNAVAILABLE 可能有行 原 cmid 重发,结果由 C1 决定
客户端进程重启 — 任意 从本地持久发件箱读出 cmid 重发(假设 A2)

最后一行依赖假设 A2:客户端在第一次发送之前就把 cmid 和正文写入本地持久存储。如果客户端只在内存中保存 cmid,进程重启后会生成新键,C1 对此无能为力。这是契约中调用方一侧的义务,必须写进客户端接入文档,而不能只由服务端保证。

11. 限制

  • 指纹只覆盖会话与正文,不覆盖未来可能增加的附件、引用等字段;增加字段时必须同步扩展指纹,否则不同内容会被判为重放。
  • NOT_MEMBER 合并了“不存在”与“不可见”,代价是排查问题时不能仅凭错误码区分两者,需要依赖服务端日志。
  • 映射到 HTTP 时可以使用:INVALID_ARGUMENT→400,UNAUTHENTICATED→401,NOT_MEMBER→404,NOT_SENDER→403,IDEMPOTENCY_MISMATCH→422,CURSOR_AHEAD/EPOCH_CHANGED/ALREADY_RECALLED/RECALL_WINDOW_EXPIRED→409,UNAVAILABLE→503 并附带 Retry-After。本批实现只走 WebSocket,这张映射表没有实现,状态为 NOT_RUN。

下一篇为这套接口建立负载模型:每秒有多少次发送、推送和确认,它们分别落到数据库写、读、网关帧和出口带宽的哪一项预算上。

参考资料