上周有个同事问我,说他们报表库的存储过程里几乎每个查询都挂着 WITH(NOLOCK),问我要不要统一换成 RCSI。我说先别急,你得先搞清楚现在这些 NOLOCK 到底在替你挡什么,不然换完可能更难看。
顺便把一个常见的误解掰正:NOLOCK 不是「不加锁」,是「加了个锁然后立刻撒手不管」。它在读之前会申请 Sch-S(架构稳定性)锁,跳过 S 锁,然后在扫描的整个过程中完全不理会别人是不是正在改这批数据。很多人的心智模型是「NOLOCK 就等于快照读」,这两件事的语义差得其实非常远——快照读给你的是某个时间点的一致视图,NOLOCK 给你的是「我扫的时候它长什么样就是什么样」。
NOLOCK 实际会怎么坏
我大概处理过三四次跟 NOLOCK 直接相关的线上问题,共同点是:平时相安无事,一到大促或者凌晨跑批就爆。表现分三种。
第一种是脏读,最好理解也最容易被忽略。会话 A 开始事务,把 OrderID = 1 的金额 UPDATE 成 9999 但没提交;会话 B 用 NOLOCK 去读,读到 9999;然后 A 回滚了,B 手里这个 9999 从来就没存在过,但它可能已经被拿去算进昨天的对账报表了。这个本地两分钟就能复现:
-- 会话 A
BEGIN TRAN;
UPDATE dbo.Orders SET Amount = 9999 WHERE OrderID = 1;
-- 先别提交,也别回滚

-- 会话 B
SELECT Amount FROM dbo.Orders WITH (NOLOCK) WHERE OrderID = 1;
-- 返回 9999。回到会话 A 执行 ROLLBACK,再读一次,这个值凭空消失了
第二种是丢失行和重复行,比脏读更难查,因为它不报错、也不给你一个明显离谱的值,就是行数不对。触发条件是查询走了分配顺序扫描(allocation order scan),同时另一个事务正在做页分裂。查聚集索引一般走索引顺序扫描,比较安全;但如果查的是没有覆盖索引的非聚集索引,或者表很小走了 IAM 扫描,就会中招。这种问题本地几乎复现不出来,得并发压测。我是靠一个客户「每天对账都差几行、第二天又对得上」才定位到的,前后花了两周。
第三种是错误 601:Could not continue scan with NOLOCK due to data movement。意思就是扫到一半发现数据被挪走了,它不接着扫,直接抛错。说实话这个反而是三种里最友好的——至少它报错了,不会让你拿着错数据去开会。
RCSI 和 SNAPSHOT 的差别,很多人说反了
RCSI(读已提交快照隔离)和 SNAPSHOT(快照隔离)老是被当成同一个东西,其实有三个关键差异。
第一,开启方式和生效范围不同。RCSI 是库级别的:
ALTER DATABASE [YourDb] SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
这里不加 WITH ROLLBACK IMMEDIATE,库上只要还有别的连接就会一直等或者直接失败。而且这个操作要求独占访问,生产上必须挑窗口。开完之后,你所有的 READ COMMITTED 查询自动变成版本读,应用代码一行都不用改——这是 RCSI 最大的卖点。
SNAPSHOT 不一样,它要求你在会话里显式执行 SET TRANSACTION ISOLATION LEVEL SNAPSHOT,或者用提示。配上连接池就更烦:连接归还池子的时候隔离级别会跟着带过去,下一个借到这条连接的人莫名其妙就进了快照隔离。我见过因为这个导致写操作大面积报错的。
第二,也是最容易被忽略的一点:写操作上的行为完全不同。SNAPSHOT 隔离下,事务启动时拿的是一个固定快照,如果另一个事务在这个快照开始之后改了同一行,你更新的时候会报 3960(快照隔离事务因更新冲突而中止),得靠应用层重试。RCSI 不会报这个,因为 RCSI 的 UPDATE 语句读的是最新已提交版本,而不是语句开始时的快照。所以那种批量跑批、天然容易撞写冲突的库,RCSI 比 SNAPSHOT 省心得多。
第三,RCSI 只是让 SELECT 不加 S 锁,它不提供任何写入层面的保护。经典的 if not exists (select ...) then insert 写法,在 RCSI 下照样会插重复行——两个会话的 SELECT 都不阻塞对方,都读到「不存在」,然后都插。这个只能靠唯一索引加唯一约束兜底,别指望隔离级别能救你。
版本存储:RCSI 真正会咬人的地方
开了 RCSI,每次更新都会把旧版本写进 tempdb。开销大概是每行 14 字节的行版本指针,再加上版本记录本身的开销。
这个数字本身不吓人,吓人的是清理规则:旧版本必须保留到「最老的那个活动事务」结束为止。也就是说,只要有一个长事务挂在那里——跑批的报表事务、或者某个忘了 commit 的 SSMS 窗口——中间所有被修改行的旧版本就全堆在 tempdb 里出不来。我见过一次 tempdb 从 8GB 一路涨到 90 多 GB,最后就是用下面第二个查询抓到一个开了 50 分钟还没提交的事务。
-- 当前库的版本存储占用(MB),需要在目标库里执行
SELECT version_store_reserved_page_count * 8.0 / 1024 AS version_store_mb
FROM sys.dm_db_file_space_usage;
-- 谁是最老的那个事务
SELECT transaction_id, name, transaction_begin_time,
DATEDIFF(SECOND, transaction_begin_time, GETDATE()) AS seconds_open
FROM sys.dm_tran_active_transactions
ORDER BY transaction_begin_time;
第二个结果如果第一行就挂着几千秒,基本可以确定是它了。
另外一定要提前估 tempdb 大小。我的做法是先开 RCSI 但不动 tempdb 的文件配置,观察一周,看它最坏一天能涨到多少,然后按这个值的 1.5 倍去配初始大小和自动增长——记得把百分比增长关掉,改成固定 MB 一档一档加,比如 512MB。百分比增长在 tempdb 上是灾难。
那什么时候我会保留 NOLOCK
最后说点可能有点反常识的:我不认为 NOLOCK 应该被一刀切干掉。下面这几种场景我到现在还留着它。
一种是冷数据上的大范围聚合。比如订单历史表按年份分区,只查三年前的数据,而那张分区早就没人写了,这时 NOLOCK 拿到的就是准确数据,读锁纯属浪费。另一种是报表库拉全表做 ETL,业务本来就接受数据粒度是天级的,偶尔读到一条脏数据没人会在意。还有一种是有明确性能证据的场景——这个很重要,别拿「别人说要加」当理由,加之前先 SET STATISTICS IO ON,看看逻辑读实际降了多少再决定。
如果真要盘查现有代码里有多少 NOLOCK,可以这么干:
SELECT OBJECT_NAME(object_id) AS obj_name, definition
FROM sys.sql_modules
WHERE definition LIKE '%NOLOCK%'
OR definition LIKE '%READUNCOMMITTED%';
它只覆盖存储过程、函数、视图、触发器的定义,应用里动态拼出来的 SQL 字符串搜不到,那部分得另想办法。替换顺序我建议是先测试库开 RCSI、跑一周、盯 tempdb 曲线和 P95,确认没问题再上生产。别一上来就全库切。我认识一个人就是直接切了,结果一个报表存储过程从 3 秒变成 20 秒——倒不是 RCSI 慢,是那个过程原来靠 NOLOCK 绕过了锁等待,现在改成要走版本链去 tempdb 取旧版本,逻辑读上去了。RCSI 不是免费的,读的成本在有些场景下是会涨的。