1. 逐行 FETCH 到底慢在哪:一次薪资批处理的真实卡顿
先明确一件事:BULK COLLECT是 Oracle PL/SQL 里的批量采集语法,它能把查询结果一次性装进集合(collection)变量,而不是让游标一行一行地FETCH。它适合谁?适合所有在 PL/SQL 里写循环处理数据的开发者,尤其是做薪资核算、对账、批量更新这类动辄几万行的场景。核心检索词就三个:Oracle、BULK COLLECT、批量 DML 提速。
我手上有个很典型的场景:某公司每月要给 5 万名员工做薪资调整,逻辑是查出员工当前薪资,按部门系数乘一遍,再写回表里。最初的写法是显式游标加逐行FETCH,然后每行执行一次UPDATE。跑一次要 6 分多钟,DBA 看着 AWR 报告直摇头。
问题出在上下文切换上。PL/SQL 引擎和 SQL 引擎是两个独立的执行环境,逐行FETCH意味着每取一行就要在两者之间来回切一次;逐行UPDATE更狠,每行都要重新解析、执行、提交一次。5 万行就是 5 万次来回,开销全耗在切换和网络往返上,真正干活的时间反而很少。
BULK COLLECT的思路是把「一行一行搬」改成「一车一车拉」。它一次把一批行读进内存里的集合,PL/SQL 引擎在内存里处理完,再用FORALL一次性把 DML 发给 SQL 引擎。上下文切换从 5 万次降到几十次,速度自然就上来了。
这里有个容易踩的坑:BULK COLLECT不是无脑全量拉。如果一次性把几百万行全塞进集合,PGA 内存会被撑爆,Oracle 反而会把集合溢写到临时表空间,效率比逐行还差。所以实战里几乎都会配LIMIT分批,比如每批 1000 或 5000 行,取一批、处理一批、写一批,内存和速度都稳。
下面这篇就按「先搭调试环境、再写可复制代码、然后验证耗时、最后排错」的顺序走。调试环境这块我用 TaoToken 统一管理数据库连接和模型调用的 Key,省得在多个工具之间来回切配置。你如果只是本地跑 SQL,环境部分可以跳过,直接看第 3 节的代码。
2. 用 TaoToken 搭一套可复用的 PL/SQL 调试环境
写 PL/SQL 最烦的不是语法,是环境。SQL Developer、VS Code 插件、命令行 sqlplus 各有一套连接配置,密码散落在不同地方,换台机器就得重配一遍。我现在的做法是用 TaoToken 做统一的 Key 和接入管理,把数据库调试相关的调用收敛到一个入口。
TaoToken 的定位是统一的模型与工具接入层,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。它本身不替代你的数据库客户端,而是帮你把「调用哪个模型来辅助写 SQL、审查执行计划、生成测试数据」这件事的鉴权统一掉。你可以在控制台里建 Key,然后让编辑器插件、命令行工具共用同一个 Key。
具体操作分三步。第一步,打开控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,登录后进 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,新建一个 Key。建议按用途命名,比如plsql-debug,方便后面区分。
第二步,把 Key 写进你的工具配置。如果你用 VS Code 配合 AI 辅助写 SQL,可以在 settings.json 里配一个统一的 Base URL 和 Key。注意 Base URL 用 https://taotoken.net/api ,不要带 UTM 参数,那是给网页跳转用的,API 调用带上反而可能出问题。
{ "taotoken.baseUrl": "https://taotoken.net/api", "taotoken.apiKey": "sk-你的Key", "taotoken.defaultModel": "claude-sonnet-4-5", "taotoken.timeout": 60000 }第三步,验证连通性。用 curl 发一个最小请求,确认 Key 和 Base URL 都对:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "写一句 Oracle BULK COLLECT 的示例"}] }'返回里有choices数组就说明通了。这一步很关键,因为后面写复杂 PL/SQL 时,我会让模型帮我审查FORALL的索引边界,如果 Key 没配好,调试链路就断了。
如果你更习惯在命令行里干活,TaoToken 也支持 Claude Code 这类编码 Agent 接入,文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。把 Base URL 和 Key 填进去,就能在终端里直接让它帮你生成测试表和批量数据。数据库连接本身还是走你自己的 sqlplus 或 SQL Developer,TaoToken 管的是「辅助编码」这一层,两者不冲突。
环境搭好后,建议先建一张测试表,别直接在生产表上试。下面这段建表语句你可以直接跑:
CREATE TABLE emp_salary_test AS SELECT employee_id, last_name, department_id, salary FROM employees WHERE 1=0; INSERT INTO emp_salary_test SELECT employee_id, last_name, department_id, salary FROM employees; COMMIT;有了这张表,后面的批量采集和批量更新都能安全地反复测试。
3. 可复制的 BULK COLLECT + FORALL 完整写法
这一节是核心,给你三段能直接跑的代码:批量采集、批量更新、以及带 LIMIT 的分批处理。每段都标了语言,路径和参数按你实际环境改。
先看最基础的批量采集。用BULK COLLECT INTO把部门 10 的员工薪资一次拉进集合:
SET SERVEROUTPUT ON SIZE UNLIMITED DECLARE TYPE sal_list IS TABLE OF emp_salary_test.salary%TYPE; v_sals sal_list; BEGIN SELECT salary BULK COLLECT INTO v_sals FROM emp_salary_test WHERE department_id = 10; DBMS_OUTPUT.PUT_LINE('采集行数: ' || v_sals.COUNT); FOR i IN 1 .. v_sals.COUNT LOOP DBMS_OUTPUT.PUT_LINE('第' || i || '行薪资: ' || v_sals(i)); END LOOP; END; /注意%TYPE的用法,它让集合元素类型自动跟表字段对齐,字段改了类型集合也跟着变,不用手动同步。v_sals.COUNT是集合当前元素个数,FIRST和LAST在稀疏集合里更安全,但这里连续填充用COUNT就够。
再看批量更新,这是提速最明显的场景。用FORALL把一批UPDATE一次性发给 SQL 引擎:
DECLARE TYPE id_list IS TABLE OF emp_salary_test.employee_id%TYPE; TYPE sal_list IS TABLE OF emp_salary_test.salary%TYPE; v_ids id_list; v_sals sal_list; CURSOR c_emp IS SELECT employee_id, salary FROM emp_salary_test WHERE department_id = 20; BEGIN OPEN c_emp; FETCH c_emp BULK COLLECT INTO v_ids, v_sals; CLOSE c_emp; FOR i IN 1 .. v_ids.COUNT LOOP v_sals(i) := v_sals(i) * 1.10; END LOOP; FORALL i IN 1 .. v_ids.COUNT UPDATE emp_salary_test SET salary = v_sals(i) WHERE employee_id = v_ids(i); DBMS_OUTPUT.PUT_LINE('更新行数: ' || SQL%ROWCOUNT); COMMIT; END; /FORALL的语法要点:它后面只能跟一条 DML,不能跟IF或LOOP嵌套;索引必须是连续区间,1 .. v_ids.COUNT这种写法最稳。SQL%ROWCOUNT在FORALL之后返回的是总影响行数,不是单条。
最后是生产环境最该用的分批版本。加LIMIT控制每批大小,避免 PGA 被撑爆:
DECLARE TYPE id_list IS TABLE OF emp_salary_test.employee_id%TYPE; TYPE sal_list IS TABLE OF emp_salary_test.salary%TYPE; v_ids id_list; v_sals sal_list; CURSOR c_emp IS SELECT employee_id, salary FROM emp_salary_test WHERE department_id = 30; v_batch PLS_INTEGER := 1000; v_total PLS_INTEGER := 0; BEGIN OPEN c_emp; LOOP FETCH c_emp BULK COLLECT INTO v_ids, v_sals LIMIT v_batch; EXIT WHEN v_ids.COUNT = 0; FOR i IN 1 .. v_ids.COUNT LOOP v_sals(i) := v_sals(i) * 1.05; END LOOP; FORALL i IN 1 .. v_ids.COUNT UPDATE emp_salary_test SET salary = v_sals(i) WHERE employee_id = v_ids(i); v_total := v_total + SQL%ROWCOUNT; COMMIT; END LOOP; CLOSE c_emp; DBMS_OUTPUT.PUT_LINE('累计更新: ' || v_total); END; /LIMIT 1000是经验值,PGA 小的库可以降到 500,内存充裕的可以到 5000。判断标准是看v$process里 PGA 使用量有没有异常飙升。分批提交还有个好处:万一中途报错,已提交的批次不会回滚,重跑时可以从断点继续。
4. 验证请求与耗时对比:从 6 分钟到 40 秒
代码写完必须验证,不然不知道提速到底有多少。我用同一张 5 万行的表,分别跑逐行版本和批量版本,记录耗时。
先跑逐行版本,用DBMS_UTILITY.GET_TIME打时间戳:
DECLARE v_start PLS_INTEGER; v_end PLS_INTEGER; CURSOR c_emp IS SELECT employee_id, salary FROM emp_salary_test; v_id emp_salary_test.employee_id%TYPE; v_sal emp_salary_test.salary%TYPE; BEGIN v_start := DBMS_UTILITY.GET_TIME; OPEN c_emp; LOOP FETCH c_emp INTO v_id, v_sal; EXIT WHEN c_emp%NOTFOUND; UPDATE emp_salary_test SET salary = v_sal * 1.01 WHERE employee_id = v_id; END LOOP; CLOSE c_emp; COMMIT; v_end := DBMS_UTILITY.GET_TIME; DBMS_OUTPUT.PUT_LINE('逐行耗时(厘秒): ' || (v_end - v_start)); END; /GET_TIME返回的是厘秒(1/100 秒),所以结果除以 100 才是秒。实测逐行版本在测试库上跑了约 36000 厘秒,也就是 360 秒,6 分钟。
再跑批量版本,同样的表、同样的更新逻辑:
DECLARE v_start PLS_INTEGER; v_end PLS_INTEGER; TYPE id_list IS TABLE OF emp_salary_test.employee_id%TYPE; TYPE sal_list IS TABLE OF emp_salary_test.salary%TYPE; v_ids id_list; v_sals sal_list; CURSOR c_emp IS SELECT employee_id, salary FROM emp_salary_test; v_batch PLS_INTEGER := 1000; BEGIN v_start := DBMS_UTILITY.GET_TIME; OPEN c_emp; LOOP FETCH c_emp BULK COLLECT INTO v_ids, v_sals LIMIT v_batch; EXIT WHEN v_ids.COUNT = 0; FOR i IN 1 .. v_ids.COUNT LOOP v_sals(i) := v_sals(i) * 1.01; END LOOP; FORALL i IN 1 .. v_ids.COUNT UPDATE emp_salary_test SET salary = v_sals(i) WHERE employee_id = v_ids(i); COMMIT; END LOOP; CLOSE c_emp; v_end := DBMS_UTILITY.GET_TIME; DBMS_OUTPUT.PUT_LINE('批量耗时(厘秒): ' || (v_end - v_start)); END; /批量版本实测约 4000 厘秒,40 秒。提速接近 9 倍。这个倍数会随数据量和 PGA 配置浮动,但量级上的差距是稳定的。
执行计划也能看出区别。逐行版本在V$SQL里会看到同一条UPDATE被硬解析多次,FORALL版本则是一条 SQL 处理一批,EXECUTIONS次数从 5 万降到 50。你可以用下面这句查:
SELECT sql_id, executions, elapsed_time/1000000 AS elapsed_sec FROM v$sql WHERE sql_text LIKE 'UPDATE emp_salary_test%' ORDER BY last_active_time DESC FETCH FIRST 5 ROWS ONLY;如果elapsed_sec明显下降、executions明显减少,说明批量生效了。这一步建议在测试库做,生产库查v$sql注意权限。
5. 常见报错排查:ORA-06550、401 与 local proxy failed
批量写法虽然快,但报错信息往往比逐行版本更绕。下面几个是我实际踩过的,按报错原文对照排查。
第一个高频错误是ORA-06550: line X, column Y: PLS-00382: expression is of wrong type。这通常出在BULK COLLECT INTO的变量类型和查询列不匹配。比如你SELECT employee_id, salary两列,但INTO后面只给了一个集合,或者集合元素类型是%TYPE但指向了错误的字段。解决办法是让集合类型严格对应列,用%TYPE或%ROWTYPE最省心。
第二个是ORA-06550: PLS-00436: implementation restriction: cannot reference fields of BULK In-BIND table of records。这个报错的意思是FORALL里不能直接引用记录集合的字段。比如你声明了TYPE t IS TABLE OF emp%ROWTYPE,然后在FORALL里写SET salary = v_t(i).salary,Oracle 不认。正确做法是把要用的列拆成独立的标量集合,像第 3 节那样用id_list和sal_list分开存。
第三个是ORA-01403: no data found。SELECT ... BULK COLLECT INTO在没查到数据时不会抛这个错,它只是把集合置空。但如果你在BULK COLLECT之后直接访问v_sals(1)而不判断COUNT,就会触发。养成习惯:BULK COLLECT之后先IF v_sals.COUNT > 0 THEN再进循环。
第四个是环境层面的401 Unauthorized。如果你在调试脚本里调用了 TaoToken 的 API 来生成测试数据,返回 401 说明 Key 无效或没带上。检查Authorization: Bearer sk-xxx头有没有写对,Key 有没有过期。控制台里可以重新生成。
第五个是local proxy failed或连接超时。这通常是 Base URL 写错了,比如把网页地址 https://taotoken.net/ 当成了 API 地址。API 必须用 https://taotoken.net/api ,两者路径不同。另外检查本地网络有没有拦截 HTTPS 出站,公司内网有时会拦。
第六个是OAuth token expired。如果你用 Claude Code 这类工具接入,OAuth 凭证有有效期,过期后重新走一次授权流程即可。文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里有说明。
排错时有个通用技巧:把FORALL换成普通FOR循环先跑通逻辑,确认集合填充没问题,再换回FORALL。这样能把「数据问题」和「语法问题」分开定位。
6. 把批量写法固化进你的日常调试链路
批量采集和批量 DML 的价值不在语法本身,而在于它改变了你处理数据的粒度。逐行思维是「取一行、算一行、写一行」,批量思维是「取一批、算一批、写一批」。这个转变在 5 万行级别能省下 80% 以上的时间,在百万行级别差距更夸张。
我的建议是把第 3 节的分批模板存成一个代码片段,下次写批量逻辑直接改表名和字段。LIMIT值先设 1000,跑一次看 PGA 和耗时,再往上调。FORALL后面永远只跟一条 DML,需要多条就拆成多个FORALL。
调试环境这块,TaoToken 的 Key 和 Base URL 配一次就能在多个工具里复用,省去反复填密码的麻烦。模型对话入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,写复杂 PL/SQL 时可以让它帮你审查索引边界;长期做编码和 Agent 任务的话,Coding Plan 在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。接入文档统一在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,API Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。
最后留一个实操建议:每次改完批量逻辑,别只看「跑通了」,一定用GET_TIME打一次耗时,跟逐行版本对比。数字不会骗人,9 倍和 1.2 倍是两种完全不同的优化效果。