题干与适用场景
请设计一个工作流引擎:客户可以定义 20 步以内的业务流程,其中某一步需要人工审批,流程可能等待数天;活动会失败、重复投递或执行后响应丢失。系统需要支持从故障恢复、超时、取消、审计和版本升级。请说明状态模型、调度、幂等、回调安全、扩展性与权衡。
这是一道系统设计题,适合后端、平台和基础设施岗位。重点是持久化执行语义,而不是画出一个简单的队列。题目中的 20 步和数天等待是面试假设;应先确认吞吐、延迟、租户隔离、数据敏感度、恢复目标与合规留存。
面试官考察点
面试官会观察候选人是否区分工作流状态、活动执行和外部副作用,是否承认活动通常可能至少执行一次,是否用幂等键与业务不变量处理重试。人工审批必须有不可伪造、可过期、只能消费一次的关联凭证。系统还要说明历史事件、当前快照、定时器、取消、版本和运营修复的边界。
回答前需要澄清的问题
- 每个租户的并发实例、每秒启动量和最长等待时间是多少?
- 活动是内部函数、容器任务还是外部 HTTP 服务?哪些副作用不可逆?
- 审批人如何认证、授权和替换?是否需要多人审批或法定人数?
- 重试允许重复执行吗?业务方能否提供幂等键或去重接口?
- 工作流定义如何发布、冻结和迁移?运行中的实例是否跟随新版本?
- 审计需要保留哪些字段,谁能读取或修改?
- 取消、暂停、人工重试和跳过步骤是否属于正常产品能力?
30 秒回答框架
“我会把工作流视为持久化状态机:定义版本、实例、步骤尝试、事件日志和当前快照分开存储。调度器只投递可重试的活动任务,worker 用活动 ID、尝试号和幂等键报告结果;结果重复时由状态机去重。人工审批创建短期一次性 token,回调必须绑定实例、步骤、租户和审批版本。计时器、超时和取消都写入事件并由调度器恢复。先用分片队列和租户配额扩展,再通过事件重放、审计和故障注入验证恢复语义。”
分步骤深入解答
先定义执行模型。工作流定义是不可变版本,包含步骤类型、输入映射、超时、重试策略和补偿规则。实例引用一个固定版本,不因新版本发布而静默改变历史。实例状态可由事件日志重建,快照用于加速读取;事件追加要有单调序号或版本条件,避免并发写覆盖。
活动执行不要直接在数据库事务里调用外部服务。状态机提交 ActivityScheduled 事件后,调度器把任务放入队列。worker 领取租约,执行外部调用,再提交 ActivitySucceeded、ActivityFailed 或 ActivityTimedOut。状态机只接受预期实例、步骤和尝试号的结果;过期或重复结果写入审计但不推进状态。
一个最小状态模型可以包括:
| 实体 | 关键字段 | 作用 |
|---|---|---|
| DefinitionVersion | tenant、definition、version、digest | 冻结步骤与策略 |
| WorkflowRun | run、definitionVersion、status、sequence | 记录实例与当前状态 |
| Event | run、sequence、type、payload、createdAt | 事实日志与重放依据 |
| ActivityAttempt | step、attempt、lease、idempotencyKey、status | 追踪投递、租约和结果 |
| Approval | step、tokenHash、approver、expiresAt、status | 管理人工审批凭证 |
| Timer | run、step、fireAt、generation、status | 管理超时、延迟与唤醒 |
调度必须是至少一次投递,不能把队列确认当成业务完成。任务消息包含 run ID、step ID、attempt、定义版本和幂等键。worker 先用租约防止两个消费者同时执行,再用 fencing token 或条件更新拒绝旧 worker 写回。租约过期后可以重新投递,但外部副作用是否重复取决于活动契约。
幂等要分层处理。状态机写事件使用唯一键 (run, sequence);活动结果使用 (run, step, idempotencyKey);外部支付、发信或写入业务系统则要求下游接受相同幂等键,或通过业务唯一约束去重。若调用成功但响应丢失,重试只能得到“已完成”或安全的重复结果,不能声称整个流程 exactly once。对于无法幂等的副作用,提供人工确认、补偿或不可重试策略。
人工审批不能把随机 URL 当作授权。创建审批任务时生成高熵随机 token,只存哈希,token 绑定租户、run、step、审批动作和过期时间。回调入口验证 token、签名或登录身份、CSRF、一次性消费和当前状态;审批人权限在提交时再次检查,而不是只在发信时检查。批准和拒绝都写入不可篡改的审计事件,过期后回调变成无效操作。
计时器必须持久化。创建超时或等待事件时写入 fireAt 与 generation;调度器用时间索引或分桶扫描到期 timer,再通过条件更新赢得处理权。重启后可从数据库恢复,重复触发由 generation 和状态条件去重。取消或审批完成时标记旧 timer,不依赖内存中的 sleep;长时间等待不应占用 worker。
取消、暂停和人工修复需要显式状态机命令。取消请求先记录意图,再阻止尚未开始的活动;正在执行的外部副作用不能凭空撤销,应等待结果或运行补偿。管理员跳过步骤、重试或修改输入必须要求权限、理由、旧状态和新状态,并产生审计事件。不能直接改快照来“修复”流程,否则事件重放会得到不同结果。
版本升级按实例隔离。新定义发布新 digest;运行中的实例默认继续使用旧版本。若必须迁移,先定义可迁移的状态、兼容输入和人工批准,生成一条 WorkflowMigrated 事件并记录前后版本。活动 worker 只接受它所执行的定义版本,避免旧任务把新流程推进到错误状态。
扩展性先做分片。按租户或 run ID 对事件和队列分区,单个热租户设置并发配额;worker 池按活动类型和优先级隔离。事件日志可追加写入分区存储,快照和索引放在在线数据库,历史 payload 按敏感度加密并设置保留期限。调度器需要背压、死信、租约监控和限流,避免一个失控工作流占满全局容量。
可观测性同时面向运行时和业务。记录每个 run 的当前步骤、等待原因、尝试次数、队列延迟、timer 延迟、审批停留时间、重试放大、补偿结果和版本。分布式 trace 的 span 要带 run、step 和 attempt,但敏感输入放在受控审计存储。运营界面显示可解释状态和下一步动作,不能把“等待”误报成失败。
故障测试至少覆盖:提交事件后进程崩溃、消息重复、租约过期、活动成功但响应丢失、审批回调重放、timer 重复触发、数据库故障、队列积压、版本发布中断和租户配额耗尽。每种故障都要定义可观察结果,例如旧状态不会回退、同一副作用不重复、审批 token 不能二次消费、恢复后最终会继续或进入人工处置。
高质量示范回答
“我会把它设计成持久化状态机,而不是让 worker 记住流程。定义版本不可变,实例固定引用一个版本;事件日志记录事实,当前快照只做读取加速。状态机提交活动调度事件,队列至少一次投递,worker 用 run、step、attempt 和幂等键执行并回报。重复或过期结果只写审计,不推进错误状态。
人工审批创建绑定租户、实例、步骤和动作的一次性 token,只存哈希并设置过期时间。回调时验证登录身份、权限、CSRF、token 状态和当前步骤,成功后条件更新为已消费并写审计。批准、拒绝、超时和取消都是事件,不直接改快照。
计时器持久化 fireAt 和 generation,调度器分桶扫描并用条件更新领取;重启可恢复,重复触发会被 generation 和状态去重。租约防止两个 worker 同时执行,fencing token 拒绝旧 worker 写回。无法证明下游幂等的付款或发信不能声称 exactly once,应该使用下游幂等键、唯一约束、补偿或人工处置。
运行实例默认不自动跟随新定义,迁移必须生成带前后版本的事件。按租户和 run 分片,隔离热租户配额,活动类型使用独立 worker 池;事件追加存储、在线快照、加密审计与保留策略分层。故障注入验证崩溃、重复投递、响应丢失、回调重放、timer 重复和版本中断时的恢复结果。”
常见错误
- 把队列确认当作业务完成 → worker 可能执行后崩溃,消息再次投递 → 用持久化状态和幂等结果去重。
- 声称 exactly once → 分布式副作用无法由本地事务保证 → 说明至少一次、下游幂等、补偿或人工处置。
- 把审批 URL 当成授权 → 泄露或重放后可能越权 → 绑定身份、租户、步骤、动作、过期和一次性消费。
- 用内存 sleep 等待数天 → 重启或扩容会丢失等待 → 把 timer 存储并由调度器唤醒。
- 直接修改快照修复 → 事件重放会产生不同结果 → 用有权限的状态机命令和审计事件。
- 所有租户共享一个队列 → 热租户会拖垮其他客户 → 分片、配额、优先级和背压。
- 新定义立即影响旧实例 → 运行中的流程不可解释、不可重放 → 实例固定版本,迁移显式化。
追问及应对
追问 1:活动成功了,但 worker 在回写前崩溃,重试会不会重复扣款?
如果下游支持幂等键,重试复用同一个键并把已完成结果映射回来;否则不能安全重试。可以查询下游状态、进入人工处置或执行补偿。状态机只能保证自己的事件一致,不能凭空给外部支付 exactly once。
追问 2:审批人点击了两次批准,如何处理?
token 只允许一次成功消费,使用条件更新或唯一约束把 pending 改为 approved。第二次请求返回已处理或过期,不再次推进流程;两次请求和身份都写入审计。
追问 3:如何支持“任何两人批准”而不是单人审批?
把审批步骤拆成策略和持久化收件箱,记录每位审批人的唯一决定、权限快照和策略版本。状态机根据去重后的决定数达到法定人数后推进;拒绝、撤销和替换审批人都走显式命令。
追问 4:运行中的流程需要紧急跳过一个步骤,怎么办?
先定义谁有权限、哪些步骤可跳过和安全条件。通过带理由的 StepSkipped 命令记录旧状态、新状态、操作者与审批,不直接改数据库。若跳过会破坏业务不变量,应拒绝并提供补偿或终止路径。
追问 5:如何防止一个租户创建无限长的工作流?
定义步骤数、分支深度、活动并发、历史大小、timer 数和总运行时配额;发布定义时静态校验,运行时按租户计量。超过配额进入等待或拒绝,并提供管理员可见的原因,避免在执行期间无限增长。