08-六块七毛六的教训:一次计费双扣事故复盘
六块七毛六的教训:一次计费双扣事故复盘
2026 年 10 月 20 日上午,我们在对账时发现:一通 20 秒的通话,前端"消费金额"一栏先是显示 0,几小时后变成 0.10 元——但租户实际被扣了两次,0.10 + 0.10。
最终清点:52 笔双扣,合计 6.76 元,波及 3 个租户,时间窗从 10 月 16 日 08:10 持续到 10 月 20 日 09:32。
金额小到不够买杯奶茶。但这是资损——计费系统的钱算错了,性质和金额无关。而且根因之隐蔽、链条之长,值得完整复盘一次。这是我个人的看法:6.76 元买来这次复盘,很便宜。
排查:从一条话单倒着挖
排查链路是一条标准的"顺藤摸瓜",每一环都指向下一环:
cc_cdr WHERE callee='18998293343'命中话单,cdrId=1211,fee=0.10——话单本身正常;- 查
cc_billing_flow WHERE cdr_id=1211(计费流水表),找到一条备注"话单消费(补偿)"的流水——"补偿"两个字不对劲,正常消费不该走补偿; - 查
cc_billing_flow WHERE cdr_id IS NULL AND biz_type=1——找到一批 cdr_id 为 NULL 的消费流水,时间对得上; - 翻 CDR 落库日志,看到关键一行:
17:07:23.297 [CdrConsumer][onMessage] CDR 落库 uniqueId=… id=null
落库成功,但主键是 null。 顺藤摸到 CdrWriteMapper.insertCdrOnConflict 的注解:SQL 里 inline 写了 nextval('cc_cdr_seq') 取序列,但方法上没有 @SelectKey——MyBatis 不会把生成的主键回填到实体对象。
一个注解的缺失,后面发生了什么?
根因链:一个 NULL 如何穿透四道防线
这个系统不是没有防线,恰恰相反,防线一道接一道——问题是每一道都被这个 NULL 以不同的方式绕过去了:
第一道:消费幂等查询。 计费前先查"这条话单扣过没有"(WHERE cdr_id=?)。cdrId 是 null,查询条件为空,查不到任何记录——幂等检查形同虚设,于是放心地写了一条流水,流水里的 cdr_id 是 NULL。
第二道:数据库唯一约束。 流水表有防重唯一索引,但它是部分索引:WHERE cdr_id IS NOT NULL——当初这么设计是为了兼容"充值、划拨这类没有话单的流水"。NULL 流水不在这个索引的管辖范围内,数据库层面也不拦。
第三道:话单费用回填。 扣完费要回填 cc_cdr.fee,回填用的也是这个 null 的 cdrId——UPDATE 影响 0 行,话单的 fee 保持 0。
第四道:日终对账与重试。 这是最讽刺的一环。对账 Job 扫描到"接通了但 fee=0"的话单,判定为"扣费遗漏",交给 BillingRetryJob 补扣。补扣时按 cdrId=1211 查幂等——NULL 流水查不到——再扣一遍。这就是那笔"话单消费(补偿)"。
四道防线,每道都在自己的视角内正确工作,合起来把一次缺陷放大成了双扣。设计防线时的假设是"cdrId 一定有值",这个假设在类型系统里根本不成立(Long 可以为 null),却没有人质疑过。
修复:五个动作
- 补
@SelectKey:@SelectKey(statement="SELECT nextval('cc_cdr_seq')", keyProperty="id", before=true, resultType=Long.class)——先取序列再插入,主键必然回填。这是根因修复。 - 反查兜底:新增
selectIdByUniqueIdAndStart,万一主键仍拿不到,按唯一键(unique_id + start_time)反查。 - 拒写 NULL 流水:
doCharge里 cdrId 为 null 直接 return,宁可这笔账挂起进人工,也不写一条查不回来的流水。 - 存量数据修整:增量 SQL
V20261020_01分四段——重建唯一索引(限定flow_type=2 AND cdr_id<>0)、52 笔双扣配对退款、孤儿流水标记、流水表 cdr_id 改NOT NULL DEFAULT 0。流水表外键列永远不该允许 NULL,这是这次最痛的领悟。 - 退款执行:按配对结果生成 52 笔退款流水,实际退给租户。
发布顺序有硬约束:必须先发后端版本、再执行增量 SQL——顺序反了,旧代码还在写 NULL 流水,新索引会把它拒之门外,消费链路直接报错。执行前 pg_dump -Fc -t cc_billing_flow 全表备份。这套"顺序约束 + 备份"写进了增量 SQL 的文件头注释里。
真正要说的两件事
一,防御性兜底是双刃剑。 对账 Job 和重试 Job 本来是安全网,但在缺陷持续写脏数据的日子里,它们成了放大器:缺陷写一条 NULL 流水 → 对账发现 fee=0 → 重试再扣一遍。兜底逻辑的每一个"我认为异常"的判断,都要问一句:如果这个异常是持续的,我会把它放大吗? 现在重试任务补扣前会检查同租户同话单的全部流水(含 NULL 时期的标记行),配不平就告警人工,不再自动动手。
二,code review 要有"主键回填"这个检查项。 用 nextval() inline 进 SQL 语句,插入完全正常、查库数据完全正常,只有 Java 对象里的 id 是 null——编译器不报错、测试不覆盖就发现不了(单测里谁会去断言 DO 的 id 字段呢)。从这次起,review 涉及 insert 的 Mapper 时,"主键怎么回填"成了一个固定问题。序列场景必须 @SelectKey(before=true),没有例外。
另外记一笔流程上的观察:从第一笔双扣(10 月 16 日)到发现(10 月 20 日)隔了四天。期间没有用户投诉——金额太小,没人注意。发现靠的是我们自己跑三方对账(话单 × 流水 × 余额快照链式校验,这也是对账体系建成后第一次真正发挥作用)。计费系统的对账不是可选项,是唯一能在用户发现之前抓到问题的手段。小额资损尤其如此:它安静、持续、不触发任何告警,直到某天余额对不上。
6.76 元,买来了一个 review 检查项、一条"流水表禁止 NULL 外键"的建表纪律、一套三方对账的实战验证,和这篇复盘。值。
本文是「云呼叫中心技术内幕」系列第 8 篇。

浙公网安备 33010602011771号