不能用OFFSET/FETCH做分批更新,因其本质是跳过式分页,每次需从头扫描并丢弃前@offset行,导致越往后性能断崖下跌;应采用主键推进式分批(Keyset Pagination),即记住上一批最后id,下一批从该id之后开始,全程索引Seek,高效稳定。

直接在单个事务里更新几十万行,基本等于给数据库“上刑”——锁升级、日志暴涨、超时、SSMS卡死、甚至拖垮整个实例。必须拆成小事务,每批控制在 1000–5000 行之间,并显式 COMMIT。
为什么不能用 OFFSET/FETCH 做分批更新
看似简洁的 UPDATE ... ORDER BY id OFFSET @offset ROWS FETCH NEXT @batchSize ROWS ONLY,实际每次都要从头扫描前 @offset 行。当处理到第 100 批(即跳过 50 万行)时,性能断崖式下跌,执行计划里全是 Clustered Index Scan。
- 根本问题:它不是游标,而是“跳过式分页”,不利用索引定位起点
- 即使加了
id上的索引,OFFSET仍强制引擎定位并丢弃前面所有行 - SQL Server 不会把
OFFSET下推为索引Seek,只会做Seek + Skip - 现象:前几批快如闪电,越往后越慢,最终卡在
WRITELOG或LCK_M_U等待上
用主键推进式分批(Keyset Pagination)才是正解
核心是“记住上一批最后的 id,下一批从它之后开始”,全程走索引 Seek,每次只查真正要改的行。
- 起始点:先取最小
id——DECLARE @min_id BIGINT = (SELECT MIN(id) FROM orders WHERE status = 'pending') - 每次更新:用
TOP (@batchSize)+WHERE id >= @min_id AND status = 'pending' ORDER BY id,ORDER BY不可省,否则TOP行为无定义 - 推进逻辑:更新完立刻刷新
@min_id——SELECT @min_id = MIN(id) FROM orders WHERE status = 'pending' AND id > @min_id - 终止条件:必须加
IF @@ROWCOUNT = 0 BREAK,否则@min_id变成NULL后循环失控 - 关键前提:
status和id字段要有复合索引,例如CREATE INDEX IX_orders_status_id ON orders(status, id)
事务控制与错误防护细节
别把所有批次包在一个大事务里,也别每个 UPDATE 都开新事务——折中做法是每批独立事务,外层用 TRYCATCH 捕获失败批次。
- 每批开头写
BEGIN TRANSACTION,成功后紧跟COMMIT TRANSACTION,不要等循环结束再统一提交 - 在
CATCH块里记录当前@min_id和错误信息,方便人工介入或断点续跑 - 避免在循环内调用
WAITFOR DELAY '00:00:00.1'—— SQL Server 存储过程里它不缓解锁竞争,反而拉长总耗时 - 如果表有触发器,确认它们不会在每批中重复执行昂贵逻辑;必要时临时禁用:
DISABLE TRIGGER trg_orders_update ON orders
复合索引设计和 @@ROWCOUNT 终止判断这两个点,最容易被跳过,但一旦漏掉,轻则性能归零,重则无限循环或全表锁死。


















