1. 从一次数据迁移踩坑说起:两种游标循环到底差在哪
如果你正在做 Oracle 到其他库的迁移,或者维护一套跑了多年的 PL/SQL 批处理,大概率绕不开显式游标。open cursor loop fetch into和for in cursor loop这两种写法,表面看只是代码风格差异,实际在异常处理、资源释放、执行计划复用上完全是两回事。我见过太多迁移脚本因为混用这两种写法,导致游标泄漏、结果集少一行、或者%NOTFOUND判断失效。
先说结论:for in cursor loop是语法糖,Oracle 自动帮你做了OPEN、FETCH、EXIT WHEN %NOTFOUND、CLOSE四件事,代码短、不容易漏关游标;而open fetch into是手动挡,你需要自己声明变量、自己控制退出条件、自己保证CLOSE被执行。手动挡灵活,但每个环节都可能出错。
这篇面向数据库开发和迁移场景,交付可复制的游标声明、循环骨架、异常处理配置,并给出执行计划与结果集一致性的验证动作。适合已经会写基本 SQL、但想在迁移或重构时把游标逻辑写扎实的读者。下面所有代码都可以直接在 SQL*Plus 或 SQL Developer 里跑,我用的是 Oracle 19c 的HR示例 schema。
2. 前置准备:TaoToken 接入与 SQL 客户端环境
在开始写游标之前,先把执行环境理顺。我平时调试 PL/SQL 会用两种方式:一种是在本地 SQL 客户端里直接跑,另一种是通过 API 把生成的 SQL 或 PL/SQL 块发给模型做审查和改写。后者在迁移场景特别有用,因为不同数据库的游标语法差异大,让模型帮你做语法映射能省不少时间。
如果你也想用 API 方式做 SQL 审查,可以先去 TaoToken 拿一个 Key。地址是 https://taotoken.net/api ,注册后在控制台创建 API Key,接入文档在 https://taotoken.net/doc 。拿到 Key 之后,你可以把下面这段游标代码发给模型,让它帮你检查%NOTFOUND的位置是否正确、CLOSE是否在所有分支都被执行。
需要说明的是,TaoToken 在这里的角色是帮你做代码审查和语法迁移的辅助工具,不是替代你的数据库客户端。真正的执行、执行计划查看、结果集比对,还是要在 SQL*Plus 或 SQL Developer 里完成。另外,如果你长期要做 PL/SQL 迁移和批量改写,可以了解一下 Coding Plan,适合需要反复调用模型做代码审查的场景。
环境方面,你需要:
- Oracle 数据库 11g 及以上(
%ROWTYPE和FOR ... IN游标在 11g 都支持) - 有
HRschema 的读权限,或者换成你自己的表 DBMS_OUTPUT已启用,否则看不到输出
启用DBMS_OUTPUT的命令:
SET SERVEROUTPUT ON SIZE UNLIMITED;3. 可复制配置:两种游标循环的完整骨架
3.1 open cursor loop fetch into 手动挡写法
先看手动挡。核心是四步:声明游标、声明接收变量、OPEN、循环FETCH并判断%NOTFOUND、最后CLOSE。
DECLARE CURSOR emp_cur IS SELECT first_name, last_name, salary FROM hr.employees WHERE department_id = 50; v_first_name hr.employees.first_name%TYPE; v_last_name hr.employees.last_name%TYPE; v_salary hr.employees.salary%TYPE; v_count PLS_INTEGER := 0; BEGIN OPEN emp_cur; LOOP FETCH emp_cur INTO v_first_name, v_last_name, v_salary; EXIT WHEN emp_cur%NOTFOUND; v_count := v_count + 1; DBMS_OUTPUT.PUT_LINE( v_count || ': ' || v_first_name || ' ' || v_last_name || ' salary=' || v_salary ); END LOOP; CLOSE emp_cur; DBMS_OUTPUT.PUT_LINE('total rows = ' || v_count); EXCEPTION WHEN OTHERS THEN IF emp_cur%ISOPEN THEN CLOSE emp_cur; END IF; DBMS_OUTPUT.PUT_LINE('error: ' || SQLERRM); RAISE; END; /这里有几个关键点。第一,EXIT WHEN emp_cur%NOTFOUND必须放在FETCH之后、处理逻辑之前,否则最后一行会被漏掉或者多处理一次。第二,%NOTFOUND在FETCH之前是NULL,所以不能提前判断。第三,异常处理里用%ISOPEN判断游标是否还开着,避免重复CLOSE报ORA-01001。
如果你用%ROWTYPE接收整行,写法会更简洁:
DECLARE CURSOR emp_cur IS SELECT first_name, last_name, salary FROM hr.employees WHERE department_id = 50; v_emp emp_cur%ROWTYPE; BEGIN OPEN emp_cur; LOOP FETCH emp_cur INTO v_emp; EXIT WHEN emp_cur%NOTFOUND; DBMS_OUTPUT.PUT_LINE(v_emp.first_name || ' ' || v_emp.last_name); END LOOP; CLOSE emp_cur; END; /注意v_emp emp_cur%ROWTYPE这种写法,变量类型直接绑定游标的返回结构,迁移时如果改了SELECT列表,变量声明不用动,这是手动挡里比较省心的一个技巧。
3.2 for in cursor loop 自动挡写法
自动挡就短很多:
BEGIN FOR v_emp IN ( SELECT first_name, last_name, salary FROM hr.employees WHERE department_id = 50 ) LOOP DBMS_OUTPUT.PUT_LINE(v_emp.first_name || ' ' || v_emp.last_name); END LOOP; END; /FOR ... IN后面可以直接跟子查询,这叫匿名游标,不用提前声明。循环变量v_emp是隐式声明的%ROWTYPE,作用域只在循环体内。Oracle 自动处理OPEN、FETCH、EXIT WHEN %NOTFOUND、CLOSE,你不需要写任何一句。
如果你已经有声明好的游标,也可以直接FOR v_emp IN emp_cur LOOP,效果一样。两种写法在 11g 之后执行计划基本一致,优化器都会做游标共享。
3.3 两种写法的对照表
| 维度 | open fetch into | for in cursor loop |
|---|---|---|
| 游标变量声明 | 必须手动声明 | 隐式声明,无需声明 |
| OPEN/CLOSE | 必须手动写 | 自动完成 |
| %NOTFOUND 判断 | 必须手动写 | 自动完成 |
| 异常时游标释放 | 需手动%ISOPEN判断 | 自动释放 |
| 循环内修改游标 | 可以,灵活 | 不可以,游标已固定 |
| 代码行数 | 多 | 少 |
| 适合场景 | 需要精细控制、动态游标 | 常规遍历、迁移脚本 |
4. 验证请求与成功结果:执行计划与结果集一致性
写完游标只是第一步,迁移场景最怕的是两种写法结果不一致。下面给出三个验证动作。
4.1 结果集行数比对
先跑一个基准查询,拿到期望行数:
SELECT COUNT(*) FROM hr.employees WHERE department_id = 50;假设返回 45。然后分别跑两种游标写法,在循环里累加计数,最后输出total rows。两次输出必须都是 45。如果手动挡输出 44,大概率是EXIT WHEN位置写错了;如果输出 46,可能是FETCH写在了EXIT后面。
4.2 执行计划查看
在 SQL*Plus 里用EXPLAIN PLAN看游标对应的查询计划:
EXPLAIN PLAN FOR SELECT first_name, last_name, salary FROM hr.employees WHERE department_id = 50; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);两种游标写法对应的查询计划应该完全一样,都是对employees表的全表扫描或索引扫描。如果不一样,说明你在手动挡里加了额外的WHERE或者ORDER BY,需要对齐。
4.3 游标泄漏检查
跑完手动挡之后,查一下当前会话打开的游标:
SELECT sql_text, cursor_type FROM v$open_cursor WHERE sid = SYS_CONTEXT('USERENV', 'SID');如果看到emp_cur对应的 SQL 还在列表里,说明CLOSE没执行到。正常情况下,CLOSE之后这条记录应该消失。自动挡不需要检查,Oracle 保证循环结束就释放。
5. 本篇常见错排查
5.1 ORA-01001: invalid cursor
这个错基本出现在手动挡。原因通常是CLOSE执行了两次,或者OPEN之前就FETCH。检查你的异常处理块,如果WHEN OTHERS里写了CLOSE emp_cur,但正常流程也CLOSE了,异常触发时就会重复关闭。正确做法是用%ISOPEN判断:
IF emp_cur%ISOPEN THEN CLOSE emp_cur; END IF;5.2 结果集少一行
手动挡里EXIT WHEN emp_cur%NOTFOUND如果写在FETCH之前,第一行还没取就退出了。如果写在处理逻辑之后,最后一行处理完FETCH返回%NOTFOUND为TRUE,但那一行已经被处理过了,不会少。少一行通常是EXIT写在了FETCH和DBMS_OUTPUT之间,导致最后一行没输出。
5.3 for 循环里改游标变量报错
FOR v_emp IN emp_cur LOOP里的v_emp是只读的,你不能在循环体里给它赋值。如果你需要修改行数据,得用UPDATE ... WHERE CURRENT OF emp_cur,但前提是游标声明时带了FOR UPDATE。自动挡不支持WHERE CURRENT OF,这是它相比手动挡的一个硬限制。
5.4 迁移到其他数据库时语法不兼容
FOR ... IN子查询这种写法在 PostgreSQL 里对应FOR rec IN SELECT ... LOOP,在 MySQL 里没有直接对应,需要改成DECLARE ... CURSOR ... HANDLER。如果你在做跨库迁移,建议先用 API 把 PL/SQL 块发给模型做语法映射,接入文档在 https://taotoken.net/doc ,模型对话入口在 https://taotoken.net/chat 。把两种写法的代码贴进去,让它输出目标库的等价写法,比手动查文档快很多。
6. 按场景选型与后续动作
选型其实很简单。如果你只是遍历一个固定查询的结果集,不需要在循环里动态改游标,直接用FOR ... IN子查询,代码短、不容易漏CLOSE、迁移时也好看。如果你需要FOR UPDATE加WHERE CURRENT OF做行级更新,或者需要在循环中途根据条件重新OPEN游标,那就用手动挡,但务必把%ISOPEN判断和异常处理写全。
迁移场景还有一个坑:老代码里经常用open fetch into配合%ROWCOUNT做分批提交。%ROWCOUNT在自动挡里也能用,但语义是当前循环已处理的行数,不是游标总行数。如果你要每 1000 行COMMIT一次,两种写法都可以:
BEGIN FOR v_emp IN (SELECT * FROM hr.employees WHERE department_id = 50) LOOP -- 处理逻辑 IF MOD(v_emp.rn, 1000) = 0 THEN COMMIT; END IF; END LOOP; COMMIT; END; /注意自动挡里没有rn这个列,你需要自己在子查询里加ROWNUM或者在循环里用计数器变量。手动挡直接用emp_cur%ROWCOUNT就行,这是它更方便的地方。
最后给一个实操建议:迁移前先把两种写法各跑一遍,用v$open_cursor确认没有泄漏,用COUNT(*)确认行数一致,用EXPLAIN PLAN确认计划一致。三个验证都过了,再往生产脚本里合。如果你需要批量审查迁移脚本里的游标写法,可以把脚本拆成小块发给模型做静态检查,API Key 在 https://taotoken.net/api-keys 创建,配合 Coding Plan 做长期迁移项目会更顺。