MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C#与Entity Framework Core数据访问优化

2021-05-131.6k 阅读

C# 与 Entity Framework Core 数据访问优化

理解 Entity Framework Core 基础

Entity Framework Core(简称 EF Core)是 .NET 开发中用于数据访问的强大工具,它基于对象关系映射(ORM)原理,允许开发者使用 .NET 对象来表示数据库中的表、行和关系,极大地简化了数据访问层的开发。在 C# 项目中使用 EF Core,首先要安装相关的 NuGet 包。以在控制台应用程序为例,在包管理器控制台中执行以下命令来安装 EF Core 以及对应数据库的提供程序(这里以 SQL Server 为例):

Install - Package Microsoft.EntityFrameworkCore.SqlServer
Install - Package Microsoft.EntityFrameworkCore.Tools

安装完成后,定义数据模型类。假设我们有一个简单的博客系统,有 BlogPost 两个实体,代码如下:

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }
    public List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public int BlogId { get; set; }
    public Blog Blog { get; set; }
}

接着,创建 DbContext 类来管理与数据库的交互:

public class BloggingContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True;");
    }
}

OnConfiguring 方法中,我们配置了数据库连接字符串。这样,一个基本的 EF Core 数据访问环境就搭建好了。

优化查询性能

避免使用 Include 过多层次

Include 方法用于在查询时预先加载相关的导航属性。例如,当我们想要获取博客及其所有文章时,可以这样写:

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
      .Include(b => b.Posts)
      .ToList();
}

然而,如果导航属性层次过深,如 Blog -> Post -> Comment -> Reply,使用过多层次的 Include 会导致生成非常复杂的 SQL 查询,性能会急剧下降。此时,可以考虑使用分离的查询来获取数据。先获取博客及其文章:

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
      .Include(b => b.Posts)
      .ToList();
    foreach (var blog in blogs)
    {
        foreach (var post in blog.Posts)
        {
            context.Entry(post).Collection(p => p.Comments).Load();
            foreach (var comment in post.Comments)
            {
                context.Entry(comment).Collection(c => c.Replies).Load();
            }
        }
    }
}

这样虽然增加了数据库查询次数,但每个查询相对简单,在大数据量和复杂关系下,可能会有更好的性能表现。

投影优化

投影是指从查询结果中选择特定的属性,而不是返回整个实体对象。使用匿名类型进行投影可以减少不必要的数据传输和内存占用。比如,我们只需要获取博客的 Url 和文章的 Title

using (var context = new BloggingContext())
{
    var result = context.Blogs
      .Select(b => new
       {
           BlogUrl = b.Url,
           PostsTitles = b.Posts.Select(p => p.Title)
       })
      .ToList();
}

在 EF Core 中,还可以使用 Select 方法结合 AsNoTracking 来进一步优化。AsNoTracking 会阻止 EF Core 跟踪查询结果,这样可以减少内存开销,特别是在只读查询场景下非常有用:

using (var context = new BloggingContext())
{
    var result = context.Blogs
      .AsNoTracking()
      .Select(b => new
       {
           BlogUrl = b.Url,
           PostsTitles = b.Posts.Select(p => p.Title)
       })
      .ToList();
}

分页

在处理大量数据时,分页是必不可少的优化手段。EF Core 提供了 SkipTake 方法来实现分页。假设我们每页显示 10 条博客数据:

using (var context = new BloggingContext())
{
    int pageNumber = 1;
    int pageSize = 10;
    var blogs = context.Blogs
      .OrderBy(b => b.BlogId)
      .Skip((pageNumber - 1) * pageSize)
      .Take(pageSize)
      .ToList();
}

同时,为了获取总页数等信息,我们可以结合 Count 方法:

using (var context = new BloggingContext())
{
    int pageNumber = 1;
    int pageSize = 10;
    var totalCount = context.Blogs.Count();
    var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
    var blogs = context.Blogs
      .OrderBy(b => b.BlogId)
      .Skip((pageNumber - 1) * pageSize)
      .Take(pageSize)
      .ToList();
}

数据库操作优化

批量插入、更新和删除

在进行数据的插入、更新或删除操作时,如果逐行操作,会导致大量的数据库往返,严重影响性能。EF Core 提供了一些方法来实现批量操作。

对于批量插入,可以使用 AddRange 方法。例如,要插入多个博客:

