1. Oracle 存储过程分页到底难在哪:从 ROWNUM 到 OFFSET FETCH 的慢 SQL 现场
Oracle 存储过程分页,说白了就是「在数据库里把一张大表按页切出来,只返回当前页需要的几十条」。听起来简单,真正落到生产环境,问题往往出在三件事上:写法选错、排序字段没走索引、以及分页 SQL 拼错之后报错信息看不懂。这篇内容适合正在写 Oracle 存储过程、被分页查询拖慢响应、或者想搞清楚 ROWNUM、ROW_NUMBER、OFFSET FETCH 三种写法差异的后端和 DBA 同学。
我先把三种主流写法摆出来,你心里有个谱:
ROWNUM 双层嵌套是最经典的写法,兼容性最好,Oracle 8i 以上都能跑。它的逻辑是先用子查询把数据查出来并排序,外层用ROWNUM <= endRecord截断,再套一层用r >= startRecord取区间。注意这里有个坑:ROWNUM 是在结果集生成过程中逐行分配的,所以必须嵌套两层,否则ROWNUM > 1永远为空。
ROW_NUMBER() 分析函数写法更直观,ROW_NUMBER() OVER (ORDER BY col) AS rn先给每行编号,外层再按 rn 过滤。可读性好,但排序开销一样跑不掉,而且如果 ORDER BY 字段没有索引,全表排序照样慢。
OFFSET FETCH 是 Oracle 12c 之后才有的语法,OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY,写起来最像 MySQL 的 LIMIT。但要注意,它在深分页场景下并不会自动变快,本质还是扫描并丢弃前 N 行。
真正让分页变慢的,通常不是分页语法本身,而是「排序字段无索引 + 深分页 + 回表」。比如一张 500 万行的订单表,按create_time倒序取第 10000 页,每页 20 条,数据库需要先排序 500 万行,再丢弃前 20 万行,最后返回 20 条。这个代价是实打实的。
我在实际排查时,习惯先把存储过程里拼出来的 SQL 单独拿出来,用EXPLAIN PLAN看执行计划,确认是全表扫描还是索引范围扫描。但问题在于,很多团队的存储过程是动态拼 SQL,参数一多,你根本不知道线上那次慢查询到底拼成了什么样。这时候就需要一个统一的通道,把「调用存储过程」和「观察返回结果/报错」这两件事串起来,TaoToken 在这里的作用就是提供统一的 Key 和 API 通道,让你在验证分页结果一致性、复现报错时不用来回切换工具和账号。
下面我会先给一套可复制的分页存储过程模板,再讲怎么用执行计划对比三种写法,最后讲怎么通过 TaoToken 的接口通道去验证分页结果和定位报错。每一步都有完整命令和参数,你可以直接跟做。
2. TaoToken 前置准备:统一 Key 与 API 通道,为分页排查铺路
在开始写存储过程之前,先把 TaoToken 这条通道准备好。它的定位不是替代你的数据库客户端,而是给你一个统一的入口:当你需要调用模型对话来帮你分析执行计划、或者用 Coding Plan 跑一段脚本去批量验证分页结果时,不用每个工具单独配一套 Key。
你需要准备三样东西:Base URL、API Key、Model ID。这三件套在后面的配置片段里会反复出现,先记牢。
Base URL 用https://taotoken.net/api,注意这个地址不带任何查询参数。API Key 需要你去控制台创建,入口在https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite。创建的时候建议按用途命名,比如oracle-page-debug,方便后面区分。
Model ID 根据你的场景选。如果你只是想让模型帮你读执行计划、解释报错,用模型对话通道就行,入口在https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite。如果你要长期跑分页验证脚本、做 Agent 类的自动化排查,那更适合 Coding Plan,入口在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。
如果你用的是 Claude Code 这类工具,需要走 Anthropic 兼容通道,配置入口在https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面有各语言的调用示例。
这里给一个通用的配置片段,你可以直接复制到你的环境变量或者配置文件里。以 JSON 格式为例:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model_id": "你的模型ID", "timeout": 60 }如果你用的是 Codex 的auth.json,结构类似,把base_url和api_key填进去即可。注意base_url后面不要加/v1之类的后缀,保持https://taotoken.net/api原样。
为什么要先做这一步?因为分页排查的痛点在于「复现」。线上报了一个ORA-00933: SQL command not properly ended,你光看日志不知道拼出来的 SQL 长什么样。这时候你可以把存储过程的入参记录下来,通过 TaoToken 的通道调用模型,让它根据你的模板和参数反推可能的 SQL 拼接结果,再对照执行计划。这比你在 PL/SQL Developer 里手动拼参数快得多。
另外,验证分页结果一致性时,你可能需要对比「存储过程返回的第 N 页」和「直接查 SQL 的第 N 页」是否一致。这个对比脚本可以用 Coding Plan 跑,把两次结果做 diff。统一 Key 的好处是,你不用在多个工具之间复制粘贴 Key,也不会因为某个工具的额度用完而中断排查。
准备好这三件套之后,我们进入正题:写分页存储过程。
3. 可复制的分页存储过程模板:ROWNUM、ROW_NUMBER、OFFSET FETCH 三版对照
这一节给你三版存储过程模板,都是可以直接复制执行的。我会把包定义、存储过程主体、以及调用方式写全。你先建包,再建过程,最后测试。
3.1 包定义
三版共用同一个包,定义游标类型:
CREATE OR REPLACE PACKAGE page_query AS TYPE cur_query IS REF CURSOR; END page_query; /3.2 ROWNUM 双层嵌套版
这是兼容性最好的写法,适合 Oracle 11g 及以下:
CREATE OR REPLACE PROCEDURE pro_query_rownum ( p_tableName IN VARCHAR2, p_strWhere IN VARCHAR2, p_orderColumn IN VARCHAR2, p_orderStyle IN VARCHAR2, p_curPage IN OUT NUMBER, p_pageSize IN OUT NUMBER, p_totalRecords OUT NUMBER, p_totalPages OUT NUMBER, v_cur OUT page_query.cur_query ) IS v_sql VARCHAR2(4000) := ''; v_countSql VARCHAR2(4000) := ''; v_startRecord NUMBER; v_endRecord NUMBER; BEGIN -- 总记录数 v_countSql := 'SELECT COUNT(*) FROM ' || p_tableName || ' WHERE 1=1 '; IF p_strWhere IS NOT NULL AND p_strWhere <> '' THEN v_countSql := v_countSql || p_strWhere; END IF; EXECUTE IMMEDIATE v_countSql INTO p_totalRecords; -- 页大小校验 IF p_pageSize IS NULL OR p_pageSize <= 0 THEN p_pageSize := 20; END IF; -- 总页数 p_totalPages := CEIL(p_totalRecords / p_pageSize); -- 页码校验 IF p_curPage IS NULL OR p_curPage < 1 THEN p_curPage := 1; END IF; IF p_curPage > p_totalPages THEN p_curPage := p_totalPages; END IF; -- 计算区间 v_startRecord := (p_curPage - 1) * p_pageSize + 1; v_endRecord := p_curPage * p_pageSize; -- 分页 SQL v_sql := 'SELECT * FROM (' || ' SELECT A.*, ROWNUM r FROM (' || ' SELECT * FROM ' || p_tableName || ' WHERE 1=1 '; IF p_strWhere IS NOT NULL AND p_strWhere <> '' THEN v_sql := v_sql || p_strWhere; END IF; IF p_orderColumn IS NOT NULL AND p_orderColumn <> '' THEN v_sql := v_sql || ' ORDER BY ' || p_orderColumn || ' ' || p_orderStyle; END IF; v_sql := v_sql || ' ) A WHERE ROWNUM <= ' || v_endRecord || ') B WHERE r >= ' || v_startRecord; DBMS_OUTPUT.PUT_LINE('SQL: ' || v_sql); OPEN v_cur FOR v_sql; END pro_query_rownum; /注意几个细节:p_pageSize和p_curPage是IN OUT,过程内部会修正非法值。v_sql用VARCHAR2(4000),如果你的表名和条件特别长,可以调到 32767。DBMS_OUTPUT.PUT_LINE把拼出来的 SQL 打出来,方便你复制到执行计划里分析。
3.3 ROW_NUMBER 分析函数版
适合 Oracle 11g 及以上,可读性更好:
CREATE OR REPLACE PROCEDURE pro_query_rownumber ( p_tableName IN VARCHAR2, p_strWhere IN VARCHAR2, p_orderColumn IN VARCHAR2, p_orderStyle IN VARCHAR2, p_curPage IN OUT NUMBER, p_pageSize IN OUT NUMBER, p_totalRecords OUT NUMBER, p_totalPages OUT NUMBER, v_cur OUT page_query.cur_query ) IS v_sql VARCHAR2(4000) := ''; v_countSql VARCHAR2(4000) := ''; v_startRecord NUMBER; v_endRecord NUMBER; BEGIN v_countSql := 'SELECT COUNT(*) FROM ' || p_tableName || ' WHERE 1=1 '; IF p_strWhere IS NOT NULL AND p_strWhere <> '' THEN v_countSql := v_countSql || p_strWhere; END IF; EXECUTE IMMEDIATE v_countSql INTO p_totalRecords; IF p_pageSize IS NULL OR p_pageSize <= 0 THEN p_pageSize := 20; END IF; p_totalPages := CEIL(p_totalRecords / p_pageSize); IF p_curPage IS NULL OR p_curPage < 1 THEN p_curPage := 1; END IF; IF p_curPage > p_totalPages THEN p_curPage := p_totalPages; END IF; v_startRecord := (p_curPage - 1) * p_pageSize + 1; v_endRecord := p_curPage * p_pageSize; v_sql := 'SELECT * FROM (' || ' SELECT A.*, ROW_NUMBER() OVER (ORDER BY ' || NVL(p_orderColumn, 'NULL') || ' ' || NVL(p_orderStyle, 'ASC') || ') AS rn FROM ' || p_tableName || ' WHERE 1=1 '; IF p_strWhere IS NOT NULL AND p_strWhere <> '' THEN v_sql := v_sql || p_strWhere; END IF; v_sql := v_sql || ') WHERE rn BETWEEN ' || v_startRecord || ' AND ' || v_endRecord; DBMS_OUTPUT.PUT_LINE('SQL: ' || v_sql); OPEN v_cur FOR v_sql; END pro_query_rownumber; /这里ROW_NUMBER() OVER (ORDER BY ...)必须放在子查询里,外层才能用rn过滤。如果你把rn过滤放在同一层,Oracle 会报ORA-30483: window functions are not allowed here。
3.4 OFFSET FETCH 版
Oracle 12c 及以上:
CREATE OR REPLACE PROCEDURE pro_query_offset ( p_tableName IN VARCHAR2, p_strWhere IN VARCHAR2, p_orderColumn IN VARCHAR2, p_orderStyle IN VARCHAR2, p_curPage IN OUT NUMBER, p_pageSize IN OUT NUMBER, p_totalRecords OUT NUMBER, p_totalPages OUT NUMBER, v_cur OUT page_query.cur_query ) IS v_sql VARCHAR2(4000) := ''; v_countSql VARCHAR2(4000) := ''; v_offset NUMBER; BEGIN v_countSql := 'SELECT COUNT(*) FROM ' || p_tableName || ' WHERE 1=1 '; IF p_strWhere IS NOT NULL AND p_strWhere <> '' THEN v_countSql := v_countSql || p_strWhere; END IF; EXECUTE IMMEDIATE v_countSql INTO p_totalRecords; IF p_pageSize IS NULL OR p_pageSize <= 0 THEN p_pageSize := 20; END IF; p_totalPages := CEIL(p_totalRecords / p_pageSize); IF p_curPage IS NULL OR p_curPage < 1 THEN p_curPage := 1; END IF; IF p_curPage > p_totalPages THEN p_curPage := p_totalPages; END IF; v_offset := (p_curPage - 1) * p_pageSize; v_sql := 'SELECT * FROM ' || p_tableName || ' WHERE 1=1 '; IF p_strWhere IS NOT NULL AND p_strWhere <> '' THEN v_sql := v_sql || p_strWhere; END IF; IF p_orderColumn IS NOT NULL AND p_orderColumn <> '' THEN v_sql := v_sql || ' ORDER BY ' || p_orderColumn || ' ' || p_orderStyle; END IF; v_sql := v_sql || ' OFFSET ' || v_offset || ' ROWS FETCH NEXT ' || p_pageSize || ' ROWS ONLY'; DBMS_OUTPUT.PUT_LINE('SQL: ' || v_sql); OPEN v_cur FOR v_sql; END pro_query_offset; /OFFSET FETCH 的坑在于:如果ORDER BY字段不唯一,分页结果可能不稳定,同一页两次查询返回不同行。所以排序字段最好带上主键,比如ORDER BY create_time DESC, id DESC。
三版建好之后,你可以用下面的匿名块测试:
SET SERVEROUTPUT ON; DECLARE v_cur page_query.cur_query; v_page NUMBER := 1; v_size NUMBER := 10; v_total NUMBER; v_pages NUMBER; v_id NUMBER; v_name VARCHAR2(100); BEGIN pro_query_rownum('YOUR_TABLE', '', 'ID', 'ASC', v_page, v_size, v_total, v_pages, v_cur); DBMS_OUTPUT.PUT_LINE('total=' || v_total || ', pages=' || v_pages); LOOP FETCH v_cur INTO v_id, v_name; EXIT WHEN v_cur%NOTFOUND; DBMS_OUTPUT.PUT_LINE(v_id || ' - ' || v_name); END LOOP; CLOSE v_cur; END; /把YOUR_TABLE换成你的表名,列名按实际调整。跑通之后,我们进入执行计划对比。
4. 验证请求与成功结果:执行计划对比 + TaoToken 接口验证分页一致性
这一节分两步:先用EXPLAIN PLAN对比三种写法的执行计划,再用 TaoToken 的接口通道验证分页结果一致性。
4.1 执行计划对比
假设你有一张ORDERS表,CREATE_TIME上有索引,数据量 500 万行。分别对三种写法取第 1000 页,每页 20 条。
ROWNUM 版拼出来的 SQL:
SELECT * FROM ( SELECT A.*, ROWNUM r FROM ( SELECT * FROM ORDERS WHERE 1=1 ORDER BY CREATE_TIME DESC ) A WHERE ROWNUM <= 20000 ) B WHERE r >= 19981;ROW_NUMBER 版:
SELECT * FROM ( SELECT A.*, ROW_NUMBER() OVER (ORDER BY CREATE_TIME DESC) AS rn FROM ORDERS WHERE 1=1 ) WHERE rn BETWEEN 19981 AND 20000;OFFSET FETCH 版:
SELECT * FROM ORDERS WHERE 1=1 ORDER BY CREATE_TIME DESC OFFSET 19980 ROWS FETCH NEXT 20 ROWS ONLY;对每条 SQL 执行:
EXPLAIN PLAN FOR <你的SQL>; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);你会看到类似这样的输出(简化):
| Id | Operation | Name | Rows | | 0 | SELECT STATEMENT | | 20 | |* 1 | VIEW | | 20000 | |* 2 | COUNT STOPKEY | | | | 3 | VIEW | | 20000 | | 4 | TABLE ACCESS BY INDEX| ORDERS | 5000K| | 5 | INDEX FULL SCAN | IDX_CREATE | 5000K|关键看两点:COUNT STOPKEY表示 ROWNUM 截断生效,INDEX FULL SCAN表示走了索引。如果ORDER BY字段没索引,你会看到SORT ORDER BY,代价高很多。
三种写法在深分页时,执行计划差异不大,都是「索引扫描 + 丢弃前 N 行」。真正的优化手段是「延迟关联」:先用索引查出主键,再回表取数据。这个后面排障部分会讲。
4.2 TaoToken 接口验证分页一致性
执行计划只能告诉你「快不快」,不能告诉你「对不对」。分页结果一致性验证,是确认存储过程返回的第 N 页和直接查 SQL 的第 N 页是否完全一致。
你可以写一个 Python 脚本,通过 TaoToken 的接口通道调用模型,让它帮你生成对比逻辑,或者直接用 Coding Plan 跑对比脚本。这里给一个用requests调用的示例:
import requests import json BASE_URL = "https://taotoken.net/api" API_KEY = "sk-你的Key" MODEL_ID = "你的模型ID" headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" } payload = { "model": MODEL_ID, "messages": [ { "role": "user", "content": "我有两个分页查询结果集,A 是存储过程返回的,B 是直接 SQL 返回的。请帮我写一段 Python 代码对比两个列表是否完全一致,如果不一致,输出差异行的主键。" } ] } resp = requests.post(f"{BASE_URL}/chat/completions", headers=headers, json=payload, timeout=60) print(resp.json()["choices"][0]["message"]["content"])如果你用的是 Claude Code 的 Anthropic 兼容通道,请求体格式略有不同,参考接入文档里的示例。核心是三件套:Base URL 用https://taotoken.net/api,Key 用你创建的,Model ID 按场景选。
验证通过的标准是:存储过程返回的 20 条记录,和直接 SQL 返回的 20 条记录,主键集合完全一致,顺序也一致。如果顺序不一致,检查ORDER BY字段是否唯一。如果记录数不一致,检查ROWNUM或rn的边界计算。
成功结果示例:
total=5000000, pages=250000 page 1000, size 20 存储过程返回主键: [19981, 19982, ..., 20000] 直接SQL返回主键: [19981, 19982, ..., 20000] 一致性校验: PASS到这里,分页存储过程模板、执行计划对比、结果一致性验证就串起来了。接下来讲排障。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 对照
这一节把分页排查和 TaoToken 调用过程中最容易遇到的报错列出来,对照真实错误信息给解法。
5.1 ORA-00933: SQL command not properly ended
这是分页 SQL 拼接最常见的错。原因通常是ORDER BY后面多了分号,或者OFFSET FETCH用在了 11g 上。检查你的v_sql最后有没有多余字符。如果是 11g,改用 ROWNUM 或 ROW_NUMBER 版。
5.2 ORA-00904: invalid identifier
通常是p_orderColumn传了不存在的列名,或者列名带了表别名但子查询里没暴露。比如你传A.CREATE_TIME,但子查询里表别名是ORDERS,就会报这个错。解法是排序字段只传列名,不带别名。
5.3 ORA-30483: window functions are not allowed here
ROW_NUMBER 版把rn过滤写在了同一层。必须嵌套子查询,外层再过滤。
5.4 401 Unauthorized
TaoToken 调用返回 401,说明 Key 不对或没带。检查Authorization头是不是Bearer sk-xxx格式,Key 有没有复制完整。如果 Key 刚创建,等几秒再试。控制台入口在https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite。
5.5 local proxy failed
这个报错通常出现在你本地网络环境有代理设置,但请求没走通。检查你的环境变量HTTP_PROXY、HTTPS_PROXY是否指向了不可用的地址。如果你在容器里跑,检查容器网络是否能访问外网。解法是清掉代理环境变量,或者确认网络策略允许访问taotoken.net。
5.6 reading choices 相关报错
如果你在解析响应时遇到reading 'choices'或choices is undefined,说明返回体不是预期的 OpenAI 格式。先打印完整响应:
print(resp.status_code) print(resp.text)常见原因是 Model ID 填错,或者请求路径少了/chat/completions。Base URL 是https://taotoken.net/api,完整路径是https://taotoken.net/api/chat/completions。
5.7 OAuth 相关报错
如果你用 Claude Code 走 Anthropic 通道,遇到 OAuth 报错,检查你的配置文件里base_url是不是写成了https://taotoken.net/api,而不是带/v1的地址。Anthropic 兼容通道的配置参考https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite。
5.8 分页结果重复或丢失
如果同一页两次查询结果不同,检查ORDER BY字段是否唯一。解法是加上主键,比如ORDER BY CREATE_TIME DESC, ID DESC。如果记录丢失,检查ROWNUM的r >= startRecord和r <= endRecord边界,startRecord应该是(page-1)*size+1,不是(page-1)*size。
5.9 深分页慢查询
第 10000 页慢,不是分页写法的问题,是「丢弃前 N 行」的代价。解法是延迟关联:
SELECT * FROM ORDERS O JOIN ( SELECT ID FROM ( SELECT ID, ROWNUM r FROM ( SELECT ID FROM ORDERS WHERE 1=1 ORDER BY CREATE_TIME DESC ) WHERE ROWNUM <= 200000 ) WHERE r >= 199981 ) T ON O.ID = T.ID ORDER BY O.CREATE_TIME DESC;先用索引查出主键区间,再回表取数据,减少回表次数。
排障的核心思路是:先看报错信息,定位是 SQL 拼接问题还是通道调用问题。SQL 问题看执行计划和拼接日志,通道问题看状态码和响应体。TaoToken 在这里的价值是给你一个统一的调用入口,让你在验证分页结果、复现报错时不用来回切换工具。
6. 长期编码与 Agent 排查:用 Coding Plan 把分页验证自动化
如果你只是偶尔排查一次分页问题,上面的步骤够用了。但如果你在团队里长期维护多个存储过程,每次改完都要手动验证分页一致性,那就值得把这件事自动化。
Coding Plan 适合这种场景:你写一个脚本,输入表名、排序字段、页码范围,自动跑三种分页写法,对比结果,输出差异报告。脚本通过 TaoToken 的通道调用模型,让模型帮你分析执行计划里的瓶颈,或者根据报错信息给出修复建议。
入口在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。配置还是那三件套:Base URL 用https://taotoken.net/api,Key 用你创建的,Model ID 选适合代码场景的。
一个实用的自动化流程是这样的:每次存储过程变更后,CI 里跑一个脚本,用EXPLAIN PLAN抓取执行计划,通过 TaoToken 让模型判断是否出现SORT ORDER BY或TABLE ACCESS FULL,如果有就告警。同时跑分页一致性校验,对比存储过程返回和直接 SQL 返回的主键集合。
这样你就不用每次改完都手动去 PL/SQL Developer 里点一遍。模型对话通道适合临时问问题,Coding Plan 适合把重复排查固化成流程。两者配合,分页问题的定位时间能从半小时压缩到几分钟。
最后给一个实用技巧:把DBMS_OUTPUT.PUT_LINE打出来的 SQL 存到一张日志表里,每次分页查询都记录入参和拼出的 SQL。出问题时直接查日志表,不用去猜参数。这张日志表加上 TaoToken 的通道,基本能覆盖分页排查的绝大多数场景。