福禄网络技术团队

福禄网络技术团队

前言

.net 环境近些年也算是稳步发展。在开发的过程中,与数据库打交道是必不可少的。早期的开发者都是DbHelper一撸到底,到现在的各种各样的ORM框架大行其道。孰优孰劣谁也说不清楚,文无第一武无第二说的就是这个理。没有什么最好的,只有最适合你的。

本人也是从DbHelper开始,期间用过SugarSql,再到EFCODE。本着学习分享的初衷分享本人工作中总结的一些小技巧,希望能帮助更多开发者,期望能达到共同进步。文中若有错误地方,欢迎大家不吝赐教。

1. DbContext配置

在asp.net中,通常情况下,通过在Startup类的ConfigureServices方法中,将ef服务注入。
示例代码如下:

services.AddDbContext<DemoDbContext>(opt=>opt.UseMySql("server=.;Database=demo;Uid=root;Pwd=123;Port=3306;"));

以上代码表示使用MySql数据库。如果使用SqlServer数据库,可以把UseMySql改为UseSqlServer,其他数据库的使用方式也是通过调用不同的方法进行选择。但需要安装对应的扩展方法的程序包,如 Microsoft.EntityFrameworkCore.SqlServer 或 Microsoft.EntityFrameworkCore.Sqlite。

另外,UseMySql方法还包含了一个可空的Action类型的参数,可以通过此参数进行一些个性化的配置,比如配置重试机制。如下所示:

services.AddDbContext<DemoDbContext>(opt => opt.UseMySql("server=.;Database=demo;Uid=root;Pwd=123456;Port=3306;",
                provideropt => provideropt.EnableRetryOnFailure(3,TimeSpan.FromSeconds(10),new List<int>(){0} )));

这个重试机制在某些场景下还是比较有用的。比如,由于网络波动或访问量导致的一瞬间的连接超时。如果不设置重试机制,则会直接触发异常,设置了超时后,则会根据设置的时间间隔以及重试次数进行重试。EnableRetryOnFailure方法的最后一个参数是用来设置错误代码的,只有设置了错误代码的错误,才会触发重试。获取错误代码的方法有很多种,个人比较推荐的是,通过异常信息进行获取,比如,使用MySql数据时,触发的异常类型是MySqlException,此类的Number属性的值EnableRetryOnFailure方法所需要的Number

2. DbContext线程问题

efcore不支持在同一个DbContext实例上运行多个并行操作,这包括异步查询的并行执行以及从多个线程进行的任何显式并发使用。 因此,始终 await 异步调用,或对并行执行的操作使用单独的 DbContext 实例。
当 EF Core 检测到并行操作或多个线程同时尝试使用 DbContext 实例时,你将看到一条 InvalidOperationException,其中包含类似于下面的消息:

A second operation started on this context before a previous operation completed. Any instance members are not guaranteed to be thread safe.

意思是,在上一个操作没有执行完毕之前,又启动了一个新的操作,所以不能保证线程是安全的。

下面是一段错误的,可以触发这个异常的示例代码:

所以,请始终await异步调用。如果在多个多个线程中使用DbContext,需保证每个线程的DbContext的实例是唯一的。

3. 数据库使用连接池

使用 services.AddDbContextPool比使用 services.AddDbContext吞吐量提升在10~20的百分点(非官方说法,对性能提高数据是本人测试后得到的结果)。
需要注意的是,连接池大小并不是越大越好。

4. 日志记录

在使用ef时,基本上绝大多数和数据库的交互都是通过linq实现的,然后ef将linq翻译成对应的sql语句,在排查问题的时候,在开发或者排查问题时,往往需要关注最终执行的sql脚本,所以就需要通过日志的方式查看。
efcore2.x的版本默认是注入日志服务,所以不需要额外的操作,就可以查看对应的sql脚本。但efcore3.x的版本默认移除了日志服务,具体原因参照:https://docs.microsoft.com/zh-cn/ef/core/what-is-new/ef-core-3.0/breaking-changes#adddbc。
可通过自定义DbContext的方式注入日志任务,示例代码如下:

public static readonly ILoggerFactory MyLoggerFactory
            = LoggerFactory.Create(builder => { builder.AddConsole(); });
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    base.OnConfiguring(optionsBuilder);
    optionsBuilder.UseLoggerFactory(MyLoggerFactory);
}

当执行ef代码时,可在控制台中查看相关的sql脚本,如下图所示:
efcore技巧贴-也许有你不知道的使用技巧-LMLPHP

5. 增

插入数据到数据库常用的场景有:普通单表单行插入,多表级联插入,批量插入。
普通单表单行插入比较简单,实例代码如下:

var student = new Student {CreateTime = DateTime.Now, Name = "zjjjjjj"};
await _context.Students.AddAsync(student);
await _context.SaveChangesAsync();

