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