SQL Server 为什么普通 SELECT 会阻塞 UPDATE?——行级 S 锁到表级 S 锁的锁升级原理与实验验证

SQL Server 的 SELECT 读取 1 行数据时,不会在表级别加 S 锁(共享锁),只是在表级别加上 IS 锁(意向共享锁),表示"这个表里有行级共享锁(S 锁)",IS 锁(意向共享锁)不阻塞写操作。

行级 S 锁只会阻塞对同一行的写操作;

表级 S 锁才会阻塞对整张表的写操作。

普通 SELECT 不会阻塞写操作(INSERT/UPDATE/DELETE)。只有 SELECT 触发了行级S锁 升级到表级S锁时,才会阻塞整张表的写操作(INSERT/UPDATE/DELETE)。

在默认的 READ COMMITTED 隔离级别下,SELECT 读取行时加行级 S 锁,但行级 S 锁是语句级释放,不是读完一行就释放。也就是说,SELECT 在执行过程中会累积已读行的 S 锁,直到整个语句结束才统一释放。当累积的行级 S 锁数量超过阈值(约 5000),SQL Server 会尝试将这些行级 S 锁升级为表级 S 锁。这不是从 IS 锁升级------IS 锁是表级意向锁,从 SELECT 开始执行时就存在,只有当行级 S 锁成功升级为表级 S 锁后,IS 锁才会被释放。当然升级是"尝试性"的:如果表上已有不兼容的锁(比如别的会话持有 IX 锁),升级会失败,SQL Server 会继续持有这些行级 S 锁,直到语句结束。升级失败不会导致报错,也不会改变锁的释放时机,只是锁的粒度保持为行级,没有变成表级。

因此,普通 SELECT是否触发行级S锁升级到表级S锁关键看"执行过程中累积的行级S锁对象数量",而不是"最终返回的行数",以及表上是否有不兼容的锁(比如别的会话持有 IX 锁)

SELECT 触发锁升级的场景(前提是表上没有有不兼容的锁比如 IX 锁)

1、走索引并回表、返回超过 5000 行:这是最容易触发升级的场景。回表逐行加锁,锁数量接近行数,容易突破5000阈值

2、没走索引、大表全表扫描、最终只返回 1 行:全表扫描通常用页锁,大表全表扫描的情况下,页锁也可能突破5000阈值

补充1:如果数据库开启了 RCSI(READ_COMMITTED_SNAPSHOT),SELECT 不加行级 S 锁,而是读行版本存储。因此,SELECT 不会产生锁累积,也不会阻塞 DML,DML 也不会阻塞 SELECT。

补充2:比如一个 SELECT 要扫描 1 万行。当它扫描到第 8000 行、累积的行级 S 锁触发升级并成功拿到表级 S 锁时,之前那 8000 行对应的行级 S 锁还在吗?后续 8001--10000 行还会继续逐行加锁吗?

锁升级成功 = "清零重来"。0-8000 行辛苦累积的行级 S 锁会被全部释放,由 1 个表级 S 锁统一接管。后续的 8001-1 万行不再逐行加锁,因为整张表已经在表级 S 锁的保护下了。 这正是锁升级"减少系统开销"的体现------用更少的锁,保护更多的数据。

SELECT 走索引要回表读 1 万行,耗时 10 分钟:

读到第 1 行 → 加行级 S 锁(保留)

读到第 2 行 → 加行级 S 锁(保留)

......

读到第 10000 行 → 加行级 S 锁(保留)

语句结束 → 统一释放所有行级 S 锁

任意时刻,这个 SELECT 持有的是已读过的所有行的 S 锁,而不是仅当前那一行的 S 锁。这正是锁数量会累积、可能触发锁升级的原因。

如果 UPDATE 要改第 1 行,而 SELECT 正在执行:

SELECT 读到第 1 行 → 加行级 S 锁并保留

UPDATE 想改第 1 行 → 需要等 SELECT 的行级 S 锁释放

如果 SELECT 执行 10 分钟,第 1 行的 S 锁就可能被持有 10 分钟,UPDATE 的阻塞时间也可能是几分钟,而不是几毫秒

示例

第一步:准备测试表

CREATE TABLE dbo.LockTest_A (id INT PRIMARY KEY, name NVARCHAR(50));

