小Cの已经记不起来的博客

SQL Server 事务隔离级别设置不当导致的并发异常分析

先说个真事儿

上个月被拉去救火,一套订单系统的对账任务每天凌晨跑,偶尔会多出几笔重复的流水,或者少算一笔。数据库那边没报错,业务那边也没报错,就是数字对不上,而且 —— 关键是 —— 复现不了。

我一开始怀疑是定时任务重跑了,翻了半天日志,没重跑。又怀疑是存储过程里游标写错了,逐行看了一遍,也挺正常。最后实在没招了,问了一句“你们这个查询是不是加了 NOLOCK”,对面沉默了三秒,说:加了,不加的话报表一跑就把订单写入卡住了。

问题基本就定位了。

这套系统的写链路用的是默认的 READ COMMITTED,读链路为了“不阻塞”全线 WITH (NOLOCK),也就是 READ UNCOMMITTED。两边其实谁都没错,错的是把“并发”这件事当成一个可以靠加个提示就绕过去的问题。

默认隔离级别,很多人其实没在用

先说个容易被忽略的点:SQL Server 的默认隔离级别是 READ COMMITTED,但它到底是不是“语句级一致读”,取决于数据库选项 READ_COMMITTED_SNAPSHOT 有没有打开。

SELECT name, is_read_committed_snapshot_on, snapshot_isolation_state_desc
FROM sys.databases
WHERE name = 'YourDb';

is_read_committed_snapshot_on = 0 时,READ COMMITTED 是锁定读:读到哪一行就对该行加 S 锁,读完释放。这意味着一个稍长的查询会一路持锁,写操作在它后面排队。

很多人就是因为这个“排队”,跑去给读加了 NOLOCK。这一步下去,问题就从“阻塞”变成了“不一致”。

NOLOCK 加出来的重复行

大部分人以为 READ UNCOMMITTED 最坏的结果就是读到没提交的数据,也就是脏读。其实还有一个更阴的问题:同一行可能被读两次,也可能被漏掉。

因为加了 NOLOCK 之后,查询不再申请共享锁,走的是所谓的“分配顺序扫描”,它不关心扫描过程中页发生了分裂或者行被移动。你正在扫的时候,另一头在插数据,行被挪到了你已经扫过的页上,那它就再也不会被读到了;反过来,行被挪到了你还没扫到的位置,它就会被读两遍。

对账任务要的恰恰是“某一时刻的完整快照”,这种读法下它的结果既不是 T1 的,也不是 T2 的,是个缝合怪。数字对不上完全合理。

顺带还有个经典坑:

BEGIN TRAN;
-- 会话 A: 扣款成功,但还没提交
UPDATE dbo.Accounts SET Balance = Balance - 100 WHERE Id = 1;
-- 会话 B
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT Balance FROM dbo.Accounts WHERE Id = 1;  -- 读到的是扣完的值

读端拿到“已经扣了”的余额,写端如果回滚,这笔钱在对账报表上就凭空消失了。这不是偶发,这是必然,只是要等到回滚才会暴露。

把 REPEATABLE READ 当“更安全的读”

另一个项目里,前任开发为了“保证同一事务里两次读到的数据一样”,在几个存储过程开头写了:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

逻辑上没毛病,REPEATABLE READ 确实能保证事务内重复读一致,它会把 S 锁一直持有到事务结束。但它也把 S 锁一直持有到事务结束。

于是就有了很常见的转换死锁:

-- 会话 A
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRAN;
SELECT * FROM dbo.Orders WHERE CustomerId = 100;  -- 持 S 锁
-- 中间做了一些别的事,事务没结束
UPDATE dbo.Orders SET Status = 2 WHERE CustomerId = 100;  -- 想升级成 X 锁
-- 会话 B,一模一样的顺序
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRAN;
SELECT * FROM dbo.Orders WHERE CustomerId = 100;  -- 也持 S 锁
UPDATE dbo.Orders SET Status = 2 WHERE CustomerId = 100;  -- 也想升级

两边都拿着 S 锁,都在等对方放手才能升级成 X 锁,谁也升级不了。SQL Server 直接挑一个当牺牲品,报 1205:

Msg 1205, Level 13, State 51
Transaction (Process ID 68) was deadlocked on lock resources with another process
and has been chosen as the deadlock victim. Rerun the transaction.

