1. 并发事务与锁治理导论

只要数据库从“单人使用”走向“多人同时使用”,并发控制就不可避免。SQL Server 默认采用悲观并发控制:当会话读取或修改数据时,会在资源上加锁,用锁来保证事务的 ACID 特性。锁本身不是问题,问题在于锁的持有时间、粒度、顺序以及事务之间的相互等待。当多个事务互相持有对方需要的锁时,就会出现阻塞;当阻塞形成环路且无法自动解开时,就演变为死锁。因此,本课程讨论的不是“如何消灭锁”,而是“如何治理锁”——让锁在正确的时间、以正确的粒度、按正确的顺序出现,并在异常时能够被快速定位和处置。

并发控制的核心挑战

SQL Server 的锁体系可以从三个维度理解:锁模式(共享 S、更新 U、排他 X、意向 IS/IU/IX 等)、锁粒度(行 RID/KEY、页 PAG、区 EXT、表 TAB、数据库 DB)以及锁的持续时间(受隔离级别与事务边界控制)。三者组合决定了并发行为。例如,在默认的 READ COMMITTED 隔离级别下,读操作只持有共享锁到语句结束,写操作持有排他锁到事务结束;而在 REPEATABLE READ 下,共享锁会一直持有到事务结束,阻塞概率显著上升。

实际生产中最常见的挑战有四类:

  • 阻塞链过长:一个长事务持有排他锁,导致后续大量会话排队等待,表现为应用超时、连接池耗尽。
  • 死锁:两个或多个事务形成等待环,SQL Server 选择代价较小的事务作为牺牲者回滚,报错 1205。
  • 锁升级:当单个语句在同一个对象上获得的锁超过阈值(默认约 5000 个),SQL Server 会将行锁/页锁升级为表锁,瞬间阻塞整张表。
  • 隔离级别误用:为了“避免阻塞”而随意使用 READ UNCOMMITTED 或 NOLOCK,引入脏读、幻读与数据不一致。

这些挑战的共同根源是:事务边界不清晰、访问顺序不一致、缺少合适的索引导致扫描范围过大。因此,锁治理必须从应用代码、SQL 语句、索引设计和数据库配置四个层面同时入手。

锁治理的目标

锁治理不是追求“零阻塞”,而是追求可预测、可观测、可恢复的并发行为。具体目标包括:

  1. 缩短锁持有时间:事务尽量短小,避免在事务中做网络调用、文件读写或等待用户输入。
  2. 减小锁粒度:通过合适的索引让查询走 Seek 而非 Scan,减少锁定的行数与页数,降低锁升级风险。
  3. 统一访问顺序:多个事务访问同一组表时,按相同顺序加锁,从源头消除死锁环路。
  4. 选择合适的隔离级别:在一致性要求允许时,使用 READ COMMITTED SNAPSHOT(RCSI)或 SNAPSHOT,让读操作不阻塞写、写不阻塞读。
  5. 建立监控与处置机制:能够通过 DMV、扩展事件、阻塞链查询快速定位根阻塞会话,并具备安全的终止与回滚策略。
锁治理的核心判断标准:当出现阻塞时,能否在 1 分钟内回答“谁在等谁、等什么资源、根阻塞会话是谁、它在执行什么语句”。如果回答不了,说明监控手段缺失,治理就无从谈起。

课程整体学习路径

本课程共 30 章,按“原理 → 观测 → 诊断 → 优化 → 治理”递进组织。前段建立锁与事务的理论模型,中段掌握 DMV、扩展事件、执行计划等观测工具,后段进入死锁分析、锁升级抑制、隔离级别选型、分区与索引优化等实战主题,最终形成一套可落地的锁治理规范。学习过程中,每一章都会围绕真实可复现的场景展开,而不是停留在概念罗列。

SQL Server 并发事务与锁治理 · 学习路径 ① 原理基础 事务与 ACID 锁模式与粒度 隔离级别语义 ② 观测手段 DMV 阻塞链查询 扩展事件跟踪 执行计划与锁提示 ③ 诊断分析 死锁图与 1205 锁升级识别 阻塞根因定位 ④ 优化治理 隔离级别选型 索引与分区 治理规范落地 核心挑战 • 长事务导致阻塞链扩散 • 死锁环路(错误 1205) • 锁升级:行锁 → 表锁 • 隔离级别误用引发脏读/幻读 • 缺少索引导致扫描范围过大 治理目标 • 缩短锁持有时间(短事务) • 减小锁粒度(Seek 替代 Scan) • 统一访问顺序,消除死锁环 • 合理选用 RCSI / SNAPSHOT • 建立可观测、可恢复的监控体系

实战环境准备

为了让后续章节的示例可以直接复现,建议准备一套独立的实验环境。以下配置属于教学示例,可根据实际机器调整:

-- 示例:创建实验数据库与基础表
CREATE DATABASE LockLab;
GO
ALTER DATABASE LockLab SET RECOVERY SIMPLE;
GO
USE LockLab;
GO
CREATE TABLE dbo.Orders (
    OrderID   INT IDENTITY(1,1) PRIMARY KEY,
    CustomerID INT NOT NULL,
    Amount    DECIMAL(10,2) NOT NULL,
    Status    TINYINT NOT NULL DEFAULT 0,
    CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME()
);
CREATE INDEX IX_Orders_CustomerID ON dbo.Orders(CustomerID);
GO
-- 插入约 5 万行示例数据,便于观察锁粒度与锁升级
INSERT INTO dbo.Orders(CustomerID, Amount, Status)
SELECT ABS(CHECKSUM(NEWID())) % 5000 + 1,
       CAST(ABS(CHECKSUM(NEWID())) % 10000 AS DECIMAL(10,2)) / 100,
       ABS(CHECKSUM(NEWID())) % 3
FROM sys.all_objects a CROSS JOIN sys.all_objects b;
GO

环境准备的关键点有三条:第一,使用独立数据库,避免实验影响生产;第二,开启读已提交快照(RCSI)作为可选对照,便于比较不同隔离级别下的阻塞差异;第三,准备一个阻塞观测脚本,后续每章都会复用。下面给出一个可直接使用的阻塞链查询示例:

-- 示例:查询当前阻塞链(谁在等谁)
SELECT
    r.session_id          AS 被阻塞会话,
    r.blocking_session_id AS 阻塞者会话,
    r.wait_type           AS 等待类型,
    r.wait_time           AS 等待毫秒,
    r.resource_description AS 等待资源,
    t.text                AS 当前语句
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.blocking_session_id <> 0
ORDER BY r.wait_time DESC;

执行该查询后,如果返回结果中 阻塞者会话 又出现在其他行的 被阻塞会话 中,就说明存在阻塞链;若阻塞链首尾相连,则形成死锁。判断依据是:根阻塞会话的 blocking_session_id 为 0,它才是真正需要优先处理的对象。后续章节会在此基础上引入锁资源视图、死锁图和扩展事件,逐步构建完整的诊断能力。

不要在生产库上直接执行 KILL 或大批量更新来“解决”阻塞。终止会话会触发事务回滚,回滚时间可能比原事务更长,反而加剧阻塞。正确做法是先定位根阻塞会话,评估其事务状态与回滚代价,再决定是否终止。

完成本章后,应当能够回答三个问题:SQL Server 为什么需要锁;锁治理要解决哪些具体问题;以及如何搭建一个可复现、可观测的实验环境。接下来的章节将从事务与锁的基本模型开始,逐步深入到每一个治理手段的实现细节。