using (var context = new BloggingContext())
{
    var newBlogs = new List<Blog>
    {
        new Blog { Url = "http://blog1.com" },
        new Blog { Url = "http://blog2.com" }
    };
    context.Blogs.AddRange(newBlogs);
    context.SaveChanges();
}

批量更新稍微复杂一些。假设我们要将所有博客的 URL 加上一个前缀:

using (var context = new BloggingContext())
{
    var blogs = context.Blogs.ToList();
    foreach (var blog in blogs)
    {
        blog.Url = "newprefix-" + blog.Url;
    }
    context.SaveChanges();
}

然而,这种方式会为每个更新生成一条 SQL 语句。可以使用 EF Core 的扩展库,如 Microsoft.EntityFrameworkCore.BulkExtensions 来实现真正的批量更新:

Install - Package Microsoft.EntityFrameworkCore.BulkExtensions

然后使用如下代码实现批量更新:

using (var context = new BloggingContext())
{
    var blogs = context.Blogs.ToList();
    foreach (var blog in blogs)
    {
        blog.Url = "newprefix-" + blog.Url;
    }
    context.BulkUpdate(blogs);
}

批量删除同样可以使用 RemoveRange 方法:

using (var context = new BloggingContext())
{
    var blogsToDelete = context.Blogs.Where(b => b.Url.Contains("oldprefix")).ToList();
    context.Blogs.RemoveRange(blogsToDelete);
    context.SaveChanges();
}

事务处理

在进行多个相关的数据库操作时,事务处理确保要么所有操作都成功,要么都失败回滚。EF Core 提供了简单的事务处理机制。例如,在博客系统中,当删除一个博客时,同时要删除其关联的文章:

using (var context = new BloggingContext())
{
    using (var transaction = context.Database.BeginTransaction())
    {
        try
        {
            var blogToDelete = context.Blogs
              .Include(b => b.Posts)
              .FirstOrDefault(b => b.BlogId == 1);
            if (blogToDelete != null)
            {
                context.Posts.RemoveRange(blogToDelete.Posts);
                context.Blogs.Remove(blogToDelete);
                context.SaveChanges();
                transaction.Commit();
            }
        }
        catch (Exception ex)
        {
            transaction.Rollback();
            // 处理异常
        }
    }
}

配置优化

连接字符串优化

连接字符串中的参数设置会影响数据库连接的性能。例如,在 SQL Server 的连接字符串中,Pooling 参数决定是否启用连接池。启用连接池可以避免每次请求都创建新的数据库连接,从而提高性能。默认情况下,Poolingtrue,但如果在连接字符串中明确设置,可以确保其正确性:

optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True;Pooling=true;");

另外,Max Pool SizeMin Pool Size 参数可以控制连接池中的最大和最小连接数。根据应用程序的负载情况合理设置这些参数很重要。如果应用程序并发请求量较大,可以适当增大 Max Pool Size

optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True;Pooling=true;Max Pool Size = 100;Min Pool Size = 10;");

缓存配置

在 EF Core 中,可以通过缓存来减少数据库查询次数。一种简单的缓存方式是在应用程序级别使用内存缓存。首先,安装 Microsoft.Extensions.Caching.Memory 包:

Install - Package Microsoft.Extensions.Caching.Memory

然后在 Startup 类中配置缓存服务:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMemoryCache();
    // 其他 EF Core 配置
}

在数据访问层,可以使用缓存来存储查询结果。例如,获取博客列表的方法:

public class BlogRepository
{
    private readonly BloggingContext _context;
    private readonly IMemoryCache _memoryCache;

    public BlogRepository(BloggingContext context, IMemoryCache memoryCache)
    {
        _context = context;
        _memoryCache = memoryCache;
    }

    public List<Blog> GetBlogs()
    {
        const string cacheKey = "BlogsList";
        if (!_memoryCache.TryGetValue(cacheKey, out List<Blog> blogs))
        {
            blogs = _context.Blogs.ToList();
            _memoryCache.Set(cacheKey, blogs, TimeSpan.FromMinutes(5));
        }
        return blogs;
    }
}

这样,在 5 分钟内再次调用 GetBlogs 方法时,会从缓存中获取数据,而不是查询数据库。

性能监控与分析

使用 SQL Server Profiler(或类似工具)

SQL Server Profiler 是 SQL Server 提供的一个强大工具,用于捕获和分析数据库服务器上发生的事件。在使用 EF Core 进行数据访问时,可以通过它来查看生成的 SQL 查询语句,分析查询性能。

