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

0 阅读10分钟

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

上一期我们学习了触发器和约束。这一期,作为开发系列的收官之作,我们来到并发编程的核心——事务与并发控制。你是否遇到过:用户明明查询了余额,扣款时却提示余额不足?两个用户同时抢购最后一件商品,都显示成功?应用莫名其妙报死锁错误?这些问题,都源于你对事务隔离级别和并发控制的理解不足。这一期,我们从开发人员视角,深入实战中的事务处理。

一、开发人员必须理解的并发问题

1.1 三种读现象回顾

现象定义业务影响
脏读读到未提交的数据看到"幽灵"数据,可能被回滚
不可重复读同一事务内两次读取不一致基于第一次读取的业务判断失效
幻读同一事务内行数变化分页、统计结果不一致

1.2 丢失更新(Lost Update)—— 最容易被忽视

-- 场景:两个用户同时修改同一篇文章

-- 事务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)

思路:假设冲突一定会发生,读取时就加锁。

-- 使用 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)

思路:假设冲突很少发生,更新时检查版本。

-- 方案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 乐观锁完整实现(应用层重试)

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
// 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(默认)平衡性能和一致性
高并发 OLTPREAD COMMITTED SNAPSHOT读写不互斥,推荐开启
账户余额查询SNAPSHOT事务内一致性
订单扣库存REPEATABLE READ防止不可重复读
金额统计汇总SERIALIZABLE防止幻读
分布式系统应用层分布式锁数据库无法解决

3.2 启用 READ_COMMITTED_SNAPSHOT(强烈推荐)

-- 检查当前设置
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 前后对比

-- 事务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 隔离级别

-- 启用快照隔离
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

特性RCSISNAPSHOT
快照时机每个语句开始事务开始
语句内一致性
事务内跨语句一致性
更新冲突检测✅(更新冲突报错 3960)
性能开销
推荐场景绝大多数 OLTP需要事务级一致性的报表

四、死锁:开发人员必须会处理

4.1 典型死锁场景与预防

-- 场景1:不同顺序访问资源(最常见死锁)

-- ❌ 容易死锁:事务A先更新表A再更新表B,事务B先更新表B再更新表A

-- ✅ 预防:统一访问顺序
-- 所有事务都按 Customer → Orders → OrderDetails 顺序访问

-- 场景2:高并发下的聚集索引插入
-- ❌ 最后页的热点插入导致死锁
-- ✅ 使用 GUID 或反转序列(如 NEWSEQUENTIALID)

-- 场景3:外键未索引
-- ❌ 子表插入时,父表需要检查外键,可能锁全表
-- ✅ 确保外键列有索引

4.2 应用层死锁重试(必须实现)

// 死锁重试模板(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协助)

-- 开启死锁跟踪(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 事务编写原则

-- ✅ 好:短小、快速、只包含必要操作
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 避免隐式事务

-- ❌ 坏:隐式事务开启(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

-- 默认行为:运行时错误只回滚当前语句,不回滚整个事务
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 开发人员常用诊断脚本

-- 查看当前阻塞(开发自用)
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 查看锁升级

-- 锁升级阈值(超过5000行可能从行锁升级到表锁)
SELECT 
    lock_escalation_desc,
    lock_escalation_threshold
FROM sys.tables
WHERE name = 'Orders'

-- 禁用锁升级(谨慎!)
ALTER TABLE Orders SET (LOCK_ESCALATION = DISABLE);

七、完整实战:秒杀系统

-- 场景:限量抢购(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数据类型选对类型,避免隐式转换
2T-SQL 编程变量、流程、动态 SQL
3DML 语句批量操作、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 代码。