64、SQL Server事务与锁:事务隔离级别、锁类型与死锁处理

说实话,事务和锁这块,是很多C语言开发者做数据库编程时最容易翻车的地方。我自己早年做嵌入式数据库对接时,就被死锁坑过好几次。今天咱们就把SQL Server里的事务隔离级别、锁类型,还有死锁检测与处理,一次性讲透。

一、事务隔离级别——你到底能看见什么?

事务隔离级别,说白了就是控制「一个事务能看到其他事务的哪些中间状态」。SQL Server支持四种隔离级别,从松到严依次是:

隔离级别脏读不可重复读幻读
READ UNCOMMITTED可能可能可能
READ COMMITTED(默认)不可能可能可能
REPEATABLE READ不可能不可能可能
SERIALIZABLE不可能不可能不可能

我个人的习惯是:生产环境至少用READ COMMITTED。READ UNCOMMITTED虽然性能好,但脏读风险太大——你读到别人回滚的数据,那后果你想想看。

重点:SQL Server默认隔离级别就是READ COMMITTED。如果你不做任何设置,就是这个级别。

设置隔离级别的语法很简单:

-- 设置当前会话的隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 开始事务
BEGIN TRANSACTION;
    -- 你的SQL操作
    SELECT * FROM Orders WHERE OrderID = 1001;
COMMIT TRANSACTION;

我曾经在一个订单系统里,因为用了REPEATABLE READ,导致大量锁等待。后来改成READ COMMITTED配合快照隔离,问题就解决了。嗯,这里要注意:隔离级别越高,并发性能越差

二、锁类型——共享锁与排他锁

锁是SQL Server实现隔离的底层机制。最常见的两种锁:

  • 共享锁(S锁):用于读取操作。多个事务可以同时持有共享锁,互不干扰。
  • 排他锁(X锁):用于写入操作(INSERT、UPDATE、DELETE)。一个事务持有排他锁时,其他事务不能加任何锁。

举个例子你就明白了:

-- 事务A:读取数据,加共享锁
BEGIN TRANSACTION;
    SELECT * FROM Products WHERE ProductID = 10;
    -- 此时ProductID=10这行被加了共享锁
    -- 事务B可以同时读,但不能写
COMMIT TRANSACTION;

-- 事务B:更新数据,需要排他锁
BEGIN TRANSACTION;
    UPDATE Products SET Price = 99.9 WHERE ProductID = 10;
    -- 如果事务A还没提交,事务B会等待
COMMIT TRANSACTION;

小技巧:SELECT ... WITH (NOLOCK) 可以跳过共享锁,直接读取未提交的数据。但小心脏读!我一般只在报表查询、不要求绝对精确的场景用。

除了S锁和X锁,SQL Server还有意向锁、架构锁等。但日常编程中,你主要跟S锁和X锁打交道。

三、死锁检测与处理——别让程序卡死

死锁是什么?两个事务互相等待对方释放锁,谁也动不了。我当年第一次遇到死锁时,整个系统卡了十几秒,用户电话都打爆了。

SQL Server有内置的死锁检测机制。它每隔5秒扫描一次,如果发现死锁,会选择一个事务作为「牺牲品」,回滚它,让另一个事务继续。

死锁的经典场景:

-- 事务A:先更新表1,再更新表2
BEGIN TRANSACTION;
    UPDATE Table1 SET Col1 = 1 WHERE ID = 1;
    -- 此时事务A持有Table1的排他锁
    WAITFOR DELAY '00:00:05';  -- 模拟延迟
    UPDATE Table2 SET Col1 = 1 WHERE ID = 2;
COMMIT TRANSACTION;

-- 事务B:先更新表2,再更新表1
BEGIN TRANSACTION;
    UPDATE Table2 SET Col1 = 2 WHERE ID = 2;
    -- 此时事务B持有Table2的排他锁
    WAITFOR DELAY '00:00:05';
    UPDATE Table1 SET Col1 = 2 WHERE ID = 1;
COMMIT TRANSACTION;

两个事务同时跑,死锁就发生了。SQL Server会检测到,然后回滚其中一个。

避坑指南:我曾经在C代码里没处理死锁异常,结果程序直接崩溃。记住:所有数据库操作都要捕获死锁错误(错误号1205),然后重试

在C语言中处理死锁的典型代码:

// 伪代码:带重试机制的数据库操作
int retry_count = 0;
int max_retries = 3;

while (retry_count < max_retries) {
    if (SQL_SUCCESS == SQLExecDirect(hstmt, 
        "UPDATE Products SET Stock = Stock - 1 WHERE ProductID = 10", 
        SQL_NTS)) {
        break;  // 成功,退出循环
    }
    
    // 检查是否是死锁错误(1205)
    if (get_sql_error_code(hstmt) == 1205) {
        retry_count++;
        printf("检测到死锁,第%d次重试...\n", retry_count);
        Sleep(100);  // 等待100ms后重试
    } else {
        // 其他错误,直接退出
        break;
    }
}

if (retry_count >= max_retries) {
    printf("重试次数耗尽,操作失败\n");
}

我个人建议:重试次数不要超过3次,每次间隔100-200ms。太多重试反而会加重系统负担。

四、如何避免死锁?

与其等死锁发生再处理,不如从源头避免。我总结了几个实战经验:

  • 按相同顺序访问资源:所有事务都先更新表1、再更新表2,就不会互相等待。
  • 缩短事务时间:事务里只放必要的操作,别在事务里做耗时的计算或网络请求。
  • 使用较低的隔离级别:如果业务允许,用READ COMMITTED代替SERIALIZABLE。
  • 加索引:没有索引会导致表锁,死锁概率大增。

我的习惯:在C代码里,我会把事务操作封装成一个函数,内部自动处理死锁重试。这样调用方不用关心底层细节,代码也干净很多。

五、知识体系总览

下面这张图帮你理清事务与锁的核心脉络:

SQL Server 事务与锁知识体系 事务隔离级别 • READ UNCOMMITTED(脏读) • READ COMMITTED(默认) • REPEATABLE READ • SERIALIZABLE 锁类型 • 共享锁(S锁)— 读操作 • 排他锁(X锁)— 写操作 • 意向锁(表级锁) • 架构锁(DDL操作) 死锁检测与处理 • 自动检测(5秒周期) • 选择牺牲品回滚 • 错误号1205 • 重试机制(≤3次) 实战要点总结 1. 隔离级别越高,数据一致性越好,但并发性能越差 2. 共享锁不阻塞共享锁,但阻塞排他锁 3. 死锁不可避免,但可以通过统一访问顺序、缩短事务来降低概率 4. C代码中务必捕获1205错误并实现重试逻辑

好了,关于SQL Server事务与锁的核心内容就这些。隔离级别决定了你能看到什么,锁类型决定了别人能不能动你的数据,而死锁处理则是保底手段。把这三点吃透,你在C语言里操作SQL Server基本不会出大问题。

公众号:蓝海资料掘金营,微信deep3321