1 消息与事件驱动要回答什么
用户在订单页面点击提交,希望尽快知道订单是否受理。订单服务还可能需要发送通知、更新分析数据、触发备货。如果 API 等这些工作全部完成才返回,任何依赖变慢都会延长请求;如果直接启动后台线程后就返回,进程重启又可能让工作消失。消息与事件驱动要回答的核心问题是:请求结束以后,尚未完成的工作由谁保存、由谁接手、怎样知道它最后完成了。
消息是交给接收方处理的数据载体,可以表达“请生成账单”这样的工作请求。事件通常表达已经发生的事实,例如“订单已受理”。事件发布者说明事实,订阅者根据自己的职责作出反应。命名也会影响理解:“发送通知”带有执行意图,“订单已受理”允许通知与分析分别响应。实际系统可能都用同一种消息服务传输,业务含义仍应说清楚。
这里的异步意味着调用方与后续工作不必在同一次请求里等到彼此完成。它不会让工作凭空减少,而是把等待放到队列、日志或订阅中,把失败恢复交给明确的协议。页面可以先显示“订单已受理,通知处理中”,再通过查询接口刷新状态。如果业务要求付款结果确定后才能确认下单,就必须保留这个业务前提,不能靠改成异步来掩盖尚未确定的结果。
在云上,这条链路跨越应用计算、消息服务、数据库、身份权限、网络和可观测平台。API 的身份需要发布权限,消费者身份需要读取与确认权限;网络必须允许相关端点通信;消息与业务数据都有保留时间和访问边界。托管服务替你承担部分基础设施运维,应用仍需定义消息内容、重复处理规则以及失败后的责任归属。

本课沿用订单受理后发送通知的例子。订单写入数据库是核心业务提交,通知是后续工作。我们关心的不是某个产品按钮,而是 3 个可迁移问题:事件是否可靠地交给消息服务,收到事件后怎样安全处理,处理跟不上或持续失败时怎样恢复。先把这 3 个问题讲清楚,再选择产品和容量,方案才有可检验的含义。
2 队列、日志与订阅模型边界
工作队列适合把任务交给一组工作者竞争处理。例如 4 个通知实例共同处理待发送任务,正常情况下每个任务只需其中 1 个实例完成。这里的“只需 1 个”是业务意图,不能直接推导出绝不重复投递。任务领取后可能暂时不可见,直到确认、超时或释放;消费确认之后是否删除、保留多久,应查看具体服务的约定。
日志模型把记录按分区追加保存,消费者维护读取位置。通知组与分析组可以分别读取同一批事件,进度互不替代。某组中的消费者协作分担分区,组外的另一组保有自己的位置。保留期间内通常可以调整位置重新读取,但重读是否安全取决于应用:再次统计可能需要去重,再次发短信可能造成打扰。记录仍在不等于副作用可以任意重放。
发布订阅表达的是“哪些接收方对这类消息感兴趣”。发布者把事件发到主题,通知订阅与分析订阅分别接收。订阅可以由队列承接,也可以建立在日志上,因此队列、日志和发布订阅并非互斥的 3 种盒子。选型时要问订阅是否持久、离线期间是否保存、各订阅能否独立重试,以及某个订阅失败会不会影响其他订阅。

