AI 生成代码翻车实录:数据库回填脚本上线后数据错乱,我的完整复盘

先交代结果

上一篇讲了我怎么建的四道防线,这篇展开那次触发我建防线的事故。

一个 AI 生成的数据库回填脚本,线上跑了一半崩了。报错在第 3,800 多行------对 null 值取属性抛了类型错误。崩的时候,前面更新的几千行已经写进去了,后面几千行没动。数据半新半旧。

然后同事重跑,又造成了二次事故。这个脚本看起来没有任何问题,问题恰恰出在"看起来没问题"上。

背景:这个脚本是干嘛的

项目有一张 orders 表,存了历史订单。每笔订单有个 order_extra 字段,是一个 JSON 字符串,里面存了折扣信息 { "discount": { "amount": "12.50", "type": "coupon" } } 这样的结构。

业务需求很简单:给 orders 表加一个 discount_amount DECIMAL(10,2) 字段,把历史数据里的折扣金额拆出来回填进去,方便报表直接查询,不用每次都解析 JSON。

这是一个典型的一次性脚本。写一次,跑完就扔。我当时觉得手写太浪费时间,直接让 AI 生成。

AI 吐出来的脚本,结构清晰,sql 语句也跟表结构对得上。我过了一遍,没发现什么问题,就放上去了。

翻车过程:跑了一半崩了

脚本在线上开始跑。到第 3,800 多行的时候,抛了个异常:

text 复制代码
TypeError: Cannot read properties of null (reading 'discount')
    at run (migrate_discount.js:25)

崩溃点其实不在 JSON.parse,而在它返回 null 之后访问 .discount 的那一下。

JSON.parse(null) 在 JS 里不抛错------它返回 nullMDN 文档 里写得很清楚,参数会被转成字符串再解析)。真正崩的是后面 if (extra.discount)null 取属性。

线上有几行历史数据,order_extra 字段是 null,不是 '{}'JSON.parse(null) 返回 null,然后访问 null.discount 抛了 TypeError。

先看 AI 当时生成的代码:

javascript 复制代码
// 这是 AI 生成的脚本,看起来没问题
const mysql = require('mysql2/promise');

async function run() {
  const conn = await mysql.createConnection(/* config */);
  
  // 字段不存在就加
  await conn.query(
    `ALTER TABLE orders ADD COLUMN discount_amount DECIMAL(10,2) DEFAULT NULL`
  );
  
  // 读取所有订单,逐条更新
  const [rows] = await conn.query(`SELECT id, order_extra FROM orders`);
  for (const row of rows) {
    const extra = JSON.parse(row.order_extra);   // ⚠️ JSON.parse(null) 返回 null,不抛错
    if (extra.discount) {                        // ⚠️ 对 null 取属性,这里崩了
      await conn.query(
        `UPDATE orders SET discount_amount = ${extra.discount.amount} WHERE id = ${row.id}`
      );
    }
  }
  
  await conn.end();
}

run();

这段代码光看结构,没有任何语法问题。AI 的写法也是典型的"全量 SELECT → 逐条 UPDATE"模式。但问题就藏在这些"看起来没问题"的假设里。

排查:三个问题是一根链

排查过程其实不复杂,但越查越觉得这个脚本的"错"不是偶然的。三个问题串在一起,一个扣一个。

问题①:AI 不知道你的数据有脏数据

AI 生成脚本时,它假设 order_extra 字段每行都有值。理由很朴素------"订单应该都有扩展信息"。

但线上数据不是这样的。这个表跑了几年,中间改过几次写入逻辑。早期版本在某个场景下会把 order_extra 写入 null。这些行不多,但存在,AI 不知道。

这个问题的根因不在代码写法,在数据假设。AI 没有"看过"你的数据库,它不知道哪些字段可能为 null、哪些字段有特殊格式、哪些历史数据是"非标准"的。它基于的"理想数据"和现实数据之间有差距。

问题②:没有事务边界,半途而废不可回滚

更致命的是事务问题。脚本没有用事务包裹,所以第 3,800 多行崩了之后,前面的 3,800 多行 UPDATE 已经生效了,后面的没执行。数据半新半旧。

而且脚本没有记录"处理到第几行",你没法精确知道哪些行更新了、哪些没更新。discount_amount 字段在 UPDATE 前默认是 NULL,所以"改了的行有值,没改的行是 NULL"------这个状态本身是可以区分的,但如果某些行在中间状态被业务访问,就会读到不一致的数据。

