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' 当然匹配不到任何行,返回空结果,reportCode 为 null,整个 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-report、paper-report、MEMBER_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 块跳过)。
任何涉及「这列存什么类型 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就断言列的语义,是在用列宽当类型,迟早翻车。