业务数据出问题时,最容易犯的错不是 SQL 写不出来,而是太快把 update 敲下去。尤其是导入类问题,乍一看只是几条金额缺失,条件一宽,旧批次、退款单、未支付单都可能被带进去。KingbaseES 里修这种数据,语法本身不复杂,麻烦的是怎样把影响范围收住。
这次用一组订单数据模拟一个常见现场:某个导入批次里,部分已支付订单的金额变成了 0。修复值暂时统一写成 199.00,不代表真实业务规则,只是为了看清修数过程。整套操作只围绕三个问题:到底要改哪些行,改错了怎么撤,留下的操作记录能不能和影响行数对上。
先把数据角色分清
先准备两张表,一张放订单,一张放修数记录。t_scene_order 是这次要处理的业务表,t_scene_fix_log 用来记录修复动作。表建好以后,用 \dt app_schema.t_scene_* 确认对象都在 app_schema 下,owner 是 app_user。

表结构本身不复杂。真正需要看的是后面的数据分布,尤其是同样金额为 0 的订单,哪些属于这次导入批次,哪些只是看起来像问题。
初始数据一共 10 行。BATCH_20260609_A 是本次要处理的导入批次,1、2、3、10 这几行都是 paid,金额是 0.00,备注里写着 import amount missing。4、6、9 虽然也是本批次已支付订单,但金额正常。5 是未支付,8 是退款,不能因为金额为 0 就动它们。
还有一行最容易误伤:7 号订单。它也是 paid,金额也是 0.00,但批次是 BATCH_20260608_B,备注已经写了 old batch should not touch。这行数据放在这里,就是为了防止修数条件写得太随手。

修数前先把这 10 行按角色分清,会比后面盯着 SQL 看更清楚。1、2、3、10 是目标行;7 是误伤参照行;4、6、9 是正常支付订单;5 和 8 虽然金额也是 0,但状态分别是 unpaid 和 refund,不属于这次修复。只要后面的 SQL 返回了不该出现的行,立刻就能停下来。
这种准备数据的方式有点啰嗦,但它更接近工作里的麻烦。真实业务里很少只有一种异常行,更多时候是几种状态混在一起,字段值看起来相似,业务含义却完全不同。金额为 0 只是表面现象,能不能修,还要看支付状态、导入批次和备注里的上下文。
第一条 SQL 不该是 update
这种场景里,第一条 SQL 不应该是 update。先把可疑范围查出来,看一眼条件会带出什么东西。
先按宽条件查:pay_status = 'paid' and amount = 0。结果返回 5 行,包含 1、2、3、7、10。这里 7 号立刻暴露出来,它不是本次导入批次,却会被这个条件带上。再加上 imported_batch = 'BATCH_20260609_A' 后,结果收成 4 行,只剩 1、2、3、10。

这一步已经把风险摆出来了。只按"已支付、金额为 0"修,会多动一条旧批次订单;按本次导入批次收紧后,才和实际要处理的范围对齐。后面所有更新,都应该围绕这 4 行展开。
宽条件不是不能查,它适合用来发现边界。第一次查出 5 行,说明异常不是只出现在当前批次里;第二次查出 4 行,说明本次任务可以把批次作为限制条件。两个结果放在一起,修数条件就不是凭感觉写出来的,而是从数据里收出来的。
这里也能顺手看出 count(*) 不是唯一选择。很多修数前只查一个数量,比如看到 4 行就准备动手,但数量不会告诉人这 4 行是不是正确的 4 行。先把订单号、批次、状态、备注一起查出来,才能判断条件有没有漏掉业务含义。
修数前留一份原始状态
动数据之前先留一份原始状态。这里用 create table as select 建了一张备份表,把本批次中 paid 且金额为 0 的订单保存下来。执行结果是 SELECT 4,备份表里也只有 1、2、3、10 四行,金额仍然是 0.00。

这份备份不解决业务问题,但它能让后面有据可查。修复前是什么样,修复后改了哪些字段,影响行数是不是一致,都可以回到这张表里对。临时实验可以这样做,正式环境里也应该有对应的备份、审批或变更记录,不能只靠终端历史。
备份表的价值还在于它不会随着后面的事务回滚一起消失。这里是在事务试修前单独建出的表,里面保留的是修复前的候选行。后面不管先试错、回到保存点,还是重新更新,这份原始数据都能留下来。修复金额、备注、更新时间这些字段发生变化以后,旧值不会只存在人的记忆里。
不过这类备份也不能随手乱留。表名里带了日期,能看出它属于哪次修复;查询条件也和后面正式修复条件一致,不是把整张订单表复制一份。正式场景里,还要考虑备份表权限、保留时间、敏感字段脱敏等问题,这里只把修数前的最小保护动作跑出来。
宽条件先暴露风险
接着进入事务,先立一个保存点 sp_before_fix。这里故意先跑一次宽条件更新,把问题暴露出来:
sql
where pay_status = 'paid'
and amount = 0
这条更新把金额改成 199.00,备注改成 fixed by wide condition,并通过 returning 返回被更新的行。结果很直观:返回了 5 行,UPDATE 5,其中包含 7 号订单。

