别再盲目翻页:Python 后端必须讲透的三种分页方案——Offset、Cursor、Seek 的原理、性能与实战选型
别再盲目翻页Python 后端必须讲透的三种分页方案——Offset、Cursor、Seek 的原理、性能与实战选型做Python 编程久了你会发现一个列表接口真正难的往往不是“把数据查出来”而是“在数据越来越多、用户越来越频繁翻页、系统还在不断写入”的情况下依然让它查得快、翻得稳、体验好。很多初学者第一次做分页几乎都会写出这样的 SQLSELECT*FROMordersORDERBYcreated_atDESCLIMIT20OFFSET0;第二页SELECT*FROMordersORDERBYcreated_atDESCLIMIT20OFFSET20;这没有错甚至可以说是大多数项目的起点。但当表从几千行增长到几百万、几千万问题就来了为什么页码越往后查询越慢为什么会出现重复数据、漏数据为什么消息流和审计日志不能简单用 page123这篇文章就从实战视角把分页这件事讲透。你会看到三种最常见的分页方式offset 分页、cursor 分页、seek 分页。更重要的是我们不只讲概念而是讲它们在真实业务里的边界、代价与最佳落地方式。无论你是刚接触后端的新人还是在做高并发接口优化的老手希望都能从中找到答案。一、先说结论分页不是“写法问题”而是“访问模式问题”很多人以为分页只是 SQL 语法选择其实不然。分页本质上是在回答三个问题用户是否需要“跳到第 N 页”数据是否持续新增、删除、更新你更在乎“任意跳页能力”还是“稳定、高性能、连续加载体验”不同答案会导向完全不同的分页策略。二、Offset 分页最直观但不一定最适合大表1. 什么是 offset 分页这是最常见、最容易理解的一种方式。SELECTid,user_id,amount,created_atFROMordersORDERBYcreated_atDESCLIMIT20OFFSET40;这表示按创建时间倒序跳过前 40 条取接下来的 20 条。对应常见接口参数page3page_size20offset(page-1)*page_size2. 优点offset 分页最大的优势是简单、通用、适合后台管理系统。易于实现支持直接跳页对前端很友好页码语义清晰适合需要展示“第 1 页 / 第 2 页 / 共 50 页”的场景Python 示例defget_orders_by_offset(db,page:int,page_size:int20):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 returndb.fetch_all(sql,(page_size,offset))注意这里我把id也加进排序字段里了这是一个很重要的Python最佳实践排序必须稳定。如果只按created_at排序而很多记录时间相同那么翻页时数据顺序可能飘忽不定。3. 缺点offset 的问题有两个性能和一致性。性能问题数据库不是“直接跳到第 1000001 行再读 20 条”。它通常需要先找到前面那些记录再把它们丢掉。也就是说LIMIT20OFFSET1000000往往意味着数据库要处理1000020 条记录最后只返回 20 条。前面的 1000000 条用户根本看不到但数据库已经为它们付出了扫描、排序、过滤的成本。一致性问题如果列表数据在不停变化比如新订单持续写入那么用户在翻页过程中可能遇到第 1 页看过的数据第 2 页又看到了某些数据明明存在却被跳过去了原因很简单offset 是按位置切片不是按数据边界切片。当前面插入新数据后原本的“第 41 条”可能变成“第 61 条”。三、为什么大表分页里 offset 往往越翻越慢这是后端面试和性能优化里非常经典的问题。1. 因为 offset 的本质是“先读再丢”假设有一张千万级订单表你要查第 50001 页每页 20 条SELECT*FROMordersORDERBYcreated_atDESCLIMIT20OFFSET1000000;数据库常见的执行思路是找出满足条件的数据按created_at DESC排序扫过前 1000000 行丢弃这 1000000 行返回后面的 20 行问题不在LIMIT 20而在OFFSET 1000000。offset 越大前面被“白白扫描和丢弃”的数据越多。2. 排序和回表会进一步放大成本如果ORDER BY字段没有合适索引数据库还可能发生额外排序如果索引不能覆盖查询字段还会发生“回表”读取整行数据。于是一次深分页可能同时包含大量索引扫描排序开销回表开销丢弃大量无用记录这就是为什么很多接口在前几页飞快翻到后面突然变慢。3. 数据越活跃体验越差如果表还在持续写入那么 offset 分页不仅慢还不稳定。对用户来说这比单纯的“慢”更难受因为他会怀疑系统是不是“少数据了”。一句话总结offset 分页慢不是因为它语法复杂而是因为它要求数据库“为你走很远的路再假装什么都没发生”。四、Cursor 分页给前端一个“游标”而不是一个页码1. 什么是 cursor 分页cursor 的核心思想是不要告诉我“第几页”告诉我“从哪里继续”。第一次请求GET /messages?limit20返回{items:[...],next_cursor:2026-04-03T10:30:00_987654}下一次请求GET /messages?limit20cursor2026-04-03T10:30:00_987654cursor 通常会封装排序边界比如最后一条数据的created_at和id。它更多是接口层协议不一定是数据库层实现细节。2. 优点适合无限下拉、加载更多不需要深度跳过前面所有记录在高并发写入场景下更稳定更适合移动端、消息流、时间线3. 缺点不天然支持“跳到第 37 页”cursor 通常是“上一次结果的延续”更偏流式访问设计不当会暴露内部排序字段或者造成游标伪造问题因此实际项目里常把 cursor 做成不透明字符串经过编码处理importbase64importjsondefencode_cursor(created_at:str,row_id:int)-str:payload{created_at:created_at,id:row_id}returnbase64.urlsafe_b64encode(json.dumps(payload).encode()).decode()defdecode_cursor(cursor:str)-dict:returnjson.loads(base64.urlsafe_b64decode(cursor.encode()).decode())五、Seek 分页数据库层真正高效的“沿索引继续走”1. 什么是 seek 分页seek 分页也常被叫做keyset pagination。它的思想非常直接不是跳过前 N 条而是基于上一页最后一条记录的排序键继续往后查。例如按created_at DESC, id DESC排序SELECTid,created_at,contentFROMmessagesWHERE(created_at,id)(2026-04-03 10:30:00,987654)ORDERBYcreated_atDESC,idDESCLIMIT20;这个写法的关键是它把“翻页”变成“从上一个边界继续扫描索引”而不是“从头数到这里”。2. 为什么它快因为数据库可以直接利用索引定位到边界位置然后顺着索引取接下来的 20 条而不是从第一页一路数过来。如果有联合索引INDEXidx_messages_created_id(created_atDESC,idDESC)那么 seek 分页会非常高效尤其适合大表。3. Seek 和 Cursor 的关系这是一个很容易混淆的点。cursor更像接口设计方式告诉客户端“下次从这个位置继续”seek更像数据库查询策略基于排序键继续扫描在很多成熟系统里cursor 是对外协议seek 是底层实现。六、三种分页方式怎么选这才是最重要的实战问题。1. 订单列表后台管理通常适合 offset用户侧列表更适合 cursor/seek如果是运营后台、财务后台用户经常有“跳到第 12 页”“查看总页数”“导出某一页”的需求那么 offset 很常见也很合理。SELECTid,order_no,amount,status,created_atFROMordersORDERBYcreated_atDESC,idDESCLIMIT20OFFSET200;但要注意两点给排序字段建索引控制最大翻页深度比如超过 1000 页改为筛选查询如果是用户 App 里的“我的订单”通常不需要真正跳页更多是“继续加载”这时更建议 cursor/seek。结论管理后台订单列表offset 可用用户端订单流cursor/seek 更优2. 消息流优先 cursor seek消息流天然是时间序列而且数据持续新增。你几乎不需要“跳到第 138 页消息”你需要的是继续向下加载旧消息拉取比当前更新的新消息保证不重不漏典型查询SELECTid,created_at,sender_id,contentFROMmessagesWHERE(created_at,id)(?,?)ORDERBYcreated_atDESC,idDESCLIMIT20;这正是 seek 分页最擅长的场景。结论消息流最适合 cursor/seek。3. 审计日志强烈建议 seek必要时配合时间过滤审计日志往往有几个特点数据量很大只读多、写入持续查询经常按时间倒序一致性要求高不能漏关键记录这类场景如果使用深 offset会非常痛苦。更推荐按event_time DESC, id DESC排序用 seek 分页再叠加时间范围筛选、用户筛选、事件类型筛选SELECTid,event_time,actor,action,resourceFROMaudit_logsWHEREevent_time2026-04-01AND(event_time,id)(?,?)ORDERBYevent_timeDESC,idDESCLIMIT100;结论审计日志最适合 seek外部接口可以包装成 cursor。七、Python 实战一个更靠谱的分页实现思路下面给一个简化版的Python实战例子展示 seek/cursor 的常见写法。importbase64importjsonfromtypingimportOptionaldefencode_cursor(created_at:str,row_id:int)-str:payload{created_at:created_at,id:row_id}rawjson.dumps(payload).encode(utf-8)returnbase64.urlsafe_b64encode(raw).decode(utf-8)defdecode_cursor(cursor:str)-dict:rawbase64.urlsafe_b64decode(cursor.encode(utf-8))returnjson.loads(raw.decode(utf-8))deflist_messages(db,limit:int20,cursor:Optional[str]None):ifcursor:datadecode_cursor(cursor)sql SELECT id, created_at, sender_id, content FROM messages WHERE (created_at, id) (%s, %s) ORDER BY created_at DESC, id DESC LIMIT %s rowsdb.fetch_all(sql,(data[created_at],data[id],limit))else:sql SELECT id, created_at, sender_id, content FROM messages ORDER BY created_at DESC, id DESC LIMIT %s rowsdb.fetch_all(sql,(limit,))next_cursorNoneifrows:lastrows[-1]next_cursorencode_cursor(str(last[created_at]),last[id])return{items:rows,next_cursor:next_cursor}这个实现里有几个值得坚持的Python教程层面的最佳实践排序字段必须唯一可比较只按created_at不够最好加id兜底。游标尽量不透明不要让前端直接拼 SQL 条件。索引要和排序一致否则 seek 的优势发挥不出来。限制最大 limit避免一次拉太多数据。八、真正落地时你还需要注意这些细节1. 不要迷信“总条数”很多人做分页时第一反应是返回当前页每页条数总条数总页数但在超大表里COUNT(*)本身也可能很贵。对于消息流、日志流很多时候只返回“是否还有下一页”就够了。2. 排序一定要稳定错误写法ORDERBYcreated_atDESC更稳妥的写法ORDERBYcreated_atDESC,idDESC否则同一秒内写入多条数据时分页边界会很危险。3. Offset 不是原罪别把 offset 说得一无是处。它在以下场景依然很有价值数据量不大需要跳页需要明确页码典型后台系统报表类界面技术选型的关键不是“哪种最先进”而是“哪种最适合你的访问模式”。九、一张表记住三种分页的选型逻辑场景推荐方案原因后台订单列表Offset支持页码、跳页、总页数展示用户侧订单流Cursor/Seek连续加载体验更好性能更稳消息流Cursor Seek数据实时变动不适合 offset审计日志Seek大表高性能、按时间顺序稳定遍历小型配置列表Offset简单直接维护成本低十、总结分页从来不只是“第几页”而是系统设计观的体现分页看似是一个小问题实际上能非常真实地反映你对数据库、接口设计和用户体验的理解。Offset简单直观适合页码明确、数据量可控的场景。Cursor更适合面向客户端的连续加载是一种更现代的接口设计。Seek则是大表、高并发、时间序列数据的性能利器本质上是“沿索引继续走”。所以回到开头那个追问为什么大表分页里 offset 往往越翻越慢因为它不是在“从当前位置开始取数据”而是在“从头开始数然后把前面大部分结果扔掉”。页越深白白浪费的扫描、排序和回表成本就越高。而对于你提到的三个实践案例我的建议非常明确订单列表后台管理偏向 offset用户端偏向 cursor/seek消息流优先 cursor seek审计日志优先 seek必要时对外封装成 cursor这也是我在长期Python实战和后端设计里反复验证过的一条经验别先问“数据库支持什么分页”先问“用户到底怎么消费这批数据”。留给你的两个思考题你在日常开发中是否遇到过“分页越翻越慢”或者“翻页出现重复/漏数”的问题你最后是怎么定位和解决的面对越来越多实时化、流式化的数据场景你觉得未来的接口设计里“页码分页”会不会逐渐退居二线