返回

浅议 EF Core 分库分表及多租户架构的实现

各位朋友,大家好,我是 Payne,欢迎大家关注我的博客,我的博客地址是:https://blog.yuanpei.me。最近这段时间,我一直在学习 ABP vNext 框架,在整个学习过程中,我基本就是在“文档”和“源码”间来回横跳。我个人推荐大家,多去阅读一点优秀的代码,因为阅读 ABP vNext 的源代码简直就是一种享受,它可以暂时让你摆脱如泥沼一般的业务代码。言归正传,ABP vNext 是一个支持多租户架构的框架,在了解了其多租户的实现原理以后,从中收获一点微不足道的小技巧。正好前几天,刚刚同一位朋友讨论完分库、分表这类话题。因此,在今天这篇博客中,我想和大家一起探讨下 EF Core 关于分库、分表以及多租户架构的实现。此中曲折,可以说是初窥门径,或许我无法提供给你一个开箱即用的方案,至少它可以带给你一点启发。有读者朋友建议我,不要总是写这种“高深”、“复杂”的话题,适当地迎合读者写点不需要动脑子的东西。对此,我想说,我有我个人技术上的追求,希望大家理解!

分库

首先,我们一起来探讨分库这个话题。从字面含义上了解,分库就是指应用程序拥有多个数据库,而这些数据库则拥有相同的表结构。你可能会问,为什么我们需要分库、分表?答案自然是性能性能,还是TM的性能。我相信,大家都曾经或多或少地听到过垂直拆分水平拆分这样的术语,下图展示了如何在数据库这一层级上进行拆分:

数据库的垂直拆分与水平拆分
数据库的垂直拆分与水平拆分

其实,我们可以从索引存储B+树高度QPS连接数 这四个不同的角度来审视这个话题。相关观点认为,当单表数据量达到一定量级(阿里巴巴Java开发手册中为500W)时,由于内存无法存储其索引,此时SQL查询会产生磁盘IO;行记录的大小决定了B+树的每个叶子节点能存储多少记录,所以,行记录的大小会影响B+树的高度;单个MySQL物理机实例写QPS峰值大概为1万,一旦业务量达到某个量级,这个瓶颈会逐步凸显出来;单个MySQL实例最大连接数有限,更多的访问量意味着需要更多的连接数。

在谈论分库、分表的时候,我们忍不住会去想譬如“自动分表”和“路由”这样的问题,这些子库、子表,到底是提前在数据库里分好呢,还是在运行时期间自动去拆分呢,以及我对库/表进行拆分以后,我应该怎么样找到某条数据对应的库/表。我承认,这些问题并不简单,但当我们对问题进行简化以后,分库本质上就是动态地切换数据库,对不对?无非是拆分后的数据库可能会是类似db_0、db_1等等这样的序列。

对 Chinook 进行水平拆分
对 Chinook 进行水平拆分

对于数据库的自动拆分,博主尝试过的一种方案是:首先,通过Add-Migration生成迁移。然后,通过循环修改连接字符串的方式,调用Context.Database.Migrate()方法为一个数据库迁移表结构和种子数据。当然,有些朋友不认同在生产环境使用迁移的做法,认为对数据库的操作权限还是应该交给 DBA 来管理,这当然无可厚非。我表达的一直都是一种思路,我不想一个工作六年的人,对技术的态度永远都停留在“能跑”、“能抄”这种水平。

一旦想清楚这一层,实现起来还是非常简单的。我们在配置中准备多个数据库来模拟分库的场景,实际应用中到底是用范围Hash 还是 配置,大家结合自己的场景来决定就好。其实,这个思路还可以用来做读写分离,无非是这个库更特殊一点,它是个从库。好了,我们一起来看下面的代码:

1// 这里随机连接到某一个数据库
2// 实际应该按照某种方式获得数据库库名后缀
3var shardings = _options.Value.MultiTenants;
4var sharding = shardings[new Random().Next(0, shardings.Count)];
5_chinookContext.Database.GetDbConnection().ConnectionString = sharding.ConnectionString;
6Console.WriteLine("--------分库场景--------");
7Console.WriteLine(_chinookContext.Database.GetDbConnection().ConnectionString);
8Console.WriteLine(_chinookContext.Album.ToQueryString());
9Console.WriteLine(_chinookContext.Artist.ToQueryString());