假如通知和分析都连接同一个竞争队列,分析实例取走并确认事件后,通知实例可能再也收不到它。这不是“随机丢消息”,而是接收模型与业务意图不匹配。若两类工作都必须发生,就应给它们独立的接收进度,例如独立订阅;每个订阅内部再用多个实例分担工作。先确定独立责任,再确定并行数量。
消息保留也不是永久备份。队列中的任务可能因过期而被移除,日志中的旧记录可能随保留窗口结束而不可重读。假设允许某个分析任务离线 24 小时,就要让消息保留、恢复速度和告警提前量共同覆盖这段故障,而不能只填“保留 24 小时”。恢复期间新事件仍在进入,旧积压也需要时间处理。
事件应包含稳定标识、事件类型、结构版本、发生时间和业务对象标识。是否放入完整订单详情,要权衡独立处理能力、数据敏感性与载荷大小。仅放订单编号会让消费者依赖查询接口,而且查询看到的可能是当前状态;需要解释历史事实时,应保留足够的当时信息。消息服务通常不该成为随意复制个人信息的通道。
3 投递语义、幂等与顺序
投递语义描述失败情况下消息可能被交付多少次,以及系统对处理作出什么保证。至多一次允许消息没有完成就不再尝试,适合某些可容忍缺失的数据;至少一次通过重试提高最终收到的机会,同时要求应用接受重复;“恰好一次”必须说明边界,是某段管道中的记录写入,还是最终业务副作用。不能仅凭产品名称推断跨数据库、短信接口和业务流程都只执行 1 次。
最容易混淆的是接收确认与消费确认。发布者得到消息服务的接收确认,表示服务按约定接管了消息;消费者发出消费确认,表示自己认为处理已经完成。两次确认之间可能相隔很久。API 返回成功时,应准确表示订单已受理或工作已登记,不能据此声称通知已经送达。通知服务接收成功与用户实际阅读也有不同含义。
至少一次投递常见的重复来源是结果不确定:消费者已经完成写入,但确认包丢失;或者消费者完成处理后崩溃,来不及确认。消息服务只知道“没有收到确认”,合理选择是再次投递。如果先确认再处理,确认之后进程崩溃,就可能永久缺少业务结果。因此确认时机与持久化结果必须一起设计。
幂等意味着针对同一个业务意图重复执行,最终效果与执行 1 次相同。消费端可以用“消费者职责 + event_id”作为处理记录的唯一键,在同一数据库事务中写入处理记录并更新本地业务状态。两个实例同时处理重复事件时,数据库唯一约束负责裁决,单纯先查询再插入并不足够。事务失败时两部分都回滚,后续重试仍有机会完成。
这个办法有明确边界。如果先把事件记为完成,再调用短信服务,崩溃可能导致通知缺失;如果先调用短信服务,再记完成,崩溃可能造成重复发送。面对外部副作用,需要下游支持稳定的幂等键,或用可查询的发送记录、后续核对和补偿流程处理不确定状态。不要把本地数据库的事务保证扩大成整个网络链路的保证。
顺序同样要限定范围。很多业务只要求同一订单的事件有序,不要求所有订单共享全局顺序。可以用 order_id 选择分区,使同一订单进入同一条有序通道,但仍需确认消费者处理和提交进度的方式是否保留这个顺序。并发执行、失败重试和人工重放都可能改变完成顺序。若状态更新依赖先后关系,可在事件中加入业务版本,明确如何处理旧版本、重复版本和缺失版本。
幂等记录也需要生命周期。消息保留 7 天、隔离消息可能在第 10 天重放,而去重记录第 2 天就删除,重复保护便会失效。应按允许的最大重放窗口与业务风险决定保留策略。这里不展开分布式事务或日志协议内部实现;入门阶段先学会把保证范围、确认时机和重复后果写成可验证的约定。
4 动手:用时序图解释至少一次投递
先准备 4 个角色:发布者、消息服务、消费者、业务数据库。用 E17 表示事件标识,用 O42 表示订单标识。画图时把“消息已发出”“服务已接收”“业务已提交”“消费已确认”分开标注。它们是不同时间点,任何相邻时间点之间都可能遇到进程退出、网络中断或请求超时。
完整入口还要回答订单提交后事件如何出现。最直接的做法是先写订单,再发布事件,但这两步之间可能崩溃,留下有订单、无事件的状态。示例采用待发布记录:在同一个数据库事务内写入订单和事件记录,再由发布任务读取并发送。这常被称为事务性发件箱模式;它让已提交的订单有可恢复的发布意图,发布任务本身仍可能重复发送。
现在只看消费阶段。沿着图从上到下读:E17 被投递,消费者写入业务结果和处理记录,数据库确认提交;随后消费确认在网络中丢失。站在消费者一侧,业务已经成功;站在消息服务一侧,是否成功仍然未知。双方都没有撒谎,只是掌握的证据不同。至少一次投递正是在这种信息缺口下允许重试。

