游标分页在 Laravel 中需确保排序字段全局唯一且严格单调,否则 cursorPaginate() 会跳数或重复;id 回收、created_at 重复、复合主键等场景须用 orderByRaw 构造稳定游标或手写 WHERE 元组比较逻辑。

游标分页在 Laravel 中不能靠 orderBy('id') 简单实现,尤其当模型有联合主键、软删除、或 ID 不严格递增时,直接用 ID 做游标会跳数据或重复。
为什么 cursorPaginate() 默认不安全
Laravel 的 cursorPaginate() 要求排序字段「全局唯一 + 严格单调」,但现实中:id 可能被回收(如软删除后重建)、时间戳字段(created_at)可能重复、复合业务主键(如 tenant_id + order_no)无法直接用于默认游标逻辑。
- 常见错误现象:
cursorPaginate(10)->links()返回的下一页 URL 中cursor=xxx对应记录在后续请求中消失,或同一条记录反复出现 - 根本原因:游标值不是“位置锚点”,而是“上一页最后一条的排序字段值”;若该字段不唯一,数据库
WHERE > ?就会漏掉或重复匹配 - 必须确保
orderBy()的字段组合在全表范围内无重复 —— 单独id行,但created_at不行,status更不行
用 orderByRaw() 拼联合唯一游标字段
当主键是复合型(比如 shop_id + sku_code),或需兼顾软删除与时间顺序时,得手动构造稳定排序表达式,让每行都有确定可比的“游标值”。
Laravel Cloud(cloud.laravel.com)。此技能用于处理所有 Laravel Cloud 请求,包括搜索和读取数据。只要任务涉及 Laravel Cloud,即使用此技能。
- 场景:查询已启用的商品,按上架时间倒序,但同一秒可能上架多个,且
deleted_at为空才有效 - 正确做法:用
orderByRaw("COALESCE(deleted_at, '9999-12-31') ASC, created_at DESC, id DESC")—— 把软删除记录排到底部,再用created_at+id保唯一 - 注意:所有字段必须加
SELECT,否则cursorPaginate()内部取游标值时会报Column not found - 性能影响:
orderByRaw若含函数(如COALESCE),MySQL 可能无法走索引;建议建联合索引:ALTER TABLE products ADD INDEX idx_cursor (deleted_at, created_at, id);
手写游标逻辑:绕过 cursorPaginate() 的限制
当排序字段无法满足唯一性,又不想改表结构或加索引,就放弃 Laravel 自带方法,自己控制游标边界。
- 核心思路:把上一页最后一条的完整排序字段值(如
['created_at' => '2024-05-01 10:00:00', 'id' => 123])编码进 URL,下一页查WHERE (created_at, id) - 用法示例:
DB::table('products') ->where('status', 1) ->where(function ($q) use ($cursor) { $q->where('created_at', 'orWhere(function ($q2) use ($cursor) { $q2->where('created_at', '=', $cursor['created_at']) ->where('id', 'orderBy('created_at', 'desc') ->orderBy('id', 'desc') ->limit(20) ->get(); - 坑点:PostgreSQL 支持元组比较
WHERE (a,b) > (?,?),但 MySQL 8.0.1 之前不支持,必须拆成OR嵌套条件 - 兼容性:此方式不依赖 Eloquent,适合复杂查询或跨模型关联场景,但需自行处理空游标(第一页)、方向翻转(上一页)等边界
真正麻烦的从来不是写几行代码,而是确认哪几个字段组合起来,在你这张表里、这个业务周期内、所有写入路径下,真的不会产生重复排序值 —— 这个验证没法靠文档,得看日志、查历史数据、甚至压测造冲突。










