ThinkPHP 8+深分页的生命周期的庖丁解牛

张开发
2026/4/10 10:15:15 15 分钟阅读

分享文章

ThinkPHP 8+深分页的生命周期的庖丁解牛
它的本质是理解当用户请求第 N 页N 很大数据时数据库MySQL或搜索引擎Elasticsearch内部发生的无效计算与资源浪费。深分页不仅是 SQL 语法的LIMIT offset, size更是索引扫描成本、内存排序开销和网络传输瓶颈的综合体现。TP8 作为 ORM 层如果直接透传深分页请求会导致后端数据库 CPU 飙升、响应超时甚至拖垮整个集群。**如果把深分页比作图书馆找书浅分页 (Page 1)你要找前 10 本书。管理员走到第一个书架拿起前 10 本给你。很快。深分页 (Page 10000)你要找第 100,001 到 100,010 本书。传统做法 (LIMIT 100000, 10)管理员必须从第一本书开始数数过 100,000 本扔掉再拿起接下来的 10 本给你。你只想要 10 本但他数了 100,010 本。这就是巨大的浪费。优化做法 (Seek Method / Cursor)你告诉管理员“我上次看到了第 100,000 本书ID 是 9527。”管理员直接通过索引跳到 ID 9527往后拿 10 本。无需数前面的书。一、MySQL 深分页LIMIT Offset, Size的代价1. 执行流程 (The Costly Path)假设查询SELECT * FROM orders ORDER BY create_time DESC LIMIT 100000, 10;索引扫描MySQL 使用create_time索引或主键定位到排序起点。回表 (Row Lookup)如果是覆盖索引Covering Index直接读取索引树。如果不是大多数情况SELECT *每读取一行索引记录都要根据主键 ID 去聚簇索引中回表读取完整行数据。丢弃数据MySQL 读取了 100,010 行完整数据。过滤抛弃前 100,000 行。返回只返回最后 10 行给 PHP。痛点IO 放大读了 10 万行只用了 10 行。CPU 浪费大量的回表操作和内存拷贝。锁竞争如果在事务中可能持有更长时间的锁。2. TP8 的默认行为// TP8 代码Db::name(orders)-order(create_time,desc)-page(10000,10)-select();// 生成的 SQL: SELECT * FROM orders ORDER BY create_time DESC LIMIT 99990, 10后果随着页数增加响应时间线性甚至指数级增长。二、Elasticsearch 深分页from/size的噩梦1. 分布式排序开销ES 是分布式的。假设索引有 5 个主分片。查询from10000, size10分片查询协调节点向 5 个分片发送请求每个分片返回前 10,010 条最匹配的数据因为全局第 10,001 条可能藏在任何一个分片里。内存聚合协调节点接收5×10,01050,0505 \times 10,010 50,0505×10,01050,050条数据。全局排序在内存中对这 50,050 条数据进行归并排序。截取取第 10,001 到 10,010 条。返回丢弃其余 50,040 条。痛点内存爆炸from size越大协调节点需要的内存越多。默认限制max_result_window 10000超过即报错。CPU 飙升大量数据的序列化和网络传输。三、TP8 实现陷阱ORM 的便利性掩盖了性能危机1. 盲目使用page()TP8 的page()方法只是简单的语法糖它不知道数据量有多大也不会自动优化 SQL。// 危险操作$listModel::where(status,1)-page($pageNum,$pageSize)-select();如果$pageNum来自用户输入且未做限制黑客可以构造?page999999发起DoS 攻击。2.SELECT *的滥用深分页时SELECT *导致的回表开销是致命的。// 较差Model::field(*)-page(1000,10)-select();// 较好Model::field(id,title)-page(1000,10)-select();// 覆盖索引不回表四、四大优化策略庖丁解牛式重构策略 1延迟关联 (Late Row Lookups) - MySQL 专用原理先通过覆盖索引查出 ID再关联原表获取完整数据。减少回表次数。原始 SQLSELECT*FROMordersORDERBYidDESCLIMIT100000,10;优化 SQLSELECTo.*FROMorders oINNERJOIN(SELECTidFROMordersORDERBYidDESCLIMIT100000,10)AStmpONo.idtmp.id;TP8 实现$idsDb::name(orders)-field(id)-order(id,desc)-limit(100000,10)-column(id);// 只查 ID走覆盖索引$listDb::name(orders)-whereIn(id,$ids)-select();// 根据 ID 批量查效率高策略 2游标分页 / Seek Method (推荐) - MySQL ES 通用原理不使用OFFSET而是基于上一页最后一条数据的唯一标识如 ID 或时间戳进行查询。SQL-- 假设上一页最后一条 ID 是 9527SELECT*FROMordersWHEREid9527ORDERBYidDESCLIMIT10;优势无论翻到第几页性能恒定。因为可以直接利用索引定位无需扫描前面的数据。TP8 实现publicfunctiongetList(int$lastId0,int$pageSize10){$queryDb::name(orders)-order(id,desc)-limit($pageSize);if($lastId0){$query-where(id,,$lastId);}$list$query-select();// 返回数据和下一页的游标$nextLastIdempty($list)?0:end($list)[id];return[data$list,next_cursor$nextLastId,has_morecount($list)$pageSize];}缺点不支持随机跳转如直接跳第 100 页只能“下一页”。适合移动端无限滚动加载。策略 3ES 的search_after- ES 专用原理类似游标分页ES 官方推荐的深分页方案。TP8 ES 客户端实现// 第一页$params[indexorders,body[size10,sort[[iddesc]],query[match_allnew\stdClass()]]];$response$client-search($params);// 获取最后一行的 sort 值$hits$response[hits][hits];$lastSortend($hits)[sort];// 第二页使用 search_after$params[body][search_after]$lastSort;$response$client-search($params);优势性能恒定无内存爆炸风险。策略 4业务限制与缓存限制最大页数如百度、Google 搜索通常只允许查看前 100 页。if($pageNum100){thrownewException(仅支持查看前100页数据);}缓存热点页对于首页、前几页使用 Redis 缓存结果。 总结原子化“深分页”全景图策略适用场景MySQL 实现ES 实现优点缺点Limit Offset小数据量浅分页LIMIT 10, 10from/size简单支持随机跳页深分页性能极差延迟关联必须随机跳页中等深度JOIN (Subquery)N/A比原生 Limit 快SQL 复杂仍有扫描开销游标/Seek无限滚动APP 列表WHERE id last_idsearch_after性能恒定最快不支持随机跳页业务限制所有场景if page 100max_result_window最简单有效用户体验受限终极心法ThinkPHP 8 深分页优化的本质是“拒绝无效劳动”。数据库不是计算器不要让它数它不关心的数据。能用游标不用偏移能查 ID不查全量能限制不开放。理解索引的跳跃能力你就掌握了深分页的钥匙。于偏移中见浪费于游标中见精准以索引为径解慢查之牛于海量数据中求极速之真。行动指令审查代码搜索项目中所有的-page()和LIMIT确认是否有深分页风险。添加限制在所有分页接口中强制限制最大页码如 100 或 1000。重构列表页将 APP 端或无限滚动的列表页改为“游标分页”Seek Method。ES 优化检查 ES 查询禁用from 10000改用search_after。思维升级记住最好的分页是“不分页”或者“只让用户看最重要的前几页”。

更多文章