第 3 章 生产系统治理、保障与技术债务:韧性架构的动态平衡
生产系统最大的错觉,是把一次次线上成功误认为系统天然可靠。事实上,只要系统仍在承载业务、不断接入新需求、持续经历变更,它就一定会沿着更复杂、更脆弱、更难解释的方向滑动。技术债会累积,保障措施会老化,组织纪律会松动,最终在某个看似偶然的流量峰值、配置变更或下游抖动中集中爆发。
这就是我理解的软件系统“熵增定律”:孤立的生产系统,总是倾向于增加混乱度。工程师的工作,本质上不是追求一个永远完美的架构,而是不断向系统注入“负熵”,在业务速度、技术债务和生产保障之间寻找动态平衡。
3.1 引言:软件系统的熵增定律与“救火队长”陷阱
很多团队在发展初期都经历过同一条路径:先用最短时间把业务跑起来,再靠人盯、人补、人扛去渡过每一次线上波动。短期看,这种方式似乎很高效;长期看,它往往把团队拖进“越忙越救火,越救火越没有时间治理”的恶性循环。
真正难的,从来不是把系统做上线,而是让它在十倍流量、百次变更、多人协作和多系统耦合之下,仍然保持可预测、可恢复、可审计和可演进。生产事故很少是纯偶发事件,更常见的情况是:技术债长期积累,在某一次变更、流量冲击或依赖抖动下突然开始收利息。
所以,本章要讨论的不是狭义上的“稳定性技巧”,而是一套更完整的生产系统方法论:治理负责治本,保障负责治标,技术债是必须正视的现实约束。三者不是并列话题,而是一个持续运转的反馈系统。
3.2 全景框架:治理、保障与技术债务的动态反馈环
如果把生产系统放到更长的时间维度里看,技术债、治理和保障之间大致构成一个闭环:
【生产治理(治本)】 ─── 指导 / 注入机制 ───► 【技术债务(利息)】
▲ │
│ 动态演练 / 事故复盘 / 规则固化 │ 隐形挤占产能 / 诱发事故
│ ▼
【应急保障(治标)】 ◄──── 承载压力 / 爆发故障 ─── 【生产运行(现实)】
这个闭环里有四个关键判断:
- 技术债是因,生产故障往往是果。
- 保障是防线,但保障不能代替治理。
- 治理的价值,不是“多立规矩”,而是持续把偶发经验沉淀成系统能力。
- 事故、演练、复盘和数据,会反过来暴露新的技术债,并推动下一轮治理。
因此,一个成熟团队不会把治理、保障和债务拆开看,而是会不断追问:今天的故障,背后暴露的是哪类债务?今天加的一道保障,究竟是临时止血,还是已经沉淀成长期机制?
3.3 技术债务篇:如何与系统的“慢性癌症”和平共处
技术债务最危险的地方,不是代码丑,不是注释少,也不是架构图不好看;真正危险的是,它会悄悄吞掉团队的变更速度、恢复能力和判断空间。很多系统不是死于一次大故障,而是死于长期“小病不断”,最后任何小改动都可能引发连锁反应。
但老系统治理的现实从来不是“零负债”,而是“让债务处于可控带宽内”。因此,技术债务管理的重点不是道德审判,而是分级、量化和准入。
从实践上看,技术债至少可以分成五类:
- 架构债:边界混乱、依赖蔓延、共享资源池过多。
- 代码债:复杂度失控、测试缺口、变更影响面难以评估。
- 数据债:口径不一致、状态无法追溯、历史快照缺失。
- 运维债:回滚困难、告警失真、恢复工具薄弱。
- 组织债:没有 owner、没有还债预算、没有变更纪律。
面对这些债务,我更建议建立三套机制:
- 红绿灯准入机制。不是所有债务都不能借。业务抢跑阶段允许存在“绿灯债”,例如先靠人工值守兜底,但要明确转红阈值,例如用户量、调用量、商家数或金额规模达到什么水平后必须重构。
- 还债预算制。把技术债纳入季度和版本计划,固定拿出一部分产能处理高利息债务,而不是永远等“有空了再说”。
- 线上重构方法论。真正成熟的重构,不是停机重写,而是“空中换引擎”:先封装抽象隔离层,再做影子系统和灰度比对,最后小流量切换、可逆迁移。
带着这个视角再看可靠性,就会发现:保障体系解决的是“今天别死”,治理体系解决的是“下次别再这么死”,而技术债务决定了系统到底有多容易在关键时刻出事。
3.4 生产治理篇:面向失效设计的韧性架构
生产治理和应急保障最大的区别,在于前者讨论的是“系统为什么总会在相似场景下反复出问题”,后者讨论的是“问题已经发生时怎么把损失控制住”。如果说保障是在事故当天保护业务,治理就是在事故之间的每一天,持续削弱下一次事故发生的概率和爆炸半径。
很多团队在稳定性建设上最容易犯的错,是把治理理解成一组零散的技术动作:补一个监控、加一个限流、写一个预案、做一次演练。这样做当然有帮助,但还不够。真正的治理,必须把这些动作变成一套持续生效的约束机制,让系统在需求增长、人员流动和架构演进中,仍然保持边界、纪律和反馈回路。
从这个角度看,生产治理至少要回答五个问题:
- 我们究竟在为哪些业务结果负责,而不是只为哪些机器指标负责?
- 什么样的故障是可以接受的,什么样的故障一旦发生就必须立刻止损?
- 哪些依赖可以降级,哪些依赖绝不能带病放行?
- 哪些技术债当前可以接受,哪些已经开始透支系统未来?
- 事故、演练和变更产生的经验,是否真的被沉淀成了下一轮治理规则?
下面这些能力看起来像是“稳定性工程细节”,但它们真正的价值,在于把经验变成规则,把规则变成默认约束,把默认约束变成系统长期可演进的基础。
业务系统的可靠性至少包含四层含义:
| 层次 | 关注问题 | 典型例子 |
|---|---|---|
| 可用性 | 用户能不能完成关键动作 | 能否搜索商品、进入结算、提交订单、完成支付 |
| 正确性 | 系统结果是否符合业务规则 | 不能超卖、不能重复扣款、不能错误计价 |
| 可恢复性 | 出错后能否回到一致状态 | 支付回调失败后可对账,库存预占失败后可释放 |
| 可解释性 | 问题能否被定位、审计和复盘 | 一笔订单为什么卡住,哪一步失败,谁处理过 |
很多团队把可靠性理解成“服务不挂”。这只是最低层目标。对电商、支付、交易、供应链这类业务系统来说,更重要的是关键业务结果不出错。
例如:
- 商品详情页推荐模块失败,可以降级为空。
- 搜索排序模型失败,可以切到默认排序。
- 购物车展示价失败,可以提示“以结算页为准”。
- 订单创建失败,不能生成半张订单。
- 支付回调重复,不能重复推进订单状态。
- 库存扣减失败,不能假装扣减成功。
可靠性工程的第一原则是:按业务重要性分层保护,而不是按技术组件平均用力。
很多业务系统的真实发展路径,并不是一开始就有完整的平台化和治理体系,而是先为了验证业务价值快速上线,再逐步补齐可靠性能力。这种路径本身没有问题,问题在于团队常常把“已经能跑”误当成“已经可生产”。
以供应商 Feed 同步系统为例,一个典型的演进节奏通常如下:
| 阶段 | 目标 | 常见范围 |
|---|---|---|
PoC | 先验证链路能不能跑通 | 申请 Partner、下载样例 Feed、实现单城市同步和 DB 入库 |
MVP | 先支撑业务启动 | 打通全 Pipeline,引入 Airflow 调度,补上基础监控 |
| 生产化 | 让系统在增长中保持稳定 | 分片、限流、告警、测试大规模数据和高频调度 |
| 优化迭代 | 用真实数据反向优化成本和质量 | 监控实际变更率,动态调整同步频率和资源水位 |
如果从时间上看,这个过程也很常见:
PoC(1-2 周):申请 Partner,下载样例 Feed,实现单城市同步和 DB 入库,重点是确认字段能不能解析、业务流程能不能闭环。MVP(4-6 周):扩成完整 Pipeline,引入 Airflow,补上基础成功率、延迟、失败量监控,重点是让业务能开始用,而不是先把所有平台能力做满。生产化(2-4 周):补齐分片、限流、告警、容量验证和大规模测试,重点是防止数据量、城市数、供应商数一上来就把早期实现拖垮。优化迭代:上线后持续监控实际变更率,再调整抓取和同步频率,避免既没有必要地高频拉取,也不要因为频率过低让数据失去业务价值。
这里最容易被忽略的是:PoC 和 MVP 阶段为了抢时间,通常会暂时跳过很多生产治理动作,例如分片策略、重试预算、告警分级、回放工具、压测、审计、容量模型。这在阶段性上是合理的,但前提是团队明确知道这些是技术债,而不是“以后再说的细节”。
一个成熟团队会把这种分阶段建设显式写进方案里:
- 第一阶段定义“验证什么能不做”。
- 第二阶段定义“哪些能力缺失仍可控”。
- 第三阶段明确“达到什么标准才算可生产”。
- 上线后用真实指标反向推动优化,而不是一直凭经验拍频率、拍容量、拍阈值。
所以,可靠性治理并不要求团队一开始就过度设计;它真正要求的是:在快速上线之后,能系统性地把生产化和治理工作补回来,并且让这些工作进入版本计划,而不是永远排在需求之后。
3.4.1 SLI、SLO、SLA 与错误预算:把稳定性变成治理语言
可靠性建设必须先量化目标。没有目标,监控只是图表;没有目标,告警只是噪声;没有目标,稳定性投入永远会输给短期需求。
最常用的概念是 SLI、SLO 和 SLA。
| 指标 | 含义 | 谁使用 | 示例 |
|---|---|---|---|
| SLI | Service Level Indicator,服务表现指标 | 工程团队 | 订单创建成功率、P99 延迟、支付回调处理延迟 |
| SLO | Service Level Objective,内部稳定性目标 | 工程与业务团队 | 订单创建 99.95% 在 2s 内完成 |
| SLA | Service Level Agreement,对外协议承诺 | 客户、法务、商业团队 | 可用性低于承诺时补偿客户 |
SLI 是观测,SLO 是目标,SLA 是契约。三者不要混用。很多团队只有 SLA,却没有可执行的 SLO;最后稳定性问题就只能靠事故后吵架,而不是事故前治理。
3.4.1.1 业务系统常见 SLI
SLI 不能只看接口成功率。业务系统至少要覆盖下面几类指标:
| 类型 | 指标 | 说明 |
|---|---|---|
| 成功率 | 下单成功率、支付成功率、库存预占成功率 | 用户是否完成关键动作 |
| 延迟 | PDP P99、结算 Init P99、创单 P99、支付回调处理耗时 | 用户等待多久,链路是否退化 |
| 正确性 | 价格差异率、超卖次数、重复扣款次数、非法状态迁移次数 | 是否出现资损或业务错误 |
| 新鲜度 | 商品索引同步延迟、库存同步延迟、供应商数据延迟 | 读模型和权威数据相差多久 |
| 完整性 | 消息堆积、DLQ 数量、补偿任务 backlog | 异步链路是否持续收敛 |
| 饱和度 | 线程池、连接池、队列、CPU、内存、磁盘、网络 | 系统距离崩溃还有多远 |
面向用户体验的 SLI 更适合做告警;面向资源和组件的 SLI 更适合做排障。不要把两者颠倒。CPU 高不一定是事故,但下单成功率跌了通常就是事故。
3.4.1.2 按用户旅程定义 SLO
业务系统的 SLO 最好围绕用户旅程定义,而不是围绕单个接口定义。
| 用户旅程 | 推荐 SLO | 说明 |
|---|---|---|
| 商品浏览 | 99.9% 商品详情页在 1s 内返回基础信息 | 推荐、营销标签可降级 |
| 搜索导购 | 99.5% 搜索请求在 800ms 内返回结果 | 个性化排序失败可回退默认排序 |
| 结算初始化 | 99.5% 结算页在 2s 内完成商品、库存、价格校验 | 非关键权益可降级 |
| 提交订单 | 99.95% 创单请求在 3s 内明确成功或失败 | 不允许返回未知半成功 |
| 支付回调 | 99.99% 合法回调在 30s 内完成幂等处理 | 延迟处理必须进入对账 |
| 商品发布 | 99% 商品发布后 5 分钟内同步搜索和缓存 | 允许短暂最终一致 |
这里有两个关键点:
第一,SLO 要区分核心与非核心能力。推荐模块和支付回调不能使用同一套目标。第二,SLO 要明确时间窗口。比如“商品发布后 5 分钟内同步搜索”,比“搜索要及时”更可执行。
3.4.1.3 错误预算
错误预算是 SLO 的另一面。
如果订单创建成功率 SLO 是 99.95%,那么一个月内允许的失败预算是 0.05%。预算消耗过快,说明系统稳定性正在透支。此时团队应该收紧发布、暂停高风险变更、优先修复稳定性问题。
错误预算的价值不是“允许失败”,而是把稳定性和研发速度放进同一个决策框架:
- 预算充足:可以更积极发布、实验和重构。
- 预算快速消耗:需要减少变更,治理错误率最高的链路。
- 预算耗尽:进入稳定性冻结期,只允许修复和低风险变更。
没有错误预算时,稳定性讨论容易变成抽象争论;有了错误预算,讨论会变成数据驱动。
3.4.2 故障模型:先知道系统会怎样坏
可靠性工程不是等故障发生后再补救,而是在设计阶段预判故障类型。
业务系统常见故障可以分为九类:
| 故障类型 | 典型表现 | 常见根因 |
|---|---|---|
| 流量冲击 | QPS 突增、队列堆积、接口超时 | 大促、热点商品、爬虫、重试风暴 |
| 慢依赖 | P99 上升、线程池耗尽 | 下游 DB 慢查询、RPC 抖动、外部供应商变慢 |
| 部分失败 | 只有某地区、某品类、某渠道异常 | 分片热点、灰度版本、地域网络、配置差异 |
| 数据不一致 | 支付成功订单未更新、搜索仍展示下架商品 | 异步消息丢失、补偿失败、对账缺失 |
| 资源耗尽 | 连接池满、磁盘满、内存 OOM、Pod 重启 | 容量低估、泄漏、突发批任务 |
| 发布回归 | 新版本错误率升高 | 兼容性不足、灰度缺失、回滚困难 |
| 配置事故 | 开关误开、阈值错误、路由错误 | 配置无审计、无灰度、无校验 |
| 外部依赖故障 | 支付渠道、短信、供应商接口异常 | 第三方不可控、超时和降级不足 |
| 人为操作事故 | 误删数据、错误补偿、误切流 | 权限过大、缺少审批和演练 |
做可靠性设计时,要针对每类故障问三个问题:
- 如何提前发现?
- 如何限制影响范围?
- 如何恢复业务和数据?
只回答“加监控”是不够的。监控只能发现问题,不能自动阻止级联故障。要让故障可控,还需要隔离、限流、熔断、降级、补偿、对账、回滚和应急流程。
3.4.3 故障域与依赖分级
大型业务系统真正危险的不是单点故障,而是故障传播。一个推荐接口变慢,拖慢商品详情;商品详情线程池被打满,网关堆积;网关重试放大流量,最终核心交易接口也被拖死。这就是故障域没有隔离。
3.4.3.1 故障域
故障域可以从多个维度划分:
| 维度 | 示例 | 隔离手段 |
|---|---|---|
| 业务域 | 商品、交易、支付、履约 | 服务边界、数据边界、独立发布 |
| 流量域 | 普通流量、大促流量、爬虫流量 | 网关限流、活动专用集群、风控拦截 |
| 用户域 | C 端用户、商家、运营后台 | 租户限流、后台任务隔离 |
| 地域域 | 华东、华南、海外 | 多地域部署、就近路由、区域熔断 |
| 资源域 | 线程池、连接池、队列、缓存 | 舱壁隔离、配额、独立集群 |
| 数据域 | 分库分表、冷热数据、租户分片 | 分片键设计、归档、读写隔离 |
故障域设计的目标是:一个局部问题不应该拖垮全站,一个非核心链路不应该拖垮核心链路,一个租户或热点商品不应该耗尽公共资源。
3.4.3.2 依赖分级
依赖必须分级。否则所有失败都会被当作同等严重,系统就很难降级。
| 依赖等级 | 定义 | 失败策略 |
|---|---|---|
| 强依赖 | 没有它不能继续业务 | 快速失败、阻断、进入补偿或人工处理 |
| 弱依赖 | 失败后可提供降级体验 | 超时后返回默认值、隐藏模块、异步补齐 |
| 后置依赖 | 不影响当前用户响应 | 写 Outbox、异步重试、DLQ |
| 观测依赖 | 只影响日志、埋点、统计 | 本地缓冲、采样、失败不阻断主链路 |
以结算页为例:
- 商品上下架和库存预占是强依赖。
- 营销标签、推荐凑单提示是弱依赖。
- 积分发放、通知、推荐特征回流是后置依赖。
- 埋点和日志上传是观测依赖。
只有把依赖分级写进设计,超时、重试、熔断、降级才有依据。
3.4.4 高可用架构原则
高可用不是“多部署几个实例”。它是一组贯穿应用、数据、基础设施和组织流程的设计原则。
3.4.4.1 冗余
冗余用于消除单点。
| 层次 | 常见做法 | 注意事项 |
|---|---|---|
| 应用层 | 多实例、多 Pod、多可用区 | 实例无状态,健康检查真实反映业务可用性 |
| 网关层 | 多入口、多 LB、DNS 容灾 | 避免单一入口和错误路由配置 |
| 数据层 | 主从、半同步、多副本、备份 | 复制延迟、故障切换、恢复演练 |
| 缓存层 | Redis Cluster、多副本、热点隔离 | 主从切换期间的一致性和热点保护 |
| 消息层 | 多 Broker、多分区、多副本 | ISR、磁盘、消费积压和重平衡 |
| 跨地域 | 同城多活、异地灾备 | 数据冲突、流量切换、成本与复杂度 |
冗余必须配合切换能力。没有演练过的备用链路,不能算可靠性能力。
3.4.4.2 无状态与快速恢复
应用服务尽量无状态。状态外置到数据库、缓存、对象存储或消息系统。这样实例可以快速扩缩容和替换。
但“无状态”不等于“没有状态”。业务状态仍然存在,只是不能绑死在某个进程内。常见问题包括:
- 本地内存保存用户会话,实例重启后登录态丢失。
- 本地队列保存待处理任务,进程崩溃后任务丢失。
- 本地缓存没有版本控制,发布后读到旧规则。
可靠的做法是明确状态归属:什么状态可以本地缓存,什么状态必须持久化,什么状态需要幂等恢复。
3.4.4.3 舱壁隔离
舱壁隔离来自船舱设计:一个舱进水,不能让整条船沉没。
业务系统里的舱壁隔离包括:
- 不同下游依赖使用不同线程池和连接池。
- 核心接口和非核心接口使用不同资源池。
- 后台批任务和在线请求隔离。
- 大客户、活动、渠道流量隔离。
- 支付、订单、库存等强一致链路与推荐、画像、报表隔离。
典型反模式是一个应用只有一个全局线程池。推荐服务变慢时,线程池被推荐请求占满,订单请求也无法执行。这种故障不是推荐系统故障,而是资源隔离设计失败。
3.4.4.4 优雅退化
优雅退化不是“随便返回默认值”,而是在业务允许的边界内选择更低质量但可接受的服务。
| 场景 | 可接受降级 | 不可接受降级 |
|---|---|---|
| 商品详情 | 隐藏推荐、展示基础信息 | 展示已下架商品为可购买 |
| 搜索导购 | 默认排序、隐藏个性化标签 | 返回过期价格导致误导购买 |
| 购物车 | 部分商品状态显示“稍后刷新” | 静默丢失用户加购 |
| 结算 | 非关键优惠提示缺失 | 跳过库存校验继续下单 |
| 支付 | 渠道切换、挂起等待对账 | 未确认支付却标记成功 |
每个降级策略都应该写清楚:触发条件、用户文案、数据后果、恢复方式、审计记录和负责人。
3.4.5 保护机制:超时、重试、幂等、限流、熔断、降级
保护机制的目标是阻止小故障变成大事故。
3.4.5.1 超时预算
超时不能拍脑袋设置。一个请求的端到端超时应该拆成多个依赖的预算。
例如结算 Init 总超时 2s:
| 依赖 | 建议预算 | 说明 |
|---|---|---|
| 商品校验 | 150ms | 批量查询,强依赖 |
| 库存预检查 | 300ms | 强依赖,失败阻断 |
| 计价试算 | 500ms | 强依赖,展示链路可降级,提交链路不可降级 |
| 营销试算 | 300ms | 视业务可部分降级 |
| 地址运费 | 300ms | 可缓存,可部分降级 |
| 聚合与序列化 | 200ms | 应用自身预算 |
如果每个下游都默认 3s 超时,一个聚合接口就会在故障时被拖成几十秒。超时预算必须从入口向下传递,不能每一层各自为政。
3.4.5.2 重试
重试只适合瞬时失败,不适合所有失败。
| 错误类型 | 是否重试 | 原因 |
|---|---|---|
| 网络抖动、连接重置 | 可以 | 可能是瞬时问题 |
| 读请求超时 | 可以,但要限制次数 | 注意不要放大流量 |
| 写请求超时 | 谨慎 | 必须有幂等键或事务状态 |
| 参数错误、权限错误 | 不重试 | 重试没有意义 |
| 下游容量不足 | 不应立即重试 | 会加剧故障 |
重试要配合退避和抖动:
第一次失败:等待 50ms
第二次失败:等待 100ms + 随机抖动
第三次失败:等待 200ms + 随机抖动
超过总预算:快速失败
没有退避的重试会形成重试风暴。没有幂等的重试会形成重复副作用。
3.4.5.3 幂等
业务系统里,“只执行一次”通常做不到;“执行多次结果一致”必须做到。
| 场景 | 幂等手段 |
|---|---|
| 创建订单 | idempotency_key + 唯一索引 + 请求摘要 |
| 支付回调 | 渠道流水号唯一约束 + 支付单状态机 |
| 消息消费 | event_id 去重表 + 业务状态条件更新 |
| 库存释放 | 预占单状态机 + 释放流水唯一约束 |
| 优惠券核销 | 券实例状态机 + 核销单唯一约束 |
幂等设计要回答四个问题:
- 幂等键由谁生成?
- 幂等键的作用范围是什么?
- 相同幂等键但请求参数不同怎么办?
- 幂等记录保存多久,如何清理?
仅仅“前端按钮防抖”不是幂等。真正的幂等必须在服务端和存储层兜底。
3.4.5.4 限流
限流不是为了拒绝用户,而是为了保护系统在极端情况下仍然服务核心请求。
常见限流维度:
- API 维度:保护单个接口。
- 用户维度:防止单用户刷爆资源。
- 商家或租户维度:防止大租户影响小租户。
- 商品维度:保护热点商品。
- 活动维度:隔离大促流量。
- 下游依赖维度:保护数据库、缓存、供应商接口。
限流结果也要分层:
| 限流对象 | 返回策略 |
|---|---|
| 非登录爬虫 | 直接拒绝 |
| 普通用户读请求 | 排队或提示稍后重试 |
| 下单请求 | 尽量排队,明确失败语义 |
| 支付回调 | 不建议简单限流,应快速落表后异步处理 |
| 后台批任务 | 降速、暂停、转离线队列 |
支付回调、库存补偿、对账任务这类恢复链路不能和普通流量共用同一个限流策略,否则事故恢复时反而被限住。
3.4.5.5 熔断
熔断解决的是“下游已经异常,继续调用只会浪费资源”的问题。
熔断通常有三种状态:
| 状态 | 行为 |
|---|---|
| 关闭 | 正常调用下游 |
| 打开 | 快速失败或降级,不再打到下游 |
| 半开 | 放少量探测流量,成功后恢复,失败后继续打开 |
熔断触发条件不宜只看错误率,还要看延迟、并发数、超时率和业务错误类型。库存不足不是系统故障,不应该触发熔断;库存服务超时率升高才可能触发熔断。
3.4.5.6 降级
降级要提前设计,而不是事故中临时写代码。
一份合格的降级方案至少包含:
- 哪些功能可以降级。
- 什么条件触发降级。
- 降级后的用户体验是什么。
- 降级是否会造成数据不一致。
- 如何恢复。
- 谁有权限开启。
- 开启和关闭是否有审计。
- 是否演练过。
降级开关必须纳入配置治理。不要让任何人可以在没有审批、没有审计、没有回滚方案的情况下打开“允许使用缓存价创单”这类高风险开关。
3.4.6 变更治理:多数生产事故,根子都在变更
生产系统不是运行时自己突然变坏的,更多时候,是某次代码发布、配置修改、数据回填、规则切换或外部依赖接入,把原本潜伏的脆弱性显露出来。很多团队把稳定性问题归咎于“流量太大”或“依赖太差”,但回头复盘会发现,真正点燃事故的火种,往往还是变更。
所以,成熟的生产治理一定把变更当成最高风险动作之一,而不是默认“上线成功就代表变更安全”。一套可执行的变更治理,至少应该包含下面几层:
- 风险分级:代码发布、配置变更、数据变更、权限变更不能用同一套审批标准。
- 小步灰度:任何高风险变更都应先在小流量、小地域、小租户范围内验证。
- 可逆设计:上线前先确认怎么回滚,而不是出事后再讨论能不能回。
- 冻结机制:错误预算快速消耗、大促期间、关键结算窗口应主动收紧变更。
- 证据闭环:每一次变更都能追溯到版本、负责人、影响范围和回滚动作。
从实践经验看,下面几类变更尤其危险:
| 变更类型 | 常见误区 | 推荐治理方式 |
|---|---|---|
| 代码发布 | 以为单测过了就安全 | 灰度、回滚预案、关键路径 watch |
| 配置变更 | 以为“只是改个参数”风险很低 | schema 校验、双人审批、灰度生效 |
| 数据变更 | 以为脚本只跑一次不会出事 | 限速、分批、影子校验、回填回滚 |
| 规则变更 | 以为规则平台天然安全 | 版本化、命中监控、误杀恢复 |
| 外部接入 | 以为先接上再慢慢治理 | 适配层、隔离池、降级与熔断预案 |
治理变更的核心不是拖慢研发,而是让团队始终知道:哪些变更在消耗系统信用,哪些变更在透支未来恢复空间。
3.4.7 混沌工程:用有计划的破坏逼出真实问题
很多技术债平时看不见,不是因为它不存在,而是因为系统还没有被真正打到边界。压测能回答“流量大了会怎样”,但不一定能回答“依赖慢了会怎样”“配置错了会怎样”“半数实例异常时系统是否还能自我保护”。这正是混沌工程的价值。
混沌工程不是为了制造戏剧化故障,更不是为了证明团队多勇敢,而是为了在可控范围内提前暴露脆弱点,逼着系统把“理论上的保护机制”变成“现实里真的有效的保护机制”。
一次像样的混沌实验,至少要说清楚四件事:
- 这次要验证什么假设。
- 影响范围控制在哪里。
- 触发后预期的保护行为是什么。
- 失败后要沉淀成什么治理动作。
例如:
- 注入库存服务 30% 超时,验证结算链路是否正确触发限流、降级和熔断。
- 关闭某个支付渠道回调消费,验证对账和补偿是否能在时间窗口内接管。
- 模拟推荐服务高延迟,验证是否会挤占订单线程池。
- 对配置中心推送错误规则到灰度环境,验证审批、审计和回滚是否顺畅。
真正有价值的混沌工程,不是最后写一句“演练完成,系统稳定”,而是逼出具体治理结果,例如:
- 某个共享线程池必须拆分。
- 某个补偿任务必须增加幂等键。
- 某个降级开关必须增加审计和审批。
- 某条告警必须改成基于业务症状而不是机器指标。
混沌工程的终点,不是“我们敢破坏系统”,而是“我们终于知道系统会怎么坏,并且愿意在真实事故前把这些问题修掉”。
3.5 应急保障篇:现代可观测性与 1-5-10 应急时钟
应急保障的价值,不是证明系统已经足够完美,而是在问题必然发生时,让团队能在最短时间看见问题、切断扩散、恢复核心业务,并为后续治理留下足够的上下文和证据链。
3.5.1 监控系统:从“有图表”到“能救命”
监控系统是可靠性工程里最容易被低估、也最容易做成表面工程的一部分。很多团队有成百上千张图,但事故来临时仍然不知道哪里坏了;有几百条告警,但真正 P0 被淹没在噪声里。
一个成熟的监控系统必须同时满足四个目标:
- 发现问题:用户感知异常前后,系统能尽快发现。
- 定位问题:能从业务指标追到服务、依赖、资源和代码路径。
- 解释问题:能说明影响范围、开始时间、变化点和根因候选。
- 推动恢复:告警附带负责人、预案、看板和止血动作。
监控不是运维团队单独负责的事情。业务系统的监控指标必须由业务研发设计,由服务 owner 负责维护。
3.5.1.1 监控分层模型
建议按五层建设监控。
| 层次 | 目标 | 典型指标 |
|---|---|---|
| 业务监控 | 判断用户和业务是否受影响 | 下单成功率、支付成功率、GMV、转化漏斗、资损金额 |
| 链路监控 | 判断关键路径哪一步异常 | 结算 Init、库存预占、计价、创单、支付回调各步耗时和成功率 |
| 应用监控 | 判断服务自身是否健康 | QPS、错误率、P99、线程池、连接池、队列、依赖耗时 |
| 中间件监控 | 判断基础组件是否退化 | MySQL、Redis、Kafka、Elasticsearch、对象存储 |
| 基础设施监控 | 判断运行环境是否饱和 | CPU、内存、磁盘、网络、容器重启、节点压力 |
这五层要能上下联动。支付成功率下降时,应该能继续下钻到支付回调延迟、支付渠道错误码、数据库锁等待、消息积压和具体版本变更。
3.5.1.2 业务监控
业务监控是最接近用户体验的一层,也是告警优先级最高的一层。
电商系统建议至少建设下面这些业务指标:
| 业务域 | 核心指标 | 异常含义 |
|---|---|---|
| 商品 | 商品发布成功率、上下架延迟、商品索引同步延迟 | 商品无法发布或前台不可见 |
| 搜索 | 搜索成功率、零结果率、召回数量分布、搜索转化率 | 搜索体验异常或索引错误 |
| 购物车 | 加购成功率、购物车读取成功率、合并失败率 | 用户意图丢失或体验下降 |
| 结算 | 结算 Init 成功率、库存预占失败率、价格试算失败率 | 交易前置链路异常 |
| 订单 | 创单成功率、订单状态推进延迟、非法状态迁移数 | 交易主链路异常 |
| 支付 | 支付发起成功率、渠道回调成功率、支付单挂起数 | 资金链路异常 |
| 库存 | 超卖次数、预占释放延迟、库存对账差异 | 资损或履约风险 |
| 供应商 | 同步成功率、数据新鲜度、解析失败率、DLQ 数量 | 外部供给质量异常 |
业务监控要特别注意分桶。只看全站平均值会掩盖局部故障。常见分桶包括:
- 地域:国家、城市、机房、可用区。
- 端:iOS、Android、Web、小程序、开放 API。
- 品类:实物、酒店、机票、券码、充值。
- 渠道:自然流量、活动流量、推荐流量、商家后台。
- 用户类型:新客、老客、会员、大客户、商家。
- 支付渠道:微信、支付宝、信用卡、余额、第三方渠道。
例如全站支付成功率只下降 0.2%,可能看起来不严重;但如果某个支付渠道在某个国家下降 20%,就是明确事故。
3.5.1.3 应用监控:RED 与 USE
在线服务常用 RED 方法:
| 指标 | 含义 | 例子 |
|---|---|---|
| Rate | 请求速率 | QPS、每分钟请求数 |
| Errors | 错误 | 5xx、业务错误码、依赖错误 |
| Duration | 延迟 | P50、P95、P99、最大值 |
资源类组件常用 USE 方法:
| 指标 | 含义 | 例子 |
|---|---|---|
| Utilization | 使用率 | CPU、内存、磁盘、连接池使用率 |
| Saturation | 饱和度 | 队列长度、线程池排队、Kafka lag |
| Errors | 错误 | 网络错误、磁盘错误、连接失败 |
对业务服务来说,应用监控至少应包含:
- 每个 API 的 QPS、成功率、错误码分布。
- P50、P90、P95、P99、P999 延迟。
- 入口请求大小、响应大小、批量大小分布。
- 下游依赖耗时、成功率、超时率、熔断次数。
- 线程池活跃数、队列长度、拒绝数。
- 数据库连接池使用率、等待时间、获取失败数。
- 本地缓存命中率、缓存大小、淘汰数。
- 消息生产成功率、消费延迟、重试次数。
- GC、goroutine/thread 数、堆内存、FD 数。
P99 比平均值重要。平均延迟稳定,不代表用户体验稳定。很多事故都是 P99 先升高,然后队列堆积,最后错误率爆炸。
3.5.1.4 中间件监控
中间件监控要关注“是否还能承载业务语义”,而不仅是“组件是否活着”。
MySQL
| 指标 | 关注点 |
|---|---|
| QPS / TPS | 流量是否异常 |
| 慢 SQL 数量 | 访问模式是否退化 |
| 连接数 / 连接池等待 | 应用是否被 DB 卡住 |
| 行锁等待 / 死锁 | 事务设计是否有问题 |
| 主从延迟 | 读写分离是否会读旧数据 |
| Buffer Pool 命中率 | 内存是否足够 |
| 磁盘 IOPS / fsync 延迟 | 存储是否饱和 |
| binlog 延迟与大小 | 数据订阅、恢复能力是否受影响 |
Redis
| 指标 | 关注点 |
|---|---|
| QPS / 延迟 | 是否出现热点或网络抖动 |
| 命中率 | 缓存是否有效 |
| 内存使用 / evicted keys | 是否正在淘汰关键数据 |
| hot key / big key | 是否存在局部热点 |
| blocked clients | 是否有慢命令阻塞 |
| replication lag | 主从切换是否有数据风险 |
| 连接数 | 客户端是否泄漏连接 |
Kafka
| 指标 | 关注点 |
|---|---|
| Produce 成功率和耗时 | 上游是否能写入 |
| Consumer lag | 下游是否跟得上 |
| 分区倾斜 | key 设计是否导致热点 |
| ISR 收缩 | 副本可靠性是否下降 |
| Broker 磁盘水位 | 是否接近不可写 |
| Rebalance 次数 | 消费组是否不稳定 |
| DLQ 数量 | 是否存在无法自动处理的数据 |
Elasticsearch
| 指标 | 关注点 |
|---|---|
| 查询延迟和错误率 | 搜索体验是否退化 |
| rejected / thread pool queue | 查询或写入是否过载 |
| JVM heap / GC | 集群是否接近不稳定 |
| segment 数量 | merge 压力是否过大 |
| refresh / indexing 延迟 | 索引新鲜度 |
| shard 分布 | 是否存在热点分片 |
| circuit breaker | 查询是否触发内存保护 |
中间件指标要和业务指标联动。例如搜索零结果率升高,可能不是 ES 宕机,而是商品索引同步延迟或某个类目 mapping 错误。
3.5.1.5 指标设计规范
指标设计不规范,监控平台很快会变成垃圾场。
命名
建议统一命名:
<domain>_<component>_<metric>_<unit>
示例:
order_create_requests_total
order_create_latency_seconds
payment_callback_failures_total
inventory_reservation_conflicts_total
checkout_init_dependency_latency_seconds
标签
常用标签:
| 标签 | 示例 |
|---|---|
service | order-service |
env | prod、staging |
region | sg、hk、sh |
cluster | primary、canary |
endpoint | CreateOrder |
method | GET、POST |
result | success、failure |
error_code | INVENTORY_TIMEOUT |
dependency | inventory-service |
biz_type | physical、hotel、voucher |
不要把高基数字段放进指标标签,例如:
user_idorder_idsku_idtrace_idrequest_id
这些字段应该进入日志或追踪,不应该进入 metrics 标签。否则监控系统会被高基数打爆。
直方图桶
延迟指标建议使用 histogram,而不是只记录平均值。桶要贴近业务 SLO。
例如创单延迟 SLO 是 3s,可以设置:
50ms, 100ms, 200ms, 500ms, 1s, 2s, 3s, 5s, 10s
如果桶全是默认值,P99 可能无法准确表达业务目标。
3.5.1.6 日志系统
日志用于回答“这一次具体发生了什么”。
成熟的业务日志应该是结构化的,而不是只打印自然语言。
{
"timestamp": "2026-05-07T10:30:00+08:00",
"level": "ERROR",
"service": "order-service",
"trace_id": "4f8c9f...",
"span_id": "a81d...",
"operation": "CreateOrder",
"user_id_hash": "u_9d21",
"order_id": "o_123456",
"idempotency_key": "idem_abc",
"error_code": "INVENTORY_RESERVE_TIMEOUT",
"dependency": "inventory-service",
"latency_ms": 850,
"message": "reserve inventory timeout"
}
日志设计要注意:
- 每条关键链路日志必须包含
trace_id。 - 写操作日志必须包含业务 ID 和幂等键。
- 错误日志必须包含标准错误码,而不是只打印异常堆栈。
- 敏感信息要脱敏,例如手机号、证件号、银行卡号。
- 日志级别要有规范,不能把正常业务失败都打成 ERROR。
- 采样要谨慎,关键交易失败日志不能被采样丢掉。
日志不是越多越好。无结构、无字段、无错误码的日志越多,排障越慢。
3.5.1.7 链路追踪
链路追踪用于回答“慢在哪里、卡在哪一跳”。
一次完整交易链路可能是:
Gateway
-> Checkout Service
-> Product Service
-> Pricing Service
-> Inventory Service
-> Marketing Service
-> Order Service
-> MySQL
-> Outbox
-> Kafka
-> Payment Service
追踪系统需要做到:
- 网关生成或透传
trace_id。 - RPC、HTTP、MQ 都要传递上下文。
- 每个 Span 标记服务名、方法、依赖、错误码、耗时。
- 异步消息要把生产端和消费端通过 trace 或 event id 关联。
- 对慢请求和错误请求提高采样率。
关键 Span 建议包含:
| 字段 | 示例 |
|---|---|
biz.operation | checkout_init、create_order、payment_callback |
biz.id | order_id、payment_id、checkout_id |
dependency | inventory-service |
error_code | INVENTORY_TIMEOUT |
retry_count | 2 |
degraded | true |
idempotency_hit | false |
追踪不要只服务研发排障,也要服务事故复盘。事故时间线中每个关键业务动作都应该能找到对应 trace。
3.5.1.8 告警体系
告警不是“指标超过阈值发消息”。告警的目标是把正确的人,在正确时间,带到正确现场。
告警优先级建议如下:
| 等级 | 定义 | 示例 | 响应 |
|---|---|---|---|
| P0 | 核心业务大面积不可用或有资损风险 | 下单成功率大跌、重复扣款、支付状态错乱 | 立即电话,事故群,负责人到场 |
| P1 | 核心链路局部异常或持续退化 | 某地区支付失败率升高、库存预占超时 | 10 分钟内响应 |
| P2 | 非核心功能异常或有恶化趋势 | 推荐不可用、某批任务失败 | 工作时间处理或值班处理 |
| P3 | 观察项或容量预警 | 磁盘水位增长、连接池接近阈值 | 工单跟进 |
告警要优先基于症状,而不是原因。
好的 P0 告警:
订单创建成功率 5 分钟内低于 98%,影响全部用户
较差的 P0 告警:
order-service CPU > 80%
CPU 高可能只是正常流量,也可能已经自动扩容;订单成功率下降才是真正用户影响。
3.5.1.9 SLO 燃烧率告警
固定阈值容易误报或漏报。更成熟的方式是错误预算燃烧率告警。
例如某接口 30 天 SLO 是 99.9%,错误预算是 0.1%。如果 5 分钟内错误率达到 5%,虽然只持续很短时间,但它燃烧预算的速度非常快,应立即告警。
常见做法是多窗口组合:
| 窗口 | 作用 |
|---|---|
| 5 分钟 | 快速发现突发事故 |
| 30 分钟 | 过滤短暂抖动 |
| 2 小时 | 发现持续退化 |
| 24 小时 | 发现慢性问题 |
示例规则:
groups:
- name: order-slo
rules:
- alert: OrderCreateHighBurnRate
expr: |
(
sum(rate(order_create_requests_total{result="failure"}[5m]))
/
sum(rate(order_create_requests_total[5m]))
) > 0.02
for: 5m
labels:
severity: P0
service: order-service
annotations:
summary: "Order create failure rate is burning error budget fast"
runbook: "https://runbook.example.com/order-create"
真实生产中还要叠加 30 分钟窗口,避免短暂毛刺直接打爆电话。
3.5.1.10 告警治理
告警治理和代码治理一样重要。没有治理,告警会腐烂。
每条告警都应该有:
- 明确 owner。
- 明确严重级别。
- 明确触发条件。
- 明确恢复条件。
- 明确 runbook。
- 明确看板链接。
- 明确是否需要夜间唤醒。
- 明确抑制和去重策略。
告警噪声常见来源:
| 噪声来源 | 治理方式 |
|---|---|
| 阈值过低 | 使用历史基线或分位数 |
| 同根因多告警 | 告警聚合和抑制 |
| 非业务影响告警夜间唤醒 | 调整严重级别 |
| 发布期间短暂波动 | 结合变更事件和灰度窗口 |
| 无 owner 告警 | 直接下线或补 ownership |
| 长期无人处理 | 每周告警复盘 |
一个很实用的指标是:每周每个 on-call 被夜间唤醒次数。如果次数持续过高,团队会进入告警疲劳,真正事故反而没人敏感。
3.5.1.11 看板设计
看板要按排障路径设计,而不是按组件堆图。
推荐四类看板:
业务驾驶舱
- GMV、订单量、支付成功率。
- 搜索、加购、结算、创单、支付漏斗。
- 各端、各地区、各品类分桶。
- 当前事故和变更事件。
核心链路看板
- 结算 Init 每一步成功率和耗时。
- 创单 Saga 每一步成功率和耗时。
- 支付回调处理延迟。
- 补偿任务 backlog。
服务看板
- API QPS、错误率、P99。
- 下游依赖耗时。
- 线程池、连接池、队列。
- 当前版本、实例数、重启次数。
中间件看板
- DB 慢 SQL、锁等待、主从延迟。
- Redis 命中率、热点 key、内存淘汰。
- Kafka lag、DLQ、分区倾斜。
- ES 查询延迟、rejected、heap、shard 状态。
看板第一屏应该回答三个问题:
- 用户是否受影响?
- 影响范围多大?
- 当前最可疑的故障域在哪里?
不要让 on-call 在事故中从几十张图里猜。
3.5.1.12 监控覆盖检查清单
上线前可以用下面的清单检查监控是否完整:
| 检查项 | 必须回答 |
|---|---|
| 业务成功率 | 核心动作是否有成功率指标 |
| 业务延迟 | 用户关键等待路径是否有 P99 |
| 错误码 | 是否区分业务失败和系统失败 |
| 依赖指标 | 每个下游是否有成功率、耗时、超时 |
| 资源饱和 | 线程池、连接池、队列是否可见 |
| 数据一致性 | 是否有对账差异、补偿 backlog、DLQ |
| 分桶维度 | 是否能按地区、端、品类、渠道下钻 |
| 变更关联 | 看板是否能看到版本、配置、开关变化 |
| 告警 owner | 每条 P0/P1 是否有人负责 |
| Runbook | 告警是否附带处理步骤 |
监控系统做到这个程度,才真正有资格说“线上可观测”。
3.5.2 应急响应:先止血,再定位,再恢复
事故处理中最常见的错误,是一开始就追根因。真正的事故响应顺序应该是:
- 判断影响范围。
- 保护核心链路。
- 止血和恢复用户体验。
- 保留证据。
- 定位根因。
- 修复和复盘。
3.5.2.1 事故角色
P0/P1 事故建议明确角色:
| 角色 | 职责 |
|---|---|
| Incident Commander | 统一指挥,决定止血动作 |
| Tech Lead | 技术定位和修复方案 |
| Operator | 执行切流、回滚、开关、扩容 |
| Communicator | 同步业务、客服、管理层 |
| Scribe | 记录时间线和关键决策 |
事故中最怕多人同时操作、没人记录、没人统一决策。角色清晰比技术高超更重要。
3.5.2.2 常见止血动作
| 场景 | 止血动作 |
|---|---|
| 新版本错误率升高 | 立即回滚或停止灰度 |
| 单地域异常 | 摘除地域流量或切到备用区域 |
| 下游依赖慢 | 熔断、降级、限制并发 |
| 数据库打满 | 限流写入、暂停批任务、扩容只读 |
| Kafka 积压 | 扩消费者、暂停低优先级生产、隔离热点 topic |
| 支付渠道异常 | 切支付渠道、挂起订单、启动渠道对账 |
| 商品错误上架 | 下架、冻结库存、停止投放 |
| 价格错误 | 关闭活动、冻结订单、通知客服和法务 |
止血动作要提前演练。事故中临时决定“能不能关这个开关”,通常已经晚了。
3.5.2.3 事故时间线
事故复盘依赖时间线。建议记录:
- 第一次异常指标出现时间。
- 第一次告警时间。
- 人工响应时间。
- 影响范围变化。
- 每次操作时间和操作人。
- 关键判断依据。
- 恢复时间。
- 根因确认时间。
没有时间线,复盘会变成各说各话。
3.5.3 容量规划、压测与故障演练
容量规划不是“机器够不够”,而是“业务增长和流量模型变化时,核心链路是否还能维持 SLO”。
3.5.3.1 容量估算
容量估算从业务漏斗开始。
日活用户 -> 高峰活跃 -> 商品浏览 -> 加购 -> 结算 -> 下单 -> 支付
需要估算:
- 峰值 QPS。
- 并发请求数。
- 数据写入量。
- 热点 key 和热点商品。
- 异步消息量。
- 重试和补偿流量。
- 外部依赖容量。
大促系统尤其要关注流量形态。日常流量通常平滑,大促流量可能在秒级集中爆发。平均 QPS 对大促没有意义。
3.5.3.2 压测
压测要验证三件事:
- 系统上限在哪里。
- 退化方式是否符合预期。
- 保护机制是否生效。
压测类型:
| 类型 | 目标 |
|---|---|
| 单服务压测 | 找服务自身瓶颈 |
| 全链路压测 | 验证端到端能力 |
| 影子流量 | 用真实流量分布验证读链路 |
| 限流压测 | 验证限流阈值和用户体验 |
| 降级压测 | 验证弱依赖失败时主链路是否稳定 |
| 恢复压测 | 验证故障恢复后队列和补偿是否追平 |
压测报告不要只写“峰值 QPS”。至少要包含:
- 安全水位。
- 瓶颈资源。
- P99 曲线。
- 错误率拐点。
- 下游依赖压力。
- 扩容建议。
- 降级开关验证结果。
3.5.3.3 故障演练
故障演练比压测更接近真实事故。
常见演练:
- 关闭一个应用实例。
- 让某个下游延迟升高。
- 让 Redis 热 key 失效。
- 让 Kafka 消费者停止。
- 让支付渠道回调延迟。
- 让数据库只读延迟升高。
- 灰度版本返回错误码。
- 配置中心下发错误配置并回滚。
演练后要复盘:
- 告警是否及时。
- 值班是否知道怎么处理。
- 开关是否可用。
- 回滚是否顺畅。
- 数据是否恢复一致。
- 文档是否准确。
未演练的预案,不应被视为可靠性能力。
3.5.4 数据恢复的底线能力:对账、补偿、备份与恢复
业务系统的可靠性最终会落到数据上。服务恢复但数据错了,事故并没有结束。
数据可靠性包括:
| 能力 | 目标 |
|---|---|
| 幂等 | 防止重复副作用 |
| 状态机 | 防止非法状态推进 |
| Outbox | 保证本地事务和消息发送可恢复 |
| 对账 | 发现跨系统不一致 |
| 补偿 | 自动或人工修复不一致 |
| DLQ | 隔离无法自动处理的数据 |
| 备份 | 防止数据不可逆丢失 |
| 恢复演练 | 确认备份真的能恢复 |
备份不是可靠性终点,恢复才是。很多团队有备份,但从未演练恢复;真正事故发生时才发现备份缺字段、权限不可用、恢复耗时不可接受。
关键业务数据至少要定期演练:
- 单表误删恢复。
- 单订单修复。
- 按时间点恢复。
- 跨系统对账修正。
- 消息重放。
- 补偿任务重跑。
数据修复必须有审计。任何人工修复都应该记录操作人、原因、SQL 或工具参数、影响行数、审批单和验证结果。
3.5.5 发布可靠性:多数事故来自变更
线上事故很大比例来自变更:代码发布、配置变更、数据迁移、活动配置、扩容缩容、依赖升级。
发布可靠性要控制三个问题:
- 变更前能否发现风险。
- 变更中能否限制影响。
- 变更后能否快速回滚。
3.5.5.1 灰度发布
灰度发布建议按层推进:
开发环境 -> 测试环境 -> 预发环境 -> 单实例灰度 -> 小流量灰度 -> 分区域灰度 -> 全量
灰度期间必须观察:
- 错误率。
- P99 延迟。
- 业务成功率。
- 下游依赖耗时。
- 新旧版本差异。
- 日志错误码。
如果灰度只看进程是否启动成功,等于没有灰度。
3.5.5.2 配置变更
配置变更和代码发布一样危险,甚至更危险,因为它通常绕过测试。
高风险配置包括:
- 限流阈值。
- 降级开关。
- 价格规则。
- 营销规则。
- 路由规则。
- 支付渠道权重。
- 分库分表配置。
- 库存策略。
配置中心至少应支持:
- 变更审批。
- 灰度下发。
- 版本记录。
- 一键回滚。
- 生效范围展示。
- 变更事件写入监控和日志。
3.5.5.3 数据库变更
数据库变更尤其要谨慎。
常见原则:
- 先兼容旧代码,再发布新代码,最后清理旧字段。
- 大表 DDL 使用在线变更工具。
- 回填任务限速,避免打爆主库。
- 回填有断点续跑和校验。
- 删除字段前至少观察一个版本周期。
- 变更前准备回滚和恢复方案。
很多事故不是 SQL 写错,而是回填任务没有限速,导致主库延迟和线上接口超时。
3.6 数据与资金安全篇:恢复、对账、补偿与资损防控
业务系统真正困难的地方,不是让每一次调用都成功,而是在调用失败、消息丢失、回调乱序、数据延迟、人工误操作和外部系统不可控时,仍然能把业务状态拉回正确轨道。
电商系统尤其如此。一次下单会穿过商品、库存、营销、计价、订单、支付、履约、供应商、搜索、财务等多个系统。任何一个系统都只能保证自己局部正确,却很难在同一个事务里保证全链路同时成功。于是,系统必须接受一个事实:短时间不一致不可避免,长期不收敛不可接受。
对账、补偿、DLQ 和故障恢复,就是让系统从“偶发错误靠人肉排查”走向“差异可发现、失败可隔离、修复可执行、过程可审计”的工程体系。
本章会以电商系统为主线,回答几个关键问题:
- 什么情况下必须做对账,谁是权威数据源?
- 对账发现差异后,如何生成安全的补偿任务?
- 什么错误应该自动重试,什么错误应该进入 DLQ?
- DLQ 为什么不能只是 Kafka 的死信 Topic?
- 支付成功但订单未更新、库存扣了但订单失败、供应商同步一半失败,应该如何恢复?
- 如何设计后台、监控、审计和人工介入流程,让故障恢复真正可运营?
3.6.1 先建立一个判断:恢复能力是主链路的一部分
很多团队把对账和补偿当作“上线后再补”的兜底脚本。这是危险的。业务系统上线第一天,就应该具备基本恢复能力。
因为故障不是例外,而是分布式协作的常态:
| 故障形态 | 电商例子 | 如果没有恢复能力 |
|---|---|---|
| 请求超时 | 库存预占接口超时,但下游实际预占成功 | 订单失败,库存长期占用 |
| 回调丢失 | 支付渠道扣款成功,回调没有送达 | 用户已扣款,订单仍待支付 |
| 消息失败 | 商品已发布,搜索索引事件消费失败 | 前台搜索不到商品或仍展示下架商品 |
| 消费乱序 | 先收到订单取消,再收到支付成功事件 | 状态机被错误推进 |
| 部分成功 | 订单创建成功,优惠券锁定失败 | 用户看到订单异常,券状态不一致 |
| 外部延迟 | 供应商库存更新延迟 10 分钟 | 平台继续售卖已售罄商品 |
| 人工误操作 | 运营错误调整价格或库存 | 需要回滚、冻结和审计 |
可靠的系统不是没有这些问题,而是问题出现后能够自动识别、自动收敛或可控地交给人工处理。
这一章讨论的对象不是单个技术组件,而是一条恢复闭环:
业务事实产生
-> 事件投递 / 状态推进 / 外部交互
-> 差异出现
-> 对账发现差异
-> 差异分类
-> 自动补偿或进入 DLQ
-> 人工修复或重新投递
-> 再次对账确认收敛
-> 形成审计记录和复盘材料
如果这个闭环缺任何一环,系统都会变成“线上看起来能跑,但出了问题谁也说不清”。
3.6.2 四个核心概念
3.6.2.1 对账
对账是用一个或多个权威数据源,比对另一个系统中的派生状态、投影状态或外部状态,发现差异并形成可处理的差异项。
对账不是财务系统专属概念。只要存在下面任意一种情况,就需要对账:
- 跨系统异步同步。
- 事件驱动的最终一致。
- 本地系统与外部渠道协作。
- 缓存、索引、读模型与主数据分离。
- 主链路为了性能接受短窗口不一致。
- 需要审计、追溯和证明业务结果。
例如:
| 对账场景 | 权威数据源 | 被对账对象 | 常见差异 |
|---|---|---|---|
| 支付对账 | 支付渠道账单、银行流水 | 本地支付单、订单状态 | 渠道成功,本地未支付 |
| 库存对账 | 库存流水账本 | 库存余额快照、Redis 库存 | 流水扣减成功,余额未刷新 |
| 订单对账 | 订单状态日志、支付事实 | 订单主状态、履约状态 | 支付成功但订单未推进 |
| 商品对账 | 商品正式表、发布版本 | 搜索索引、缓存、推荐特征 | 商品下架但搜索仍可见 |
| 营销对账 | 券实例状态、核销流水 | 订单优惠快照、用户券包 | 订单取消但券未释放 |
| 供应商对账 | 供应商接口或文件 | 平台商品、库存、价格 | 供应商下架但平台仍售卖 |
| 消息对账 | Outbox 表 | Kafka 消费进度、下游处理表 | 事件已生成但未被消费 |
对账的本质是:用更可信的事实校准更容易漂移的状态。
3.6.2.2 补偿
补偿是针对已发现的不一致,执行一个可幂等、可重试、可审计的修复动作,让系统重新收敛。
补偿不是“反向操作”这么简单。真实业务里,补偿动作要遵守状态机、资金规则、库存规则和用户体验边界。
例如:
- 库存预占成功但订单创建失败,可以释放库存。
- 支付成功但订单仍待支付,应推进订单到已支付,而不是退款。
- 订单已取消后又收到支付成功,可能要进入退款或人工确认,而不是直接把订单改回已支付。
- 商品已下架但搜索仍展示,应删除搜索文档或标记不可售。
- 供应商酒店价格异常,可能不能自动覆盖,要进入人工审核。
补偿的核心原则是:修复业务结果,而不是机械回滚技术动作。
3.6.2.3 DLQ
DLQ 是 Dead Letter Queue,死信队列。但在业务系统里,DLQ 不应该只是 MQ 里的失败 Topic。
对于电商系统,失败数据往往需要查询、筛选、分派、修复、重新投递、忽略、审计和统计。Kafka DLQ 可以保留失败消息,但它不适合承担运营治理主存储。
更成熟的做法是:
Kafka DLQ:短期失败消息缓冲,可选
MySQL DLQ:权威问题单、处理状态、修复入口和审计台账
对象存储:保存大 payload、原始文件、渠道账单、供应商原始响应
运营后台:提供筛选、修复、重放、忽略、导出、审批
所以本章提到 DLQ 时,重点指“可运营的死信治理体系”,不只是消息中间件里的一个 Topic。
3.6.2.4 故障恢复
故障恢复是比对账、补偿、DLQ 更大的概念。它覆盖请求失败、任务失败、数据不一致、系统宕机、灾备切换和人工修复。
可以分为四层:
| 层次 | 目标 | 常见手段 |
|---|---|---|
| 请求级恢复 | 单次请求失败后快速返回或安全重试 | 超时、重试、幂等键、状态查询 |
| 任务级恢复 | 异步任务失败后继续推进 | 重试队列、补偿任务、Checkpoint、Worker Lease |
| 数据级恢复 | 多系统数据重新收敛 | 对账、重放、差异修复、人工工单 |
| 系统级恢复 | 服务或机房故障后恢复业务能力 | 回滚、切流、降级、灾备、数据恢复 |
成熟系统不会把恢复责任压在某一层。只靠请求重试会放大故障,只靠夜间对账会延长用户影响,只靠人工工单会拖垮团队。
3.6.3 设计对账前,先定义“事实”和“口径”
对账最容易失败的原因,不是 SQL 写错,而是团队没有统一“谁说了算”。
3.6.3.1 权威数据源
不同业务域的权威事实不同:
| 业务对象 | 权威事实 | 不应作为权威的对象 |
|---|---|---|
| 支付是否成功 | 渠道交易流水、本地支付单状态机 | 前端支付页结果、同步预下单返回 |
| 订单是否有效 | 订单主状态 + 状态变更日志 | 搜索索引、客服备注 |
| 库存是否扣减 | 库存流水账本、预占记录、确认记录 | Redis 当前值、前台展示库存 |
| 券是否核销 | 券实例状态机、核销流水 | 订单优惠展示字段 |
| 商品是否可售 | 商品发布版本、可售规则、库存状态 | ES 文档、缓存详情页 |
| 供应商是否有库存 | 供应商实时查询或最近一次有效同步 | 平台缓存库存 |
权威数据源也不是永远固定的。资金事实通常以渠道和银行为准;业务状态以本地状态机为准;履约状态可能以供应商确认为准;财务入账以总账系统为准。对账系统要明确每类差异的裁决规则。
3.6.3.2 业务口径
同一个字段在不同系统里可能含义不同。
以支付为例:
| 字段 | 可能的口径差异 |
|---|---|
paid_amount | 用户实付、渠道扣款、平台到账、扣除手续费后金额 |
paid_time | 用户确认时间、渠道扣款时间、回调到达时间、本地入库时间 |
success | 渠道受理成功、扣款成功、清算成功、可结算成功 |
refund_amount | 申请退款金额、渠道退款金额、实际到账金额 |
如果这些口径不统一,对账会不断制造“假差异”。因此重要字段必须有数据字典,并在系统边界上写清楚。
3.6.3.3 差异容忍窗口
最终一致系统允许短时间不一致。对账不能把所有延迟都当事故。
| 场景 | 合理容忍窗口 | 说明 |
|---|---|---|
| 支付回调到订单推进 | 秒级到分钟级 | 回调、Outbox、消费者可能有短暂延迟 |
| 商品发布到搜索可见 | 分钟级 | 索引刷新和缓存刷新需要时间 |
| 库存 Redis 与 MySQL 快照 | 秒级到分钟级 | 热路径与账本路径可能异步 |
| 供应商库存同步 | 分钟级到小时级 | 取决于供应商接口能力和品类风险 |
| 财务清结算 | T+1 或 T+N | 取决于渠道账单周期 |
对账规则必须写清“多长时间内算正常延迟,多长时间后算差异”。否则系统会在高峰期产生大量误报。
3.6.4 对账系统的分层设计
一套通用对账系统通常包含五层。
| 层次 | 职责 | 电商例子 |
|---|---|---|
| 数据采集层 | 拉取权威数据和被对账数据 | 渠道账单、订单表、库存流水、供应商文件 |
| 标准化层 | 把异构数据转成统一模型 | 金额单位、币种、状态枚举、时间格式 |
| 比对层 | 执行 Join、聚合、恒等式校验 | 支付单和渠道流水外连接 |
| 差异层 | 生成差异项并分类 | 长款、短款、状态不一致、缺失记录 |
| 处置层 | 自动补偿、人工工单、忽略、复核 | 推进订单、退款、释放库存、重建索引 |
不要把对账写成一个巨大定时脚本。脚本可以作为第一版实现,但架构上仍应具备批次、明细、差异状态、处理动作和审计。
3.6.4.1 对账批次
每次对账都应该有批次记录。
CREATE TABLE recon_batch (
id BIGINT PRIMARY KEY,
recon_type VARCHAR(64) NOT NULL,
biz_date DATE NOT NULL,
source_system VARCHAR(64) NOT NULL,
target_system VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
total_count BIGINT NOT NULL DEFAULT 0,
matched_count BIGINT NOT NULL DEFAULT 0,
diff_count BIGINT NOT NULL DEFAULT 0,
auto_fixed_count BIGINT NOT NULL DEFAULT 0,
manual_required_count BIGINT NOT NULL DEFAULT 0,
started_at DATETIME NOT NULL,
finished_at DATETIME NULL,
error_code VARCHAR(64) NULL,
error_message VARCHAR(512) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_recon_batch (recon_type, biz_date, source_system, target_system)
);
批次的价值是可追溯:今天对了什么,是否完成,发现多少差异,自动修复多少,还剩多少需要人工处理。
3.6.4.2 对账明细和差异项
差异项应单独建表,而不是只打日志。
CREATE TABLE recon_diff (
id BIGINT PRIMARY KEY,
batch_id BIGINT NOT NULL,
recon_type VARCHAR(64) NOT NULL,
biz_id VARCHAR(128) NOT NULL,
biz_sub_id VARCHAR(128) NULL,
diff_type VARCHAR(64) NOT NULL,
severity VARCHAR(32) NOT NULL,
source_snapshot JSON NOT NULL,
target_snapshot JSON NOT NULL,
expected_action VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
compensation_task_id BIGINT NULL,
owner VARCHAR(64) NULL,
first_seen_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
resolved_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_diff_dedup (recon_type, biz_id, biz_sub_id, diff_type)
);
几个关键点:
source_snapshot和target_snapshot保存比对时的证据。diff_type用于分类,不要只写自然语言。expected_action说明建议处理动作。status管理差异生命周期,不能靠“有没有日志”判断是否处理。- 唯一键用于差异去重,避免同一问题每天生成一堆新差异。
3.6.4.3 差异分类
差异分类决定能否自动修复。
| 差异类型 | 示例 | 推荐动作 |
|---|---|---|
| 缺本地记录 | 渠道有成功支付,本地无支付单 | 人工确认或补建支付单,风险高 |
| 缺外部记录 | 本地支付成功,渠道账单无记录 | 查单复核,可能短款 |
| 状态不一致 | 渠道成功,本地支付中 | 自动查单后推进状态 |
| 金额不一致 | 本地 100 元,渠道 99 元 | 人工复核,禁止自动改账 |
| 时间延迟 | 订单已支付,履约未开始 | 超窗口后生成补偿 |
| 投影缺失 | 商品已发布,ES 无文档 | 自动重建索引 |
| 重复记录 | 同一渠道流水命中多笔本地单 | P0 资损风险,人工冻结 |
原则上,资金金额差异、重复扣款、重复退款、跨用户归属错误,都不应该直接自动修复。可以自动生成工单、冻结风险对象、通知负责人,但不能让补偿程序“自作主张”。
3.6.5 电商核心场景对账
3.6.5.1 支付对账
支付对账是最典型、也最不能偷懒的对账场景。
核心数据源包括:
| 数据源 | 作用 |
|---|---|
| 渠道账单 | 外部扣款和退款事实 |
| 本地支付单 | 平台支付状态机 |
| 支付渠道交互日志 | 请求、回调、查单证据 |
| 订单表 | 用户侧交易状态 |
| 财务分录 | 入账和清结算口径 |
支付对账常见差异:
| 差异 | 含义 | 处理 |
|---|---|---|
| 渠道成功,本地支付中 | 回调丢失或处理失败 | 主动查单,推进支付单和订单 |
| 渠道成功,本地失败 | 本地状态错误或晚到成功 | 进入人工复核,可能需要补发事件 |
| 本地成功,渠道无成功记录 | 本地误判成功或账单延迟 | 查单复核,超窗口转人工 |
| 渠道退款成功,本地退款中 | 退款回调丢失 | 推进退款单,通知订单售后 |
| 金额不一致 | 资损风险 | 冻结差异项,人工复核 |
支付对账的推荐流程:
T+1 拉取渠道账单
-> 校验文件完整性和签名
-> 标准化为 ChannelTradeRow
-> 与 payment_channel_log / payment_order 外连接
-> 生成 recon_diff
-> 对低风险状态差异自动查单
-> 查单确认后生成 compensation_task
-> 补偿推进支付单、订单、Outbox
-> 高风险金额差异进入人工复核
注意:支付对账的自动补偿应该优先“推进状态”,而不是直接“改金额”。金额差异必须谨慎,因为它会影响财务、税务、商家结算和用户权益。
3.6.5.2 订单对账
订单是业务承诺的载体。订单对账重点不是“表里有没有数据”,而是订单状态是否和周边事实一致。
| 订单状态 | 需要对齐的事实 |
|---|---|
| 待支付 | 支付单应未成功,库存和营销资源仍在锁定窗口内 |
| 已支付 | 支付成功事实存在,库存确认或履约创建应已触发 |
| 已取消 | 支付未成功或已退款,库存和券应释放 |
| 已发货 / 履约中 | 履约单或供应商订单应存在 |
| 已完成 | 履约完成、售后窗口和财务确认满足规则 |
典型对账规则:
- 支付单成功超过 1 分钟,订单仍待支付,生成订单推进补偿。
- 订单取消超过 5 分钟,库存预占仍未释放,生成库存释放补偿。
- 订单已支付超过 10 分钟,履约单未创建,生成履约补偿。
- 订单已退款,营销券仍显示已核销,生成营销释放或人工修复。
- 主单完成,但子单仍处于中间态,生成状态修复任务。
订单对账必须尊重状态机。不能因为“支付成功”就无条件把订单改成“已支付”。如果订单已经取消、退款、关闭或进入风控冻结,补偿动作应该进入人工确认或走专门的反向流程。
3.6.5.3 库存对账
库存对账的目标是保证“可售、预占、已售、释放、作废”这些状态最终符合账本。
一个常见库存恒等式:
total = available + reserved + sold + locked + damaged
如果使用 Redis 做热路径扣减,还需要额外比较:
MySQL 可售快照
vs Redis 可售值
vs 库存流水聚合
vs 订单预占状态
典型差异:
| 差异 | 可能原因 | 处理 |
|---|---|---|
| Redis 少于 MySQL | 扣减成功但落库失败、回填失败 | 以账本为准重建 Redis |
| Redis 多于 MySQL | 释放重复、缓存覆盖错误 | 冻结商品,按账本修复 |
| 预占超时未释放 | 关单任务失败 | 补偿释放预占 |
| 已支付订单库存仍 reserved | 支付确认事件消费失败 | 补偿确认 sold |
| 流水聚合不等于余额 | 人工改库或程序缺陷 | 停止自动修复,人工排查 |
库存对账最重要的原则是:Redis 不是账本。故障恢复时应以 MySQL 账本、预占记录、订单状态和审计日志为准,Redis 可以重建。
3.6.5.4 商品与搜索对账
商品系统经常采用“主数据 + 读模型”架构。正式商品表是权威数据,搜索索引、商品详情缓存、推荐特征都是投影。
常见对账规则:
- 商品已下架,ES 仍可搜索到,删除或标记不可售。
- 商品已发布,ES 缺文档,重建索引。
- 商品价格版本已更新,详情页缓存仍是旧版本,刷新缓存。
- 商品可售状态依赖库存和履约规则,搜索文档的可售标记过期,重新投影。
- 供应商商品被标记风险,前台仍展示,强制下架并告警。
商品投影对账通常可以自动修复,因为它不改变主数据,只重建派生视图。但如果发现主数据本身质量异常,例如类目映射错误、酒店坐标漂移、价格异常波动,则应进入质量问题单或 DLQ,而不是自动发布。
3.6.5.5 供应商同步对账
供应商同步是电商系统里最容易产生长尾差异的地方。供应商接口可能限流、超时、字段缺失、分页游标异常、同一商品编码复用、价格库存延迟更新。
供应商对账要区分三类事实:
| 事实 | 说明 |
|---|---|
| Raw Snapshot | 供应商原始响应,便于追溯和重放 |
| Standardized Model | 平台标准化后的中间模型 |
| Published Version | 已发布到交易链路的正式版本 |
同步成功不等于可以发布。一个酒店从供应商拉取成功,但城市映射失败、经纬度漂移、价格低于风险阈值、房型缺关键字段,都不应该直接进入正式商品。
典型处理:
- 网络超时:指数退避重试。
- 供应商 5xx:限流、熔断、延迟重试。
- 字段缺失:进入 MySQL DLQ,等待供应商或运营修复。
- 城市映射失败:进入映射工单。
- 价格异常:进入审核,不自动发布。
- 下游索引刷新失败:生成投影补偿。
供应商同步的恢复能力通常依赖 Task + Checkpoint + Worker Lease + Raw Snapshot + DLQ。没有这些能力,长任务一旦中断,就只能从头跑,或者靠人工猜进度。
3.6.6 补偿任务设计
补偿任务不是简单的定时重试。它是一个小型工作流系统。
3.6.6.1 补偿任务模型
CREATE TABLE compensation_task (
id BIGINT PRIMARY KEY,
task_type VARCHAR(64) NOT NULL,
biz_type VARCHAR(64) NOT NULL,
biz_id VARCHAR(128) NOT NULL,
biz_sub_id VARCHAR(128) NULL,
priority INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_payload JSON NOT NULL,
context_snapshot JSON NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
max_retry_count INT NOT NULL DEFAULT 10,
next_retry_at DATETIME NOT NULL,
last_error_code VARCHAR(64) NULL,
last_error_message VARCHAR(512) NULL,
locked_by VARCHAR(128) NULL,
locked_until DATETIME NULL,
source_type VARCHAR(64) NOT NULL,
source_id BIGINT NULL,
manual_required TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_comp_idem (task_type, idempotency_key),
KEY idx_comp_schedule (status, next_retry_at, priority),
KEY idx_comp_biz (biz_type, biz_id)
);
字段说明:
| 字段 | 设计目的 |
|---|---|
task_type | 区分支付查单、订单推进、库存释放、索引重建等动作 |
biz_type / biz_id | 关联业务对象,便于客服和运营检索 |
idempotency_key | 防止同一差异生成多个重复补偿 |
context_snapshot | 保存补偿创建时的上下文证据 |
source_type / source_id | 关联来源,例如 recon_diff、dlq、manual |
locked_by / locked_until | 支持多 Worker 抢占和故障恢复 |
manual_required | 标记是否需要人工审批或处理 |
补偿任务必须能回答:为什么生成、要修什么、修复前证据是什么、谁执行过、执行结果如何。
3.6.6.2 补偿状态机
建议状态机保持简单:
PENDING
-> RUNNING
-> SUCCESS
-> RETRY_WAIT
-> MANUAL_REQUIRED
-> DLQ
-> CANCELLED
状态含义:
| 状态 | 含义 |
|---|---|
PENDING | 等待执行 |
RUNNING | Worker 正在执行 |
RETRY_WAIT | 可重试失败,等待下一次执行 |
SUCCESS | 补偿完成,并通过必要校验 |
MANUAL_REQUIRED | 需要人工判断或审批 |
DLQ | 自动补偿已无法继续,转入死信治理 |
CANCELLED | 差异已消失或任务被人工取消 |
不要让任务在 RUNNING 状态永久停留。Worker 必须使用租约,超时后允许其他 Worker 接管。
3.6.6.3 重试策略
补偿重试要区分错误类型。
| 错误类型 | 示例 | 策略 |
|---|---|---|
| 瞬时错误 | 网络抖动、连接重置 | 指数退避重试 |
| 下游限流 | 渠道 QPS 超限、供应商 429 | 降低并发,延后重试 |
| 数据未就绪 | 订单刚创建,支付单尚未落库 | 短延迟重试 |
| 业务冲突 | 状态机不允许推进 | 转人工或重新对账 |
| 参数错误 | 商品 ID 不存在、字段格式错误 | 进入 DLQ |
| 资损风险 | 金额不一致、重复扣款 | 冻结并人工复核 |
一个常见退避策略:
第 1 次:30 秒后重试
第 2 次:2 分钟后重试
第 3 次:10 分钟后重试
第 4 次:30 分钟后重试
第 5 次:2 小时后重试
超过阈值:MANUAL_REQUIRED 或 DLQ
不要无上限重试。无上限重试会掩盖真实问题,打爆下游,也会让补偿队列永远无法清空。
3.6.6.4 Worker 执行原则
补偿 Worker 应该遵守这些原则:
- 先抢租约,再执行任务。
- 执行前重新读取业务对象最新状态。
- 判断差异是否仍然存在。
- 调用幂等接口或执行条件更新。
- 执行后验证结果。
- 写补偿日志和审计记录。
- 失败时按错误分类更新下一次动作。
伪流程:
扫描 PENDING / RETRY_WAIT 且 next_retry_at <= now 的任务
-> CAS 抢占 locked_by / locked_until
-> 读取任务和业务对象
-> 如果差异已消失,标记 SUCCESS 或 CANCELLED
-> 如果状态机不允许自动修复,标记 MANUAL_REQUIRED
-> 执行幂等补偿动作
-> 校验修复结果
-> 成功:标记 SUCCESS
-> 可重试失败:计算 next_retry_at,标记 RETRY_WAIT
-> 不可重试失败:写入 DLQ
3.6.6.5 补偿接口要幂等
补偿任务本身会重试,也可能被人工重放。因此所有补偿动作都必须幂等。
| 补偿动作 | 幂等设计 |
|---|---|
| 推进订单已支付 | order_id + paid_event_id 唯一约束,状态条件更新 |
| 释放库存预占 | reservation_id + release_reason 唯一流水,状态机从 reserved 到 released |
| 确认库存已售 | reservation_id 只能确认一次 |
| 释放优惠券 | coupon_lock_id 状态机,重复释放直接返回成功 |
| 重建搜索索引 | item_id + publish_version 覆盖式写入 |
| 支付查单 | payment_id 查询不产生副作用,结果推进用状态机 |
| 供应商重投递 | task_item_id + retry_round 去重,保留原始 payload 引用 |
判断幂等是否合格,可以问一句:同一补偿任务执行 10 次,业务结果是否仍然正确?
3.6.6.6 补偿不是万能的
有些差异不应该自动补偿。
| 场景 | 原因 |
|---|---|
| 金额不一致 | 可能涉及资损、税务、分账 |
| 重复支付 | 需要确认是否退款、是否合并订单 |
| 跨用户归属错误 | 可能是严重数据串号 |
| 商品价格异常 | 可能需要法务和运营判断 |
| 供应商确认失败但用户已支付 | 需要客服、退款或替代履约策略 |
| 人工 SQL 导致状态错乱 | 需要冻结现场,防止越修越错 |
自动化的边界必须清楚。好的补偿系统不是“什么都自动修”,而是低风险自动收敛,高风险快速冻结、分派和审计。
3.6.7 DLQ 设计:从失败消息到可运营问题单
3.6.7.1 什么应该进入 DLQ
进入 DLQ 的前提是:系统判断当前数据无法继续正常自动处理,或者继续重试会产生更高风险。
常见进入 DLQ 的原因:
| 类型 | 例子 | 是否可自动恢复 |
|---|---|---|
| 格式错误 | 消息 JSON 无法解析、字段类型不兼容 | 通常不可 |
| 业务对象缺失 | 订单不存在、商品不存在 | 可能需要等待或人工 |
| 状态冲突 | 订单已取消但收到支付成功事件 | 需要业务判断 |
| 下游持续失败 | 重试超过阈值 | 可能稍后恢复 |
| 数据质量问题 | 供应商城市映射失败、价格异常 | 通常需要人工 |
| 幂等冲突 | 同一幂等键参数不同 | 高风险,人工 |
| 安全风险 | 验签失败、来源不可信 | 不应重放 |
不要把所有失败都立即丢进 DLQ。瞬时网络失败应该先重试;明确不可恢复的数据问题才应进入 DLQ。
3.6.7.2 MySQL DLQ 表
业务 DLQ 推荐落 MySQL 或其他可查询事务存储,作为权威问题单。
CREATE TABLE dead_letter_item (
id BIGINT PRIMARY KEY,
dlq_type VARCHAR(64) NOT NULL,
biz_type VARCHAR(64) NOT NULL,
biz_id VARCHAR(128) NOT NULL,
stage VARCHAR(64) NOT NULL,
error_code VARCHAR(64) NOT NULL,
error_message VARCHAR(512) NOT NULL,
retryable TINYINT NOT NULL DEFAULT 0,
severity VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
payload_ref VARCHAR(512) NULL,
payload_digest VARCHAR(128) NOT NULL,
context_snapshot JSON NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME NULL,
owner VARCHAR(64) NULL,
resolved_by VARCHAR(64) NULL,
resolved_at DATETIME NULL,
resolution VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_dlq_dedup (dlq_type, biz_type, biz_id, stage, error_code, payload_digest),
KEY idx_dlq_status (status, severity, created_at),
KEY idx_dlq_biz (biz_type, biz_id)
);
设计要点:
- 大 payload 不要直接塞主表,保存对象存储引用和摘要。
payload_digest用于去重和防篡改。stage表示失败阶段,例如PARSE、VALIDATE、PUBLISH、CONSUME、CALLBACK。resolution表示处理结果,例如REPLAYED、IGNORED、MANUAL_FIXED、REFUNDED。- 所有人工动作必须写审计日志。
3.6.7.3 DLQ 状态机
OPEN
-> ASSIGNED
-> RETRYING
-> RESOLVED
-> IGNORED
-> ESCALATED
| 状态 | 含义 |
|---|---|
OPEN | 新进入,等待处理 |
ASSIGNED | 已分派给 owner |
RETRYING | 正在自动或人工重放 |
RESOLVED | 已修复并验证 |
IGNORED | 确认为无需处理,必须填写原因 |
ESCALATED | 升级到事故、客服、财务或供应商 |
DLQ 的“忽略”能力很重要,但必须有权限和审计。没有忽略能力,队列会永远堆积;忽略没有审计,又会变成掩盖问题的洞。
3.6.7.4 Kafka DLQ 与 MySQL DLQ 的边界
| 能力 | Kafka DLQ | MySQL DLQ |
|---|---|---|
| 保留失败消息 | 强 | 中 |
| 顺序回放 | 强 | 弱 |
| 运营查询 | 弱 | 强 |
| 分派处理 | 弱 | 强 |
| 人工修复 | 弱 | 强 |
| 审计报表 | 弱 | 强 |
| 大规模吞吐 | 强 | 中 |
| 问题单生命周期 | 弱 | 强 |
推荐组合:
消息消费失败
-> 可重试错误:延迟重试 Topic
-> 超过阈值:写 Kafka DLQ 保留原消息
-> 同时写 MySQL DLQ 作为问题单
-> 运营或系统修复后,从 MySQL 触发重新投递
-> 需要原始消息时,从 Kafka / 对象存储读取 payload
3.6.7.5 重新投递要有护栏
DLQ 重新投递不能只是一个按钮。
重放前必须检查:
- 原始 payload 是否完整。
- 消费代码是否已经修复。
- 业务对象当前状态是否允许重放。
- 是否会重复扣款、重复发券、重复扣库存。
- 是否需要灰度重放。
- 是否需要限速。
- 是否需要审批。
重放策略:
| 策略 | 使用场景 |
|---|---|
| 单条重放 | 高风险订单、支付、退款 |
| 批量限速重放 | 商品索引、缓存刷新 |
| 按错误码重放 | 已修复某类解析错误 |
| 按时间窗口重放 | 某次发布造成的失败 |
| 影子重放 | 先验证新代码能否处理,不写正式状态 |
高风险 DLQ 应该要求双人审批。尤其是支付、退款、库存和营销权益,不能让一个人随手批量重放。
3.6.8 Outbox、事件重放与对账的关系
Outbox 解决的是“本地事务成功后,事件可靠发出去”的问题。对账解决的是“事件发出去之后,下游最终有没有处理成功”的问题。
两者不能互相替代。
| 能力 | 解决问题 | 不能解决 |
|---|---|---|
| Outbox | 本地数据和事件生成同事务 | 下游消费失败、业务处理失败 |
| MQ 重试 | 临时消费失败 | 不可重试数据错误 |
| DLQ | 隔离无法处理的数据 | 自动判断业务真相 |
| 对账 | 发现最终状态差异 | 实时阻止所有错误 |
| 补偿 | 修复已发现差异 | 替代主链路正确性 |
一个电商订单支付成功事件的可靠链路应该是:
支付回调落库
-> 支付单状态机推进为 SUCCESS
-> 同事务写 payment_outbox_event
-> Outbox Relay 投递 PaymentSucceeded
-> 订单服务消费事件,幂等推进订单
-> 履约、库存、营销继续消费订单事件
-> 对账任务扫描支付成功但订单未支付
-> 发现差异后生成订单推进补偿
即使 Outbox 做得很好,对账仍然必要。因为下游可能消费失败、业务状态冲突、代码 Bug、数据污染或人工误操作。
3.6.9 端到端案例一:支付成功但订单未更新
这是电商系统最典型的恢复案例。
3.6.9.1 故障过程
用户提交支付
-> 支付渠道扣款成功
-> 渠道回调支付服务
-> 支付服务验签成功,更新支付单 SUCCESS
-> 写 Outbox 事件 PaymentSucceeded
-> 订单服务消费事件时数据库超时
-> MQ 重试多次失败
-> 消息进入 DLQ
-> 用户订单仍显示待支付
用户看到的是“钱扣了,订单没变”。这类问题必须优先处理,因为它直接影响信任。
3.6.9.2 发现方式
至少应该有三条发现路径:
- 实时链路告警:支付成功事件消费失败率升高。
- 近实时对账:支付成功超过 1 分钟,订单仍待支付。
- T+1 渠道对账:渠道成功,本地订单未完成。
只靠用户投诉是不合格的。
3.6.9.3 自动补偿策略
低风险情况下,可以自动推进订单:
| 前置条件 | 判断 |
|---|---|
| 支付单为 SUCCESS | 是 |
| 渠道流水验签和金额正确 | 是 |
| 订单仍处于 PendingPay | 是 |
| 订单未取消、未退款、未风控冻结 | 是 |
| 支付金额等于订单应付金额 | 是 |
| 幂等事件未处理成功 | 是 |
补偿动作:
创建 compensation_task: ADVANCE_ORDER_PAID
-> 读取订单和支付单最新状态
-> 校验金额、币种、支付单归属
-> CAS 更新订单 PendingPay -> Paid
-> 写订单状态日志
-> 写 OrderPaid Outbox
-> 标记补偿成功
-> 对账差异改为 RESOLVED
如果订单已取消,就不能简单推进。需要判断取消原因和支付时间:
| 情况 | 处理 |
|---|---|
| 支付发生在取消前 | 可能是状态处理延迟,人工确认 |
| 支付发生在取消后 | 通常发起退款或人工确认 |
| 订单已风控冻结 | 进入风控工单 |
| 金额不一致 | 进入财务差异工单 |
3.6.9.4 用户体验
恢复系统还要考虑用户展示:
- 支付成功但订单未推进时,不要长期显示“待支付”。
- 可以展示“支付结果确认中”,并提供刷新或客服入口。
- 如果自动补偿完成,应同步推送订单状态。
- 如果需要退款,应明确退款处理中和预计到账时间。
很多系统后台补偿做得不错,但前台状态表达很差,用户仍然会投诉。故障恢复不只是数据修复,也包括用户可解释性。
3.6.10 端到端案例二:库存预占成功但订单创建失败
3.6.10.1 故障过程
用户提交订单
-> 订单服务调用库存预占
-> 库存系统写 reservation SUCCESS
-> 订单服务写订单主表时失败
-> 用户收到下单失败
-> 库存仍被占用
如果不处理,库存会被幽灵订单占住,造成少卖。
3.6.10.2 恢复设计
库存预占必须有过期时间和业务关联。
关键字段:
| 字段 | 说明 |
|---|---|
reservation_id | 预占 ID |
request_id | 创单请求幂等键 |
order_id | 如果订单创建成功则绑定 |
sku_id | 库存对象 |
quantity | 预占数量 |
status | RESERVED、CONFIRMED、RELEASED、EXPIRED |
expire_at | 预占过期时间 |
恢复路径:
- 创单失败后,订单服务同步调用库存释放。
- 如果同步释放失败,写补偿任务
RELEASE_INVENTORY_RESERVATION。 - 库存系统定时扫描超时
RESERVED记录。 - 对账任务扫描“无订单绑定的有效预占”。
- 补偿释放库存并写释放流水。
注意:释放库存必须幂等。重复释放不能让库存加两次。
3.6.10.3 什么时候不能自动释放
如果订单服务写主表超时,可能订单实际已经写成功,只是响应失败。因此释放前要查订单:
| 查询结果 | 处理 |
|---|---|
| 订单不存在 | 可以释放 |
| 订单存在且待支付 | 不释放,绑定 reservation |
| 订单存在且已支付 | 确认库存 sold |
| 订单存在但状态异常 | 转人工或重新对账 |
这就是为什么补偿执行前必须重新读取最新状态。补偿不能只相信任务创建时的上下文。
3.6.11 端到端案例三:供应商同步失败进入 DLQ
3.6.11.1 场景
一个酒店供应商每天同步 100 万个酒店和房型。某次同步中,供应商把城市编码从 BKK 改成新编码,但没有提前通知平台。平台城市映射失败,导致部分酒店无法发布。
如果系统只是打印日志,运营不会知道哪些酒店失败,也无法修复。
3.6.11.2 正确链路
Supplier Sync Task
-> 拉取 Raw Snapshot
-> 标准化字段
-> 城市 / 商户 / 品牌映射
-> 质量校验
-> Diff
-> 低风险自动发布
-> 高风险审核
-> 失败进入 MySQL DLQ
DLQ 记录应包含:
| 字段 | 示例 |
|---|---|
dlq_type | SUPPLIER_SYNC |
biz_type | HOTEL |
biz_id | supplier_hotel_id |
stage | MAPPING |
error_code | CITY_MAPPING_NOT_FOUND |
payload_ref | raw snapshot 地址 |
context_snapshot | supplier_id、city_code、hotel_name、task_id |
owner | supply-ops |
retryable | false |
运营后台应该能按供应商、城市、错误码筛选,批量建立映射规则,然后重放受影响 DLQ。
3.6.11.3 为什么不能直接自动发布
城市映射失败不是技术瞬时错误,而是业务语义缺失。自动发布可能导致:
- 酒店出现在错误城市。
- 用户搜索结果错误。
- 税费和履约规则错误。
- 供应商结算主体错误。
所以这类 DLQ 的正确处理不是重试,而是修复映射数据后重新投递。
3.6.11.4 重放后的验证
重放成功不等于结束。还要验证:
- 商品正式版本是否更新。
- 搜索索引是否刷新。
- 详情页缓存是否刷新。
- 可售诊断是否通过。
- 质量报表中的失败数是否下降。
- 原 DLQ 是否标记
RESOLVED并写审计。
这才是闭环。
3.6.12 故障恢复中的人工介入
业务系统不能完全排斥人工介入。关键是人工介入必须产品化、权限化、审计化。
3.6.12.1 人工介入入口
建议后台至少提供:
| 能力 | 说明 |
|---|---|
| 差异查询 | 按订单号、支付单、SKU、供应商、错误码查找 |
| 证据展示 | 展示对账快照、原始 payload、trace、日志链接 |
| 操作建议 | 系统给出推荐动作和风险提示 |
| 单条修复 | 对单个差异执行推进、释放、重建、退款等动作 |
| 批量处理 | 对低风险同类问题批量重放 |
| 审批流程 | 高风险动作需要双人审批 |
| 操作审计 | 记录操作人、原因、参数、前后状态 |
| 结果验证 | 操作后自动触发对账或状态检查 |
禁止直接让运营或研发用 SQL 改核心状态。手工 SQL 最大的问题不是慢,而是不可审计、不可复用、不可防错。
3.6.12.2 人工动作也要幂等
人工点击“重新投递”也可能点两次,后台超时后也可能重复提交。所以人工操作不能绕过幂等。
每次人工动作都应该生成 manual_operation 记录:
CREATE TABLE manual_operation (
id BIGINT PRIMARY KEY,
operation_type VARCHAR(64) NOT NULL,
biz_type VARCHAR(64) NOT NULL,
biz_id VARCHAR(128) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
operator VARCHAR(64) NOT NULL,
reason VARCHAR(512) NOT NULL,
request_payload JSON NOT NULL,
before_snapshot JSON NOT NULL,
after_snapshot JSON NULL,
status VARCHAR(32) NOT NULL,
approved_by VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_manual_idem (operation_type, idempotency_key)
);
人工修复不是“绕过系统”,而是通过系统提供的安全入口完成修复。
3.6.13 监控与指标
第 3 章已经系统讨论监控体系,本章只列对账、补偿和 DLQ 必须具备的专项指标。
3.6.13.1 对账指标
| 指标 | 含义 |
|---|---|
recon_batch_success_rate | 对账批次成功率 |
recon_batch_duration_seconds | 对账耗时 |
recon_diff_total | 差异数量 |
recon_diff_age_seconds | 差异存活时间 |
recon_auto_fix_success_rate | 自动修复成功率 |
recon_manual_required_total | 需要人工处理的差异 |
关键告警:
- 支付对账批次失败。
- P0 差异数量大于 0,例如重复扣款、金额不一致。
- 差异超过 SLA 未处理。
- 同一错误码差异突然上升。
3.6.13.2 补偿指标
| 指标 | 含义 |
|---|---|
compensation_pending_total | 待执行任务数 |
compensation_retry_total | 重试任务数 |
compensation_success_rate | 补偿成功率 |
compensation_oldest_age_seconds | 最老任务年龄 |
compensation_manual_required_total | 需要人工介入数量 |
compensation_execution_duration_seconds | 单次执行耗时 |
比“失败数量”更重要的是“最老任务年龄”。如果一条支付补偿卡了 6 小时,哪怕总数只有 1,也可能是严重用户问题。
3.6.13.3 DLQ 指标
| 指标 | 含义 |
|---|---|
dlq_open_total | 未处理 DLQ 数 |
dlq_new_total | 新增 DLQ 数 |
dlq_resolved_total | 已解决数 |
dlq_replay_success_rate | 重放成功率 |
dlq_oldest_age_seconds | 最老 DLQ 年龄 |
dlq_by_error_code | 按错误码分布 |
dlq_by_owner | 按负责人分布 |
DLQ 要有运营 SLA。例如:
- 支付和退款 DLQ:15 分钟内响应。
- 订单状态 DLQ:30 分钟内响应。
- 商品投影 DLQ:2 小时内处理。
- 供应商数据质量 DLQ:工作日内处理或升级供应商。
3.6.14 上线前检查清单
上线一个跨系统链路前,可以用下面清单检查恢复能力。
| 检查项 | 必须回答 |
|---|---|
| 权威数据源 | 出现差异时谁说了算 |
| 幂等键 | 重试、回调、补偿是否共用稳定幂等语义 |
| 状态机 | 哪些状态允许自动推进,哪些必须人工 |
| Outbox | 本地事务成功后事件是否可靠投递 |
| 对账规则 | 是否有差异发现机制和容忍窗口 |
| 补偿任务 | 是否有任务表、状态机、租约、重试和审计 |
| DLQ | 不可自动处理的数据是否可查询、可分派、可重放 |
| 人工入口 | 是否禁止直接 SQL 修核心状态 |
| 监控告警 | 是否能看到差异数量、积压时间、重放成功率 |
| Runbook | 值班同学是否知道如何止血和恢复 |
如果一个链路没有对账和补偿,它不是“设计简单”,而是把复杂度转嫁给事故当天的 on-call。
3.6.15 常见反模式
| 反模式 | 问题 |
|---|---|
| 只写日志不落差异表 | 问题不可运营,无法统计和追踪 |
| 对账只告警不修复 | 告警越来越多,团队逐渐麻木 |
| 补偿没有幂等 | 重试本身制造二次事故 |
| 所有失败都无限重试 | 打爆下游,掩盖数据问题 |
| DLQ 只是 Kafka Topic | 运营无法查询、分派、修复、审计 |
| 人工直接改库 | 无权限边界、无审计、无防错 |
| 金额差异自动修 | 极易扩大资损 |
| 不保存原始 payload | 无法复盘、无法重放、无法举证 |
| 不区分错误类型 | 可重试和不可重试混在一起 |
| 补偿执行前不读最新状态 | 修复旧问题,制造新问题 |
3.6.16 小结:恢复体系不是兜底脚本,而是业务可信度的一部分
对账、补偿、DLQ 与故障恢复,是业务系统长期稳定运行的底座。
对电商系统来说:
- 支付对账保护资金事实。
- 库存对账保护可售承诺。
- 订单对账保护用户状态。
- 商品与搜索对账保护前台体验。
- 供应商对账保护供给质量。
- 补偿任务负责把低风险差异自动拉回正确状态。
- DLQ 负责把不可自动处理的问题变成可运营、可追踪、可审计的问题单。
- 人工介入不是失败,而是高风险业务恢复的一部分,但必须通过系统化入口完成。
真正成熟的恢复体系,不是“出了问题有脚本”,而是从主链路设计时就把失败纳入模型:每个动作都有幂等语义,每个状态都有推进规则,每个事件都有重放路径,每个差异都有处理责任,每次人工操作都有证据链。
系统会失败,但业务结果必须最终可信。这就是对账、补偿、DLQ 和故障恢复的工程价值。
3.6.17 延伸思考:恢复能力为什么总在团队最忙时才显得昂贵
恢复体系的建设往往最容易被延后,因为它很少直接带来新用户、新 GMV 或新功能。但从长期看,恢复能力不足带来的代价,通常会以更昂贵的方式回到团队面前:一次支付差异、一次库存冻结、一次批量修复、一次通宵对账、一次无法解释的用户投诉。
真正成熟的团队,不会把对账、补偿、DLQ 和人工修复看成附属能力,而是会把它们视为主链路可信度的组成部分。因为系统规模越大,越不可能靠“所有调用都成功”维持正确,只能靠“失败后仍能收敛”维持业务可信。
3.6.18 资损防控:资金、库存、优惠与账务安全
资损防控是业务系统可靠性里最“硬”的部分。接口慢一点,用户可能刷新;推荐失败,用户可能少点一个商品;但一旦出现重复扣款、错误退款、超卖、优惠券被薅、商家错结算、价格算错,系统损失的就不只是可用性,而是现金、库存、商誉和合规风险。
很多团队把资损防控理解成支付系统或财务系统的事情,这是不完整的。真正的资损往往发生在业务链路交界处:计价给了错误金额,订单保存了错误快照,库存释放重复,营销券重复核销,支付回调乱序,退款绕过原路退回,供应商履约失败但订单仍完成,运营后台误改了高风险配置。
本章从业务系统工程视角讨论资损防控:如何识别资损场景,如何设计账本与状态机,如何在主链路中防错,如何在高风险操作前审批和拦截,如何通过对账、补偿、冻结和审计把损失控制在最小范围。
3.6.18.1 什么是资损
资损不只是“钱少了”。只要系统错误导致平台、商家、用户、供应商之间的权益分配偏离真实业务承诺,都可以视为资损风险。
在电商系统中,常见资损对象包括:
| 对象 | 资损表现 | 例子 |
|---|---|---|
| 现金 | 多扣、少扣、漏扣、错退 | 用户支付 100 元,订单只入账 90 元 |
| 库存 | 超卖、少卖、重复释放、错误锁定 | 库存只有 1 件,卖出 2 件 |
| 优惠 | 重复用券、超预算补贴、错误叠加 | 满减和新人券错误叠加导致 0 元购 |
| 积分 / 余额 | 重复发放、重复扣减、扣减失败 | 订单取消后积分未退回 |
| 商家结算 | 错分账、错费率、错周期 | 商家应结 1000 元,只结 900 元 |
| 供应商结算 | 平台订单与供应商单不一致 | 供应商已确认,平台未生成结算 |
| 发票 / 税务 | 金额口径错误 | 含税价和不含税价混用 |
| 履约权益 | 虚拟码重复发、酒店错订 | 同一券码发给两个用户 |
资损防控的目标不是让所有风险变成零。成本、体验和效率决定了系统只能对不同风险分级治理。核心目标是:
- 高风险错误尽量在发生前拦截。
- 已发生的差异尽快发现。
- 发现后能冻结影响范围。
- 修复动作可幂等、可审批、可审计。
- 损失金额、影响用户和责任边界可解释。
3.6.18.2 资损风险的来源
资损通常不是单点 Bug,而是业务规则、分布式一致性、人工操作和外部依赖共同作用的结果。
3.6.18.2.1 计价错误
计价错误是资损高发区。
| 场景 | 风险 |
|---|---|
| 优惠叠加顺序错误 | 折扣、满减、券、积分重复优惠 |
| 金额精度错误 | 浮点计算、舍入规则不一致 |
| 币种转换错误 | 汇率、币种精度、税费口径错误 |
| 价格缓存过期 | 下架价、活动价、供应商价未刷新 |
| 运费 / 税费漏算 | 用户少付或商家少收 |
| 会员价与活动价冲突 | 高优先级规则未生效 |
计价资损最危险的地方在于:错误金额一旦进入订单快照,后续支付、退款、结算都会沿着错误口径继续执行。
3.6.18.2.2 状态机错误
状态机错误会让系统在不该推进时推进,或者在该回滚时没有回滚。
| 场景 | 风险 |
|---|---|
| 支付成功晚于订单取消 | 订单状态和资金事实冲突 |
| 退款成功但订单仍完成 | 商家结算多结 |
| 发货后重复退款 | 用户拿到商品又拿到钱 |
| 订单取消后库存未释放 | 少卖 |
| 订单取消后券未退回 | 用户权益损失 |
| 售后关闭后退款回调到达 | 重复状态推进 |
状态机必须用代码和数据库约束共同保护,不能只靠业务同学“按流程操作”。
3.6.18.2.3 幂等缺失
高并发系统中,重复请求、重复回调、重复消息、重复人工提交都很常见。
幂等缺失会直接造成:
- 重复创建订单。
- 重复扣库存。
- 重复核销优惠券。
- 重复发放积分。
- 重复退款。
- 重复给供应商下单。
只在前端按钮上做防抖没有意义。资损防控要求服务端、数据库唯一键、状态机和流水账本共同保证幂等。
3.6.18.2.4 异步链路不一致
为了性能,电商系统大量使用异步事件。
支付成功
-> 订单已支付
-> 库存确认已售
-> 营销确认核销
-> 履约创建
-> 商家结算计提
-> 数据仓库入账
任何一步事件丢失、消费失败、乱序或重复,都可能造成资损。因此异步链路必须具备 Outbox、消费幂等、DLQ、对账和补偿。
3.6.18.2.5 人工操作
很多严重资损来自人工操作,而不是代码 Bug。
| 操作 | 风险 |
|---|---|
| 批量改价 | 大量商品低价售卖 |
| 修改活动规则 | 优惠门槛错误、预算失控 |
| 手工补券 | 重复发券或发错用户 |
| 手工退款 | 超额退款或错退 |
| 直接 SQL 修数据 | 绕过状态机和审计 |
| 修改支付渠道权重 | 走错费率或不可用渠道 |
| 修改供应商映射 | 商品履约和结算主体错误 |
资损防控必须把后台、配置中心、批量任务和人工修复工具纳入治理,而不是只保护 C 端接口。
3.6.18.2.6 外部系统不确定性
支付渠道、供应商、物流、银行、清结算文件都可能不稳定。
典型风险:
- 渠道同步返回成功,但最终扣款失败。
- 渠道回调成功,但对账文件缺记录。
- 供应商确认成功,但后续取消。
- 供应商价格和平台价格不同步。
- 银行清算金额与本地账务不同。
外部依赖不能被当作本地事务的一部分。必须用适配层、状态机、查单、对账和人工复核治理。
3.6.18.3 资损防控的三道防线
成熟的资损防控不是某一个风控规则,而是三道防线。
| 防线 | 目标 | 手段 |
|---|---|---|
| 事前防错 | 不让高风险动作发生 | 规则校验、状态机、唯一键、限额、审批、灰度 |
| 事中拦截 | 异常发生时立即阻断扩散 | 风险规则、冻结、熔断、预算水位、异常检测 |
| 事后收敛 | 已发生差异可发现、可修复 | 对账、补偿、DLQ、审计、复盘 |
很多团队只做事后对账,等财务发现差异时,用户已经拿到错误权益,商家已经结算,供应商已经履约,修复成本极高。真正有效的资损防控必须前移。
3.6.18.3.1 事前防错
事前防错的核心是把业务不变量写进系统。
例如:
- 退款总额不能超过支付成功金额。
- 同一支付渠道流水只能绑定一笔支付单。
- 同一张券只能被一个订单核销一次。
- 库存释放必须关联原始预占流水。
- 订单金额必须等于商品金额、优惠、运费、税费的可解释组合。
- 活动预算消耗不能超过预算上限。
- 高风险价格变更必须审批和灰度。
这些规则不能只存在于 PRD 或口头约定中,必须落到数据库约束、领域服务校验、审批流和监控规则。
3.6.18.3.2 事中拦截
事中拦截关注“异常趋势刚出现时,能否快速止血”。
典型拦截:
- 某活动优惠核销速度超过预算曲线,自动暂停活动。
- 某 SKU 下单价低于成本价阈值,自动下架或进入审核。
- 某支付渠道短时间失败率异常,自动降权或切流。
- 某商家退款率异常升高,冻结自动退款。
- 某接口重复退款请求异常,触发用户或订单级限流。
- 库存超卖风险出现,冻结商品可售。
拦截动作必须区分用户体验和资损风险。推荐失败可以降级,但金额异常应该宁可阻断,也不能带病放行。
3.6.18.3.3 事后收敛
事后收敛依赖第 3 章的对账、补偿、DLQ 和故障恢复能力。
关键要求:
- 差异能被批次化发现。
- 差异能按风险等级分类。
- 低风险差异能自动修复。
- 高风险差异能冻结并进入人工复核。
- 所有修复动作都有审计证据。
- 修复后再次对账确认收敛。
事后收敛不是失败的借口,而是承认分布式系统不可能永远同步成功。
3.6.18.4 资损防控的核心模型:账本、状态机、快照
资损防控有三个底层模型:账本、状态机、快照。
3.6.18.4.1 账本
账本记录每一次权益变化的事实流水。余额、库存、券、积分、商家结算都应该有账本。
账本的特点:
- 追加写,不随意物理删除。
- 每条流水有业务来源和幂等键。
- 借贷或增减方向明确。
- 能从流水重放出余额。
- 能与快照和外部账单对账。
以资金账本为例:
CREATE TABLE fund_ledger (
id BIGINT PRIMARY KEY,
account_id VARCHAR(128) NOT NULL,
biz_type VARCHAR(64) NOT NULL,
biz_id VARCHAR(128) NOT NULL,
direction VARCHAR(16) NOT NULL,
amount BIGINT NOT NULL,
currency VARCHAR(16) NOT NULL,
ledger_type VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
before_balance BIGINT NULL,
after_balance BIGINT NULL,
status VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_fund_ledger_idem (ledger_type, idempotency_key),
KEY idx_fund_ledger_account (account_id, created_at),
KEY idx_fund_ledger_biz (biz_type, biz_id)
);
金额建议使用最小货币单位的整数,例如分、厘、cent,避免浮点误差。
3.6.18.4.2 状态机
状态机定义业务对象能从哪里到哪里。
支付单状态机示例:
INIT
-> PAYING
-> SUCCESS
-> FAILED
-> CLOSED
-> REFUNDING
-> REFUNDED
状态机要做三件事:
- 限制非法迁移。
- 处理重复事件。
- 为补偿和对账提供判断依据。
例如支付单从 SUCCESS 不能回到 PAYING;退款成功回调重复到达时应该幂等返回;订单已取消后收到支付成功事件时不能无条件推进订单,需要进入特殊处理。
3.6.18.4.3 快照
快照用于固定业务承诺。
订单创建时应保存:
- 商品快照。
- SKU 快照。
- 价格快照。
- 优惠快照。
- 运费和税费快照。
- 履约规则快照。
- 供应商报价和确认快照。
如果订单只保存商品 ID,后续商品改价、活动下线、供应商规则变化,就无法解释当时为什么收这个价,也无法正确退款、结算和审计。
资损防控里有一个重要原则:交易发生后,用交易快照解释历史,不用当前配置解释历史。
3.6.18.5 计价资损防控
计价是资损防控第一现场。
3.6.18.5.1 金额公式必须可解释
订单应付金额应该能被公式解释:
payable_amount
= item_amount
+ shipping_fee
+ tax_amount
+ service_fee
- platform_discount
- merchant_discount
- coupon_discount
- points_discount
每一项都必须有来源、版本和上限。
| 金额项 | 防控要求 |
|---|---|
| 商品金额 | 绑定商品价格版本和币种 |
| 运费 | 保存运费模板版本和计算输入 |
| 税费 | 保存税率、税区、含税口径 |
| 平台优惠 | 绑定活动 ID、预算、规则版本 |
| 商家优惠 | 区分商家承担和平台承担 |
| 券优惠 | 绑定券实例和核销流水 |
| 积分抵扣 | 保存积分扣减流水 |
如果金额只保存一个总价,后续退款、结算、财务入账都会变成猜谜。
3.6.18.5.2 金额不变量
计价系统必须校验不变量:
| 不变量 | 说明 |
|---|---|
| 应付金额不能小于 0 | 除非明确支持 0 元单 |
| 优惠总额不能超过可优惠金额 | 防止倒贴 |
| 平台补贴不能超过活动预算 | 防止预算穿透 |
| 商家承担金额不能超过商家授权 | 防止错扣商家 |
| 退款金额不能超过原支付成功金额 | 防止超退 |
| 分账总额不能超过实收金额 | 防止商家多结 |
| 不同币种不能直接加减 | 必须先明确汇率和结算币种 |
这些校验应该存在于计价服务、订单创建、支付创建、退款创建和结算生成多个关键点。
3.6.18.5.3 价格保护
价格变更要有风险保护:
- 价格低于成本价或历史均价一定比例,进入审核。
- 批量改价影响 SKU 数超过阈值,需要双人审批。
- 活动价生效前灰度到小流量或内部用户。
- 新规则上线后监控订单平均折扣率、毛利率和 0 元单数量。
- 价格配置变更写审计,并能快速回滚。
价格错误通常扩散非常快。一个错误活动配置可能几分钟内产生几万笔订单,所以价格保护必须具备自动暂停能力。
3.6.18.6 订单、支付与退款资损防控
3.6.18.6.1 创建支付单
创建支付单前必须校验:
| 校验 | 目的 |
|---|---|
| 订单仍可支付 | 防止取消后支付 |
| 支付金额等于订单应付金额 | 防止篡改金额 |
| 支付币种一致 | 防止币种错配 |
| 支付单幂等 | 防止重复支付单 |
| 订单未进入退款或风控冻结 | 防止状态冲突 |
支付金额不能信任前端传入。前端只能提交订单号和支付方式,金额必须由服务端根据订单快照读取。
3.6.18.6.2 支付回调
支付回调是资损高风险入口。
处理顺序建议:
接收回调
-> 验签
-> 校验渠道商户号
-> 幂等检查渠道流水
-> 查询本地支付单
-> 校验金额、币种、订单归属
-> 状态机推进
-> 写支付流水和 Outbox
-> 返回渠道成功
几个关键原则:
- 验签失败不能进入业务逻辑。
- 渠道流水号必须唯一。
- 回调金额必须等于支付单金额。
- 更新支付状态和写 Outbox 应在同一事务内。
- 成功响应渠道应该晚于本地事务提交。
- 重复回调必须幂等返回成功。
3.6.18.6.3 退款防控
退款比支付更容易出资损,因为它常常由售后、客服、运营或自动规则触发。
退款前必须校验:
| 校验 | 说明 |
|---|---|
| 原支付成功 | 未支付不能退款 |
| 可退金额 | 累计退款不能超过实付 |
| 退款原因 | 是否符合售后规则 |
| 订单状态 | 已发货、已核销、已完成的退款规则不同 |
| 权益回收 | 券、积分、库存、发票是否需要反向处理 |
| 风险审批 | 大额退款、批量退款、跨账户退款必须审批 |
退款建议使用退款单状态机:
INIT
-> REFUNDING
-> SUCCESS
-> FAILED
-> CLOSED
原路退回是默认原则。任何非原路退款、线下退款、人工补偿,都应提高风险等级并要求审批。
3.6.18.6.4 重复退款防控
重复退款常见来源:
- 用户重复提交售后。
- 客服重复点击。
- 渠道回调重复。
- 补偿任务重复执行。
- 多个售后单同时关联同一支付单。
防控方式:
payment_id + refund_request_id唯一。- 支付单累计退款金额使用条件更新。
- 退款成功流水唯一。
- 退款补偿幂等。
- 售后单和退款单一对一或明确多对一规则。
金额更新建议使用条件约束:
UPDATE payment_order
SET refunded_amount = refunded_amount + :refund_amount
WHERE payment_id = :payment_id
AND paid_amount - refunded_amount >= :refund_amount;
更新影响行数为 0 时,不能继续发起渠道退款。
3.6.18.7 库存与履约资损防控
库存资损既可能是超卖,也可能是少卖。
3.6.18.7.1 库存账本
库存系统不应该只有一个 available_stock 字段。至少需要库存流水:
| 流水类型 | 说明 |
|---|---|
| INIT | 初始化库存 |
| RESERVE | 下单预占 |
| CONFIRM | 支付成功确认已售 |
| RELEASE | 取消或超时释放 |
| ADJUST | 人工调整 |
| FREEZE | 风险冻结 |
| UNFREEZE | 解除冻结 |
库存余额可以是快照,但库存真相应来自流水和状态机。
3.6.18.7.2 超卖防控
超卖防控要覆盖并发、缓存和供应商三类风险。
| 风险 | 防控 |
|---|---|
| 并发扣减 | DB 条件更新、Redis Lua、库存分片 |
| 重复扣减 | 预占流水幂等键 |
| 重复释放 | 释放流水唯一键 |
| Redis 与 DB 不一致 | 定期对账,以账本修复缓存 |
| 热点 SKU | 限流、排队、库存分桶 |
| 供应商库存延迟 | 可售水位、安全库存、实时确认 |
典型扣减条件:
UPDATE inventory_balance
SET available = available - :qty,
reserved = reserved + :qty
WHERE inventory_key = :inventory_key
AND available >= :qty;
影响行数为 0 表示库存不足,不能继续创建可支付订单。
3.6.18.7.3 少卖防控
很多团队只关注超卖,忽略少卖。少卖也是资损,因为平台损失交易机会。
少卖来源:
- 订单失败后库存未释放。
- Redis 扣减成功但 DB 释放失败。
- 预占过期扫描任务挂掉。
- 供应商库存恢复后平台未同步。
- 风险冻结后没有自动解冻。
少卖防控依赖:
- 预占过期释放。
- 库存对账。
- 冻结库存监控。
- 供应商库存新鲜度监控。
- 手工冻结工单 SLA。
3.6.18.7.4 虚拟商品和券码
券码、充值、酒店、机票这类库存更复杂。
| 品类 | 风险 | 防控 |
|---|---|---|
| 券码 | 同一码发给多个用户 | code_id 状态机、发码幂等 |
| 充值 | 供应商扣款但用户未到账 | 供应商请求幂等、回调对账 |
| 酒店 | 平台收款但供应商未确认 | 支付前二次确认或支付后快速取消退款 |
| 机票 | 价格和舱位实时变化 | 锁价、验舱、支付时效 |
虚拟履约必须有供应商请求流水,不能只记录“履约成功 / 失败”。否则供应商争议时无法举证。
3.6.18.8 营销与优惠资损防控
营销系统是“有意让利”的系统,也是资损高发区。
3.6.18.8.1 优惠预算
每个活动都应该有预算控制:
| 预算类型 | 说明 |
|---|---|
| 总预算 | 活动最多补贴金额 |
| 日预算 | 单日最多补贴金额 |
| 用户预算 | 单用户最多领取或核销 |
| 商家预算 | 单商家承担上限 |
| 商品预算 | 单 SKU 补贴上限 |
| 渠道预算 | 不同渠道独立预算 |
预算扣减要使用原子操作,并有预算流水。
预算冻结
-> 订单支付成功确认消耗
-> 订单取消释放预算
-> 退款时按规则返还或不返还
不要只在活动开始前校验预算,活动运行中必须实时监控预算消耗速度。
3.6.18.8.2 优惠叠加规则
优惠叠加要明确:
- 哪些优惠互斥。
- 哪些优惠可叠加。
- 叠加顺序是什么。
- 每类优惠作用范围是什么。
- 优惠是否参与退款。
- 优惠由平台还是商家承担。
常见事故是优惠规则从“单券”扩展到“多券 + 满减 + 积分 + 会员价”后,没有重新设计规则引擎,只是在代码里堆条件。
3.6.18.8.3 薅羊毛防控
营销资损不一定来自 Bug,也可能来自规则被利用。
常见手段:
- 新用户补贴被批量注册领取。
- 邀请奖励被刷。
- 低价商品叠加满减套利。
- 退款后优惠权益未回收。
- 组合订单拆单后优惠分摊错误。
防控方式:
- 用户、设备、支付工具、收货地址、IP、账号关系图谱。
- 优惠领取和核销频率限制。
- 低价订单和 0 元订单专项监控。
- 退款后优惠回收规则。
- 活动预算和毛利率水位告警。
营销系统不能只看转化率,也要看补贴效率和异常套利。
3.6.18.9 供应商与结算资损防控
B2B2C 和多供应商平台里,资损常发生在平台、用户、商家、供应商之间的边界。
3.6.18.9.1 供应商履约一致性
平台订单和供应商订单必须建立映射:
| 字段 | 说明 |
|---|---|
order_id | 平台订单 |
supplier_order_id | 供应商订单 |
supplier_id | 供应商 |
confirm_status | 供应商确认状态 |
supplier_amount | 供应商结算金额 |
currency | 币种 |
confirm_time | 确认时间 |
raw_response_ref | 原始响应 |
如果用户已支付但供应商未确认,系统必须明确处理策略:
- 自动重试确认。
- 切换供应商。
- 退款。
- 客服介入。
- 平台承担差价履约。
不能让订单无限停在“处理中”。
3.6.18.9.2 商家结算
商家结算至少要对齐:
用户实付
- 平台优惠承担
- 商家优惠承担
- 佣金
- 手续费
- 退款
- 售后扣减
= 商家应结算金额
结算资损常见问题:
| 问题 | 风险 |
|---|---|
| 优惠承担方错 | 平台和商家分摊错误 |
| 退款未冲减结算 | 商家多结 |
| 费率配置错误 | 佣金少收或多收 |
| 结算周期错误 | 提前或延后结算 |
| 跨币种汇率错误 | 汇兑损失 |
| 订单状态口径错误 | 未完成订单被结算 |
结算系统必须使用不可变结算批次和结算明细。已出账批次不能随意修改,应通过调账单修正。
3.6.18.9.3 调账
调账是必要能力,但也是高风险能力。
调账必须具备:
- 调账原因。
- 调账对象。
- 调账金额。
- 关联订单或结算单。
- 审批人。
- 操作人。
- 前后余额。
- 财务凭证。
调账不能直接修改原始订单金额或支付金额。正确方式是追加调账流水。
3.6.18.10 权限、配置与后台操作防控
后台是资损防控的重点区域。
3.6.18.10.1 高风险操作分级
| 风险等级 | 操作 | 控制 |
|---|---|---|
| P0 | 批量退款、批量改价、批量发券、结算调账 | 双人审批、白名单、限额、审计 |
| P1 | 活动规则上线、支付渠道切换、供应商映射修改 | 审批、灰度、回滚 |
| P2 | 单商品改价、单订单补偿、单用户补券 | 权限校验、理由必填、审计 |
| P3 | 查询、导出、低风险配置 | 权限和水印 |
高风险操作要做到“能不能做、谁能做、做多少、怎么回滚、谁审批、如何审计”都有系统约束。
3.6.18.10.2 配置变更防控
配置变更必须和代码发布一样治理。
要求:
- 配置有 schema 校验。
- 配置有版本号。
- 配置支持灰度。
- 配置支持一键回滚。
- 高风险配置需要审批。
- 配置变更事件写入监控。
- 配置生效范围可见。
例如支付渠道权重、活动预算、费率、退款规则、库存策略,都不应允许无审批直接全量生效。
3.6.18.10.3 禁止直接改库成为流程
事故处理中偶尔可能需要 DBA 级别的紧急修复,但不能把直接 SQL 变成日常流程。
直接改库的风险:
- 绕过状态机。
- 绕过幂等。
- 绕过 Outbox。
- 绕过审计。
- 无法触发下游同步。
- 无法被对账系统正确解释。
正确方式是建设修复工具:通过领域服务执行修复,自动写状态日志、Outbox、审计和对账标记。
3.6.18.11 风险规则与拦截系统
资损防控需要规则系统,但规则系统不能变成没人懂的黑盒。
3.6.18.11.1 规则类型
| 规则类型 | 示例 |
|---|---|
| 阈值规则 | 单笔退款超过 5000 元需要审批 |
| 比例规则 | 商品价格低于近 7 日均价 50% 拦截 |
| 频率规则 | 同用户 10 分钟内退款超过 3 次 |
| 预算规则 | 活动预算消耗超过 90% 自动降级 |
| 状态规则 | 已发货订单不能自动全额退款 |
| 关系规则 | 同设备批量领取新人券 |
| 对账规则 | 支付成功但订单未推进超过 1 分钟 |
规则结果不一定只有通过和拒绝,还可以是:
- 放行。
- 二次确认。
- 人工审批。
- 冻结对象。
- 降级权益。
- 触发对账。
- 触发告警。
3.6.18.11.2 规则执行点
规则要放在关键业务动作前:
| 动作 | 规则 |
|---|---|
| 计价 | 价格、优惠、预算、毛利率 |
| 创单 | 库存、优惠、风控、金额 |
| 支付 | 金额一致、订单状态、渠道风险 |
| 退款 | 可退金额、售后状态、风险审批 |
| 发券 | 用户资格、频率、预算 |
| 改价 | 成本价、影响范围、审批 |
| 结算 | 订单完成、退款冲减、费率 |
| 人工修复 | 权限、审批、幂等、审计 |
越靠近源头拦截,损失越小。等到财务 T+1 对账才发现,通常已经很难无痛修复。
3.6.18.11.3 规则治理
规则系统本身也会造成事故。
治理要求:
- 规则有 owner。
- 规则有版本。
- 规则有命中日志。
- 规则支持灰度。
- 规则支持回滚。
- 规则变更有审批。
- 规则效果可监控。
- 规则误杀可申诉和恢复。
没有治理的规则系统,最后会变成谁也不敢改、谁也解释不了的风险源。
3.6.18.12 监控与告警
资损监控要比普通可用性监控更贴近业务结果。
3.6.18.12.1 核心指标
| 域 | 指标 |
|---|---|
| 计价 | 0 元单数量、负毛利订单数、折扣率分布、价格异常拦截数 |
| 支付 | 支付金额不一致、重复渠道流水、支付成功订单未推进 |
| 退款 | 超额退款拦截数、重复退款尝试、人工退款金额 |
| 库存 | 超卖次数、预占超时未释放、库存对账差异 |
| 营销 | 预算消耗速度、券重复核销、异常领取量 |
| 结算 | 长短款、调账金额、结算差异 |
| 供应商 | 平台支付成功但供应商未确认、供应商金额差异 |
| 后台 | 高风险操作次数、审批拒绝数、批量操作影响量 |
3.6.18.12.2 告警分级
| 等级 | 场景 | 响应 |
|---|---|---|
| P0 | 重复扣款、重复退款、大面积错价、超卖、金额不一致 | 立即止血,事故群,冻结风险对象 |
| P1 | 活动预算异常消耗、供应商确认失败激增、结算差异升高 | 10 分钟内响应 |
| P2 | 单类商品价格异常、补偿积压、DLQ 增长 | 值班处理 |
| P3 | 审计报表、趋势异常 | 工单跟进 |
资损告警必须带上影响金额、影响订单数、影响用户数、开始时间、建议止血动作。只告警“规则命中异常”没有行动价值。
3.6.18.12.3 风险看板
资损看板建议分为四屏:
- 实时风险屏:P0/P1 风险、影响金额、冻结对象、当前止血状态。
- 交易链路屏:计价、创单、支付、退款、履约各环节异常。
- 权益与库存屏:库存、券、积分、预算、活动风险。
- 运营与审计屏:高风险操作、审批、调账、人工修复。
看板第一屏必须让值班同学知道:现在是否正在亏钱,亏在哪,是否已经止血。
3.6.18.13 资损事故处理
资损事故处理顺序和普通可用性事故不同。普通事故优先恢复服务,资损事故优先止血和保护证据。
推荐顺序:
- 确认是否仍在产生损失。
- 冻结风险入口,例如活动、商品、退款、支付渠道、供应商。
- 保护现场,保存日志、配置版本、订单快照、渠道流水。
- 估算影响范围和金额。
- 分类处理用户、商家、供应商和财务影响。
- 执行修复或补偿。
- 复核账务和数据。
- 输出复盘和长期改进项。
3.6.18.13.1 常见止血动作
| 事故 | 止血动作 |
|---|---|
| 错价 | 下架商品、暂停活动、冻结未履约订单 |
| 重复退款 | 暂停自动退款、冻结售后入口 |
| 优惠被薅 | 暂停活动、冻结异常券、限制用户 |
| 超卖 | 停售 SKU、冻结库存、启动客服预案 |
| 支付状态错乱 | 暂停相关渠道、启动查单和对账 |
| 供应商确认失败 | 暂停售卖或切供应商 |
| 商家结算错误 | 暂停出账批次、冻结打款 |
止血动作要提前做成开关,并有权限和审计。事故中临时找人写 SQL 关活动,通常已经晚了。
3.6.18.13.2 影响范围评估
资损事故必须回答:
- 影响开始和结束时间。
- 影响订单数。
- 影响用户数。
- 影响商家或供应商。
- 最大可能损失金额。
- 已实际发生损失金额。
- 可追回金额。
- 需要赔付金额。
- 是否涉及合规和法务。
影响范围评估依赖快照、账本、审计和对账。如果平时没有这些数据,事故当天无法凭空生成。
3.6.18.14 典型案例
3.6.18.14.1 错价 0 元购
故障过程:
运营配置满 100 减 100
-> 规则未限制适用品类
-> 低价商品叠加新人券
-> 大量订单应付金额变成 0
-> 支付绕过
-> 履约开始发货
防控点:
- 计价校验应付金额不能低于最低支付金额,除非活动明确允许。
- 活动规则必须绑定适用品类、商品、用户和预算。
- 0 元单数量异常告警。
- 负毛利订单实时拦截。
- 履约前二次校验订单风险状态。
3.6.18.14.2 重复退款
故障过程:
用户申请退款
-> 售后系统创建退款单
-> 支付渠道退款超时
-> 补偿任务重试
-> 客服又手工发起退款
-> 两条路径使用不同幂等键
-> 用户收到两笔退款
防控点:
- 退款以支付单累计可退金额为最终约束。
- 售后单、退款单、支付单建立唯一关系。
- 所有退款入口共用统一退款服务。
- 人工退款也走同一幂等和审批流程。
- 渠道超时先查单,不盲目再次退款。
3.6.18.14.3 库存重复释放
故障过程:
订单超时取消
-> 关单任务释放库存
-> 支付失败回调也释放库存
-> 补偿任务再次释放库存
-> 可售库存被加回三次
-> 后续超卖
防控点:
- 库存释放必须基于 reservation 状态机。
reservation_id + release_type或 release ledger 唯一。- 库存余额不能由多个系统直接修改。
- 库存对账发现
available + reserved + sold不平立即冻结。
3.6.18.15 上线前资损检查清单
| 检查项 | 必须回答 |
|---|---|
| 金额口径 | 订单、支付、退款、结算金额是否有统一定义 |
| 快照 | 订单是否保存商品、价格、优惠、税费和履约快照 |
| 幂等 | 支付、退款、发券、扣库存是否有服务端幂等 |
| 状态机 | 是否限制非法状态迁移和晚到事件 |
| 账本 | 资金、库存、券、积分、预算是否有流水 |
| 预算 | 活动和补贴是否有实时预算控制 |
| 可退金额 | 是否防止累计退款超过实付 |
| 可售库存 | 是否防止重复释放和超卖 |
| 人工操作 | 高风险后台操作是否有审批、限额、审计 |
| 配置变更 | 活动、费率、渠道、库存策略是否支持灰度和回滚 |
| 对账 | 支付、库存、营销、结算、供应商是否有对账 |
| 冻结 | 发现异常时能否冻结商品、订单、退款、活动、结算 |
| 监控 | 是否有影响金额、订单数和异常趋势告警 |
| Runbook | 值班同学是否知道如何止血和评估影响 |
3.6.18.16 小结:资损防控最终比拼的是系统约束力
资损防控是业务系统工程能力的综合考试。它同时考验领域建模、金额口径、状态机、幂等、账本、审批、对账、补偿、监控和事故处理。
对电商系统来说,资损防控不能只放在支付系统里,也不能只依赖财务 T+1 对账。真正可靠的做法是:
- 在计价阶段保证金额可解释。
- 在订单阶段固化交易快照。
- 在库存和营销阶段用账本保护权益变化。
- 在支付和退款阶段用幂等、状态机和渠道对账保护资金事实。
- 在供应商和结算阶段明确外部事实和结算口径。
- 在后台和配置中心治理人工高风险操作。
- 在事故中优先止血、冻结、保留证据和评估影响。
资损防控的最高标准不是“出了问题能赔”,而是系统能在大多数错误发生前拦住,在少数错误发生后快速发现,并在修复过程中保留完整证据链。
3.6.18.17 延伸思考:为什么资损防控最后会变成组织成熟度问题
资损防控表面上是技术能力,底层其实是在考验一整个组织是否真正接受了“高风险业务必须被系统约束”这件事。一个团队如果仍然习惯凭经验改价、靠口头约定补券、用 SQL 修订单、出了问题再让财务和客服兜底,那么再多的规则引擎和告警平台都只是表面繁荣。
真正有效的资损防控,最终会落实到几条朴素但难坚持的原则上:金额必须可解释,状态必须可追溯,人工操作必须可审批,修复动作必须可审计,异常趋势必须可止血,技术债务必须被持续偿还。做到这些,系统才不仅仅是“能交易”,而是“值得信任”。
3.7 组织文化篇:不惩罚文化与不可逾越的红线
系统治理走到最后,拼的从来不只是架构图和工具链,而是团队面对复杂性时的判断、纪律和克制。技术问题到最后往往会回到组织问题:需求压力如何与稳定性约束平衡,事故之后如何不甩锅但又不放松红线,谁来为长期治理负责,谁来捍卫对生产环境的敬畏。
3.7.1 复盘文化:从事故里赚回经验
事故复盘不是追责会。复盘的目标是让系统和团队变得更可靠。
一份高质量复盘至少包含:
- 事故摘要。
- 影响范围。
- 时间线。
- 根因。
- 为什么监控没有更早发现。
- 为什么保护机制没有阻止扩散。
- 为什么恢复耗时这么长。
- 已完成修复。
- 后续行动项。
- 行动项 owner 和截止时间。
复盘中最有价值的问题通常不是“谁犯了错”,而是:
- 为什么这个错误能上线?
- 为什么灰度没有发现?
- 为什么告警没有及时响?
- 为什么回滚不够快?
- 为什么影响范围没有被隔离?
- 为什么同类问题以前没有被系统性治理?
复盘行动项要避免空话。比如“加强测试”“提升稳定性意识”不是有效行动项。有效行动项应该是:
- 为订单创建接口新增创单成功率 SLO 告警。
- 将营销依赖从订单线程池拆到独立 bulkhead。
- 支付回调增加渠道流水唯一约束。
- 配置中心对支付渠道权重开启双人审批。
- 每月演练一次库存服务超时降级。
3.8 本章小结:生产系统不是被设计稳定的,而是被治理稳定的
生产系统的长期稳定,从来不是靠某一个高可用组件、某一套报警平台,或者某几位经验丰富的值班同学单独撑起来的。它真正依赖的是一整套动态平衡机制:在业务增长时控制技术债,在事故发生时快速止血,在恢复之后把经验沉淀成治理规则,在组织层面守住对生产环境的敬畏。
如果只做保障,不做治理,团队会不断重复同一类事故;如果只谈治理,不建设恢复能力,系统会在第一次真实冲击中暴露脆弱;如果长期回避技术债,任何表面上的稳定都会在未来某个节点以更高利息偿还。
所以,本章真正想强调的不是某一项技巧,而是一种面向长期演进的工程观:治理负责治本,保障负责治标,技术债务决定系统的隐形成本,而这三者必须在真实生产运行中不断被重新校准。一个能够活二十年的系统,不是因为它从不出错,而是因为它能在每一次出错后变得更可控、更可解释,也更值得继续承载业务。
3.9 延伸思考:当系统继续增长,下一阶段的治理挑战是什么
走到这里,可以回头问三个更难、也更长期的问题:
- 当系统规模继续扩大时,我们是在增加能力,还是在增加复杂度?
- 当业务继续追求速度时,哪些技术债仍然可以接受,哪些已经越过了红线?
- 当平台和工具越来越多时,团队是否真的形成了自驱治理能力,还是仍然靠少数“救火队长”维持运转?
很多系统的分水岭,不在于某一次大促有没有扛住,而在于团队能不能从“出了问题会处理”,走向“复杂性上升时仍然知道该如何治理”。