首先,启动 SQL Server Profiler,连接到对应的数据库实例。然后,在 C# 应用程序中执行 EF Core 查询操作,SQL Server Profiler 会捕获到相关的 SQL 语句。通过分析这些语句,可以发现诸如查询条件不合理、索引未使用等性能问题。

例如,如果发现某个查询执行时间很长,查看捕获的 SQL 语句,发现查询条件字段没有建立索引,可以通过在数据库中为该字段创建索引来优化查询性能。在 SQL Server 中,可以使用以下语句创建索引:

CREATE INDEX idx_blog_url ON Blog(Url);

EF Core 日志记录

EF Core 本身提供了日志记录功能,可以帮助我们了解数据访问过程中的详细信息。在 Startup 类中配置日志记录:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<BloggingContext>(options =>
    {
        options.UseSqlServer(Configuration.GetConnectionString("BloggingConnection"));
        options.LogTo(Console.WriteLine, LogLevel.Information);
    });
}

上述代码将 EF Core 的日志输出到控制台,并且只记录 Information 级别及以上的日志。通过查看这些日志,可以了解查询的生成过程、执行时间等信息,从而进行针对性的优化。

例如,日志中可能会显示某个查询的执行时间较长,我们可以根据日志中的信息优化查询语句,或者调整 EF Core 的查询策略。

模型设计优化

合理设计实体关系

在设计 EF Core 数据模型时,合理的实体关系设计至关重要。避免创建不必要的复杂关系,例如多对多关系如果可以简化为一对多关系,应尽量简化。

假设我们有一个场景,学生和课程之间的关系。如果每个学生只能选修一门课程,并且每门课程可以有多个学生,那么使用一对多关系即可。实体类设计如下:

public class Student
{
    public int StudentId { get; set; }
    public string Name { get; set; }
    public int CourseId { get; set; }
    public Course Course { get; set; }
}

public class Course
{
    public int CourseId { get; set; }
    public string CourseName { get; set; }
    public List<Student> Students { get; set; }
}

这样的设计相对简单,在数据访问和性能方面都更优。如果错误地设计为多对多关系,会增加数据库表结构的复杂性,以及查询和维护的难度。

索引设计

在 EF Core 中,可以通过数据注释或 Fluent API 为实体属性添加索引。例如,在 Blog 实体的 Url 属性上添加索引,使用数据注释方式:

public class Blog
{
    public int BlogId { get; set; }
    [Index(IsUnique = true)]
    public string Url { get; set; }
    public List<Post> Posts { get; set; }
}

使用 Fluent API 方式:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
      .HasIndex(b => b.Url)
      .IsUnique();
}

合理的索引设计可以显著提高查询性能,但过多的索引也会增加数据库的维护成本,因为每次数据插入、更新或删除时,相关的索引也需要更新。所以需要根据实际的查询需求来设计索引。

异步数据访问优化

使用异步方法

EF Core 提供了丰富的异步方法,在数据访问时使用异步操作可以提高应用程序的响应性,特别是在处理 I/O 密集型操作时。例如,获取博客列表的异步方法:

public async Task<List<Blog>> GetBlogsAsync()
{
    using (var context = new BloggingContext())
    {
        return await context.Blogs.ToListAsync();
    }
}

在调用该方法时,主线程不会被阻塞,可以继续执行其他任务。在 ASP.NET Core 应用程序中,控制器方法可以很方便地使用异步数据访问方法:

[ApiController]
[Route("[controller]")]
public class BlogsController : ControllerBase
{
    private readonly BlogRepository _blogRepository;

    public BlogsController(BlogRepository blogRepository)
    {
        _blogRepository = blogRepository;
    }

    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var blogs = await _blogRepository.GetBlogsAsync();
        return Ok(blogs);
    }
}

避免异步中的阻塞操作

在异步方法中,要避免使用会阻塞线程的同步操作。例如,不要在异步方法中使用 ToList 代替 ToListAsync。以下是错误的示例:

public async Task<List<Blog>> GetBlogsAsync()
{
    using (var context = new BloggingContext())
    {
        // 错误:ToList 是同步方法,会阻塞线程
        return context.Blogs.ToList();
    }
}

应始终使用异步版本的方法,以充分利用异步操作的优势,提高应用程序的性能和响应性。

多租户场景下的数据访问优化

租户数据隔离策略

在多租户应用程序中,数据隔离是关键。一种常见的策略是每个租户使用独立的数据库。在 EF Core 中,可以为每个租户创建独立的 DbContext 实例,并配置不同的连接字符串。例如:

public class TenantDbContext : DbContext
{
    private readonly string _connectionString;

    public TenantDbContext(string connectionString)
    {
        _connectionString = connectionString;
    }

    public DbSet<Blog> Blogs { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(_connectionString);
    }
}

在应用程序中,根据租户标识获取对应的连接字符串,并创建 DbContext 实例:

public class TenantService
{
    public TenantDbContext GetTenantDbContext(string tenantId)
    {
        string connectionString = GetConnectionStringByTenantId(tenantId);
        return new TenantDbContext(connectionString);
    }

    private string GetConnectionStringByTenantId(string tenantId)
    {
        // 根据租户标识获取连接字符串的逻辑
    }
}

另一种策略是在同一个数据库中使用架构(Schema)来隔离租户数据。在 EF Core 中,可以通过 Fluent API 配置实体的架构:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
      .ToTable("Blogs", "tenant1");
}

多租户查询优化

在多租户场景下进行查询时,要确保查询仅返回当前租户的数据。如果使用独立数据库策略,查询时只需在对应的 DbContext 上执行。如果使用架构隔离策略,可以在查询时添加架构相关的条件。例如:

using (var context = new TenantDbContext(GetConnectionStringByTenantId("tenant1")))
{
    var blogs = context.Blogs
      .FromSqlInterpolated($"SELECT * FROM tenant1.Blogs")
      .ToList();
}

还可以通过创建视图来简化多租户查询,将租户相关的查询逻辑封装在视图中,在 EF Core 中直接查询视图即可。

数据访问层的测试与优化

单元测试数据访问方法

对数据访问层进行单元测试可以确保方法的正确性,并发现潜在的性能问题。使用 MSTest 框架为例,测试 BlogRepositoryGetBlogs 方法:

[TestClass]
public class BlogRepositoryTests
{
    [TestMethod]
    public void GetBlogs_ReturnsBlogs()
    {
        // 使用 InMemoryDbContext 进行测试
        var options = new DbContextOptionsBuilder<BloggingContext>()
          .UseInMemoryDatabase(databaseName: "TestDatabase")
          .Options;
        using (var context = new BloggingContext(options))
        {
            var blog1 = new Blog { Url = "http://blog1.com" };
            var blog2 = new Blog { Url = "http://blog2.com" };
            context.Blogs.AddRange(blog1, blog2);
            context.SaveChanges();
        }
        using (var context = new BloggingContext(options))
        {
            var repository = new BlogRepository(context, null);
            var blogs = repository.GetBlogs();
            Assert.AreEqual(2, blogs.Count);
        }
    }
}

通过单元测试,可以验证数据访问方法的返回结果是否符合预期。同时,在测试过程中可以观察方法的执行时间,发现性能瓶颈。

性能测试与调优

使用工具如 BenchmarkDotNet 进行性能测试。首先安装 BenchmarkDotNet 包:

Install - Package BenchmarkDotNet

然后创建性能测试类:

using BenchmarkDotNet.Attributes;
using System.Collections.Generic;
using System.Linq;

public class BlogRepositoryBenchmark
{
    private BloggingContext _context;
    private BlogRepository _repository;

    [GlobalSetup]
    public void Setup()
    {
        var options = new DbContextOptionsBuilder<BloggingContext>()
          .UseInMemoryDatabase(databaseName: "BenchmarkDatabase")
          .Options;
        _context = new BloggingContext(options);
        var blogs = new List<Blog>
        {
            new Blog { Url = "http://blog1.com" },
            new Blog { Url = "http://blog2.com" },
            // 插入更多博客数据用于测试
        };
        _context.Blogs.AddRange(blogs);
        _context.SaveChanges();
        _repository = new BlogRepository(_context, null);
    }

    [Benchmark]
    public void GetBlogs_Benchmark()
    {
        _repository.GetBlogs();
    }
}

运行性能测试后,可以得到方法的执行时间、内存使用等详细信息。根据这些信息,可以对数据访问方法进行针对性的优化,如调整查询语句、优化实体关系等。

通过以上多个方面的优化,可以显著提升 C# 应用程序中使用 Entity Framework Core 进行数据访问的性能,从而提高整个应用程序的质量和用户体验。无论是在小型项目还是大型企业级应用中,这些优化策略都具有重要的实际意义。在实际开发中,需要根据具体的业务需求和数据量等因素,灵活运用这些优化方法,以达到最佳的性能效果。