消息平台设计:发送意图、渠道副作用与结果对账
平台不能凭自己的一条去重记录保证第三方只发送一次——本设计把承诺拆成"平台意图幂等、内部任务可恢复、供应商副作用按其能力处理、结果可查询和对账"四段。业务幂等与Outbox的协议细节由业务幂等与跨资源事务权威讲授;多租户配额见租户隔离与数据生命周期。
教学设计,消息中间件基线为 Kafka 3.7.1,关系数据语义为 MySQL 8.0。数值是算例假设;"成功"的口径必须在接口与运营报表中一致。
需求、成功语义与边界
一个多租户平台支持站内信、短信、邮件与推送,提供模板、收件人、计划时间、优先级和渠道回执。题设日发送1亿条,峰值3万条/秒;通道配额、成本与到达时效不同。先区分平台接收、排队、供应商接收、设备/邮箱投递与用户阅读,不能用一个sent布尔值覆盖全部状态。
业务需要可靠接受任务,但外部通道可能已发送后丢失响应。平台不能凭自己的一条去重记录保证第三方只发送一次。设计承诺应拆成:平台意图幂等、内部任务可恢复、供应商副作用按其能力处理、结果可查询和对账。
逻辑消息、投递任务与尝试
logical_message 表示用户的一个发送意图,主键与租户业务键映射;delivery_task 是某收件人和渠道的一次逻辑投递;delivery_attempt 保存每次调用的结果、耗时、供应商请求号和代际。逻辑任务与尝试分开,才能在“不再新建业务投递”情况下重试传输。
logical_message
├─ recipient A / SMS → attempt 1 UNKNOWN → query → ACCEPTED
└─ recipient B / EMAIL → attempt 1 RATE_LIMITED → attempt 2 ACCEPTED状态至少包含SCHEDULED、READY、IN_FLIGHT、ACCEPTED、DELIVERED、FAILED、UNKNOWN、EXPIRED。取消在READY之前可做原子裁决;进入外部通道后不能保证撤回,此时返回“取消意图已记录/投递不可撤销”,而不是谎报已取消。模板保存不可变版本与渲染摘要,重试不应使用后来修改的模板。
CREATE TABLE delivery_task (
task_id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL,
biz_id VARCHAR(64) NOT NULL, recipient_hash BINARY(32) NOT NULL,
channel VARCHAR(16) NOT NULL, state VARCHAR(24) NOT NULL,
request_hash BINARY(32) NOT NULL, attempt INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(6) NOT NULL, expires_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_intent(tenant_id,biz_id,recipient_hash,channel),
KEY ix_due(state,next_retry_at,task_id)
);隐私收件地址可另行加密保存,普通索引和日志使用受控摘要;摘要不能替代可访问控制的数据治理。
接受任务与Outbox
接入服务在一个事务中保存投递任务与Outbox,事务成功后返回任务ID。relay发布唤醒事件,事件可能重复;worker以task_id读取事实并原子领取。接口返回202表示接受,任务查询提供后续状态,参见HTTP 202语义。
relay在“已发Kafka、未标记”时退出会重发;消费者必须允许重放。若消费者把任务状态写入另一个数据库,应先提交该状态再提交offset,这允许重复而避免已提交offset但状态丢失;若两者顺序反转,进程崩溃可能跳过任务。Kafka事务不能把第三方短信接口纳入同一个原子提交范围。
从KafkaProducer 3.7.1的send/doSend追到RecordAccumulator可以看到本地排队与网络发送的分离。拿到Future只获得一次异步发送句柄,不能据此写“短信已发送”。Broker确认也不是用户收到通知。
渠道调用与UNKNOWN
调用前持久化稳定的供应商幂等键。渠道支持同键查询与去重时,超时后先查询;只支持查询时,用供应商受理号恢复;两者皆无时,平台只能选择可能重复的重试或可能漏发的终止,并明确高价值/低价值通知不同策略。
领取任务并增加attempt
→ 检查租户与渠道配额、TTL、退订状态
→ 持久化此次外部请求标识
→ 在事务外调用
→ 明确接收:ACCEPTED;明确拒绝:按类别处理
→ 不可确认:UNKNOWN,进入查询/人工对账数据库中的attempt守卫拒绝旧worker回写,却无法消除已发生的外部发送。更换渠道前必须确认旧渠道状态:短信超时后立即转邮件可能是产品允许的多渠道通知,也可能是不可接受的重复触达,不能把它当纯技术重试。
调度、隔离与容量
平均速率约1157条/秒,峰值3万约为平均26倍。假设一条意图及索引/尝试记录的逻辑新增1KiB,日原始新增约95.4GiB;三副本、索引、归档与失败尝试会放大。正文只给容量模型,不假设压缩比。
渠道峰值限额若为2万/秒,流入3万持续5分钟将积压300万条。峰后新到达5000/秒,渠道处理2万/秒,则理想清理需200秒;如果重试占配额或渠道降额,时间更长。必须将TTL纳入队列:过期营销消息不应继续抢占验证码通道。
采用租户、优先级、渠道三级公平调度;为验证码预留资源,限制大租户批量发送。延时任务用按时间索引扫描或专门调度存储,避免把长延时任务反复丢回热队列。分区键选择决定顺序域:需要同用户同业务顺序时按该域分区,不能以全局排序压扁并行度。
故障矩阵
| 故障点 | 不安全后果 | 可恢复设计 |
|---|---|---|
| 业务提交后未发队列 | 意图永远不执行 | Outbox扫描 |
| worker领取后退出 | 永久IN_FLIGHT | 租约过期、attempt守卫和查询 |
| 供应商接受后超时 | 重复通知 | UNKNOWN、同键查询/重试策略 |
| 回执先于发送结果落库 | 状态倒退 | 回执幂等,按状态偏序合并 |
| 退订与发送并发 | 违反触达规则 | 明确不可撤销点、调用前检查、审计 |
| 渠道返回大批429 | 重试风暴 | 通道限速、全局预算与抖动 |
回执处理需要验签和供应商状态映射。DELIVERED之后到达ACCEPTED不能覆盖;FAILED和DELIVERED冲突时按供应商事实和时间语义处理,不能只按消息到达时间last-write-wins。
对账、可观测性与成本
按task_id串联接入、Outbox、发送、回执和供应商账单。分别计算接受成功率、在期限内被供应商接受比例、到达率与UNKNOWN年龄;这些分母不同,不能相互替代。限制recipient标签进入指标,避免产生亿级时序基数。
渠道计费以已受理、送达或提交尝试为准需要合同确认。预算模型包含正常发送、UNKNOWN重复费用、失败重试、跨渠道兜底、存储与值守。每成功触达成本必须用同一关闭窗口的成本与触达数,不能拿全月费用除以一天成功量。
实验与连续追问
用假供应商记录稳定请求键,并提供“接受后断开”“返回429”“重复/乱序回执”开关。断言平台重试不新建逻辑任务;渠道无幂等时,报告外部调用可能重复,不能用mock成功掩盖。提交失败时间线、最终状态和收费次数。
意图—尝试—回执状态机
本页把消息设计拆成逻辑消息、投递任务与尝试三层,评审时还需要一张可执行状态机:意图什么时候变得不可撤销、尝试如何领取、回执如何合并。三层分开的意义在于:重试不新建业务意图,回执也不能把状态倒退。
状态机骨架:任务从 READY 出发,领取后进入不可撤销点(已发出外部调用);明确接受变 ACCEPTED,不可确认变 UNKNOWN,明确拒绝回 RETRY_WAIT。UNKNOWN 不能被当作失败直接重试——要先按供应商受理号查询;确认无效果后回到 RETRY_WAIT 再发起第二次尝试。示例中第一次尝试 UNKNOWN、查询确认后第二次 ACCEPTED,最终两条事件分别记 attempt 1 与 2。
回执合并按状态偏序,不按到达时间。DELIVERED 高于 ACCEPTED:迟到的 ACCEPTED 不能把已送达改回已接受,示例中第三次回执返回 IGNORED_STALE。这条规则正是本页故障矩阵"回执先于发送结果落库"的具体实现。
不可撤销点要写进对外答复:READY 之前可以原子取消;进入 IN_FLIGHT 之后平台只能说"取消意图已记录、投递不可撤销",不能谎报已取消。把不可撤销点画在状态机上,比在文档里写一句"尽量支持取消"可靠得多。
执行 python3 labs/system-design/design_points_models.py message_state_machine:预期输出两次尝试、回执合并 ["APPLIED","APPLIED","IGNORED_STALE"]、最终 DELIVERED、不可撤销状态 ["IN_FLIGHT","ACCEPTED","DELIVERED"]。脚本是确定性状态机,不连供应商;真实回执验签与断连未运行。
两个合理选择:A 迟到回执按 last-write-wins 合并,简单但会把送达改回接受;B 按状态偏序合并并保留冲突审计。本例选 B,并规定回执映射表按供应商版本固化,映射变更走评审。
渠道单位经济与恢复责任
本页算了 300 万积压的清理时间,但没有算每成功触达的真实成本,也没有把 UNKNOWN 的重复费用与恢复责任人写进设计。单位经济与恢复责任表是消息平台从"能发"到"敢承诺"的分界线。
单位成本:每天 1 亿条、供应商每成功 0.03 元、UNKNOWN 占比 0.2%(20 万条/天)、重试 5%(500 万条/天),可计费量 1 亿+20 万+500 万=1.052 亿,供应商成本约 315.6 万元/天;加运维 8,000、存储 2,000,总成本约 316.6 万元/天,单均约 0.0317 元。若目标单均 0.03 元,over_target=true——差距来自 UNKNOWN 重复与重试,不是供应商单价。
把 UNKNOWN 重复费用藏起来会低估成本:如果只按 1 亿条计,单均约 0.0301 元,看起来刚好达标。现实里 UNKNOWN 是真实支出,且平台无法凭一条去重记录让第三方只发一次(本页开头已说明)。单位经济必须把它计入分子,并在报表里单列。
恢复责任表四列:故障类型、平台责任(到哪一步)、业务责任(终态与外部效果)、对账方式。示例:供应商接受后超时,平台责任到"记录 UNKNOWN 并可查询",业务责任是"定义可重复触达的上限",对账按供应商账单与平台尝试记录比对。责任不写清,事故时平台与业务会互相等待。
执行 python3 labs/system-design/design_points_models.py channel_unit_cost:预期输出可计费 1.052 亿、供应商 315.6 万、总 316.6 万、单均 0.0317、over_target=true。脚本只做算术,供应商单价与占比为教学输入。
两个合理选择:A 只报供应商单价,简单但掩盖重复与重试成本;B 报含 UNKNOWN 与重试的单位经济加责任表。本例选 B,并规定渠道替换决策必须同时比较单位成本与恢复责任,不能只比单价。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
架构师进阶
当租户要求各自独立SLA时,对比共享资源加配额与独立池的成本、利用率和故障隔离。跨区灾备要明确任务主权、去重域、供应商幂等保留期和回执路由;把队列复制过去不等于获得外部发送的跨区恰好一次。