题干与适用场景
orders.events 是一条使用 Avro 编码的订单事件流,Schema 由 Registry 管理。生产服务滚动发布时,新旧 writer 会同时存在;12 个独立消费方的发布节奏不同,其中最慢的团队每周只能发布一次。流量平均每秒 8000 条、峰值每秒 25000 条,消息保留 90 天,消费者可以从任意保留位置重放。
现有 OrderPlaced 事件把订单金额放在必填字段 totalcents 中,所有旧消费者都把它解释为美元分。业务现在要支持多币种,并希望改成 amountminor 与 currency。请设计从提案、兼容性检查、发布排序、数据验证到旧版本下线的完整流程,并处理以下故障:
- 新 writer 已上线,但一个旧消费者尚未升级;
- 新消费者从 60 天前开始重放旧消息;
- Registry 认为结构兼容,但字段含义已经改变;
- 双流迁移期间,同一业务事件同时出现在 v1 与 v2;
- 发布后发现新字段填充错误,需要停止或回退。
吞吐、消费者数量、发布周期和保留时间都是面试假设,不是任何平台的公开性能上限。本题聚焦事件契约、演进协议和消费数据正确性,因此归入 data。Kafka、Avro 与某个 Registry 只是具体场景;高质量答案应先解释 writer/reader 关系,再说明工具如何执行这份契约。
面试官考察点
第一,候选人是否能把“向后兼容”说清楚。兼容性必须指明谁读谁写:新 reader 能否读取旧 writer 的数据,与旧 reader 能否读取新 writer 的数据是两个方向。只背 BACKWARD、FORWARD 和 FULL 名称,无法指导发布顺序。
第二,候选人是否区分结构兼容与语义兼容。Registry 可以检查字段、类型、默认值和格式规则,却不知道 total_cents 原本隐含美元。添加一个可空 currency 可能通过结构检查,但让旧消费者把欧元分继续当美元,数据仍然错误。
第三,候选人能否把滚动发布、独立消费者和历史重放放进同一时间轴。只验证最新两个 Schema 版本,会漏掉 90 天历史和长期未升级的消费者;只做编译期检查,也无法证明生产者真的正确填充新字段。
第四,候选人是否知道破坏性变更需要新的兼容边界。字段删除、含义改变、主键改变或枚举行为改变,通常不能靠一次原地 Schema 更新安全完成。强回答会说明何时保留旧字段、何时新建 major 事件流、如何桥接以及如何避免双重消费。
最后看发布是否可操作。每一阶段都应有进入条件、观察指标、中止动作和可回滚状态。消费者清单、Schema 版本、消息填充率、反序列化错误、隔离量、消费 lag 与新旧结果差异都应能被查询,而不是靠群里询问“大家升级了吗”。
回答前需要澄清的问题
- 使用什么编码格式和 Registry? Avro、Protobuf 与 JSON Schema 的兼容细节不同。本文用 Avro 推导主流程,再指出 Protobuf 字段编号等格式专属约束。
- 生产者和消费者能否独立发布? 本题存在滚动 writer 和 12 个独立消费者,不能假设原子升级。需要知道最慢发布周期、回滚版本和是否存在无人维护的消费方。
- 历史数据会保留和重放多久? 新 reader 要读取 90 天内所有 writer Schema,兼容检查和 Schema 保留策略必须覆盖这段历史,而不只是最新版本。
- 变更只是结构调整,还是改变业务含义? 字段重命名可以双写过渡;美元分扩展为任意币种会改变旧消费者的解释,需要新的事件 major 版本或严格路由。
- 哪些消费方必须接收多币种订单? 如果旧链路只服务美元,可以让 v1 继续只承载美元,同时把全部订单写入 v2;不能把旧消费者无法理解的数据悄悄塞进 v1。
- 是否有稳定的事件 ID? 双流、重试和桥接都需要稳定
event_id。消费方切换订阅时必须按这个 ID 去重,不能把两个 topic offset 当成同一个身份。 - 发现不兼容消息时能否隔离并重放? 隔离区要保存原始字节、writer Schema ID、来源位置和原因;否则修复 parser 后无法恢复数据洞。
- 谁拥有契约和下线权? 生产方负责不代表可以单方面删字段。需要明确契约负责人、消费方登记方式、例外审批、弃用期限和最终下线证据。
30 秒回答框架
“我会先把每个变更写成 writer/reader 兼容矩阵,并把 90 天重放和最慢消费者纳入支持窗口。Registry 在 CI 阻止结构不兼容 Schema,主题使用覆盖历史版本的兼容策略;另外用契约样例、影子消费和字段质量指标检查 Registry 看不见的语义变化。
这个金额变更不能在原流上直接改含义。我会先添加带默认值的新字段,让新消费者先上线并对旧消息回退读取 totalcents,生产者再同时填充新旧表示。多币种属于破坏性语义变化,因此建立 orders.v2,用同一 eventid 双发或桥接,v1 只保留旧语义。消费方逐个切换并对账,完成 90 天重放验证和弃用门禁后再停止 v1。任何门禁失败都停留在仍兼容的阶段;不要通过回滚代码重新发送含义不同的事件。”
分步骤深入解答
第一步:建立消费者清单与不变量
先登记每个事件类型的 owner、writer、consumer、Schema 格式、支持版本、最大 lag、重放范围和发布联系人。自动收集消费者组、Schema ID、反序列化错误和最近读取时间,但不要把“最近没流量”直接等同于“已经下线”;批任务和灾备任务可能长期静默。
这次迁移至少有四条不变量:
- 任一已登记 reader 都不能因为收到支持窗口内的消息而反序列化失败。
- 同一个
event_id在 v1 与 v2 表示同一笔业务事实,桥接或重试不得创造第二笔订单。 - v1 的
total_cents始终保持“美元分”语义,不能在旧字段名下装入其他币种的次级单位。 - 新 reader 重放 90 天历史时,必须能找到当时的 writer Schema,并得到明确的金额解释。
这些条件比“Schema 注册成功”更接近业务正确性,也为测试和下线提供可验证标准。
第二步:用 writer/reader 矩阵判断兼容方向
先写四格矩阵,不依赖术语猜测:
| Reader | Writer | 必须支持的原因 | |---|---|---| | 旧 reader | 旧 writer | 当前线上基线 | | 新 reader | 旧 writer | 历史重放与 producer 尚未升级 | | 旧 reader | 新 writer | 滚动发布与慢消费者 | | 新 reader | 新 writer | 目标状态 |
Avro 读取时会同时使用 writer Schema 与 reader Schema。writer 多出的字段可以被 reader 忽略;reader 需要但 writer 缺少的字段,必须由 reader Schema 提供默认值,否则解析失败。默认值只在读取缺失字段时使用,并不会让 writer 在编码时可以省略一个必填字段。
因此第一阶段可以添加带默认值的新字段,但不能原地删除 total_cents。示意 Schema 只展示本题相关字段:
{
"type": "record",
"name": "OrderPlaced",
"fields": [
{ "name": "event_id", "type": "string" },
{ "name": "total_cents", "type": "long" },
{ "name": "amount_minor", "type": ["null", "long"], "default": null },
{ "name": "currency", "type": ["null", "string"], "default": null }
]
}null 排在 union 第一项,与默认值一致。新 reader 读取旧消息时得到 null,再执行明确的旧版回退;新 writer 仍写 total_cents,所以旧 reader 继续工作。这个结构能支持过渡,却没有让多币种语义自动变安全。
第三步:把兼容门禁放进提案和 CI
每个事件变更先提交机器可读 Schema、兼容方向、语义说明、样例消息、消费方影响、迁移步骤、弃用日期和回滚点。CI 对目标 subject 执行格式校验与兼容检查,禁止开发环境以 NONE 注册后直接进入生产。
本题同时存在混合版本和 90 天重放,选择 FULLTRANSITIVE 作为该 Avro subject 的练习设定:新 reader 要读所有保留 writer 版本,支持窗口内的旧 reader也要能读新 writer。TRANSITIVE 表示与所有历史版本比较,而非只比较最新版本。这个选择不是通用答案;如果系统严格遵循先升级 consumer、再升级 producer,并只要求新 reader 读旧数据,BACKWARDTRANSITIVE 可能更合适。
Registry 门禁之外再运行三类契约测试:
- 用每个保留 writer Schema 的真实样例喂给新 reader;
- 用新 writer 样例喂给仍受支持的旧 reader;
- 对金额、币种、事件 ID、枚举未知值和空值执行业务断言。
Schema ID、兼容配置和生成代码版本都固定到发布产物。运行时禁止 writer 静默自动注册一个未经评审的新 Schema。
第四步:先扩展 reader,再让 writer 双填充
先发布新 reader。它按以下顺序读取金额:若 amountminor 与 currency 都存在,就使用新表示;若两者都缺失,就把 totalcents 解释为 USD;若只出现一个新字段,或新旧美元金额不一致,就隔离而非猜测。
新 reader 在影子模式重放样本和一段生产流,比较旧逻辑与新逻辑对美元订单的结果。通过后再发布 writer,让美元事件同时填充:
event_id = stable business event identity
total_cents = 2599
amount_minor = 2599
currency = "USD"上线门禁应包含新字段成对填充率、美元金额一致率、Schema ID 分布、旧 reader 错误、隔离量和端到端消费 lag。先 canary 单个 writer,再逐步扩大;不要在第一批新字段刚出现时就删除旧读路径。
即使 FULL_TRANSITIVE 允许某些发布顺序,先 reader 后 writer 仍更容易观察语义和回退。Schema 兼容描述“能解析”,发布顺序还要保障“能正确处理”。
第五步:为破坏性语义建立 v2 边界
多币种事件不能继续放进所有旧 reader 都会消费的 v1。旧 reader 不认识 currency,会把 2599 日元错误解释为 25.99 美元。Registry 无法从字节结构发现这项错误。
创建 orders.v2 及独立 subject,v2 从第一版就使用明确字段:
{
"type": "record",
"name": "OrderPlacedV2",
"fields": [
{ "name": "event_id", "type": "string" },
{ "name": "amount_minor", "type": "long" },
{ "name": "currency", "type": "string" },
{ "name": "schema_major", "type": "int" }
]
}迁移期间,所有订单写入 v2;只有能保持旧美元语义的订单同时写入 v1。可以由事务内 outbox 产生一个规范业务事件,再由发布器投递两个表示,或由可重放桥接器从规范日志生成 v1。不能让两个独立请求各自决定事件内容,否则一次失败会造成流间缺口。
消费方以同一个 eventid 对账。单个消费作业不要同时把 v1 与 v2 当成两笔收入;切换时可先影子读取 v2、比较结果,再在记录好的 offset/时间边界停 v1、启 v2。确实需要重叠读取时,在状态存储中按 eventid 去重,并把去重保留期覆盖重叠与重放窗口。
第六步:保护历史 Schema、默认值与重放语义
消息保留 90 天,Registry 中对应的 writer Schema 至少要在这段时间及灾备余量内保持可取。删除 subject、改变旧 Schema 或回收字段身份会让原始字节失去解释方式。长期归档还要把 writer Schema 或稳定 Schema ID 映射与数据一起保存。
默认值是兼容工具,不是业务迁移结果。新 reader 对旧消息得到 currency = null 后,可以根据明确的 v1 契约推导 USD;它不能对来源不明的数据一律猜美元。字段的含义、单位、时区、枚举未知值策略和空值语义都要进入契约测试。
如果格式是 Protobuf,还要保留字段编号:删除字段后 reserved 其 tag 与名称,不能把旧 tag 分配给新含义,也不要在原 tag 上随意改变类型。旧二进制可能仍在日志中,重新使用编号会让历史数据被错误解析。不同格式共享发布流程,但不能共享一张过度简化的“允许变更”表。
第七步:设置可中止门禁与可观测性
给每个阶段规定进入和退出条件:
- 提案通过:owner、消费者清单、矩阵、样例和下线计划齐全。
- 新 reader 就绪:能重放 90 天样本,旧消息回退结果正确。
- writer canary:新字段成对填充,美元双表示一致,旧消费者无新增错误。
- v2 影子期:按
event_id的数量、金额、币种和关键业务聚合与真源对平。 - 消费者切换:每个 owner 确认版本、offset、lag、结果差异与回滚点。
- v1 下线:没有活跃或计划内消费者,回放窗口已跨过,审计证据已保存。
主要指标包括每 Schema ID 消息量、reader 版本分布、未知 Schema、反序列化失败、语义校验失败、隔离区最老记录、各消费方 lag、v1/v2 事件集合差异、字段空值率和币种分布。指标按 producer、consumer、Schema 版本和地域切片,避免全局低错误率掩盖一个消费方完全失败。
回滚动作也要按阶段定义。writer canary 失败时停止新 Schema 写入,旧字段仍在所以无需回写历史;v2 语义错误时暂停 v2 发布并保留规范事件以便修复后重放。已经发出的错误事件不能通过回滚二进制“撤回”,需要更正事件、新版本重放或下游补偿,并保留原始审计记录。
第八步:用版本组合和故障注入验收
测试矩阵至少覆盖旧 writer/旧 reader、新 writer/旧 reader、旧 writer/新 reader、新 writer/新 reader四种组合。对每个保留 Schema 版本做序列化—反序列化测试,并加入缺字段、未知字段、未知枚举、只填一半新字段、金额不一致和不支持币种样例。
集成演练还应覆盖:writer 滚动发布中途回滚;Registry 暂时不可用;consumer 在新版本上线后从 60 天前重放;v1 成功而 v2 发布重试;桥接器重复投递;消费方在切换 offset 前后崩溃;隔离记录修复后重复重放;错误 Schema 被 CI 拒绝。验收不能只看进程无报错,还要核对事件集合、业务金额、Schema ID、offset 连续性与下游副作用。
发布完成后保留一次完整回放演练。只有新 reader 从最早保留位置读到实时位置、结果对平且没有未知 Schema,才能证明历史兼容链路成立。
高质量示范回答
“我会先列出 12 个消费者、各自 reader 版本、最大 lag 和 90 天重放要求,再把变更写成四种 writer/reader 组合。Avro 新 reader 读取旧数据时依赖 writer Schema 和 reader 默认值;旧 reader 读取新数据则要求新 writer 继续提供旧 reader 所需字段。因为系统会混跑并重放历史,我会为该 subject 配置覆盖历史的兼容检查,并在 CI 之外加语义契约样例。
第一阶段只做扩展:加入可空 amountminor 和 currency,新 reader 先上线。它对旧消息把 totalcents 明确解释为美元;对新字段缺一或美元双表示不一致的消息直接隔离。验证影子消费后,writer canary 同时填充旧字段与新字段,监控 Schema ID、填充率、金额差异、反序列化错误和 consumer lag。
支持多币种会改变 totalcents 的隐含含义,因此不能把日元或欧元事件继续发给 v1 旧 reader。我会建立 orders.v2 与独立 subject,从规范业务事件生成 v2;v1 只桥接仍满足美元语义的事件。两个表示共享稳定 eventid。消费方先影子读取、按事件集合和金额对账,再在记录好的 offset 边界切换,重叠期按事件 ID 去重。
最后,Registry 与原 writer Schema 至少保留整个重放窗口。每个消费方都要证明能从最早保留位置读到实时位置。v1 下线必须同时满足无活跃消费者、弃用窗口结束、v2 对账通过和可回滚证据齐全。若 canary 失败,我停止新写入并继续旧契约;若已发布的数据含义错误,就发更正或修复后重放,不能声称回滚代码会自动修复历史。”
常见错误
- 只说“保持向后兼容” → 没有说明新 reader 读旧 writer,还是旧 reader 读新 writer → 先画四种版本组合,再选择 Registry 模式和发布顺序。
- Registry 检查通过就直接上线 → 结构兼容无法发现美元分变成任意币种次级单位 → 补充语义契约、样例消息、影子消费与业务对账。
- 给新字段加默认值后立刻删除旧字段 → 旧 reader 仍依赖原字段,历史版本也可能没有适合新 reader 的信息 → 先扩展和双填充;破坏性收缩使用新 major 流或跨过完整支持窗口。
- 让旧 topic 同时承载旧语义和新语义 → 旧消费者能解析,却会安静地产生错误金额 → 保持 v1 语义不变,把多币种放入明确的 v2 契约。
- 双发时生成两个事件 ID → 消费者无法证明两个 topic 中的是同一事实 → 从规范业务事件派生两个表示,并保留同一个稳定
event_id。 - 只和最新 Schema 版本比较 → 新 reader 重放 90 天历史时可能遇到更早的不兼容 writer → 在支持窗口内做 transitive 检查,并对所有保留版本运行 reader 测试。
- 把默认值理解为 writer 可以省略必填字段 → Avro 默认值用于 reader 解析缺失字段,不改变 writer 编码要求 → 分别验证 writer Schema 与 reader resolution。
- Protobuf 删除字段后复用 tag → 历史二进制或旧服务会把新含义解析成旧字段 → 保留被删除的编号和名称,新增字段使用新 tag。
- 靠群聊确认消费方已经升级 → 静默批任务、灾备任务和无人维护 consumer 容易遗漏 → 维护可查询的消费者清单、版本遥测与下线审批。
- 回滚应用就当作恢复历史数据 → 已发布的错误事件仍在日志和下游状态中 → 暂停发布,保留原始事件,通过更正、重放和对账完成数据修复。
追问及应对
追问一:为什么本题使用 FULL_TRANSITIVE,不直接用默认 BACKWARD?
本题要求旧 reader 在滚动期间读取新 writer,同时要求新 reader 重放所有保留旧数据,因此两个方向都存在;90 天历史又要求比较全部支持版本。FULLTRANSITIVE 是这组题设的保守选择。真实系统如果强制 consumer 先升级、旧 reader 不会见到新 writer,可以采用 BACKWARDTRANSITIVE,但要把发布顺序变成强制门禁。
追问二:字段别名能否直接完成 totalcents 到 amountminor 的重命名?
Avro alias 可以帮助某些 reader 解析旧名称,但不能让已经部署的旧 reader理解新业务含义,也不能解决多币种语义。别名适合结构重命名的一部分,仍需验证使用的实现、发布方向和历史 Schema;本题的语义改变需要 v2 边界。
追问三:双 topic 如何保证不漏不重?
一个本地事务先提交规范业务事件或 outbox,再由可重放发布器生成 v1 与 v2 表示。每个目标独立记录投递状态并可重试,所以暂时只成功一个不会丢失另一份。重复投递由稳定 event_id 和消费方幂等写入吸收;对账按事件集合比较,不只比较总数。
追问四:Registry 不可用时 producer 应该怎样处理?
已经缓存且获准的 Schema ID 可以在明确的过期和安全策略内继续使用;未知 Schema 不能绕过 Registry 自动注册。若无法确认 writer Schema,关键事件应限流、排队或失败关闭,并监控积压。恢复后先验证配置与 Schema ID,再按原顺序重放,不能切到无 Schema JSON 作为临时后门。
追问五:一个消费者永远无法升级怎么办?
先确认它是否仍有业务 owner 和真实需求。若必须长期保留,就把 v1 作为正式兼容产品,明确能力、成本和结束条件;桥接器只产生它能正确解释的事件。不能让这个消费者无限阻塞 v2,也不能在没有业务批准时单方面切断。无人认领且没有可验证读取的消费者进入有审计的下线流程。
追问六:如何验证语义变化,而不仅是反序列化成功?
使用固定黄金样例、生产影子流和独立真源对账。按 event_id 比较新旧金额、币种、订单数和关键下游结果,专门覆盖零值、最大值、退款、未知币种和重放。字段空值率、金额分布和币种分布用于发现漂移,但最终门禁应是可解释的不变量与逐事件差异。