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 的新行,直到当前事务结束。
二、兼容矩阵
下表给出主要锁类型之间的兼容关系,“√”表示可共存,“×”表示互斥。行锁与行锁之间按此矩阵判断,意向锁与上层锁之间也遵循同样规则。
| 已持有 \ 请求 | S | U | X | IS | IX | SIX | Sch-S | Sch-M |
|---|---|---|---|---|---|---|---|---|
| S | √ | √ | × | √ | × | × | √ | × |
| U | √ | × | × | √ | × | × | √ | × |
| X | × | × | × | × | × | × | × | × |
| IS | √ | √ | × | √ | √ | √ | √ | × |
| IX | × | × | × | √ | √ | √ | √ | × |
| SIX | × | × | × | √ | √ | × | √ | × |
| Sch-S | √ | √ | × | √ | √ | √ | √ | × |
| Sch-M | × | × | × | × | × | × | × | × |
从矩阵可以读出几条关键结论:X 锁与所有锁互斥,是阻塞的主要来源;U 锁只与 U 锁和 X 锁互斥,因此多个读操作可以和一个“预备更新”共存;意向锁之间几乎都兼容,只有 SIX 与 SIX 互斥;Sch-M 与一切互斥,所以 DDL 是“独占式”的。
三、观察锁的申请与等待
下面用两个会话演示 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 和阻塞链查询,定位具体是哪种锁、在哪个资源上、由哪个会话持有,从而把“感觉慢”转化为可验证的锁等待问题。