游标分页是大数据量下唯一可行方案,因paginate()强制COUNT(*)+大OFFSET导致性能崩溃;需用where+order+limit手写无offset查询,以id或create_time为索引游标,封装模型方法并注意双字段游标、前端禁跳页等细节。

ThinkPHP 默认的 paginate() 在大数据量下会卡死,不是因为写法错,而是它强制执行 COUNT(*) + 大 OFFSET —— 游标分页是唯一能救性能的方案。
为什么不能用 paginate() 做日志/订单流分页
当表有 500 万行、用户翻到第 5000 页时,paginate(10) 会生成类似 SELECT * FROM order WHERE status=1 ORDER BY id DESC LIMIT 10 OFFSET 49990 的 SQL。MySQL 要扫描前 49990 行才能取后 10 条,主键索引也救不了。更糟的是,它还会同步跑一条 SELECT COUNT(*),IO 和 CPU 双爆。
- 现象:第一页快,越往后越慢,甚至超时;监控显示慢查询集中在
LIMIT ... OFFSET和COUNT - 适用场景:消息列表、操作日志、实时订单流 —— 这些天然按时间或 ID 递增,且用户只“往下刷”,不跳页
- 必须满足:游标字段(如
id或create_time)有索引,且排序方向和 WHERE 条件严格一致(ORDER BY id DESC→WHERE id < $lastId)
where() + order() + limit() 手写游标查询
放弃 paginate(),直接用 Query 构建无 offset 查询。核心就三步:取上一页最后一条的游标值、构造 WHERE 条件、限制数量多查一条用于判断“是否有下一页”。
- 首次请求(无游标):
Db::name('log')->where('status', 1)->order('id DESC')->limit(11)->select() - 后续请求(带游标):
Db::name('log')->where('id', '<', $lastId)->where('status', 1)->order('id DESC')->limit(11)->select() - 前端只渲染前 10 条,把第 11 条的
id提取出来作为下一页的cursor参数 - 注意:不要用
create_time当游标除非它是唯一+非空+有索引;否则同秒内多条记录会导致漏数据或重复
把游标逻辑封装成模型方法
每次手写 where()+order()+limit() 容易漏条件、错方向、忘索引。抽成模型里的一个方法,既复用又可控。
立即学习“PHP免费学习笔记(深入)”;
- 在模型中加方法:
public function cursorPaginate($lastId = null, $limit = 10, $where = []) - 内部统一处理:
$query = $this->where($where);,再根据$lastId决定是否追加where('id', '<', $lastId) - 可在此处统一加缓存(如 Redis 缓存最近 100 个游标结果)、权限校验(如只查当前用户数据)、字段白名单(防 SQL 注入)
- 返回结构建议包含:
data(10 条)、has_next(布尔)、next_cursor(第 11 条的 id,若存在)
容易被忽略的边界和兼容点
游标分页看着简单,但线上出问题往往卡在细节。
- 删除数据后,游标可能“跳过”有效记录 —— 这是设计使然,不是 bug;业务接受“略过”比“卡死”更重要
- 新插入记录如果
id小于当前游标(比如从旧库导入),会导致漏展示;解决方案是强制用create_time+id双字段游标:WHERE (create_time, id) < (?, ?)(需 MySQL 8.0+ 支持行比较) - ThinkPHP 6.1+ 的
CursorPaginator类只是概念存在,未内置可用实现;别搜官方文档找现成类,它没提供 - 前端必须禁用“跳页输入框”和“末页按钮”,游标分页只支持线性下拉,这是协议级限制



















