Day31-04|10.2.2 消息演化:在协议变化中保持协作和数据一致
Day31-04|10.2.2 消息演化:在协议变化中保持协作和数据一致
一、教材定位
在分布式系统、面向服务架构和事件驱动架构中,消息是组件之间传递事实、命令和状态变化的重要载体。随着业务变化,消息的字段、语义、生产者、消费者、路由、顺序和投递方式都会发生变化。消息演化如果缺少契约和兼容控制,容易造成消费失败、重复处理、数据不一致和故障扩散。
消息演化不同于简单修改一个数据结构。消息一旦发布,就可能被多个版本、多个团队、多个系统和离线任务保存或重放。生产者通常不知道所有消费者,消费者也可能不能同时升级。因此消息设计必须考虑向后兼容、向前兼容、版本共存、幂等、顺序、重试、死信、回放和治理。
二、消息的基本类型与演化影响
1. 命令消息
命令消息要求接收方执行某个动作,例如创建订单、扣减库存或发起退款。命令通常有明确目标和执行语义,演化时要关注权限、幂等、超时和重复执行。新增字段可以提供默认值,改变命令含义则可能破坏旧消费者。
2. 事件消息
事件消息描述已经发生的事实,例如订单已支付、库存已扣减或用户已注册。事件通常被多个消费者订阅,生产者不应假设所有消费者同步升级。事件字段要表达稳定事实,避免把某个内部实现细节直接暴露为长期契约。
3. 查询和响应消息
查询响应包含结果、状态、分页和错误信息。演化时要考虑客户端对未知字段、缺失字段、枚举新增值和错误码的处理。响应结构变化可能影响移动端、第三方和离线程序,通常需要版本化和兼容窗口。
三、消息契约的组成
消息契约不仅是字段列表,还包括:消息名称、版本、唯一标识、产生时间、来源、目标、业务关联号、字段类型、可选性、语义、单位、枚举、顺序、重复处理规则、错误处理和生命周期。
建议区分技术元数据和业务数据。技术元数据包括消息ID、追踪ID、生产者、版本和时间;业务数据包括订单、金额、状态和对象内容。元数据用于路由、审计和诊断,业务数据用于业务处理。两者混在一起会增加演化和治理难度。
字段设计要说明是否必填、默认值、是否允许为空、是否允许扩展、是否敏感以及何时废弃。金额要明确精度和币种,时间要明确时区和格式,状态要说明未知值和未来新增枚举的处理方式。
四、消息演化的兼容原则
1. 向后兼容
新消费者能够读取旧消息。常见做法是新字段可选、提供合理默认值、保留旧字段一段时间、不要改变原字段语义和单位。新消费者不能假设所有旧消息都包含新字段。
2. 向前兼容
旧消费者能够处理新消息。旧消费者应忽略未知字段,对新增枚举采用安全的未知分支,不能因消息中多了字段就失败。并不是所有旧客户端都天然向前兼容,必须通过契约测试验证。
3. 双向兼容和版本共存
在滚动发布和多团队协作中,新旧生产者和消费者会同时运行,最好保证双向兼容。若无法兼容,应通过主题版本、消息版本、适配器或转换层让不同版本共存,并设置迁移和下线期限。
4. 语义兼容
结构兼容不代表语义兼容。把金额单位从元改成分、把“已支付”改成“支付处理中”、把时间从本地时间改成 UTC,即使字段类型不变,也可能破坏消费者。语义变化应显式版本化并提供迁移说明。
五、消息版本策略
1. 同一主题扩展字段
适合新增可选字段且旧消费者能够忽略的场景。优点是主题和路由稳定,缺点是版本差异隐藏在字段中,消费者可能出现复杂分支。要配合字段弃用和兼容测试。
2. 消息头携带版本
通过元数据标记 schema 或业务版本,消费者根据版本选择解析和处理策略。适合多个版本短期共存,但要防止消费者分支无限增长。
3. 新旧主题并行
对语义变化较大或无法兼容的消息建立新主题,旧主题在迁移窗口内保留。生产者可以双发或由转换器转发,消费者逐步切换。必须处理重复、顺序、失败和最终下线。
4. 适配器和防腐层
在边界转换旧消息和新消息,保护内部模型不受外部演化污染。适配器适合外部合作伙伴和遗留系统,但转换规则要可观测、可测试,并设定退出条件。
六、消息顺序、重复与幂等
1. 顺序
顺序可能是全局顺序、分区内顺序、对象内顺序或无顺序。不要把“消息系统通常按顺序投递”写成无条件结论。系统要明确哪些业务需要顺序,以及如何通过分区键、序列号和状态检查处理乱序。
2. 重复
网络重试、消费者超时、确认丢失和生产者重发都会造成重复消息。消费者应使用消息ID、业务幂等键、去重表、唯一约束或状态机防止重复副作用。只在内存中去重无法应对重启和多实例。
3. 幂等
幂等是同一业务操作执行一次或多次,最终有效结果相同。幂等键要有生命周期和作用域,不能简单使用随机请求ID导致重试无法识别。涉及扣款、库存和发货等操作,还要记录处理状态、结果和失败原因。
七、消息可靠投递与故障处置
消息可靠性要同时考虑生产、传输、存储、消费和业务提交。生产成功但业务事务未提交,可能产生脏事件;业务提交成功但消息发送失败,可能造成状态不同步。可采用本地消息表、事务消息、变更数据捕获、Outbox、重试和补偿等方式协调。
重试要设置次数、间隔、退避和最大时长,并判断错误是否值得重试。对非幂等操作盲目重试会放大副作用。无法处理的消息应进入死信或隔离队列,保留原消息、错误原因和处理上下文,支持人工修复和安全重放。
消息演化还要考虑回放。回放可能重新触发通知、扣款或库存变更,因此应区分事实事件和可执行命令,提供只读重放、时间范围、过滤条件和副作用保护。
八、事件语义和数据所有权
事件应描述发布者拥有并确认的事实,消费者不能把事件当成共享数据库的替代品。事件字段应尽量稳定、语义明确,避免包含只对某个内部表有意义的字段。数据所有权变化时,要重新定义事件来源、发布时机、版本和一致性。
事件名称应使用业务事实而不是技术动作,例如“订单已支付”比“调用支付更新接口完成”更适合作为长期契约。事件时间、发生时间和发布时间可能不同,消费者要根据业务语义选择使用哪个时间。
九、案例分析:订单状态消息演化
某电商系统原有订单消息只有订单号和状态,消费者根据状态查询共享数据库。后来订单拆分为多个包裹,增加部分发货、部分退款和渠道订单。项目组直接把“已发货”改成“部分发货”,并删除了旧状态字段,导致老消费者无法识别消息,部分报表重复计算。
改造时应先明确事件语义和数据所有权,设计包含事件ID、订单ID、包裹ID、状态、序列号、发生时间、版本和来源的消息契约。旧状态保留兼容窗口,新状态通过可扩展枚举和明确映射提供;不能理解新状态的消费者进入安全未知分支并告警。对需要对象内顺序的消费者使用订单或包裹作为分区键,使用序列号检测乱序;对重复消息使用事件ID和业务状态机幂等处理。
发布采用新旧版本共存,先让消费者支持新消息,再逐步切换生产者,观察解析失败、未知状态、重复处理和对账差异。历史消息回放时禁止重新触发外部副作用,只用于重建报表或验证状态。迁移结束后下线旧字段和旧主题,并保留契约、监控和版本记录。
十、综合知识练习
题1
新增消息字段为什么通常应设计为可选?
参考答案:滚动发布时旧消费者可能仍在运行,可选字段并提供默认值能够让旧消费者继续处理消息,降低版本耦合。但还要验证语义、默认值和向前兼容,不能只依赖字段可选。
题2
消息重复和消息乱序分别应如何处理?
参考答案:重复通过消息ID、业务幂等键、去重记录、唯一约束和状态机抑制副作用;乱序通过分区键、序列号、版本检查、延迟处理或状态转换规则处理。二者都要结合业务语义,不能只依赖消息中间件。
题3
为什么消息回放可能造成新的业务事故?
参考答案:历史消息可能包含扣款、通知、库存等有副作用的操作,重新消费会重复执行。回放应区分事实重建和命令执行,提供只读模式、过滤、副作用保护、幂等和审计。
十一、论文写作素材
论文中可以这样描述消息演化:
“针对系统中消息结构变化导致的消费失败和重复处理问题,我建立了消息契约和版本治理机制,明确消息ID、业务关联号、版本、字段语义、时间和幂等规则。新增字段采用可选和默认值,语义变化通过版本或新主题隔离,滚动发布期间保证新旧生产者和消费者兼容。消费者使用业务幂等键、序列号和状态机处理重复与乱序,生产端通过本地消息表和补偿保证业务状态与消息发布的一致性,失败消息进入死信并支持安全重放。迁移过程中持续监控解析失败、积压、重复处理和对账差异,完成后再下线旧契约。”
十二、今日复盘
- 消息一旦发布,就可能被多个版本和多个系统长期依赖。
- 消息契约包含结构、语义、版本、顺序、幂等和错误处理规则。
- 兼容包括向后兼容、向前兼容、双向兼容和语义兼容。
- 重复、乱序、重试、死信和回放必须结合业务副作用设计。
- 事件应表达稳定业务事实,明确数据所有权和发布时机。
- 案例答题要写清版本策略、迁移顺序、监控指标和旧契约退出条件。