问题③:重跑没有幂等保护,造成二次事故

同事看到脚本崩了,改了一下代码------加了个 if (row.order_extra) 跳过 null 行------然后重新全量跑。

这次跑了,但造成了一个新问题。重跑时,脚本 SELECT 的是全量订单。而在第一次跑和第二次跑之间,线上业务有新的订单写入,新订单的 order_extra 格式和旧数据不一样------新格式里 discount.amount 存的是数值类型(比如 12.5),不再是字符串("12.50")。重跑脚本把新订单的 discount_amount 也重新计算了------用的还是旧逻辑,parseFloat(12.5) 又把数值转了一遍再写进 DECIMAL(10,2) 列,精度对不上。这就是二次事故:本来只想修复未更新的行,结果把已经写对的行的数据又覆盖成了错误的值。

事故时间线

整个过程串起来看,是这样一条链:

text 复制代码
AI 生成脚本(没事务、没容错、没幂等)
    │
    ▼
线上跑 → 第 3,800 多行 → 对 null 值取属性崩
    │
    ▼
没事务 → 前面 3,800 多行已更新,后面未动 → 数据半新半旧
    │
    ▼
同事改代码(跳过 null)→ 全量重跑
    │
    ▼
重跑覆盖了新订单 → 新格式精度不一致 → 二次事故
    │
    ▼
修复:对账 → 备份恢复 → 重写脚本(事务/容错/幂等)

修复:对账、恢复、重写

修复分了三步。

第一步,数据对账。把 orders 表按更新时间分段,找出"第一次跑前已存在的行"和"两次跑之间新写入的行",对比 discount_amount 的值,确认哪些行错了、错成什么样。

第二步,从备份恢复错乱的数据。这个不复杂,但耗时------因为要确认恢复范围。

第三步,重写脚本,加了三个东西:

javascript 复制代码
const mysql = require('mysql2/promise');

async function run() {
  const conn = await mysql.createConnection(/* config */);
  
  // ① 事务包裹,中途失败全部回滚
  await conn.beginTransaction();
  
  try {
    // 先检查字段是否存在,做幂等
    const [tables] = await conn.query(
      `SHOW COLUMNS FROM orders LIKE 'discount_amount'`
    );
    if (tables.length === 0) {
      await conn.query(
        `ALTER TABLE orders ADD COLUMN discount_amount DECIMAL(10,2) DEFAULT NULL`
      );
    }
    
    // 分批处理,记录进度
    const BATCH_SIZE = 1000;
    let offset = 0;
    let processed = 0;
    
    while (true) {
      // 加 ORDER BY id,保证分批顺序稳定,防止并发写入时漏行/重复
      const [rows] = await conn.query(
        `SELECT id, order_extra FROM orders ORDER BY id LIMIT ? OFFSET ?`,
        [BATCH_SIZE, offset]
      );
      if (rows.length === 0) break;
      
      for (const row of rows) {
        // ② 容错处理:脏数据跳过并记录
        if (!row.order_extra) {
          console.warn(`[WARN] order ${row.id} has null order_extra, skipped`);
          processed++;
          continue;
        }
        let extra;
        try {
          extra = JSON.parse(row.order_extra);
        } catch (e) {
          console.warn(`[WARN] order ${row.id} invalid JSON, skipped`);
          processed++;
          continue;
        }
        if (extra.discount) {
          // ③ 参数化查询,不用拼接
          await conn.query(
            `UPDATE orders SET discount_amount = ? WHERE id = ?`,
            [parseFloat(extra.discount.amount), row.id]
          );
        }
        processed++;
      }
      offset += BATCH_SIZE;
    }
    
    // 全部成功才提交
    await conn.commit();
    console.log(`[OK] processed ${processed} orders`);
    
  } catch (e) {
    // 任何失败都回滚
    await conn.rollback();
    console.error(`[FAIL] rolled back: ${e.message}`);
    throw e;
  } finally {
    await conn.end();
  }
}

run();

这次跑完,脏数据行被跳过并记录,剩下的安全处理完,全部成功才提交:

text 复制代码
$ node migrate_discount.js