多表级联插入,需要在实体映射中配置属性导航。
比如Blog表和Post是的关系是1对多的关系。则在Blog的实体中,定义一个类型为List的属性。示例代码如下:

[Table("blog")]
public class Blog
{
    [Column("id")]
    public long Id { get; set; }
    [Column("title")]
    public string Title { get; set; }
    public List<Post> Posts { get; set; }
    [Column("create_date")]
    public DateTime CreateDate { get; set; }
}

对应的插入语句如下所示:

var blog = new Blog
{
    Title = "测试标题",
    Posts = new List<Post>
    {
        new Post{Content = "评论1"},
        new Post{Content = "评论2"},
        new Post{Content = "评论3"},
    }
};
await _context.Blog.AddAsync(blog);
await _context.SaveChangesAsync();

执行此代码,会生成如下的日志:
efcore技巧贴-也许有你不知道的使用技巧-LMLPHP
从日志中可以看出,通过这种方式实现了级联插入的效果。

批量插入实现方式有两种,一种是EF默认实现,适用于数据源较少的情况。另一种,我们基于EF开发一个大数据量批量插入的服务,适合于数据源大于1000的场景。在万级及以上的数据量上,较EF默认的批量插入性能上有非常明显的提升。具体参考:https://www.cnblogs.com/fulu/p/13370335.html

EF默认实现:

var list = new List<Student>();
for (int i = 0; i < num; i++)
{
    list.Add(new Student { CreateTime = DateTime.Now, Name = "zjjjjjj" });
}

await _context.Students.AddRangeAsync(list);
await _context.SaveChangesAsync();

ISqlBulk实现:

var list = new List<Student>();
for (int i = 0; i < 100000; i++)
{
    list.Add(new Student { CreateTime = DateTime.Now, Name = "zjjjjjj" });
}
await _bulk.InsertAsync(list);

自增 OR GUID

int自增的优点:

1、需要很小的数据存储空间,仅仅需要4 byte 。

2、insert和update操作时使用INT的性能比GUID好,所以使用int将会提高应用程序的性能。

3、index和Join 操作,int的性能最好。

4、容易记忆。

int自增的缺点:

1、使用INT数据范围有限制。如果存在大量的数据,可能会超出INT的取值范围。

2、很难处理分布式存储的数据表。

GUID做主键的优点:

1、唯一性。

2、适合大量数据中的插入和更新操作。

3、跨服务器数据合并非常方便。

GUID做主键的缺点:

1、存储空间大(16 byte),因此它将会占用更多的磁盘大小。

2、很难记忆。join操作性能比int要低。

3、没有内置的函数获取最新产生的guid主键。

4、EF默认生成的GUID是无序的,会影响数据插入性能。

结论:

在数据量比较少的场景下,建议使用int自增,比如分类。对于大数据量,建议使用有序GUID。因为默认.net生成GUID是无序的,而数据库中主键默认是聚集索引,而聚集索引在物理上的存储是有序的,当插入数据时,如果插入的是无序的GUID,可能就会涉及到移动数据的情况,进而影响插入的性能,特别是百万级数据量的时候,性能影响则较为明显。参考资料:https://www.cnblogs.com/CameronWu/p/guids-as-fast-primary-keys-under-multiple-database.html

其他可选方案:

经过个人多番了解,目前市面上常用的分布式id生成算法和Twitter发布的雪花算法大同小异,个人也在项目中使用过雪花算法,有兴趣的朋友可以在博客园找下相关的内容。不过目前用.net封装的雪花算法普遍较基础,很难在docker或者k8s环境下简单的使用,所以在此预告下,本人根据雪花算法编写的可用于k8s环境的即将开源,敬请期待。

6. 查

EF使用Linq查询数据库中的数据,使用Linq可编写强类型的查询。当命令执行时,EF先将Linq表达式转换成sql脚本,然后再提交给数据库执行。可在日志中查看生成的sql脚本。

根据条件查询:
await _context.Blog.Where(x=>x.Id>0).ToListAsync();

上述代码执行时生成的sql脚本如下所示:

SELECT `x`.`id`, `x`.`create_date`, `x`.`title`
      FROM `blog` AS `x`
      WHERE `x`.`id` > 0
获取单个实体

可实现获取单个实体的方式有First,FirstOrDefault,Single,SingleOrDefault
其中First,FirstOrDefault执行时生成的sql脚本如下:

 SELECT `x`.`id`, `x`.`create_date`, `x`.`title`
      FROM `blog` AS `x`
      WHERE `x`.`id` > 10
      LIMIT 1

Single,SingleOrDefault执行时生成的sql脚本如下:

 SELECT `x`.`id`, `x`.`create_date`, `x`.`title`
      FROM `blog` AS `x`
      WHERE `x`.`id` > 10
      LIMIT 2