INSERT INTO dbo.LockTest_A VALUES (1, 'a1'), (2, 'a2');

CREATE TABLE dbo.LockTest_B (id INT PRIMARY KEY, name NVARCHAR(50));

INSERT INTO dbo.LockTest_B VALUES (1, 'b1'), (2, 'b2');

第二步:模拟 A 表持有行级 S 锁

会话1

BEGIN TRAN;

SELECT * FROM dbo.LockTest_A WITH (HOLDLOCK) WHERE id = 1;

第三步:模拟 B 表持有表级 S 锁

会话2

BEGIN TRAN;

SELECT * FROM dbo.LockTest_B WITH (TABLOCK, HOLDLOCK);

第四步:验证阻塞效果

会话3

UPDATE dbo.LockTest_A SET name = 'b1_updated' WHERE id = 1;--堵塞

会话4

UPDATE dbo.LockTest_A SET name = 'b1_updated' WHERE id = 2;--正常执行

会话5

UPDATE dbo.LockTest_B SET name = 'b1_updated' WHERE id = 2;--堵塞

第五步:查询琐结果类型

sql 复制代码
SELECT 
    l.request_session_id AS SPID,
    OBJECT_NAME(COALESCE(p.object_id, l.resource_associated_entity_id)) AS TableName,
    l.resource_type AS LockResource,
    l.request_mode AS LockMode,
    l.request_status AS LockStatus,
    r.blocking_session_id AS BlockedBy
FROM sys.dm_tran_locks l
LEFT JOIN sys.partitions p 
    ON l.resource_associated_entity_id = p.hobt_id
    AND l.resource_type IN ('KEY', 'RID', 'PAGE')
LEFT JOIN sys.dm_exec_requests r 
    ON l.request_session_id = r.session_id
WHERE 
    OBJECT_NAME(COALESCE(p.object_id, l.resource_associated_entity_id)) IN ('LockTest_A', 'LockTest_B')
ORDER BY TableName, LockMode;
返回结果如下
SPID	TableName	LockResource	LockMode	LockStatus	BlockedBy
会话1	LockTest_A	PAGE	IS	GRANT	NULL
会话1	LockTest_A	OBJECT	IS	GRANT	NULL
会话3	LockTest_A	OBJECT	IX	GRANT	会话1
会话3	LockTest_A	PAGE	IX	GRANT	会话1
会话1	LockTest_A	KEY	S	GRANT	NULL
会话3	LockTest_A	KEY	X	WAIT	会话1
会话5	LockTest_B	OBJECT	IX	WAIT	会话2
会话2	LockTest_B	OBJECT	S	GRANT	NULL

解读返回结果

会话1持有LockTest_A的行级S锁,页级IS锁,表级IS锁,其中会话1持有LockTest_A的行级S锁,堵塞了会话3的行级X锁

会话2持有LockTest_B表级S锁,堵塞了会话5的表级IX锁

相关推荐
Leo.yuan1 小时前
企业业务人员如何独立完成数据分析?2026年自助BI工具选型与应用指南
大数据·数据库·人工智能
roman_日积跬步-终至千里1 小时前
数仓开发的三层责任:业务定义、模型设计与 SQL 实现
数据库·数据仓库·sql·数据建模
瀚高PG实验室1 小时前
瀚高数据库创建新数据库卡住问题排查
数据库·postgresql·瀚高数据库
瑶总迷弟1 小时前
银河麒麟 V10 ARM64 安装 Oracle 19c 静默部署操作手册
数据库·oracle
程序员塔塔喵2 小时前
【MySQL篇|第十而二篇】深层次讲解MVCC机制
mysql·mvcc
飞浪纪元[FWC–FE]2 小时前
Qt 工程创建与 CMake / qmake 构建规范
开发语言·数据库·qt
Ivanqhz2 小时前
MoE + LLM 应用
数据库·人工智能·pytorch·python·深度学习·算法·1024程序员节
Tsuki_tl2 小时前
【总结】Redis五大数据类型应用场景及基本命令
数据库·redis·后端·缓存·缓存中间件·redis命令·redis 五大数据类型
Gauss松鼠会2 小时前
GaussDB 整体架构解析
运维·服务器·网络·数据库·sql·架构·gaussdb