Production War Stories
ODTS 22 — 生产事故集:什么崩了,为什么
这里每个事故都是真实的。名字和日期已改,以保护”肇事者”。
事故一:多算了 10 倍的保证金催缴
背景: 2022 年一个周一早上。保证金团队运行每日变动保证金 (VM) 计算。一个客户的催缴显示为 5000 万人民币。上次催缴是 500 万。“他们的持仓变了?""没有,和周五一样。""标的物价格有变动?""略微波动,但不会有 10 倍。”
调查: 三个小时的深入调查发现根因:一笔上周五到期的交易没有被关闭。簿记系统把交易状态设为”LIVE”,EOD 批处理根据到期日将其标记为”EXPIRED”,但周四的一个手工修改 (amendment) 把状态重新设成了”LIVE”——修改代码对所有修改都设置 status = "LIVE",包括期限展期修正。结果是已到期交易被当作 LIVE 处理,其名义本金(5 亿)被计入保证金计算,VM 从 500 万跳到了 5000 万。
修复: 一行代码 if (amendment.type == EXTENSION) { keep existing status; }。但找到这个问题花了 3 个小时的 SQL 查询、日志挖掘和代码审查。
业务影响: 客户在 09:30 收到了 5000 万的保证金催缴,09:35 就打电话给客户经理问”搞什么?“。客户经理花了 2 小时解释这是系统错误。客户信任度略微下降(“你们的保证金催缴不可信”)。运营在 13:00 才发出更正后的催缴。
教训: 状态是神圣的。每行修改交易状态的代码都必须经过审查。修改操作不应该盲目重置状态。
事故二:运行了 18 小时的批处理
背景: EOD 批处理,2023 年 7 月。通常 2 小时完成。这天晚上,它跑了 18 小时。
18:00 批处理启动,19:00 卡在第 3 步(交易丰富化),20:00 运营注意到异常,21:00 开发者被叫来查日志,22:00 发现 EDS_contractElementValue 表上一个查询正在对 8000 万行做全表扫描 (full table scan)。凌晨 1:00 加索引——没帮助。凌晨 4:00 终于发现:TO_NUMBER(elementValue) 在 8000 万个 VARCHAR2 值上做转换,无法使用索引。凌晨 6:00 停掉批处理,修复查询(先按 elementId 过滤再转换),从第 3 步重跑。中午 12:00 批处理完成。运营下午 1:00 才回家。
慢查询对比:
-- 改前(慢):先 TO_NUMBER 再过滤
SELECT contractId FROM EDS_contractElementValue
WHERE TO_NUMBER(elementValue) > 10000000
AND elementId = 'E000028';
-- 改后(快,< 1 秒):先过滤再 TO_NUMBER
SELECT contractId FROM EDS_contractElementValue
WHERE elementId = 'E000028'
AND TO_NUMBER(elementValue) > 10000000;
Oracle 优化器选择了错误的执行计划——它先应用了 TO_NUMBER 转换,然后才做 elementId 过滤。
业务影响: 交易员看不到当天的 P&L;周二保证金催缴被延迟(基于周一的 EOD 数据);客户结算指令未准备;错过了香港监管截止时间(SFC T+1 报告在 15:00 提交而非 10:00);3 人通宵工作(开发 + 运维 + DBA)。
教训: EAV 查询在大数据量下是危险的。在 10 万行上跑得好好的查询,在 8000 万行上就崩溃了。EAV 设计让这成为必然。
事故三:消失的交易
背景: 2022 年 12 月。客户来电说收到了一笔他们从未同意的 TRS 交易的确认书。交易台检查,交易确实存在于系统中,但交易员说从未簿记过。
调查: 这笔交易由用户 ID “SYS_BATCH” 簿记。EOD 批处理中有一个针对到期 TRS 的”自动续约”步骤。一笔 12 月 1 日到期的 TRS 被批处理自动续约,但交易员已在 11 月 30 日手工续约了该交易——批处理没有检查交易是否已被续约。结果是一笔重复交易被自动簿记,名义本金 1 亿人民币,对手方是一家大型中国银行。
修复: 增加了检查:“如果已存在相同条款和交易对手的替代合约,跳过自动续约。“此外,所有自动续约的交易现在状态设为 PENDING_REVIEW 而非 LIVE,运营必须手工批准。
教训: 没有幂等性 (idempotency) 的自动化是危险的。如果批处理运行两次(例如在失败后重跑),就会产生重复数据。每个批处理过程都应该设计为幂等的——运行两次应该产生与运行一次相同的结果。
事故四:打错账户的结算
背景: 一次常规结算批处理。系统生成 SWIFT MT202 指令,将 2000 万人民币从中金账户转入客户账户。但银行账号是错的。
客户在 11 月 1 日更改了结算账户,修改在 CRM 系统中完成。但结算系统有自己的银行账户数据副本。CRM 到结算系统的同步任务有 bug——它只在工作日同步。11 月 1 日是周六,同步没有运行,结算系统使用了旧(已关闭)账号。
托管行退回了指令:“账户已关闭”。运营在 15:30 发现,更正了账号,15:45 重新发送。结算在 16:05 完成(超过托管行截止时间 5 分钟,但他们接受了)。
教训: 系统间数据同步是可靠性噩梦。每个数据副本都可能变旧,而且终将变旧。解决方案要么是实时 API 调用(没有副本),要么是每日对账加差异告警。
事故五:名义本金错误的监管报告
背景: SAC 监管报告,2023 年 12 月。监管问询:你们 TRS 交易的名义本金总额差了 20 亿。
调查发现 20 亿差异来自 4 笔交易,每笔名义本金 5 亿。这些交易以美元簿记但以人民币报告。报告使用了 EOD 快照的汇率(7.2)而非交易日的汇率(6.8)。每笔差异 2 亿 × 4 = 8 亿——还不够 20 亿。进一步调查发现其中 2 笔还有另一个问题:它们的期限通过修改被展期,修改发布了交易的新版本,其中名义本金被设为了 0(遗留 bug),报告取了旧版本的名义本金但用了错误的汇率。
解决: 交易台提交了更正后的报告,向监管解释了汇率方法。监管接受了解释,但要求采用更稳健的报告流程。
教训: 监管报告的质量取决于其数据谱系 (data lineage)。报告中的每个数字都追溯到源系统。如果源数据存在歧义(哪个汇率?哪个交易版本?),报告就会出错。
事故六:搞崩一切的线上热修复
背景: 开发者 A 在周五 15:30 部署了一个热修复(已经违反了规则一:绝不在周五下午部署)。热修复改了 EDS_contractElementValue 插入逻辑的一行代码,本意是为确认模板增加一个新字段。
15:35 交易助理尝试簿记一笔新交易,15:36 弹出”系统错误:ORA-01438:值大于指定精度”。15:37 所有交易簿记被阻塞。15:38 开发者慌了,回滚热修复。15:45 回滚完成,交易簿记恢复。但热修复已经插入了 8 条损坏的记录。15:47 运维开始清理,16:00 全部清理完成。损失了 25 分钟的簿记时间。
根因: 热修复把某个特定字段的 elementValue 长度从 30 改成了 20,但现有数据是 25 个字符,INSERT 触发了 Oracle 的值过大错误。
真正原因: 没有代码审查。没有测试环境。直接在生产数据库上执行。
教训: 软件中最危险的词:“就快速改一下。“
事故七:圣诞节重复簿记的交易
背景: 2022 年 12 月 25 日。团队大部分人在休假,一位高级交易员在值班。交易员簿记一笔互换交易,UI 显示”簿记成功”。但交易员因延迟以为第一次没成功,点了两次”提交”。
系统生成了两个不同的合约 ID,相同条款、相同对手方、相同名义本金。客户收到了同一笔交易的两份确认书。
根因: 簿记 API 没有幂等性保护。前端发送了两次 POST 请求,后端都处理了。
修复: 实现了幂等键 (idempotency key) 模式——客户端在请求头中发送唯一的幂等键,如果 5 分钟内再次看到相同键,返回已有结果而不是创建新交易。这是支付系统的标准做法,但交易系统没有。
教训: 每个创建操作都应该是幂等的。如果客户端重试,你应该得到相同的结果。
这些事故教会了我们什么
| 模式 | 出现于 | 教训 |
|---|---|---|
| 状态破坏 (State corruption) | 1, 5 | 状态转换必须明确且经过验证 |
| 查询退化 (Query degradation) | 2 | EAV 查询不经仔细优化无法扩展 |
| 非幂等自动化 | 3, 7 | 每个批处理和 API 必须安全处理重试 |
| 数据副本陈旧 | 4 | 实时数据应该实时获取,而非批处理同步 |
| 数据谱系缺失 | 5 | 每个报告值必须追溯到单一真相源 |
| 周五部署 | 6 | 别。千万别。 |
| 重复提交 | 7 | 幂等键不是可选项 |
每个事故的经济代价:
- 10 倍保证金错误 — 客户经理 2 小时的解释时间、客户信任损失(隐性成本)。更直接的代价:更正后的保证金延迟到 13:00 才发出,期间客户有 3.5 小时的无担保敞口。如果客户在那 3.5 小时内违约,交易台损失 4500 万(5000 万 - 500 万已付)。这种事件的直接成本没有发生,但信用风险团队为此增加了该客户的保证金频率——从每周追保改为每日追保。增加了运营每天 15 分钟的工作量。
- 18 小时批处理 — 3 人通宵加班(开发+运维+DBA):加班成本约 6000 元。错过香港 SFC 监管截止时间:虽然没有罚款,但合规部门出了整改报告,团队花了 1 周写流程文档。交易台一天的 P&L 数据不可用:交易员第二天早上盲目交易,无法判断隔夜风险。机会成本:如果 P&L 在早会前可用,交易员可能发现一个需要紧急对冲的敞口。不可量化,但经理说不出现这种成本就够了——“你不知道你不知道什么,这才是最可怕的。”
- 消失的 TRS 交易 — 名义本金 1 亿的重复交易,幸运的是客户诚实 + 发现及时。如果客户不诚实,交易台可能要为这 1 亿承担对手方风险。即使被发现,取消这笔交易也需要修改 amendment 和双方签署——运营工作量 2 小时 + 法务审核。
- 打错账户的结算 — 本身只造成了 25 分钟的延迟。但托管行对延迟结算的标准收费是 500 元/笔。更严重的是:如果这个 bug 发生在年终结算日,2000 万资金隔夜才到账 = 透支利息约 5000 元/晚。经理说:“这不是最贵的,但它是那种哪天真的出大事了就会想起的事情。”
- 名义本金错误的监管报告 — SAC 问询的专业团队回复成本:合规 1 人 + 运营 1 人 + 交易员 0.5 人,共 2.5 人 × 2 周 = 5 人周 = 10 万。加上”被 SAC 重点关注”的隐性成本:后续 6 个月的每次报送都多花 15 分钟人工复核 = 约 30 小时。
- 搞崩一切的线上热修复 — 25 分钟的交易簿记阻塞。如果在交易活跃期(09:30-11:30),25 分钟可能意味着错失 3–5 笔新交易。平均每笔 TRS 交易每年产生 10–20 万利润——25 分钟的阻塞≈损失 30–100 万潜在收入。好在发生在周五下午 15:35(交易清淡期),实际损失仅为 8 笔损坏记录的清理成本(运维 0.5 小时)。
- 重复簿记的交易 — 因为客户诚实退还了确认书,实际损失是取消交易的运营工作量。但如果客户不诚实:1 亿名义本金的 TRS,如果市场朝有利方向变动,客户可以主张交易成立——潜在损失 = 市场变动金额。最坏情况按 10% 波动算 = 1000 万。
每个故事的共同主线:设计假设了完美条件。数据库设计 (EAV) 假设值都能放进 30 个字符。批处理假设它永远不会并发运行。热修复假设它不会破坏任何东西。生产环境是所有假设被检验的地方。