数据库事务隔离级别深入
数据库事务隔离级别深入
简介
事务隔离级别(Transaction Isolation Level)是数据库并发控制的核心机制,它决定了多个事务同时访问相同数据时的行为方式。隔离级别在数据一致性和并发性能之间做出权衡——隔离级别越高,数据一致性越强,但并发性能越低;隔离级别越低,并发性能越好,但可能出现数据异常现象。
理解事务隔离级别不仅对数据库管理员至关重要,对应用开发者同样不可或缺。选择不当的隔离级别可能导致数据损坏、业务逻辑错误或性能瓶颈。本文将从原理到实践,全面深入地剖析数据库事务隔离机制。
ACID 中的隔离性(Isolation)
ACID 是数据库事务的四个基本特性:
- 原子性(Atomicity):事务是不可分割的最小执行单位
- 一致性(Consistency):事务执行前后数据库保持一致状态
- 隔离性(Isolation):并发事务之间互不干扰
- 持久性(Durability):事务提交后结果永久保存
其中,隔离性是最复杂也是最有弹性的一个特性。SQL 标准定义了四种隔离级别,每种级别对隔离性的保证程度不同。
-- 查看当前会话的隔离级别(MySQL)
SELECT @@transaction_isolation;
-- 查看全局隔离级别
SELECT @@global.transaction_isolation;
-- 设置当前会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;-- PostgreSQL 查看和设置隔离级别
SHOW transaction_isolation;
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 执行事务操作
COMMIT;-- SQL Server 查看和设置隔离级别
DBCC USEROPTIONS;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;读现象(Read Phenomena)
SQL 标准定义了三种并发事务可能导致的数据异常现象,正是这些现象的存在,才需要不同的隔离级别来加以防范。
脏读(Dirty Read)
脏读是指一个事务读取到了另一个事务尚未提交的数据。如果那个事务随后回滚,那么读到的数据就是无效的"脏数据"。
-- 事务 A(时间线 T1)
BEGIN TRANSACTION;
UPDATE Accounts SET Balance = Balance - 500 WHERE AccountId = 1;
-- 此时 Balance 从 1000 变为 500(未提交)
-- 事务 B(时间线 T2,隔离级别 READ UNCOMMITTED)
BEGIN TRANSACTION;
SELECT Balance FROM Accounts WHERE AccountId = 1;
-- 读到 500(脏读!事务 A 可能回滚)
-- 事务 A(时间线 T3)
ROLLBACK;
-- 事务 B 读到的 500 是无效数据// .NET 中演示脏读风险
using var connection = new SqlConnection(connectionString);
// 会话1:开启事务但不提交
await connection.OpenAsync();
using var transaction1 = connection.BeginTransaction(IsolationLevel.ReadUncommitted);
var command1 = new SqlCommand("UPDATE Accounts SET Balance = 500 WHERE Id = 1", connection, transaction1);
await command1.ExecuteNonQueryAsync();
// 会话2:在 ReadUncommitted 下可以读到未提交数据
using var connection2 = new SqlConnection(connectionString);
await connection2.OpenAsync();
using var transaction2 = connection2.BeginTransaction(IsolationLevel.ReadUncommitted);
var command2 = new SqlCommand("SELECT Balance FROM Accounts WHERE Id = 1", connection2, transaction2);
var balance = await command2.ExecuteScalarAsync();
// balance 可能是 500(脏读)不可重复读(Non-repeatable Read)
不可重复读是指在一个事务内,两次读取同一行数据得到了不同的结果,因为另一个事务在这两次读取之间修改并提交了该行。
-- 事务 A
BEGIN TRANSACTION;
SELECT Balance FROM Accounts WHERE AccountId = 1; -- 结果:1000
-- 事务 B(同时执行)
BEGIN TRANSACTION;
UPDATE Accounts SET Balance = 800 WHERE AccountId = 1;
COMMIT;
-- 事务 A(再次读取同一行)
SELECT Balance FROM Accounts WHERE AccountId = 1; -- 结果:800(不可重复读!)
COMMIT;幻读(Phantom Read)
幻读是指在一个事务内,两次执行相同的查询返回了不同的行集合,因为另一个事务插入或删除了满足查询条件的数据行。
-- 事务 A
BEGIN TRANSACTION;
SELECT * FROM Orders WHERE Amount > 1000; -- 返回 5 行
-- 事务 B(同时执行)
BEGIN TRANSACTION;
INSERT INTO Orders (OrderId, Amount, CustomerId) VALUES (999, 1500, 10);
COMMIT;
-- 事务 A(再次执行相同查询)
SELECT * FROM Orders WHERE Amount > 1000; -- 返回 6 行(幻读!)
COMMIT;三种读现象的对比
| 读现象 | 描述 | 影响范围 |
|---|---|---|
| 脏读 | 读到未提交的数据 | 单行数据 |
| 不可重复读 | 同一事务内两次读同一行结果不同 | 单行数据 |
| 幻读 | 同一事务内两次查询行集合不同 | 多行数据 |
四种隔离级别
SQL 标准定义了四种隔离级别,从低到高对读现象的防护逐步增强。
读未提交(READ UNCOMMITTED)
最低的隔离级别,允许脏读。事务可以读取其他事务未提交的修改。
-- MySQL 中设置读未提交
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT * FROM Products WHERE ProductId = 100;
-- 可以读到其他事务未提交的修改
COMMIT;// EF Core 中使用读未提交
using var context = new AppDbContext();
using var transaction = context.Database.BeginTransaction(IsolationLevel.ReadUncommitted);
var product = await context.Products.FindAsync(100);
// 可能读到其他事务未提交的数据
await transaction.CommitAsync();适用场景:近乎实时的数据分析、监控大屏展示等对数据精确度要求不高但对实时性要求极高的场景。
读已提交(READ COMMITTED)
大多数数据库的默认隔离级别。事务只能读取已经提交的数据,防止了脏读,但允许不可重复读和幻读。
-- PostgreSQL 默认隔离级别就是 READ COMMITTED
BEGIN;
SELECT Balance FROM Accounts WHERE AccountId = 1; -- 读取已提交的快照
-- 其他事务此时修改并提交了数据
SELECT Balance FROM Accounts WHERE AccountId = 1; -- 可能得到不同结果
COMMIT;// .NET 中使用读已提交(SQL Server 默认)
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connectionString)
.Options;
using var context = new AppDbContext(options);
using var transaction = context.Database.BeginTransaction(IsolationLevel.ReadCommitted);
try
{
var account = await context.Accounts.FindAsync(1);
Console.WriteLine($"第一次读取: {account.Balance}");
// 模拟其他事务修改数据...
await Task.Delay(2000);
context.Entry(account).Reload();
Console.WriteLine($"重新读取: {account.Balance}");
// 可能得到不同的值
await transaction.CommitAsync();
}
catch (Exception)
{
await transaction.RollbackAsync();
}可重复读(REPEATABLE READ)
保证在同一事务内多次读取同一行数据的结果一致。MySQL/InnoDB 的默认隔离级别。
-- MySQL 默认隔离级别
BEGIN;
SELECT Balance FROM Accounts WHERE AccountId = 1; -- 读取并记录快照
-- 其他事务修改并提交
-- UPDATE Accounts SET Balance = 500 WHERE AccountId = 1; COMMIT;
SELECT Balance FROM Accounts WHERE AccountId = 1; -- 仍然是原来的值
COMMIT;注意:MySQL InnoDB 的 REPEATABLE READ 通过 Next-Key Locking 在一定程度上防止了幻读,这超出了 SQL 标准的要求。
-- MySQL InnoDB 使用间隙锁防止幻读
BEGIN;
SELECT * FROM Orders WHERE Amount BETWEEN 100 AND 500 FOR UPDATE;
-- InnoDB 会锁定 (100, 500) 范围内的间隙
-- 其他事务无法在此范围内插入新行
-- 另一个事务尝试插入
-- INSERT INTO Orders (Amount) VALUES (200); -- 被阻塞
COMMIT;可串行化(SERIALIZABLE)
最高的隔离级别,事务完全串行执行,消除了所有读现象,但并发性能最低。
-- 设置可串行化隔离级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM Orders WHERE Amount > 1000;
-- 所有匹配行被加上共享锁,阻止其他事务修改
-- 其他事务尝试修改
-- UPDATE Orders SET Amount = 900 WHERE OrderId = 5; -- 被阻塞
COMMIT;隔离级别对比表
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能影响 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 最低 |
| READ COMMITTED | 防止 | 可能 | 可能 | 低 |
| REPEATABLE READ | 防止 | 防止 | 可能* | 中等 |
| SERIALIZABLE | 防止 | 防止 | 防止 | 最高 |
*注:MySQL InnoDB 的 REPEATABLE READ 通过间隙锁在一定程度上防止了幻读。
MVCC(多版本并发控制)
MVCC(Multi-Version Concurrency Control)是现代数据库实现事务隔离的核心技术。它通过保存数据的多个版本,让读操作不需要加锁就能获取一致的数据快照,极大提升了并发性能。
MVCC 基本原理
数据行版本链示例:
Row: AccountId = 1, Balance = 1000
┌──────────────────────────────────────────────┐
│ TxnId: 100 | Balance: 1000 | Ptr: null │ ← 最新已提交版本
├──────────────────────────────────────────────┤
│ TxnId: 85 | Balance: 1200 | Ptr: ↑ │ ← 历史版本
├──────────────────────────────────────────────┤
│ TxnId: 60 | Balance: 1500 | Ptr: ↑ │ ← 更早的历史版本
└──────────────────────────────────────────────┘MySQL InnoDB 的 MVCC 实现
InnoDB 为每行数据添加了三个隐藏列:
- DB_TRX_ID:最后修改该行的事务 ID
- DB_ROLL_PTR:回滚指针,指向 undo log 中的前一个版本
- DB_ROW_ID:隐藏自增 ID(如果没有主键)
-- 查看 InnoDB 的事务状态
SELECT * FROM information_schema.INNODB_TRX;
-- 查看 InnoDB 锁状态
SELECT * FROM performance_schema.data_locks;
-- 查看 InnoDB 锁等待
SELECT * FROM performance_schema.data_lock_waits;// 模拟 MVCC 快照读行为
public class InnoDBRow
{
public int AccountId { get; set; }
public decimal Balance { get; set; }
public long DataTrxId { get; set; } // DB_TRX_ID
public long RollbackPtr { get; set; } // DB_ROLL_PTR
public long DataRowId { get; set; } // DB_ROW_ID
}
public class MVCCDemo
{
// Read View 决定事务能看到哪个版本
public bool IsVisible(InnoDBRow row, long readViewTrxId,
long minTrxId, long maxTrxId, HashSet<long> activeTrxIds)
{
long rowTrxId = row.DataTrxId;
// 版本事务ID < 最小活跃事务ID,说明在创建Read View时已提交
if (rowTrxId < minTrxId)
return true;
// 版本事务ID >= 最大事务ID,说明在创建Read View之后才开始
if (rowTrxId >= maxTrxId)
return false;
// 版本事务ID在活跃事务列表中,说明创建Read View时还未提交
if (activeTrxIds.Contains(rowTrxId))
return false;
return true;
}
}PostgreSQL 的 MVCC 实现
PostgreSQL 使用更激进的 MVCC 策略,每个元组(tuple)都带有 xmin 和 xmax 标记:
-- PostgreSQL 查看行的版本信息
SELECT xmin, xmax, ctid, * FROM Accounts WHERE AccountId = 1;
-- xmin: 插入该行的事务ID
-- xmax: 删除/更新该行的事务ID(0表示仍然有效)
-- ctid: 行的物理位置(块号,偏移量)-- PostgreSQL 中查看事务ID和快照
SELECT txid_current(); -- 当前事务ID
SELECT txid_current_snapshot(); -- 当前快照信息
-- 查看表的膨胀(dead tuples)
SELECT n_dead_tup, n_live_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'accounts';快照隔离(Snapshot Isolation)
快照隔离是 MVCC 的一个应用,事务读取的是开始时刻的数据快照,而不是当前最新数据。
// SQL Server 中的快照隔离配置
public class SnapshotIsolationDemo
{
public async Task EnableSnapshotIsolation(string connectionString)
{
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
// 启用数据库的快照隔离
var command = new SqlCommand(
"ALTER DATABASE MyDatabase SET ALLOW_SNAPSHOT_ISOLATION ON",
connection);
await command.ExecuteNonQueryAsync();
// 启用 RCSI(Read Committed Snapshot Isolation)
command = new SqlCommand(
"ALTER DATABASE MyDatabase SET READ_COMMITTED_SNAPSHOT ON",
connection);
await command.ExecuteNonQueryAsync();
}
public async Task UseSnapshotIsolation(string connectionString)
{
using var context = new AppDbContext();
// 使用快照隔离
using var transaction = context.Database
.BeginTransaction(IsolationLevel.Snapshot);
try
{
var account = await context.Accounts.FindAsync(1);
// 此时读取的是事务开始时的快照
// 其他事务的修改不会影响此读取
account.Balance -= 100;
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (DbUpdateConcurrencyException)
{
// 快照隔离下的更新冲突
await transaction.RollbackAsync();
throw;
}
}
}行版本管理(Row Versioning)
-- SQL Server 中监控行版本开销
SELECT * FROM sys.dm_tran_version_store;
-- 查看版本存储的空间使用
SELECT
database_id,
reserved_page_count,
reserved_space_kb
FROM sys.dm_tran_version_store_space_usage;隔离级别选择指南
按业务场景选择
业务场景决策树:
是否需要精确的事务一致性?
├── 否 → READ UNCOMMITTED(监控、统计大屏)
└── 是 → 是否允许可重复读问题?
├── 否 → 是否数据冲突频繁?
│ ├── 是 → SERIALIZABLE 或 乐观并发
│ └── 否 → REPEATABLE READ
└── 是 → READ COMMITTED(大多数场景的默认选择)不同数据库默认隔离级别
| 数据库 | 默认隔离级别 | 快照隔离支持 |
|---|---|---|
| MySQL/InnoDB | REPEATABLE READ | 支持(MVCC) |
| PostgreSQL | READ COMMITTED | 支持(MVCC) |
| SQL Server | READ COMMITTED | 支持(RCSI) |
| Oracle | READ COMMITTED | 支持(MVCC) |
SQL Server/MySQL/PostgreSQL 隔离级别对比
SQL Server 的特殊隔离级别
-- SQL Server 除了标准四种,还支持快照隔离和 RCSI
-- 1. 启用 RCSI(推荐方式)
ALTER DATABASE MyDb SET READ_COMMITTED_SNAPSHOT ON;
-- 之后默认的 READ COMMITTED 就使用行版本而非锁
-- 2. 启用快照隔离
ALTER DATABASE MyDb SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
-- 3. 使用 NOLOCK 提示(等同于 READ UNCOMMITTED)
SELECT * FROM Orders WITH (NOLOCK) WHERE Amount > 1000;
-- 4. 使用 UPDLOCK 提示(悲观并发控制)
BEGIN TRANSACTION;
SELECT * FROM Accounts WITH (UPDLOCK) WHERE AccountId = 1;
-- 获取更新锁,阻止其他事务获取更新锁或排他锁
UPDATE Accounts SET Balance = Balance - 100 WHERE AccountId = 1;
COMMIT;MySQL InnoDB 的特殊行为
-- MySQL 的 REPEATABLE READ 实际上防止了大部分幻读
-- 当前读 vs 快照读
-- 快照读:普通 SELECT,读取 MVCC 快照
SELECT * FROM Orders WHERE Amount > 1000;
-- 当前读:加锁读取,读取最新已提交数据
SELECT * FROM Orders WHERE Amount > 1000 FOR UPDATE; -- 排他锁
SELECT * FROM Orders WHERE Amount > 1000 LOCK IN SHARE MODE; -- 共享锁(MySQL 5.7)
SELECT * FROM Orders WHERE Amount > 1000 FOR SHARE; -- MySQL 8.0+
-- Next-Key Lock 示例
BEGIN;
SELECT * FROM Orders WHERE OrderId BETWEEN 100 AND 200 FOR UPDATE;
-- 锁定 (100, 200] 以及前后间隙
-- 阻止其他事务在此范围插入或修改
COMMIT;PostgreSQL 的特殊隔离
-- PostgreSQL 的 SERIALIZABLE 使用 SSI(Serializable Snapshot Isolation)
-- 不会阻塞读操作,而是检测到串行化异常时回滚
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM Accounts WHERE AccountId = 1;
-- 如果检测到串行化异常
-- ERROR: could not serialize access due to concurrent update
-- 应用需要重试逻辑// PostgreSQL 可串行化事务的重试模式
public class PgSerializableRetry
{
public async Task ExecuteWithRetry(
string connString,
Func<NpgsqlConnection, NpgsqlTransaction, Task> operation,
int maxRetries = 3)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
using var connection = new NpgsqlConnection(connString);
await connection.OpenAsync();
using var transaction = await connection.BeginTransactionAsync(
IsolationLevel.Serializable);
await operation(connection, transaction);
await transaction.CommitAsync();
return; // 成功则退出
}
catch (PostgresException ex) when (
ex.SqlState == "40001") // serialization_failure
{
if (attempt == maxRetries)
throw new InvalidOperationException(
$"操作在 {maxRetries} 次重试后仍然失败", ex);
await Task.Delay(TimeSpan.FromMilliseconds(
Random.Shared.Next(50, 200)));
}
}
}
}乐观并发 vs 悲观并发
悲观并发控制(Pessimistic Concurrency)
悲观并发认为冲突大概率会发生,因此提前加锁阻止冲突。
-- 悲观并发:使用 SELECT FOR UPDATE
BEGIN;
SELECT Balance FROM Accounts WHERE AccountId = 1 FOR UPDATE;
-- 获取排他锁,其他事务被阻塞
UPDATE Accounts SET Balance = Balance - 500 WHERE AccountId = 1;
COMMIT;// EF Core 中的悲观并发
public class PessimisticConcurrency
{
public async Task TransferFunds(int fromId, int toId, decimal amount)
{
using var context = new AppDbContext();
using var transaction = await context.Database.BeginTransactionAsync();
try
{
// 使用原始 SQL 获取排他锁
var fromAccount = await context.Accounts
.FromSqlRaw("SELECT * FROM Accounts WITH (UPDLOCK) WHERE AccountId = {0}", fromId)
.FirstAsync();
var toAccount = await context.Accounts
.FromSqlRaw("SELECT * FROM Accounts WITH (UPDLOCK) WHERE AccountId = {0}", toId)
.FirstAsync();
fromAccount.Balance -= amount;
toAccount.Balance += amount;
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}乐观并发控制(Optimistic Concurrency)
乐观并发认为冲突很少发生,只在提交时检测冲突。
// EF Core 乐观并发配置
public class Account
{
public int AccountId { get; set; }
public decimal Balance { get; set; }
// 乐观并发令牌
[Timestamp]
public byte[] RowVersion { get; set; }
}
// 或者使用 Fluent API
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Account>()
.Property(a => a.RowVersion)
.IsRowVersion()
.IsConcurrencyToken();
}
}// 乐观并发冲突处理
public class OptimisticConcurrencyHandler
{
public async Task UpdateAccount(int accountId, Action<Account> updateAction)
{
bool saved = false;
int retryCount = 0;
while (!saved && retryCount < 3)
{
try
{
using var context = new AppDbContext();
var account = await context.Accounts.FindAsync(accountId);
updateAction(account);
await context.SaveChangesAsync();
saved = true;
}
catch (DbUpdateConcurrencyException ex)
{
retryCount++;
foreach (var entry in ex.Entries)
{
// 策略1:数据库优先(放弃本地修改)
await entry.ReloadAsync();
// 策略2:客户端优先(覆盖数据库)
// entry.OriginalValues.SetValues(
// await entry.GetDatabaseValuesAsync());
// 策略3:合并
// var dbValues = await entry.GetDatabaseValuesAsync();
// var currentValues = entry.CurrentValues;
// var originalValues = entry.OriginalValues;
// 手动合并逻辑...
}
}
}
if (!saved)
throw new InvalidOperationException("并发冲突无法解决");
}
}乐观 vs 悲观选择指南
| 考虑因素 | 乐观并发 | 悲观并发 |
|---|---|---|
| 冲突频率 | 低冲突 | 高冲突 |
| 锁持有时间 | 短 | 可以较长 |
| 死锁风险 | 无 | 需要注意 |
| 实现复杂度 | 中等(需要冲突处理) | 简单 |
| 用户体验 | 可能需要重试 | 等待锁释放 |
| 适用场景 | Web应用、移动端 | 金融交易、库存扣减 |
.NET 事务隔离与 EF Core
EF Core 中的事务管理
// 基本事务使用
public class OrderService
{
public async Task PlaceOrder(Order order)
{
using var context = new AppDbContext();
// EF Core 的 SaveChanges 自动使用事务
// 多个操作会在一个事务内
context.Orders.Add(order);
foreach (var item in order.Items)
{
var product = await context.Products.FindAsync(item.ProductId);
product.Stock -= item.Quantity;
}
await context.SaveChangesAsync(); // 自动事务
}
}// 显式事务控制
public class BankingService
{
public async Task Transfer(int fromId, int toId, decimal amount)
{
using var context = new AppDbContext();
// 显式开启事务,指定隔离级别
using var transaction = await context.Database
.BeginTransactionAsync(IsolationLevel.Serializable);
try
{
var fromAccount = await context.Accounts.FindAsync(fromId);
var toAccount = await context.Accounts.FindAsync(toId);
if (fromAccount.Balance < amount)
throw new InvalidOperationException("余额不足");
fromAccount.Balance -= amount;
toAccount.Balance += amount;
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}跨上下文事务(分布式事务)
// 使用 TransactionScope 实现跨数据库事务
public class CrossDbTransaction
{
public async Task TransferBetweenDatabases(
string sourceConnStr, string targetConnStr,
int sourceAccountId, int targetAccountId, decimal amount)
{
// TransactionScope 需要 MSDTC 支持分布式事务
var transactionOptions = new TransactionOptions
{
IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted,
Timeout = TransactionManager.MaximumTimeout
};
using var scope = new TransactionScope(
TransactionScopeOption.Required,
transactionOptions,
TransactionScopeAsyncFlowOption.Enabled);
using var sourceContext = new BankingDbContext(
new DbContextOptionsBuilder<BankingDbContext>()
.UseSqlServer(sourceConnStr).Options);
using var targetContext = new BankingDbContext(
new DbContextOptionsBuilder<BankingDbContext>()
.UseSqlServer(targetConnStr).Options);
var sourceAccount = await sourceContext.Accounts
.FindAsync(sourceAccountId);
var targetAccount = await targetContext.Accounts
.FindAsync(targetAccountId);
sourceAccount.Balance -= amount;
targetAccount.Balance += amount;
await sourceContext.SaveChangesAsync();
await targetContext.SaveChangesAsync();
scope.Complete();
}
}EF Core 乐观并发完整示例
// 完整的乐观并发解决方案
public class ConcurrencySafeRepository
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public ConcurrencySafeRepository(
IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
public async Task<T> UpdateWithRetry<T>(
int id,
Action<T> updateAction,
int maxRetries = 3) where T : class
{
for (int attempt = 0; attempt < maxRetries; attempt++)
{
using var context = await _contextFactory.CreateDbContextAsync();
var entity = await context.Set<T>().FindAsync(id);
if (entity == null)
throw new NotFoundException($"未找到ID为 {id} 的实体");
updateAction(entity);
try
{
await context.SaveChangesAsync();
return entity;
}
catch (DbUpdateConcurrencyException)
{
// 并发冲突,重试
if (attempt == maxRetries - 1)
throw;
await Task.Delay(100 * (attempt + 1));
}
}
throw new InvalidOperationException("不应该到达此处");
}
}性能注意事项
锁争用监控
-- SQL Server 锁等待统计
SELECT
wait_type,
waiting_tasks_count,
wait_time_ms,
max_wait_time_ms
FROM sys.dm_os_wait_stats
WHERE wait_type LIKE '%LCK%'
ORDER BY wait_time_ms DESC;
-- 查看当前锁等待
SELECT
t1.request_session_id AS '等待会话',
t2.request_session_id AS '阻塞会话',
DB_NAME(t1.resource_database_id) AS '数据库',
t1.resource_type AS '资源类型',
t1.request_mode AS '请求模式',
t1.resource_description AS '资源描述'
FROM sys.dm_tran_locks t1
JOIN sys.dm_tran_locks t2 ON t1.resource_associated_entity_id =
t2.resource_associated_entity_id
WHERE t1.request_status = 'WAIT'
AND t2.request_status = 'GRANT';-- MySQL InnoDB 锁等待
SELECT
r.trx_id AS waiting_trx,
r.trx_mysql_thread_id AS waiting_thread,
b.trx_id AS blocking_trx,
b.trx_mysql_thread_id AS blocking_thread,
r.trx_query AS waiting_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;隔离级别性能影响
-- SQL Server:监控 tempdb 版本存储开销(RCSI/Snapshot)
SELECT
DB_NAME(database_id) AS DatabaseName,
reserved_page_count * 8.0 / 1024 AS ReservedSpaceMB
FROM sys.dm_tran_version_store_space_usage;
-- 监控行版本相关的等待
SELECT * FROM sys.dm_os_wait_stats
WHERE wait_type IN (
'VERSION_STORE_QUERY',
'VERSION_STORE_GENERATION',
'VERSION_STORE_FULL'
);死锁检测与处理
-- SQL Server 死锁图
-- 启用死锁跟踪标志
DBCC TRACEON(1222, -1); -- 写入错误日志
DBCC TRACEON(1204, -1); -- 详细死锁信息
-- 使用扩展事件捕获死锁
CREATE EVENT SESSION [DeadlockCapture] ON SERVER
ADD EVENT sqlserver.xml_deadlock_report
ADD TARGET package0.event_file(
SET filename = N'C:\Temp\deadlocks.xel'
);// .NET 中的死锁重试策略
public class DeadlockRetryPolicy
{
private readonly ILogger _logger;
private const int MaxDeadlockRetries = 5;
public async Task ExecuteWithDeadlockRetry(Func<Task> operation)
{
int attempt = 0;
while (true)
{
try
{
await operation();
return;
}
catch (SqlException ex) when (ex.Number == 1205) // Deadlock
{
attempt++;
_logger.LogWarning("检测到死锁,第 {Attempt} 次重试", attempt);
if (attempt >= MaxDeadlockRetries)
{
_logger.LogError("死锁重试次数已达上限");
throw;
}
await Task.Delay(TimeSpan.FromMilliseconds(
Random.Shared.Next(100, 500)));
}
}
}
}总结
事务隔离级别是数据库并发控制的核心,选择合适的隔离级别需要平衡数据一致性和并发性能:
- READ COMMITTED 是大多数场景的最佳选择,兼顾一致性和性能
- MVCC 是现代数据库实现隔离的主流技术,读写互不阻塞
- 快照隔离 提供了比 REPEATABLE READ 更好的性能
- 乐观并发 适合 Web 应用,悲观并发适合高冲突场景
- EF Core 提供了完善的事务和并发控制 API
关键知识点
- SQL 标准定义了三种读现象:脏读、不可重复读、幻读
- 四种隔离级别逐级防止更多读现象
- MVCC 通过保存多版本数据实现读写不阻塞
- InnoDB 的 REPEATABLE READ 通过间隙锁部分防止幻读
- PostgreSQL 的 SERIALIZABLE 使用 SSI 检测而非阻塞
- 快照隔离可能导致写偏序(write skew)问题
- EF Core 的 SaveChanges 自动使用事务
- 乐观并发推荐使用 RowVersion/Timestamp 列
常见误区
误区1:更高的隔离级别总是更安全
更高的隔离级别确实提供更强的一致性保证,但也会带来更多的锁争用、死锁和性能下降。应该根据实际业务需求选择适当的级别。
误区2:REPEATABLE READ 完全防止幻读
SQL 标准的 REPEATABLE READ 不防止幻读,但 MySQL InnoDB 通过 Next-Key Locking 在一定程度上防止了。不同数据库的行为可能不同。
误区3:MVCC 完全不需要锁
MVCC 只消除了读写之间的锁冲突,但写写冲突仍然需要锁来解决。两个事务同时修改同一行,仍然需要排他锁。
误区4:EF Core 的 SaveChanges 会处理一切
虽然 SaveChanges 自带事务,但复杂业务场景需要显式控制事务范围和隔离级别,特别是涉及多个聚合根的操作。
误区5:NOLOCK 是性能优化的万能药
使用 WITH (NOLOCK) 虽然避免了读锁,但可能读到脏数据、甚至数据行可能被多次读取或遗漏。只在明确可以接受这些风险的场景使用。
进阶路线
- 深入 MVCC 实现:阅读 InnoDB 或 PostgreSQL 源码中的 MVCC 实现
- 分布式事务:学习 2PC、3PC、TCC、Saga 模式
- 乐观并发高级模式:事件溯源(Event Sourcing)、CQRS
- 数据库内核:锁管理器、事务管理器的内部实现
- NewSQL 数据库:学习 CockroachDB、TiDB 的分布式事务实现
适用场景
- READ UNCOMMITTED:监控大屏、实时统计、日志分析
- READ COMMITTED:大多数 OLTP 业务、Web 应用
- REPEATABLE READ:财务报表生成、数据一致性校验
- SERIALIZABLE:资金转账、库存扣减、票务系统
- 快照隔离:长时间运行的报表查询、审计
落地建议
- 为每个数据库选择合适的默认隔离级别,并在应用启动时显式设置
- 对于关键业务(如支付),使用 SERIALIZABLE 或悲观并发
- 在 EF Core 实体上配置并发令牌,统一处理冲突
- 实现通用的死锁重试策略
- 监控锁等待和死锁频率,作为性能指标
- 定期审查长时间运行的事务
排错清单
复盘问题
- 你当前项目使用的默认隔离级别是什么?为什么选择这个级别?
- 有没有遇到过因为隔离级别不当导致的数据问题?
- 你的应用如何处理并发冲突?乐观还是悲观?
- 你的长时间查询是否影响了 OLTP 事务?如何解决?
- 你是否监控过锁等待和死锁指标?
延伸阅读
- MySQL InnoDB Locking
- PostgreSQL Transaction Isolation
- SQL Server Transaction Isolation Levels
- EF Core Concurrency Conflicts
- 《数据库系统概论》- 事务管理与并发控制章节
- 《Designing Data-Intensive Applications》- 第七章 事务
