架构取舍案例 · 75–100 分钟 · 先会读 HTTP 请求、数据库行与基本队列
超时后再点一次,会生成两份报告吗?
财务点“生成月报”,两秒后页面报超时。后台还在计算,他又点了一次。现在队列里有两份相同工作,费用记了两次。有人建议把 HTTP 超时改成一分钟,有人建议上消息队列。两个建议都没有回答:第一次到底有没有被受理?第二次是不是同一个业务意图?这节课先追踪这两个问题,再选择组件。
以下时间和容量是完整的教学输入,不是产品压测。你要画出请求和状态,手算一个突发,处理三个失败,再为另一种负载改方案。交付的是可以被反例检验的决定,而不是一张服务 logo 图。
先把“马上有结果”拆成两种承诺
| 条件 | 明确数值或边界 |
|---|---|
| 用户交互 | 2 秒内知道是否受理;接受后可离开页面,5 分钟内取得报告 |
| 计算 | 单个 worker 串行计算,一份报告固定 20 秒;本题忽略调度和下载开销 |
| 负载 | 平时每分钟 1 个新意图;某次同时提交 10 个不同意图 |
| 存储 | 受理接口最迟 80 ms 完成账本写入;事务成功后才算接受 |
| 排队容量 | 1 个运行中任务 + 最多 8 个等待任务;FIFO,无优先级插队 |
| 业务边界 | 同一用户、同一幂等键、同一参数只能创建一个任务;允许重新发起不同意图 |
| 计费边界 | 本题只记录内部费用账本;真实支付、发邮件等外部副作用不在此保证内 |
2 秒约束针对受理,5 分钟约束针对完成。把它们都写成“响应时间”会导向错误方案。纯同步接口若等 20 秒才答复,即使最终报告正确,也不满足受理体验;把超时调长并没有改变这一点。异步接口可以尽快返回任务 ID,却必须提供后续状态和失败结果,不能把“进队列”叫成“已完成”。
HTTP 202 可表达请求已接受而尚未完成;本例采用它作为接口约定,不代表协议承诺任务一定成功。用户稍后用任务 ID 查询。对于耗时小于交互预算的简单查询,同步返回结果仍然合适,不需要为了“架构先进”加队列。
走过一次响应丢失,而不是只画成功箭头
幂等键由客户端为一个业务意图生成;服务端的唯一约束使用 (user_id, key),同时保存规范化参数的摘要。同一个键重来时,参数相同返回已有任务;参数不同则拒绝冲突,不能悄悄复用旧任务。键的保留期、过期后语义需要成为接口合同,本例假定 24 小时内保留,超过后要求用户查询历史并明确发起新意图。
| 时刻 | 事件 | 账本与客户端分别知道什么 |
|---|---|---|
| 0.00 s | 用户 U 提交 K17 | 尚未证明受理 |
| 0.05 s | 事务创建 J42 与待处理记录 | 持久化已成功;返回包丢失 |
| 0.10 s | worker 开始 J42 | J42=running |
| 2.00 s | 客户端等不到响应 | 客户端只知道超时;不能推出服务端失败 |
| 2.10 s | 用户用原键 K17 重试 | 返回 J42 的当前状态,不再创建任务 |
| 20.10 s | worker 提交结果和内部费用 | J42=succeeded;内部费用一次 |
| 20.20 s | 确认消息丢失,引发再次处理 | 检查已完成 J42,返回旧结果;不再次记费用 |
“请求已经超时”属于客户端观察;“事务是否提交”属于服务端事实。服务端若先提交任务再独立发布消息,进程在两者之间崩溃,就可能留下永远不执行的任务。小系统可以直接轮询数据库中的待处理任务;更大的系统可在创建任务的同一事务里写待发布记录,再由投递器重试发布。后者通常叫事务 outbox。重复投递仍然可能发生,所以消费者仍需按任务 ID 判定,而不是相信队列会替业务自动去重。
队列解决等待,不创造容量
一个 worker 每 20 秒完成一份,即每分钟 3 份。平时每分钟 1 个意图,在本题稳定耗时模型下,工作占用比例为 1/3;这不是实测 CPU 利用率。突发 10 个不同意图时,一个立即运行,八个等待,最后一个被容量限制拒绝。已接纳的第九个在 160 秒开始、180 秒完成,仍在 300 秒预算内。拒绝应明确可重试与退避方式,不能返回“受理成功”再丢任务。
如果只把等待容量改为 20,十个都能接纳,第十个 200 秒完成;但源源不断每分钟 4 个的长期到达率仍超过每分钟 3 个的服务能力,任何有限队列都会满。加 worker 能否线性提升还取决于数据库、下游和资源,不能从本题串行模型直接推广。这里的架构决定同时包含接纳、等待时限、执行容量和用户可见状态。
小检查:队列容量 8 和总容量 8 是一回事吗?
不是。本题最多 8 个等待,加 1 个运行,共 9 个接受。若约定为“系统总共最多 8 个”,同样突发则拒绝 2 个;第八个完成于 160 秒。必须先说明分母与状态,再写数字。
你来为三次扰动做决定
- 沿上表逐行画客户端观察与服务端状态。然后把第二次请求改成新键 K18,写出为什么会形成第二个任务,以及 UI 如何保留原意图的键。
- 原键 K17 再次提交,但月份改为 2026-10。说明为什么不能返回旧报告或覆盖参数;给出错误响应与用户下一步。
- worker 在“提交任务”之后、“发布消息”之前崩溃。比较数据库轮询与 outbox 两种恢复方式,写出各自的简单性、延迟与运营代价;不要假定只能有一种正确答案。
- 将突发改为 16 个意图,等待容量仍为 8,计算接受、拒绝、最长完成时间。再把长期到达改为每分钟 4 个,解释增大队列不能解决的事。
参考与评分边界
K18 是新业务意图,在本例中可以生成另一个任务;前端需把请求键和任务 ID 与操作关联,超时后保留,明确“重新生成”才换键。相同 K17 却换月份应返回冲突(本例用 409)并保留 J42;用户确认新意图后用新键。轮询直接以持久任务表为真相,简单但需索引和控制查询成本;outbox 需要投递进度与清理,但任务和待投递记录可一起提交。16 个突发接受 9、拒绝 7,最后一份 180 秒完成。长期到达 4/min 会以平均 1/min 积压,扩队列只是推迟拒绝。通过需说明至少一个失败分支、参数绑定、容量边界及一种替代方案;只写“用了 MQ 所以可靠”不通过。
陌生负载:这里仍然需要队列吗?
另一个接口只读已经生成的报告摘要,给定端到端 P99=120 ms,用户预算 300 ms,无写副作用;峰值容量经过本次教学假设验证可满足。这时请给一条同步方案和一项仍需观察的指标,再解释为何不把“生成报告”的架构原样照搬。
参考
同步查询可直接满足给定预算,队列会增加状态与等待复杂度。继续观察尾延迟、错误率、超时与缓存版本;若负载改变,需要重新测容量。120 ms 是题设,不是你已在真实环境测出的成绩。
完成稿应只有一页架构决定、两条时序(正常/响应丢失)、状态表、突发算例与三项未知。回到原版集成模式继续学习补偿与契约,再用可靠性章节定义恢复目标。查阅 HTTP 202 定义时,找出它没有保证什么;别把接口码当成业务完成证据。