[WARN] order 3827 has null order_extra, skipped
[WARN] order 4102 invalid JSON, skipped
[WARN] order 4103 invalid JSON, skipped
[OK] processed 8012 orders

改动要点:

  • 事务包裹beginTransaction() + commit() / rollback(),全成功或全失败
  • 容错处理:脏数据行跳过并记录,不让脚本崩
  • 幂等保护:先检查字段是否存在再做 ALTER TABLE,防止重跑崩
  • 参数化查询 :用 ? 占位符替代 ${} 拼接
  • 分批处理:LIMIT + OFFSET 分批,不把全量数据 load 到内存
  • 进度记录:每批结束后记录处理进度,支持断点续跑

沉淀下来的东西:数据库脚本上线前检查清单

这次事故之后,我整理了一个清单,每次写数据库脚本(无论 AI 还是手写)都过一遍:

检查项 为什么 怎么查 本次事故对应
事务包裹 防止跑一半崩了数据不一致 看是否有 BEGIN/COMMIT/ROLLBACK 跑一半崩了,数据半新半旧
幂等性 防止重跑崩或数据重复 测试连续跑两次是否安全 重跑覆盖新订单,精度出错
数据分布假设 AI 基于理想数据生成,不知道脏数据 SELECT 检查 NULL/空值/异常格式 order_extra 有 null,AI 不知道
参数化查询 防止 SQL 注入和转义问题 看是否有 ${} 拼接 原脚本用 ${} 拼接
是否有 WHERE 限制 防止全表 UPDATE/DELETE 确认 UPDATE 有 WHERE 条件 逐条 UPDATE 有 WHERE,但全量 SELECT
先在测试库跑 用真实数据子集发现问题 在 staging 或测试环境跑一遍 直接上线上跑,没先验证
记录进度 支持断点续跑和事后对账 每批处理完打印进度 崩了不知道处理到第几行

这个清单后来成了主推文里四道防线中安全扫描那个环节的补充------脚本级别的代码,走这套检查就够了,不需要上全套 CI。

边界说明:这不是 AI 的错,是没有 review 机制

聊到这儿,得说句公道话。

这个事故不是 AI 写代码的错,是"没有 review 机制"的错。手写脚本也可能犯同样的错------没写事务、没考虑脏数据、没做幂等。

但 AI 把"犯错的成本"放大了:它生成代码太快了,快到让人觉得很"可靠",放松了审查的警惕。如果这个脚本是手写的,我可能会多看两遍,会多想"还有没有边界情况"。

所以我的判断不是"以后别用 AI 写数据库脚本",而是"用了 AI 写,就要多一道检查"。一次性脚本不用上全套 CI,但至少过一遍上面那个清单。

总结

这个事故的三根链条,指向同一个根因:AI 生成的代码符合"脚本的统计形态",但不符合"你的数据现实"。

它不知道你的表里有脏数据、不知道线上有并发写入、不知道重跑一次会把数据搞乱。它只是生成了一个"看起来对的脚本"。

那次事故是我建质量管线的起点。主推文里那四道防线,就是这么来的。

相关推荐
Lambert28125 分钟前
Agent 的 App Store 来了:写一个 SKILL.md,Java Agent 立刻多一项新技能
aigc·ai编程
Web3_Basketball29 分钟前
从0到1落地模型供应链审计RAG依赖来源校验:踩坑全记录
ai编程
用户75650617861131 分钟前
开发 Nemu 的第二天:从会说话,到受控地看懂钉钉
ai编程
小羊4332 分钟前
Agent的任务拆解艺术:从目标到可执行子任务
ai编程
桃西西呀2 小时前
模型都能自己写代码了,你的 Agent 为什么还接不进一个日历?——一篇讲透 MCP 这个 AI 世界「USB-C」
人工智能·ai编程·mcp
刘广睿2 小时前
AI 生图从玄学到工程:Stable Diffusion、ComfyUI 与 Midjourney 的原理与 Prompt 方法论
aigc·stablediffusion·comfyui·ai生图
Csvn2 小时前
第 7 章 MCP 标准化工具接入
人工智能·aigc·agent
zynio2 小时前
手写 RAG 知识库问答智能体:混合检索 + 查询改写 + 抗幻觉,黄金集实测全过
ai编程
心易行者3 小时前
html在线运行搭AI编程验证流水线:5步走完从生成到到上线全流程
人工智能·python·ai编程