2016年,某电商大促凌晨,数据库查询超时导致订单页白屏。根因?后端一次性返回了87万条商品记录。8年后,Stackademic统计的API故障报告里,「未分页查询」仍是TOP3死因。数据不会撒谎:一个设计良好的分页接口,响应时间能从12秒压到120毫秒。
这不是优化,是救命。
模式一:偏移分页——最简单的陷阱
OFFSET + LIMIT 的组合,是新手村标配,也是深坑起点。
写法直白:跳过前20条,取接下来10条。但翻到第10000页时,数据库要先数完前面99990条。MySQL的EXPLAIN会告诉你,这变成了全表扫描。Instagram早期API用过这套,用户翻到个人主页底部时,加载时间指数级爆炸。
更隐蔽的问题是数据漂移。你正在看第2页, meanwhile 有人删了第1页的一条。刷新后,原本第2页第一条的内容,现在跑到了第1页末尾。用户以为遇到了灵异事件。
适用场景:后台管理系统、数据总量可控的内部工具。生产环境的高频查询?建议直接跳过。
模式二:游标分页——TikTok的无限滑动秘密
不用页码,用「上次看到哪」作为锚点。
实现方式:每条记录带一个唯一游标(通常是排序字段的编码值)。请求下一页时,带上「last_cursor=xyz123」。数据库只需找比xyz123大的记录,时间复杂度稳定O(log n)。
Twitter时间线、TikTok推荐流、微信朋友圈,底层全是这套。用户感知不到分页,手指一滑就加载,体验无缝。代价也明显:无法直接跳转到「第50页」,不支持随机访问。这对消费级产品不是缺陷,是设计选择——你本来就不想让用户跳页。
技术细节:游标必须基于不可变字段。用created_at有风险,毫秒级并发可能产生相同时间戳。Twitter的Snowflake算法生成64位ID,天然有序且唯一,是行业标准解法。
模式三:键集分页——游标的亲戚,更野的路子
把排序字段本身当成分页键,连游标字符串都省了。
假设按价格升序排列,第一页最后一件商品售价199元。下一页请求直接带「price_gt=199」。数据库走索引范围查询,性能与游标相当,实现更简单。
但排序字段必须唯一。价格199元的商品如果有10件,需要复合键:「price_gt=199」或「price_eq=199 AND id_gt=xxx」。Stripe的API文档里,这套逻辑写了整整三页参数说明。
另一个坑:排序字段变更。用户切换「按销量排序」,键集全部失效,必须重置。前端状态管理要跟上。
模式四:搜索令牌——Google的「下一页」为什么时快时慢
复杂查询的救场方案,用临时令牌换计算成本。
用户搜「红色连衣裙 棉质 200元以下」,涉及多字段过滤、全文检索、价格区间。第一次查询很重的计算,服务端生成一个search_token缓存结果集。后续翻页只传token,直接取预计算好的切片。
Google搜索的「下一页」按钮,底层就是搜索令牌。你注意过没有?快速连点两次「下一页」,第二次几乎瞬间返回。不是网络变快了,是令牌命中了缓存。
令牌有效期通常5-15分钟。超过后重新计算,所以深翻页可能「回到原点」。Google的解决方式是限制总结果数——「约为1,270,000条结果」,但只让你看前1000条。产品经理管这叫「足够好」,工程师管这叫「降本增效」。
模式五:过滤优先——别让分页背过滤的锅
先砍数据量,再谈分页策略。
一个反直觉的事实:很多「分页性能问题」,根源是过滤条件没下推。用户只想看「未发货订单」,后端却先把全部订单分页,再在内存里过滤。数据量小的时候察觉不到,百万级记录时直接OOM。
正确姿势:过滤条件必须参与数据库查询的WHERE子句。如果是Elasticsearch,用filter context而非query context,利用缓存位图。如果是GraphQL,警惕N+1查询——每个订单查一次用户信息,100条订单就是101次请求。
Netflix的API设计规范里有一条硬规:任何列表接口必须支持服务端过滤,禁止客户端全量拉取后自行筛选。这条规则来自2019年的一次事故:某个推荐列表接口被爬虫全量拉取,账单上的 egress 费用让财务部门破了防。
模式六:聚合与分页的生死局
分页前聚合,还是聚合后分页?选错直接数据错乱。
场景:订单列表要显示「商品数量」,但订单和商品是多对多关系。方案A:先分页取10条订单,再各自查商品数。方案B:先JOIN算出所有订单的商品数,再分页。
方案A的问题是N+1,且聚合结果无法参与排序——「按商品数排序」失效。方案B的问题是数据量大时,JOIN产生的临时表可能拖垮数据库。
Shopify的折中方案:预计算聚合字段。订单表冗余一个item_count列,由触发器或消息队列维护。读取时直接排序分页,写入时承担一致性代价。这不是技术问题,是业务权衡——电商读多写少,预计算划算。
模式七:流式分页——当数据根本装不下内存
放弃「页」的概念,用数据流思维处理超大规模结果。
导出全年交易记录?传统分页需要计算总页数,但COUNT(*)在亿级表上可能执行数秒。流式方案不计算总量,用数据库游标逐行读取,HTTP响应以chunked encoding持续输出。
CSV导出、日志下载、数据同步场景常用。客户端用NDJSON或CSV格式,边收边处理。Stripe的「List all events」接口支持auto-pagination,SDK内部用流式实现,开发者感知不到分页存在。
代价是放弃随机访问和进度显示。用户问「还有多久下完」,你只能回答「正在处理」。Progress bar 在这里是谎言,不如换成「已处理X条」的计数器。
模式八:混合策略——没有银弹,只有组合拳
生产环境从来不是单选题。
GitHub的Issues接口是典型案例:默认时间线用游标分页(适合无限滚动),搜索模式用搜索令牌(复杂过滤),导出功能用流式(大数据量)。同一个资源,三种策略,URL路径或参数区分。
设计原则是「按场景拆分,而非按资源统一」。用户的行为模式决定了技术选型:浏览用游标,搜索用令牌,导出用流式。试图用一套参数满足所有场景,结果是所有场景都别扭。
监控层面,建议为每种分页策略单独埋点。游标分页的「重复拉取率」、搜索令牌的「缓存命中率」、流式的「连接存活时间」,这些指标比「平均响应时间」更能暴露问题。
最后问一句:你现在的项目里,分页参数是前端随便传的,还是后端严格校验的?我见过最离谱的接口,LIMIT参数接受用户输入,有人传了999999,数据库直接拒绝连接——这算是幸运,还是没被攻击的侥幸?
热门跟贴