题干与适用场景
你要为多租户账户服务设计事件日志。每次余额、权限或配置变化都生成事件,消费者可以从历史重放状态;实例故障后不能从头扫描无限历史。系统需要支持高吞吐追加、按租户顺序、消费者独立进度、快照恢复和有限存储成本。回答还要说明删除事件、乱序事件和 schema 演进。
本题适合高级系统设计、平台和数据基础设施面试。Martin Fowler 的 Event Sourcing 文章定义了以事件序列保存状态变化的核心思想;Apache Kafka 设计文档说明分区日志、消费者位置和 log compaction 如何保留每个键的最新值;公开系统设计题也把分区追加日志、复制、保留、压缩和恢复列为考察点。这些来源支持主题代表性,但不能证明某家公司固定使用该题或具体频率。本题归入 system-design,因为核心是端到端一致性、恢复边界与容量权衡。
面试官考察点
第一,看能否把“事件历史”和“当前物化状态”分开。快照是加速重放的派生物,不能取代不可变事件或让快照与事件边界不一致。
第二,看分区键是否同时满足顺序和扩展。按租户或聚合 ID 分区可以保证单聚合顺序,但热点租户、跨聚合事务和全局顺序都需要明确限制。
第三,看是否理解压缩语义。键级压缩只保留每个键的最新记录,适合状态变更流,不等价于事件历史;删除需要 tombstone 和保留窗口,消费者不能在压缩前后混用任意 offset 假设。
最后,看恢复和运维:快照校验、事件版本、检查点原子性、复制确认、保留成本、消费者落后和 schema 兼容都应可观测。
回答前需要澄清的问题
- 事件是不可变审计事实,还是可重建的当前状态变更? 审计需要保留完整历史,状态流才适合键级压缩。
- 顺序范围是什么? 只保证同一聚合顺序,还是要求租户内或全局顺序?不同承诺决定分区与吞吐。
- 消费者需要从任意时间重放吗? 如果需要,不能只保留压缩后的最新键值;要有长期归档或独立审计日志。
- 快照由谁生成和校验? 生产者、消费者和快照服务的职责不同,必须绑定日志位置和 schema 版本。
- 删除如何表达? 使用带版本的 tombstone,还是保留业务删除事件?两者的压缩和合规含义不同。
30 秒回答框架
“我把每个聚合的事件按聚合 ID 分区,日志只追加,副本以已提交 offset 对外确认。事件包含 event ID、聚合版本、schema 版本和时间,消费者用 offset 检查点实现至少一次重放并用 event ID 去重。快照记录聚合状态、最后事件 offset 和 schema 版本,恢复时先校验快照,再从下一 offset 重放。键级压缩只用于可重建的当前状态流,并保留 tombstone 窗口;审计事件走不可压缩归档。监控包括复制滞后、消费者 lag、快照年龄、压缩进度和重放校验差异。”
分步骤深入解答
1. 定义事件和顺序边界
事件至少包含 eventId、aggregateId、aggregateVersion、schemaVersion、payload、产生时间和来源。aggregateVersion 在同一聚合内单调递增;写入时用条件提交拒绝过时版本,避免两个并发写者静默覆盖。
分区键使用 aggregate ID,保证同一聚合进入同一有序日志。系统不承诺跨分区全局顺序;如果产品需要跨聚合原子事实,应把事务结果编码成一个聚合事件或使用事务外盒,而不是依赖时间戳排序。
2. 追加、复制和确认
领导副本先把事件追加到本地日志,再复制到足够副本;只有达到配置的提交条件才向生产者返回已接受。事件 ID 和生产者序号支持重试去重。磁盘段按大小或时间滚动,索引帮助消费者定位 offset。
确认语义要写清楚:客户端拿到确认表示事件已经进入可恢复的提交日志,不代表所有消费者已处理,也不代表物化读模型已更新。消费者失败不会回滚已追加事件。
3. 消费者检查点和幂等
每个消费者组独立保存 partition offset。处理一个事件并更新自己的读模型后,再提交检查点;崩溃可能导致重复处理,因此 handler 必须按 event ID 或聚合版本幂等。检查点和物化写入若需要原子关系,可把二者写入同一事务存储,或用 outbox 记录提交结果。
消费者落后时保留日志,不能为了清理 lag 直接跳过未处理事件。对于可重建读模型,可以从快照 offset 开始重放;对于不可重建副作用,要用补偿或人工复核,而不是盲目重放。
4. 快照协议
快照保存聚合 ID、序列化状态、最后应用的 offset、聚合版本、schema 版本、校验和和生成时间。生成时读取一个稳定边界:先记录目标 offset,再应用到该 offset,最后以同一边界写入快照。恢复只接受校验通过且 offset 属于该聚合分区的快照。
恢复顺序是加载快照状态,然后从 snapshotOffset + 1 重放事件。若 schema 版本旧,先执行版本化迁移;迁移失败必须阻止发布错误状态。快照是缓存,删除快照不会损坏日志,只会增加恢复时间。
5. 区分保留、压缩和归档
时间保留删除旧日志,适合有明确重放窗口的事件。键级压缩在同一分区保留每个 key 的最新值,可让状态消费者从较短日志重建当前状态。它不能满足审计或任意时间点回放,因为中间事件可能已被清掉。
删除使用 tombstone 表示 key 已删除;tombstone 必须保留到所有符合契约的消费者都能看见它,之后才可被压缩清除。审计事件写入不可变归档,设置访问控制、加密和保留期限,不能把压缩日志当作完整审计证据。
6. Schema、乱序和毒事件
事件 schema 使用向后兼容规则:新增可选字段、保留旧含义,消费者遇到未知字段可忽略。破坏性变更需要新 schema 版本、双读或迁移窗口。消费者记录 schema 版本和解析错误,不应把无法解析的事件静默提交 offset。
同一聚合乱序通常说明生产者或复制链路违反了分区顺序。可用 aggregateVersion 拒绝过时事件,把缺口放入等待队列;跨聚合事件只能按业务时间或因果 ID定义补偿,不能用服务器到达时间假装顺序。
7. 容量、恢复和可观测性
容量模型包括每秒事件数、平均和尾部 payload 大小、复制因子、保留窗口、快照大小、压缩收益和消费者重放速度。快照间隔越短恢复越快,但写放大和存储成本越高;压缩越积极,状态恢复越便宜,但审计与历史查询能力越弱。
监控生产确认延迟、复制 lag、磁盘段、压缩 backlog、消费者 lag、快照年龄、重放速率、schema 错误、重复率和快照校验差异。定期从快照和事件抽样重建,与物化状态做校验;发现差异时保留 offset 和事件样本,便于定位而不是继续覆盖。
高质量示范回答
“我把账户聚合 ID 作为分区键,事件只追加,并用聚合版本拒绝并发的过时写入。生产者得到确认时,事件已进入满足复制条件的提交日志;消费者独立提交 partition offset,处理后再检查点,因此崩溃只会导致可去重的重复处理。
快照包含聚合状态、最后事件 offset、聚合和 schema 版本、校验和。恢复先验证快照,再从下一 offset 重放;快照删除只增加恢复时间。键级压缩只应用于可重建的当前状态流,删除用 tombstone 并保留到消费者契约允许的窗口;审计流保留不可变归档,不能用压缩结果代替。
我会限制承诺为同一聚合有序,不承诺跨分区全局顺序。容量模型纳入复制、保留、压缩和快照写放大,监控 lag、快照年龄、压缩 backlog、schema 错误和重放校验差异,定期用快照加事件重建状态验证读模型。”
常见错误
- 把快照当作事件源 → 删除或损坏快照就无法审计和恢复 → 快照是绑定 offset 的派生加速层。
- 把压缩日志当完整历史 → 中间事件和任意时间点状态已丢失 → 审计流归档,状态流才使用键级压缩。
- 用时间戳排序所有事件 → 时钟偏差会制造错误顺序 → 按聚合版本和分区顺序定义契约。
- 处理后才决定是否提交 offset → 重复不可避免却未设计幂等 → 用 event ID/版本去重,检查点与读模型有明确关系。
- tombstone 立即清除 → 迟到消费者会把已删除键复活 → 保留到消费者契约覆盖后再压缩。
- 拒绝一个 schema 版本就跳过并提交 → 日志与读模型静默分叉 → 停住、隔离毒事件并保留错误证据。
- 不建恢复容量模型 → 快照、压缩或重放在故障时成为瓶颈 → 计算 lag、恢复时间和写放大。
- 声称 exactly-once 解决所有重复 → 外部副作用仍可能重复 → 对外操作使用幂等键、事务或补偿。
追问及应对
为什么不直接把当前状态放数据库?
如果只需要当前读,数据库更简单。事件日志的价值是可重放、审计、多个消费者和从历史重建不同读模型;它也带来 schema、重放、容量和副作用幂等成本。应根据这些需求选择,而不是默认事件溯源更先进。
压缩后如何支持新消费者从头建立状态?
新消费者只能建立压缩后仍能表达的当前状态,不能重建被删除的中间历史。若业务需要历史,保留不可压缩归档或独立 change log,并从归档快照和事件开始。文档要标明每个 topic 的重放能力。
快照生成期间有新事件怎么办?
为快照选定稳定目标 offset。之后到达的事件继续追加,但不写入该快照;恢复加载快照后从下一 offset 重放。若快照写入失败,保留旧快照和日志,不发布部分写入的文件。
如何迁移事件 schema?
先定义兼容规则和版本字段,消费者可同时读取旧、新版本。写入端在窗口内发布新字段,等所有消费者升级后再停止旧字段;无法兼容的变更使用新事件类型或离线迁移,并用重放测试验证历史事件。