Appearance
批量操作
EF Core 8+ 原生批量更新和删除,性能提升 10-100 倍
📖 目录
为什么需要批量操作
传统方式的问题
csharp
// ❌ 传统方式: N+1 次数据库往返
var products = await context.Products
.Where(p => p.CategoryId == 1)
.ToListAsync();
foreach (var product in products)
{
product.Price *= 1.1m; // 涨价 10%
}
await context.SaveChangesAsync();
// 问题:
// 1. 加载所有实体到内存
// 2. 跟踪所有实体
// 3. N 次 UPDATE 语句
// 4. 性能极差性能分析(1000 条记录):
- 查询: 1 次 SELECT
- 跟踪: 1000 个实体快照
- 更新: 1000 次 UPDATE
- 总耗时: 5000ms
批量操作的优势
csharp
// ✅ EF Core 8+ 批量操作
await context.Products
.Where(p => p.CategoryId == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.1m));
// 优势:
// 1. 不加载实体到内存
// 2. 无跟踪开销
// 3. 单次 UPDATE 语句
// 4. 性能极佳性能分析(1000 条记录):
- 更新: 1 次 UPDATE
- 总耗时: 50ms
- 性能提升: 100 倍
ExecuteUpdateAsync
1. 基础用法
csharp
// 批量更新单个属性
await context.Products
.Where(p => p.CategoryId == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, 99.99m));
// 生成的 SQL:
// UPDATE [Products] SET [Price] = 99.99 WHERE [CategoryId] = 12. 基于原值的更新
csharp
// 涨价 10%
await context.Products
.Where(p => p.CategoryId == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.1m));
// 生成的 SQL:
// UPDATE [Products]
// SET [Price] = [Price] * 1.1
// WHERE [CategoryId] = 13. 更新多个属性
csharp
await context.Products
.Where(p => p.IsObsolete)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.IsActive, false)
.SetProperty(p => p.DiscontinuedAt, DateTime.UtcNow)
.SetProperty(p => p.ModifiedBy, "system"));
// 生成的 SQL:
// UPDATE [Products]
// SET [IsActive] = 0,
// [DiscontinuedAt] = GETUTCDATE(),
// [ModifiedBy] = 'system'
// WHERE [IsObsolete] = 14. 条件更新
csharp
// 根据条件设置不同值
await context.Products
.Where(p => p.Price < 50)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.PriceCategory, "Low"));
await context.Products
.Where(p => p.Price >= 50 && p.Price < 100)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.PriceCategory, "Medium"));
await context.Products
.Where(p => p.Price >= 100)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.PriceCategory, "High"));5. 使用导航属性
csharp
// 更新某个分类下所有产品的价格
await context.Products
.Where(p => p.Category.Name == "Electronics")
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.05m));
// 生成的 SQL:
// UPDATE [Products]
// SET [Price] = [Price] * 1.05
// FROM [Products] AS [p]
// INNER JOIN [Categories] AS [c] ON [p].[CategoryId] = [c].[Id]
// WHERE [c].[Name] = N'Electronics'ExecuteDeleteAsync
1. 基础用法
csharp
// 批量删除
await context.Products
.Where(p => !p.IsActive)
.ExecuteDeleteAsync();
// 生成的 SQL:
// DELETE FROM [Products] WHERE [IsActive] = 02. 条件删除
csharp
// 删除一年前的订单
var cutoffDate = DateTime.UtcNow.AddYears(-1);
await context.Orders
.Where(o => o.OrderDate < cutoffDate && o.Status == OrderStatus.Completed)
.ExecuteDeleteAsync();
// 生成的 SQL:
// DELETE FROM [Orders]
// WHERE [OrderDate] < @cutoffDate AND [Status] = 23. 级联删除
csharp
// 删除分类时,先删除相关产品
var categoryId = 1;
// 删除产品
await context.Products
.Where(p => p.CategoryId == categoryId)
.ExecuteDeleteAsync();
// 删除分类
await context.Categories
.Where(c => c.Id == categoryId)
.ExecuteDeleteAsync();性能对比
基准测试
csharp
public class BulkOperationBenchmark
{
private readonly AppDbContext _context;
// 传统方式: 批量更新
[Benchmark]
public async Task TraditionalUpdate()
{
var products = await _context.Products
.Where(p => p.CategoryId == 1)
.ToListAsync();
foreach (var product in products)
{
product.Price *= 1.1m;
}
await _context.SaveChangesAsync();
}
// 批量操作: ExecuteUpdate
[Benchmark]
public async Task BulkUpdate()
{
await _context.Products
.Where(p => p.CategoryId == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.1m));
}
// 传统方式: 批量删除
[Benchmark]
public async Task TraditionalDelete()
{
var products = await _context.Products
.Where(p => !p.IsActive)
.ToListAsync();
_context.Products.RemoveRange(products);
await _context.SaveChangesAsync();
}
// 批量操作: ExecuteDelete
[Benchmark]
public async Task BulkDelete()
{
await _context.Products
.Where(p => !p.IsActive)
.ExecuteDeleteAsync();
}
}性能数据
更新 1000 条记录
| 方式 | 时间 | 内存 | 数据库往返 | 相对性能 |
|---|---|---|---|---|
| 传统方式 | 5000ms | 80MB | 1001 次 | 1.0x |
| ExecuteUpdate | 50ms | 1MB | 1 次 | 100x |
删除 1000 条记录
| 方式 | 时间 | 内存 | 数据库往返 | 相对性能 |
|---|---|---|---|---|
| 传统方式 | 4500ms | 75MB | 1001 次 | 1.0x |
| ExecuteDelete | 40ms | 1MB | 1 次 | 112x |
不同数据量对比
| 记录数 | 传统更新 | 批量更新 | 提升 |
|---|---|---|---|
| 100 | 500ms | 10ms | 50x |
| 1,000 | 5000ms | 50ms | 100x |
| 10,000 | 50s | 200ms | 250x |
| 100,000 | 超时 | 1500ms | ∞ |
使用场景
✅ 适合批量操作的场景
1. 批量状态更新
csharp
// 禁用所有过期产品
await context.Products
.Where(p => p.ExpiryDate < DateTime.UtcNow)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.IsActive, false)
.SetProperty(p => p.Status, ProductStatus.Expired));
// 激活所有新产品
await context.Products
.Where(p => p.CreatedAt >= DateTime.Today && !p.IsActive)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.IsActive, true));2. 批量价格调整
csharp
// 所有电子产品涨价 5%
await context.Products
.Where(p => p.CategoryId == electronicsCategoryId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.05m));
// 清仓甩卖: 所有库存大于 100 的产品打 7 折
await context.Products
.Where(p => p.StockQuantity > 100)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 0.7m)
.SetProperty(p => p.DiscountNote, "Clearance Sale"));3. 批量清理数据
csharp
// 删除一年前的日志
var cutoffDate = DateTime.UtcNow.AddYears(-1);
await context.Logs
.Where(l => l.CreatedAt < cutoffDate)
.ExecuteDeleteAsync();
// 删除未激活的临时用户
await context.Users
.Where(u => !u.IsActive && u.CreatedAt < DateTime.UtcNow.AddDays(-7))
.ExecuteDeleteAsync();4. 批量重置
csharp
// 每月重置产品销量
await context.Products
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.MonthlySales, 0));
// 重置用户积分
await context.Users
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.Points, 0));5. 批量标记
csharp
// 标记所有未读消息为已读
await context.Messages
.Where(m => m.RecipientId == userId && !m.IsRead)
.ExecuteUpdateAsync(setters => setters
.SetProperty(m => m.IsRead, true)
.SetProperty(m => m.ReadAt, DateTime.UtcNow));
// 标记过期的优惠券
await context.Coupons
.Where(c => c.ExpiryDate < DateTime.UtcNow && c.IsActive)
.ExecuteUpdateAsync(setters => setters
.SetProperty(c => c.IsActive, false)
.SetProperty(c => c.Status, CouponStatus.Expired));❌ 不适合批量操作的场景
1. 需要触发业务逻辑
csharp
// ❌ 错误: 跳过业务验证
await context.Products
.Where(p => p.Price < 0)
.ExecuteDeleteAsync();
// ✅ 正确: 需要验证和清理关联数据
var products = await context.Products
.Include(p => p.OrderItems)
.Include(p => p.Reviews)
.Where(p => p.Price < 0)
.ToListAsync();
foreach (var product in products)
{
// 业务验证
if (product.OrderItems.Any())
{
throw new InvalidOperationException("Cannot delete product with orders");
}
// 清理关联数据
context.Reviews.RemoveRange(product.Reviews);
context.Products.Remove(product);
}
await context.SaveChangesAsync();2. 需要审计日志
csharp
// ❌ 错误: 无法记录审计日志
await context.Products
.Where(p => p.Id == productId)
.ExecuteDeleteAsync();
// ✅ 正确: 记录删除日志
var product = await context.Products.FindAsync(productId);
if (product != null)
{
// 记录审计日志
auditLogService.LogDelete(product);
context.Products.Remove(product);
await context.SaveChangesAsync();
}3. 需要并发控制
csharp
// ❌ 错误: 跳过并发检查
await context.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, newPrice));
// ✅ 正确: 检查并发
var product = await context.Products.FindAsync(productId);
if (product != null)
{
product.Price = newPrice;
await context.SaveChangesAsync(); // 会检查 RowVersion
}注意事项
1. 不触发变更跟踪
csharp
// ExecuteUpdate/Delete 不会触发 ChangeTracker
await context.Products
.Where(p => p.CategoryId == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, 99.99m));
// ⚠️ 已加载的实体不会自动更新
var product = await context.Products.FindAsync(1);
Console.WriteLine(product.Price); // 旧值!
// ✅ 解决: 重新查询或清除缓存
context.ChangeTracker.Clear();
var updatedProduct = await context.Products.FindAsync(1);
Console.WriteLine(updatedProduct.Price); // 新值2. 不触发验证
csharp
public class Product
{
[Range(0, 10000)]
public decimal Price { get; set; }
}
// ❌ ExecuteUpdate 跳过验证
await context.Products
.Where(p => p.Id == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, 99999m)); // 超出范围,但不会报错
// ✅ SaveChanges 会验证
var product = await context.Products.FindAsync(1);
product.Price = 99999m;
await context.SaveChangesAsync(); // 抛出验证异常3. 不触发拦截器
csharp
// DbCommandInterceptor 仍然会拦截
// 但 SaveChangesInterceptor 不会触发
public class AuditInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
// ❌ ExecuteUpdate/Delete 不会触发此方法
return base.SavingChanges(eventData, result);
}
}4. 事务支持
csharp
// ExecuteUpdate/Delete 支持事务
await using var transaction = await context.Database.BeginTransactionAsync();
try
{
// 批量更新
await context.Products
.Where(p => p.CategoryId == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.1m));
// 其他操作
await context.Categories
.Where(c => c.Id == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(c => c.LastUpdated, DateTime.UtcNow));
await transaction.CommitAsync();
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}第三方库方案
EFCore.BulkExtensions(EF Core 8 之前)
bash
dotnet add package EFCore.BulkExtensionscsharp
// 批量插入
await context.BulkInsertAsync(products);
// 批量更新
await context.BulkUpdateAsync(products);
// 批量删除
await context.BulkDeleteAsync(products);
// 批量读取
var products = await context.BulkReadAsync<Product>(productIds);
// Upsert(存在则更新,不存在则插入)
await context.BulkInsertOrUpdateAsync(products);优势:
- ✅ 支持 EF Core 6/7
- ✅ 更丰富的功能(Upsert/Merge)
- ✅ 可配置批量大小
劣势:
- ❌ 第三方库
- ❌ 额外依赖
- ❌ EF Core 8+ 已有原生支持
💡 最佳实践
1. 优先使用原生批量操作
csharp
// EF Core 8+ 项目
await context.Products
.Where(p => p.IsObsolete)
.ExecuteDeleteAsync();
// 而不是
await context.BulkDeleteAsync(products);2. 大批量操作分批处理
csharp
// 删除百万级数据
int batchSize = 10000;
int deletedCount;
do
{
deletedCount = await context.Logs
.Where(l => l.CreatedAt < cutoffDate)
.Take(batchSize)
.ExecuteDeleteAsync();
Console.WriteLine($"Deleted {deletedCount} records");
} while (deletedCount > 0);3. 配合事务使用
csharp
public async Task UpdatePricesAsync(int categoryId, decimal multiplier)
{
await using var transaction = await context.Database.BeginTransactionAsync();
try
{
// 批量更新价格
await context.Products
.Where(p => p.CategoryId == categoryId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * multiplier));
// 记录变更日志
await context.PriceChangeLogs.AddAsync(new PriceChangeLog
{
CategoryId = categoryId,
Multiplier = multiplier,
ChangedAt = DateTime.UtcNow
});
await transaction.CommitAsync();
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
}4. 监控批量操作
csharp
public class BulkOperationMonitor
{
private readonly ILogger<BulkOperationMonitor> _logger;
public async Task MonitorBulkUpdateAsync(IQueryable<Product> query)
{
var stopwatch = Stopwatch.StartNew();
var affectedRows = await query.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.ModifiedAt, DateTime.UtcNow));
stopwatch.Stop();
_logger.LogInformation(
"Bulk update completed: {Rows} rows in {Elapsed}ms",
affectedRows,
stopwatch.ElapsedMilliseconds);
}
}📊 性能优化检查清单
使用批量操作前确认:
- [ ] 是否需要触发业务逻辑?
- [ ] 是否需要审计日志?
- [ ] 是否需要并发控制?
- [ ] 数据量是否大于 100 条?
- [ ] 是否可以接受不触发验证?
- [ ] 是否需要事务支持?
如果以上都为"否",使用批量操作!
📚 延伸阅读
💡 小结
核心要点:
- ✅ ExecuteUpdateAsync 批量更新(EF Core 8+)
- ✅ ExecuteDeleteAsync 批量删除(EF Core 8+)
- ✅ 性能提升 10-100 倍
- ✅ 不加载实体到内存
- ✅ 单次数据库往返
- ❌ 不触发变更跟踪、验证、拦截器
使用原则:
简单批量操作 → ExecuteUpdate/Delete
需要业务逻辑 → 传统方式
EF Core 6/7 → EFCore.BulkExtensions
大批量数据 → 分批处理 + 事务下一步:
- 学习 预编译查询
- 掌握 查询拆分 AsSplitQuery
- 理解 避免 N+1 查询