ENGINEERING NOTES · 工程笔记

线上改数据的正确姿势:脚本化、可回滚、有守卫

利益相关:笔者是多租户微信小程序 SaaS 项目的主力工程师,技术栈 Java 21 + Spring Boot,生产 PostgreSQL、本地开发 H2,85 个 Flyway 迁移推进到 V113,覆盖 27 个业务域。结构变更有 Flyway 管着——那套纪律之前写过一篇;但线上数据订正,那些"把几百行错误状态改回来"的活,Flyway 管不到,而它们恰恰最危险。本文讲我们把订正流程化的五步,SQL 均为脱敏示意。

一、背景:改结构的有人管,改数据的在裸奔

真实一幕。一个周五下午四点多,客服转来工单:某租户一批核销记录状态卡在页面上刷新不出来。排查到六点确认:v2.3.1 一个前端重复点击的 bug,让 status 列写进了一个业务上不存在的值。修代码半小时;已落库的几百行脏数据要订正。同事连上生产库,手敲 UPDATE,回车。

影响行数跳出来的那个瞬间他脸色变了:WHERE 里漏了租户条件,匹配的不止目标租户。万幸他包在事务里、万幸他盯着行数看了一眼——ROLLBACK,前后不到十秒。复盘会上他说:如果当时不在事务里,如果先去倒了杯水再看结果,这个事故就要靠第二天对账才浮出来,而那时连"改了什么"都查不到——手敲的 SQL 没有版本、没有评审、没有备份。删错了结构有迁移历史兜底,删错了数据,没有后悔药。

虚惊一场,但裸奔是实打实的。自此立规矩:线上订正数据必须走流程,五步,一步不省。

二、流程五步

  1. 需求书面化:改什么、为什么、影响多少行。 一张工单写清目标表与字段、订正原因(关联 bug 单号)、预估影响行数。预估不拍脑袋——先 COUNT,COUNT 的 WHERE 必须与正式脚本逐字一致。预估 812 行、实际影响 8000 行,说明条件写弱了,停下来查,别硬跑。
  2. 脚本进版本库:订正脚本也是代码。 路径如 ops/fix/20260814_fix_verify_status.sql,走 PR 评审后再执行。执行的就是仓库里那份文件,不是评审完又手敲一遍的"差不多"版本——手敲,正是事故里漏掉租户条件的来源。
  3. 先备份:目标行导出留底。 执行前把即将被改的行 SELECT 出来,连同主键与旧值存成工单附件。回滚预案 = 留底数据 + 反向脚本,执行前备好、评审时一起过目。
  4. 分批执行:一次一批、批间校验。 主键区间切片、每批约 5000 行、批间提交并抽查。长事务一把梭会带来什么(锁等待顺着连接池堵到应用层),迁移那篇讲过,订正同理。
  5. 验证与对账:改后核对业务口径。 不只看影响行数:改后字段值的分布对不对、与预估是否一致、抽样行在页面上表现得对不对。行数对了不等于改对了。

三、守卫前置:防再犯比改这一次更重要

每张订正工单收尾前必答一问:这批脏数据是怎么进来的?如果答案是"现在的代码仍写得进这个值",那光订正等于给漏水的桶舀水。订正同批(或之前)先上守卫迁移——CHECK 约束、唯一约束、触发器,全部版本化进 Flyway(延续上一篇的纪律四),让同类脏数据在写入那刻被数据库拒绝。代码修复、守卫上库、数据订正,三件事一起收口,这个坑才算填平。

四、完整示例:一次状态字段批量订正

场景(脱敏示意):核销记录表 t_verify(示意表名)的 status 字段,约 800 行被写成不存在的 PARTIAL_DONE,业务含义应为 DONE。从预估到回滚预案的全过程如下。

第一步,书面化工单:

工单 FIX-20260814-01
改什么:t_verify.status,PARTIAL_DONE -> DONE
为什么:bug #487,v2.3.1 重复点击可触发
影响行数:COUNT 后回填(见第二步)
回滚:留底 CSV + 反向 UPDATE(见第三步)

第二步,COUNT 预估,WHERE 与正式脚本逐字一致:

SELECT tenant_id, COUNT(*) AS rows_to_fix
FROM t_verify
WHERE status = 'PARTIAL_DONE'
GROUP BY tenant_id;
-- 结果:3 个租户合计 812 行。
-- 这串条件直接复制进订正脚本,不许手抄走样。

第三步,备份留底并备好反向脚本:

-- 导出即将被改的行:主键与旧值是回滚的全部依据
COPY (SELECT id, tenant_id, status, updated_at
      FROM t_verify
      WHERE status = 'PARTIAL_DONE')
TO '/tmp/fix-20260814.csv' WITH CSV HEADER;

-- 反向脚本同场备好,评审时与正向脚本一起过目
UPDATE t_verify SET status = 'PARTIAL_DONE'
WHERE id IN (:backup_ids);

第四步,事务内分批执行,每批先确认再动手:

BEGIN;
-- 保险:先 SELECT 确认本批目标行数,数不对立刻 ROLLBACK
SELECT COUNT(*) FROM t_verify
WHERE id BETWEEN :batch_start AND :batch_end
  AND status = 'PARTIAL_DONE';

UPDATE t_verify
SET status = 'DONE', updated_at = now()
WHERE id BETWEEN :batch_start AND :batch_end
  AND status = 'PARTIAL_DONE';
COMMIT;  -- 每批一提交;批间抽查 10 行页面表现;
         -- WHERE 里的状态条件天然可重跑,中断续跑不重不漏

第五步,对账收尾:总影响 812 行,与预估一致;全库 PARTIAL_DONE 归零;按租户分组核对分布与预估吻合;抽样 20 行在管理后台显示正常。工单关闭,CSV 归档。回滚预案全程没启用——这正是它的意义:可以不用,不能没有。

五、踩坑清单

  1. UPDATE 忘 WHERE,或 WHERE 写弱。 现象:影响行数远超预估。定位:对比 COUNT 预估与影响行数,一秒识别。修法:事务内先 SELECT 同条件确认,数不对 ROLLBACK;另加一道硬门槛——脚本模板内置检查,无 WHERE 的 UPDATE/DELETE 拒绝入库:
# 伪代码:订正脚本入库前检查
if 语句是 UPDATE 或 DELETE 且没有 WHERE:
    reject("裸 UPDATE/DELETE 禁止入库")
  1. 预估与实际对不上还硬跑。 现象:COUNT 812、预演命中 8000。定位:九成是预估时漏了租户条件,或两处 WHERE 口径不一致。修法:以预估为准停下,逐字比对两串条件再放行。
  2. 只备结构不备行。 现象:真要回滚时发现留的是建表语句,不是"改前那几行的旧值"。修法:留底必须含主键与被改字段旧值,CSV 归档进工单,别散落在个人电脑。
  3. 分批没有断点。 现象:中途失败后重跑,已改的行被再改一遍。定位:批次边界依赖外部游标、脚本不可重入。修法:依赖数据自身状态做天然幂等边界(如 WHERE status = 'PARTIAL_DONE'),改完即不再命中。
  4. 改完不对业务口径。 现象:行数正确,第二天业务方报表仍异常。定位:只验了行数、没验分布。修法:对账清单固定三项——总值归零、分组分布、抽样页面表现。

三句话总结

  1. 订正脚本也是代码:工单、版本库、评审、留底,四样齐了才许碰生产库。
  2. 可回滚是底线:先备份后动手,预估与正式脚本同条件,行数对不上就停。
  3. 守卫前置才闭环:代码修复、约束上库、数据订正三件一起收口,防再犯比改这一次更重要。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询