ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Oracle三千万行订单表压测造数:CONNECT BY与APPEND实战

Oracle三千万行订单表压测造数:CONNECT BY与APPEND实战 要给一张三千万行的订单表做压测第一件事从来不是优化SQL而是先把数据灌进去。Oracle快速构造大量测试数据这个活儿看起来就是个体力活无非是往表里塞行但真动过手的人都知道路线选错了跑一整晚也摸不到千万级。我第一次接造数需求时用PL/SQL写了个FOR循环逐条INSERT跑了四个小时才三百万行中途还因为undo段涨得太猛被负责库房的同事叫停。后来换了个思路同样的机器、同样的表结构二十分钟出三千万行差别全在怎么写这一步上。这篇内容就是把这条路线完整摊开从行数怎么来、随机值怎么造、写盘怎么加速到中途会炸掉哪些资源、造完怎么验证和清理。适合手上正好有一张空表等着灌数据的人也适合写过几段造数脚本但总觉得慢得没道理的人。下面所有SQL都基于Oracle 11g及以上版本19c同样适用涉及12c之后才有的语法我会单独标注。1. 手工INSERT在造数场景里最先崩掉的三个地方1.1 一次真实的四小时只跑了三百万行那次需求很朴素一张交易流水表压测要三千万行字段包括流水号、用户ID、金额、状态、创建时间。我当时的脚本是这样的外层一个FOR i IN 1..30000000的循环内层拼一条INSERT VALUES每十万行COMMIT一次。逻辑没错跑起来也没报错但速度肉眼可见地慢四小时三百万行折算下来每秒两百行出头。这个数字放在OLTP场景里其实不算难看问题是造数要的是三千万不是三万。停在四小时那个点的时候我做了件后来觉得挺关键的事把脚本拆开单独测生成一条随机值的耗时和插入一条记录的耗时。结果很反直觉——生成随机值占了总耗时的不到百分之五剩下百分之九十五全花在插入本身。这就说明问题不在造数逻辑而在写入路径。绝大多数人第一次写造数脚本时直觉都是我要造多少行就循环多少次这个直觉在几千行级别完全没问题一旦上一百万它就成了瓶颈本身。1.2 逐条INSERT的开销到底花在哪逐条INSERT慢慢在三处叠加而且这三处是乘算关系不是加算。第一处是递归SQL。Oracle执行一条INSERT VALUES时背后会触发一串递归调用包括解析、权限检查、字典查询、执行计划生成。如果每条INSERT都是硬解析这个开销会大到离谱即使SQL文本完全一致、能命中游标缓存软解析本身也要走一遍哈希查找和权限校验。单条成本看着微不足道乘以三千万就是一座山。第二处是回滚与重做的持续开销。每条INSERT都要写undo同时生成redo。undo的意义是万一回滚能把这一行擦掉redo的意义是万一断电能把这一行重做出来。造数这个场景有个特殊性这些数据本来就是假的崩了重跑一遍就行我们根本就不需要undo的保护也不需要redo的保障。但逐条INSERT路径没法关掉它们必须老老实实为每一条假数据付出真数据的代价。第三处是日志同步等待。每次COMMIT都意味着要把redo从log buffer刷到在线日志文件这个动作依赖磁盘I/O而且COMMIT是同步的必须等它落盘才返回。我那个脚本每十万行COMMIT一次三十万次COMMIT每次哪怕只等几毫秒累计也是可观的时间。更麻烦的是这种等待在不同存储上表现差异极大本机测试跑得好好的脚本换到共享存储上可能直接慢一倍。1.3 造数要同时算清的三笔账明白了开销在哪就能理解为什么造数和写业务数据必须用两套完全不同的策略。时间账业务INSERT必须保证正确性和可恢复性慢一点无所谓造数INSERT的唯一KPI就是吞吐量一切不影响最终结果正确性的开销都可以砍掉。空间账一次批量写入几千万行undo表空间、临时表空间、redo日志文件都会被顶到极限。我那次被叫停就是因为undo涨到百分之九十多。造数前必须先确认这几个空间的余量尤其是临时表空间——它最容易被忽略也最容易在中途炸出来原因后面第5章会细说。可维护账造数脚本往往要反复跑很多次表结构变了要改字段加了一个要改行数从一千万调到五千万还要改。所以脚本从一开始就得参数化把行数批次随机种子这些变量提到最上层。我吃过这个亏第一版脚本里行数是散落在七个地方的硬编码改一次要全局搜索替换漏掉一处就得到一张行数对不上的表。2. CONNECT BY LEVEL把要多少行变成一个可控变量2.1 LEVEL伪列为什么能当计数器用想批量插入第一件事是先凭空变出N行。Oracle里最省事的办法是用CONNECT BY配合LEVEL伪列SELECT LEVEL AS rn FROM dual CONNECT BY LEVEL 10;这段会返回1到10共十行。原理是CONNECT BY本质是个递归查询Oracle从dual里取出起始行然后不断对它做递归展开每展开一层LEVEL就加一直到LEVEL 10不再成立为止。这里没有用PRIOR关键字去关联父子关系所以每一层只产生一行整体就是一条从1数到10的直线而不是树。这个技巧的价值在于它把一个循环变成了一个集合。下面这句才是真正的转折点——INSERT INTO t_test (id, amt) SELECT LEVEL, ROUND(DBMS_RANDOM.VALUE(1, 9999), 2) FROM dual CONNECT BY LEVEL 1000000;同样是一百万行这里是一条SQL。它只解析一次只有一次执行计划undo和redo按批量路径产生而不是一百万个独立事务。实测同一台机器上同样是百万行逐条INSERT要跑六分多钟这条SQL通常在八到十五秒之间差距接近三十倍。2.2 百万级和千万级的写法并不一样LEVEL当计数器有个上限而且这个上限不是硬性数字取决于你查的是什么。CONNECT BY LEVEL 100000基本是安全的 1000000在多数11g/19c实例上也能跑通但再往上就有两个风险一是可能直接报ORA-30009二是即使跑通了临时段的膨胀速度也会很吓人。千万级的稳妥写法是交叉连接把一个大数字拆成两个能安全处理的因子INSERT /* APPEND */ INTO t_test (id, user_id, amt) SELECT ROWNUM, TRUNC(DBMS_RANDOM.VALUE(1, 5000000)), ROUND(DBMS_RANDOM.VALUE(1, 99999), 2) FROM (SELECT LEVEL FROM dual CONNECT BY LEVEL 1000) a, (SELECT LEVEL FROM dual CONNECT BY LEVEL 1000) b;两个一千行的集合做笛卡尔积得到一百万行要一千万就把其中一个换成 10000。这里有个小坑交叉连接之后ROWNUM的生成顺序不保证和LEVEL一致如果你需要严格顺序的编号用(a.LEVEL - 1) * 1000 b.LEVEL这种算式自己拼别指望ROWNUM。另外12c之后还有个更干净的写法——XMLTABLEINSERT /* APPEND */ INTO t_test (id) SELECT ROWNUM FROM XMLTABLE(1 to 10000000);这行的写法比交叉连接简洁得多也不容易触发ORA-30009代价是XML解析本身要吃点CPU。我在19c上用这种方式一次插过两千万行没出过问题。2.3 ORA-30009不是内存不够是递归评估方式的问题ORA-30009的官方描述是Not enough memory for CONNECT BY operation但它出现的真实原因往往不是内存真的不够。CONNECT BY在评估过程中会为递归的每一层维护上下文当LEVEL上限极大、或者CONNECT BY的谓词里带了复杂表达式比如调用了函数、或者带排序评估成本会指数级上升最后撞上内部限制。绕开它有四条路按推荐顺序排方案适用行数说明交叉连接两个小集合千万到亿最通用11g也稳XMLTABLE序列千万到亿12c写法最简洁预建数字辅助表任意一次性建好长期复用拆分多次INSERT任意逻辑最简单但脚本变长数字辅助表值得单独说一句。如果你的库经常要做造数、补数、按序列展开这类操作建一张NUMBERS(N NUMBER)一次性灌进一千万行之后所有造数SQL直接JOIN NUMBERS ON N 目标行数既快又省心。这张表还可以建唯一索引JOIN的时候走索引扫描比每次现算CONNECT BY更可控。3. DBMS_RANDOM把假数据做出真数据的手感3.1 数值字段区间、精度与业务分布DBMS_RANDOM.VALUE带两个参数时返回指定区间的随机数不带参数时返回0到1之间的小数。金额这类字段最常见的要求是保留两位小数、落在合理区间写法是ROUND(DBMS_RANDOM.VALUE(1, 99999), 2) AS amt注意一个细节DBMS_RANDOM.VALUE(1, 99999)返回的随机数在区间内是均匀分布的也就是说一万以下的金额和九万以上的金额出现概率一样。但真实交易数据几乎不可能是均匀分布——绝大多数订单金额集中在几十到几百大额订单非常稀少。如果你造的测试数据被用来评估索引选择性或者分区裁剪效果均匀分布会给出完全错误的结论。要让分布更接近现实可以用幂函数压缩ROUND(POWER(DBMS_RANDOM.VALUE(0, 1), 3) * 5000 1, 2) AS amt取三次方之后结果会强烈偏向低值区长尾自然形成。这个技巧我在做金额字段压测时反复用过效果比调区间参数好得多因为它是控制形状而不是控制范围。还有一种需求是枚举型数值比如订单状态只可能是1到5。这时候别用随机数用取模或者字符串截取更稳SUBSTR(12345, TRUNC(DBMS_RANDOM.VALUE(1, 6)), 1) AS status这样能保证取值严格落在这五个字符里不会出现随机算法边界导致的越界值。这也是为什么很多人宁愿用字符串枚举再转换——可读性高边界也好控。3.2 字符串字段定长串、枚举值与中文姓氏DBMS_RANDOM.STRING是造字符串的主要工具第一个参数是模式第二个是长度DBMS_RANDOM.STRING(U, 10) -- 10位大写字母 DBMS_RANDOM.STRING(L, 10) -- 10位小写字母 DBMS_RANDOM.STRING(X, 16) -- 16位大写字母数字适合做流水号 DBMS_RANDOM.STRING(A, 20) -- 20位大小写字母混合字母模式有个实际影响它的字符集里包含元音字母所以生成的串读起来像乱码但也像单词放在报表里不会太刺眼。而X模式经常被拿来做订单号、流水号这类看起来像真的的字段。枚举值我前面提过用SUBSTR截字符串这里补一个更完整的用法。假设订单状态串是PAID,UNPAID,SHIPPED,CANCELLED,REFUNDED先用按逗号拆行的思路把它变成集合再随机取WITH e AS ( SELECT PAID,UNPAID,SHIPPED,CANCELLED,REFUNDED AS s FROM dual ), s AS ( SELECT REGEXP_SUBSTR(s, [^,], 1, LEVEL) AS status FROM e CONNECT BY LEVEL REGEXP_COUNT(s, ,) 1 ) SELECT status FROM s ORDER BY DBMS_RANDOM.VALUE;这个按逗号拆成多行的手法在造数里很有用一旦状态枚举变了只要改那个字符串不用动CASE WHEN的分支。说到CASE WHEN如果你更习惯用它控制加权分布也可以这么写CASE WHEN DBMS_RANDOM.VALUE 0.70 THEN PAID WHEN DBMS_RANDOM.VALUE 0.85 THEN SHIPPED WHEN DBMS_RANDOM.VALUE 0.95 THEN UNPAID ELSE CANCELLED END AS status注意这里每一行都重新调用了一次DBMS_RANDOM.VALUE所以在同一行内多次比较是独立随机的。这个写法能造出百分之七十已支付的分布比均匀取值贴近真实业务得多。中文姓名稍微麻烦点因为Oracle没有内置的中文词库。常规做法是准备两个字符串一个放姓氏、一个放名字常用字然后随机截取SUBSTR(赵钱孙李周吴郑王冯陈褚卫, TRUNC(DBMS_RANDOM.VALUE(1, 13)), 1) || SUBSTR(伟芳娜秀敏静丽强磊洋艳勇军杰娟涛超明霞平刚, TRUNC(DBMS_RANDOM.VALUE(1, 24)), 1) AS cname这样造出来的是单字名两字名就把后面那段拼两次。要提醒一句中文字符在你的库字符集下占几个字节要提前确认如果目标字段是BYTE长度的VARCHAR2可能造到一半报长度超限。3.3 时间字段TRUNC(SYSDATE)加减随机天数时间字段最忌讳的是让所有行的时间戳都一样。事务表里如果创建时间全都相同任何基于时间的分区裁剪、AWR分析、索引范围扫描测试都会失真。基本写法是拿当天零点当基线往前推一个随机天数TRUNC(SYSDATE) - TRUNC(DBMS_RANDOM.VALUE(0, 365)) AS create_time加TRUNC是为了把时分秒也截掉否则同一天内会出现大量重复时间戳。如果你希望时间戳在24小时内也随机分布改成TRUNC(SYSDATE) - DBMS_RANDOM.VALUE(0, 365) AS create_time这样既跨了年又在天内均匀铺开。实测这个写法造一年跨度的数据用SELECT TRUNC(create_time,MM), COUNT(*) GROUP BY去看每个月的数据量大致均匀适合做分区表的造数。有个容易忽略的点是时间字段和业务字段的一致性。比如支付时间应该晚于创建时间发货时间应该晚于支付时间。造数时如果忽略了这类约束等到写校验SQL的时候会发现数据根本没法用。稳妥做法是先造创建时间再用它加上一个随机间隔TRUNC(SYSDATE) - DBMS_RANDOM.VALUE(0, 365) DBMS_RANDOM.VALUE(0, 2) AS pay_time3.4 身份证号与手机号必须存成字符型的那类字段这两类字段有个共同点它们是看起来像数字的字符串不是数字。身份证号18位手机号11位如果字段定义成NUMBER绝大多数场景下不会报错NUMBER精度足够但会埋下三个雷。第一导出的CSV用表格软件打开18位数字会被显示成1.10101E17这种科学计数法看起来完全不是身份证号。第二前导零会丢比如身份证号是以0开头的地区码。第三任何拼接、截取的操作都得先隐式转字符性能差还容易出意外。所以造数时字段就该建成VARCHAR2(18)生成逻辑直接拼字符串-- 手机号11位以常见号段开头 13 || LPAD(TRUNC(DBMS_RANDOM.VALUE(0, 100000000)), 8, 0) AS mobile -- 身份证18位纯数字串仅用于测试不含真实校验逻辑 LPAD(TRUNC(DBMS_RANDOM.VALUE(0, 1000000)), 6, 0) || TO_CHAR(TRUNC(SYSDATE) - DBMS_RANDOM.VALUE(7000, 25000), YYYYMMDD) || LPAD(TRUNC(DBMS_RANDOM.VALUE(0, 10000)), 4, 0) AS id_card这里身份证的中间8位我特意用了出生日期格式前后各拼随机位这样造出来的号段看起来有结构不像纯随机串。如果你需要更真的效果可以再补一个mod 11-2的校验位计算但那属于锦上添花压测场景基本用不上。LPAD是这几行里的关键函数。不用它的话随机取到的小数字符串长度不够手机号会变成9位、10位造完数据要花时间清洗。凡是定长数字串字段LPAD都要记得加上。4. 直接路径插入NOLOGGING与APPEND的组合开关4.1 APPEND到底省掉了哪一步INSERT /* APPEND */叫直接路径插入它和普通INSERT的根本区别在于数据往哪写。普通INSERT走的是缓冲区缓存路径先在内存里找到目标块把行塞进去然后等DBWR按自己的节奏把脏块刷到数据文件。这条路径要维护块内的空闲空间信息、要处理块分裂、要更新多种内部结构。直接路径插入跳过这一切。它在表的高水位线之上直接申请新块绕过缓冲区缓存把数据按块组织好一次性写进数据文件。同时它不维护块内的空闲空间链表也不做行级替换检查——因为设计前提就是这是批量灌入全新的数据不存在和旧数据挤在同一块的情况。这个差异带来的性能提升非常直接。我实测过一张二十个字段的表插一千万行普通INSERT SELECT跑了大概两分四十秒换成APPEND之后是四十七秒。差距的主要来源不是省了buffer cache操作而是APPEND能够配合并行、能够配合NOLOGGING而后两者才是真正的大头。一定要记住APPEND插入的数据在COMMIT之前对其他会话不可见。而且如果这个事务回滚整个插入全部作废不能回滚到中间某一行。造数脚本里如果中途因为表空间不足中断不要指望已经插进去的还在老老实实TRUNCATE重来。4.2 索引和约束造数期间该不该留着有个很常见的误区是索引反正是要建的边插边维护也一样。完全不一样。每插入一行Oracle都要去更新这一行涉及的所有索引。索引维护涉及索引块的分裂、排序、以及额外的redo。造一千万行、表上五个索引等于额外做五千万次索引插入动作。而且索引维护过程中产生的redo量经常超过表数据本身的redo量——因为索引块分裂是随机的、碎片化的。正确的做法是造数前把非必要的索引和约束先处理掉-- 1. 记录现有索引定义用于之后重建 SELECT DBMS_METADATA.GET_DDL(INDEX, INDEX_NAME) FROM USER_INDEXES WHERE TABLE_NAME T_TEST; -- 2. 删除索引 DROP INDEX IDX_T_TEST_USER; DROP INDEX IDX_T_TEST_TIME; -- 3. 禁用约束如果不需要在灌数据时校验 ALTER TABLE T_TEST DISABLE CONSTRAINT FK_T_USER; ALTER TABLE T_TEST DISABLE CONSTRAINT CK_AMT_POSITIVE;注意主键约束不能随便禁因为很多业务表依赖它如果表本身有主键索引可以选择在造数期间禁用后重建。还有一种做法是把索引设为UNUSABLE但UNUSABLE状态下对该表的DML可能报错行为比较绕不如直接DROP来得干净。灌完数据、COMMIT之后再统一重建索引这时候可以加并行和NOLOGGINGCREATE INDEX IDX_T_TEST_USER ON T_TEST(USER_ID) NOLOGGING PARALLEL 8; ALTER INDEX IDX_T_TEST_USER NOPARALLEL;批量重建索引比边插边维护快多少还是那张一千万行的表五个索引边插边维护总耗时十二分钟左右先灌后建索引总计不到六分钟。而且先建索引还有个额外好处索引结构更紧凑没有插入过程中反复分裂留下的空洞后续查询的聚簇因子更好。4.3 并行DML与PGA、TEMP表空间的拉扯APPEND可以和并行叠加这是把造数速度再提一档的关键ALTER SESSION ENABLE PARALLEL DML; INSERT /* APPEND PARALLEL(t, 8) */ INTO t_test t (id, user_id, amt) SELECT (a.LEVEL - 1) * 1000 b.LEVEL, TRUNC(DBMS_RANDOM.VALUE(1, 5000000)), ROUND(DBMS_RANDOM.VALUE(1, 99999), 2) FROM (SELECT LEVEL FROM dual CONNECT BY LEVEL 1000) a, (SELECT LEVEL FROM dual CONNECT BY LEVEL 1000) b;PARALLEL(t, 8)里的8是并行度经验值取CPU核数的一半到全部之间。别盲目往上加并行度超过可用CPU之后进程之间抢CPU反而让总时间变长而且每个并行进程都会占一份PGAPGA被顶爆的话会直接报ORA-04030。并行DML真正的资源风险在临时表空间。并行执行计划里每个从属进程在排序、哈希、去重时都会申请临时段。造数SQL里如果有DISTINCT、ORDER BY、GROUP BY或者CONNECT BY的递归需要中间物化临时段规模会迅速膨胀。我见过一次因为并行度设成16同时插入两千万行带DISTINCT的SQL临时表空间五分钟内从2G涨到30G直接把库房的总表空间吃掉一大半。两个应对办法。一是给造数SQL去掉一切不必要的DISTINCT和ORDER BY——造数要的是行数和分布排序毫无意义。二是调大PGA让排序尽量在内存里完成减少对临时段的依赖ALTER SESSION SET PGA_AGGREGATE_TARGET 4G;这条是个会话级设置只对当前造数会话生效不会影响其他业务。造完数据后断开连接设置自动失效这是我会优先推荐的方式。5. 三千万行订单表造数全程复盘5.1 表结构与字段清单拿一个真实的例子来说。目标表结构大致是这样字段类型造数方式ORDER_IDNUMBER(19)算式生成保证唯一USER_IDNUMBER(10)随机一万到五百万之间ORDER_NOVARCHAR2(32)DBMS_RANDOM.STRING(X, 20)AMTNUMBER(12,2)幂函数压缩后保留两位STATUSVARCHAR2(16)CASE WHEN加权MOBILEVARCHAR2(11)号段拼接LPADID_CARDVARCHAR2(18)分段拼接CREATE_TIMEDATETRUNC(SYSDATE)减随机数PAY_TIMEDATE基于CREATE_TIME加零到两天REMARKVARCHAR2(200)随机字母串部分为空这张表十个字段看着简单但覆盖了造数里几乎所有典型情况唯一主键、外键引用、定长字符串、金额、枚举、带格式的个人信息、时间、可空字段。5.2 分阶段执行与耗时分布整个过程我拆成了四步分步执行比一条巨型SQL更好排查问题也方便中途调整。第一步清场。TRUNCATE表、DROP索引、禁用非主键约束、记录DDL。这一步几十秒但绝不能省。第二步灌主数据。用并行APPEND一次插三千万行。这一步是主体八并行的情况下大约十一分钟。插完立刻COMMIT。第三步补齐依赖字段。有些字段依赖其他表的ID比如USER_ID要引用户表。我的做法是先不校验外键直接灌随机数灌完再UPDATE修正——但UPDATE三千万行比重新INSERT还慢所以更好的办法是让随机范围落在用户表实际的ID区间内直接保证引用有效。实测这个方法可行前提是你知道用户表的ID上下限。第四步重建索引和约束。五个索引并行重建加约束校验大约四分钟。总计约十六分钟出三千万行。这里有个细节值得说第四步加约束的ENABLE VALIDATE会全表扫描校验如果数据里存在不满足约束的行会在这一步报错。所以约束校验其实也是一次免费的数据质量检查跑通就说明数据符合预期。5.3 中途炸掉的临时表空间和跳号的序列第一次跑的时候翻了两回车。第一次是临时表空间爆掉。我最初版本的SQL里带了SELECT DISTINCT本意是避免重复的订单号结果三千万行做去重排序临时段直接涨到二十多个G。而且这个DISTINCT毫无必要——订单号用DBMS_RANDOM.STRING(X, 20)生成二十位大写字母加数字的组合空间是天文数字重复概率可以忽略。去掉DISTINCT之后问题立刻消失。这件事教训很深造数SQL里出现DISTINCT、ORDER BY、GROUP BY几乎都是设计错误。第二次是序列跳号。我一开始用SEQUENCE生成ORDER_ID灌完之后一查最小值是一最大值是三千万多但COUNT(*)是三千万整。也就是说中间跳了大量号。原因有两个一是SEQUENCE默认CACHE 20实例重启或者被驱逐时会跳二是并行DML每个并行进程会各自申请一批序列值交错插入后序号不连续。如果你需要严格连续的编号别用SEQUENCE用我在4.3里写的算式(a.LEVEL - 1) * 1000 b.LEVEL。这才是真正连续的。这两次翻车最值得记的一点是造数脚本一定要在小规模上先试。把行数改成十万跑一遍看执行计划、看资源消耗、看数据分布。十万行能暴露的问题三千万行会以十倍的代价暴露。我现在的习惯是先跑一万行再跑一百万行最后才上目标行数。6. 数据落地之后抽样验证、参数化封装与重复执行6.1 抽样验证随机性而不是看着像随机造完数据最忌讳的就是随便SELECT几行看看感觉挺随机的。真正需要验证的是分布不是样本。第一步查整体规模和唯一性SELECT COUNT(*) AS total, COUNT(DISTINCT order_id) AS distinct_id, MIN(order_id) AS min_id, MAX(order_id) AS max_id FROM t_test;如果total和distinct_id不相等说明唯一性没保证多半是随机算法的取值空间太小或者拼接逻辑有重复。如果min_id不是1、max_id不是total说明编号有跳号或者断号。第二步查枚举分布SELECT status, COUNT(*), ROUND(COUNT(*) / SUM(COUNT(*)) OVER () * 100, 2) AS pct FROM t_test GROUP BY status ORDER BY 2 DESC;这一步用来确认加权CASE WHEN真的生效了。如果PAID的比例明显偏离你设定的百分之七十回头检查CASE WHEN的写法最常见的问题是分支顺序错了或者阈值写重叠了。第三步查时间和金额的分布SELECT TRUNC(create_time, MM) AS mon, COUNT(*) FROM t_test GROUP BY TRUNC(create_time, MM) ORDER BY 1; SELECT TRUNC(amt, -2) AS bucket, COUNT(*) FROM t_test GROUP BY TRUNC(amt, -2) ORDER BY 1;时间按月分布应该大致均匀金额按百元分桶应该呈现明显的长尾形状。如果金额分布是平的说明你的POWER压缩没生效。还有一个特别隐蔽的坑DBMS_RANDOM的种子。如果你在造数脚本开头调用了DBMS_RANDOM.SEED(固定值)或者DBMS_RANDOM.INITIALIZE(固定值)那么每一次执行都会生成完全相同的随机数据。这在某些场景下是优点可复现但在需要多套测试数据的场景下是灾难——第二套数据看起来和第一套一模一样任何对比测试都失去意义。默认情况下不设种子DBMS_RANDOM会用会话的随机状态每次执行结果不同。要不要设种子取决于你的用途但一定要知道自己设了。6.2 把造数脚本参数化造数脚本一定会被改很多遍所以从第一版就该带上参数。我的习惯是用PL/SQL匿名块包一层把变量集中放在开头DECLARE v_rows PLS_INTEGER : 10000000; -- 目标行数 v_batch PLS_INTEGER : 1000000; -- 每批行数 v_par_deg PLS_INTEGER : 8; -- 并行度 v_user_max PLS_INTEGER : 5000000; -- 用户ID上限 BEGIN EXECUTE IMMEDIATE ALTER SESSION ENABLE PARALLEL DML; EXECUTE IMMEDIATE ALTER SESSION SET PGA_AGGREGATE_TARGET 4G; FOR i IN 1 .. CEIL(v_rows / v_batch) LOOP EXECUTE IMMEDIATE INSERT /* APPEND PARALLEL(t, || v_par_deg || ) */ INTO t_test t SELECT ... FROM ...; COMMIT; END LOOP; END; /分批的意义不只是控制undo规模更是让脚本可中断可续跑。三千万行如果跑到两千万的时候因为网络断开或者会话被kill中断了一批一百万的话你已经完成的部分还在只要从断点续跑就行。这里有个顺序细节先COMMIT再进下一批不要把COMMIT放在循环外面。虽然频繁COMMIT会增加日志同步开销但在造数场景下这个开销远小于跑到一半全丢了的损失。批次大小一百万到五百万之间比较合适太小了COMMIT开销占比高太大了中断损失大。6.3 重复执行时的去重MERGE与NOT EXISTS造数脚本跑第二遍的时候如果表里已经有数据直接INSERT会产生重复。有三种处理方式看情况选最简单的是TRUNCATE重来。如果表里的数据本来就是上一轮造的、没有任何价值直接清空。这是我最常用的方式一行TRUNCATE比任何去重逻辑都快。需要保留已有数据时用NOT EXISTSINSERT /* APPEND */ INTO t_test (order_id, user_id, amt) SELECT s.order_id, s.user_id, s.amt FROM (SELECT ... ) s WHERE NOT EXISTS (SELECT 1 FROM t_test t WHERE t.order_id s.order_id);注意这个写法的性能取决于t_test上order_id有没有索引。如果没索引NOT EXISTS会变成对t_test的反复全表扫描三千万行级别基本跑不动。所以要么保证索引存在要么改用哈希反连接——把t_test的order_id先抽到临时表里再和源数据做HASH JOIN。需要存在则更新、不存在则插入时用MERGEMERGE /* APPEND */ INTO t_test t USING (SELECT ... ) s ON (t.order_id s.order_id) WHEN NOT MATCHED THEN INSERT (order_id, user_id, amt) VALUES (s.order_id, s.user_id, s.amt);MERGE在造数场景里其实用得不多因为造数一般不需要更新但有一种情况例外你想往已有数据里补一个新字段。这时候用MERGE只更新那一列比整表重造快得多。不过要提醒一句ORA-30926无法在源表中获得稳定的行集是MERGE最常见的报错原因基本都是源数据集里存在重复的关联键导致Oracle不知道该用哪一行去更新。造数时如果源是随机生成的记得先对关联键做去重或者干脆在源集合上用GROUP BY收敛一下。最后分享个我一直在用的小习惯每次造数脚本执行完立刻把脚本本身、执行时间、行数、遇到的报错记到一个文本文件里。造数这件事的调试成本很高下次表结构变了或者换台机器再跑翻出上次的记录能省掉大量重复排查。尤其是并行度、PGA设置、批次大小这几个参数它们的最优值在不同机器上差异很大不记下来就只能靠反复试。
返回列表