事实上,如果选择性地忽略 “路由” 和 “自动分表” 这两个特性,我们已经在 EF 层面上局部的实现了 “分库” 功能:

分库场景
分库场景

分表

好了,聊完分库,我们再来聊聊分表。分表就是指同一个数据库里拥有多张结构(Schema)相同的表。一个典型的例子是,Excel里的多张Sheet,只要它们拥有相同的结构(Schema),就可以视为同一类型的数据,虽然它们拥有不同的表名。和分库类似,分表的着眼点是避免产生“大表”,从而达到提高查询性能的目的。而对应到 EF(EntityFramework) 的场景中,分表本质上就是在解决 EF 动态适配表名的问题。同样的,下面两张图展示了如何在这个层级进行拆分:

表的垂直拆分
表的垂直拆分

表的水平拆分
表的水平拆分

图片援引自:雨点的名字 - 分库分表(1) — 理论

譬如,我们以年为单位,产生了Album_2020Album_2021两张表。那么,在已经定义好了实体Album的情况下,有没有办法可以让实体Album动态地去适配这两张表呢?或许,熟悉 EF 的你,此刻正在心里暗笑道,这有何难,只要在对应实体的OnModelCreating()方法中,修改ToTable()方法的参数就好了啊。可如果你亲自试一试,就会知道这是你的一厢情愿啦!

针对 Album 和 Artist 按年份进行拆分
针对 Album 和 Artist 按年份进行拆分

事实上,EF 针对实体和表的映射关系做了缓存,这意味着,一旦在OnModelCreating()方法中确定映射关系,这组映射关系将被缓存下来。在 EF 中,这组映射关系的缓存行为,由IModelCacheKeyFactory接口来决定,它提供了一个Create()方法,如果该方法的返回值与上一次相同,则不会调用OnModelCreating()方法。所以,我们的思路就是,让这个Create()方法返回不同的对象。为此,我们考虑实现IModelCacheKeyFactory接口,并用这个自定义实现来替换微软的默认实现。我们一起来看下面的代码:

1public class DynamicModelCacheKeyFactory : IModelCacheKeyFactory
2{
3    public object Create(DbContext context)
4    {
5        return context is ShardingContext shardingContext
6            ? (context.GetType(), shardingContext.ShardingSuffix)
7            : (object)context.GetType();
8    }
9}

为了配合DynamicModelCacheKeyFactory的使用,我们还需要定义用于分表的ShardingContext,它继承自DbContext,我们为其扩展了ShardingSuffix属性,并通过注入的IShardingPolicyProvider接口来获取一个分表后缀。比如,我们有Order表,经过拆分后获得Order_01Order_02这样的子表,所以,这个分表后缀其实就是01、02。没错,我们还是要去修改ToTable()方法中的表名,不同的是,这里的表名是动态的。注意到,Create()方法返回的是一个元组,所以,不同的ShardingSuffix会产生不同的映射关系。

 1public class ShardingContext : DbContext
 2{
 3    public DbSet<Artist> Artist { get; set; }
 4    public DbSet<Album> Album { get; set; }
 5
 6    private readonly IShardingPolicyProvider _shardingPolicyProvider;
 7    public string ShardingSuffix { get; private set; }
 8
 9    public ShardingContext(
10      DbContextOptions<ShardingContext> options, 
11      IShardingPolicyProvider shardingPolicyProvider
12    ) : base(options)
13    {
14        _shardingPolicyProvider = shardingPolicyProvider;
15        ShardingSuffix = _shardingPolicyProvider.GetShardingSuffix();
16    }
17
18    protected override void OnModelCreating(ModelBuilder modelBuilder)
19    {
20        base.OnModelCreating(modelBuilder);
21
22        // Album
23        // 动态映射表名,譬如:Album_2021
24        modelBuilder.Entity<Album>().ToTable($"Album_{ShardingSuffix}");
25        modelBuilder.Entity<Album>().HasKey(x => x.AlbumId);
26        modelBuilder.Entity<Album>()
27          .Property(x => x.AlbumId).HasColumnName("AlbumId");
28        modelBuilder.Entity<Album>()
29          .Property(x => x.Title).HasColumnName("Title");
30        modelBuilder.Entity<Album>()
31          .Property(x => x.ArtistId).HasColumnName("ArtistId");
32
33        // Artist
34        // 动态映射表名,譬如:Artist_2021
35        modelBuilder.Entity<Artist>().ToTable($"Artist_{ShardingSuffix}");
36        modelBuilder.Entity<Artist>().HasKey(x => x.ArtistId);
37        modelBuilder.Entity<Artist>()
38          .Property(x => x.ArtistId).HasColumnName("ArtistId");
39        modelBuilder.Entity<Artist>()
40          .Property(x => x.Name).HasColumnName("Name");
41    }
42}