细心的你应该已经发现了两者的区别,Single需要查询2条数据,当返回的数据多余一条时,Single,SingleOrDefault方法就会报Source sequence contains more than one element.异常。所以Single方法仅适用于查询条件对应的数据只有一条的场景,比如查询主键的值。如下所示:

await _context.Blog.SingleOrDefaultAsync(x => x.Id==100);

后缀带OrDefault和不带后缀的区别是,当sql脚本执行查询不到数据时,带后缀的会返回空值,而不带后缀的则会直接报异常。

判断数据库是否存在

可通过Any()和Count()方法实现是否存在数据。示例代码如下:

await _context.Blog.AnyAsync(x => x.Id > 100);

await _context.Blog.CountAsync(x => x.Id > 100)>0;

生成的sql脚本对应如下:

SELECT CASE
          WHEN EXISTS (
              SELECT 1
              FROM `blog` AS `x`
              WHERE `x`.`id` > 100)
          THEN TRUE ELSE FALSE
      END
SELECT COUNT(*)
      FROM `blog` AS `x`
      WHERE `x`.`id` > 100

乍一看,Any方法生成的脚本貌似更复杂些,但实际上,Any方法的性能在大数据量下比Count方法高了很多。所以在判断是否存在时,请使用Any方法。

连接查询

连接查询是关系数据库中最主要的查询,主要包括内连接、外连接(左连接、外连接)和交叉连接等。通过连接运算符可以实现多个表查询。本文主要讲解下常用的内连接和左连接。
内连接的示例代码如下:

var query = from post in _context.Post
            join blog in _context.Blog on post.BlogId equals blog.Id
    where blog.Id > 0
    select new {blog, post};

左连接的示例代码如下:

var query = from post in _context.Post
                        join blog in _context.Blog on post.BlogId equals blog.Id
                        into pbs
                        from pb in pbs.DefaultIfEmpty()
                where pb.Id>0 && post.Content.Contains("1")
                        select new {post,pb.Title};
级联查询

在很多场景中,可能会涉及到查询与父表关联的子表数据,在这样的场景中,会有一部分人先查出主表数据,然后根据主表的主键再去查询子表的数据,笔者在使用ef初期也是这种处理方式的。但借助Include的方法可以让我们更方便的解决父子表级联查询的问题。示例代码如下:

var result = await _context.Blog.Include(b => b.Posts) .SingleOrDefaultAsync(x=>x.Id==157);

如果有更多的层级,可以借助ThenInclude进行查询。

有的时候,还有这样的场景:我们不是简单的查询子表的数据,而是需要查询满足指定条件的数据,那就要求咱们在调用Include的方法时传入参数,示例代码如下:

 var filteredBlogs = await _context.Blogs
        .Include(blog => blog.Posts
            .Where(post => post.BlogId == 1)
            .OrderByDescending(post => post.Title)
            .Take(5))
        .ToListAsync();

注:以上方法仅在.net5中支持。所以,efcore也是在一个发展的过程中,随着时间与版本的更新,功能也会渐渐趋于完善。相关内容请参考:https://docs.microsoft.com/zh-cn/ef/core/querying/related-data

7. 改

使用过EF的应该都了解查询的跟踪与非跟踪的概念吧(纳尼?你没听说过,老衲给您指条明路吧:https://docs.microsoft.com/zh-cn/ef/core/querying/tracking)。

通常来讲,更新的流程大概是这样:查询出数据,修改某些字段的值,调用Update方法,然后调用SaveChange方法。看上去毫无破绽,但如果你仔细观察过生成的sql脚本的话,或许你就应该有更好的方法,咱们先来看看示例代码:

var school = await _context.Schools.FirstAsync(x => x.Id > 0);
school.Name = "6666";
_context.Schools.Update(school);
await _context.SaveChangesAsync();

如下图所示的是执行以上代码生成的update的sql语句,我们发现明明代码中只对Name重新赋了值,但生成的脚本却将此记录的所有字段进行了更新,显然这不是我们想要的结果。

efcore技巧贴-也许有你不知道的使用技巧-LMLPHP

其实,如果实体是通过跟踪查询得到的,则可直接调用SaveChage方法,而不用多余调用Update方法,此时,EF内部会自动判断哪些字段进行了更新,从而只生成值改变了的sql语句。

结论:当要更新的实体开启了跟踪,则更新时,无需调用Update方法, 直接调用SaveChange方法,此时之后更新值发生改变的字段。 如果先调用Update则SaveChange,则不管实体的字段有没有更新,生成的sql脚本依旧会更新所有的字段,牺牲了性能。假如你的实体不是通过数据库的跟踪查询获取的,则在调用时才需要调用Update方法。


福禄ICH.架构出品

作者:福尔斯

2020年8月

08-29 01:39