3. 锁的基本类型与兼容性

SQL Server 的锁并不是“一把锁锁住一切”,而是按资源粒度和访问意图分成多种类型。理解每种锁的语义,以及它们之间能否共存,是判断阻塞、死锁和并发性能的起点。本节先逐一说明六类常见锁的语义,再给出兼容矩阵,最后用一段可复现的示例说明如何观察锁的申请与等待。

一、六类锁的语义

共享锁(S 锁)用于读取。一个会话对某行、页或表加 S 锁后,其他会话仍可对该资源加 S 锁,因此“读读不阻塞”。但 S 锁会阻止排他锁,所以“读写会互相阻塞”。在默认的 READ COMMITTED 隔离级别下,S 锁在语句读完该行后即释放;若使用 HOLDLOCK 或 REPEATABLE READ,S 锁会持有到事务结束。

排他锁(X 锁)用于修改。INSERT、UPDATE、DELETE 在修改数据时申请 X 锁。X 锁与任何其他锁都不兼容,包括其他 X 锁和 S 锁。因此“写写必阻塞、写读也阻塞”。X 锁通常持有到事务提交或回滚,这是长事务造成阻塞的主要原因。

更新锁(U 锁)是更新操作的前置锁,用于避免“共享锁转排他锁”时产生的转换死锁。典型场景是 UPDATE 先读取目标行判断条件,再修改。若直接加 S 锁再升级为 X 锁,两个事务可能同时持有 S 锁、又都想升级为 X 锁,形成死锁。U 锁的语义是:同一时刻同一资源只能有一个 U 锁,但 U 锁与 S 锁兼容。当更新条件确认后,U 锁再升级为 X 锁。因此 U 锁是“预备写”的标记。

意向锁(IS、IX、SIX)不是直接锁数据,而是表达“下层资源已被加锁”的意图。当会话要对某行加 S 锁时,会先对页和表加意向共享锁(IS);要对某行加 X 锁时,会先对页和表加意向排他锁(IX)。意向锁之间大多兼容,因为它们的目的是让上层能够快速判断“下层是否已有锁”,而不必逐行扫描。SIX 是“共享+意向排他”的组合,表示该资源整体被共享读,同时下层有排他修改,常见于某些复杂查询计划。

架构锁(Sch-S、Sch-M)保护元数据。编译查询时申请架构稳定性锁(Sch-S),它不阻塞其他事务的数据读写,但会阻止 DDL 修改表结构。执行 ALTER TABLE、DROP TABLE 等 DDL 时申请架构修改锁(Sch-M),它与所有锁都不兼容,因此 DDL 会被长时间运行的数据查询阻塞,反之 DDL 也会阻塞后续查询。

键范围锁(RangeS、RangeX、RangeS-S 等)只在 SERIALIZABLE 隔离级别下出现,用于防止幻读。它不仅锁住已存在的行,还锁住索引键之间的“范围”,使其他事务无法在该范围内插入新行。例如对 WHERE id BETWEEN 10 AND 20 加范围锁后,其他事务无法插入 id=15 的新行,直到当前事务结束。

二、兼容矩阵

下表给出主要锁类型之间的兼容关系,“√”表示可共存,“×”表示互斥。行锁与行锁之间按此矩阵判断,意向锁与上层锁之间也遵循同样规则。

已持有 \ 请求SUXISIXSIXSch-SSch-M
S√√×√××√×
U√××√××√×
X××××××××
IS√√×√√√√×
IX×××√√√√×
SIX×××√√×√×
Sch-S√√×√√√√×
Sch-M××××××××

从矩阵可以读出几条关键结论:X 锁与所有锁互斥,是阻塞的主要来源;U 锁只与 U 锁和 X 锁互斥,因此多个读操作可以和一个“预备更新”共存;意向锁之间几乎都兼容,只有 SIX 与 SIX 互斥;Sch-M 与一切互斥,所以 DDL 是“独占式”的。

SQL Server 锁兼容关系示意 绿色连线=兼容,红色连线=互斥;节点颜色区分锁族 S 共享锁 U 更新锁 X 排他锁 Sch-M 架构修改锁 IS 意向共享 IX 意向排他 SIX 共享+意向排他 Sch-S 架构稳定锁 兼容(可同时持有) 互斥(后到者等待) SIX 与 SIX 互斥,Sch-M 与全部互斥

三、观察锁的申请与等待

下面用两个会话演示 S 锁与 X 锁的阻塞。先建一张测试表并插入数据:

CREATE TABLE dbo.LockDemo (
    id   INT PRIMARY KEY,
    val  NVARCHAR(50)
);
INSERT INTO dbo.LockDemo VALUES (1, N'A'), (2, N'B'), (3, N'C');

会话 A 开启事务并更新 id=1 的行,此时该行持有 X 锁,且事务未提交:

-- 会话 A
BEGIN TRAN;
UPDATE dbo.LockDemo SET val = N'A1' WHERE id = 1;
-- 不提交,保持 X 锁

会话 B 尝试读取同一行,会申请 S 锁。由于 S 与 X 互斥,会话 B 进入等待:

-- 会话 B
SELECT * FROM dbo.LockDemo WHERE id = 1;
-- 被阻塞,等待会话 A 释放 X 锁

此时在第三个会话中查询锁等待情况:

SELECT
    request_session_id   AS 会话,
    resource_type        AS 资源类型,
    resource_description AS 资源描述,
    request_mode         AS 请求模式,
    request_status       AS 状态
FROM sys.dm_tran_locks
WHERE resource_database_id = DB_ID()
ORDER BY request_session_id;

典型输出中,会话 A 的 request_mode 为 X、request_status 为 GRANT;会话 B 的 request_mode 为 S、request_status 为 WAIT。若将会话 B 的语句改为 SELECT ... WITH (NOLOCK),则不会申请 S 锁,也就不会被阻塞,但可能读到未提交数据,这是用隔离性换并发的典型取舍。

判断阻塞时,先看 request_status = 'WAIT' 的会话在等什么资源,再看谁持有该资源的互斥锁。多数情况下,持有 X 锁且长时间不提交的事务就是阻塞源头。

四、兼容性对日常治理的启示

  • 缩短 X 锁持有时间:事务中尽快提交,避免在事务内做网络调用、大批量计算或等待用户输入。
  • 读操作按需选择隔离级别:报表类查询若允许脏读,可用 READ UNCOMMITTED 或 NOLOCK 减少与写操作的 S/X 冲突;若不允许,则应接受必要的等待。
  • DDL 安排在低峰期:Sch-M 与所有锁互斥,ALTER TABLE 会阻塞整张表的读写,应避开业务高峰。
  • 警惕 SERIALIZABLE 的范围锁:键范围锁会扩大锁定范围,容易造成插入阻塞,除非确有防幻读需求,否则不要随意提升到该级别。
  • 用 U 锁理解更新死锁:两个事务先读后写同一批数据时,U 锁的互斥特性会使其中的一个等待,从而避免 S→X 转换死锁,但若加锁顺序不一致,仍可能形成循环等待。

掌握锁的语义与兼容矩阵后,下一步就可以结合 sys.dm_tran_locks、sys.dm_exec_requests 和阻塞链查询,定位具体是哪种锁、在哪个资源上、由哪个会话持有,从而把“感觉慢”转化为可验证的锁等待问题。