在纸上或编辑器里复制这张时序图,依次把故障点放在数据库提交前、提交后与确认后。提交前失败通常需要重新处理;提交后但确认前失败需要识别重复;确认已经被服务接受后,正常情况下不再靠这一轮重投恢复。消息服务的具体租约、可见性或读取位置机制可以不同,但都应根据实际确认协议解释结果。
再做 1 个检查:让同一个 E17 被交给 2 个消费者实例,观察唯一约束是否阻止重复修改。这里应验证业务结果,而不只是日志里有没有“已消费”。例如通知任务表最终只有 1 个对应工作项,处理记录只有 1 个有效完成记录;若真正调用了外部服务,还要核对下游幂等键和调用结果。投递 2 次、业务完成 1 次是预期证据。
最后给时序图补上客户端响应。订单 API 在本地事务提交后可返回“已受理”及订单查询地址;若发布任务尚未发送,页面仍不能显示通知完成。这个小实验的交付物是带故障位置和恢复动作的时序图,不需要先搭建复杂集群。只有当每个箭头的成功含义都说得清楚,运行日志才有可解释的参照。
5 积压、分区与消费者并行取舍
积压是尚未完成的工作量。短时积压可以吸收流量波动,让请求入口不必跟随每次峰值立即扩容;持续增长的积压则表示输入与处理能力不平衡。只看消息数量容易误判:100 条耗时任务可能比 10000 条轻量任务更危险。最老未完成消息的年龄能直接提示等待时间,但仍要结合业务时限,判断通知是否已经失去价值。
用一个简化估算理解容量。新消息每秒进入 60 条,消费者每秒完成 100 条,则每秒有 40 条能力用于消化旧积压;6000 条积压大约需要 150 秒清空。这里假设每条成本相近、没有重试且输入稳定。如果完成能力降到每秒 60 条,旧积压就不会消失;低于 60 条时还会继续增加。把正常流量刚好处理完,不等于具备故障恢复能力。
分区决定很多日志模型的并行边界。假设 4 个分区在某个消费者组内各由 1 个实例处理,增加到 8 个实例未必获得 8 份工作。即使分区足够,数据库连接、外部接口配额或网络出口也可能先达到上限。扩容应根据瓶颈证据进行,而不是把实例数当作吞吐量的直接替身。
拖动交互中的实例数,先观察分区不足时的闲置,再观察共享数据库达到容量上限后的平台期。随后提高输入速率,比较“能跟上当前流量”和“能在时限内清理积压”的差别。交互使用平均处理能力,真实系统还会受到长尾耗时、重试和热点键影响,因此估算应留余量,并用接近实际载荷的测试校准。
分区键也会制造偏斜。按商户编号分区时,某个大商户可能占据大部分事件,其他分区很空却帮不上忙。改成订单编号通常分散得更细,但业务若要求商户级严格顺序,就不能直接换键而忽略语义。扩大并行与保留顺序之间的取舍,应围绕真正需要有序的业务对象展开。

至少观察发布失败率、输入速率、完成速率、最老未完成年龄、重试次数与隔离数量,并保留按订阅或分区定位的能力。消息服务显示已交付,并不等于业务已完成;消费者确认很快,也可能只是把任务塞进了不可靠的本地内存。应以持久化业务结果作为最终参照,让告警直接关联“通知超过约定时限”的影响。
6 常见坑:把消息当 RPC 返回值
RPC 常被用来表达“调用并等待结果”,消息常被用来表达“先接收工作,稍后完成”。如果订单 API 发出消息后一直等待消费者回复,直到通知完成才向客户端返回,就重新引入了同步等待链。系统当然可以设计请求与应答消息,但它需要关联标识、超时、回复保存和迟到结果处理,不能因为换了传输方式就声称已经解耦。
客户端超时尤其容易造成误解。用户没有收到响应,可能是订单未写入,也可能是订单已经写入、响应丢失。用户再次提交时,订单入口需要稳定的请求幂等标识;消费端的 event_id 去重解决的是另一段重复,不能自动防止生成 2 笔订单。应分别定义用户请求、事件发布、消费处理这几个层次的标识和重复行为。
另一个坑是把成功响应写得过满。返回“通知发送成功”会让用户依赖尚未发生的结果。更准确的状态是已受理、处理中、已完成或失败待处理,并说明用户从哪里查询。状态变化也要有持久化来源,避免页面依赖某台消费者内存中的标记。对于需要用户采取动作的失败,产品界面应给出明确后续路径。
重试不能替代错误分类。网络短暂超时可能适合退避后重试;缺少必填字段、未知事件版本或永久撤销的权限,重复几千次通常不会自行恢复。持续立即重试还会消耗资源并放大依赖压力。应设置尝试次数或时间窗口,记录失败原因,并把超出范围的事件交给隔离与人工处理流程。退避可以加入少量随机变化,减少大量消费者同时重试。
还要防止消息内容成为隐形耦合。消费者若依赖发布者数据库的内部列名,发布者改表就可能让异步链路停摆。事件契约应表达稳定业务含义,允许合理的字段扩展,并明确必填项和版本兼容规则。日志中保留事件标识和错误类别即可支撑多数定位,敏感内容要按需要脱敏,不能为了排错把完整订单长期复制到所有平台。
7 案例:毒消息阻塞分区消费
周末上线后,通知订阅的最老未完成年龄持续上升,但总吞吐只小幅下降。进一步按分区观察,发现 1 个分区的读取位置不再推进,其他分区仍正常。消费者日志反复出现相同 event_id 和解析错误。所谓毒消息,就是某条消息由于内容、版本或处理程序缺陷反复失败,按照当前策略继续尝试也无法完成。
这次事件中,发布者把某个金额字段改为带单位的字符串,旧消费者仍按数字解析。该分区要求顺序处理,失败记录位于后续记录之前,因此消费者不断重试当前位置。订单数据库运行正常,增加消费者实例也无法绕过这条顺序约束。故障看起来像容量不足,实际证据却指向契约兼容性问题。

