应采用主键推进式分批更新,每批1000–5000行并显式提交;禁用OFFSET/FETCH分页,因其导致全表扫描;需建(status, id)复合索引、及时更新统计信息,并配合TRY...CATCH保障事务安全。
直接在单个事务里更新几十万行,基本等于给数据库“上刑”——锁升级、日志暴涨、超时、SSMS卡死、甚至拖垮整个实例。必须拆成小事务,每批控制在 1000–5000 行之间,并显式
。
为什么不能用 OFFSET/FETCH 做分批更新
看似简洁的
,实际每次都要从头扫描前
行。当处理到第 100 批(即跳过 50 万行)时,性能断崖式下跌,执行计划里全是
。
根本问题:它不是游标,而是“跳过式分页”,不利用索引定位起点
即使加了
上的索引,
仍强制引擎定位并丢弃前面所有行
SQL Server 不会把
下推为索引 Seek,只会做 Seek + Skip
现象:前几批快如闪电,越往后越慢,最终卡在
或
等待上
用主键推进(Keyset Pagination)才是正解
核心是“记住上一批最后的
,下一批从它之后开始”,全程走索引 Seek,每次只查真正要改的行。
起始点:先取最小
——
每次更新:用
+
,确保顺序和边界可控
推进逻辑:更新完立刻刷新
——
终止条件:必须加
,否则
变成
后循环失控
关键前提:
条件字段(如
)和排序字段(
)要有复合索引,例如
事务控制与错误防护细节
别把所有批次包在一个大事务里,也别每个
都开新事务——折中做法是每批独立事务,外层用
捕获失败批次。
每批开头写
,成功后紧跟
,不要等循环结束再统一提交
在
块里记录当前
和错误信息,方便人工介入或断点续跑
避免在循环内调用
—— SQL Server 存储过程里它不缓解锁竞争,反而拉长总耗时
如果表有触发器,确认它们不会在每批中重复执行昂贵逻辑;必要时临时禁用:
注意
是否开启:没开的话,大更新期间其他查询可能被阻塞;开了能缓解,但不解决日志压力本身
容易被忽略的索引与统计陷阱
哪怕逻辑完全正确,没配好索引或统计信息过期,照样慢得像没分批。
复合索引顺序很重要:
要求索引是
,反过来写成
就无法高效过滤
更新过程中,如果
字段区分度急剧下降(比如从 1% pending 变成 90% processed),旧统计可能导致执行计划退化——建议在循环前执行
别依赖
算最大值来预估循环次数——它本身就会变慢,且和更新逻辑无关,删掉省事
如果
是
类型,别硬套这套方案:无序 GUID 会导致索引碎片+范围查询失效,应改用时间戳字段或引入自增代理键
COMMITUPDATE ... ORDER BY id OFFSET @offset ROWS FETCH NEXT @batchSize ROWS ONLY@offsetClustered Index ScanidOFFSETOFFSETWRITELOGLCK_M_UididDECLARE @min_id BIGINT = (SELECT MIN(id) FROM orders WHERE status = 'pending')TOP (@batchSize)WHERE id >= @min_id AND ... ORDER BY id@min_idSELECT @min_id = MIN(id) FROM orders WHERE status = 'pending' AND id > @min_idIF @@ROWCOUNT = 0 BREAK@min_idNULLWHEREstatusidCREATE INDEX IX_orders_status_id ON orders(status, id)UPDATETRY...CATCHBEGIN TRANSACTIONCOMMIT TRANSACTIONCATCH@min_idWAITFOR DELAY '00:00:00.1'DISABLE TRIGGER trg_orders_update ON ordersREAD_COMMITTED_SNAPSHOTWHERE status = 'pending' AND id > @last(status, id)(id, status)statusstatusUPDATE STATISTICS orders IX_orders_status_idSELECT TOP 1 id FROM ... ORDER BY id DESCidGUID