关于分库、分表以后,怎么去匹配对应的库或者表,这类问题我们称之为路由问题。常见的策略主要有,范围Hash配置

  • 范围最直观的就是按照时间来拆分,比如按年、按月、按天等等,主要的问题是分布不均匀;其次,可以按照Id的范围来划分,比如0到10万、10万到20万依次划分到不同的表里,主要的问题是热点数据带来的性能问题。
  • Hash主要指哈希取模。例如,可以针对用户Id做如下处理:HASH(userId) % N,其中,N表示当前拆分表的数目。可以预见的问题是,当N变化的时候,会产生数据迁移的需求,所以,这种方式并不利于扩容,
  • 配置,顾名思义,就是用一张表来存储数据和子表间的映射关系,每次先按照数据的主键找到子表,然后再从子表中查询所需要的数据。好处是扩容灵活,而缺点同样明显,查询配置表,带来了额外的性能损耗。

在这里,我们是使用年份来作为分表后缀的。为了方便演示,在实现ShardingByYearPolicy类时,我们直接使用了当前时间,这意味着我们会将Album实体映射到Album_2021这张表,以此类推。在实际使用中,更推荐大家使用 雪花算法 生成Id,因为这样,我们就可以通过Id反推出具体的时间范围,进而决定要映射到哪一个库、哪一张表。关于子表的生成,博主这里是通过迁移来实现的,考虑到EF自动创建数据库/表,都需要先创建迁移,所以,这并不是一个开箱即用的方案。

1class ShardingByYearPolicy : IShardingPolicyProvider
2{
3    public string GetShardingSuffix()
4    {
5        return $"{DateTime.Now.ToString("yyyy")}";
6    }
7}

好了,现在我们可以编写简单的代码,来验证我们的这些想法是都正确,即使是最简单的控制台程序,我还是喜欢用依赖注入:

1// 注入ShardingContext
2services.AddDbContext<ShardingContext>(options => {
3    options.UseSqlite(config.GetValue<string>("Database:Default")); 
4    //替换默认实现
5    options.ReplaceService<IModelCacheKeyFactory, DynamicModelCacheKeyFactory>(); 
6});
7
8// 注入IShardingPolicyProvider
9services.AddTransient<IShardingPolicyProvider, ShardingByYearPolicy>();

接下来,我们可以通过ShardingContext来匹配Album_2021表:

1// 这里应该连接到Album_2021表
2// 实际应该按照某种方式获得表名后缀
3Console.WriteLine("--------分表场景--------");
4Console.WriteLine(_shardingContext.Database.GetDbConnection().ConnectionString);
5Console.WriteLine(_shardingContext.Album.ToQueryString());
6Console.WriteLine(_shardingContext.Artist.ToQueryString());

此时,我们会得到下面的结果:

EF Core 分表效果演示
EF Core 分表效果演示

至此,如果选择性地忽略 “路由” 和 “自动分表” 这两个特性,我们已经在 EF 层面上局部的实现了 “分表” 功能。怎么样,是不是还行?

多租户架构