处理时先保存事件标识、结构版本、错误类型、分区与位置,并核对影响范围。对确定无法靠等待恢复的解析错误,按既定策略把原始记录及失败上下文写入隔离位置,确认隔离已经持久化,再推进原消费进度。写隔离和推进进度之间也可能失败,所以隔离写入需要可去重,不能先跳过再指望事后补存。
隔离通常被称为死信处理,但名字不代表问题已经解决。如果后续事件依赖这条事件的状态更新,跳过它可能让业务顺序出现缺口。对独立通知任务可以在记录缺口后继续,对严格依赖前置状态的流程则可能需要暂停相关业务键,等待修复或核对。应在设计阶段明确允许哪种处置,值班时才不必临时猜测业务含义。
团队先停止继续扩大不兼容发布,再部署能识别新旧字段的消费者,使用隔离副本验证修复。随后按受控速率重放失败事件,并沿用原有业务幂等标识,避免恢复动作制造重复通知。重放要核对当前订单状态:已经取消的订单可能需要不同通知,而非机械执行过时动作。原事件的事实与此刻应做的动作可以不同。
恢复完成的证据包括停滞分区继续推进、最老未完成年龄回落、隔离事件逐条有处理结论,以及受影响订单的业务结果核对。队列变空只能说明记录离开了某个位置,无法独立证明用户收到正确通知。事件契约检查、未知版本处置和重放责任人应进入后续发布流程,避免同类故障再次依赖人工猜测。
8 把投递语义写进异步方案
异步方案应先写业务承诺。例如“订单受理后登记通知工作,在约定时间内完成发送或形成可追踪失败记录”。这句话比“使用消息队列提高可靠性”更容易评审,因为它指出入口成功含义、完成期限和失败去向。时间目标应由业务决定,再反推保留时间、容量余量和告警阈值。
接着描述事件契约与发布路径。写明事件类型、稳定标识、业务键、结构版本及敏感数据范围,说明数据库提交与待发布记录如何保持一致。发布任务收到确认前后的崩溃怎样恢复,发送结果不确定时是否会重发,都要能在时序图里找到位置。待发布记录的年龄也应可观测,否则消息服务指标正常时,工作可能还滞留在入口数据库。
消费部分应写清至少一次投递、完成确认时机、幂等作用域与有效窗口,并单独列出外部副作用的约束。顺序应具体到订单、商户或其他业务键,说明哪些处理允许并行,遇到缺失版本如何处置。若没有强顺序需要,也应明确记录,避免团队为了模糊的“有序”承担全局串行成本。
容量与恢复部分要用同一套单位连接起来:每秒进入多少条、平均完成多少条、积压多久必须清空、依赖能承受多少并发。然后给出重试上限、隔离条件、保留期限和重放负责人。访问控制也落在具体职责上,发布身份不自动拥有消费权限,恢复人员的重放权限应有范围和审计记录。
验证计划至少覆盖重复投递、消费者提交后崩溃、暂时不可用、毒消息与消费者离线恢复。每种情况都要预先写出期望业务结果和观测信号。这样测试看到重复日志时,能判断它是允许的恢复过程还是重复副作用;看到积压下降时,也能继续核对业务是否完成。方案的价值在于让实现、测试和运维共享同一份成功定义。
9 从消息与事件驱动走向CI/CD(C39)
事件驱动让服务可以独立运行,也让发布期间的新旧版本共存成为日常情况。生产者更新之后,旧消费者可能仍在处理昨天积压的事件;消费者回滚之后,又可能读到新版本已经发布的记录。因此一次发布的兼容范围不仅覆盖当前在线请求,还覆盖保留窗口内的历史事件和隔离事件。

