25 · 综合设计:交付活动报名系统
目标:整合前 24 课的模型、实验与取舍,交付一份能被具体故障案例检验的系统设计。
现在把承诺连起来
你已经拥有一份逐课扩展的 design.md 和独立实验。这一课不要求搭建生产集群,也不要求从零实现数据库。最终成果是完整的设计说明,以及能复现关键反例的实验集合。它应让另一位开发者知道:哪个动作有权确认报名,失败时什么保持不变,哪些读模型可能落后,如何重建且不让已删除数据复活。
项目条件沿用第 1–2 课:日均 10 万次浏览、1 万次报名;报名测试峰值每秒 200 次,40% 指向一个活动;列表测试峰值每秒 1000 次。它们是待验证的负载模型。目标是报名 p99 在 800 毫秒内成功或明确拒绝,浏览允许最多 5 秒视图滞后,日报每五分钟更新。实际容量仍需在选定数据库和环境中压测。
把已有实验重新组成验收路径
在你保存过实验的 ddia-labs 目录执行,不需要新依赖:
uv run --python 3.12 --no-project transactions.py
uv run --python 3.12 --no-project event_delivery.py
uv run --python 3.12 --no-project failure_models.py
uv run --python 3.12 --no-project dataflow.py
uv run --python 3.12 --no-project data_responsibility.py
运行前先写下预测:事务中断是否留下半份报名?确认丢失是否让统计多一条?旧任务是否覆盖新报告?取消重放是否生效?删除用户会不会回来?
预期输出分别与第 14、21、17、23、24 课相同。关键观测是:报名数 1、剩余 0;重投后计数 1、待发 0;Fencing 后保留 fresh-report;有效报名数 1 且重放相等;安全重建仅保留 u2。若之前做了练习修改,先恢复对应课的原始完整代码再比较这些输出。
这些脚本验证各自边界,没有运行一个真实端到端网络系统。最终设计要把这些边界连接起来,而不是把五个局部成功误认作生产验收通过。
一份可工作的参考架构
客户端(稳定 request_id)
↓
报名服务 → 单个事实数据库 / 活动分片
[名额 + 报名 + 请求结果 + Outbox] 同一事务
↓ 提交后转发,可重复
事件日志 / 代理
↙ ↘
用户报名视图 统计与搜索视图
去重与状态同事务 版本化重建、核对
↘ ↙
查询入口
删除控制记录 → 事实清理、各派生系统、备份恢复后的重建过滤
这里的核心选择是把硬约束留在同一事实边界,把可延迟的展示移到异步视图。客户端只有在提交成功后看到“已确认”;超时返回结果未知,可用同一请求 ID 查询和重试。搜索中的“剩余名额”只是展示,最终以报名事务的条件更新为准。
活动主键为 activity_id,报名有 registration_id、activity_id、user_id 与业务状态;请求记录保存 request_id、输入指纹和结果。一个用户在一个活动只能有效报名一次时,需要业务唯一约束及明确的取消后再次报名规则,不能借用请求键代替。原始请求结果与报名当前状态是不同含义:取消后查询同一历史请求仍可返回原确认凭证,同时提供当前已取消状态。
活动包含当前标题和地址,报名可保存当时快照。列表使用 (city, starts_at) 等与查询匹配的索引。初始阶段采用一个逻辑事务数据库;为满足单个节点故障后不丢已确认写的目标,候选部署使用三个独立故障域中的投票副本,只有按数据库共识提交规则持久确认的事务才能返回成功。选型时核实确认策略、读语义和恢复行为;本地 SQLite 实验没有验证这套部署。只有测量显示必须分片时,才按活动分片,用户列表另作派生。热点活动仍需限流和短事务,哈希不能消除单键竞争。
报名、取消和对应 Outbox 写入同一事务。每次状态变化有稳定事件 ID、报名内单调版本和完整目标状态。转发允许重复,消费者原子保存处理身份与效果。事件保留期应覆盖最大恢复与重建周期,历史不足时改用一致快照加位置之后的日志,不能假装仍可从头重放。
普通浏览可读视图;报名结果查询走权威路径或带最低位置的读。名额确认需要的持久性取决于数据库何时确认复制;设计不能一面承诺已确认写不丢,一面允许无条件提升落后异步副本。失去法定写入能力时停止确认报名,展示查询按独立降级规则服务。
日报按固定事件时间和报名时城市计算,有效状态由版本派生。迟到事件更新旧窗口并产生修正;超过热状态保留期进入补算。新版本视图从确定输入重建、追赶到切换点,与事实源按同一边界核对,再切流量;旧版本保留一个明确的回滚窗口。删除控制必须在两套视图生效,不能为回滚留下未经处理的用户数据。
引导演练:一条报名经历三个故障
request-q1的事务已经提交,响应丢失。查事实,应有一条报名和对应 Outbox。客户端保持原请求 ID,不能创建新意图。- 转发成功,发送端未记确认。接收端去重表和视图已经一起提交。再次发送后视图不增加第二次。
- 新版日报任务接任,旧任务恢复。存储侧按代际令牌和发布边界阻止旧结果覆盖,旧任务不能只凭自己还在运行继续发布。
每一步都写下事实、派生和用户显示的状态。若写不出某个中间状态,回到第 10、16 课补齐状态机;不要使用“失败就重试”来跳过结果未知。
独立任务:在上述架构中加入“用户取消报名”,要求重复取消只释放一次名额,并且取消事件晚于重新报名事件到达时不会覆盖较新状态。写出事务、事件字段和三个验收条件。
完整的设计级参考解答
取消请求携带自己的稳定请求 ID 和目标 registration_id,在同一事务里验证用户身份并读取当前状态。只有 active → cancelled 的成功条件更新才能释放名额;状态已经 cancelled 的重试返回已取消,不再释放。事务同时增加该报名版本并写入唯一 Outbox 事件,提交后通知。
重新报名创建新的 registration_id,不会复用旧报名的版本空间。旧取消事件即使晚到,也只更新旧报名。派生模型按报名 ID 保存最高版本与状态,再按活动/用户汇总有效报名;如产品选择复用同一报名 ID,就必须继续增加版本,消费者忽略较低版本完整状态。两个方案不能混用。
验收一:取消请求重试三次,名额只增加 1,只有一个有效取消状态转换。验收二:事务在释放名额之后、写 Outbox 之前失败,名额和报名状态均回滚,未产生已提交取消事件。验收三:先处理新报名,再处理旧取消,最终新报名仍 active,旧报名 cancelled,按活动计数与事实源一致。
若“同用户同活动不能同时有两份有效报名”是硬约束,报名与取消路径还必须共享对应事务内唯一约束或守卫;仅有消费者版本检查不能保护源端业务约束。
用反例检查最终文档
| 场景 | 文档必须给出的答案 | 已有实验 |
|---|---|---|
| 最后一个名额被同时争抢 | 原子条件更新与失败结果,不超卖 | 第 14–15 课 |
| 提交后响应丢失 | 同键查询/重试、输入冲突检查 | 第 10、14 课 |
| 从落后副本读报名 | 等待、回源或明确不可用 | 第 11、18 课 |
| 主节点被隔离 | 哪一方有资格确认写入 | 第 19 课 |
| 转发/消费中断 | 去重与效果原子提交,位置可恢复 | 第 21 课 |
| 报名事件迟到 | 事件时间、修正与补算策略 | 第 22 课 |
| 重建期间继续写 | 固定起点、追赶边界、切换与回滚 | 第 20、23 课 |
| 用户删除后恢复备份 | 隔离恢复、重新应用删除、核对 | 第 24 课 |
完成标准是每行都有具体机制、预期状态和证据边界。实验断言通过证明你能复现这些局部机制;部署选型、负载容量、备份恢复时间和真正的网络故障行为仍要在目标环境验证。将待验证条件写入设计,就能让下一步实现有明确起点。