如果没有 returning,这里只看一眼 UPDATE 5 也能发现不对,因为前面备份候选行只有 4 条。但 returning 更直接,7 号订单的批次 BATCH_20260608_B 已经出现在返回结果里,说明这条 SQL 会误伤旧批次。
这个时候不能提交,也没必要整笔事务重来。保存点已经立好了,直接回到 sp_before_fix。
sql
rollback to savepoint sp_before_fix;
回到保存点后,再查 1、2、3、7、10。金额全部回到 0.00,7 号订单的备注仍然是 old batch should not touch,updated_at 也没有被写进去。刚才那次试修被撤掉了,事务仍然还在。

这就是保存点在修数场景里的价值。它不是用来炫语法的,而是给一次试探留退路。条件写宽了,先撤回到试修之前;前面已经完成的备份、范围判断和事务上下文不用重新来。
回到保存点以后,当前会话还在同一笔事务里。前面的试修结果没了,但数据库连接没有退出,后面还能继续执行新的 update。这和直接 rollback 整笔事务不同:整笔回滚会把当前事务里的所有变动都撤掉,保存点只撤掉保存点之后的那段。
这类修数不适合靠"应该没问题"推进。宽条件试修已经把 7 号带出来了,如果此时继续往下改,只会让错误叠在错误上。先回保存点,把现场清回试修前,再换成更窄条件,后面的结果才容易判断。
收紧条件后再正式更新
重新写更新条件,这次把导入批次放进去:
sql
where imported_batch = 'BATCH_20260609_A'
and pay_status = 'paid'
and amount = 0
返回结果变成 4 行,刚好是 1、2、3、10。金额统一改成 199.00,备注改成 fixed after batch check,更新时间是 2026-06-09 14:20:00。7 号订单没有出现在返回结果里。

这次影响范围和前面备份表对上了:备份 4 行,正式更新 4 行,订单号也是同一组。修数时最怕的是只看 SQL 能跑通,不看它到底改了谁。这里通过 returning 直接把被改的订单拉出来,少了一层猜测。
returning 返回的是更新后的值,所以这里能直接看到金额已经变成 199.00,备注已经变成 fixed after batch check,更新时间也写成了 2026-06-09 14:20:00。如果只看 UPDATE 4,还要再跑一条查询才能知道改后的字段是否符合预期。
当然,returning 也不能替代后面的对账。它只能告诉人当前这条更新命中了哪些行,不能证明没有遗漏别的业务条件。比如退款单 8 没出现,是因为条件里限制了 pay_status = 'paid';旧批次 7 没出现,是因为这次加了导入批次。字段限制是不是完整,还要回到业务场景判断。
提交前把参照行一起查
提交前再做一次对账。把目标行和 7 号参照行一起查出来:1、2、3、10 的金额已经是 199.00,备注都是 fixed after batch check;7 号仍然是 0.00,批次仍然是 BATCH_20260608_B。再查本批次中 paid 且金额为 0 的数量,结果是 0。

这一步比单纯看 UPDATE 4 更稳。UPDATE 4 只能说明改了 4 行,不能说明这 4 行是不是目标行;对账查询同时看了目标行、参照行和剩余脏数据,才知道这次修复没有把旧批次带进去。
对账时把 7 号放进查询里,是有意为之。它不是目标行,却是最容易被宽条件带走的行。只查 1、2、3、10,会看到目标行都修好了;把 7 一起查出来,才能确认"该留在原地的行"也确实没有变化。
remain_dirty_rows 返回 0,也只是当前条件下的结果:本批次、已支付、金额为 0 的订单没有剩余。它不代表整个订单表再也没有金额为 0 的数据,因为 5、8、7 这些行本来就不在本次修复范围里。这个边界要写清楚,否则很容易把一次局部修复讲成全表清洗。
修数记录要和影响行数对上
确认范围没问题后,再写一条修数记录。记录里写了 fix paid zero amount orders,影响行数是 4,备注是 only BATCH_20260609_A paid orders were updated,时间是 2026-06-09 14:25:00。这条记录和前面的更新结果能对上。

修数记录已经插入成功,返回 INSERT 0 1。这里没有把后续提交和全表终态强行写成已经验证过的内容,只按终端里能确认的结果收住:修数记录已经生成,提交前的对账已经确认目标行和参照行状态,记录也有对应的批次和影响行数。
修数记录里的 affected_rows 写成 4,不能随便填。它对应的是收紧条件后的 UPDATE 4,不是宽条件试修时的 UPDATE 5。如果这里也写 5,记录本身就会和实际修复范围冲突;如果不写记录,事后只能从日志或者终端历史里翻,成本会高很多。
这套流程跑下来,开始时宽条件查出 5 行,说明直接按金额修会带上旧批次;按批次收紧后是 4 行,备份表也保存了这 4 行;事务里故意试了一次宽条件,returning 把 7 号误伤暴露出来;回到保存点后,7 号恢复原样;收紧条件再更新,返回结果只剩目标 4 行;提交前对账,剩余本批次脏数据为 0。
如果把这次操作缩成一句话,就是先把"可能有问题的数据"变成"确定要处理的数据"。这中间靠的不是某个固定模板,而是几次核对:宽范围查一次,按批次收一次,备份一次,事务里试一次,发现多改就退回去,再把条件补齐。
以后遇到类似修数,不要急着把 SQL 改到"看起来正确"就执行。先查范围,留原始状态,在事务里试一遍,用返回结果看清影响行,发现不对就回到保存点。最后再把修复结果、参照行和记录表对上。这样处理虽然多敲几条 SQL,但比一次宽条件更新之后再追着查误伤要省很多事。