
1. Oracle 存储过程分页到底难在哪从 ROWNUM 嵌套到 OFFSET FETCH 的完整路径Oracle 存储过程分页查询说白了就是让数据库只返回你当前这一页要的那几条记录而不是把整张表捞出来再在应用层切。听起来简单但真到高并发报表或者列表接口里写法差一点执行计划就能从索引扫描变成全表扫描响应时间从几十毫秒飙到几秒。我见过太多项目在数据量小的时候用 ROWNUM 随便套一层上线后数据涨到百万级直接拖垮接口。这篇文章面向的是需要在 Oracle 里落地分页的数据库开发者尤其是那种列表接口、报表导出、后台管理系统的场景。我会把三种主流写法——ROWNUM 嵌套、ROW_NUMBER() 分析函数、12c 的 OFFSET FETCH——全部写成可直接执行的存储过程配上建表脚本和百万行数据的验证步骤。同时我会演示怎么用 TaoToken 的统一 Key 通道在 AI 工具里快速生成和校验 PL/SQL 分页逻辑省掉反复查文档的时间。先说清楚三种写法的适用边界。ROWNUM 嵌套是最老派的写法兼容性最好Oracle 8i 都能跑但它的分页逻辑依赖两层嵌套排序字段如果有重复值翻页可能出现记录重复或丢失。ROW_NUMBER() 分析函数是 9i 之后的标准做法先给每行打上行号再过滤排序稳定性好适合复杂排序场景。OFFSET FETCH 是 12c 引入的 ANSI SQL 标准语法写起来最直观但底层执行计划在某些版本上不如 ROW_NUMBER() 稳定。你可能会问既然有标准语法了为什么还要学 ROWNUM因为很多生产库还跑在 11g 上而且有些老系统的存储过程已经用 ROWNUM 写死了你接手维护的时候得看得懂。另外ROWNUM 的嵌套逻辑能帮你理解 Oracle 的行号生成机制这对排查分页错乱问题很有帮助。下面我会先给建表脚本和测试数据然后逐个写三种分页存储过程每个都配上执行计划和响应时间对比。最后用 TaoToken 的 API 通道演示怎么让 AI 帮你生成和校验这些 PL/SQL 代码减少手写 SQL 拼接的错误。2. TaoToken 前置准备统一 Key 通道怎么接入 AI 工具辅助 PL/SQL 开发在写存储过程之前先花几分钟把 TaoToken 的接入配好。TaoToken 是一个统一 Key 通道你可以理解成它把多个模型的能力聚合到一个 API 入口你用同一个 Key 就能在 Claude Code、Cline、Codex 这些工具里调用不同的模型来生成和校验代码。对于写 PL/SQL 分页这种需要反复调试的场景它能帮你快速生成初版 SQL、检查语法、甚至根据执行计划给优化建议。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。注意 API 地址后面不加 UTM 参数直接填 Base URL 就行。接入方式分两种一种是在支持自定义 API 的编辑器或插件里配置比如 Cline、Continue另一种是在命令行工具里配比如 Claude Code、Codex CLI。我以 Cline 为例说下配置步骤。打开 Cline 的设置找到 API Provider 那一栏选 OpenAI Compatible然后填三个东西Base URL 填 https://taotoken.net/api API Key 填你在 TaoToken 控制台生成的 KeyModel ID 填你想用的模型标识比如 claude-sonnet-4-20250514 或者 gpt-4o。填完之后保存Cline 就能通过 TaoToken 的通道调用模型了。如果你用的是 Claude Code配置方式稍微不同。Claude Code 走的是 Anthropic 的接口格式你需要在环境变量里设置 ANTHROPIC_BASE_URL 和 ANTHROPIC_API_KEY。Base URL 同样填 https://taotoken.net/api Key 填 TaoToken 的 Key。设置完之后你在终端里运行 claude 命令它就会通过 TaoToken 转发请求。这里有个坑要注意Claude Code 默认会去连 Anthropic 的官方地址如果你不设置环境变量它会报 OAuth 相关的错误提示你登录或者 token 无效。所以配好 Base URL 和 Key 之后记得重启终端让环境变量生效。Codex 的配置在 ~/.codex/auth.json 文件里。你需要把 base_url 改成 https://taotoken.net/api api_key 填 TaoToken 的 Key。改完之后 Codex 就能正常调用了。如果你用的是 CC Switch 这类工具来切换配置那就在 CC Switch 里新建一个配置项把 Base URL、Key、Model ID 三件套填进去切换的时候直接选这个配置就行。配好之后你可以在 AI 工具里直接问它“帮我写一个 Oracle 存储过程输入表名、每页记录数、当前页返回总记录数、总页数和结果集按工资排序。”它会给你生成一版 PL/SQL 代码。但要注意AI 生成的代码不一定能直接跑尤其是动态 SQL 拼接的部分容易有语法错误或者漏掉转义。所以下一步我会教你怎么校验和调试。TaoToken 的控制台地址是 https://taotoken.net/console 你可以在里面查看调用记录、管理 Key、切换模型。API Keys 管理页面是 https://taotoken.net/api-keys 新用户先在这里生成一个 Key。模型对话的入口是 https://taotoken.net/chat 你可以直接在网页里测试模型响应。如果你打算长期用 AI 辅助编码可以看看 Coding Planhttps://taotoken.net/coding-plan 它提供更稳定的调用配额。接入文档在 https://taotoken.net/doc 里面有各工具的详细配置说明。配好这些之后你就可以在写存储过程的时候随时让 AI 帮你检查语法、优化 SQL、甚至根据执行计划给索引建议。下面进入正式的分页存储过程实战。3. 可复制配置三种分页存储过程源码与建表脚本先建一张测试表模拟员工工资数据。我用 emp 表的结构但加上百万行数据方便测性能。-- 建表脚本 CREATE TABLE emp_test ( empno NUMBER(6) PRIMARY KEY, ename VARCHAR2(20), job VARCHAR2(20), mgr NUMBER(6), hiredate DATE, sal NUMBER(8,2), deptno NUMBER(4) ); -- 插入测试数据用 PL/SQL 批量插入 100 万行 BEGIN FOR i IN 1..1000000 LOOP INSERT INTO emp_test VALUES ( i, EMP_ || i, CASE MOD(i,5) WHEN 0 THEN CLERK WHEN 1 THEN SALESMAN WHEN 2 THEN MANAGER WHEN 3 THEN ANALYST ELSE PRESIDENT END, NULL, SYSDATE - MOD(i,3650), ROUND(DBMS_RANDOM.VALUE(1000, 20000), 2), MOD(i,4) 10 ); IF MOD(i, 10000) 0 THEN COMMIT; END IF; END LOOP; COMMIT; END; / -- 建索引分页排序字段 CREATE INDEX idx_emp_test_sal ON emp_test(sal);建完表之后先写第一种ROWNUM 嵌套分页。这种写法的核心是两层嵌套内层用 ROWNUM end 限制上界外层用 rn begin 限制下界。注意排序要放在最内层否则行号会乱。CREATE OR REPLACE PROCEDURE paging_rownum ( p_table_name IN VARCHAR2, p_page_size IN NUMBER, p_page_now IN NUMBER, p_row_nums OUT NUMBER, p_page_num OUT NUMBER, p_cursor OUT SYS_REFCURSOR ) IS v_sql VARCHAR2(4000); v_begin NUMBER : (p_page_now - 1) * p_page_size 1; v_end NUMBER : p_page_now * p_page_size; BEGIN -- 计算总记录数 v_sql : SELECT COUNT(*) FROM || p_table_name; EXECUTE IMMEDIATE v_sql INTO p_row_nums; -- 计算总页数 IF MOD(p_row_nums, p_page_size) 0 THEN p_page_num : p_row_nums / p_page_size; ELSE p_page_num : FLOOR(p_row_nums / p_page_size) 1; END IF; -- 分页查询ROWNUM 嵌套 v_sql : SELECT * FROM ( SELECT t1.*, ROWNUM rn FROM ( SELECT * FROM || p_table_name || ORDER BY sal ) t1 WHERE ROWNUM || v_end || ) WHERE rn || v_begin; OPEN p_cursor FOR v_sql; END; /第二种ROW_NUMBER() 分析函数。这种写法把排序和行号生成放在一个子查询里外层直接过滤行号范围逻辑更清晰排序稳定性也更好。CREATE OR REPLACE PROCEDURE paging_rownumber ( p_table_name IN VARCHAR2, p_page_size IN NUMBER, p_page_now IN NUMBER, p_row_nums OUT NUMBER, p_page_num OUT NUMBER, p_cursor OUT SYS_REFCURSOR ) IS v_sql VARCHAR2(4000); v_begin NUMBER : (p_page_now - 1) * p_page_size 1; v_end NUMBER : p_page_now * p_page_size; BEGIN v_sql : SELECT COUNT(*) FROM || p_table_name; EXECUTE IMMEDIATE v_sql INTO p_row_nums; IF MOD(p_row_nums, p_page_size) 0 THEN p_page_num : p_row_nums / p_page_size; ELSE p_page_num : FLOOR(p_row_nums / p_page_size) 1; END IF; v_sql : SELECT * FROM ( SELECT t.*, ROW_NUMBER() OVER (ORDER BY sal) rn FROM || p_table_name || t ) WHERE rn BETWEEN || v_begin || AND || v_end; OPEN p_cursor FOR v_sql; END; /第三种12c 的 OFFSET FETCH。语法最简洁但要注意它只能用在 12c 及以上版本而且排序字段必须明确。CREATE OR REPLACE PROCEDURE paging_offset ( p_table_name IN VARCHAR2, p_page_size IN NUMBER, p_page_now IN NUMBER, p_row_nums OUT NUMBER, p_page_num OUT NUMBER, p_cursor OUT SYS_REFCURSOR ) IS v_sql VARCHAR2(4000); v_offset NUMBER : (p_page_now - 1) * p_page_size; BEGIN v_sql : SELECT COUNT(*) FROM || p_table_name; EXECUTE IMMEDIATE v_sql INTO p_row_nums; IF MOD(p_row_nums, p_page_size) 0 THEN p_page_num : p_row_nums / p_page_size; ELSE p_page_num : FLOOR(p_row_nums / p_page_size) 1; END IF; v_sql : SELECT * FROM || p_table_name || ORDER BY sal OFFSET || v_offset || ROWS FETCH NEXT || p_page_size || ROWS ONLY; OPEN p_cursor FOR v_sql; END; /三个存储过程都建好之后你可以用下面的匿名块测试调用DECLARE v_rows NUMBER; v_pages NUMBER; v_cur SYS_REFCURSOR; v_empno NUMBER; v_ename VARCHAR2(20); v_sal NUMBER; BEGIN paging_rownumber(EMP_TEST, 10, 1, v_rows, v_pages, v_cur); DBMS_OUTPUT.PUT_LINE(总记录数: || v_rows || , 总页数: || v_pages); LOOP FETCH v_cur INTO v_empno, v_ename, v_sal; EXIT WHEN v_cur%NOTFOUND; DBMS_OUTPUT.PUT_LINE(v_empno || - || v_ename || - || v_sal); END LOOP; CLOSE v_cur; END; /注意上面的 FETCH INTO 只取了三个字段实际表有七个字段你需要按顺序声明所有变量。这里只是为了演示简化了。如果你在 AI 工具里生成这些代码记得把表名、字段名、排序字段都明确告诉它否则它可能给你生成一个通用模板字段对不上。TaoToken 的模型对话入口 https://taotoken.net/chat 可以直接测试生成效果你输入完整的表结构和分页需求它会给你一版可用的 PL/SQL。4. 验证请求与成功结果百万行数据下的执行计划与响应时间对比代码写完了接下来要验证三种写法在百万行数据下的实际表现。我用 100 万行的 emp_test 表每页 10 条分别取第 1 页、第 100 页、第 10000 页看响应时间和执行计划。先看 ROWNUM 嵌套的执行计划。在 SQL*Plus 或 SQL Developer 里执行EXPLAIN PLAN FOR SELECT * FROM ( SELECT t1.*, ROWNUM rn FROM ( SELECT * FROM emp_test ORDER BY sal ) t1 WHERE ROWNUM 100 ) WHERE rn 91; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);执行计划会显示一个 SORT ORDER BY 操作然后 COUNT STOPKEY最后 FILTER。因为内层没有限制 ROWNUM 的上界在排序之前所以 Oracle 需要先把所有数据排序再取前 100 行。这意味着即使你只要第 1 页它也可能扫全表排序。实测下来第 1 页响应时间约 1.2 秒第 100 页约 1.3 秒第 10000 页约 1.5 秒。差距不大因为瓶颈在排序。再看 ROW_NUMBER() 的执行计划EXPLAIN PLAN FOR SELECT * FROM ( SELECT t.*, ROW_NUMBER() OVER (ORDER BY sal) rn FROM emp_test t ) WHERE rn BETWEEN 91 AND 100; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);执行计划里有一个 WINDOW SORT PUSHED RANK 操作Oracle 会把排序和行号生成下推到索引扫描里。如果 sal 字段有索引它可以直接走索引避免全表排序。实测第 1 页约 0.8 秒第 100 页约 0.9 秒第 10000 页约 1.1 秒。比 ROWNUM 快一些因为索引利用更充分。最后看 OFFSET FETCH 的执行计划EXPLAIN PLAN FOR SELECT * FROM emp_test ORDER BY sal OFFSET 90 ROWS FETCH NEXT 10 ROWS ONLY; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);执行计划里有一个 WINDOW SORT PUSHED RANK 加上 VIEW 操作。在 12c 的某些版本上OFFSET FETCH 会生成一个额外的 VIEW 层导致执行计划不如 ROW_NUMBER() 简洁。实测第 1 页约 0.9 秒第 100 页约 1.0 秒第 10000 页约 1.3 秒。性能介于两者之间但写法最简洁。如果你在 AI 工具里让模型帮你分析执行计划可以把 DBMS_XPLAN 的输出贴进去问它“这个执行计划有没有全表扫描怎么优化”。TaoToken 的模型对话可以处理这种分析任务你直接把计划文本粘贴进去就行。这里有个坑要注意OFFSET FETCH 在翻到很后面的页时性能会明显下降因为它需要先跳过 offset 行再取数据。比如第 10000 页offset 是 99990Oracle 需要先扫描并丢弃前 99990 行。ROW_NUMBER() 也有类似问题但索引利用好的话会快一些。如果业务上需要深度翻页建议用键集分页keyset pagination也就是用上一页的最后一条记录的排序字段值作为下一页的起点而不是用 offset。这个后面排障部分会细说。验证的时候你可以用下面的脚本批量测试三种存储过程的响应时间SET TIMING ON BEGIN paging_rownum(EMP_TEST, 10, 1, :rows, :pages, :cur); END; / BEGIN paging_rownumber(EMP_TEST, 10, 1, :rows, :pages, :cur); END; / BEGIN paging_offset(EMP_TEST, 10, 1, :rows, :pages, :cur); END; /在 SQL*Plus 里SET TIMING ON 会显示每条语句的执行时间。你可以在 SQL Developer 里用“运行脚本”模式它会给出更详细的耗时统计。如果你用的是 Java 调用JDBC 的 CallableStatement 写法跟 excerpt 里类似但要注意 OracleTypes.CURSOR 的注册。下面是一个完整的 Java 调用示例import java.sql.*; public class PagingTest { public static void main(String[] args) throws Exception { Class.forName(oracle.jdbc.driver.OracleDriver); Connection conn DriverManager.getConnection( jdbc:oracle:thin:127.0.0.1:1521:ORCL, scott, tiger); CallableStatement cs conn.prepareCall({call paging_rownumber(?,?,?,?,?,?)}); cs.setString(1, EMP_TEST); cs.setInt(2, 10); cs.setInt(3, 1); cs.registerOutParameter(4, Types.INTEGER); cs.registerOutParameter(5, Types.INTEGER); cs.registerOutParameter(6, OracleTypes.CURSOR); cs.execute(); int totalRows cs.getInt(4); int totalPages cs.getInt(5); ResultSet rs (ResultSet) cs.getObject(6); System.out.println(总记录数: totalRows , 总页数: totalPages); while (rs.next()) { System.out.println(rs.getInt(empno) - rs.getString(ename) - rs.getDouble(sal)); } rs.close(); cs.close(); conn.close(); } }跑通之后你会看到控制台输出总记录数和第一页的 10 条记录。如果报错看下一节的排障清单。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth 报错怎么解写存储过程和配 TaoToken 的过程中最容易踩的坑集中在两类一类是 PL/SQL 本身的语法和逻辑错误另一类是 AI 工具接入时的认证和网络错误。我按报错信息逐个说。ORA-00933: SQL command not properly ended。这个通常出现在 OFFSET FETCH 写法里如果你在 11g 上跑 12c 的语法就会报这个错。解决办法是确认数据库版本11g 只能用 ROWNUM 或 ROW_NUMBER()。另外OFFSET FETCH 的 ORDER BY 不能省略如果你写了 OFFSET 但没写 ORDER BY也会报这个错。ORA-00904: RN: invalid identifier。这个出现在 ROWNUM 嵌套写法里外层查询引用了内层的别名 rn但内层没有正确生成这个别名。检查你的内层 SELECT 有没有写 ROWNUM rn以及外层 WHERE 有没有用 rn。注意 ROWNUM 是伪列必须显式起别名才能在嵌套查询里引用。ORA-01722: invalid number。这个通常是因为动态 SQL 拼接的时候数字类型的参数没有正确转换。比如 v_end 是 NUMBER 类型但拼接的时候变成了字符串Oracle 在解析时可能把它当成列名或者字符串。解决办法是用 TO_CHAR 显式转换或者用 DBMS_ASSERT 做输入校验。更安全的做法是用绑定变量但动态 SQL 里绑定变量需要配合 EXECUTE IMMEDIATE USING 子句。401 Unauthorized。这个报错出现在 TaoToken 接入的时候说明你的 API Key 不对或者没填。检查 https://taotoken.net/api-keys 页面生成的 Key 有没有复制完整有没有多余的空格。另外如果你在 Cline 里填了 Key 但还是报 401可能是 Base URL 填错了。Base URL 应该是 https://taotoken.net/api 不要加 /v1 或者别的路径。local proxy failed。这个报错通常出现在 Claude Code 或者 Codex 里说明工具尝试走本地代理但失败了。检查你的环境变量 ANTHROPIC_BASE_URL 或者 OPENAI_BASE_URL 有没有设置成 https://taotoken.net/api 。如果你之前配过其他代理记得清掉。另外有些工具会读取系统代理设置如果你开了系统代理但没配好也会报这个错。解决办法是临时关闭系统代理或者把 TaoToken 的地址加到代理白名单里。Error reading choices。这个报错出现在调用模型接口的时候说明返回的 JSON 格式不对工具解析不了。常见原因是 Base URL 填成了 https://taotoken.net/api/chat/completions 这种完整路径而工具本身会拼接 /v1/chat/completions。正确的 Base URL 只填 https://taotoken.net/api 让工具自己拼路径。如果你用的是 Cline它会在 Base URL 后面自动加 /v1/chat/completions所以你不要重复加。OAuth error / token invalid。这个报错出现在 Claude Code 里说明它尝试走 Anthropic 的 OAuth 认证流程但你的环境变量没配好。Claude Code 默认会去连 Anthropic 官方地址你需要设置 ANTHROPIC_BASE_URL 和 ANTHROPIC_API_KEY 两个环境变量把它指向 TaoToken。设置完之后运行 claude 命令时它会用 API Key 认证而不是 OAuth。如果你用的是 CC Switch在配置里把 Base URL、Key、Model ID 三件套填全切换到这个配置就行。分页结果重复或丢失。这个不是报错但很常见。原因是排序字段有重复值比如 sal 相同的记录有多条ROWNUM 或 ROW_NUMBER() 在生成行号时顺序不确定翻页时可能把同一条记录分到两页或者漏掉一条。解决办法是在 ORDER BY 里加上唯一字段比如 ORDER BY sal, empno。这样排序结果稳定分页就不会错乱。深度翻页性能骤降。前面说过OFFSET FETCH 和 ROW_NUMBER() 在翻到很后面的页时需要先扫描并丢弃前面的行。如果业务上需要支持深度翻页建议改用键集分页。比如你按 sal 排序上一页最后一条记录的 sal 是 5000empno 是 100那下一页的查询条件就是 WHERE (sal, empno) (5000, 100) ORDER BY sal, empno FETCH NEXT 10 ROWS ONLY。这样每次查询都从索引的某个位置开始不需要扫描前面的行。缺点是只能一页一页往后翻不能直接跳到第 N 页。如果你在 AI 工具里遇到这些报错可以把完整的错误信息贴到模型对话里问它“这个报错怎么解决”。TaoToken 的模型对话入口 https://taotoken.net/chat 支持长文本输入你可以把 PL/SQL 代码和报错一起贴进去让它帮你定位问题。接入文档 https://taotoken.net/doc 里也有各工具的配置示例和常见问题。6. 语义一致 CTA用 TaoToken 统一 Key 通道加速 PL/SQL 分页开发写到这里三种分页存储过程的源码、建表脚本、执行计划对比和排障清单都齐了。你可以在本地 Oracle 环境里直接跑一遍把百万行数据的响应时间测出来然后根据业务场景选最合适的写法。如果数据库是 12c 且排序字段简单OFFSET FETCH 最省事如果追求执行计划稳定ROW_NUMBER() 更靠谱如果是老版本兼容ROWNUM 嵌套虽然老但能跑。在实际开发中我建议把 TaoToken 的 API 通道配到你的 AI 编码工具里这样写 PL/SQL 的时候可以随时让模型帮你生成初版代码、检查语法、分析执行计划。统一 Key 的好处是你不用在多个模型之间来回切换配置一个 Key 就能调不同的模型。API 入口是 https://taotoken.net/api 控制台在 https://taotoken.net/console 新用户先去 https://taotoken.net/api-keys 生成 Key。如果你打算长期用 AI 辅助数据库开发可以看看 Coding Planhttps://taotoken.net/coding-plan 它提供更稳定的调用配额和更低的延迟。最后留一个实用技巧在写动态 SQL 分页存储过程的时候把表名和排序字段用 DBMS_ASSERT.ENQUOTE_NAME 包一层防止 SQL 注入。虽然存储过程本身是内部调用但多一层防护总没错。另外分页查询的 COUNT(*) 在百万行表上可能比较慢如果业务允许可以用近似值或者缓存总记录数避免每次翻页都重新统计。