常见的稳妥顺序是先部署能够接受新旧事件的消费者,观察运行情况,再逐步让生产者发出新结构。新增可选字段通常比删除必填字段容易兼容,但是否安全仍取决于解析方式与业务规则。更改金额含义或状态含义,即使字段类型没有变化,也可能破坏契约,所以检查不能只停留在格式校验。
流水线可以把本课时序图中的故障点转成检查:同一事件处理 2 次是否产生重复结果,未知版本是否进入可追踪的失败路径,旧样本是否仍能处理。发布观察则关注积压年龄、失败类型和业务完成率,而不仅是进程存活。消费者成功启动并不保证它理解队列里现存的数据。
下一课 CI/CD(C39)会继续讨论如何把变更构建、验证和交付到环境中。本课给它留下的输入是事件契约、兼容样本、故障场景、观测门槛与恢复预案。回滚应用版本也不会自动撤回已经发布的事件或已经产生的副作用;发布方案必须说明回滚后由谁继续处理这些记录,以及何时采用向前修复。
放进方案里
为订单通知写异步方案时,先记录业务承诺:订单数据库提交成功后,页面显示已受理,并通过查询接口展示通知状态;正常负载下,通知应在 60 秒内完成,超时进入告警与跟进流程。这里的完成指下游通知服务接受发送请求,实际送达另有状态,不能混为同一个指标。
订单与待发布记录在同一数据库事务中提交,由发布任务发送 OrderAccepted 事件。事件包含稳定标识、订单编号、结构版本与必要业务字段。发布确认只代表消息服务接管记录。通知使用独立订阅,分析使用另一个订阅,各自维护进度;消费者按至少一次投递设计,并在持久化处理结果后确认。
消费端以通知职责和事件标识建立唯一约束,把本地业务修改与处理记录放在同一事务。调用外部通知服务时传递稳定幂等键,并保留可核对的发送状态。顺序范围限定为同一订单;暂时故障按退避策略有限重试,永久解析错误进入隔离位置。隔离成功后是否推进进度,按后续事件是否依赖该状态决定。
容量估算以每秒 60 条输入、每秒 100 条有效完成能力为起点,预留每秒 40 条清理积压的余量,并在实际载荷下验证。监控覆盖待发布记录年龄、订阅积压年龄、完成速率、重试与隔离数量。保留期限覆盖预计最长故障和恢复时间,幂等记录覆盖允许的重放窗口。
发布前演练确认丢失、消费者崩溃和毒消息,核对业务结果。方案指定隔离事件负责人、重放权限与审计要求;重放保留原幂等标识并控制速率。事件变更先验证兼容消费者,再逐步发布生产者,以积压年龄和业务完成率作为继续发布的依据。
10 小结:下一步CI/CD(C39)
消息与事件驱动把后续工作变成可保存、可接手、可恢复的流程。队列强调任务分担,日志强调保留与读取位置,订阅强调独立接收责任;至少一次投递要求应用处理重复,顺序和幂等都必须注明业务范围。
检查自己能否解释:业务成功而确认丢失为何会重投;增加实例为何未必消除积压;毒消息被隔离后为何仍要核对业务结果。能用时序图和观测信号回答这些问题,就具备了进入 C39 的基础。接下来把事件契约、兼容性验证和恢复门槛纳入 CI/CD,让每次变更都能按同样的标准交付。