### 第八期:存储引擎深度(四)—— 事务与并发控制的内部实现
#### 1. 事务的ACID与SQL Server的实现层次
| ACID属性 | SQL Server实现机制 | 关键组件 |
|----------|-------------------|----------|
| **原子性(Atomicity)** | 事务日志 + 撤消(Undo) | 事务日志,LSN |
| **一致性(Consistency)** | 约束、触发器、隔离级别 | 查询处理器,锁管理器 |
| **隔离性(Isolation)** | 锁 + 行版本控制 | 锁管理器,tempdb |
| **持久性(Durability)** | WAL + 检查点 + 恢复 | 事务日志,检查点 |
**核心原则回顾**:WAL(Write-Ahead Logging)—— 日志写入必须在数据页写入之前完成。
#### 2. 深入事务日志:LSN与日志链
**LSN(Log Sequence Number)**:每个日志记录的唯一标识,由三部分构成:
- **VLF序号**(2字节):虚拟日志文件编号
- **块偏移量**(4字节):日志块在VLF中的位置
- **日志槽号**(2字节):块内的日志记录序号
**日志记录的关键字段**:
+--------------+----------+----------+-----------+------+
| 事务ID | LSN前向指针 | 操作类型 | 页ID(前/后镜像)| ... |
+--------------+----------+----------+-----------+------+
- **前向指针**:形成日志链,支持正向扫描(恢复时重做)和反向扫描(回滚时撤消)
- **操作类型**:LOP_BEGIN_XACT, LOP_COMMIT_XACT, LOP_INSERT_ROWS, LOP_MODIFY_ROW, LOP_DELETE_ROWS 等
**日志截断与日志备份的关系**:
简单恢复模式:检查点 → 截断非活动VLF
完整恢复模式:日志备份 → 标记VLF为可重用(但必须日志备份后)
大容量日志恢复模式:最小化日志操作(SELECT INTO, 索引重建)记录更少
**现象解释**:
- 为什么完整恢复模式下日志一直增长?因为没有做日志备份(log\_reuse\_wait\_desc = LOG\_BACKUP)
- 为什么大事务回滚非常慢?回滚需要重新扫描日志链,找到所有撤消记录并重放
#### 3. 行版本控制(Row Versioning)内部机制
**行版本的工作原理**:
1. 修改行时,不直接覆盖原行,而是在tempdb中存储行的先前版本
2. 每个行版本带有一个**事务序列号(XSN)**
3. 读操作根据隔离级别,选择合适版本的行
**两种行版本隔离级别**:
| 隔离级别 | 读操作行为 | 写操作行为 | 配置 |
|----------|-----------|-----------|------|
| **READ COMMITTED SNAPSHOT(RCSI)** | 读取语句开始时已提交的最新行版本 | 仍然使用锁(X锁,U锁) | 数据库级设置 |
| **SNAPSHOT ISOLATION** | 读取事务开始时已提交的行版本 | 检测写冲突(更新冲突会失败) | 数据库级+事务级设置 |
**行版本的结构(存储在tempdb中)**:
+---------------------------+----------+----------+------+
| 版本头(XSN、长度、指针) | 前镜像数据 | 后镜像数据 | 链指针 |
+---------------------------+----------+----------+------+
- 版本链:通过指向tempdb中前一个版本的指针形成
- 版本清理:当没有任何活动事务需要该版本时,定期清理
**启用行版本控制**:
\-- 检查是否启用
SELECT name, is\_read\_committed\_snapshot\_on, snapshot\_isolation\_state
FROM sys.databases WHERE name = 'YourDB';
\-- 启用RCSI(需要数据库独占,重启现有连接)
ALTER DATABASE YourDB SET READ\_COMMITTED\_SNAPSHOT ON;
\-- 启用快照隔离
ALTER DATABASE YourDB SET ALLOW\_SNAPSHOT\_ISOLATION ON;
\-- 事务级别使用快照隔离
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRAN
\-- 查询看不到事务开始后的任何修改
COMMIT
**现象解释**:
- 为什么开启RCSI后,读写不再互相阻塞?因为读使用tempdb中的行版本,不需要共享锁
- 为什么tempdb空间可能会暴增?长事务导致大量行版本保留,无法清理
#### 4. 恢复过程详解(ARIES恢复模型)
SQL Server使用ARIES(Algorithms for Recovery and Isolation Exploiting Semantics)理论的变体。
**恢复的三大阶段**:
**阶段1:分析(Analysis)**
- 从上一个检查点开始扫描日志
- 找出检查点时所有活动事务
- 确定重做(Redo)的起始LSN(最老脏页对应的LSN)
**阶段2:重做(Redo)**
- 从分析阶段确定的起始LSN开始,正向扫描日志
- 对所有已提交事务的修改,重新应用到数据页(确保磁盘上的页与日志一致)
- 幂等操作:重复应用不会破坏一致性
**阶段3:撤消(Undo)**
- 反向扫描日志,回滚所有未提交事务
- 生成撤消日志(也会写入日志,确保崩溃恢复时能再次撤消)
- 最后标记事务为已回滚
**示例恢复场景**:
检查点LSN=100,当前LSN=500
日志内容:
100-200: 事务T1的各种操作
200-300: 事务T2操作,T2在LSN=300提交
300-400: T1继续操作
400-500: 事务T3操作,未提交
崩溃后恢复:
1\. 分析:活动事务为T1(未提交)、T3(未提交),Redo起始LSN=100
2\. 重做:重新应用LSN=100到500的所有已提交事务(T2)
3\. 撤消:反向回滚T1和T3的修改
**检查点类型**:
| 检查点类型 | 触发条件 | 写入内容 |
|------------|----------|----------|
| 自动检查点 | 按恢复间隔(默认1分钟)或日志增长 | 所有脏页列表 |
| 手动检查点 | CHECKPOINT命令 | 所有脏页 |
| 间接检查点 | TARGET_RECOVERY_TIME设置 | 平滑写入,避免尖峰 |
| 内部检查点 | 备份、DBCC、关机 | 确保数据文件一致 |
#### 5. 事务的三种回滚场景
**场景一:显式回滚**
BEGIN TRAN
UPDATE Orders SET Status='Cancelled' WHERE OrderID=101
ROLLBACK -- 从日志中提取撤消信息,直接回滚
**场景二:自动回滚(运行时错误)**
- 严重级别11-16:通常会回滚当前语句,但事务保持活动
- 严重级别17+:可能回滚整个事务
**场景三:死锁牺牲品**
- 检测到死锁,选择牺牲品 → 回滚整个事务(错误1205)
**回滚内部机制**:
1. 从日志中定位事务的开始记录(LOP_BEGIN_XACT)
2. 反向扫描日志链(通过前向指针或从末尾反向)
3. 为每个操作生成撤消记录(可能写入日志)
4. 释放该事务持有的所有锁
5. 标记事务为已回滚
**现象解释**:
- 为什么回滚很慢但回滚过程中看不到进度?因为回滚在后台完全执行,直到完成才释放锁
- 为什么回滚时日志还在增长?因为撤消操作本身会生成日志
#### 6. 监控事务与恢复的关键DMV
| DMV/命令 | 信息 | 关键列 |
|----------|------|--------|
| sys.dm\_tran\_active\_transactions | 活动事务列表 | transaction_begin_time, transaction_type, transaction_state |
| sys.dm\_tran\_database\_transactions | 数据库级事务信息 | database_transaction_log_bytes_used, database_transaction_log_record_count |
| sys.dm\_tran\_session\_transactions | 会话与事务关联 | session_id, transaction_id |
| sys.dm\_tran\_current\_snapshot | 当前快照事务 | snapshot_sequence_number |
| DBCC OPENTRAN | 最早活动事务 | 主机名、程序名、开始时间 |
| sys.dm\_exec\_requests | 当前请求的等待状态 | wait_type, wait_time, blocking_session_id |
**监控行版本使用**:
\-- 查看tempdb行版本使用情况
SELECT version\_store\_reserved\_page\_count, version\_store\_reserved\_kb
FROM sys.dm\_db\_file\_space\_usage;
\-- 查看生成行版本最多的事务
SELECT TOP 10 transaction\_id, version\_chain\_traversed, page\_count
FROM sys.dm\_tran\_version\_store
ORDER BY page\_count DESC;
#### 7. 常见问题与优化
| 问题 | 根本原因 | 排查方法 | 解决方案 |
|------|----------|----------|----------|
| 恢复时间过长 | 检查点间隔过大,或间接检查点目标时间长 | 查看恢复时间设置 | 调整TARGET\_RECOVERY\_TIME |
| tempdb暴增 | 长事务导致行版本积压 | version\_store\_reserved\_kb持续增长 | 优化长事务,定期提交 |
| 更新冲突(快照隔离) | 两个事务同时更新同一行 | 错误3960 | 重试逻辑,或改回RCSI |
| 撤消(Undo)被阻塞 | 回滚需要锁,但被其他会话阻塞 | 查看wait\_type | 找出阻塞源,或终止回滚(极端) |
| 日志写入瓶颈 | WRITELOG等待过高 | sys.dm\_os\_wait\_stats | 日志盘分离、加速磁盘、优化事务大小 |
#### 8. 最佳实践
1. **理解恢复模式的取舍**:
- 简单模式:最小化管理,但只能恢复到完整备份
- 完整模式:支持时间点恢复,但需要日志备份
- 大容量日志:批量操作时性能最佳,但日志备份含大容量操作
2. **合理设置目标恢复时间**:
```sql
-- 平衡恢复时间与运行时性能
ALTER DATABASE YourDB SET TARGET_RECOVERY_TIME = 60 SECONDS; -- 目标1分钟
```
3. **避免长事务**:
- 不要在事务中等待用户输入
- 将大批量操作分批提交(每批1000-5000行)
- 使用sys.dm\_tran\_active\_transactions监控
4. **行版本控制的适用场景**:
- RCSI:读写混合、要求读取最新已提交数据 → 首选
- 快照隔离:需要事务级别一致性读,且可以接受更新冲突 → 次选
- 两者都避免:大量写入且tempdb空间有限的环境
5. **监控检查点频率**:
```sql
-- 查看检查点相关计数器
SELECT counter_name, cntr_value
FROM sys.dm_os_performance_counters
WHERE counter_name IN ('Checkpoint pages/sec', 'Lazy writes/sec');
```
---
**第八期小结**
事务与并发控制的内部实现基于事务日志(LSN链)和行版本控制(tempdb存储)。恢复过程遵循ARIES模型的三阶段:分析→重做→撤消。行版本隔离(RCSI/快照)通过牺牲tempdb空间换取读写不互斥的优势。理解这些底层机制,就能解释为什么恢复耗时、为什么tempdb暴增、以及如何正确设置恢复模式与检查点策略。
**下期预告**:高可用与灾难恢复 —— 日志传送、复制、Always On可用性组,如何选择合适的高可用架构?