惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

SecWiki News
SecWiki News
I
InfoQ
The Cloudflare Blog
人人都是产品经理
人人都是产品经理
博客园 - Franky
T
Tailwind CSS Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
量子位
博客园_首页
罗磊的独立博客
V
V2EX
李成银的技术随笔
大猫的无限游戏
大猫的无限游戏
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
True Tiger Recordings
Vercel News
Vercel News
Cyberwarzone
Cyberwarzone
Cisco Talos Blog
Cisco Talos Blog
F
Fox-IT International blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
M
Microsoft Research Blog - Microsoft Research
Know Your Adversary
Know Your Adversary
爱范儿
爱范儿
The Register - Security
The Register - Security
G
Google Developers Blog
The Hacker News
The Hacker News
Malwarebytes
Malwarebytes
S
Securelist
博客园 - 三生石上(FineUI控件)
Jina AI
Jina AI
T
Threat Research - Cisco Blogs
T
The Exploit Database - CXSecurity.com
S
SegmentFault 最新的问题
博客园 - 叶小钗
F
Fortinet All Blogs
Apple Machine Learning Research
Apple Machine Learning Research
宝玉的分享
宝玉的分享
博客园 - 聂微东
T
Threatpost
博客园 - 【当耐特】
D
Docker
P
Privacy & Cybersecurity Law Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
G
GRAHAM CLULEY
V
Visual Studio Blog
C
Cisco Blogs
IT之家
IT之家
S
Security Archives - TechRepublic
Latest news
Latest news
阮一峰的网络日志
阮一峰的网络日志

元视角

Terraform 极简入门:从 AWS-CLI 到基础设施即代码(IaC) - 元视角 .NET 生态下的 Agent 框架选型:从 ReAct 到原生推理 - 元视角 从「能用」到「好用」:LLM 流式响应实现方式的探索之路 - 元视角 当我用 2000 条聊天记录,让 AI 为我画一幅自画像 - 元视角 基于 Supabase 的 AI 应用开发探索 - 元视角 盛世幻象:从荔枝看盛唐的兴衰 - 元视角 观影 - 元视角 听歌 - 元视角 个人作品 - 元视角 站点统计 - 元视角 读书 - 元视角 微博 × MCP:社交媒体新玩法解锁 - 元视角 四点钟海棠花未眠 - 元视角 留言 - 元视角 Semantic Kernel × MCP:智能体的上下文增强探索 - 元视角 命运、偏见与自由:《魔童之哪吒闹海》的终极抗争 - 元视角 基于 K-Means 聚类分析实现人脸照片的快速分类 - 元视角 容器技术驱动下的代码沙箱实践与思考 - 元视角 温故而知新:后端通用查询方案的再思考 - 元视角 浅议 CancellationToken 在前后端协同取消场景中的应用 - 元视角 Semantic Kernel 视角下的 Text2SQL 实践与思考 - 元视角 走走停停,允许一切发生 - 元视角 关于 ChatGPT 的流式传输,你需要知道的一切 - 元视角 从抖音看见世界的参差 - 元视角 俯仰之间:五一小长假出行记 - 元视角 RAG 的是与非、Rewrite 和 Rerank - 元视角 《你想活出怎样的人生》与宫崎骏的自我和解 - 元视角 使用 EFCore 和 PostgreSQL 实现向量存储及检索 - 元视角 基于 LLaMA 和 LangChain 实践本地 AI 知识库 - 元视角 使用 llama.cpp 在本地部署 AI 大模型的一次尝试 - 元视角 如何为 Git 配置多个 SSH Key - 元视角 C# 使用 LibUsbDotNet 实现 USB 设备检测 - 元视角 基于 C# 实现样式与数据分离的打印方案 - 元视角 基于 SVG 的图形交互方案实践 - 元视角 前端视频播放技术概览 - 元视角 你好,千寻小姐 - 元视角 温故而知新,再话 Python 动态导入 - 元视角 后 GPT 时代,NLP 不存在了? - 元视角 视频是不能 P 的系列:使用 Milvus 实现海量人脸快速检索 - 元视角 GDI+下字体大小自适应方案初探 - 元视角 小爱音箱集成 ChatGPT 的不完全教程 - 元视角 程序员视角下的三体世界随想 - 元视角 关于 Docker 容器配置信息的渐进式思考 - 元视角 在 Docker 容器内集成 Crontab 定时任务 - 元视角 为你的服务器集成 LDAP 认证 - 元视角 似花还似非花 - 元视角 视频是不能 P 的系列:使用 Dlib 实现人脸识别 - 元视角 浅议分布式链路追踪与日志的整合 - 元视角 关于 Git 大文件上传这件小事 - 元视角 .NET 进程内队列 Channel 的入门与应用 - 元视角 使用 Fody 实现 .NET 的静态编织 - 元视角 .NET Core + ELK 搭建可视化日志分析平台(下) - 元视角 聊一聊前端图片懒加载背后的故事 - 元视角 杂感·七月寄望 - 元视角 支持外部链接跳转的 Vue Router 扩展实现 - 元视角 视频是不能 P 的系列:OpenCV 和 Dlib 实现表情包 - 元视角 不得不说的 ASP.NET Core 集成测试 - 元视角 再议 DDD 视角下的 EFCore 与 领域事件 - 元视角 Vue.js 前端项目容器化部署实践极简教程 - 元视角 再见,人间四月天 - 元视角 Python 图像风格化迁移助力画家梦想 - 元视角 在 Vue.js 中使用 Mock.js 实现接口模拟 - 元视角 利用 ASP.NET Core 中的标头传播实现分布式链路追踪 - 元视角 读《一个叫欧维的男人决定去死》 - 元视角 利用 gRPC 实现文件的上传与下载 - 元视角 七种武器:延迟队列的原理和实现总结 - 元视角 gRPC 流式传输极简入门指南 - 元视角 烟波梦影,从天国王朝到刺客信条 - 元视角 关于 - 元视角 Envoy 集成 Jaeger 实现分布式链路追踪 - 元视角 浅议非典型 Web 应用场景下的身份认证 - 元视角 gRPC 借助 Any 类型实现接口的泛化调用 - 元视角 分布式丛林探险系列之 Redis 集群模式 - 元视角 写在冬阳升起以前 - 元视角 分布式丛林探险系列之 Redis 主从复制模式 - 元视角 通过 Python 预测 2021 年双十一交易额 - 元视角 从《失控玩家》中得到的启示 - 元视角 gRPC 搭配 Swagger 实现微服务文档化 - 元视角 SSL/TLS 加密传输与数字证书的前世今生 - 元视角 夕雾花园:从建筑中读出的爱情和美学 - 元视角 使用 Python 自动识别防疫健康码 - 元视角 你不可不知的容器编排进阶技巧 - 元视角 ASP.NET Core 搭载 Envoy 实现 gRPC 服务代理 - 元视角 再话 AOP,从简化缓存操作说起 - 元视角 洗衣随想曲 - 元视角 ASP.NET Core 搭载 Envoy 实现微服务身份认证(JWT) - 元视角 浪客剑心:一曲幕末时代的挽歌 - 元视角 ASP.NET Core 搭载 Envoy 实现微服务的监控预警 - 元视角 ASP.NET Core 搭载 Envoy 实现微服务的负载均衡 - 元视角 ASP.NET Core 搭载 Envoy 实现微服务的反向代理 - 元视角 ASP.NET Core gRPC 打通前端世界的尝试 - 元视角 EFCore 实体命名约定库:EFCore.NamingConventions - 元视角 ASP.NET Core gRPC 集成 Polly 实现优雅重试 - 元视角 ASP.NET Core gRPC 健康检查的探索与实现 - 元视角 ASP.NET Core gRPC 拦截器的使用技巧分享 - 元视角 SnowNLP 使用自定义语料进行模型训练 - 元视角 假如时间有温度 - 元视角 使用 HttpMessageHandler 实现 HttpClient 请求管道自定义 - 元视角 ABP vNext 的实体与服务扩展技巧分享 - 元视角 ABP vNext 对接 Ant Design Vue 实现分页查询 - 元视角
浅议 EF Core 分库分表及多租户架构的实现 - 元视角
2021-03-27 · via 元视角

