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