-
C#EF Core性能优化——查询缓存与索引
第一部分:C#基础入门
8.EF Core性能优化——查询缓存与索引
实例介绍
你有没有遇到过这样的场景?电商系统的商品分类页,第一次加载要1.5秒,刷新几次后还是慢;或者商品搜索页,输入关键词后要等3秒才出结果,后台数据库CPU飙升到100%。这些问题的根源,往往是没有对频繁查询的热门数据做缓存,或者查询的字段没有加索引导致全表扫描,再或者不小心写出了N+1查询。
举个真实案例:我们团队之前做的电商项目,首页的“热门商品”模块每vb.net教程C#教程python教程SQL教程access 2010教程天有10万+访问量,每次都直接查数据库,导致数据库QPS高达2000+,经常出现连接池耗尽。后来我们给热门商品加了内存缓存,QPS直接降到200以下,页面加载时间从1秒变成50ms。还有商品搜索功能,因为没给Name字段加索引,模糊查询全表扫描(商品表有10万条数据),每次查询要3秒,加了索引后变成200ms。
这一节,我们就通过这三个典型场景,带你掌握EF Core性能优化的核心技巧:查询缓存、索引优化、避免N+1查询,让你的EF Core查询性能提升50%以上。
需求分析
本节要解决的核心性能问题:
1.热门数据频繁查询:首页热门商品、分类列表等静态数据,每天被查询上万次,如何缓存这些数据,减少数据库压力?
2.慢查询优化:商品搜索、订单筛选等查询,因为没有索引导致全表扫描,如何通过索引优化提升查询速度?
3.N+1查询消除:循环中查询关联数据(比如查商品后再查分类),导致数据库交互次数爆炸,如何一次性加载所有需要的数据?
4.缓存一致性:当缓存的数据更新时(比如分类名称修改),如何让缓存失效,避免用户看到旧数据?
目标:让EF Core查询的响应时间从秒级降到毫秒级,数据库QPS降低80%以上,同时保证数据的一致性。
代码实现
前置条件:.NET 6+、EF Core 7+;需安装Microsoft.EntityFrameworkCore、Microsoft.EntityFrameworkCore.SqlServer、Microsoft.Extensions.Caching.Memory;配置数据库连接字符串(appsettings.json中添加ShopDb连接)。
准备工作:实体类与DbContext配置
csharp
// 分类实体
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = null!; // EF Core7+非空标注
public string? Description { get; set; }
public ICollection
}
// 商品实体
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public decimal Price { get; set; }
public int Stock { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
// DbContext配置
public class ShopDbContext : DbContext
{
public DbSet<Category> Categories { get; set; } = null!;
public DbSet<Product> Products { get; set; } = null!;
public ShopDbContext(DbContextOptions<ShopDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 后续索引配置会在这里添加
}
}
// Program.cs配置(.NET6+ Top-level statements)
var builder = WebApplication.CreateBuilder(args);
// 配置数据库连接
builder.Services.AddDbContext<ShopDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("ShopDb")));
// 配置内存缓存
builder.Services.AddMemoryCache();
var app = builder.Build();
// 省略其他中间件配置...
场景1:查询缓存(MemoryCache)
错误写法:每次查询都访问数据库,没有缓存
csharp
// ❌错误:热门分类列表每次都查数据库,QPS高时数据库压力大
[HttpGet("categories")]
public async Task
{
// 分类列表是静态数据,没必要每次都查数据库
var categories = await db.Categories.ToListAsync();
return Ok(categories);
}
正确写法:先查缓存,没有再查数据库,并存入缓存
csharp
// ✅正确:用MemoryCache缓存热门分类列表
[HttpGet("categories")]
public async Task
ShopDbContext db,
IMemoryCache cache)
{
const string cacheKey = "CategoryList_All"; // 缓存键要唯一,包含数据标识
if (cache.TryGetValue(cacheKey, out List
{
// 缓存命中,直接返回(节省数据库查询时间)
return Ok(categories);
}
// 缓存未命中,查询数据库
categories = await db.Categories.ToListAsync();
// 设置缓存过期策略:滑动过期30分钟(用户频繁访问则延长)+ 绝对过期1小时(防止数据太旧)
var cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(30))
.SetAbsoluteExpirationRelativeToNow(TimeSpan.FromHours(1))
.SetPriority(CacheItemPriority.High); // 高优先级,缓存满时最后被删除
// 将查询结果存入缓存
cache.Set(cacheKey, categories, cacheOptions);
return Ok(categories);
}
// 缓存失效处理:当分类更新时,删除对应的缓存
[HttpPut("categories/{id}")]
public async Task<IActionResult> UpdateCategory(
int id,
[FromBody] CategoryUpdateDto dto,
ShopDbContext db,
IMemoryCache cache)
{
var category = await db.Categories.FindAsync(id);
if (category == null) return NotFound();
// 更新分类数据
category.Name = dto.Name;
category.Description = dto.Description;
await db.SaveChangesAsync();
// 删除缓存,下次请求会重新加载最新数据
cache.Remove("CategoryList_All");
return Ok(category);
}
// 分类更新DTO
public class CategoryUpdateDto
{
public string Name { get; set; } = null!;
public string? Description { get; set; }
}
场景2:索引优化
错误写法:频繁查询的字段没有索引,导致全表扫描
csharp
// ❌错误:商品名称模糊查询没有索引,全表扫描导致慢查询
[HttpGet("products/search")]
public async Task
[FromQuery] string keyword,
ShopDbContext db)
{
// 商品表有10万条数据时,Name字段无索引会触发全表扫描(耗时3秒以上)
var products = await db.Products
.Where(p => p.Name.Contains(keyword))
.ToListAsync();
return Ok(products);
}
正确写法:给频繁查询的字段加索引,提升查询速度
首先在ShopDbContext的OnModelCreating中配置索引:
csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// ✅给商品Name字段加非聚集索引(适合频繁查询、少更新的字段)
modelBuilder.Entity
.HasIndex(p => p.Name)
.HasDatabaseName("IX_Products_Name"); // 自定义索引名称,方便数据库维护
// ✅给商品的CategoryId和Price加复合索引(适合"按分类查商品+按价格排序"的场景)
modelBuilder.Entity<Product>()
.HasIndex(p => new { p.CategoryId, p.Price })
.HasDatabaseName("IX_Products_CategoryId_Price");
}
然后查询代码(和错误写法一致,但因为有索引,速度提升10倍以上):
csharp
// ✅正确:Name字段有索引,模糊查询不再全表扫描
[HttpGet("products/search")]
public async Task
[FromQuery] string keyword,
ShopDbContext db)
{
// 索引优化后,查询时间从3秒降到200ms以内
var products = await db.Products
.Where(p => p.Name.Contains(keyword))
.ToListAsync();
return Ok(products);
}
场景3:避免N+1查询
错误写法:循环中查询关联数据,导致N+1次数据库交互
csharp
// ❌错误:查商品后循环查分类,触发N+1查询(1次查商品+N次查分类)
[HttpGet("products")]
public async Task
{
// 第一次查询:获取所有商品(1次数据库交互)
var products = await db.Products.ToListAsync();
// 循环中查询每个商品的分类:N次数据库交互(N是商品数量)
foreach (var product in products)
{
// EF Core默认延迟加载,这里会触发新的查询
var categoryName = product.Category.Name;
// 处理商品数据...
}
return Ok(products);
}
正确写法1:用Include一次性加载关联数据
csharp
// ✅正确:用Include加载商品和关联的分类,仅1次数据库交互
[HttpGet("products")]
public async Task
{
// Include(p => p.Category):生成LEFT JOIN查询,一次性加载所有数据
var products = await db.Products
.Include(p => p.Category) // 关键:加载关联实体
.ToListAsync();
// 循环中访问product.Category不会再触发查询
foreach (var product in products)
{
var categoryName = product.Category.Name;
// 处理数据...
}
return Ok(products);
}
正确写法2:用投影查询只加载需要的字段(更高效)
csharp
// ✅正确:投影查询只取需要的字段,减少数据传输和内存占用
[HttpGet("products")]
public async Task
{
// 投影查询:只取商品的Id、Name、Price和分类的Name(不需要的字段不查)
var productDtos = await db.Products
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
CategoryName = p.Category.Name // 关联字段直接取,EF Core生成JOIN
})
.ToListAsync();
return Ok(productDtos);
}
// 前端需要的DTO类(只包含必要字段)
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public decimal Price { get; set; }
public string CategoryName { get; set; } = null!;
}
逐行讲解
场景1:查询缓存
1.缓存键设计:const string cacheKey = "CategoryList_All"——缓存键要唯一,包含数据的类型和范围(比如“分类列表_全部”),避免和其他缓存数据冲突。
2.缓存命中判断:cache.TryGetValue(cacheKey, out List
3.缓存未命中处理:db.Categories.ToListAsync()——查询数据库获取最新数据。
4.缓存过期策略:MemoryCacheEntryOptions设置滑动过期+绝对过期,既保证用户频繁访问时缓存不失效,又防止数据长时间不更新。
5.缓存一致性:更新分类时调用cache.Remove(cacheKey)——删除对应的缓存,下次请求会重新加载最新数据,避免用户看到旧数据。
场景2:索引优化
1.单个索引配置:HasIndex(p => p.Name)——给商品的Name字段加非聚集索引,非聚集索引适合频繁查询但不频繁更新的字段(分类名称修改少)。
2.复合索引配置:HasIndex(p => new { p.CategoryId, p.Price })——复合索引的字段顺序很重要,查询时如果先按CategoryId过滤(比如Where(p => p.CategoryId == 1)),再按Price排序(OrderBy(p => p.Price)),这个顺序的复合索引效率最高。
3.索引名称自定义:HasDatabaseName("IX_Products_Name")——默认EF Core会生成很长的索引名称,自定义名称方便后续在数据库中维护(比如删除、重建索引)。
场景3:避免N+1查询
1.错误原因:db.Products.ToListAsync()查商品(1次),循环中product.Category.Name触发延迟加载(N次),总共1+N次数据库交互,性能极差。
2.Include的作用:Include(p => p.Category)让EF Core生成LEFT JOIN的SQL,一次性加载商品和关联的分类,仅1次数据库交互。
3.投影查询的优势:比Include更高效,因为只取需要的字段(比如分类的Description字段不需要,就不查询),减少了数据传输和内存占用,生成的SQL也更简洁。
基础知识拓展
- 核心概念对比
| 概念 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
- 最佳实践
缓存最佳实践:
1.缓存键要唯一,包含数据标识(如Product_Hot_10表示热门商品Top10)。
2.用滑动过期+绝对过期结合的策略,避免缓存数据太旧或频繁失效。
3.数据更新时一定要删除对应的缓存,保证缓存一致性(如更新分类后删除分类列表缓存)。
4.不要缓存频繁更新的数据(如实时库存),否则缓存失效太频繁,反而增加开销。
索引最佳实践:
1.给频繁查询的字段加索引(如商品Name、CategoryId)。
2.复合索引的字段顺序按查询频率排序(最频繁过滤的字段放前面)。
3.避免过度索引,每个表的索引数控制在5个以内(索引会减慢插入/更新/删除操作)。
4.用EF Core的HasIndex配置索引,不要手动在数据库加,保证代码与数据库一致。
避免N+1最佳实践:
1.用Include或投影查询替代循环中的延迟加载。
2.禁用延迟加载(在DbContext配置中UseLazyLoadingProxies(false)),防止不小心触发N+1。
3.用EF Core的日志功能检测N+1查询(如LogTo(Console.WriteLine)查看生成的SQL)。
总结
EF Core性能优化的核心是减少数据库交互次数和提升单查询速度。查询缓存解决了频繁查询的问题,索引优化解决了慢查询的问题,避免N+1解决了数据库交互次数爆炸的问题。三者结合,能让你的EF Core应用性能提升一个档次。
关键要点:
1.热门静态数据用缓存,更新时要失效缓存。
2.频繁查询的字段加索引,复合索引要注意字段顺序。
3.用Include或投影查询避免N+1,优先用投影查询(更高效)。
4.定期用EF Core日志或数据库查询分析器检测性能问题,持续优化。
通过这一节的学习,你应该能解决EF Core开发中80%的性能问题,让你的应用跑得更快更稳!
本站原创,转载请注明出处:https://www.xin3721.com/ArticlecSharp/c49456.html