最后,我们来聊聊多租户架构这个话题。可能有朋友觉得多租户架构和分库、分表没什么关系,不好意思啊,这是个非常合理的联想,因为还真就有关系,甚至我们还能继续发散到读写分离。你想想看,多租户架构中,如果一个租户一个数据库,这是不是就是分库的场景。而在分库的场景中,如果一个是主库,一个是从库,这是不是就是读写分离的场景。在学习数学的过程中,学会转化问题是一种重要的思维,即让一个不熟悉的问题变成一个熟悉的问题,在今天这篇博客中,从分库发散到多租户、读写分离,正是这一思路的体现,通常情况下,多租户架构有多数据库和单数据库两种实现方式。

多数据库

多数据库,指每一个租户一个数据库。这种实现方式的好处是,租户间的数据天然隔离,数据库的访问压力天然隔离。可由于所有租户都共享一套应用程序,随着数据库越来越多,维护的成本亦越来越高。参考分库的实现,我们可以非常容易地实现租户数据库的切换。这里,我们的思路是,调用方在 HTTP 请求中加入自定义的首部字段X-TenantIdDbContext通过该字段来匹配对应的链接字符串,这样就可以实现多数据库的多租户架构:

 1public class TenantInfoProvider : ITenantInfoProvider
 2{
 3    private const string X_TENANT_ID = "X-TenantId";
 4    private readonly IHttpContextAccessor _httpContextAccessor;
 5    public TenantInfoProvider(IHttpContextAccessor httpContextAccessor)
 6    {
 7        _httpContextAccessor = httpContextAccessor;
 8    }
 9
10    public string GetTenantId()
11    {
12        var httpContext = _httpContextAccessor.HttpContext;
13        if (httpContext != null && httpContext.Request.Headers.ContainsKey(X_TENANT_ID))
14            return httpContext.Request.Headers[X_TENANT_ID].FirstOrDefault();
15
16        return null;
17    }
18}

接下来,假设我们AppSettings.json文件维护各个租户的连接字符串信息。通常,在实际场景中,我们会将这些信息存储在数据库中:

 1{
 2  "Database": {
 3    "Default": "Data Source=Chinook.db",
 4    "MultiTenants": [
 5      {
 6        "tenantId": "01",
 7        "ConnectionString": "Data Source=Chinook01.db"
 8      },
 9      {
10        "tenantId": "02",
11        "ConnectionString": "Data Source=Chinook02.db"
12      }
13    ]
14  }
15}

此时,我们可以通过下面的代码片段来实现租户切换:

 1var tenantId = _tenantInfoProvider.GetTenantId();
 2var database = _options.Value.MultiTenants.FirstOrDefault(x => x.TenantId == tenantId);
 3if (database == null)
 4    throw new Exception($"Invalid tenantId \"{tenantId}\"");
 5
 6_chinookContext.Database.GetDbConnection().ConnectionString = database.ConnectionString;
 7Console.WriteLine("--------多租户 + 多数据库--------");
 8Console.WriteLine($"TenantId:{tenantId}");
 9Console.WriteLine(_chinookContext.Database.GetDbConnection().ConnectionString);
10Console.WriteLine(_chinookContext.Album.ToQueryString());
11Console.WriteLine(_chinookContext.Artist.ToQueryString());

可以注意到,一切如我们所预料的一样,程序自动切换到01这个租户:

多租户 + 多数据库
多租户 + 多数据库

单数据库

单数据库,指所有租户都在一个数据库里,使用相同的表结构(Schema),并通过TenantId字段进行区分。ABP vNext 中的多租户架构就是这种模式,而我之前的公司,则是单数据库 + 多数据库的混合模式。这种实现方式的好处是数据库非常精简,而缺点同样很明显,一旦某个租户出现问题,非常容易波及所有租户,因为所有租户都在一个数据库里,数据库的压力实际上是大家一起分担的,租户间相互影响的可能性非常大。

