ThinkPHP分页混乱这事儿,说白了,十有八九不是分页本身写错了,而是排序没控住。没加 ORDER BY,或者加了但索引没对上、字段重复、参数丢了……只要把这几个点卡死,基本就能稳住。
先说第一个核心:必须显式写 order(),而且得放在 select 前面。 paginate() 不会自动给你补排序,你要是不写 order,那排序就相当于交给 MySQL 随机决定了。翻页的时候数据跳动、重复、漏条,全是这个埋的坑。
- ✅ 正确写法:
User::order('id desc')->paginate(15),或者稳妥一点用多维排序User::order(['create_time' => 'desc', 'id' => 'desc'])->paginate(15) - ❌ 错误写法:
User::paginate(15)(没排序)、User::select()->paginate(15)(这等于先把数据查出来,然后退化成内存切片翻页,效率低还容易乱) - 多表关联的时候,order 字段必须带上表别名,比如
->join('profile p', 'user.id = p.user_id')->order('p.sort desc'),不然 MySQL 直接报错“Column 'sort' is ambiguous”。
接着看第二个点:排序字段必须建索引,顺序也得匹配。 拿 EXPLAIN 看一下,如果出现 Using filesort,就说明 MySQL 没用上索引排序,而是临时开了个内存表来排序——性能差不说,翻页也容易错乱。
- 有个常见的坑:where 条件写了
where('status', 1),order 用了order('create_time desc'),但数据库里只给 status 建了单列索引。正确做法是建联合索引(status, create_time),顺序不能反。 - 游标分页(cursorPaginate)要求排序字段严格非空、单调、可比较。主键 id 天然满足这个要求,create_time 就得设成
NOT NULL DEFAULT '1970-01-01',并且配上(create_time, id)的联合索引。 - 还要注意,别在排序字段上加函数,比如
orderRaw("DATE(create_time) desc")或者whereRaw("YEAR(create_time) = ?"),这种写法规避了索引,直接让性能掉档。
第三个问题:筛选条件要跟着分页一起透传。 点第二页,地址栏变成 ?page=2,但 start_time、keyword 全丢了?这不是 bug,是 paginate 默认只读取 page 参数。
- 安全的写法:
->paginate(15, false, ['query' => array_filter(request()->only(['start_time', 'end_time', 'status', 'keyword']))]),只传你需要的参数。 - 千万别图省事写
['query' => request()->param()],这会把 token、_csrf 这些敏感参数也带过去,安全问题不是闹着玩的。 - 模板里渲染分页,就用
{$list->render()},别自己拼 URL 或者用{$page|raw}。
最后是高并发下的时间戳重复问题。create_time 相同的订单一多,ORDER BY create_time DESC LIMIT 10 OFFSET 20 这种写法就可能跳过某些记录,尤其是翻页到后面的时候,问题更明显。
- 解决办法很简单:复合排序兜底,
->order('create_time desc, id desc'),保证每条记录排序值唯一。 - 更好的方案是改用游标分页
User::order('id desc')->cursorPaginate(20),基于主键排序,天然稳定,也没有 offset 带来的性能衰减。 - 还要注意,NULL 值必须处理。要么数据库层设
create_time NOT NULL,要么写入时补一个默认值。不处理的话,cursorPaginate 会报错或者行为异常。
