返回博客

CHAR(36) 不等于 UUID:列类型不代表运行时语义

线上某后台管理系统的审批流功能出现了一个诡异现象:新建的审批单在快照里 columns 字段始终是空数组 [],但手动查数据库明明有数据。更蹊跷的是,这个问题潜伏了数月,大量已有审批单都悄悄受影响,没有任何报错日志。排查源头,问题出在一行看起来完全合理的代码审查结论上。

现象

审批快照捕获逻辑执行后,存储的 columns 是空数组。追日志发现:

// captureSnapshot 内部
const reportCode = await resolveReportCode(reportId)
if (reportCode) {
  columns = await loadColumns(reportCode)
}
// reportCode 始终为 null,columns 从未被填充

resolveReportCode 查询:

SELECT code FROM reports WHERE id = ?

传入的参数是 title-report——一个字符串 code,不是 UUID。WHERE id = 'title-report' 当然匹配不到任何行,返回空结果,reportCodenull,整个 if 块被静默跳过。

根因

代码审查时(人工或 AI 均可能犯此错),看到表结构:

workflows.report_id        CHAR(36) NULL
workflow_node_fields.report_id  CHAR(36) NULL
approvals.related_report_id     CHAR(36) NULL

推理链:「CHAR(36) 正好是 UUID 的宽度(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx = 36 字符)→ 这些列存的是 reports.id(UUID)→ 调用方应该传 UUID。」

这个推理链每一步听起来都有道理,却在关键处踩了空:MySQL 的 CHAR(36) 是宽度约束,不是类型约束。任何不超过 36 字符的字符串都合法,比如 title-reportpaper-reportMEMBER_LEVEL_VIP

实际上,写入这些列的是业务代码 code,不是数据库主键 UUID。只需一条 30 秒就能跑完的 DB 探针就能发现真相:

SELECT DISTINCT report_id FROM workflow_node_fields WHERE is_deleted = 0;
-- 结果:education-report, paper-report, patent-report, title-report

完全是可读 code,不是任何 UUID 格式。

坑:用列宽推断标识符语义

CHAR(36) / VARCHAR(36) 只约束最大长度,不约束内容格式。UUID 恰好是 36 字符,但符合这个宽度的字符串多了去了。审查代码时若仅凭 SHOW COLUMNS 的类型信息断言「这列存 UUID」,就是在用列宽当类型——这是错的。

CHAR(36) BINARY 强制大小写敏感对比,CHAR(36) CHARACTER SET ascii 只限字符集,两者都不验证 UUID 格式。只有 PostgreSQL 的 uuid 类型才真正拒绝非 UUID 值。如果用的是 MySQL,不要信列宽。

正解

第一步:先跑 DB 探针,再推语义

SELECT DISTINCT col FROM table WHERE col IS NOT NULL LIMIT 10;

看返回值的字面形状:UUID 形如 074e8e9f-ba49-43aa-a3c6-057d54ba2db9,业务 code 形如 title-report。无需猜测,30 秒出结论。

第二步:覆盖所有写入方

确认运行时值类型后,grep 全部写入路径确认语义一致:

rg -n "INSERT INTO <table>.*<column>" --type ts
rg -n "UPDATE <table>.*SET <column>" --type ts

若写入方存在分歧(有的写 UUID、有的写 code),说明有更深的 schema 语义冲突,不是单纯的读端 bug。

第三步:用防御性 resolver 消灭静默空查询

当调用方可能传入任意一种标识符时(存量旧调用传 UUID、新调用传 code),实现两阶段查找:

async function resolveCanonicalCode(
  q: Queryer,
  input: string
): Promise<string | null> {
  if (!input?.trim()) return null
 
  // 优先用真实运行时语义(code)查
  const [byCode] = await q.query(
    `SELECT code FROM reports WHERE code = ? AND is_deleted = 0 LIMIT 1`,
    [input]
  )
  if (byCode.length > 0) return byCode[0].code
 
  // 兼容旧调用路径(传了 UUID),明确打 warn 标记为待清理
  const [byId] = await q.query(
    `SELECT code FROM reports WHERE id = ? AND is_deleted = 0 LIMIT 1`,
    [input]
  )
  if (byId.length > 0) {
    console.warn(
      `[resolver] 用 id fallback 匹配成功,请把调用方改为传 code · input=${input}`
    )
    return byId[0].code
  }
 
  throw new Error(`无法解析标识符 input=${input}`)
}

关键特性:canonical 路径先跑、fallback 只做兼容不做静默、找不到就 throw(禁止静默返回 null 然后被 if 块跳过)。

正解:DB 探针 + 防御性 resolver

任何涉及「这列存什么类型 ID」的推断,都必须先跑 SELECT DISTINCT,用实际字节证明,再写代码。Resolver 两阶段查找 + 明确 throw 消灭静默空结果。SHOW COLUMNS 的类型信息只告诉你宽度,不告诉你语义。

事故复盘

这次 bug 潜伏了数月、影响了十余条在途数据,核心原因是「DB 探针这个 30 秒动作没人做」——审查时凭列宽推断,写代码时对推断结果深信不疑,fallback 路径又恰好救了部分读取路径(从 workflows.report_id 直读绕过了 snapshot),遮盖了问题表面。

最终通过重建脚本将所有空 columns 的历史审批单补全,soak verifier 确认 exit 0 后才关案。代价:多轮 PR 来回、数月数据待修。若当初 30 秒跑一条 SELECT DISTINCT,整条链路都不会走偏。

一句话外卖

CHAR(36) 是字符宽度,UUID 只是恰好 36 字符的子集——在 MySQL 里,不运行 SELECT DISTINCT 就断言列的语义,是在用列宽当类型,迟早翻车。