写后端的多半都碰到过这一幕:库刚建好,十几张表干干净净,想跑个 demo、调个分页、压一压列表接口,结果连一条数据都没有。手动敲 INSERT 累死人,随手乱灌又一上来就撞外键。用 Faker 给单表造数据倒是不难,可表跟表之间一旦有外键关系,谁先谁后、引用对不对得上,立马就乱了。
我自己那个 ER 图工具里就加了个「一键给整库造数据」的功能,把上面这些麻烦事都包圆了:按外键先后顺序生成、引用关系保证对得上、结果可复现,还能照顾到字段上的各种约束。这篇就把里头几个关键的设计聊一聊。代码是 TypeScript 写的,但思路跟语言没啥关系,换成别的也照样用。
先说清楚要解决啥
输入是一份 schema(几张表、字段信息、外键关系),输出是能直接灌进库的假数据。说起来简单,真要做对,得满足下面这几条:
- 外键得对得上 :
orders.user_id必须是某个真实存在的users.id,不能瞎填一个。 - 生成有先后 :得先有
users才能有orders,不然给子表造数据的时候,父表压根还没值可以引。 - 结果能复现:同一份 schema 配同一个种子,每次跑出来必须一模一样。这点对快照测试、团队共享 fixture 太关键了。
- 守住字段约束 :主键唯一、自增列从
1排到n、VARCHAR(20)不能超长、ENUM只能在声明的几个值里挑、UNSIGNED不能是负数、可空列偶尔来个NULL。 - 值得像样 :
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 里那俩 1、2,正是上面 users 表里真实存在的自增主键。
做完之后的几点感想
- 拓扑排序 + 边生成边存 是处理「带依赖的批量生成」的万能套路,不光造数据用得上。
- 可复现这事值得专门花心思:一个能指定种子的 PRNG 成本极低,却能让测试、协作、排查问题都轻松不少。把随机流按职责拆开,还能保证「可选能力」的改动不污染核心输出。
- 能兜底就别报错:循环依赖、约束撞车这类边角情况,能扛就扛过去,别为一个小问题把整个生成搞崩。
- 整套逻辑都是纯函数、不碰 DOM,所以浏览器和 Node 服务端都能直接拿来用。这也是当初咬牙把它跟 UI 拆干净的回报。