应采用键集分页替代offset-fetch:以主键或唯一递增字段为游标,配合复合索引(如ix_logs_created),避免函数排序和not in子查询,确保高效稳定。

用键集分页替代OFFSET-FETCH
OFFSET-FETCH在SQL Server中超过百万行后性能断崖式下跌,不是因为语法错,而是引擎必须扫描并跳过前面所有行——逻辑读暴涨,IO压力直接拉满。键集分页绕过这个问题:它不依赖“跳过N行”,而是记住上一批最后一条的id(或复合排序字段),下一次查WHERE id > @lastId ORDER BY id。
实操要点:
- 分页字段必须有索引,且最好是主键或唯一递增字段;复合排序场景下,建联合索引如
IX_orders_status_created(顺序必须匹配ORDER BY status, created_at DESC, id DESC) - 禁止在
ORDER BY里用NEWID()、GETDATE()或计算列,否则索引失效,触发强制Sort算子 - 不要用
TOP @batchSize NOT IN (SELECT ...),子查询在大数据量下变成全表扫描
跨表JOIN分页必须用动态SQL+显式索引对齐
多表JOIN后直接套OFFSET 1000 ROWS FETCH NEXT 20 ROWS ONLY,大概率出错或慢到超时。根本原因是JOIN结果中非驱动表字段可能为NULL,导致排序不稳定;SQL Server要求分页排序必须是确定性的,而NULL参与排序会破坏确定性。
安全做法是把ORDER BY严格绑定到主表(如orders.id)的高选择性字段,并用动态SQL拼接整个查询:
- 表名/字段名一律用
QUOTENAME()包裹,防注入,例如'FROM ' + QUOTENAME(@MainTable) + ' o LEFT JOIN ' + QUOTENAME(@UserTable) + ' u ON o.user_id = u.id' -
WHERE条件分段组装,每段前后加空格,最后用REPLACE(@WhereClause, 'WHERE AND', 'WHERE')清理语法错误 - 模糊搜索避免
LIKE '%abc',改用全文索引或前导通配符可控的方案(如u.name LIKE @UserName + '%')
批处理更新要切片+限速,别碰大事务
在存储过程中批量更新百万行时,写UPDATE big_table SET status = 1 WHERE condition等于主动锁表、刷爆binlog、触发Lock wait timeout exceeded。这不是代码逻辑问题,而是事务粒度失控。
正确切法:
- 用
WHERE id BETWEEN @startId AND @endId分片,别依赖LIMIT配合ORDER BY,否则边界数据可能漏或重复 - 每次更新后加
WAITFOR DELAY '00:00:00.1'(SQL Server)或DO SLEEP(0.1)(MySQL),缓解I/O和主从延迟 - 检查
innodb_buffer_pool_size(MySQL)或max server memory(SQL Server),内存不足时频繁刷脏页反而更慢 - 如果执行中报锁超时,第一反应不是调优SQL,而是把批次从5000降到1000——这是最快速有效的止损点
索引碎片高时重建比重组织更有效
即使分页SQL写得再规范,如果底层索引碎片率长期高于30%,logical reads仍会飙升。这不是查询问题,是物理存储退化——页分裂导致B树不紧凑,范围扫描被迫读更多页。
判断和处理:
- 查碎片率:
SELECT avg_fragmentation_in_percent FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('orders'), NULL, NULL, 'DETAILED') - 碎片>30%必须
ALTER INDEX IX_orders_id ON orders REBUILD,别用REORGANIZE——后者只整理页内,不合并页、不释放空间 - 重建索引期间会阻塞写操作,建议在低峰期执行;线上系统可考虑在线重建(SQL Server Enterprise版支持
WITH (ONLINE = ON))
最容易被忽略的是:键集分页依赖索引稳定性,而高碎片会让MAX(id)取值不准、游标偏移,这种问题不会报错,只会悄悄漏数据。











