应采用主键推进式分批更新,每批1000–5000行并显式提交;禁用OFFSET/FETCH分页,因其导致全表扫描;需建(status, id)复合索引、及时更新统计信息,并配合TRY...CATCH保障事务安全。

直接在单个事务里更新几十万行,基本等于给数据库“上刑”——锁升级、日志暴涨、超时、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 ... ORDER BY id,确保顺序和边界可控 - 推进逻辑:更新完立刻刷新
@min_id——SELECT @min_id = MIN(id) FROM orders WHERE status = 'pending' AND id > @min_id - 终止条件:必须加
IF @@ROWCOUNT = 0 BREAK,否则@min_id变成NULL后循环失控 - 关键前提:
WHERE条件字段(如status)和排序字段(id)要有复合索引,例如CREATE INDEX IX_orders_status_id ON orders(status, id)
事务控制与错误防护细节
别把所有批次包在一个大事务里,也别每个 UPDATE 都开新事务——折中做法是每批独立事务,外层用 TRY...CATCH 捕获失败批次。
- 每批开头写
BEGIN TRANSACTION,成功后紧跟COMMIT TRANSACTION,不要等循环结束再统一提交 - 在
CATCH块里记录当前@min_id和错误信息,方便人工介入或断点续跑 - 避免在循环内调用
WAITFOR DELAY '00:00:00.1'—— SQL Server 存储过程里它不缓解锁竞争,反而拉长总耗时 - 如果表有触发器,确认它们不会在每批中重复执行昂贵逻辑;必要时临时禁用:
DISABLE TRIGGER trg_orders_update ON orders - 注意
READ_COMMITTED_SNAPSHOT是否开启:没开的话,大更新期间其他查询可能被阻塞;开了能缓解,但不解决日志压力本身
容易被忽略的索引与统计陷阱
哪怕逻辑完全正确,没配好索引或统计信息过期,照样慢得像没分批。
- 复合索引顺序很重要:
WHERE status = 'pending' AND id > @last要求索引是(status, id),反过来写成(id, status)就无法高效过滤status - 更新过程中,如果
status字段区分度急剧下降(比如从 1% pending 变成 90% processed),旧统计可能导致执行计划退化——建议在循环前执行UPDATE STATISTICS orders IX_orders_status_id - 别依赖
SELECT TOP 1 id FROM ... ORDER BY id DESC算最大值来预估循环次数——它本身就会变慢,且和更新逻辑无关,删掉省事 - 如果
id是GUID类型,别硬套这套方案:无序 GUID 会导致索引碎片+范围查询失效,应改用时间戳字段或引入自增代理键


