各位朋友,大家好,我是 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 还是 配置,大家结合自己的场景来决定就好。其实,这个思路还可以用来做读写分离,无非是这个库更特殊一点,它是个从库。好了,我们一起来看下面的代码:

// 这里随机连接到某一个数据库
// 实际应该按照某种方式获得数据库库名后缀
var shardings = _options.Value.MultiTenants;
var sharding = shardings[new Random().Next(0, shardings.Count)];
_chinookContext.Database.GetDbConnection().ConnectionString = sharding.ConnectionString;
Console.WriteLine("--------分库场景--------");
Console.WriteLine(_chinookContext.Database.GetDbConnection().ConnectionString);
Console.WriteLine(_chinookContext.Album.ToQueryString());
Console.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接口,并用这个自定义实现来替换微软的默认实现。我们一起来看下面的代码:

public class DynamicModelCacheKeyFactory : IModelCacheKeyFactory
{
    public object Create(DbContext context)
    {
        return context is ShardingContext shardingContext
            ? (context.GetType(), shardingContext.ShardingSuffix)
            : (object)context.GetType();
    }
}

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

public class ShardingContext : DbContext
{
    public DbSet<Artist> Artist { get; set; }
    public DbSet<Album> Album { get; set; }

    private readonly IShardingPolicyProvider _shardingPolicyProvider;
    public string ShardingSuffix { get; private set; }

