根本原因是MySQL的ORDER BY+LIMIT在排序字段不唯一时顺序不稳定,修复方法是ORDER BY后追加主键等唯一字段,或改用游标分页。

Hyperf 项目中用 MySQL 做分页时出现重复数据,根本原因不是 Hyperf 本身的问题,而是 MySQL 的 ORDER BY + LIMIT 分页机制在排序字段不唯一时的固有缺陷 —— 尤其在并发写入或堆排序优化(Priority Queue)介入后,相同排序值的行顺序不稳定,导致不同 offset 查询结果错位。
为什么 ORDER BY 字段不唯一就会重复
MySQL 在执行 ORDER BY non_unique_col LIMIT 10 OFFSET 10 时,并不保证所有 non_unique_col 相同的记录物理顺序一致。5.6+ 版本启用优先队列优化后,仅对前 20 行做局部堆排序,相同值的行可能被任意置换。比如两行 created_at = '2026-08-12',第一次查询排第 9、10 位,第二次可能变成第 10、11 位 —— 第二页就漏掉一条、多出一条。
- 即使加了
created_at索引,只要该字段非唯一,问题依旧存在 - Hyperf 的
Db::table()->orderBy()->paginate()底层仍是拼LIMIT/OFFSET,无法绕过这个底层行为 - 事务隔离级别(如 RR)不能解决排序不确定性,只管可见性
必须加主键(或唯一字段)到 ORDER BY
最直接有效的修复是让排序结果完全确定:在业务排序字段后追加主键(或任何唯一、非空字段)作为第二排序条件。
面向 CRM 系统的 MySQL 8 数据库模式设计。当用户需要为客户关系管理(CRM)系统设计、评审、优化或生成数据库模式时,应使用此项技能。
SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 40;
- 不要只写
ORDER BY created_at DESC,哪怕created_at有索引 - 如果业务要求时间相同时按插入顺序倒序,就用
id DESC;若要正序,用id ASC - Hyperf 中可这样写:
Db::table('orders')->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate(20) - 注意:
id必须是主键或有唯一索引,否则仍可能失效
Hyperf 中用游标分页替代 OFFSET
当数据高频写入(如订单、日志),或每页数据量大(>1000 条),OFFSET 性能会随页码增大急剧下降,且重复风险更高。此时应切换为基于游标的 Keyset Pagination。
- 首次请求传
last_id = null,查最新 20 条:SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 - 后续请求带
last_created_at和last_id:SELECT * FROM orders WHERE (created_at, id) - Hyperf 中需手动构造 WHERE 条件,不能依赖
paginate();可用Db::select()或 QueryBuilder 链式调用 - 复合条件括号必须严格匹配排序顺序和方向,否则跳过数据或重复
容易被忽略的细节
加了 id 排序也不一定保险 —— 如果业务排序字段允许 NULL,而索引未包含 NULL 值处理逻辑,MySQL 可能将 NULL 行排在最前或最后,破坏一致性。更稳妥的做法是:显式排除 NULL、或用 COALESCE(created_at, '1970-01-01') 统一兜底,再加 id。