同样地,我们依然需要用到X-TenantId这个请求头,由于所有租户都在一个数据库上,我们不会再试图去修改链接字符串。EF Core 中针对实体提供了HasQueryFilter()扩展方法,该访问允许我们传入一个 Lambda 表达式。此时,我们所有的请求都会自动带上类似Album.TenantId = 'xxxx'这样的条件,这样我们就实现了单数据库的多租户架构。

 1public class MulitiTenancyContext : DbContext
 2{
 3    public DbSet<Artist> Artist { get; set; }
 4    public DbSet<Album> Album { get; set; }
 5    private readonly ITenantInfoProvider _tenantInfoProvider;
 6    public MulitiTenancyContext(
 7      DbContextOptions<MulitiTenancyContext> options, 
 8      ITenantInfoProvider tenantInfoProvider
 9    ) : base(options)
10    {
11        _tenantInfoProvider = tenantInfoProvider;
12    }
13
14    protected override void OnModelCreating(ModelBuilder modelBuilder)
15    {
16        base.OnModelCreating(modelBuilder);
17
18        modelBuilder.ApplyConfiguration(new ArtistMap());
19        modelBuilder.ApplyConfiguration(new AlbumMap());
20        
21        // 利用 HasQueryFilter 进行租户间数据隔离
22        var tenantId = _tenantInfoProvider.GetTenantId();
23        if (!string.IsNullOrEmpty(tenantId))
24        {
25            modelBuilder.Entity<Album>().HasQueryFilter(x => x.TenantId == tenantId);
26            modelBuilder.Entity<Artist>().HasQueryFilter(x => x.TenantId == tenantId);
27        }
28    }
29}

为了在实体上应用这个过滤条件,参照 ABP vNext 中的实现,我们定义了IMulitiTenancy接口,所有实体均需要实现TenantId字段。为了简化设计,我们直接使用字符串类型来定义租户Id,而在 ABP vNext 中很多主键都被定义为 Guid,我们掌握核心原理即可,不用过分强求和 ABP vNext 的一致。

 1// IMulitiTenancy
 2public interface IMulitiTenancy
 3{
 4    public string TenantId { get; set; }
 5}
 6
 7// Album
 8public class Album : IMulitiTenancy
 9{
10    public int AlbumId { get; set; }
11    public string Title { get; set; }
12    public int ArtistId { get; set; }
13    public string TenantId { get; set; }
14}

此时,我们可以编写简单的测试代码,来验证我们的想法是否正确。同样地,我还是使用了依赖注入:

1// 这里应该查询01租户内的Album
2var tenantId = _tenantInfoProvider.GetTenantId();
3Console.WriteLine("--------多租户 + 单数据库--------");
4Console.WriteLine($"TenantId:{tenantId}");
5Console.WriteLine(_mulitiTenancyContext.Database.GetDbConnection().ConnectionString);
6Console.WriteLine(_mulitiTenancyContext.Album.ToQueryString());
7Console.WriteLine(_mulitiTenancyContext.Artist.ToQueryString());

可以注意到,打印出的 SQL 语句中自动带出了过滤条件:

多租户 + 多数据库
多租户 + 多数据库

本文小结

这篇博客主要探讨了 EF 在分库、分表及多租户架构上实施的可能性。分库、分表的目的是为了提高数据库的查询性能,在这个过程中,我们可以考虑范围Hash配置三种路由策略,它们各自有自己的优缺点,需要使用者结合业务场景去衡量。虽然分库、分表在面对百万级别以上的数据时,不失为一种提高性能的方案,可世间万物都是双刃剑,它同样带来了一系列新的问题,譬如跨库写带来的分布式事务问题,跨库读带来的Join、Count()、排序、分页等问题,数据迁移问题等等,而如果希望通过Hash(Id)来进行拆分,还需要解决全局Id唯一的问题。所以说,这是一个没有标准答案的问题,需要使用者自己去进行取舍。多租户架构、读写分离均可以看作是特殊的分库场景,EF Core 中新增的HasQueryFilter()方法则帮助我们解决了单数据库的多租户架构问题。好了,以上就是这篇博客的全部内容啦,如果大家对文中的观点有建议或者意见,欢迎大家在评论区留言,谢谢!

附本文源代码:https://github.com/Regularly-Archive/2021/tree/master/EF.Sharding

Built with Hugo
Theme Stack designed by Jimmy