ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯SQL生成百万级测试数据:MySQL/Oracle/PostgreSQL通用模板

纯SQL生成百万级测试数据:MySQL/Oracle/PostgreSQL通用模板

给开发库塞百万行测试数据,这件事我几乎每个迭代都要干一次。早年间我也跟大多数人一样,第一反应是写存储过程:一个 WHILE 循环从 1 加到 100 万,MySQL 上要先用 DELIMITER 包起来,Oracle 上换 PL/SQL 匿名块,到了 PostgreSQL 又得改成 DO 语句。折腾过几轮之后你会发现,存储过程这条路在 MySQL/Oracle/PostgreSQL 三种库里一点都不通用,而且测试库上能建 procedure,换个环境就未必有权限。其实纯 SQL 也能把同样的事情干完——一条 INSERT SELECT 就能从 0 干到百万行,不依赖存储过程、不依赖外部脚本。这篇就把我在三种数据库上验证过的高效 SQL 模板、参数配置和踩坑记录汇总出来,适合要做性能冒烟、灌数测试、备份恢复演练的同行直接抄作业。

1. 为什么造测试数据要绕开存储过程

1.1 权限、兼容性、维护成本这三道坎

先说权限。开发环境里拿到的账号,经常只有 SELECT、INSERT、UPDATE、DELETE 这几类权限,建存储过程需要 CREATE ROUTINE 之类的权限,很多团队出于安全考虑并不会放开。尤其自动化流水线里跑数用的连接账号,权限更是卡得死死的。你手里有成熟的存储过程模板,换个环境跑不了,等于白搭。

再看兼容性。MySQL 的存储过程要用 DELIMITER 改变分隔符,Oracle 要用 PL/SQL 的 BEGIN...END + COMMIT,PostgreSQL 又得写 DO $$ ... $$ LANGUAGE plpgsql。三种语法没有一个是通用的,改一个字段就得同步改三份代码,调试的时候还经常被各种隐式转换、异常处理折磨。纯粹为了造测试数据,这个成本完全没必要。

最后是维护成本。测试数据不是造一次就完事,业务迭代、字段变更、数据清理之后都需要重新灌数。存储过程是持久化对象,每次改动都要先 DROP 再 CREATE,出问题还得去排查哪个环境还残留旧版本。换成纯 SQL 就舒服多了——在 Navicat、PL/SQL Developer、DBeaver 里直接选中执行,用完即弃,数据不行改一改再跑一遍,没有任何历史包袱。

1.2 纯 SQL 造数的适用边界

纯 SQL 方案最适合的场景是:单表百万至千万行级别、生成规则比较简单(自增编号、固定前缀、随机数值、日期区间)、需要反复重建、不需要逐行依赖前一行状态的数据。它的核心思路是"先生成一个足够长的数字/日期序列,再把序列映射成目标字段",本质上是集合操作,所以效率远高于逐行循环。

但也有明显的边界。如果你要造的是亿级数据,或者生成逻辑依赖前一行计算结果、需要复杂分支判断,纯 SQL 就不够用了,那种场景更该考虑专业数据生成工具、导出导入脚本或者外部 ETL 流程。另外,纯 SQL 生成的数据分布相对"机械",如果要做很贴近真实业务分布的压测数据,还是得配合自定义脚本做抽样和洗数。下面所有方案都默认在"规则简单、单表百万行"这个范围内讨论。

2. MySQL:递归 CTE 一把梭,旧版本用笛卡尔积放大

2.1 MySQL 8.0 的 WITH RECURSIVE 写法

MySQL 8.0 引入递归 CTE 之后,造数逻辑一下子清晰了很多。思路就是把"造数"变成"生成一个从 1 到 N 的序列",然后再在这个序列上做字段映射。下面是最基础的序列生成方式:

WITH RECURSIVE seq(n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1000000 ) SELECT n FROM seq;

这里有个非常关键的参数:cte_max_recursion_depth。MySQL 8.0 默认值只有 1000,意思是一条递归 CTE 最多迭代 1000 次,超过就直接报错。所以跑百万序列之前,必须先设置会话级别的递归深度:

SET SESSION cte_max_recursion_depth = 1000000;

设置完之后,就可以把序列塞进 INSERT SELECT 里了。拿一张常见的用户表举例,包含自增 ID、用户名、手机号、邮箱、注册时间:

SET SESSION cte_max_recursion_depth = 1000000; INSERT INTO app_user (id, user_name, phone, email, create_time) WITH RECURSIVE seq(n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1000000 ) SELECT n, CONCAT('user_', n), LPAD(FLOOR(RAND() * 10000000000), 11, '0'), CONCAT('user_', n, '@test.com'), NOW() - INTERVAL FLOOR(RAND() * 365) DAY FROM seq;

我实测下来,这种写法在普通 SSD 上生成 100 万行基本在几十秒量级。需要注意:递归 CTE 生成的中间结果在 MySQL 里可能落到临时表,如果你的tmpdir空间不足,或者临时表超过tmp_table_size、max_heap_table_size的限制,MySQL 会把内存临时表转成磁盘临时表,速度会明显下降。真正常年造数的团队,我更建议直接在库里建一张永久性的numbers数字辅助表,从 1 到 1000 万一次性生成好,后面所有造数 SQL 都 JOIN 这张表,稳定性和速度都比每次递归 CTE 好得多。

2.2 5.7 及更早版本的 UNION ALL 放大法

MySQL 5.7 不支持 CTE,但同样能生成百万序列,手法是"0 到 9 十个数字的派生表做笛卡尔积"。原理很简单:六张各 10 行的数字表做 CROSS JOIN,组合数就是 10 的 6 次方,刚好 100 万。

SELECT a.n + b.n * 10 + c.n * 100 + d.n * 1000 + e.n * 10000 + f.n * 100000 + 1 AS n FROM (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a, (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) b, (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) c, (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d, (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) e, (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) f;

表 a 是个位、b 是十位、c 是百位,以此类推到十万位,+1是为了让编号从 1 开始而不是 0。中间结果虽然组合量大,但 MySQL 优化器对这种无依赖的笛卡尔积处理得挺快,输出 100 万行没什么压力。唯一要注意的是别把中间结果先存成用户临时表再二次处理,直接一条 SELECT 一步到位最省事。

如果你连这段 UNION ALL 都嫌长,还有个取巧的办法:利用系统自带的元数据表做放大,比如information_schema.columns或mysql.help_topic,搭配用户变量自增:

SELECT @rn := @rn + 1 AS n FROM information_schema.columns a, information_schema.columns b, (SELECT @rn := 0) r LIMIT 1000000;

不过这个方案依赖元数据表里的行数,跨版本行为不稳定,我一般只拿来应急,规规矩矩的数字派生表才是首选。

2.3 把数字序列变成"看起来像业务数据"的字段

序列只是骨架,造数的关键是让字段看起来像真的。MySQL 里常用的字段映射手法有几种:固定前缀可以用CONCAT('user_', n);定长手机号用LPAD补零;随机日期用NOW() - INTERVAL FLOOR(RAND() * N) DAY;随机状态字段用IF(RAND() > 0.7, 1, 0)。

完整模板可以这样写:

INSERT INTO app_user (id, user_name, phone, email, reg_date, is_active) WITH RECURSIVE seq(n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1000000 ) SELECT n, CONCAT('user_', n), LPAD(FLOOR(RAND() * 10000000000), 11, '0'), CONCAT('user_', n, '@test.com'), DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 1095) DAY), IF(RAND() > 0.7, 1, 0) FROM seq;

这里有两个容易踩的点:第一,NOW()在整个语句里是常量,想生成"最近三年内随机时间"必须靠INTERVAL搭配RAND()做偏移,不能直接对NOW()做随机;第二,RAND()在 INSERT SELECT 里通常是逐行求值的,但不同版本的优化器行为有细微差别,建议先跑一个 1 万行的小批量,确认随机分布符合预期再放全量。另外,如果目标表有唯一索引或主键,生成前要自己确保编号不重复,比如上面的n就必须保证唯一。

3. Oracle:CONNECT BY LEVEL 的百万行正确打开方式

3.1 直接巡百万行会碰到的内存瓶颈

Oracle 里最直觉的序列来源是CONNECT BY LEVEL,写法极其短:

INSERT INTO app_user (id, user_name, phone, email, create_time) SELECT LEVEL, 'user_' || LEVEL, LPAD(FLOOR(DBMS_RANDOM.VALUE() * 10000000000), 11, '0'), 'user_' || LEVEL || '@test.com', SYSDATE - FLOOR(DBMS_RANDOM.VALUE() * 1000) FROM dual CONNECT BY LEVEL <= 1000000;

