审核系统设计:版本、轮次、并发裁决与发布隔离
"审核通过"和"允许发布"是两个动作,不能把审核表的一个 APPROVED 字符串直接当作全站发布权限。本设计从状态、任务和裁决推导人工审核的容量与恢复;机器审核的编排见机审系统设计,版本守卫的通用机制见零停机 schema 迁移。
教学题设与推演,数据库采用 MySQL 8.0 / InnoDB。所有规模均为明确假设;并发 SQL 与故障注入步骤尚不代表生产验证。
需求与不变量
设计内容提交、机审、人工复核、申诉及重新审核系统。内容修改后必须重新审核;人工结论可能晚于机审,也可能与机器冲突;业务要求已发布内容的版本必须有对应有效批准。先确认"审核通过"和"允许发布"是两个动作。
题设:日提交 1000 万次,峰值 3000 次/秒;文字、图片和视频对象走不同规则与超时预算。平均负载为 115.7 次/秒,峰均比约 25.9,不能按日均配置 worker。内容体放对象存储,事务库保存不可变对象摘要、版本、规则和审计记录;worker 不把完整视频塞入消息。
核心不变量有四项:结论绑定 content_version;每次重审增加 review_round;同一轮次只允许一个符合策略的最终裁决;发布状态必须引用当时仍有效的批准与内容版本。修改内容、撤销、回调、人工动作都必须遵守这些条件,绕开主入口的批处理也不例外。
数据模型:三种版本不能混用
content_version 表示内容本体;review_round 区分同一内容版本的不同审核过程;row_version 用于并发条件更新。三者分别处理旧内容、旧流程、同一状态的并发写。规则与模型也保存版本,便于申诉时解释为何给出当时结论。
CREATE TABLE review_case (
case_id BIGINT PRIMARY KEY,
content_id BIGINT NOT NULL, content_version BIGINT NOT NULL,
review_round INT NOT NULL, state VARCHAR(24) NOT NULL,
row_version BIGINT NOT NULL DEFAULT 0,
policy_version VARCHAR(40) NOT NULL,
decision_id VARCHAR(64) NULL,
UNIQUE KEY uk_round(content_id, content_version, review_round),
KEY ix_work(state, case_id)
);
CREATE TABLE review_decision (
decision_id VARCHAR(64) PRIMARY KEY,
case_id BIGINT NOT NULL, actor_type VARCHAR(16) NOT NULL,
result VARCHAR(16) NOT NULL, evidence_ref VARCHAR(256) NOT NULL,
created_at TIMESTAMP(6) NOT NULL
);补充回调表时用 provider + external_task_id + event_id 作为接入幂等域,并保存签名验证结果和 payload 摘要。事件 ID 防重复接入,但不能决定业务状态是否还能前进;业务更新还需校验轮次、状态和版本。
状态机与单点裁决
主链为 SUBMITTED → MACHINE_PENDING → APPROVED / HUMAN_PENDING / REJECTED;人工复核只能从允许的前态推进,终态不能因迟到机审重新打开。申诉创建新轮次或明确的申诉流程,而非改写历史结论。机器批准是否能覆盖人工拒绝必须由策略明确,本题选择人工终裁优先。
收到回调
→ 验证调用方、签名、时间窗和任务映射
→ 持久化接入事件/判断重复
→ 核对 content_version、review_round、模型任务
→ 在允许前态上执行 CAS 更新
→ 写 decision 与发布意图 outbox
→ 提交后返回已接收以下教学 SQL 显示数据库裁决位置;新增 decision、状态更新及 Outbox 在同一事务中,更新行数为零时回滚本次候选结论:
UPDATE review_case
SET state='APPROVED', decision_id=?, row_version=row_version+1
WHERE case_id=? AND content_version=? AND review_round=?
AND state='MACHINE_PENDING' AND row_version=?;row_version 不匹配可能意味着并发合法动作已经发生,不能无条件重试覆盖;需重读权威状态并重新判断业务动作是否仍合法。普通 SELECT 后再无条件写无法保证这点。InnoDB 锁与条件更新的实现条件见锁定读,不能把缓存读作为最终前态判断。InnoDB 的锁与 MVCC 行为由 MySQL 主题权威讲授。
发布如何拒绝旧批准
批准事件携带 content_id、content_version、review_round、decision_id。发布服务读取当前内容指针,并仅对相同内容版本执行条件发布;旧事件可被记录为过期,不应误报为系统失败并无限重试。若审核与发布同库,状态和发布指针可共同提交;若跨服务,则接受一个有界延迟窗口,并把待发布状态与可靠恢复纳入业务协议。
内容更新 v7→v8 与 v7 批准并发时,至少有两种策略:先修改指针再让旧批准失效;或短暂冻结指针完成批准与发布。前者写入延迟低,但所有读取出口都要核对版本;后者减少过渡窗口,却增加锁定与用户等待。不要在更新后只删缓存:消息重放仍可能把旧内容重新写入投影。
队列、人工与容量
假设 10% 进入人工,日均人工量为 100 万。每单平均处理 30 秒,每人每天 6 小时有效处理,则每人约 720 单/天,纯处理能力至少约 1389 人日;还未计复核、班次、等待和质量抽查。这个算例揭示人工通道可能才是瓶颈,不能只增加 Kafka 分区就声称解决。
机器分支若峰值 3000/秒、每任务占用本地 worker 20 ms,本地平均并发约 60;外部模型耗时若平均 800 ms,则等待中的模型请求约 2400。两种并发不是同一资源池:同步线程全程等待会占大量连接/线程;可用受限并发的异步客户端,但仍需给外部模型限额与排队上限。
优先队列按风险与期限分级,设置老化策略避免普通任务永久饥饿;业务 key 对同一内容排序,而非强求全局排序。队列年龄比消息条数更接近用户等待体验。人工队列需要领取租约、转派与审计,不能只暴露一个可修改状态的后台按钮。
故障时序与补偿
| 故障 | 危险做法 | 恢复条件 |
|---|---|---|
| 回调丢失 | 永久停在 PENDING | 扫描超时任务,按外部任务号查询或进入复核 |
| 旧轮次回调迟到 | 覆盖新轮次 | 校验三个版本与允许前态,保存过期原因 |
| 同时批准和拒绝 | 最后写入覆盖 | 原子状态迁移与显式优先策略 |
| 已批准未发布 | 人工直接修改发布字段 | Outbox 重放、发布条件更新与结果查询 |
| 误发布后撤销 | 只改主库状态 | 发布撤销事件、失效缓存、搜索与 CDN 并核对传播水位 |
客户端重复提交要用提交业务键和请求摘要。HTTP 条件请求可减少覆盖,但其语义不自动替代应用事务;RFC 9110 条件请求为接口的前置条件与状态码提供标准基础。
验证与高级回答
最小实验准备 v7 的两轮任务及 v8 新内容:依次注入旧回调、新回调、人工拒绝、重复批准和发布重放。每一步写出合法状态集合,核对最终发布指针、decision 数、CAS 行数及过期回调记录。再让两个会话从同一 row_version 更新不同结论,预期只能一个成功;失败方不能绕过守卫。这些为预期验证步骤,本轮未运行真实并发注入。
回答架构题时先给不变量,再画版本与状态图,最后讨论队列和组件。若先列 MySQL、Redis、MQ 而说不出旧结论在哪里被拒绝,说明设计仍未落到正确性。
审核—发布跨系统协议表
本页讲了"发布如何拒绝旧批准",但评审时还需要一张端到端协议表:四类事件、允许前态、拒绝条件、拒绝后动作。四类事件是:机审通过、人工拒绝、撤销、发布。每一类都要写明从哪些状态出发合法、从哪些状态出发必须拒绝,以及拒绝后保留什么证据。
协议表(状态转移):MACHINE_APPROVED 只允许从 MACHINE_PENDING 到 APPROVED,从 APPROVED 再次到达要拒绝并返回已存决策(不新建第二个决策,脚本标记 REJECT_TERMINAL);HUMAN_REJECTED 只允许从 HUMAN_PENDING 到 REJECTED;REVOKE 只允许从 APPROVED 到 REVOKED,从 SUBMITTED 到达要拒绝(没有批准可撤销);PUBLISH 只允许从 APPROVED 到 PUBLISHED,从 REVOKED 到达要拒绝。
发布侧的关键检查不是状态字符串,而是版本对齐:发布读取当前 content_version,与批准携带的版本比较。示例中内容已到 v8、批准是 v7,publish_ok=false,发布必须拒绝并记录"旧批准"。只检查 state='APPROVED' 会放过这个错误——状态对但版本旧,正是本页开头"审核通过和允许发布是两个动作"的具体形态。
拒绝后动作也要规定:旧批准被拒绝时记录为过期事件,不重试、不报警为系统故障;终态重复到达时拒绝新决策并返回已存结果(幂等语义由"返回原结果"实现,不是再走一次状态迁移);撤销前进到一半时回滚发布意图并保留审计。
执行 python3 labs/system-design/design_review_models.py review_publish_protocol:预期输出四个允许转移、四个拒绝转移、stale_approval_publish=REJECTED(v8 内容 vs v7 批准)。脚本是确定性状态机,不连数据库;真实并发 CAS 注入与跨服务回调未运行。
两个合理选择:A 审核与发布同库,状态和发布指针同事务提交,简单但耦合;B 跨服务,接受有界延迟窗口,需要 Outbox、去重与对账。本例按题设规模选 B,并把协议表作为两个团队之间的接口契约——协议表变更必须同时更新两侧的测试。
人工审核的单位经济
本页算过人工通道的纯处理量约 1,389 人,但评审常停在"需要多少人",没有算"每单多少钱、错误值多少钱、预算够不够"。单位经济把人工审核从容量问题变成可比较的成本问题。
输入:日提交 1,000 万、10% 进人工即 100 万单/天;每单 30 秒、每人每天 6 小时有效处理,容量 720 单/人/天,需约 1,389 人;按每小时 80 元计,人工成本约 666,667 元/天。误放行率 0.1%(1,000 单/天,每单 50 元)≈ 50,000 元/天;误拒率 0.5%(5,000 单/天,每单 20 元)≈ 100,000 元/天;错误成本合计 150,000 元/天。总成本约 816,667 元/天,单均 0.82 元。若日预算 12 万元,over_budget=true——规模本身已经不可行。
单位经济的价值在于暴露这种不可行,而不是精确到分。误放行与误拒必须分开定价:把两者合成一个"准确率",会掩盖"放行一条违规内容的成本可能是误拒一条正常内容的几十倍"这一结构性差异。预算约束要写进设计:人工比例、抽检比例、复核层级都直接改变成本。
降低单均成本的三条路:降低人工比例(提高机审置信度或改产品规则)、缩短每单耗时(更好的证据展示与工具)、降低错误率(但通常要更多人工,成本反向)。每条都要重新算总成本,不能只报一条指标。
执行 python3 labs/system-design/design_review_models.py human_unit_economics:预期输出 people_needed=1389、人工 666,667、错误 150,000、总计约 816,667、单均 0.82 元、over_budget=true。脚本只做算术,人工耗时与错误率为教学假设,需真实测量替换。
两个合理选择:A 只报人工人数,简单但无法与机审投入比较;B 报单位经济加预算约束,复杂但能支撑"提高机审准确率是否值得"的决策。本例选 B,并规定任何人工比例调整必须同时给出新的单位成本与错误成本。
架构师变化条件
把人工复核扩展为两级(初审+质检抽检):轮次模型增加子轮次或复核层,终态裁决点仍唯一,抽检比例与误放行边界进入 SLI。若审核结论需多语言多地区策略,policy_version 细化为规则集+地区,同轮次并行策略选择由业务裁决声明。峰值提升十倍时先分清本地 CPU、模型并发、人工处理与存储写入四类资源,分区只解决其中一类。每次变化重填四不变量与状态机。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。