2. 事务的ACID与隔离级别

在 SQL Server 中,事务不是一条语句,而是一组被当作一个逻辑单元来提交或回滚的操作集合。理解事务,需要同时看清三条线索:事务从开始到结束的生命周期、ACID 四个特性分别由什么机制支撑、以及隔离级别如何在这套机制之上调节并发行为。这三条线索共同决定了“并发事务与锁治理”的边界。

事务的生命周期

一个显式事务通常经历四个阶段:BEGIN TRANSACTION 标记起点并分配事务描述符;随后进入语句执行阶段,每条语句在事务上下文中读写数据,并产生相应的锁与日志记录;接着是提交或回滚决策点,由 COMMIT 或 ROLLBACK 决定事务的最终命运;最后是清理阶段,释放锁、写提交或回滚日志、结束事务描述符。

SQL Server 默认运行在自动提交模式下,每条语句自身就是一个事务。当显式发出 BEGIN TRANSACTION 后,@@TRANCOUNT 从 0 变为 1;嵌套 BEGIN 只会让计数递增,但只有最外层 COMMIT 才真正提交,ROLLBACK 则会把整个事务栈全部回滚。这一点在存储过程嵌套调用时尤其容易出错:内层过程的 ROLLBACK 会连同外层一起撤销。

BEGIN TRANSACTION;
    UPDATE dbo.Account SET Balance = Balance - 100 WHERE AccountId = 1;
    UPDATE dbo.Account SET Balance = Balance + 100 WHERE AccountId = 2;
    -- 此时 @@TRANCOUNT = 1,两个更新均未持久化
COMMIT TRANSACTION;   -- 日志写入提交记录,锁释放

如果第二条 UPDATE 因约束冲突失败,事务并不会自动回滚,需要显式 ROLLBACK,否则连接会一直持有锁,直到会话结束或超时。这是生产环境中最常见的“锁堆积”来源之一。

ACID 的实现机制

ACID 不是四个独立开关,而是由 SQL Server 的日志与锁体系共同保证的:

  • 原子性(Atomicity):由事务日志(transaction log)保证。所有修改先写日志再写数据页(Write-Ahead Logging)。回滚时按日志中的补偿记录(compensation log record)逆向撤销。
  • 一致性(Consistency):由约束、触发器、外键以及事务边界的正确使用共同保证。数据库本身只保证“从一致状态到一致状态”,业务规则仍需应用层配合。
  • 隔离性(Isolation):由锁、行版本控制(row versioning)和隔离级别共同实现。隔离级别越高,并发副作用越少,但阻塞与死锁风险越大。
  • 持久性(Durability):提交时日志记录被刷入磁盘(WAL 的 force flush),即使断电,恢复阶段也能通过重做(redo)恢复已提交事务。
可以把日志理解为“先记账、后改账本”:任何数据页的修改,都必须先有对应的日志记录落盘,崩溃恢复时才能判断哪些事务需要 redo、哪些需要 undo。

SQL Server 六种隔离级别

SQL Server 支持六种隔离级别,其中四种是标准级别,另外两种是基于行版本控制的快照级别。它们对并发副作用的抑制程度不同:

隔离级别脏读不可重复读幻读实现方式
READ UNCOMMITTED可能可能可能不加共享锁,可读未提交数据
READ COMMITTED(默认)不可能可能可能读时加共享锁,读完即释放
REPEATABLE READ不可能不可能可能共享锁持有到事务结束
SERIALIZABLE不可能不可能不可能范围锁,键范围锁定
SNAPSHOT不可能不可能不可能行版本控制,读不加锁
READ COMMITTED SNAPSHOT不可能可能可能行版本控制,语句级快照

READ COMMITTED 是 SQL Server 的默认级别,它通过“读时加共享锁、读完立即释放”避免脏读,但同一事务内两次读取之间,其他事务仍可修改数据,因此不可重复读和幻读依然可能发生。REPEATABLE READ 把共享锁持有到事务结束,阻止其他事务修改已读行,但无法阻止新行插入,所以幻读仍存在。SERIALIZABLE 通过键范围锁(key-range lock)锁定索引区间,阻止插入,代价是并发度最低。

SNAPSHOT 与 READ COMMITTED SNAPSHOT 依赖 tempdb 中的行版本存储。开启后,读操作不再申请共享锁,而是读取事务开始时刻(SNAPSHOT)或语句开始时刻(RCSI)的一致性版本。这能显著减少读写阻塞,但会带来两个代价:一是 tempdb 版本存储增长,二是更新冲突时可能抛出 3960 错误(快照隔离冲突),需要应用层重试。

-- 开启数据库级行版本控制
ALTER DATABASE SalesDB SET ALLOW_SNAPSHOT_ISOLATION ON;
ALTER DATABASE SalesDB SET READ_COMMITTED_SNAPSHOT ON;

-- 会话级使用快照隔离
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
    SELECT Balance FROM dbo.Account WHERE AccountId = 1;
COMMIT;

如何选择隔离级别

选择隔离级别时,先问三个问题:业务能否容忍脏读?同一事务内是否需要重复读取同一结果?是否必须阻止新行插入?如果只是报表查询、允许读到近似值,READ UNCOMMITTED 或 RCSI 可以降低阻塞;如果涉及金额扣减、库存扣减,通常需要 REPEATABLE READ 或 SERIALIZABLE 来保证读-改-写的正确性;如果系统读多写少且希望读写互不阻塞,SNAPSHOT 是更现代的选择,但必须准备好处理更新冲突。

提高隔离级别并不等于“更安全”。SERIALIZABLE 会引入范围锁,容易造成死锁;SNAPSHOT 会消耗 tempdb 并可能产生 3960 冲突。真正安全的做法是:在明确业务语义的前提下,选择刚好满足一致性要求的最低隔离级别,并配合重试逻辑与锁超时设置。
事务生命周期与 ACID 实现机制 BEGIN TRAN 分配事务描述符 语句执行 加锁 + 写日志 COMMIT / ROLLBACK 决策点 清理 释放锁 / 结束事务 ACID 实现机制 原子性 A 事务日志 WAL 补偿记录回滚 一致性 C 约束 / 外键 / 触发器 事务边界正确使用 隔离性 I 锁 + 行版本控制 隔离级别调节 持久性 D 提交时日志刷盘 崩溃恢复 redo 六种隔离级别与并发副作用 隔离级别 脏读 不可重复读 幻读 实现方式 READ UNCOMMITTED 可能 可能 可能 不加共享锁 READ COMMITTED 不可能 可能 可能 读时加锁,读完释放 REPEATABLE READ 不可能 不可能 可能 共享锁持有到事务结束 SERIALIZABLE 不可能 不可能 不可能 键范围锁

图中把生命周期、ACID 机制与隔离级别放在同一张视图里,是为了说明三者并非孤立:生命周期决定锁的持有时间,ACID 决定日志与版本控制的职责,隔离级别则是在两者之上对并发副作用的取舍。实际治理中,先确认事务边界是否合理,再判断隔离级别是否匹配业务语义,最后才考虑锁提示或重试策略,顺序颠倒往往会把问题从“锁等待”变成“数据不一致”。