SQL Server 开发系列(第八期):事务与并发控制(开发视角)——避免死锁与数据异常

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

后续学习建议

  1. 性能调优:结合《性能优化系列》深入学习执行计划、等待统计
  2. 高级特性:列存储索引、内存优化表、JSON/XML 处理
  3. 云数据库:Azure SQL Database 的自动调优、无服务器计算
  4. 数据架构:分区表、分布式查询、数据仓库

📌 本文代码已在 SQL Server 2019+ 验证。事务处理是数据库开发中最容易出错的环节,建议在测试环境充分验证并发场景。

《SQL Server 开发系列》到此完结。感谢你的阅读,希望这八期内容能帮助你在日常开发中写出更高效、更可靠的 T-SQL 代码。

相关推荐
z4424753262 小时前
如何高效进行堆叠分类器的超参数调优:解决 GridSearchCV 卡顿问题
jvm·数据库·python
2301_775148152 小时前
如何操作 XML 数据_XMLTYPE 与 EXTRACT 函数解析节点
jvm·数据库·python
2402_854808372 小时前
怎么利用Navicat进行调整备份文件压缩等级_详细配置与操作步骤
jvm·数据库·python
pele2 小时前
mysql如何使用INNER JOIN内连接_mysql等值连接实现方式
jvm·数据库·python
maqr_1102 小时前
uni-app怎么解决H5端跨域请求 uni-app生产环境代理配置【解决】
jvm·数据库·python
Irissgwe2 小时前
redis之事务
数据库·redis·缓存
2401_883600252 小时前
SQL视图名称冲突如何避免_建立规范化的命名空间与管理
jvm·数据库·python
殷紫川2 小时前
PostgreSQL vs MySQL:Java 选型指南与实战避坑
数据库·mysql·postgresql
Treh UNFO2 小时前
SQL 注入详解:原理、危害与防范措施
数据库·sql·oracle