NetCore踩坑记2、喜!悲?项目演进的分享和EntityFrameworkCore升级之后的奇怪现象

随着微软对.NET Core2.2停止支持,我们也将项目从.NET Core2.2 升级到.NET Core3.1[LTS]以寻求最新的安全支持。
这其中有一些不大不小的改动,但通过文档中心的指南,我们基本完成了版本的跨越。
由于变更的积压,我们在经过简单的测试之后就将升级后的服务推送到了生产环境,不充分的测试也为后面的问题埋下了种子。

提要

我们的服务运行在阿里云提供的服务上,而不是自有服务器

我司主营业务为内部交易设备的生产制造,销售,服务支持和售后;我们的主要客户是工厂/学校/医院食堂以及美食城。
这意味这我们服务的高峰与三餐(宵夜的人其实也不少,严格的说是四餐)的时间重合,平时没有太多流量,但在就餐时间流量会激增。

我们服务于全国3000+个客户,所以我们的项目是一个有3000+租户的Saas服务,我们对客户使用编号来标识他们的身份,也通过客户的编号对客户进行数据的隔离。

过去我们采用直接分库的方式进行数据隔离,即 DB_{客户编号} 的数据库命名方式,这样是最优的隔离方式,也最有利于数据维护。
但在前年,阿里云来我司进行游说,在阐述了一些优点之后,我们放弃了自建数据库,改为采用他们的RDS for SqlServer 2008R2,由于他们的限制(2008R2 数据库必须从阿里云后台新建,且每个实例只能新建50个数据库,不知道限制有没有改),我们不得不将隔离结构改为分表隔离,每个客户的数据表都是一样的,如果有客户需要定制功能,我们会引导他们进行数据的本地化,这使得我们的数据库结构相对稳定,我们进行分表隔离,对每个客户的每个数据表加上客户编号作为隔离标识,即采用 {客户编号}_数据表名 的命名方式,这样的方式使得数据库的可维护性变差了。

团队规模不大,后端开发只有4人;我们选择使用ORM(EntityFrameworkCore)作为数据库访问的媒介,这大大的提升了我们团队的效率,但也带来了一些问题;比如,我们需要对EntityFrameworkCore的上下文进行复用,并实现对数据的隔离。

1、我们如何应对访问的峰谷变化

对于我们这种峰谷差异明显的服务来说,很明显的,我们需要弹性来对资源进行协调,以求获得可用性和成本的最大化收益,这其实也很容易找到解决方案,比如K8S。
但这个方案并不是特别适合我们,我们现在处于一个业务迁移的时期,项目中还有很多依然运行在.NET Framework的部分,他们是和设备通信的Socket,采用Supersocket v1.6实现,他们是我们需要进行弹性的主要资源,是流量的主要入口,但它也是Windows服务,是无法一时半刻升级到.NET Core的部分。
最终我们选择使用阿里云提供的 弹性伸缩(Auto Scaling)直接对ECS实例进行弹性伸缩,这样可以让我们不加任何改动就能轻松实现弹性。

2、我们如何实现EntityFrameworkCore的上下文复用并且实现数据的隔离

由于数据库结构的特殊原因,很多客户的数据其实是在一个数据库上的,分不同的数据库编号存储,这使得我们需要对每个客户进行Mapping,而不是简单的更改链接字符串。
如下代码片段;我们全程使用FluentApi进行Mapping配置(不是必要的),并通过ToTable在运行时映射到实际访问的客户队友的数据表。

 protected override void OnModelCreating(ModelBuilder modelBuilder)
 {
     modelBuilder.Entity<Table_SystemConfig>(entity =>
     {
      //...省略部分话题无关的Mapping配置
      entity.ToTable($"{CompanyCode}_T_SystemConfig");
     });
 }

