1. 项目背景与需求拆解1.1 核心需求从哪来先交代一下场景。某数据平台需要按天生成多张业务分表比如按日期后缀生成TRADE_20250101、TRADE_20250102这样一张张表接着要把源库的历史数据按日期切分后灌入对应的分表。如果用手工方式靠 SQL*Plus 一条条CREATE TABLE、一条条INSERT几十张表、几百万数据量足够让人崩溃。于是我当时接到这个任务首先拆解成三个核心需求批量创建一批结构相同、表名不同的表。批量向这些表插入对应的数据。整个过程要在存储过程中自动完成并输出基础日志方便追溯。这类需求在金融、零售、物流行业非常常见。尤其是月底报表、日终批处理、数据归档场景几乎绕不开“批量建表批量插入”的组合拳。这个项目本身没有特别高的技术门槛但它非常考验对 Oracle 语法细节、事务控制、性能调优的理解。写顺了能省下大量人工时间写崩了可能把生产环境搞停。1.2 技术选型背后的逻辑当时可选的技术方案有不少。最朴素的方案是用 Shell 脚本循环调用 sqlplus每次执行一条建表语句、一条插入语句。优点是好理解缺点是每张表都要单独建立连接编译和执行开销很大。更关键的瓶颈在于只要中间有一张表插入失败脚本就会中断你得手工去排查究竟跑到哪一步了。所以我们决定把逻辑都收拢到数据库内部用存储过程实现。建表用动态 SQL插入用游标 动态 SQL 或者直接使用INSERT ... SELECT。这样有几个好处所有操作都在数据库实例内部完成减少客户端网络往返执行链路更短。可以在一个存储过程内通过异常处理捕获错误记录日志。通过COMMIT的节奏控制保证单批数据的可控性。选型的时候还要考虑一个问题目标表是否需要预先存在如果表不存在存储过程里动态建表然后再插入。这个方案最灵活适合“批量生成大量结构相同的表”这类场景。如果表已经由 DBA 建好那么存储过程只负责数据搬运即可。我这个项目属于前者所以重点放在了“建表插入”一体化的实现。1.3 整体架构怎么搭我用一张图来表述整体流程这里用文字描述存储过程接收一个批次日期和时间范围然后解析目标表名清单先循环动态建表再循环动态插入每个表插入完成后立即提交一次同时记录日志到一张日志表。从这个流程可以看出核心模块有三个批次表名解析模块负责生成目标表名清单。动态建表模块负责按模板建表。数据插入模块负责按条件从源表抽取数据并写入目标表。这三个模块集成在一个包PKG_BATCH_DATA中。外层只暴露一个入口RUN_BATCH传入日期参数即可。这样设计既能满足复用也方便后续扩展。项目实际测试下来10 张表每张 50 万条左右的数据量整体耗时从手工执行的 40 多分钟缩短到 7 分钟左右效果非常直观。2. 批量建表的核心实现2.1 动态SQL与静态SQL的选择在 Oracle 存储过程中如果要建的表名是运行时才知道的CREATE TABLE不能像普通 DML 那样直接写在过程体里。必须使用动态 SQL。常见的动态 SQL 执行方式有两种EXECUTE IMMEDIATE和DBMS_SQL。对于建表这类简单场景EXECUTE IMMEDIATE完全够用。很多人会问为什么不用ALL_TABLES判断表是否存在后再决定是否建表答案是可以的而且很值得做。因为存储过程一旦设计成可重复执行就必须考虑幂等性。如果某张表已经存在再执行一次CREATE TABLE会直接报ORA-00955: name is already used by an existing object。我在实际编写时是这样处理的BEGIN SELECT COUNT(1) INTO v_cnt FROM ALL_TABLES WHERE OWNER DATA_OWNER AND TABLE_NAME v_table_name; IF v_cnt 0 THEN EXECUTE IMMEDIATE CREATE TABLE DATA_OWNER. || v_table_name || AS SELECT * FROM DATA_OWNER.TMP_MODEL WHERE 1 0; END IF; END;这里用WHERE 1 0的目的很清楚只要表结构不要数据。这种方式比手工逐列拼CREATE TABLE的列定义要省事得多而且模板表后续如果加列建出来的新表会自动带上新列不需要额外改代码。这个技巧非常实用能减少大量维护成本。2.2 批量建表的实现思路批量建表前先要拿到表名清单。表名来源有多种可能根据前缀加日期、从配置表读取、或者从上游文件解析。在我这个项目里目标表名是TRADE_YYYYMMDD的格式。假设要生成某月 1 号到 10 号共 10 张表我直接在存储过程中用循环生成日期字符串并拼接表名。直接贴核心代码基于常见实践已做脱敏处理CREATE OR REPLACE PROCEDURE PRC_CREATE_TABLES( p_start_date IN DATE, p_end_date IN DATE ) IS v_cur_date DATE : p_start_date; v_table_name VARCHAR2(64); v_sql VARCHAR2(4000); v_cnt NUMBER; BEGIN WHILE v_cur_date p_end_date LOOP v_table_name : TRADE_ || TO_CHAR(v_cur_date, YYYYMMDD); SELECT COUNT(1) INTO v_cnt FROM ALL_TABLES WHERE OWNER DATA_OWNER AND TABLE_NAME v_table_name; IF v_cnt 0 THEN v_sql : CREATE TABLE DATA_OWNER. || v_table_name || AS SELECT * FROM DATA_OWNER.TMP_MODEL WHERE 1 0; EXECUTE IMMEDIATE v_sql; DBMS_OUTPUT.PUT_LINE(Created table: || v_table_name); -- 写入日志表 INSERT INTO BATCH_LOG(LOG_ID, MODULE_NAME, ACTION_DESC, RUN_TIME) VALUES (SEQ_LOG.NEXTVAL, PRC_CREATE_TABLES, CREATE || v_table_name, SYSDATE); END IF; v_cur_date : v_cur_date 1; END LOOP; COMMIT; EXCEPTION WHEN OTHERS THEN ROLLBACK; DBMS_OUTPUT.PUT_LINE(Error at: || v_table_name || , Message: || SQLERRM); RAISE; END;这里面有几点值得注意ALL_TABLES查询的是当前用户能看到的表如果当前连接用户没有DATA_OWNER下所有表的查询权限会查不到记录导致重复建表时报错。所以最好在过程中加上捕获异常的逻辑。COMMIT放在循环外还是循环内取决于是否需要保留部分结果。我的做法是全部建表成功后才提交避免产生半成品表。日志表每建一张就插入一条后续排查时能清楚知道哪些表在何时被创建。2.3 模板表与表结构克隆模板表TMP_MODEL是这个方案的关键。它只需要建一次放在一个固定 Schema 下面。后续所有批量创建的表都基于它克隆结构。克隆时还可以把注释、默认值一起带过来。但要注意CREATE TABLE ... AS SELECT这种方式不会复制主键、非空约束、索引和触发器等对象。如果目标表也需要这些约束那么要么建完表之后用ALTER TABLE补要么改成直接执行一套完整的CREATE TABLE语句再执行配套的索引创建语句。我当时的做法是“基础结构模板化 索引脚本化”。也就是说核心表结构用模板克隆主键和常用索引单独放在一个脚本里批量建表之后再统一执行一遍EXECUTE IMMEDIATE ALTER TABLE DATA_OWNER. || v_table_name || ADD CONSTRAINT PK_ || v_table_name || PRIMARY KEY (ID);这种做法的优点是灵活缺点是每个约束名都需要唯一。如果表名中包含日期主键名直接把表名作为后缀拼接上去基本可以避免重名问题。如果表名不是唯一的可能还需要加一个序列来生成约束名后缀。关于WHERE 1 0这里再延展一句有些人喜欢先SELECT * FROM TMP_MODEL取出来做成CREATE TABLE语句其实没必要直接克隆结构更简洁。另外模板表定期会有列变更比如业务加了一列旧的分表并不会自动更新。所以在批量建表之前如果有变更需要先对模板表做ALTER TABLE然后重建旧的表或者用ALTER TABLE ... ADD/MODIFY去旧表上动态添加列。这个在真实项目中是避免不了的建议提前设计好“结构同步”规则。3. 批量插入存储过程设计3.1 批量插入的几种常见姿势批量插入是另一个重点。Oracle 下插入大批量数据常见方案有单条INSERT INTO ... VALUES循环。游标循环逐条插入。INSERT INTO ... SELECT ...一次性从源表抽取。INSERT ALL多表同时插入。外部表加载或SQL*Loader。在存储过程中做批量插入最常用的是方案 3。它把“从源表查出数据再插入目标表”的动作合二为一由 Oracle 优化器统一处理。如果只是从一张表搬到另一张表用INSERT INTO target SELECT FROM source WHERE condition几乎是最优解。它的隐含问题在于一个巨大的INSERT ... SELECT会导致回滚段/undo 空间占用很高提交前所有变更都在一个事务里一旦中途失败全部回滚。所以需要控制批次大小。方案 2 的逐条插入虽然可控但每条INSERT都会触发独立的解析和事务开销性能很差。只有在需要处理复杂行级逻辑时才考虑。而方案 4INSERT ALL适合把同一份源数据同时插入到多张结构相同的表或分区表中。比如把TRADE_20250101和TRADE_20250102两张表的数据都用同一个查询结果集来填充那么INSERT ALL可以减少对源表的重复扫描。这个技巧容易被人忽略后面我会专门讲。3.2 存储过程中的批量插入核心代码先看我用的最基础版本按日期循环每天对应一张表从源总表SRC_TRADE中抽取当天数据插入目标表。CREATE OR REPLACE PROCEDURE PRC_INSERT_BATCH( p_start_date IN DATE, p_end_date IN DATE ) IS v_cur_date DATE : p_start_date; v_table_name VARCHAR2(64); v_sql VARCHAR2(4000); BEGIN WHILE v_cur_date p_end_date LOOP v_table_name : TRADE_ || TO_CHAR(v_cur_date, YYYYMMDD); v_sql : INSERT INTO DATA_OWNER. || v_table_name || SELECT * FROM DATA_OWNER.SRC_TRADE WHERE TRADE_DATE :1; EXECUTE IMMEDIATE v_sql USING v_cur_date; DBMS_OUTPUT.PUT_LINE(Inserted into: || v_table_name || , Rows: || SQL%ROWCOUNT); COMMIT; v_cur_date : v_cur_date 1; END LOOP; EXCEPTION WHEN OTHERS THEN ROLLBACK; DBMS_OUTPUT.PUT_LINE(Error at: || v_table_name || , Message: || SQLERRM); RAISE; END;注意SQL%ROWCOUNT在EXECUTE IMMEDIATE执行 DML 之后是可以取到的。这个值代表该条语句影响的行数。不过对于INSERT ... SELECT它返回的是实际插入的行数。这段代码有个问题如果某一天没有数据INSERT ... SELECT不会报错只会影响 0 行。这没问题。但如果目标表有主键且数据里存在重复键插入会报ORA-00001整个存储过程会停止。所以还要在异常处理上多做一层判断。3.3 自动分批的边界处理如果是单表数据量过大比如一天要插入 5 百万行直接一条INSERT ... SELECT执行时间太长undo 空间也可能爆炸。虽然“每天一张表”这个粒度天然做了数据切分但单表内部依然可能很大。这种情况下要再做一层循环分批。Oracle 里没有类似 MySQL 的LIMIT通常用ROWNUM或游标FETCH FIRST n ROWS ONLY。更常见的方式是先按某种业务键如ID的范围分片。比如每批处理 10 万行可以写成v_sql : INSERT INTO DATA_OWNER. || v_table_name || SELECT * FROM (SELECT * FROM DATA_OWNER.SRC_TRADE WHERE TRADE_DATE :1 AND ID :2 ORDER BY ID) WHERE ROWNUM 100000;但这种方法在分片时容易漏数据或重复取数需要记录上次的最大ID比如v_last_id : 0; LOOP v_sql : INSERT INTO DATA_OWNER. || v_table_name || SELECT * FROM (SELECT * FROM DATA_OWNER.SRC_TRADE WHERE TRADE_DATE :1 AND ID :last_id ORDER BY ID) WHERE ROWNUM 100000; EXECUTE IMMEDIATE v_sql USING v_cur_date, v_last_id; IF SQL%ROWCOUNT 0 THEN EXIT; END IF; COMMIT; -- 获取本批最大ID作为下一批起点 SELECT NVL(MAX(ID), v_last_id) INTO v_last_id FROM DATA_OWNER.SRC_TRADE WHERE TRADE_DATE v_cur_date AND ID v_last_id AND ROWNUM 1; END LOOP;这种写法相对安全而且每批独立提交。代价是每次都要重新排序如果源表在(TRADE_DATE, ID)上有索引性能问题不大。如果担心ROWNUM在半途数据新增时出现问题还可以在批处理前先锁定源表或者把该天数据先抽到一个临时表再从临时表分片插入目标表。4. 性能优化与参数调优4.1 主要瓶颈在哪里批量建表本身耗时不高真正的瓶颈在数据插入。插入慢的常见原因有目标表有大量索引每条记录插入时要同步维护索引。约束检查如主键、唯一键会增加开销。每次COMMIT太频繁产生大量 redo log。EXECUTE IMMEDIATE每次执行都需要硬解析尤其动态 SQL 拼接的表名不同可能导致无法复用执行计划。源表和目标表间存在类型转换隐性转换会让索引失效。参数方面主要关注SGA_TARGET、PGA_AGGREGATE_TARGET、DB_CACHE_SIZE和UNDO_TABLESPACE。但存储过程内部不能随意修改系统参数能做的更多是操作层面优化。4.2 Commit 的节奏COMMIT太频繁和太少都是问题。太频繁比如每次插入 1 行就提交会极大增加同步和日志写入开销太少比如一个事务灌几百万行undo 表空间必然告急。我的经验是单批次数据量控制在 5 万到 10 万行左右。既不会让事务过长也能让性能有基本保障。对于上面按“天”切表的场景如果每天数据量不到 10 万行直接一张表一个事务没问题。如果超过这个量就按ID分批。手动控制分批时可以在存储过程中加一个参数p_batch_size默认 100000v_sql : INSERT INTO DATA_OWNER. || v_table_name || SELECT * FROM (SELECT * FROM DATA_OWNER.SRC_TRADE WHERE TRADE_DATE :1 AND ID :2 ORDER BY ID) WHERE ROWNUM :3; EXECUTE IMMEDIATE v_sql USING v_cur_date, v_last_id, p_batch_size;这样灵活性更高。DBA 可以根据当前库的 redo 生成速度和磁盘 IO 调整批大小不用改代码。4.3 并行度控制Oracle 的并行执行可以在表级别或语句级别启用。比如ALTER SESSION ENABLE PARALLEL DML; INSERT /* PARALLEL(target_table, 4) */ INTO target_table SELECT * FROM source_table;但存储过程中直接加并行提示要小心资源竞争。批量插入本身就涉及多个表循环如果每个表都开启 4 个并行10 张表可能会把 CPU 打满。建议“小库不开并行大库只对最大表开并行”或者把并行度限制在 2 到 4。另外COMMIT之后并行 DML 的会话状态可能会重置导致后续并行提示失效。所以在循环内如果每批都提交需要每次循环都重新执行ALTER SESSION ENABLE PARALLEL DML或者用EXECUTE IMMEDIATE ALTER SESSION ENABLE PARALLEL DML。4.4 减少日志与索引维护插入前临时禁用目标表的索引和约束插完再重建这是性能杀手锏但也很危险。如果中途断电可能留下一堆无效索引。实际操作时我通常在测试环境验证后再用于生产。大致语法EXECUTE IMMEDIATE ALTER INDEX idx_name UNUSABLE; -- 批量插入 EXECUTE IMMEDIATE ALTER INDEX idx_name REBUILD ONLINE;如果目标表是新建的其实最开始根本没有索引插入完再统一建索引是最好的。还有一个提升性能的常用手段在插入前关闭表的日志记录也就是NOLOGGING。建表时可以直接加NOLOGGING或者ALTER TABLE table_name NOLOGGING;这样生成的 redo 会大幅度减少。但要注意如果后续数据库发生了介质恢复NOLOGGING操作涉及的数据块可能无法被完整恢复。建议在可重建的临时场景下使用。5. 实战踩坑与问题定位5.1 权限不足引发的诡异报错存储过程里EXECUTE IMMEDIATE CREATE TABLE ...时如果当前用户只有CONNECT角色没有RESOURCE角色往往在执行时提示ORA-01031: insufficient privileges这个问题很容易被忽略。因为你在 SQL 窗口里手动执行CREATE TABLE是成功的但编译好的存储过程默认使用定义者权限。如果执行存储过程的用户和建表用户不是同一个存储过程会被屏蔽掉对应的权限。解决办法是给过程加AUTHID CURRENT_USER或者给执行用户显式授予建表权限。CREATE OR REPLACE PROCEDURE PRC_CREATE_TABLES( p_start_date IN DATE, p_end_date IN DATE ) AUTHID CURRENT_USER IS ...另一个麻烦是目标表前缀如DATA_OWNER.TRADE_20250101如果执行用户不能访问DATA_OWNER在建表时会报“表或视图不存在”。用户必须拥有对DATA_OWNER的CREATE ANY TABLE权限或者干脆用DATA_OWNER账号来跑存储过程。真实生产环境通常不允许普通应用账号持有过多权限此时需要 DBA 单独开一个批处理账号只授予必要的建表、DML 权限。5.2 临时表空间不足批量插入时经常遇到ORA-01652: unable to extend temp segment by 128 in tablespace TEMP原因是排序、分组或索引创建占用了大量临时表空间。比如我用ORDER BY ID分片时整个结果集可能先在临时表空间排序如果临时表空间太小就会爆掉。解决方法是先查询SELECT * FROM SRC_TRADE WHERE TRADE_DATE :1 AND ID :2这时候如果源表在组合索引(TRADE_DATE, ID)上有索引ORDER BY ID可以走索引消除排序否则就要保证临时表空间足够大。更稳妥的做法是给临时表空间增加数据文件或者降低单批数据量减少单次排序的数据规模。如果源表非常庞大还可以先把当天的数据抽到中间临时表然后再从中间临时表往目标表搬。5.3 动态SQL与SQL%ROWCOUNT的坑SQL%ROWCOUNT只有在最近一条 DML 执行后才有效。如果中间查询了数据字典或者执行了别的语句它就会被覆盖。我刚接触时在循环里先SELECT COUNT(1)判断目标表是否存在然后再执行插入导致取到的ROWCOUNT是 0 或错误的数值。解决办法是执行完EXECUTE IMMEDIATE后立刻保存EXECUTE IMMEDIATE v_sql USING v_cur_date; v_rows : SQL%ROWCOUNT;不要在保存前插入其他语句。另外EXECUTE IMMEDIATE执行非查询语句时无法直接从绑定变量传回行数只能靠SQL%ROWCOUNT这个细节很容易被忽略。5.4 常见问题速查表我把这个项目里典型的问题整理成了一张表方便后续复用。现象可能原因解决方案ORA-00955 建表重名ALL_TABLES判断失效或权限看不全用USER_TABLES或确认是否有其他 schema 同名表ORA-01031 权限不足存储过程定义者权限不够增加AUTHID CURRENT_USER或授CREATE TABLEORA-01652 temp 不够排序或索引重建占满临时表空间扩容临时表空间降低 batch_size动态SQL执行计划差表名不同导致硬解析使用 bind 变量固定谓词表名必须提前拼接但尽量只变化日期范围ROWCOUNT 始终为0执行后又被其他语句覆盖执行后立刻保存到变量插入时 ORA-00001 重复键源数据重复或约束逻辑冲突插入前用MERGE或先DELETE重复键过程执行到一半失败事务未分批提交每批次完成检查后再COMMIT5.5 日志与断点续跑批处理最怕中途失败后从头再来。所以我额外设计了一个日志表BATCH_LOG和状态表BATCH_RUN_STATUS。每次建表成功、插入完成都会记录状态。下次执行时如果发现目标表已经存在且状态为DONE就跳过这张表SELECT STATUS INTO v_status FROM BATCH_RUN_STATUS WHERE TABLE_NAME v_table_name AND BATCH_ID v_batch_id; IF v_status DONE THEN CONTINUE; END IF;这个逻辑看似简单但很实用。尤其是生产环境重启、网络中断等异常情况可以直接用同一套存储过程续跑不用人工删表或清数据。状态表还可以用来做后续的数据质量核对。6. 扩展玩法从“批量”到“灵活调度”6.1 动态表名清单的几种配置方式上面讲的都是固定规则生成表名比如按日期生成。实际项目中表名规则往往更复杂比如“客户号 日期”“渠道 月份”。这时候建议把表名清单放在配置表里由上游 ETL 生成好后入库存储过程直接读配置表循环执行。这样改动表名规则时不需要重新编译存储过程只需要改数据。配置表示例CREATE TABLE TABLE_CONFIG ( RUN_DATE DATE, TARGET_TABLE_NAME VARCHAR2(64), SOURCE_FILTER VARCHAR2(4000), STATUS VARCHAR2(16) );存储过程读表时采用FOR ... IN ... LOOPFOR rec IN (SELECT TARGET_TABLE_NAME, SOURCE_FILTER FROM TABLE_CONFIG WHERE RUN_DATE p_run_date ORDER BY TARGET_TABLE_NAME) LOOP EXECUTE IMMEDIATE INSERT INTO || rec.TARGET_TABLE_NAME || SELECT * FROM || rec.SOURCE_FILTER; END LOOP;这样把“批量”玩成了“配置驱动”后续加表、减表都不动代码。6.2 插入与幂等性设计存储过程的幂等性是生产环境最重要的收敛点。第二次执行同一批日期时数据会不会重复插入前需不需要先清空目标表我实现了一个PRC_TRUNCATE_IF_EXISTS子过程BEGIN EXECUTE IMMEDIATE TRUNCATE TABLE || p_table_name; EXCEPTION WHEN OTHERS THEN IF SQLCODE ! -942 THEN RAISE; END IF; END;TRUNCATE 是 DDL无法回滚所以在执行前要确认业务允许“重跑覆盖”语义。如果源数据是按时间范围全量抽取重跑覆盖没问题如果源数据是增量追加那就必须用MERGE或先查重。这里还有一个值得注意的点如果目标表上有外键引用TRUNCATE 可能不被允许会报 ORA-02266。处理方式是把外键先 disable之后 enable。6.3 结合调度系统做定时批处理存储过程写好后可以包一层 Shell 脚本由 cron 或者调度平台触发。脚本只需要调用 SQL*Plussqlplus -s user/passsrv EOF EXEC PKG_BATCH_DATA.RUN_BATCH(TO_DATE(2025-01-01, YYYY-MM-DD)); EOF如果要把多个批次并行起来可以在存储过程里使用DBMS_SCHEDULER。比如每个表作为一个 JOB 提交由调度器控制并行度。需要注意并行 JOB 坏了不容易排查建议先用单线程跑通整体流程再根据资源情况决定是否逐步放开并行。我个人在实际操作中踩过一个坑在某实例上开启多个并行 JOB 同时建表由于大家都去查ALL_TABLES并且对同一张模板表做CREATE TABLE ... AS SELECT偶尔会触发字典锁竞争导致“enqueue”等待。后来我把建表阶段固定为单线程插入阶段再并行问题就消失了。6.4 后续还能怎么扩展这套存储过程框架继续扩展的方向很多支持分区表每张表按日期建分区插入时直接走分区。增加数据校验插入完成后对比源表和目标表的COUNT和SUM关键字段生成校验报告。动态索引管理结合DBA_INDEXES自动判断哪些索引需要重建。适配其他数据库把EXECUTE IMMEDIATE的语法抽象成公共接口方便迁移。不过这些都是锦上添花核心还是先把“批量建表批量插入”的基础打牢再逐步添加外围能力。7. 最后再分享几个小经验很多初学者会把注意力放在“如何写出一条很长的存储过程”上但我更建议把存储过程拆成多个小模块每个模块只做一件事。比如建表过程、插入过程、日志记录过程分开后期维护会轻松得多。在实际开发中我一般会先在测试库跑一遍完整流程观察以下几点每张表插入的行数是否和源数据一致。单次提交耗时是否稳定。redo 日志增长是否异常。临时表空间使用率是否过高。如果某一步耗时异常优先看执行计划而不是盲目加并行。绝大多数性能问题都能从 SQL 是否走索引上找到答案。还有一个很容易被忽视的点存储过程里拼接的动态 SQL尽量少拼接、多用绑定变量。表名和 schema 名不能绑定但条件值一定要绑定。比如上面的WHERE TRADE_DATE :1就比WHERE TRADE_DATE TO_DATE(...)更有利于复用游标。我见过不少因为动态拼接导致每天硬解析、最终把共享池打爆的案例这些问题都很隐蔽但排查起来并不困难。回到这个项目本身Oracle 批量建表和批量插入存储过程的难点从来不在语法而在于对事务边界、权限模型、资源开销的管理。把这三个点控制好不管数据量是百万还是千万都能稳定运行。