这段 SQL 在多数 Oracle 11g/12c/19c 环境下跑 100 万行问题不大,LEVEL的值正好从 1 递增到 100 万,可以直接拿来当 ID。但你要知道它在背后的执行方式:层级查询是"深度优先"递归,从dual出发每次迭代加深一层,100 万层的深度意味着递归内部要维护一条极长的层级路径,内存和 CPU 开销都不小。我试过把LEVEL <= 1000000改成LEVEL <= 10000000,十有八九会碰到 ORA-30009,提示not enough memory for CONNECT BY。到那个量级,调大 PGA 治标不治本,得换更聪明的写法。

3.2 用笛卡尔积把 LEVEL 放大到任意规模

既然递归深度是瓶颈,那就别让递归变深,改成让每层递归"扇出"更多行。具体做法是:两个各 1000 行的平凡结果集做 CROSS JOIN,1000 乘以 1000 正好 100 万,再用ROWNUM做编号:

INSERT INTO app_user (id, user_name, phone, email, create_time) SELECT ROWNUM, 'user_' || ROWNUM, LPAD(FLOOR(DBMS_RANDOM.VALUE() * 10000000000), 11, '0'), 'user_' || ROWNUM || '@test.com', SYSDATE - FLOOR(DBMS_RANDOM.VALUE() * 1000) FROM ( SELECT 1 FROM dual CONNECT BY LEVEL <= 1000 ) a CROSS JOIN ( SELECT 1 FROM dual CONNECT BY LEVEL <= 1000 ) b WHERE ROWNUM <= 1000000;

这样每条分支递归深度只有 1000,内存压力小了一个量级,而且理论上你可以把 1000 换成 3000、5000,轻松把结果集放大到千万、亿级。WHERE ROWNUM <= 1000000其实在这句里是冗余的,因为两个集合相乘刚好 100 万,保留它是为了让上限可控;如果你想要任意 N 行,可以让两个内层集合各自生成CEIL(SQRT(N))行,外层再用ROWNUM <= N截断。12c 及以上版本也可以用FETCH FIRST 1000000 ROWS ONLY代替 ROWNUM 过滤,语义更清晰。

3.3 INSERT ALL 与 APPEND+NOLOGGING 提速

Oracle 批量插数据有几个常用加速手段。第一个是INSERT ALL,它可以把同一份 SELECT 结果同时插入多张表,非常适合造数时顺便给影子表、按日期拆分的月表各灌一份:

INSERT ALL INTO app_user (id, user_name, email) VALUES (id, user_name, email) INTO app_user_log (id, action) VALUES (id, 'INIT') SELECT LEVEL AS id, 'user_' || LEVEL AS user_name, 'user_' || LEVEL || '@test.com' AS email FROM dual CONNECT BY LEVEL <= 1000000;

第二个是直接路径插入。普通 INSERT 会写 redo 日志,走直接路径的INSERT /*+ APPEND */会批量写数据块,配合表上的 NOLOGGING 属性可以大幅减少 redo 量:

ALTER TABLE app_user NOLOGGING; INSERT /*+ APPEND */ INTO app_user (id, user_name, phone, email, create_time) SELECT LEVEL, 'user_' || LEVEL, LPAD(FLOOR(DBMS_RANDOM.VALUE() * 10000000000), 11, '0'), 'user_' || LEVEL || '@test.com', SYSDATE - FLOOR(DBMS_RANDOM.VALUE() * 1000) FROM dual CONNECT BY LEVEL <= 1000000; COMMIT; ALTER TABLE app_user LOGGING;

这里特别提醒一句:NOLOGGING 意味着这些数据块不会完整记录 redo,一旦数据库需要基于归档日志做恢复,这些块可能变成不可读状态。所以它只适合纯测试表,业务表、需要保数据的表千万别这么玩。另外 APPEND 直接路径在 RAC 环境还有更严格的限制,单实例测试环境不受影响。

4. PostgreSQL:generate_series 是三家最顺手的生成器

4.1 数字序列、日期序列和随机文本的生成

PostgreSQL 造数体验在三家里是最好的,因为内置的generate_series函数就是干这个的。生成 1 到 100 万:

SELECT n FROM generate_series(1, 1000000) AS g(n);

generate_series支持整数、bigint、numeric,也支持 timestamp 步进。生成日期序列非常灵活:

