一次点击触发 800 条 SQL:衍生状态持久化的写放大陷阱
某天在排查一个内部后台管理系统的性能问题时,我发现一个极其普通的"保存字段映射"操作,在 Chrome DevTools Network 里显示 Waiting for server response 长达 15 秒。更诡异的是:这段时间里,浏览器里所有其他标签页的请求全部变成 Queued 1.5min,整个 app 假死。
服务器 CPU 是闲的,局域网带宽也闲着,数据库连接池没满。那 15 秒去哪了?
现象
- PATCH 请求体只有几十字节(更新一条记录里的字段映射),但 TTFB 稳定在 10-20s
- 同样的接口,数据库里只有 10 条关联记录时 2s 完成,50 条时 15s,500 条时 >150s——请求体没变,关联数据越多越慢
- 开发环境 Next.js dev server(单 worker),一个慢请求会把所有后续请求排在队列里——这是头行阻塞(head-of-line blocking)的典型症状
根因
调用链追了 4 层才看清真相:
updateTemplateMappings(id, { fieldMappings })
└─ patchEntity(..., { syncRelated: true })
└─ readAllSnapshot(conn) // 拉全部模板 + 全部关联记录
└─ updater(target) // 只改一条记录
└─ recomputeDerivedAcrossAll(db) // ← 对每条关联记录重算 readiness
└─ syncAllRelated(conn, {
siblings: db.allSiblings, // ← 传入"全部",不是"脏了的"
cousins: db.allCousins,
})
└─ for (const row of allRows)
await upsertWithChildTables(row) // 每条 = DELETE+INSERT × 16 子表
核心反模式:contractReadiness、appointmentReadiness、nodeStatuses 这些字段是纯函数的计算结果(只依赖其他已持久化字段),却被存进了数据库,并且每次有任何依赖项变化就触发全表重写。
50 条关联记录 × 16 张子表操作 = ~800 条串行 SQL,全部在同一个连接上顺序执行。
readiness / blockerList / 聚合计数这类字段,如果它们是其他列的纯函数,就不该出现在写路径里。一旦把它们存进数据库,每次任意依赖项变动都会触发"全量重算 + 全量回写",写放大倍数 = 关联行数 × 子表数。
更危险的是:这种模式在小数据量时根本不会暴露——10 条记录 2s "还能接受",等到生产数据涨到 500 条你才知道出事了。
正解
第一层:脏数据指纹过滤(分钟级见效)
在快照写入边界加指纹对比,只 upsert 真正变化的行:
// 关键:fingerprint 必须在 updater 运行前采集
const fpBefore = snapshotFingerprints(db.siblings, fingerprintRow)
const next = await updater(db.target, db)
recomputeDerivedAcrossAll(db)
await syncAllRelated(conn, {
target: next,
siblings: filterDirty(db.siblings, fpBefore, fingerprintRow),
// 真正变化的才传进去,其余直接跳过
})
function fingerprintRow(row) {
// 只覆盖 recompute 步骤实际写入的字段,不要 stringify 整条记录
return JSON.stringify({
readiness: row.readiness ?? null,
blockers: row.blockers ?? null,
})
}易错点:fingerprint 必须在 updater 调用之前采集。如果在之后采,before === after,所有行都会被判为"未变化",真实改动被静默丢弃。
同类操作里还有一个变体:即使只同步单条记录,upsertWithChildTables 也会无差别 DELETE + INSERT 所有子表,哪怕这次调用根本没触碰那几张表。解法是按调用场景拆出"窄写"变体:
// 全量写——初始导入、批量分析时用
export async function upsertEntity(conn, entity) { /* 全部子表 */ }
// 窄写——只改了字段映射时用,跳过 terms/analysis/preview 等无关子表
export async function upsertEntityMappingsOnly(conn, entity) {
await conn.query(mainUpsertSql(), mainParams(entity))
await replaceFieldMappings(conn, entity) // 只动这一张
}再加上把多条子行 INSERT 合并为一条 INSERT … VALUES (…),(…) ON DUPLICATE KEY UPDATE,实测从 247ms 降到 33ms,提升约 7.5 倍。
第二层:根因修复(架构级)
真正的出路是把衍生状态从写路径移到读路径:
- 在
getEntity()查询时实时计算readiness/blockerList,而不是存一列随时维护 - 如果实时计算确实耗时(>50ms),用
(inputHash → derivedValue)的失效缓存,不要用全量 eager 回写 - 数据库支持的场景可以用视图、物化视图、生成列
一个字段如果能在 <1ms 内由其他持久化字段算出来,就不该被持久化。把它存进数据库只是把"计算"换成了"维护",维护代价随数据规模线性放大,计算代价不随数据规模变化。
持久化衍生状态的充分条件必须同时满足三点:① 重算代价 >50ms;② 读远比写频繁;③ 存在与实际变化正比的失效策略——不是"依赖变了就全量重写所有消费者"。
在 conn.query 外层包一个计数器,记录单次请求触发的 SQL 数量。如果数量随无关表的行数增长 → 是 Layer 1 的跨行扇出问题;如果数量随单个实体的子表数增长、和关联行数无关 → 是单实体的全子表盲写问题。两种情况可以同时存在。
一句话外卖
纯函数的计算结果不要存进数据库写路径——每一个持久化的衍生字段都是一颗定时的写放大炸弹,数据量涨上来之前你看不见它。