offset + limit 分页在大数据量下变慢是因为数据库需扫描跳过offset行,索引易失效;游标分页用WHERE条件推进,需唯一有序字段如id或(created_at,id)复合游标,Laravel用cursorPaginate()实现。

为什么 offset + limit 分页在大数据量下越来越慢
因为数据库要先跳过前面 offset 行,再取 limit 行——哪怕你只要第 100 页的 20 条数据,MySQL 仍得扫描前 1999 条(假设每页 20 条),索引可能失效,I/O 和 CPU 都在白忙。
常见错误现象:DB::table('orders')->orderBy('id')->skip(10000)->take(20)->get() 在百万级表上响应超 2s;Model::paginate() 第 500 页开始明显卡顿。
- 适用场景:订单列表、日志流、消息 Feed 等「按时间/ID 有序滚动」的场景,不适用于需要跳转任意页(如页码输入框)
- 核心前提:必须有唯一、递增(或严格有序)、非空的字段作游标,比如
id、created_at(需加microsecond或联合索引防重复) - 不能用
updated_at单独做游标——更新频繁+精度低会导致漏数据或重复 - 复合游标更稳:例如
(created_at, id),避免同一秒多条记录时顺序不确定
Laravel 原生支持游标分页:用 cursorPaginate() 替代 paginate()
从 Laravel 8.47+ 开始,Eloquent 原生支持游标分页,底层不依赖 OFFSET,而是用 WHERE 条件“记住上一页末尾值”来推进。
示例:
$users = User::where('status', 1)
->orderBy('id')
->cursorPaginate(15);
- 返回结果里
$users->nextCursor()和$users->previousCursor()是编码后的游标字符串(如MTAwMA==),不是原始 ID - 前端请求下一页时,传
?cursor=MTAwMA==,Laravel 自动解码并生成WHERE id > 1000类查询 - 必须显式指定
orderBy(),且字段要和游标字段一致;否则抛出InvalidArgumentException: Cursor pagination requires an order by clause - 不支持
groupBy、union、distinct等会破坏顺序语义的操作
游标值被篡改或失效时会发生什么
游标本质是上一页最后一条记录的排序字段值编码,它不是 token,也不加密。如果用户手动修改 URL 中的 cursor 参数,Laravel 默认行为是:查不到对应记录就返回空集合(count() === 0),不会报错也不会跳转到第一页。
- 典型问题:用户把
?cursor=MTAwMA==改成?cursor=MQ==(即 ID=1),结果返回首屏数据——这不是 bug,是设计使然,但业务上可能需拦截 - 安全建议:若需防跳页,可在控制器中校验游标对应记录是否存在(查一次主键),但会多一次 DB 查询,权衡性能与控制力
- 注意时区和精度:用
created_at游标时,确保应用和 DB 时区一致;毫秒级时间戳推荐用Carbon::now()->us存储,避免秒级重复 - 迁移存量接口时,别直接替换
paginate()—— 前端分页组件需适配游标逻辑(无总页数、无跳页输入框、只有“下一页”按钮)
复合排序 + 多字段游标怎么写
当单字段无法保证唯一排序(如高并发下单,created_at 秒级重复),必须组合字段。Laravel 支持多字段游标,但要求 orderBy 顺序和游标字段完全一致。
示例(按创建时间降序,再按 ID 降序防重复):
$posts = Post::where('published', true)
->orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->cursorPaginate(20);
- 游标值是两个字段拼接后编码(如
2024-05-01 10:30:00,12345→ base64),所以orderBy字段顺序、方向(asc/desc)必须和查询完全一致 - 对应索引必须匹配:建联合索引
INDEX (created_at DESC, id DESC),否则游标查询无法走索引 - 升序+降序混用要小心:比如
orderBy('status', 'asc')->orderBy('id', 'desc'),游标解码后 WHERE 条件会变复杂,容易漏数据,尽量统一方向



