单凭这样的改动是不行的,EntityFrameworkCore会在第一次Mapping之后对Model进行缓存,进入缓存之后,Model和Table的Mapping关系即确定并缓存,这会导致下一个其它客户的请求进来时,由于Mapping关系的缓存,将错误的访问到第一个访问系统的客户的数据。有两个方式来解决这种问题
*1、获取到Model的缓存后更改其Mapping关系,使其访问正确的数据。
*2、缓存多份Model,使得每个客户获取到的Model包含正确的Mapping关系
以上所述两种方式,暂且不谈可实现性;它们 1将付出算力的代价,2将付出存储的代价(因为Mapping基本不能被序列化,因此只能缓存在内存中)。
在CPU和内存的取舍中,最终选择舍弃内存来换取更加宝贵的算力资源,通过查询资料,我们最终找到了这样的实现方式,见以下代码片段。

    public class DynamicModelCacheKeyFactory : IModelCacheKeyFactory
    {
        public object Create(DbContext context)
        {
            return  $"{context.GetType()}-{(context as CompanyDbContext).CompanyCode}";
         
        }
    }

    public class CompanyDbContext: DbContext
    {
        public string CompanyCode{ get; set; };

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.ReplaceService<IModelCacheKeyFactory, DynamicModelCacheKeyFactory>();
        }
    }

如上代码片段,通过重新实现IModelCacheKeyFactory接口并替换EntityFrameworkCore的默认实现,实现了预期的功能,经过测试,实际效果和预期一致。

我们付出了内存的代价实现了功能,最终在生产环境表现为每个客户消耗了300kb的内存(每个客户大约有50个表),这样3000+的客户,我们实际上使用了1GB+的内存,并且这些内存不会在闲时被释放。

2.2到3.1的升级EntityFrameworkCore遇到了什么问题

前面除了付出了一些内存的代价外,一切都很稳定,这时候.NET Core3.1更新了,我们权衡利弊,决定升级到3.1版本,毕竟它是LTS版本,相对应的,EntityFrameworkCore也从2.2.x的版本升级到3.1.x的版本
除了一些Api的更名,EFCore基本是没有什么使用上的改动。于是我们升级完之后就推送到了生产环境。
即使是在业务的高峰,预期的1GB+的内存并没有被消耗,这让我很奇怪,学校都不恰饭了吗?
升级后,内存的消耗维持在200MB+,太棒了!
CPU使用高了许多,有问题!但回退已经来不及了。只好多加几个实例,短时间内解决问题,后面到年关了,不能再有大的改动,因此生产环境使用了堆配置的方法暂时解决了问题。
【COVID-19来了】本计划年后就来解决的这个问题,一直被拖到了4月。

我们如何定位

