发生了什么

某业务给商户算电费账单时,不是每次都去读合同主表,而是在合同生效时把单价等关键信息拷成一份合同快照,之后账单生成都直接读这份快照。几个网点续约后签了新合同、单价下调,新合同也正常写进了主表,但续约走的是一条旧的回调路径,这条路径只更新了主表,没有重建快照。结果账单生成时读到的还是续约前的旧快照,一直按旧的高单价结算,多算了商户的钱。这个缺陷一年前就存在,一直没人发现,直到业务方对账时发现单价不对才上报,最后靠重建这几个网点的快照、并订正历史账单来修复。

本质

被破坏的约束是主数据和它的冗余副本必须始终保持一致。为了性能把单价快照化本身没错,但一旦引入副本,就多了一条隐性契约:任何能修改主数据的入口,都必须同步刷新副本。这次续约的旧回调路径漏掉了重建快照这一步,副本和主表就此长期分叉;而下游账单只信副本、从不回查主表,也没有任何一致性核对,于是错误既发生得悄无声息,又能持续很久。

下次遇到这些场景要警惕

只要为主数据建了快照或冗余副本,先把哪些入口会改主数据列全(新建、续约、变更、手工订正、各种新老回调路径),保证每一条都同步重建副本,别让某条老分支成为漏网之鱼。设计上尽量把更新主数据和重建快照绑成一个事务或一个统一入口,避免有人只改一半。一定要加主表与快照的一致性对账(比如定期比对快照单价与当前合同单价、账单单价与合同单价),把这种静默的数据不一致主动暴露出来,而不是等业务方对账发现。测试上专门覆盖续约或变更后再生成账单的场景,断言账单单价跟随新合同,而不只测首次签约。

下面用代码把漏更新快照和修复讲清楚。

问题版本:续约的旧回调只更新主表,忘了重建快照。

void onContractResign(Contract newContract) {
    contractMapper.update(newContract); // 更新了合同主表(新单价进库)
    // 旧回调路径漏了这一步:没有重建快照
    // snapshotService.rebuild(newContract.getNodeId());
}

// 账单生成:只读快照,读到的还是旧单价
Snapshot snap = snapshotMapper.getByNode(nodeId); // 旧快照,price=旧价
bill.setUnitPrice(snap.getUnitPrice());           // 一直按旧价结算

修复版本:把更新主表和重建快照收敛到一个入口一个事务,所有改动路径都走它。

@Transactional
void applyContractChange(Contract contract) {
    contractMapper.update(contract);                 // 更新主表
    snapshotService.rebuild(contract.getNodeId());   // 同步重建快照,二者不分叉
}

// 新建、续约、手工订正等所有入口,统一调用这一个方法,避免漏掉某条分支

一致性对账兜底(把静默不一致主动暴露):

// 定期核对:快照单价是否等于当前合同单价
for (Node node : allActiveNodes) {
    BigDecimal contractPrice = contractMapper.getCurrentPrice(node.getId());
    BigDecimal snapshotPrice = snapshotMapper.getByNode(node.getId()).getUnitPrice();
    if (contractPrice.compareTo(snapshotPrice) != 0) {
        alert("快照单价与合同单价不一致,疑似续约未刷新快照", node.getId()); // 主动告警
    }
}

前后端边界:这条完全是后端数据一致性问题,后端续约回调漏了重建快照、账单只读快照不回查主表、又缺对账。前端或商户端只是如实展示后端算出来的账单单价,本身没错。修复重心全在后端:统一改主数据即重建快照的入口、补主表与快照的一致性对账、并订正历史脏数据。前端无需改动。