SELECT * FROM generate_series('2020-01-01'::timestamp, '2023-12-31'::timestamp, '1 day') AS g(day);

随机文本随手就可以拼:md5(random()::text)生成 32 位随机哈希,substr(md5(random()::text), 1, 8)取前 8 位,配合前缀用起来很方便。相比 MySQL 的 LPAD 和 Oracle 的 DBMS_RANDOM,PG 的表达式更直白,几乎不用查文档就能写出来。

4.2 CTAS 直接建表 vs 先建表再 INSERT

PostgreSQL 里造数有两种落地方式。如果这张测试表本来就不存在,最推荐直接 CTAS(CREATE TABLE AS SELECT),一步到位:

CREATE TABLE app_user AS SELECT n AS id, 'user_' || n AS user_name, '13' || lpad((floor(random() * 1000000000))::text, 9, '0') AS phone, 'user_' || n || '@test.com' AS email, timestamp '2020-01-01' + random() * interval '1000 days' AS reg_date, (ARRAY['ACTIVE', 'INACTIVE', 'PENDING'])[(1 + floor(random() * 3))::int] AS status FROM generate_series(1, 1000000) AS g(n);

CTAS 创建的表不会自动带主键、索引,这对造数反而是好事——先灌数据再回头建索引,通常比带索引逐行插入快得多。如果表已经存在,那就规规矩矩用 INSERT SELECT:

INSERT INTO app_user (id, user_name, phone, email, reg_date, status) SELECT n, 'user_' || n, '13' || lpad((floor(random() * 1000000000))::text, 9, '0'), 'user_' || n || '@test.com', timestamp '2020-01-01' + random() * interval '1000 days', (ARRAY['ACTIVE', 'INACTIVE', 'PENDING'])[(1 + floor(random() * 3))::int] FROM generate_series(1, 1000000) AS g(n);

两种方式跑百万行都很快,CTAS 在纯测试场景还能配合 UNLOGGED 表再快一截,这个下面专门说。

4.3 贴近真实分布的字段表达式清单

造数不只是"填满行",最好让数据分布看起来像真的。我常用的 PG 字段表达式有这么几类:

