1. 分页存储过程为什么总在“最后一页”翻车
分页的存储过程,说白了就是把「查第 N 页、每页 M 条」这件事封装进数据库,让上层业务只传表名、页大小、页码三个参数,就能拿到一页数据和总页数。它适合谁?适合那些还在用 Oracle、SQL Server、MySQL 写业务系统,又不想把分页 SQL 散落在几十个 Java 文件里的团队。核心检索词就三个:存储过程、分页、结果集边界。
我见过太多分页存储过程在测试环境跑得好好的,一上生产就出问题。典型症状有三种:第一,翻到最后一页返回空结果集,但总页数明明显示还有;第二,页码传 0 或者负数时直接抛异常,调用方拿到一个没有上下文的错误;第三,总条数统计和实际返回的行数对不上,因为统计 SQL 和分页 SQL 用的过滤条件不一致。
这些问题的根子不在 SQL 写得多复杂,而在于「配置」和「调用」之间缺了一层统一的约定。存储过程本身是数据库里的逻辑,但它的参数从哪来、Key 怎么管、调用链怎么追踪,这些工程化的问题如果只靠硬编码,维护成本会指数级上升。所以这篇要做的,是把分页存储过程和一个统一的配置骨架绑在一起:用settings.json管住连接参数和通道信息,用 TaoToken 统一 Key/API 通道,让分页查询的每一次调用都可复现、可追踪。
下面我会先给出一份可以直接抄的settings.json骨架,再写一个带边界处理的分页存储过程,然后用 Python 和 Java 两种方式调用验证,最后把常见的翻车点一个个拆开。你跟着做,能拿到一个「页码越界不崩、总页数准确、调用可追踪」的分页方案。
2. TaoToken 前置:统一 Key 与 API 通道
在写存储过程之前,先把「通道」这件事说清楚。分页存储过程本身不关心 Key,但调用它的应用需要连数据库、需要调模型做 SQL 审核或者日志分析,这些外部调用如果每个服务各管一套 Key,很快就会乱。TaoToken 在这里的角色,是提供一个统一的 API 通道,把模型对话、编码辅助、Key 管理收敛到一个入口。
你需要先拿到一个可用的 API Key。操作路径是:访问官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,进入控制台,在 API Keys 页面创建一个新 Key。创建时建议按用途命名,比如proc-page-dev,这样后面在settings.json里引用时一眼能看出是给分页存储过程调试用的。
拿到 Key 之后,API 的基础地址是 https://taotoken.net/api ,注意这个地址不带任何查询参数,直接作为 base_url 使用。如果你用的是 OpenAI 兼容的 SDK,把base_url指向它,api_key填刚创建的值即可。对于长期跑编码任务或者 Agent 场景,可以了解 Coding Plan,它更适合需要持续调用、按周期计费的用法;如果只是临时验证模型输出,用模型对话页面就够了。
这里要强调一点:TaoToken 是统一的 API 通道,不是让你绕过数据库直连生产库。分页存储过程该在数据库里跑还是在数据库里跑,TaoToken 负责的是调用链上的 Key 管理和请求追踪。两者职责分开,后面排查问题才不会互相甩锅。
3. 可复制配置:settings.json 骨架与分页存储过程
3.1 settings.json 骨架
先给一份可以直接落地的settings.json。它的设计原则是:数据库连接、TaoToken 通道、分页默认参数三块分开,互不污染。你只需要替换host、service_name、user、password和api_key这几处。
{ "database": { "type": "oracle", "host": "127.0.0.1", "port": 1521, "service_name": "ORCLPDB1", "user": "app_user", "password": "your_db_password", "pool": { "min_size": 2, "max_size": 10, "timeout_seconds": 30 } }, "taotoken": { "base_url": "https://taotoken.net/api", "api_key": "sk-your-taotoken-key", "default_model": "gpt-4o-mini", "timeout_seconds": 60, "max_retries": 2 }, "pagination": { "default_page_size": 20, "max_page_size": 200, "default_page_number": 1, "count_timeout_seconds": 10 }, "logging": { "level": "INFO", "trace_channel_calls": true, "log_sql": false } }几个参数值得单独说。pagination.max_page_size是防止调用方传一个page_size=100000把数据库拖垮,存储过程里会做二次校验。logging.trace_channel_calls打开后,每次通过 TaoToken 发起的请求都会带一个 trace id,方便和数据库侧的调用日志对齐。database.pool里的timeout_seconds要和存储过程的执行时间匹配,分页查询如果超过 30 秒还没返回,大概率是统计 SQL 没走索引。
3.2 分页存储过程(Oracle 版)
下面这个存储过程在原始 excerpt 的基础上做了三处工程化改造:参数校验、总页数计算与结果集边界对齐、异常时返回明确错误码。先创建包和游标类型:
CREATE OR REPLACE PACKAGE pkg_page AS TYPE page_cursor IS REF CURSOR; END pkg_page; / CREATE OR REPLACE PROCEDURE proc_page ( p_table_name IN VARCHAR2, p_page_size IN NUMBER, p_page_number IN NUMBER, p_total_rows OUT NUMBER, p_total_pages OUT NUMBER, p_result OUT pkg_page.page_cursor, p_error_code OUT NUMBER, p_error_msg OUT VARCHAR2 ) AS v_sql VARCHAR2(4000); v_count_sql VARCHAR2(1000); v_offset NUMBER; v_page_size NUMBER; v_page_num NUMBER; BEGIN p_error_code := 0; p_error_msg := NULL; -- 参数校验:页大小和页码都必须是正整数 IF p_page_size IS NULL OR p_page_size <= 0 THEN p_error_code := 1001; p_error_msg := 'page_size must be a positive integer'; RETURN; END IF; IF p_page_number IS NULL OR p_page_number <= 0 THEN p_error_code := 1002; p_error_msg := 'page_number must be a positive integer'; RETURN; END IF; v_page_size := LEAST(p_page_size, 200); v_page_num := p_page_number; v_offset := (v_page_num - 1) * v_page_size; -- 统计总行数 v_count_sql := 'SELECT COUNT(*) FROM ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_table_name); EXECUTE IMMEDIATE v_count_sql INTO p_total_rows; -- 计算总页数,注意向上取整 p_total_pages := CEIL(p_total_rows / v_page_size); -- 页码超出总页数时,返回空结果集而不是报错 IF p_total_rows = 0 OR v_page_num > p_total_pages THEN OPEN p_result FOR SELECT * FROM DUAL WHERE 1 = 0; RETURN; END IF; -- 分页查询:用 ROWNUM 两层嵌套,外层过滤 rn v_sql := 'SELECT * FROM (' || ' SELECT t.*, ROWNUM rn FROM (' || ' SELECT * FROM ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_table_name) || ' ) t WHERE ROWNUM <= ' || (v_offset + v_page_size) || ') WHERE rn > ' || v_offset; OPEN p_result FOR v_sql; EXCEPTION WHEN OTHERS THEN p_error_code := SQLCODE; p_error_msg := SUBSTR(SQLERRM, 1, 500); IF p_result%ISOPEN THEN CLOSE p_result; END IF; END proc_page; /这里有几个关键点。第一,DBMS_ASSERT.SIMPLE_SQL_NAME用来防止表名拼接注入,虽然存储过程内部调用,但表名来自外部参数,必须校验。第二,页码超出总页数时,OPEN p_result FOR SELECT * FROM DUAL WHERE 1 = 0返回一个空结果集,调用方拿到的是「0 行」而不是异常,这样前端翻到最后一页再点下一页不会崩。第三,p_total_pages用CEIL计算,总行数 21、页大小 20 时结果是 2,不会出现「第 2 页是空的但总页数显示 1」这种矛盾。
3.3 调用示例:Python 侧
用 Python 的oracledb库调用,同时读取settings.json里的配置。注意cursor.callproc的参数顺序要和存储过程定义一致。
import json import oracledb with open("settings.json", "r", encoding="utf-8") as f: cfg = json.load(f) db = cfg["database"] conn = oracledb.connect( user=db["user"], password=db["password"], dsn=f"{db['host']}:{db['port']}/{db['service_name']}" ) page_size = cfg["pagination"]["default_page_size"] page_number = cfg["pagination"]["default_page_number"] cursor = conn.cursor() total_rows = cursor.var(int) total_pages = cursor.var(int) result_cursor = cursor.var(oracledb.CURSOR) error_code = cursor.var(int) error_msg = cursor.var(str) cursor.callproc( "proc_page", [ "USERS", page_size, page_number, total_rows, total_pages, result_cursor, error_code, error_msg, ], ) if error_code.getvalue() != 0: print(f"procedure error: {error_code.getvalue()} - {error_msg.getvalue()}") else: print(f"total_rows={total_rows.getvalue()}, total_pages={total_pages.getvalue()}") rows = result_cursor.getvalue().fetchall() for row in rows: print(row) conn.close()跑通之后你会看到类似total_rows=105, total_pages=6的输出,以及第一页的 20 行数据。把page_number改成 6,返回最后 5 行;改成 7,返回空列表且error_code=0。这就是边界处理生效的表现。
4. 验证请求与成功结果
4.1 分页结果正确性验证
验证分页是否正确,不能只看第一页。我通常用三个动作交叉确认。第一个动作:把page_size设为 10,依次请求第 1 页到第 6 页,把每页返回的行数加起来,应该等于total_rows。第二个动作:请求第total_pages + 1页,确认返回 0 行且error_code=0。第三个动作:把page_size设为 0 或负数,确认返回error_code=1001,而不是数据库抛出的原始异常。
# 验证:逐页累加行数 all_rows = 0 for p in range(1, total_pages.getvalue() + 1): cursor.callproc("proc_page", ["USERS", 10, p, total_rows, total_pages, result_cursor, error_code, error_msg]) rows = result_cursor.getvalue().fetchall() all_rows += len(rows) print(f"page {p}: {len(rows)} rows") print(f"sum={all_rows}, expected={total_rows.getvalue()}") assert all_rows == total_rows.getvalue(), "row count mismatch"如果sum和expected对不上,八成是统计 SQL 和分页 SQL 的过滤条件不一致,或者表在两次查询之间被写入了新数据。生产环境建议在同一个事务快照里做统计和分页,或者接受「总行数是一个近似值」并在文档里说明。
4.2 通道调用可追踪验证
TaoToken 侧的追踪,靠的是每次请求带上的 trace id。在settings.json里打开trace_channel_calls后,你可以在调用模型做 SQL 审核时,把存储过程的参数一起传进去,这样日志里能同时看到「哪次分页调用触发了哪次模型请求」。
import requests def review_sql_with_taotoken(sql_text, trace_id): headers = { "Authorization": f"Bearer {cfg['taotoken']['api_key']}", "Content-Type": "application/json", "X-Trace-Id": trace_id, } payload = { "model": cfg["taotoken"]["default_model"], "messages": [ {"role": "system", "content": "你是一个 SQL 审核助手,只回答风险点。"}, {"role": "user", "content": f"检查这段分页 SQL 是否有注入风险:{sql_text}"}, ], } resp = requests.post( f"{cfg['taotoken']['base_url']}/v1/chat/completions", headers=headers, json=payload, timeout=cfg["taotoken"]["timeout_seconds"], ) resp.raise_for_status() return resp.json() trace_id = f"proc-page-{page_number}-{page_size}" result = review_sql_with_taotoken("SELECT * FROM USERS WHERE ROWNUM <= 20", trace_id) print(result["choices"][0]["message"]["content"])成功的结果是:模型返回一段简短的风险说明,同时你在 TaoToken 控制台的请求日志里能按X-Trace-Id搜到这次调用。这样分页存储过程的每一次执行,都能和通道调用对齐,出问题时不用在两个系统之间来回猜。
5. 本篇常见错排查
5.1 ORA-00942 表或视图不存在
这个错误通常不是表真的不存在,而是p_table_name传进来时带了 schema 前缀或者大小写不对。Oracle 默认把未加引号的标识符转成大写,如果你传的是users,存储过程里拼出来的是users,而实际表名是USERS,就会报 ORA-00942。解决办法是在settings.json里约定表名统一大写,或者在存储过程里加UPPER(p_table_name)。但更稳妥的做法是调用方传准确的表名,存储过程只做DBMS_ASSERT校验,不做隐式转换。
5.2 最后一页返回空但总页数不为零
这是最典型的分页边界问题。原因通常是分页 SQL 的rn > offset和ROWNUM <= offset + page_size两个条件在最后一页时交集为空。检查你的v_offset计算:(page_number - 1) * page_size。如果page_number从 0 开始传,第一页的 offset 就是负数,整个 SQL 逻辑就乱了。确认调用方传的页码从 1 开始,存储过程里也做了p_page_number <= 0的校验。
5.3 总页数比实际能翻的页数多 1
比如 100 行、每页 10 条,总页数应该是 10,但返回了 11。这通常是CEIL用在了错误的地方,或者统计 SQL 把COUNT(*)写成了COUNT(1)但过滤条件多了一个WHERE 1=1之外的冗余条件。检查p_total_pages := CEIL(p_total_rows / v_page_size)这一行,确保p_total_rows是准确的过滤后行数。如果表里有软删除标记,统计和分页都要带上同样的WHERE is_deleted = 0。
5.4 TaoToken 请求返回 401
401 说明 Key 无效或者请求头格式不对。先确认settings.json里的api_key是完整的,没有多余空格。然后确认Authorization头的格式是Bearer sk-xxx,注意Bearer和 Key 之间有一个空格。如果 Key 是在控制台刚创建的,确认它没有被禁用或者过期。另外,base_url不要写成https://taotoken.net/api/带尾部斜杠,拼接/v1/chat/completions时会出现双斜杠,部分网关会拒绝。
5.5 存储过程编译通过但调用时报参数个数不匹配
Oracle 的callproc对参数顺序和类型很敏感。OUT参数必须用cursor.var()声明,不能直接传 Python 的None。如果你在 Python 里传了 7 个参数但存储过程定义了 8 个,会报ORA-06550。对照存储过程的参数列表逐个核对,特别是p_result这个游标类型,必须用oracledb.CURSOR声明。
6. 把分页存储过程接进你的工程链路
到这里,你已经有了一个可复制的settings.json骨架、一个带边界处理的分页存储过程、两种语言的调用示例,以及一套验证动作。接下来要做的,是把它接进你现有的工程链路。
如果你主要是在做数据库侧的排障和接入,建议先去 API Keys 页面确认 Key 的权限范围,然后对照接入文档把base_url和鉴权头配置到你的 HTTP 客户端里。如果你需要验证模型对分页 SQL 的审核效果,直接用模型对话页面贴一段 SQL 进去,看它能不能指出ROWNUM嵌套的边界问题。如果你是长期跑编码任务或者 Agent,需要按周期稳定调用,Coding Plan 会比按次调用更省心。
最后留一个我踩过的坑:分页存储过程的p_total_rows和p_total_pages在并发写入场景下会漂移。如果你的业务对总页数要求绝对准确,要么在统计和分页之间加表锁,要么接受「总页数仅供参考」并在前端做容错。分页的核心不是把数字算得完美,而是让调用方在任何边界下都能拿到一个可预期的结果。