SQL Server 开发系列(第八期):事务与并发控制(开发视角)------避免死锁与数据异常
上一期我们学习了触发器和约束。这一期,作为开发系列的收官之作,我们来到并发编程的核心------事务与并发控制 。你是否遇到过:用户明明查询了余额,扣款时却提示余额不足?两个用户同时抢购最后一件商品,都显示成功?应用莫名其妙报死锁错误?这些问题,都源于你对事务隔离级别和并发控制的理解不足。这一期,我们从开发人员视角,深入实战中的事务处理。
一、开发人员必须理解的并发问题
1.1 三种读现象回顾
| 现象 | 定义 | 业务影响 |
|---|---|---|
| 脏读 | 读到未提交的数据 | 看到"幽灵"数据,可能被回滚 |
| 不可重复读 | 同一事务内两次读取不一致 | 基于第一次读取的业务判断失效 |
| 幻读 | 同一事务内行数变化 | 分页、统计结果不一致 |
1.2 丢失更新(Lost Update)------ 最容易被忽视
sql
-- 场景:两个用户同时修改同一篇文章
-- 事务A(用户1)
BEGIN TRAN
SELECT Content FROM Articles WHERE ArticleID = 1 -- 读到 "原文"
-- 用户1 编辑了 10 分钟
UPDATE Articles SET Content = '用户1的修改' WHERE ArticleID = 1
COMMIT
-- 事务B(用户2,几乎同时)
BEGIN TRAN
SELECT Content FROM Articles WHERE ArticleID = 1 -- 也读到 "原文"
-- 用户2 编辑了 5 分钟,先提交
UPDATE Articles SET Content = '用户2的修改' WHERE ArticleID = 1
COMMIT
-- 结果:用户1的修改被覆盖(丢失更新)
二、乐观锁 vs 悲观锁
2.1 悲观锁(Pessimistic Locking)
思路:假设冲突一定会发生,读取时就加锁。
sql
-- 使用 UPDLOCK 行锁(读时加更新锁,防止其他事务修改)
BEGIN TRAN
SELECT @Stock = Stock FROM Products WITH (UPDLOCK, ROWLOCK)
WHERE ProductID = @ProductID
IF @Stock >= @Quantity
UPDATE Products SET Stock = Stock - @Quantity WHERE ProductID = @ProductID
ELSE
THROW 50001, '库存不足', 1
COMMIT
-- 使用 XLOCK 排他锁(更严格)
SELECT * FROM Orders WITH (XLOCK, ROWLOCK) WHERE OrderID = @OrderID
优点 :强一致性,不会丢失更新 缺点:并发性能差,容易死锁
2.2 乐观锁(Optimistic Locking)
思路:假设冲突很少发生,更新时检查版本。
sql
-- 方案1:使用 ROWVERSION 列
ALTER TABLE Articles ADD RowVer ROWVERSION
-- 读取时获取版本号
SELECT Content, RowVer FROM Articles WHERE ArticleID = @ArticleID
-- 更新时检查版本号
UPDATE Articles
SET Content = @NewContent, RowVer = DEFAULT
WHERE ArticleID = @ArticleID AND RowVer = @OldRowVer
IF @@ROWCOUNT = 0
THROW 50002, '数据已被其他用户修改,请刷新后重试', 1
-- 方案2:使用时间戳/业务字段
UPDATE Articles
SET Content = @NewContent,
LastModified = GETDATE(),
Version = Version + 1
WHERE ArticleID = @ArticleID AND Version = @OldVersion
优点 :高并发,无锁 缺点:更新失败需要重试
2.3 乐观锁完整实现(应用层重试)
sql
CREATE PROC usp_UpdateArticle
@ArticleID INT,
@NewContent NVARCHAR(MAX),
@ExpectedVersion INT
AS
BEGIN
SET NOCOUNT ON;
UPDATE Articles
SET Content = @NewContent,
LastModified = GETDATE(),
Version = Version + 1
WHERE ArticleID = @ArticleID AND Version = @ExpectedVersion
IF @@ROWCOUNT = 0
BEGIN
-- 获取最新版本号,供客户端重试
SELECT @ExpectedVersion = Version, Content
FROM Articles WHERE ArticleID = @ArticleID
THROW 50002, FORMATMESSAGE('数据已过时,最新版本: %d', @ExpectedVersion), 1
END
END
csharp
// C# 应用层重试逻辑
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++)
{
try
{
var article = GetArticle(articleId);
article.Content = newContent;
UpdateArticle(article.Id, article.Content, article.Version);
break; // 成功,退出循环
}
catch (SqlException ex) when (ex.Number == 50002)
{
if (i == maxRetries - 1) throw;
await Task.Delay(100); // 等待后重试
// 重新获取最新数据
}
}
三、隔离级别的开发选择指南
3.1 快速决策表
| 业务场景 | 推荐隔离级别 | 原因 |
|---|---|---|
| 报表查询、数据导出 | READ UNCOMMITTED | 允许脏读,不被阻塞 |
| 普通 OLTP(大多数) | READ COMMITTED(默认) | 平衡性能和一致性 |
| 高并发 OLTP | READ COMMITTED SNAPSHOT | 读写不互斥,推荐开启 |
| 账户余额查询 | SNAPSHOT | 事务内一致性 |
| 订单扣库存 | REPEATABLE READ | 防止不可重复读 |
| 金额统计汇总 | SERIALIZABLE | 防止幻读 |
| 分布式系统 | 应用层分布式锁 | 数据库无法解决 |
3.2 启用 READ_COMMITTED_SNAPSHOT(强烈推荐)
sql
-- 检查当前设置
SELECT name, is_read_committed_snapshot_on
FROM sys.databases
WHERE name = DB_NAME()
-- 启用 RCSI(需要独占数据库,无其他连接)
ALTER DATABASE YourDB SET READ_COMMITTED_SNAPSHOT ON;
-- 启用后,READ COMMITTED 级别的读操作不再加共享锁
-- 读操作读取事务开始时的快照,不被写操作阻塞
-- 写操作也不被读操作阻塞
RCSI 前后对比:
sql
-- 事务A(写,耗时操作)
BEGIN TRAN
UPDATE Orders SET Status = 'Processing' WHERE OrderID = 1
WAITFOR DELAY '00:00:10'
COMMIT
-- 事务B(读,在事务A执行期间运行)
-- RCSI 关闭时:被阻塞,等待10秒
-- RCSI 开启时:立即返回修改前的数据
SELECT Status FROM Orders WHERE OrderID = 1
3.3 SNAPSHOT 隔离级别
sql
-- 启用快照隔离
ALTER DATABASE YourDB SET ALLOW_SNAPSHOT_ISOLATION ON;
-- 使用快照隔离(事务级别的一致性)
SET TRANSACTION ISOLATION LEVEL SNAPSHOT
BEGIN TRAN
-- 所有读取都基于事务开始时的快照
SELECT SUM(Amount) FROM Orders WHERE OrderDate = '2024-01-01'
-- 即使其他事务修改了数据,这里看到的一致
SELECT COUNT(*) FROM OrderDetails
WHERE OrderID IN (SELECT OrderID FROM Orders WHERE OrderDate = '2024-01-01')
COMMIT
RCSI vs SNAPSHOT:
| 特性 | RCSI | SNAPSHOT |
|---|---|---|
| 快照时机 | 每个语句开始 | 事务开始 |
| 语句内一致性 | ✅ | ✅ |
| 事务内跨语句一致性 | ❌ | ✅ |
| 更新冲突检测 | ❌ | ✅(更新冲突报错 3960) |
| 性能开销 | 低 | 中 |
| 推荐场景 | 绝大多数 OLTP | 需要事务级一致性的报表 |
四、死锁:开发人员必须会处理
4.1 典型死锁场景与预防
sql
-- 场景1:不同顺序访问资源(最常见死锁)
-- ❌ 容易死锁:事务A先更新表A再更新表B,事务B先更新表B再更新表A
-- ✅ 预防:统一访问顺序
-- 所有事务都按 Customer → Orders → OrderDetails 顺序访问
-- 场景2:高并发下的聚集索引插入
-- ❌ 最后页的热点插入导致死锁
-- ✅ 使用 GUID 或反转序列(如 NEWSEQUENTIALID)
-- 场景3:外键未索引
-- ❌ 子表插入时,父表需要检查外键,可能锁全表
-- ✅ 确保外键列有索引
4.2 应用层死锁重试(必须实现)
csharp
// 死锁重试模板(C#)
public async Task<T> ExecuteWithRetryAsync<T>(
Func<Task<T>> action,
int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
return await action();
}
catch (SqlException ex) when (ex.Number == 1205) // 死锁错误号
{
if (i == maxRetries - 1) throw;
// 指数退避:100ms, 200ms, 400ms
await Task.Delay(100 * (int)Math.Pow(2, i));
}
}
throw new Exception("Should not reach here");
}
// 使用示例
await ExecuteWithRetryAsync(async () => {
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync("usp_TransferMoney",
new { From = 1, To = 2, Amount = 100 },
commandType: CommandType.StoredProcedure);
});
4.3 捕获死锁图(DBA协助)
sql
-- 开启死锁跟踪(DBA操作)
DBCC TRACEON(1222, -1); -- 死锁信息写入错误日志
DBCC TRACEON(1204, -1); -- 详细格式
-- 从系统扩展事件中查询死锁
SELECT
CAST(target_data AS XML).value('(/EventFileTarget/File/@name)[1]', 'VARCHAR(255)') AS FileName
FROM sys.dm_xe_sessions s
JOIN sys.dm_xe_session_targets t ON s.address = t.event_session_address
WHERE s.name = 'system_health'
五、事务最佳实践
5.1 事务编写原则
sql
-- ✅ 好:短小、快速、只包含必要操作
CREATE PROC usp_TransferMoney
@From INT, @To INT, @Amount DECIMAL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON; -- 发生错误自动回滚
BEGIN TRY
BEGIN TRANSACTION;
-- 先更新高风险记录(减少死锁概率)
UPDATE Accounts SET Balance = Balance - @Amount WHERE AccountID = @From
UPDATE Accounts SET Balance = Balance + @Amount WHERE AccountID = @To
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK;
THROW;
END CATCH
END
-- ❌ 坏:长事务,包含用户交互
BEGIN TRAN
SELECT * FROM Orders WHERE OrderID = 1
-- 用户输入确认(10秒)
UPDATE Orders SET Status = 'Confirmed' WHERE OrderID = 1
COMMIT
-- 这10秒内锁一直持有,阻塞其他操作
5.2 避免隐式事务
sql
-- ❌ 坏:隐式事务开启(SET IMPLICIT_TRANSACTIONS ON)
SET IMPLICIT_TRANSACTIONS ON
SELECT * FROM Orders -- 自动开启事务
-- 忘记 COMMIT,事务一直开着
-- ✅ 好:显式事务 + 超时保护
BEGIN TRANSACTION
SELECT * FROM Orders
-- 设置会话超时
SET LOCK_TIMEOUT 5000 -- 5秒锁超时
COMMIT
5.3 使用 SET XACT_ABORT ON
sql
-- 默认行为:运行时错误只回滚当前语句,不回滚整个事务
BEGIN TRAN
UPDATE Accounts SET Balance = Balance - 100 WHERE AccountID = 1
INSERT INTO AuditLog VALUES ('Transfer') -- 假设这里出错
UPDATE Accounts SET Balance = Balance + 100 WHERE AccountID = 2 -- 这行还会执行!
COMMIT
-- 结果:事务未提交,但第一行 UPDATE 没有回滚
-- ✅ 正确:启用 XACT_ABORT
SET XACT_ABORT ON
BEGIN TRAN
UPDATE Accounts SET Balance = Balance - 100 WHERE AccountID = 1
INSERT INTO AuditLog VALUES ('Transfer') -- 出错
UPDATE Accounts SET Balance = Balance + 100 WHERE AccountID = 2 -- 跳过
COMMIT
-- 错误发生时自动回滚整个事务
六、监控事务与锁
6.1 开发人员常用诊断脚本
sql
-- 查看当前阻塞(开发自用)
SELECT
blocked.session_id AS BlockedSession,
blocking.session_id AS BlockingSession,
blocked.wait_time / 1000 AS WaitSeconds,
blocked.wait_type,
(SELECT TEXT FROM sys.dm_exec_sql_text(blocked.sql_handle)) AS BlockedSQL,
(SELECT TEXT FROM sys.dm_exec_sql_text(blocking.sql_handle)) AS BlockingSQL
FROM sys.dm_exec_requests blocked
JOIN sys.dm_exec_requests blocking
ON blocked.blocking_session_id = blocking.session_id
WHERE blocked.blocking_session_id > 0
-- 查看当前打开的事务
SELECT
session_id,
transaction_id,
transaction_begin_time,
transaction_type,
transaction_state
FROM sys.dm_tran_active_transactions tat
JOIN sys.dm_tran_session_transactions tst
ON tat.transaction_id = tst.transaction_id
-- 查看锁(按对象分组)
SELECT
OBJECT_NAME(p.object_id) AS TableName,
COUNT(*) AS LockCount,
request_mode,
request_status
FROM sys.dm_tran_locks l
JOIN sys.partitions p ON l.resource_associated_entity_id = p.hobt_id
WHERE resource_type = 'OBJECT'
GROUP BY p.object_id, request_mode, request_status
ORDER BY LockCount DESC
6.2 查看锁升级
sql
-- 锁升级阈值(超过5000行可能从行锁升级到表锁)
SELECT
lock_escalation_desc,
lock_escalation_threshold
FROM sys.tables
WHERE name = 'Orders'
-- 禁用锁升级(谨慎!)
ALTER TABLE Orders SET (LOCK_ESCALATION = DISABLE);
七、完整实战:秒杀系统
sql
-- 场景:限量抢购(100件商品,高并发)
-- 方案1:乐观锁(适合冲突不高的场景)
CREATE PROC usp_Seckill_Optimistic
@ProductID INT,
@UserID INT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Version INT, @Stock INT;
DECLARE @Retry INT = 0;
DECLARE @MaxRetry INT = 3;
WHILE @Retry < @MaxRetry
BEGIN
-- 读取当前库存和版本
SELECT @Stock = Stock, @Version = Version
FROM Products WHERE ProductID = @ProductID
IF @Stock <= 0
THROW 50001, '已售罄', 1
-- 尝试扣减(乐观锁)
UPDATE Products
SET Stock = Stock - 1,
Version = Version + 1
WHERE ProductID = @ProductID
AND Version = @Version
AND Stock > 0
IF @@ROWCOUNT > 0
BEGIN
-- 记录订单
INSERT INTO Orders (ProductID, UserID, OrderDate)
VALUES (@ProductID, @UserID, GETDATE())
RETURN 0 -- 成功
END
SET @Retry = @Retry + 1
WAITFOR DELAY '00:00:00.050' -- 50ms 后重试
END
THROW 50002, '系统繁忙,请重试', 1
END
-- 方案2:悲观锁 + 队列(适合高并发)
-- 使用 Service Broker 或 Redis 队列削峰
八、核心总结
| 知识点 | 核心要点 |
|---|---|
| 丢失更新 | 乐观锁(ROWVERSION)或悲观锁(UPDLOCK) |
| RCSI | 强烈推荐开启,读写不互斥 |
| SNAPSHOT | 事务级一致性,适合报表 |
| 死锁预防 | 统一访问顺序、短事务、合理索引 |
| 死锁处理 | 应用层重试(错误号 1205) |
| XACT_ABORT | 错误时自动回滚,建议开启 |
| 长事务 | 绝对避免,会导致锁积累 |
一句话记住本期内容(开发系列收官):
开发人员的事务三定律:开启 RCSI 避免读写阻塞,用乐观锁防止丢失更新,写重试逻辑处理死锁。
系列回顾与进阶方向
SQL Server 开发系列八期回顾
| 期数 | 主题 | 核心技能 |
|---|---|---|
| 1 | 数据类型 | 选对类型,避免隐式转换 |
| 2 | T-SQL 编程 | 变量、流程、动态 SQL |
| 3 | DML 语句 | 批量操作、MERGE、OUTPUT |
| 4 | 连接与子查询 | JOIN 算法、EXISTS vs IN |
| 5 | 窗口函数 | 排名、累计、移动计算 |
| 6 | 存储过程与函数 | 参数嗅探、内联表值函数 |
| 7 | 触发器与约束 | 审计日志、复杂验证 |
| 8 | 事务与并发 | 乐观锁、死锁重试、RCSI |
后续学习建议
- 性能调优:结合《性能优化系列》深入学习执行计划、等待统计
- 高级特性:列存储索引、内存优化表、JSON/XML 处理
- 云数据库:Azure SQL Database 的自动调优、无服务器计算
- 数据架构:分区表、分布式查询、数据仓库
📌 本文代码已在 SQL Server 2019+ 验证。事务处理是数据库开发中最容易出错的环节,建议在测试环境充分验证并发场景。
《SQL Server 开发系列》到此完结。感谢你的阅读,希望这八期内容能帮助你在日常开发中写出更高效、更可靠的 T-SQL 代码。