凌晨两点四十,手机在床头柜上震动。我眯着眼摸过来一看,告警群里挂着一条:DWD_ACCOUNT_FLOW跑批任务执行超时,当前已运行 3 小时 26 分钟。这已经是本周第三次在深夜被同一张表的跑批叫醒了——问题出在一张累计上亿行的流水表上,每天夜间要把前一天产生的几千万增量数据清洗、去重、比对,再写入目标表,全量核对时还要按业务键做更新。最要命的是,这个写入过程在串行执行下根本跑不完,整夜都在跟时间赛跑。
后来我在金仓数据库(KingbaseES)上把这条批处理链路的写入环节改造成并行 DML,效果直接把同一份 1.2 亿行的加载任务,从原来 4 小时 17 分压到了 1 分 52 秒。这篇文章不聊大道理,就把那天从凌晨排查、定位、改写 SQL 到最终压测通过的全过程拆开讲清楚,包括哪些参数真正起了作用、哪些坑差点让我回滚,以及如果你也守着类似的批处理任务,可以从哪里开始下手。
1. 亿级流水表夜批的真实场景:问题到底卡在哪
1.1 跑批任务为什么永远排在凌晨
做数据开发或者库房运维的人都知道,跑批基本都在后半夜。原因很简单:白天业务在跑,在线交易、报表查询、接口调用全都要占用数据库资源。如果你这时候把一张大表锁住做全量更新,业务侧马上就会报“锁等待超时”,紧接着就是投诉、工单、甚至故障评审。
所以跑批天然被安排在凌晨的业务低峰窗口,理想情况是 2 点到 6 点之间把所有任务跑完。但问题在于,这个窗口本身非常紧。一旦某个环节从半小时飘到两小时,后面的任务全部顺延,早晨上班前如果还没跑完,白天整个数仓的指标数据就是脏的,领导要看报表时数据还没就绪,那就是事故。
我遇到的那个项目,核心流水表DWD_ACCOUNT_FLOW每天夜间要接收上游STG_ACCOUNT_FLOW推送的增量数据。这个增量不是几千几万条,而是高峰日几千万条,普通交易日也在千万条以上。周末因积压数据补推,单日增量能冲到 1.2 亿行。原来的处理逻辑:INSERT INTO ... SELECT ... FROM 源表,一行一行按业务键去重后塞进大表,整个跑批链路跑完要 4 个多小时。
1.2 定位瓶颈:把整条链路拆开看耗时
凌晨排查的第一件事不是急着优化 SQL,而是先搞明白时间到底消耗在哪里。很多人一上来就说“这条 SQL 太慢了”,但慢在哪里、是读慢还是写慢、是单条语句慢还是因为锁冲突被卡住,这些不搞清楚就动手,往往白忙活。
我当时把跑批任务拆成了四个阶段:源数据清洗、目标表写入、索引维护、事务提交。然后分别记录各阶段耗时,得到的结果是这样的:
| 阶段 | 处理内容 | 耗时 | 占比 |
|---|---|---|---|
| 1. 清洗阶段 | 去重、格式转换、类型转换 | 18 分钟 | 7% |
| 2. 目标表写入 | 单条 INSERT...SELECT 串行写入 1.2 亿行 | 2 小时 41 分钟 | 62% |
| 3. 索引维护 | 三个二级索引的同步更新 | 52 分钟 | 20% |
| 4. 事务提交 | commit 刷盘、释放锁 | 26 分钟 | 10% |
结论很清楚:大头全在写入阶段和提交阶段,这两个加起来超过 3 小时。清洗阶段虽然也不快,但相对于整体来说不是主要矛盾。而且清洗阶段本身也可以并行做,只不过当时的优先级没这么高。
1.3 为什么串行写入这么慢
这里需要理解一个基础问题:一条INSERT INTO ... SELECT语句在没有并行执行时,几乎只有一个进程在从头到尾干活。它要扫描源表、逐行做表达式计算、逐行检查目标表索引、逐行写入事务日志。即使服务器是 32 核 256G 内存,绝大多数核都在边上看着,只有一个核在累死累活。
用一个生活化的类比:抄写一本人名册。你让一个人抄,哪怕他手速再快,一万页也得抄到天亮。但如果你喊来二十个人,每个人分 500 页,同时开工,那物理上就是能快 20 倍。数据库并行 DML 干的就是这件事——把原本一个人干的活儿拆给多个并行服务进程一起干。
但这里有个关键前提:你的服务器得真的有多余的 CPU、IO 和内存资源可以用来并行,否则“并行”就变成了“大家一起排队”。
2. 金仓并行 DML 的工作逻辑:不是简单加个并发就完事
2.1 串行执行与并行执行的差别
金仓数据库(KingbaseES)的并行执行框架,兼容了业界的并行查询思路,很多 Hint 语法和会话机制对用过 Oracle 的人非常亲切。比如在并行 DML 场景下,它要求在会话里先打开一个开关:ALTER SESSION ENABLE PARALLEL DML;,然后用/*+ PARALLEL(n) */提示来指定并行度。不开那个开关,哪怕你在 SQL 里写了 hint,实际执行时 DML 也不会进入并行模式。
很多人栽的第一个跟头就在这。我一开始也以为只要在 SQL 里加个并行 hint 就够了,结果执行计划显示完全没有并行——后来翻文档才确认,金仓对于INSERT/UPDATE/DELETE/MERGE这类 DML 操作,默认会话是关闭并行能力的,必须显式开启。
串行执行时,执行计划通常是这样的逻辑:一个进程扫源表,然后一行一行往目标表里插。并行执行时,执行计划会变成多个并行服务进程同时扫描不同的数据分片,每个进程独立完成一部分行的写入,最后由一个协调进程负责汇总、排序、提交。判断有没有真正并行,最直接的办法是看执行计划里有没有出现Parallel Seq Scan、Parallel Insert这类算子,以及有没有Gather节点。
2.2 并行 DML 的核心环节:数据分片与结果合并
并行执行的本质,是把一个任务拆成多个子任务,让它们在不同进程里同时跑。以INSERT INTO 目标表 SELECT FROM 源表为例,金仓会先根据并行度把源表的数据范围分成若干份,每个并行服务进程负责一份。源表扫描并行、数据转换并行、目标表写入并行,最后协调进程统一收尾。
听起来挺美好,但代价也是明确的:并行度越高,协调开销越大,资源消耗越大,锁冲突可能性也越大。这里有个很常见的误区——并行度不是越大越好。很多人看到一个任务慢,直接把PARALLEL(32)写上,结果 CPU 被打满,IO 排队,最后比串行还慢。因为并行度超过物理核数之后,后续的并行进程全在排队抢 CPU,多出来的全是调度开销。
2.3 并行度 DOP 怎么选:资源换时间的权衡
并行度(DOP,Degree of Parallelism)的选择,核心考虑三个因素:服务器 CPU 核数、磁盘 IO 能力、以及跑批时段系统还有多少空闲资源。
我当时的服务器是 32 核 CPU,跑批时段基本空闲,所以先试了DOP=8,效果明显;然后逐步加到 16,也能继续提速;但加到 32 的时候耗时反而变长了,因为并行服务进程之间的上下文切换和内存排序开销超过了并行收益。最后稳定在DOP=16。
这里给一个经验公式:并行度先取 CPU 核数的一半试探,比如 32 核就先设 16,如果磁盘是 SSD 或 NVMe,可以放宽到与核数接近;如果是机械盘,并行度别超过磁盘盘的并发能力,否则 IO 就是新的瓶颈。
3. 实际优化过程:从 SQL 改写到底层参数调优
3.1 环境确认与版本检查
动手术之前先看体检报告。我连上金仓数据库,先确认版本和现有并行相关参数:
-- 查看数据库版本 SELECT version(); -- 查看并行最大服务进程数 SHOW parallel_max_servers; -- 查看当前数据库支持的并行进程数量 SHOW max_parallel_workers;当时的结果:数据库版本为 KingbaseES V8 系列,parallel_max_servers默认值是 8,max_parallel_workers也是 8。这个值明显不够支撑 1.2 亿行的并行写入——并行服务进程本身就需要多个,如果上限只有 8,即使你设了DOP=16,实际也只会并发 8 个。
这类参数属于实例级参数,修改后需要重启实例或至少 reload 配置。我当时是先在测试环境验证,再在维护窗口里调整生产库,避免白天直接改参数影响线上服务。
3.2 第一板斧:显式开启并行 DML
金仓的并行 DML 会话开关,官方写法是:
ALTER SESSION ENABLE PARALLEL DML;这个命令的作用范围是当前会话。我的建议是,不要在跑批的通用连接池里默认开启它,而是单独为跑批任务建一个专用会话,避免其他普通 SQL 误伤。
如果你不想每条 SQL 都靠 hint 指定并行度,还可以在表级别设置并行属性:
ALTER TABLE DWD_ACCOUNT_FLOW PARALLEL 16;表级别设置了并行度之后,凡是访问这张表的 DML 语句,在没有显式 hint 的情况下也会参考表级并行度。但如果语句里带了 hint,hint 优先。
3.3 第二板斧:SQL 改写与并行 Hint
改造前的原始 SQL 大致长这样:
INSERT INTO DWD_ACCOUNT_FLOW ( BIZ_DATE, ACCOUNT_NO, TXN_AMOUNT, TXN_TIME, STATUS ) SELECT BIZ_DATE, ACCOUNT_NO, TXN_AMOUNT, TXN_TIME, STATUS FROM STG_ACCOUNT_FLOW WHERE BIZ_DATE = TO_DATE('2024-06-30', 'YYYY-MM-DD');这条 SQL 在 1.2 亿行数据下跑了 2 小时 41 分钟。改造后变成:
ALTER SESSION ENABLE PARALLEL DML; INSERT /*+ PARALLEL(16) */ INTO DWD_ACCOUNT_FLOW ( BIZ_DATE, ACCOUNT_NO, TXN_AMOUNT, TXN_TIME, STATUS ) SELECT /*+ PARALLEL(STG_ACCOUNT_FLOW, 16) */ BIZ_DATE, ACCOUNT_NO, TXN_AMOUNT, TXN_TIME, STATUS FROM STG_ACCOUNT_FLOW WHERE BIZ_DATE = TO_DATE('2024-06-30', 'YYYY-MM-DD');这里有两个并行 hint:INSERT后面的PARALLEL(16)控制目标表写入的并行度;SELECT块里的PARALLEL(STG_ACCOUNT_FLOW, 16)控制源表扫描的并行度。两个并行度理论上可以分别设置,比如源表读多写少,读并行度可以高一点;目标表写入涉及索引维护,写并行度要控制得保守一点。
如果业务里需要一边插入一边更新重复记录,把INSERT INTO改成MERGE语句同样适用并行:
ALTER SESSION ENABLE PARALLEL DML; MERGE /*+ PARALLEL(16) */ INTO DWD_ACCOUNT_FLOW t USING STG_ACCOUNT_FLOW s ON (t.ACCOUNT_NO = s.ACCOUNT_NO AND t.BIZ_DATE = s.BIZ_DATE) WHEN MATCHED THEN UPDATE SET t.TXN_AMOUNT = s.TXN_AMOUNT, t.TXN_TIME = s.TXN_TIME, t.STATUS = s.STATUS WHEN NOT MATCHED THEN INSERT ( BIZ_DATE, ACCOUNT_NO, TXN_AMOUNT, TXN_TIME, STATUS ) VALUES ( s.BIZ_DATE, s.ACCOUNT_NO, s.TXN_AMOUNT, s.TXN_TIME, s.STATUS );改造后实测,同样的数据量,目标表写入阶段从 2 小时 41 分钟降到了 40 秒左右。加上索引维护和提交,整体从 4 小时 17 分压到了 1 分 52 秒。这个结果当时把我都看愣了。
3.4 第三板斧:关键参数校准
光改 SQL 还不够,几个隐藏参数如果不调,并行 DML 的效果会大打折扣。我整理了一张实际调过的参数表:
| 参数名 | 默认值 | 调整后 | 作用说明 |
|---|---|---|---|
parallel_max_servers | 8 | 32 | 整个实例最大并行服务进程数,并行 DML 的硬上限 |
max_parallel_workers | 8 | 32 | 与上面配套,控制并行 worker 总数 |
work_mem | 4MB | 64MB | 单个操作可用的内存,排序、hash join 都会用到 |
temp_buffers | 8MB | 128MB | 临时表缓存,并行排序时吃的是这里 |
ALTER SESSION ENABLE PARALLEL DML | 关闭 | 开启 | 会话级并行 DML 开关 |
目标表PARALLEL属性 | 无 | 16 | 表级默认并行度 |
这里特别提醒一句:work_mem调大确实能提升并行 sort/hash 的性能,但这是每个进程都有的,一旦并行度是 16,实际内存消耗就是work_mem × 16。如果你的服务器内存不够大,盲目调大work_mem反而会触发内存不足,甚至导致 OOM。我当时的 256G 内存,work_mem=64MB、16 并行进程,最大额外内存约 1G,这个量级完全没有问题。如果内存只有 32G,我会把work_mem控制在 16MB 以下。
还有一个参数值得注意,parallel_degree_policy。默认是LIMITED,表示只有带并行 hint 的语句才走并行;如果调成AUTO,数据库会自己判断是否该并行。但自动判断在跑批场景里不太可控,我建议保持默认或者显式用 hint 控制,避免白天业务 SQL 被数据库“自作主张”并行执行,抢占资源。
3.5 效果验证:实测数据对比
我专门做了一张改造前后的对比表,数据量都是同一天的 1.2 亿行,环境是 32 核 / 256G 内存 / SSD 盘:
| 指标 | 串行写入(改造前) | 并行 DML DOP=16(改造后) |
|---|---|---|
| 清洗阶段 | 18 分钟 | 4 分 31 秒 |
| 目标表写入 | 2 小时 41 分钟 | 41 秒 |
| 索引维护 | 52 分钟 | 22 秒 |
| 事务提交 | 26 分钟 | 18 秒 |
| 全链路总耗时 | 4 小时 17 分钟 | 1 分 52 秒(112 秒) |
注意一个细节:事务提交时间从 26 分钟降到 18 秒,不是因为 commit 本身变快了,而是因为并行写大幅缩短了事务内变更的距离,日志刷盘的批量效应更好了,锁持有时间也大幅缩短。这意味着不仅跑批快了,连带着对其它夜间任务的阻塞窗口也缩短了。
4. 实证中容易翻车的点:锁等待、临时空间与并行风暴
4.1 并行 DML 带来的锁等待变化
并行 DML 不是点一下开关就完事,它对锁的影响很微妙。并行写入时,多个服务进程同时操作同一张目标表,每一条索引、每一个数据页都可能发生竞争。最典型的现象是索引分裂时的瞬间锁等待——如果索引组织得不好,并行进程同时往同一个索引块插入数据,就会互相等待。
我遇到过一种情况:并行度从 8 提高到 16 时,写入阶段耗时反而从 35 秒涨到 58 秒,一看等待事件,全卡在索引块竞争上。原因是我目标表上的一个联合索引字段顺序设计得不好,业务字段作为索引前缀,导致大量并发进程竞争同一个索引块。
解决办法有两个方向:一是重新设计索引,把高并发的插入键值打散;二是绕开索引竞争,在导入前先禁用索引,数据写完后统一重建索引。第二种方案在亿级数据导入场景里非常常用,代价是重建索引本身也要时间,但重建索引可以并行执行,整体算下来往往比边插边维护索引更快。
4.2 临时表空间与回滚段的压力
并行 DML 的另一大隐形杀手是临时表空间和回滚段。大事务在更新过程中,数据库必须保存足够多的回滚信息,以便事务失败时回滚。1.2 亿行的写入,回滚段大小是几个 GB 起步。如果你的表空间分配不够,跑批跑到一半会直接报“无法扩展临时段”或回滚段不足的错误。
当时的处理是提前扩容临时表空间,并且把 undo 表空间单独扩大到足够容纳整批事务的回滚量。这里有个经验:与其让数据库在事务中间因为空间问题失败,不如提前做大空间,因为失败后的回滚时间比成功写入还要长。回滚 1.2 亿行的代价几乎是写入过程的 60%,一旦中途失败,那整晚就真的白干了。
4.3 并行风暴:多个任务同时并行时的资源抢占
还有一个在单任务上不明显、但多任务一起跑时非常致命的问题——并行风暴。你优化了任务 A,把它的并行度设为 16,任务 B 也跟着学,任务 C 也加并行,结果凌晨两点半一过,所有任务同时向数据库要并行服务进程,parallel_max_servers=32被瞬间打满。
这时候后面到达的并行任务并不会立刻失败,它们会进入等待状态,等待空闲的并行服务进程。你以为进入了并行,实际全在排队,甚至因为相互等待,整体跑批比串行还慢,还多出一堆协调进程的开销。
我的处理方式是给不同任务分配不同的并行度上限,重要任务设置高优先级,非核心任务限制并行度或干脆错峰执行。金仓的资源管理能力支持按用户或会话设置资源限额,虽然配置起来有一点成本,但晚上能救你很多次。
5. 多表联合场景下的组合用法
5.1 与分区表配合:多一层裁剪能力
并行 DML 解决了“大任务拆给多个进程”的问题,但如果配合分区表使用,还能再减少扫描范围,把 1.2 亿行的任务缩小到 1 千万行。
我当时的目标表DWD_ACCOUNT_FLOW按BIZ_DATE做了范围分区。跑批只处理某一天的数据,并行 DML 加分区裁剪之后,执行器会先通过分区裁剪过滤掉无关分区,再对剩余分区做并行写入。实际效果:分区裁剪把处理行数减少了 80%,配合并行度 16,全链路总耗时进一步压到了 1 分 40 秒以内。
如果你现在的表还没有分区,而且跑批维度里有明显的日期或业务范围字段,建议优先补上分区。分区不只是让查询更快,更重要的是让跑批任务的“工作集”变小,数据加载、删除老数据、重建索引都会轻松很多。
5.2 与索引维护配合:先禁用再并行重建
前面提到,亿级数据导入时边插边维护索引效率很低。更合理的顺序是:先ALTER INDEX ... UNUSABLE或直接 drop 索引,然后并行写入数据,最后再并行重建索引。
金仓里重建索引可以用并行:
ALTER INDEX IDX_ACCOUNT_FLOW_ACCT_NO REBUILD PARALLEL 8; ALTER INDEX IDX_ACCOUNT_FLOW_TX_TIME REBUILD PARALLEL 8;实测下来,三个二级索引的并行重建,全部加起来 22 秒完成。而如果让它们跟着 INSERT 一边插一边维护,光索引同步就要 52 分钟。这个差距非常直观。
这里有个细节要注意:重建索引期间,如果目标表还有并发查询,可能会阻塞。所以这个方案适合在夜间跑批窗口用,白天别这么搞。
5.3 分片写入:一条 SQL 并行之外的另一条路
除了让数据库内部的并行机制干活,还有一种更“笨”但更稳的办法:把数据分片,由多个会话同时提交多条 SQL,每个会话只处理一部分数据。
比如按照ACCOUNT_NO取模分 8 片,写 8 个并行会话,每个会话执行一条普通的 INSERT SQL,查询条件里加上分片条件。这种做法的好处是:不依赖数据库的并行 DML 开关,只要有连接池的多线程能力就能做;而且每条 SQL 的失败范围被隔离到单个分片,一个分片挂了不影响其它分片。
缺点也很明显:分片逻辑要自己写,数据倾斜时要手动调整分片键,而且多个会话并发提交时,如果目标表有约束冲突,处理起来更麻烦。我的建议是:优先用数据库自带的并行 DML,当它因为某些限制无法生效时(比如某些特殊语句结构不支持并行),再考虑外部会话分片写入。
6. 优化之后的运维习惯:可以放心睡但要有监控
6.1 新监控指标:从“任务超时”到“并行度使用率”
优化完成后,我养成了新的监控习惯。以前盯的是“跑批是否超时”,这种黑盒监控只能告诉你坏了没有,不能告诉你快了还是慢了、资源够不够。现在我会盯三个具体指标:
- 并行服务进程使用率:
parallel_max_servers当前用了多少,有没有接近上限。 - 关键会话的等待事件:有没有出现索引竞争、临时空间不足、锁等待。
- 跑批各阶段耗时分布:写入、索引重建、提交分别花了多少时间。
这三个指标能让你在问题发生之前就发现征兆。比如并行服务进程使用率长期维持在 80% 以上,说明你可能需要增加parallel_max_servers,或者错峰跑批;如果某个阶段的耗时突然翻倍,能立刻定位到是写入变慢还是索引重建变慢,而不是等到早晨被业务侧投诉。
6.2 自动化巡检的思路
优化不是一次性的事。我后来在跑批任务完成之后加了一个自动统计脚本,核心逻辑很简单:任务结束后查询系统视图,把各阶段耗时记录到日志表里,再对比前几天的历史数据,超过阈值就发一条告警。
伪代码思路如下:
#!/bin/bash # 跑批结束后,自动记录关键耗时并推送 BATCH_ID=$(date +%Y%m%d) psql -h 127.0.0.1 -U batch_user -d dwdb <<SQL INSERT INTO batch_daily_log(batch_id, stage, cost_seconds, ts) SELECT '$BATCH_ID', 'write_stage', extract(epoch FROM (now() - start_time)), now() FROM batch_run_info WHERE batch_id = '$BATCH_ID'; SQL # 对比历史,超过历史平均值 30% 则告警 psql -h 127.0.0.1 -U monitor_user -d dwdb -c " SELECT 'slow batch warning' AS alert WHERE (SELECT cost_seconds FROM batch_daily_log WHERE batch_id='$BATCH_ID') > (SELECT avg(cost_seconds) * 1.3 FROM batch_daily_log WHERE batch_id < '$BATCH_ID'); "这套东西不复杂,但它能让你从“熬夜守着跑批”升级为“早上起来看报表”,跑批任务自己会告诉你它好不好。
6.3 几个值得长期保留的实战习惯
第一,任何大表改造前,先在测试环境用 1/10 数据量压测一遍,确认并行度、内存参数、耗时都在合理范围内,再上生产。不要直接在生产上调参数试错,代价太高。
第二,跑批事务的隔离级别和提交策略一定要确认好。并行 DML 下的大事务,提交时产生的日志会集中刷盘,如果磁盘性能一般,提交瞬间会有一次明显的 IO 尖峰。必要时可以把一个大事务拆成按分区维度的多个小事务,每个分区单独提交,能显著降低提交时的瞬时压力。
第三,把关键 SQL 的执行计划固化下来。并行 DML 最怕的不是慢,而是某天计划突然变成串行,但你还不知道为什么变慢了。固定的执行计划能让你快速发现计划变化,避免晚上干瞪眼。
那次改造之后,我最大的感受是:很多时候我们总以为“亿级数据写入几个小时是正常的”,其实只是因为我们默认只用了一个 CPU 在干活。硬件买回来就是要用的,数据库的并行能力不是附加题,而是跑批场景下必须掌握的常规操作。下次再遇到凌晨被跑批问题叫醒,你至少会先看看执行计划里有没有出现Parallel字样——有,说明还有优化空间;没有,那才说明问题还没找到。