这个报错在业务侧经常被 catch 掉然后吞了,最后表现为“这笔单子偶尔没处理”。而 REPEATABLE READ 本身并不防“幻读”,新插入的行照样能读进来,因为锁的是行不是范围。

SERIALIZABLE 和它的范围锁

再往上走一档,SERIALIZABLE 听起来最保险,它确实是最严格的,但代价是键范围锁。写过一次就印象深刻:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRAN;
SELECT COUNT(*) FROM dbo.Orders WHERE CustomerId = 100;
-- 事务没提交之前,别人插 CustomerId = 100 的订单会被直接堵住

本来只是想“读得稳一点”,结果把一张高频写入表的插入全卡住了。更糟的是范围锁特别容易和更新顺序不一致的会话组死锁,而且死锁图看着特别迷惑,因为它锁的不是某一行,是一段区间。

这种隔离级别我是建议只给极少数明确需要“防幻读”的场景用,比如号段分配、序列取号,配合 sp_getapplock 甚至比它更可控。

RCSI 和 SNAPSHOT 不是同一件事

很多人把这两个混着说,其实差别不小。

READ_COMMITTED_SNAPSHOT ON(RCSI)只是把 READ COMMITTED 从锁定读换成行版本读,写操作还是老实加锁的,写写之间该阻塞还是阻塞,该冲突还是冲突。它治的是“读写互相阻塞”,治不了“写写抢锁”。

而 SNAPSHOT 隔离级别是真乐观并发,事务提交时发现要改的行版本已经不是自己看到的那个了,直接抛:

Msg 3960, Level 16, State 2
Snapshot isolation transaction aborted due to update conflict.

这个错实际上是在提醒你:你的业务逻辑需要处理“别人先改了”的情况,而大部分代码根本没写这个分支,一抛就 500。

另外 RCSI 还有个副作用,就是长事务会让 tempdb 里的版本存储一直涨,因为旧版本得留着给人读。见过一个跑了几十分钟的报表事务把 tempdb 撑爆的。

我排查一般看这几样

先确认当前会话到底在什么隔离级别下跑:

DBCC USEROPTIONS;

或者从 DMV 里捞,能一次看到所有连接的设置,数字含义是 1=Read Uncommitted、2=Read Committed、3=Repeatable Read、4=Serializable、5=Snapshot:

SELECT s.session_id, s.login_name, s.transaction_isolation_level,
       r.status, r.command, r.blocking_session_id
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
WHERE s.is_user_process = 1;

死锁的话不用装工具,system_health 会自己抓,把 ring_buffer 里的事件捞出来慢慢看:

SELECT CAST(t.target_data AS XML) AS deadlock_xml
FROM sys.dm_xe_session_targets t
JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address
WHERE s.name = 'system_health' AND t.target_name = 'ring_buffer';

死锁图里重点看两件事:谁先拿的锁,以及锁的模式(是 S 升级 X,还是范围锁互撞)。这两点一清楚,改法基本就出来了。

我现在的处理原则

说实话,隔离级别这事儿没有“正确配置”一说,只有“跟业务匹配不匹配”。

我的做法是:默认保持 READ COMMITTED 不动,需要读不阻塞的地方,在数据库级别开 RCSI,而不是在每条 SQL 后面撒 NOLOCK——这样至少不会读到脏数据和重复行。对账、报表这类需要一致快照又跑得久的,单独放到只读副本或者按时间分区去跑,别在主库上跟交易抢。

至于“先读再改”的逻辑,与其调隔离级别,不如把条件写进 WHERE 里:

UPDATE dbo.Stock SET Qty = Qty - @n
WHERE Sku = @sku AND Qty >= @n;

IF @@ROWCOUNT = 0
    RAISERROR('库存不足', 16, 1);

一条原子语句干掉“读-判断-写”三步,比什么锁提示都干净。剩下真正需要跨多张表维持一致性的,老老实实走显式事务,把事务范围压到最短,锁提示能不加就不加。

最后一点:别指望隔离级别能兜住业务上的并发问题。它管的是数据库看到的世界长什么样,管不了两个用户同时点“提交”时你希望发生什么。这个得在设计的时候想清楚,出事儿再补,代价通常就是凌晨三点的那通电话。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放