目标推荐表达式说明
自增 IDn直接用 generate_series 的序列值
用户名`'user_'
手机号`'13'
邮箱`n
时间timestamp '2020-01-01' + random() * interval '1000 days'均匀分布在区间内
枚举状态(ARRAY['ACTIVE','INACTIVE','PENDING'])[(1 + floor(random() * 3))::int]从数组随机取一个
数值floor(random() * 100)0 到 99 均匀分布
随机文本substr(md5(random()::text), 1, 16)需要较真随机串时可用

有一点容易忽略:generate_series(1, N)默认按整数返回,如果字段是 bigint 或者你在表达式里做了乘法,要注意类型和溢出问题。想生成 bigint 序列就写成generate_series(1::bigint, 1000000),让 PG 自动推导类型。

4.4 几个能明显提速的 PG 开关

PostgreSQL 最大的写入开销在 WAL 日志。架构上任何事务提交都要写 WAL,造测试数据这种场景数据库本身不追求崩溃恢复,所以可以适当关掉一些保护机制。

最常用的是synchronous_commit参数,普通默认是on,每次 commit 都要等 WAL 刷盘,改成off之后 commit 不会同步等待,百万行插入能快不少:

SET synchronous_commit = off;

再进一步,测试表可以直接建 UNLOGGED 表。UNLOGGED 表不写 WAL,插入速度提升非常明显,代价是崩溃或异常重启后表数据会丢失。对一次性测试数据来说,这个代价完全可接受:

CREATE UNLOGGED TABLE app_user AS SELECT ... FROM generate_series(1, 1000000) AS g(n);

还有一个容易被低估的点:单条 SQL 插入百万行是一个超大事务,PG 的 vacuum 在这个事务结束前无法清理旧版本数据,WAL 也会显著膨胀。如果磁盘不大,建议把 100 万拆成 10 个批次,每批 10 万行,分别在事务里提交。这样既能控制 WAL 大小,失败重跑时也不用从头再来。

5. 三库横向对比:方案、参数与一眼看懂的差距

5.1 三种数据库百万造数方案对照表

把前面讲的方案收拢到一张表里,方便按库直接选型:

数据库首选方案百万行写法难易度灵活控制行数常用提速手段
MySQL 8.0+WITH RECURSIVE 递归 CTE简单,但要先调会话参数改递归终止条件cte_max_recursion_depth、分片执行
MySQL 5.70-9 数字表 CROSS JOIN中等,SQL 较长调整笛卡尔层级先物化数字表,避免中间临时表过大
OracleCONNECT BY LEVEL / 笛卡尔放大简单,大数量要换写法ROWNUM 或 FETCH FIRSTAPPEND、NOLOGGING、PARALLEL
PostgreSQLgenerate_series最简单传参即可UNLOGGED 表、synchronous_commit=off

从表格里能看出一个共性:所有方案的核心都是"先生成序列,再做字段映射"。区别只在于序列生成机制——MySQL 靠 CTE 或笛卡尔积,Oracle 靠层级查询,PG 直接内置函数。理解了这个本质,换库写 SQL 的时候就很容易迁移。

5.2 决定生成速度的三个隐藏变量

很多人以为造数慢是"SQL 写得不好",其实多数情况下瓶颈在数据库日志和约束上。

第一个隐藏变量是日志写放大。MySQL 有 binlog,Oracle 有 redo/undo,PostgreSQL 有 WAL。100 万行 INSERT SELECT 产生的日志量往往比数据本身还大,日志写入时间会明显超过插入时间本身。我的经验是:测试库如果没开 binlog,MySQL 插入能快一倍以上;Oracle 靠 NOLOGGING + APPEND 把 redo 压下来;PG 用 UNLOGGED 表直接绕开 WAL。这些手段的本质都是"牺牲恢复能力换速度",所以只用在纯测试表上。

第二个隐藏变量是索引和外键。带一堆索引的表插 100 万行,每个索引都要同步维护,耗时跟行的数量成正比。保险做法是:造数前先 DROP 掉非必要索引和禁用外键约束,数据灌完再一次性 CREATE INDEX。对 MySQL,SET FOREIGN_KEY_CHECKS=0可以临时跳过外键检查;对 Oracle,外键约束要么 DISABLE,要么保证插入顺序;PG 则可以用SET CONSTRAINTS ALL DEFERRED延迟约束检查,但需要在同一事务里。注意,MySQL 的外键检查关闭是会话级的,别忘了造完再打开。

第三个隐藏变量是约束校验本身。NOT NULL、UNIQUE、CHECK 约束都会在插入时逐行校验,如果数据本身规则简单(比如编号唯一),这些校验其实是白白浪费 CPU。反过来,如果你生成的数据违反约束导致中途报错,整个事务回滚的代价更大。所以造数 SQL 跑之前,先用 EXPLAIN 或小批量验证类型、长度、唯一性,是最值得的一步。

5.3 分片插入与执行监控

我不建议所有场景都一条 SQL 梭哈百万行。分片插入虽然看起来多跑几次,但好处很明显:单批失败只回滚当前批次,方便定位问题;日志和临时空间压力更小;还能实时看到每批耗时,判断账号是不是越跑越慢。

三个库的分片方式各不相同。MySQL 的递归 CTE 可以把终止条件改成区间,比如生成n BETWEEN 200001 AND 300000;Oracle 用 CONNECT BY 加外层WHERE ROWNUM BETWEEN过滤,或者干脆用前面说的笛卡尔放大配合FETCH FIRST ... OFFSET ...;PG 最简单,generate_series(200001, 300000)直接给区间。执行端一般就是外层循环跑十次,每次打印耗时。

监控方面,MySQL 可以用SHOW PROCESSLIST看 SQL 是否还在跑;Oracle 可以查V$SESSION_LONGOPS观察进度;PG 没有专门的 INSERT 进度视图,靠每个批次的结束时间估算。我自己的习惯是每批结束查一下COUNT(*),对不上数量立刻停。

6. 实测踩坑记录:版本差异、内存溢出和序列不同步

6.1 MySQL 的 cte_max_recursion_depth 只是第一道坑

MySQL 8.0 的递归 CTE,最容易踩的坑就是忘记设置cte_max_recursion_depth,报错信息很直白——"Recursive query aborted after 1001 iterations",翻译过来就是递归第 1001 次被拦下了。解决办法是执行前先SET SESSION cte_max_recursion_depth = 1000000;。但这里有个隐蔽问题:如果你用连接池或者 ORM 框架连接数据库,这个会话变量只在当前连接里生效,换一个连接就得重新设置。我就在自动化脚本里吃过亏,脚本里明明 set 了,跑批时连接池把连接切走了,照样报错。后来我把SET SESSION写进了和造数 SQL 同一条连接的执行序列里,才算彻底解决。

还有一个坑是递归深度设置过大后,中间结果会把 tmpdir 撑爆。CTE 生成的百万级中间结果如果超过内存临时表上限,会转成磁盘临时文件,写到 MySQL 的 tmpdir 里。磁盘不够或者 /tmp 是 tmpfs 的话,直接报磁盘空间不足。遇到这种情况,要么给临时表扩容,要么换永久 numbers 表方案,别跟递归 CTE 死磕。

6.2 Oracle 的 ORA-30009 与临时表空间

Oracle 造数最典型的问题就是 ORA-30009。前面说过,直接CONNECT BY LEVEL <= 10000000深度递归很容易触发内存不足,这时候我建议直接换成笛卡尔积放大写法,而不是去调 PGA_AGGREGATE_TARGET。前者是把内存压力变成广度组合,后者只是拆东墙补西墙。

另一个容易忽略的问题是临时表空间。Oracle 的排序、哈希连接都可能用到临时表空间,如果临时表空间没开自动扩展或者空间不足,百万行插入会报 ORA-01652(无法扩展临时段)。造数之前先确认TEMP表空间状态,比临时抱佛脚改 SQL 更省事。

还遇到过一种情况:目标表有触发器,每次插入的行会触发 PL/SQL 逻辑,直接路径插入对这种表会被降级为普通插入,APPEND 的好处没了。所以造数前检查目标表有没有触发器、物化视图日志,有的话要么临时禁用,要么干脆换一张干净的表,否则你看到的执行时间跟预期差一大截。

6.3 PostgreSQL 的整型溢出和 WAL 膨胀

PG 造数也有自己的暗坑。最常见的是类型溢出:generate_series(1, 1000000)返回的是普通 integer,如果你在表达式里做n * 2之类的运算,到 100 万附近就可能超出 int 上限(2147483647),报 integer out of range。处理办法是显式使用generate_series(1::bigint, ...),或者对结果做n::bigint转换再运算。

然后是 WAL 膨胀。我之前在一台只有 200G 磁盘的测试机上跑 1000 万行插入,单条大事务直接把 pg_wal 目录撑到几十 G,差点把磁盘写满。后来改成 100 万一批,每批一个事务,基本控制在可接受范围内。如果你的数据库开了复制槽,WAL 膨胀会更严重,因为复制槽会阻止 WAL 清理,这种情况务必分片。最后提一句,synchronous_commit=off只影响提交时的等待行为,不会减少 WAL 总量,想真正减量还是得靠 UNLOGGED 表。

6.4 造数之后的清理与序列重置

数据造完,最容易翻车的是序列不同步。Oracle 和 PG 如果用了显式 ID 插入,序列对象本身不会跟着往前跳,下次应用插入新数据时,nextval会从旧值开始,跟已有主键撞车。PG 里我一般这么修:

SELECT setval(pg_get_serial_sequence('app_user', 'id'), (SELECT COALESCE(MAX(id), 1) FROM app_user));

Oracle 18c 以上可以直接ALTER SEQUENCE seq_name RESTART START WITH 1000001;,旧版本只能先 DROP 再 CREATE。MySQL 的 AUTO_INCREMENT 反而不用太担心,显式插入自增列时 InnoDB 会自动把计数器抬到 MAX+1,前提是你的sql_mode没有禁用这种默认行为。

最后说说清理。测试数据不要后用TRUNCATE而不是DELETE。DELETE 只是逐行标记删除,空间和索引都不会立刻释放,大数据量下还容易撑大 undo 和 WAL;TRUNCATE 是干脆利落地重置整个表,MySQL 里还会顺带重置 AUTO_INCREMENT,PG 里不需要额外 VACUUM,Oracle 里则会回收已分配的区段。三种库在这个场景下,行为出奇地一致。

我自己现在造数的默认套路,就是这套三库模板:MySQL 8 直接 CTE,Oracle 用笛卡尔放大,PG 用 generate_series;每套模板前面都固定跑一个 1 万行的冒烟版本,确认类型、长度、唯一性没问题之后再放全量。这个习惯帮我避免了好几次半夜被测试环境报错吵醒的情况。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进