一键给整个库造测试数据:外键、约束一个都不能少

写后端的多半都碰到过这一幕:库刚建好,十几张表干干净净,想跑个 demo、调个分页、压一压列表接口,结果连一条数据都没有。手动敲 INSERT 累死人,随手乱灌又一上来就撞外键。用 Faker 给单表造数据倒是不难,可表跟表之间一旦有外键关系,谁先谁后、引用对不对得上,立马就乱了。

我自己那个 ER 图工具里就加了个「一键给整库造数据」的功能,把上面这些麻烦事都包圆了:按外键先后顺序生成、引用关系保证对得上、结果可复现,还能照顾到字段上的各种约束。这篇就把里头几个关键的设计聊一聊。代码是 TypeScript 写的,但思路跟语言没啥关系,换成别的也照样用。

先说清楚要解决啥

输入是一份 schema(几张表、字段信息、外键关系),输出是能直接灌进库的假数据。说起来简单,真要做对,得满足下面这几条:

  1. 外键得对得上 :orders.user_id 必须是某个真实存在的 users.id,不能瞎填一个。
  2. 生成有先后 :得先有 users 才能有 orders,不然给子表造数据的时候,父表压根还没值可以引。
  3. 结果能复现:同一份 schema 配同一个种子,每次跑出来必须一模一样。这点对快照测试、团队共享 fixture 太关键了。
  4. 守住字段约束 :主键唯一、自增列从 1 排到 nVARCHAR(20) 不能超长、ENUM 只能在声明的几个值里挑、UNSIGNED 不能是负数、可空列偶尔来个 NULL
  5. 值得像样 :email 字段就该长得像个邮箱,created_at 就该是个正常的时间,而不是清一色 "abc123"

下面一条条拆。

一、谁先谁后:外键依赖的拓扑排序

子表引用父表,那父表就得先生成。这事说白了就是个有向图拓扑排序 :每张表当一个节点,外键 child → parent 就是一条「先父后子」的依赖边。

排完序,挨个生成,顺手把每张表已经造好的行塞进一个 Map,后面子表要取外键值,直接从里面捞:

ts 复制代码
// 只让真实(非虚拟)外键参与排序
const realRelations = data.relations.filter((r) => !r.virtual)
const { tables: ordered } = sortTablesByForeignKeys(data.tables, realRelations)

const generated = new Map<string, Array<Record<string, SeedValue>>>()

for (const table of ordered) {
  const count = resolveRowCount(table, opts)
  const rows: Array<Record<string, SeedValue>> = []
  // ...生成 count 行...
  generated.set(table.id, rows) // 存起来给子表用
}

这里有个绕不开的坎:循环依赖。两张表互相引(A 引 B、B 又引 A),拓扑排序根本排不出严格的先后。我的处理是「能排就排,排不动的先生成再说」------后面赋外键时要是发现父表还没数据,就退回去当普通值生成,不硬要求引用对得上。工程上嘛,能兜住总比直接报错挂掉强。

二、引用对得上:外键值直接从父表「抄」

序排好了、父表先生成了,那子表的外键列填啥?其实特简单:从父表已经造好的那批行里随便挑一行,把对应的列照抄过来。这么填进去的外键值,铁定是父表里真实有的。

先把外键关系预处理成一组组「分组」,每组就是「子表的一组列引用父表的一组列」(顺带支持复合外键):

ts 复制代码
interface FkGroup {
  localColumns: string[]   // 子表本地列,比如 ['user_id']
  parentTableId: string    // 父表 id
  parentColumns: string[]  // 父表被引用的列,比如 ['id']
}

赋值的时候,从父表的行里 pick 一行,再按列一一对应抄过来:

ts 复制代码
function assignForeignKeys(row, fkGroups, generated, rng): Set<string> {
  const assigned = new Set<string>()
  for (const group of fkGroups) {
    const parentRows = generated.get(group.parentTableId)
    if (!parentRows || parentRows.length === 0) continue // 父表没数据(比如循环依赖):跳过
    const parent = rng.pick(parentRows) // 锁定同一行
    group.localColumns.forEach((local, idx) => {
      const parentCol = group.parentColumns[idx] ?? group.parentColumns.at(-1)
      row[local] = parent[parentCol] ?? null
      assigned.add(local)
    })
  }
  return assigned
}

这儿有个容易翻车的点:复合外键必须取自同一行 。假如 (country_code, region_code) 一起引用父表的 (country, region),那这两个值就得来自父表的同一条记录;要是各挑各的,拼出来的组合在父表里可能压根不存在。所以代码是「先挑一行,再抄这一行的多列」,绝不是「每列各自随机摸一个」。

赋过外键的列会记进 assigned,后面生成普通字段时直接跳过,免得又被覆盖掉。

三、可复现:一个 32 位的确定性随机数

「同一个种子跑出同样的结果」是这个功能的命根子。Math.random() 不行,它没法指定种子。我用的是 mulberry32,一个特别轻的确定性 PRNG,几行就搞定:

ts 复制代码
function createRng(seed: number) {
  let a = seed >>> 0
  const next = () => {
    a |= 0
    a = (a + 0x6d2b79f5) | 0
    let t = Math.imul(a ^ (a >>> 15), 1 | a)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
  return {
    next,
    int: (min, max) => Math.floor(next() * (max - min + 1)) + min,
    pick: <T>(arr: readonly T[]): T => arr[Math.floor(next() * arr.length)]
  }
}

种子一样,next() 吐出来的序列就完全一样。于是「同 schema + 同 seed = 同数据」就成立了,快照测试、可复现的 fixture 全靠它。

这里还藏着一个我觉得挺得意的小设计:两条互不干扰的随机流

四、文本生成可插拔 + 双随机流

字段值大体分两拨:

  • 结构化的值:整数、小数、日期、布尔、UUID、枚举、外键------这些核心逻辑自己造就够了。
  • 文本类的值 :姓名、email、城市、地址、一段 lorem------这些交给专门的库(比如 falso)造出来更真。

问题是:换不换真实文本库,不该影响数值的可复现性。所以我干脆把随机源拆成两条:核心数值走一条 PRNG,文本走另一条(种子做个偏移派生出来):

ts 复制代码
const rt = {
  core: createRng(seedValue),
  // 文本流用偏移种子,跟核心数值流脱钩
  text: createRng((seedValue ^ 0x9e3779b9) >>> 0),
  provider,
  opts
}

这么一来,不管你用的是零依赖的默认文本生成器,还是塞了 falso 的「真实数据」版,整数、日期、外键这些值在同一个种子下都纹丝不动,变的只有字符串那几列。文本生成器本身抽成一个接口:

ts 复制代码
export interface SeedTextProvider {
  seed?(seed: number): void
  email(ctx: SeedTextContext): string
  fullName(ctx: SeedTextContext): string
  city(ctx: SeedTextContext): string
  sentence(count: number, ctx: SeedTextContext): string
  // ...
}

默认实现零依赖(内置一张小词表就完事),falso 版走动态 import 单独打成一个 chunk,大概 17KB(gzip),不进主包。要更真实再按需加载------这算是个挺典型的「能力可选、谁用谁付包体」的取舍。

五、约束感知:让数据「合法」

光随机还不够,得保证灌进库不报错。核心都在 generateScalar 里:

ts 复制代码
function generateScalar(col, rowIndex, rt, uniqueTracker): SeedValue {
  const mustBeUnique = Boolean(col.isPrimaryKey || col.isUnique)

  // 整型主键:老老实实从 1 排到 n,跟数据库自增对齐,外键引用也好对
  if (col.isPrimaryKey && isIntegerType(col.type)) {
    return rowIndex + 1
  }

  // 可空列按概率给 NULL(主键/唯一列除外,别把约束搞坏了)
  if (col.isNullable && !mustBeUnique && rt.core.next() < rt.opts.nullProbability) {
    return null
  }

  const produce = () => generateByType(col, rowIndex, rt)
  if (!mustBeUnique) return produce()

  // 唯一性:先重试几次,实在撞不开就加序号兜底
  const seen = uniqueTracker.get(col.name) ?? new Set<string>()
  uniqueTracker.set(col.name, seen)
  let value = produce()
  let attempts = 0
  while (seen.has(String(value)) && attempts < 20) {
    value = produce()
    attempts++
  }
  if (seen.has(String(value))) value = appendSuffix(value, rowIndex + 1, col)
  seen.add(String(value))
  return value
}

几个值得拎出来说的点:

  • 自增主键 直接给 1..n,既跟数据库新表的自增结果一致,外键引用起来也稳稳的。
  • 唯一约束 走「重试 + 加后缀兜底」:先随机重试 20 次,实在撞不开就补个序号,免得因为重复值把 INSERT 整挂。
  • 可空列nullProbability 概率给 NULL,默认是 0(尽量塞满),但主键和唯一列永远不给 NULL

类型上也分得细一点,比如整型会按子类型卡住范围,还认 UNSIGNED:

ts 复制代码
function generateInteger(col, rng): number {
  const base = baseType(col.type)
  let max = 100000
  if (base === 'TINYINT') max = col.unsigned ? 255 : 127
  else if (base === 'SMALLINT') max = col.unsigned ? 65535 : 32767
  else if (base === 'MEDIUMINT') max = col.unsigned ? 16777215 : 8388607
  // ...
}

还有个 MySQL 的老坑:TINYINT(1) 通常拿来当布尔用。所以类型判断里得单独把它认成布尔,而不是当成普通整数:

ts 复制代码
function isBooleanType(type: string): boolean {
  const base = baseType(type)
  if (base === 'BOOLEAN' || base === 'BOOL') return true
  return /^tinyint\s*\(\s*1\s*\)/i.test(type.trim()) // MySQL 的习惯
}

六、连蒙带猜:让值「看着像真的」

generateString 会瞅一眼列名猜它想要啥,猜中了就走对应的文本生成器:

ts 复制代码
if (/email/.test(name)) {
  value = p.email(ctx)
} else if (/(first_?name)/.test(name)) {
  value = p.firstName(ctx)
} else if (/(phone|mobile|tel)/.test(name)) {
  value = `+1${rng.int(2000000000, 9999999999)}`
} else if (/(status|state)/.test(name)) {
  value = rng.pick(STATUSES)
} else if (/(token|hash|secret|key)/.test(name)) {
  value = hexString(rng, 32)
}
// ...最后按 length 截一刀,保证不超长
return length ? value.slice(0, length) : value

成本很低,效果却立竿见影:users 表里 email 列真就是个邮箱、created_at 真就是个时间戳、status 真就落在枚举值里。造出来的数据扫一眼就觉得「有那味儿」,拿去截图做演示特别加分。

七、出口:SQL / CSV / JSON 三件套

生成逻辑吐出来的是一份「中间结果」(每张表一组 Record<string, SeedValue>),跟渲染彻底解耦。这样三种格式各写一个 renderer 就行。SQL 最常用,每张表合成一条多行 INSERT:

ts 复制代码
function renderSql(seeds, opts): string {
  const blocks: string[] = []
  for (const { table, rows } of seeds) {
    if (rows.length === 0) continue
    // SQL 输出默认把自增列省掉,交给数据库自己分配
    const cols = table.columns.filter(
      (c) => !(opts.omitAutoIncrement && c.isAutoIncrement)
    )
    const colNames = cols.map((c) => quoteIdentifier(c.name, opts.dialect)).join(', ')
    const valuesLines = rows.map(
      (row) => `  (${cols.map((c) => formatSqlValue(row[c.name], opts.dialect)).join(', ')})`
    )
    blocks.push(
      `INSERT INTO ${quoteIdentifier(table.name, opts.dialect)} (${colNames}) VALUES\n` +
        valuesLines.join(',\n') + ';'
    )
  }
  return blocks.length > 0 ? blocks.join('\n\n') + '\n' : '-- No data generated.\n'
}

两个细节:SQL 输出默认把自增列省掉 (交给数据库自增),但内部照样按 1..n 给它造好值供外键引用,两边对得上;布尔字面量分方言,PostgreSQL 用 TRUE/FALSE,MySQL 用 1/0。CSV 则按 RFC 4180 转义(碰到逗号、引号、换行就加引号,内部的引号翻倍)。

最后跑出来的 SQL 差不多长这样,表的先后顺序就是能安全插入的顺序:

sql 复制代码
INSERT INTO `users` (`name`, `email`, `created_at`) VALUES
  ('Alice Smith', 'alice.smith1@example.com', '2023-06-12 08:31:05'),
  ('Bob Johnson', 'bob.johnson2@example.com', '2022-11-30 14:02:47');

INSERT INTO `orders` (`user_id`, `amount`, `status`) VALUES
  (1, 128.50, 'active'),
  (2, 39.90, 'pending');

orders.user_id 里那俩 12,正是上面 users 表里真实存在的自增主键。

做完之后的几点感想

  • 拓扑排序 + 边生成边存 是处理「带依赖的批量生成」的万能套路,不光造数据用得上。
  • 可复现这事值得专门花心思:一个能指定种子的 PRNG 成本极低,却能让测试、协作、排查问题都轻松不少。把随机流按职责拆开,还能保证「可选能力」的改动不污染核心输出。
  • 能兜底就别报错:循环依赖、约束撞车这类边角情况,能扛就扛过去,别为一个小问题把整个生成搞崩。
  • 整套逻辑都是纯函数、不碰 DOM,所以浏览器和 Node 服务端都能直接拿来用。这也是当初咬牙把它跟 UI 拆干净的回报。

erdiagram 在线使用

相关推荐
摇滚侠1 小时前
SpringMVC 入门到实战 拦截器 78-82
java·后端·spring·maven·intellij-idea
椰椰椰耶1 小时前
[SpringCloud][13]OpenFeign快速上手
后端·spring·spring cloud
雪宫街道1 小时前
SpringBoot 静态资源映射规则与定制
java·spring boot·后端·spring
西凉的悲伤1 小时前
Spring Boot 与 Maven 依赖管理详解
spring boot·后端·maven·依赖管理
宸津-代码粉碎机1 小时前
Spring AI企业级实战|智能记忆摘要+自动遗忘机制落地,彻底解决上下文爆炸与Token冗余
java·大数据·人工智能·后端·python·spring
南极企鹅1 小时前
springboot项目不退出的原因
java·spring boot·后端
成为你的宁宁1 小时前
【K8S使用Helm部署MySQL一主多从并集成Prometheus监控】
mysql·kubernetes·prometheus
钝挫力PROGRAMER2 小时前
Kylin V10 安装 MySQL 8.0 后无法通过 127.0.0.1 连接
mysql·kylin
仍然.2 小时前
SpringBoot快速上手
java·spring boot·后端