    public ShardingContext(
      DbContextOptions<ShardingContext> options, 
      IShardingPolicyProvider shardingPolicyProvider
    ) : base(options)
    {
        _shardingPolicyProvider = shardingPolicyProvider;
        ShardingSuffix = _shardingPolicyProvider.GetShardingSuffix();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Album
        // 动态映射表名,譬如:Album_2021
        modelBuilder.Entity<Album>().ToTable($"Album_{ShardingSuffix}");
        modelBuilder.Entity<Album>().HasKey(x => x.AlbumId);
        modelBuilder.Entity<Album>()
          .Property(x => x.AlbumId).HasColumnName("AlbumId");
        modelBuilder.Entity<Album>()
          .Property(x => x.Title).HasColumnName("Title");
        modelBuilder.Entity<Album>()
          .Property(x => x.ArtistId).HasColumnName("ArtistId");

        // Artist
        // 动态映射表名,譬如:Artist_2021
        modelBuilder.Entity<Artist>().ToTable($"Artist_{ShardingSuffix}");
        modelBuilder.Entity<Artist>().HasKey(x => x.ArtistId);
        modelBuilder.Entity<Artist>()
          .Property(x => x.ArtistId).HasColumnName("ArtistId");
        modelBuilder.Entity<Artist>()
          .Property(x => x.Name).HasColumnName("Name");
    }
}

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

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

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

class ShardingByYearPolicy : IShardingPolicyProvider
{
    public string GetShardingSuffix()
    {
        return $"{DateTime.Now.ToString("yyyy")}";
    }
}

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

// 注入ShardingContext
services.AddDbContext<ShardingContext>(options => {
    options.UseSqlite(config.GetValue<string>("Database:Default")); 
    //替换默认实现
    options.ReplaceService<IModelCacheKeyFactory, DynamicModelCacheKeyFactory>(); 
});

// 注入IShardingPolicyProvider
services.AddTransient<IShardingPolicyProvider, ShardingByYearPolicy>();

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

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

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

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

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

多租户架构

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

多数据库

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

public class TenantInfoProvider : ITenantInfoProvider
{
    private const string X_TENANT_ID = "X-TenantId";
    private readonly IHttpContextAccessor _httpContextAccessor;
    public TenantInfoProvider(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public string GetTenantId()
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext != null && httpContext.Request.Headers.ContainsKey(X_TENANT_ID))
            return httpContext.Request.Headers[X_TENANT_ID].FirstOrDefault();

        return null;
    }
}

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

{
  "Database": {
    "Default": "Data Source=Chinook.db",
    "MultiTenants": [
      {
        "tenantId": "01",
        "ConnectionString": "Data Source=Chinook01.db"
      },
      {
        "tenantId": "02",
        "ConnectionString": "Data Source=Chinook02.db"
      }
    ]
  }
}

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

var tenantId = _tenantInfoProvider.GetTenantId();
var database = _options.Value.MultiTenants.FirstOrDefault(x => x.TenantId == tenantId);
if (database == null)
    throw new Exception($"Invalid tenantId \"{tenantId}\"");

_chinookContext.Database.GetDbConnection().ConnectionString = database.ConnectionString;
Console.WriteLine("--------多租户 + 多数据库--------");
Console.WriteLine($"TenantId:{tenantId}");
Console.WriteLine(_chinookContext.Database.GetDbConnection().ConnectionString);
Console.WriteLine(_chinookContext.Album.ToQueryString());
Console.WriteLine(_chinookContext.Artist.ToQueryString());

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

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

单数据库

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

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

public class MulitiTenancyContext : DbContext
{
    public DbSet<Artist> Artist { get; set; }
    public DbSet<Album> Album { get; set; }
    private readonly ITenantInfoProvider _tenantInfoProvider;
    public MulitiTenancyContext(
      DbContextOptions<MulitiTenancyContext> options, 
      ITenantInfoProvider tenantInfoProvider
    ) : base(options)
    {
        _tenantInfoProvider = tenantInfoProvider;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.ApplyConfiguration(new ArtistMap());
        modelBuilder.ApplyConfiguration(new AlbumMap());
        
        // 利用 HasQueryFilter 进行租户间数据隔离
        var tenantId = _tenantInfoProvider.GetTenantId();
        if (!string.IsNullOrEmpty(tenantId))
        {
            modelBuilder.Entity<Album>().HasQueryFilter(x => x.TenantId == tenantId);
            modelBuilder.Entity<Artist>().HasQueryFilter(x => x.TenantId == tenantId);
        }
    }
}

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

// IMulitiTenancy
public interface IMulitiTenancy
{
    public string TenantId { get; set; }
}

// Album
public class Album : IMulitiTenancy
{
    public int AlbumId { get; set; }
    public string Title { get; set; }
    public int ArtistId { get; set; }
    public string TenantId { get; set; }
}

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

// 这里应该查询01租户内的Album
var tenantId = _tenantInfoProvider.GetTenantId();
Console.WriteLine("--------多租户 + 单数据库--------");
Console.WriteLine($"TenantId:{tenantId}");
Console.WriteLine(_mulitiTenancyContext.Database.GetDbConnection().ConnectionString);
Console.WriteLine(_mulitiTenancyContext.Album.ToQueryString());
Console.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