重新审视这个问题,内存降低+CPU升高,难道是缓存没有命中?我首要怀疑这个原因,也整着手从这个角度去寻找,和解决问题。
EntityFrameworkCore是一个开源项目,并且我能获取到它各个版本的源码,这给我解决问题带来了极大的方便。
我比对了2.2.x版本和3.1.x版本的源码,发现了一些端倪。
如下两个片段

    //EntityFrameworkCore version 2.2.8
    public class ModelSource : IModelSource
    {
        private readonly ConcurrentDictionary<object, Lazy<IModel>> _models = new ConcurrentDictionary<object, Lazy<IModel>>();


        public ModelSource([NotNull] ModelSourceDependencies dependencies)
        {
            Check.NotNull(dependencies, nameof(dependencies));

            Dependencies = dependencies;
        }


        protected virtual ModelSourceDependencies Dependencies { get; }


        public virtual IModel GetModel(DbContext context, IConventionSetBuilder conventionSetBuilder, IModelValidator validator)
            => _models.GetOrAdd(
                Dependencies.ModelCacheKeyFactory.Create(context),
                // Using a Lazy here so that OnModelCreating, etc. really only gets called once, since it may not be thread safe.
                k => new Lazy<IModel>(
                    () => CreateModel(context, conventionSetBuilder, validator),
                    LazyThreadSafetyMode.ExecutionAndPublication)).Value;
      //省略了一些无关的代码
      }
    public class ModelSource : IModelSource
    {
        private readonly object _syncObject = new object();


        public ModelSource([NotNull] ModelSourceDependencies dependencies)
        {
            Check.NotNull(dependencies, nameof(dependencies));

            Dependencies = dependencies;
        }


        protected virtual ModelSourceDependencies Dependencies { get; }


        public virtual IModel GetModel(
            DbContext context,
            IConventionSetBuilder conventionSetBuilder)
        {
            var cache = Dependencies.MemoryCache;
            var cacheKey = Dependencies.ModelCacheKeyFactory.Create(context);
            if (!cache.TryGetValue(cacheKey, out IModel model))
            {
                // Make sure OnModelCreating really only gets called once, since it may not be thread safe.
                lock (_syncObject)
                {
                    if (!cache.TryGetValue(cacheKey, out model))
                    {
                        model = CreateModel(context, conventionSetBuilder);
                        model = cache.Set(cacheKey, model, new MemoryCacheEntryOptions { Size = 100, Priority = CacheItemPriority.High });
                    }
                }
            }

            return model;
        }
        //省略了一些无关的代码
    }

    public sealed class ModelSourceDependencies
    {
        [EntityFrameworkInternal]
        public ModelSourceDependencies(
            [NotNull] IModelCustomizer modelCustomizer,
            [NotNull] IModelCacheKeyFactory modelCacheKeyFactory,
            [NotNull] IMemoryCache memoryCache)
        {
            Check.NotNull(modelCustomizer, nameof(modelCustomizer));
            Check.NotNull(modelCacheKeyFactory, nameof(modelCacheKeyFactory));
            Check.NotNull(memoryCache, nameof(memoryCache));

            ModelCustomizer = modelCustomizer;
            ModelCacheKeyFactory = modelCacheKeyFactory;
            MemoryCache = memoryCache;
        }
    }

可以看到,在缓存Model的时候,实际上是发生了一些变化的,缓存从ConcurrentDictionary 变成了IMemoryCache,查找EFCore内部注入的IMemoryCache 的来源发现,它来自于

  //其它代码太多就不贴了  源码相关文件 src\EFCore\Infrastructure\EntityFrameworkServicesBuilder.cs
  TryAdd<IMemoryCache>(p => new MemoryCache(new MemoryCacheOptions { SizeLimit = 10240 }));

看到这里就知道问题所在了;2.2.x缓存Model单纯的使用ConcurrentDictionary ,变成了使用MemoryCache,由Microsoft.Extensions.Caching.Memory包提供实现。
在Model被创建后放入MemoryCache时,给Model设置的占有空间为100,而MemoryCache被初始化为空间大小10240,简单即可得知,当缓存最多102份(其它地方也使用了这个MemoryCache缓存数据)时,缓存空间即满;
后续会移除之前的缓存以腾出空间给新的缓存;要缓存3000+份的Model,是远远不够的,这就造成了高峰时MemoryCache不断的写入新项,移除旧项,GetModel的过程(即为FluentApi构建Mapping关系的过程)一直在进行,真正命中缓存的很少;导致算力消耗增多,内存反而下降。

我们如何解决

找到问题之后,从缓存入手,就很好解决问题了,几个角度
*1 增大MemoryCache实例的[空间?]最大值(使用新的MemoryCache实例替换掉原本的实现)
*2 减小每个Model在MemoryCache中的[空间?]占用
当然 设置缓存存活时间,配置缓存刷新时间,减少访问频率低的缓存留存,这样能减少不必要的内存消耗,并且算力的负担也不会增加太多,我认为这样更好。
注: 这个空间是指定尺寸,并不是真实的占用内存大小,它的作用是控制缓存的上限

记录此次的解决问题的过程,希望对你有所帮助,撰写仓促,如有错误,请指出,我将及时更正。

NetCore踩坑记2、喜!悲?项目演进的分享和EntityFrameworkCore升级之后的奇怪现象

上一篇:js实现斐波那契数列


下一篇:HTML——超链接