分页查询在并发写入时会跳过或重复数据,因limit offset,size不加锁且offset依赖实时索引扫描而非一致性快照;应改用基于唯一排序字段的游标分页(如where id > 12345),避免偏移漂移,提升性能与一致性。

分页查询在并发写入时会跳过或重复数据
mysql 的 LIMIT offset, size 分页本身不加锁,也不保证快照一致性——只要在查询执行过程中有新记录插入或旧记录删除,ORDER BY id 排序下的偏移位置就会漂移。典型现象是:用户翻页时某条记录“消失”,或下一页开头又出现上一页末尾的同一条记录。
根本原因是:InnoDB 默认的 REPEAtable READ 隔离级别只对**首次 select 读取的数据行**建立一致性视图(consistent read view),但 OFFSET 计算依赖的是实时索引扫描结果,不是快照里的行数。
- 插入新记录(尤其在排序字段前)→ 下一页
OFFSET对应的物理位置后移 → 跳过原应显示的记录 - 删除记录 → 同样偏移量指向新行 → 某条记录被重复查出
- 即使加了
SELECT ... for UPDATE,也只锁住当前查到的行,不锁住“将要被 OFFSET 跳过的那些间隙”
用游标分页(cursor-based pagination)替代 OFFSET
真正安全的方案是放弃数字偏移,改用上一页最后一条记录的排序字段值作为下一页起点,也就是游标分页。它天然规避了偏移漂移问题,且性能更好(可走索引范围扫描)。
要求:ORDER BY 字段必须有唯一性约束(如主键 id 或联合唯一索引),否则无法精确定位下一页起始点。
SELECT * FROM orders WHERE id > 12345 ORDER BY id ASC LIMIT 20;
- 上一页最后一条记录
id = 12345→ 下一页条件为WHERE id > 12345 - 插入新记录只会追加在末尾(
id更大),不影响已有游标逻辑 - 删除记录也不会导致“空洞跳跃”,因为游标基于值而非位置
- 注意:不能用
>=,否则可能重复返回id = 12345这条
为什么不能靠事务隔离级别解决
把事务设成 SERIALIZABLE 或显式加 SELECT ... LOCK IN SHARE MODE,看似“更安全”,但实际既低效又无效:
-
SERIALIZABLE会让所有读操作隐式加范围锁,极大降低并发度,还可能触发死锁 -
LOCK IN SHARE MODE只锁住本次查到的 20 行,不锁住OFFSET跨过的那几千行,插入仍可发生 - 一致性读视图(consistent read view)在事务开始时就固定了可见版本,但
SELECT count(*) + LIMIT类分页逻辑中,COUNT 是快照值,而 LIMIT 扫描是实时索引遍历——两者看到的数据状态不一致
count(*) 分页总数在并发下本身就是个伪需求
当业务需要显示“共 123456 条”,这个数字在用户点击“第 1 页”和“第 100 页”之间大概率已失效。高并发写入场景下,SELECT COUNT(*) FROM table 不仅慢,而且结果不具备业务意义。
- 用近似值替代:查
information_schema.TABLES中的TABLE_ROWS(MyISAM 精确,InnoDB 是估算) - 前端改用“加载更多”按钮,不显示总页数;或只显示“还有更多”,避免强一致性错觉
- 如果真需精确总数,必须加读锁或串行化处理,代价远超收益
游标分页不依赖总数,也不需要 COUNT,这才是面向并发的真实解法。最容易被忽略的一点是:游标值必须由服务端生成并透传,不能让前端拼接或猜测,否则游标被篡改会导致数据越界或泄露。