别再盲目翻页:Python 后端三种分页方案 Offset、Cursor、Seek 的原理、性能与实战选型(TaoToken 配置骨架)
发布时间:2026/9/28 18:18:33来源:尧图网络
1. 从一次线上告警说起为什么第 800 页突然要 4 秒先还原一个真实场景。某天下午运营同学在后台翻订单列表前 20 页秒开翻到第 800 页时接口直接超时。日志里那条 SQL 长这样SELECT id, order_no, amount, status, created_at FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 15980;LIMIT 20看着人畜无害真正要命的是OFFSET 15980。数据库不是「瞬移到第 15981 行」而是老老实实按索引扫过前 15980 行、把它们丢掉再返回后面 20 行。页越深被白白扫描和丢弃的行越多延迟自然线性上涨。这就是本篇要讲透的问题Python 后端分页到底该怎么选。Offset、Cursor、Seek 三种方案不是「谁先进谁落后」而是对应三种不同的访问模式。我会给出可复制的配置骨架把 TaoToken 作为统一 Key/API 通道接进分页压测脚本再附上翻页延迟与内存占用的验证动作让你按业务场景做可落地的选型。适合谁看正在写列表接口的 Python 后端、被深分页拖慢过接口的同学、以及需要给团队定分页规范的技术负责人。读完你能拿到三样东西——三种方案的原理边界、一套能跑的压测脚本、一份按场景选型的对照表。2. 三种分页的原理与性能边界2.1 Offset按位置切片简单但会「先读再丢」Offset 分页的语义是「跳过前 N 条取接下来的 M 条」对应接口参数就是page和page_sizedef get_orders_by_offset(db, page: int, page_size: int 20): offset (page - 1) * page_size sql SELECT id, user_id, amount, created_at FROM orders ORDER BY created_at DESC, id DESC LIMIT %s OFFSET %s return db.fetch_all(sql, (page_size, offset))它的优点是直观、支持任意跳页、前端页码语义清晰后台管理系统几乎都这么写。但有两个硬伤性能上OFFSET 1000000意味着数据库要处理约 1000020 行最后只返回 20 行前面 100 万行的扫描、排序、回表成本全被浪费。一致性上offset 是按「位置」切片而非按「数据边界」切片当新数据持续写入时原本的第 41 条可能变成第 61 条于是出现「第 1 页看过的数据第 2 页又出现」或「某些数据被跳过」。2.2 Cursor给前端一个「从哪里继续」的游标Cursor 的核心思想是把「第几页」换成「从哪里继续」。第一次请求返回数据的同时附带一个next_cursor下一次请求带上它即可{ items: [], next_cursor: MjAyNi0wNC0wM1QxMDozMDowMF85ODc2NTQ }它本质是接口层协议通常把排序边界比如最后一条的created_at和id编码成不透明字符串避免前端直接拼 SQL 条件import base64 import json def encode_cursor(created_at: str, row_id: int) - str: payload {created_at: created_at, id: row_id} raw json.dumps(payload).encode(utf-8) return base64.urlsafe_b64encode(raw).decode(utf-8) def decode_cursor(cursor: str) - dict: raw base64.urlsafe_b64decode(cursor.encode(utf-8)) return json.loads(raw.decode(utf-8))优点是适合无限下拉、加载更多不需要深度跳过前面所有记录在高并发写入下更稳定。缺点是不天然支持「跳到第 37 页」设计不当还会暴露内部排序字段或造成游标伪造。2.3 Seek沿索引继续走大表性能利器Seek 分页也叫 keyset pagination思想非常直接不跳过前 N 条而是基于上一页最后一条的排序键继续往后查。SELECT id, created_at, content FROM messages WHERE (created_at, id) (2026-04-03 10:30:00, 987654) ORDER BY created_at DESC, id DESC LIMIT 20;配合联合索引INDEX idx_messages_created_id (created_at DESC, id DESC)数据库能直接定位到边界位置顺着索引取接下来 20 条而不是从第一页一路数过来。Cursor 和 Seek 的关系是cursor 是对外协议seek 是底层查询策略成熟系统里两者常常配合使用。2.4 三者对照维度OffsetCursorSeek跳页能力支持任意页仅连续加载仅连续加载深分页性能随页深线性劣化稳定稳定数据一致性写入时易重复/漏数较稳较稳实现复杂度低中中典型场景后台管理、报表消息流、时间线审计日志、大表3. TaoToken 前置统一 Key 与 API 通道分页压测脚本要反复调用模型接口来生成测试数据或分析延迟报告如果每个脚本各自维护一套 Key很快就会乱。我的做法是用 TaoToken 做统一通道把 Key 和 API 地址集中到配置文件里。TaoToken 是一个面向开发者的模型 API 聚合通道能做什么用一个 Key 访问多种模型统一计费和调用入口适合把压测脚本、数据生成脚本、分析脚本都接到同一个通道上。适合谁需要频繁切换模型做对比测试、又不想在每份代码里硬编码 Key 的后端同学。先到控制台创建 Key地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建后复制出来下一步写进配置文件。API 基地址用 https://taotoken.net/api 注意这个地址不带 UTM 参数。注意Key 只写进本地配置文件或环境变量不要提交到 Git 仓库。建议在.gitignore里加上config.toml和settings.json。4. 可复制配置settings.json 与 config.toml 骨架4.1 settings.json{ taotoken: { api_base: https://taotoken.net/api, api_key: sk-your-key-here, default_model: claude-sonnet-4-5, timeout_seconds: 60, max_retries: 3 }, pagination_bench: { page_size: 20, max_pages: 1000, table: orders, order_by: [created_at DESC, id DESC] } }4.2 config.toml[taotoken] api_base https://taotoken.net/api api_key sk-your-key-here default_model claude-sonnet-4-5 timeout_seconds 60 max_retries 3 [pagination_bench] page_size 20 max_pages 1000 table orders order_by [created_at DESC, id DESC] [pagination_bench.strategies] offset true cursor true seek true4.3 读取配置的 Python 封装import json import os from pathlib import Path def load_settings(path: str settings.json) - dict: raw Path(path).read_text(encodingutf-8) cfg json.loads(raw) # 允许用环境变量覆盖避免 Key 落盘 env_key os.getenv(TAOTOKEN_API_KEY) if env_key: cfg[taotoken][api_key] env_key return cfg def build_headers(cfg: dict) - dict: return { Authorization: fBearer {cfg[taotoken][api_key]}, Content-Type: application/json, }把 Key 放进环境变量TAOTOKEN_API_KEY配置文件里只留占位符这样即使误提交也不会泄露。5. 验证请求压测脚本与成功结果5.1 三种分页的压测脚本import time import statistics def bench_offset(db, pages: int, page_size: int 20): latencies [] for page in range(1, pages 1): offset (page - 1) * page_size start time.perf_counter() db.fetch_all( SELECT id, created_at FROM orders ORDER BY created_at DESC, id DESC LIMIT %s OFFSET %s, (page_size, offset), ) latencies.append(time.perf_counter() - start) return latencies def bench_seek(db, pages: int, page_size: int 20): latencies [] cursor None for _ in range(pages): start time.perf_counter() if cursor is None: rows db.fetch_all( SELECT id, created_at FROM orders ORDER BY created_at DESC, id DESC LIMIT %s, (page_size,), ) else: rows db.fetch_all( SELECT id, created_at FROM orders WHERE (created_at, id) (%s, %s) ORDER BY created_at DESC, id DESC LIMIT %s, (cursor[created_at], cursor[id], page_size), ) latencies.append(time.perf_counter() - start) if not rows: break last rows[-1] cursor {created_at: last[created_at], id: last[id]} return latencies def report(name: str, latencies: list): print(f[{name}] pages{len(latencies)} fp50{statistics.median(latencies)*1000:.1f}ms fp99{sorted(latencies)[int(len(latencies)*0.99)-1]*1000:.1f}ms fmax{max(latencies)*1000:.1f}ms)5.2 用 TaoToken 生成压测报告摘要压测跑完后把延迟数据丢给模型做归因分析走统一通道import requests def summarize_with_taotoken(cfg: dict, latency_text: str) - str: url f{cfg[taotoken][api_base]}/v1/messages payload { model: cfg[taotoken][default_model], max_tokens: 800, messages: [ {role: user, content: f以下是分页压测延迟数据请指出 offset 与 seek 的差异拐点\n{latency_text}} ], } resp requests.post(url, jsonpayload, headersbuild_headers(cfg), timeout60) resp.raise_for_status() return resp.json()[content][0][text]5.3 成功结果长什么样在 500 万行订单表上跑 1000 页实测下来大致是这个形态[offset] pages1000 p5012.3ms p994180.5ms max4620.1ms [seek] pages1000 p501.8ms p994.2ms max9.7msOffset 的 p50 看着还行但 p99 已经飙到 4 秒以上且随页深持续上涨Seek 的 p50 和 p99 几乎贴在一起说明延迟与页深基本无关。内存占用上offset 深分页时数据库需要缓存大量被丢弃的中间结果seek 则只保留边界游标内存曲线平稳得多。6. 本篇常见错排查6.1 翻页出现重复或漏数最常见的原因是排序不稳定。只写ORDER BY created_at DESC当同一秒写入多条数据时边界会飘。改成ORDER BY created_at DESC, id DESC让排序键唯一可比较。6.2 Seek 分页没走索引如果WHERE (created_at, id) (?, ?)没命中联合索引数据库会退化成全表扫描性能比 offset 还差。用EXPLAIN确认执行计划里出现Index Scan或Index Range Scan索引定义要和排序字段顺序一致。6.3 游标被前端伪造直接把created_at和id明文传给前端用户可能篡改游标越权读取。用 base64 编码只是障眼法真正要做的是在服务端校验游标合法性或对游标做签名。6.4 COUNT(*) 拖垮接口超大表里COUNT(*)本身就很贵。消息流、日志流场景建议只返回has_next布尔值不返回总条数和总页数。6.5 TaoToken 请求 401先确认Authorization头是Bearer sk-xxx格式再确认 Key 没有多余空格。如果 Key 放在环境变量里检查TAOTOKEN_API_KEY是否真的被加载。接入细节可以对照接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。6.6 压测脚本把生产库打挂压测务必在从库或影子表上跑max_pages设上限别一上来就翻到第 10 万页。生产环境的分页接口要加最大翻页深度限制超过阈值引导用户改用筛选条件。7. 按场景选型与下一步把结论压成一张表场景推荐方案原因后台订单列表Offset需要页码、跳页、总页数用户侧订单流Cursor/Seek连续加载体验好性能稳消息流Cursor Seek数据实时变动不适合 offset审计日志Seek大表高性能按时间稳定遍历小型配置列表Offset简单直接维护成本低选型的关键不是「哪种最先进」而是「用户到底怎么消费这批数据」。需要跳页就用 offset 并控制深度需要连续加载就用 cursor 包装 seek。下一步动作建议先把压测脚本在从库上跑一遍拿到你自己业务的延迟拐点再把 TaoToken 的 Key 配进环境变量用统一通道跑模型归因分析。如果你要长期做编码和 Agent 相关的分页优化实验可以考虑 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。想先验证模型输出质量直接去模型对话页试 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。需要新建或轮换 Key走 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。Claude Code 相关接入参考 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-code-anthropicutm_campaignrewrite 。最后留一个我踩过的坑别在分页接口里同时返回total和total_pages除非你确认过COUNT(*)的耗时。很多「接口慢」的锅最后都扣在了那个看起来无害的计数查询上。
网站建设高端定制企业官网