返回博客

一次点击触发 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 子表

核心反模式:contractReadinessappointmentReadinessnodeStatuses 这些字段是纯函数的计算结果(只依赖其他已持久化字段),却被存进了数据库,并且每次有任何依赖项变化就触发全表重写。

50 条关联记录 × 16 张子表操作 = ~800 条串行 SQL,全部在同一个连接上顺序执行。

坑:衍生状态落库 + syncAll(全量) = 隐性写放大

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;② 读远比写频繁;③ 存在与实际变化正比的失效策略——不是"依赖变了就全量重写所有消费者"。

排查方法:用 SQL 计数器快速定位

conn.query 外层包一个计数器,记录单次请求触发的 SQL 数量。如果数量随无关表的行数增长 → 是 Layer 1 的跨行扇出问题;如果数量随单个实体的子表数增长、和关联行数无关 → 是单实体的全子表盲写问题。两种情况可以同时存在。

一句话外卖

纯函数的计算结果不要存进数据库写路径——每一个持久化的衍生字段都是一颗定时的写放大炸弹,数据量涨上来之前你看不见它。