疑难杂症——EF+Automapper引发的查询效率问题解析

前言:前面总结了一些WebApi里面常见问题的解决方案,本来打算来分享下oData+WebApi的使用方式的,奈何被工作所困,只能将此往后推了。今天先来看看EF和AutoMapper联合使用的一个问题。

最近两周一直在解决一个问题:使用Automapper将EF的Model转换成DTO的Model,数据量只有几百条,但是导航属性比较多,执行 Mapper.Map<List<TM_STATION>, List<DTO_TM_STATION>>(lstEFModel) 这一句的时候需要耗时十多秒钟左右,简直到了用不了的节奏。于是乎各种找资料,好不容易解决了,今天来简单记录下这个过程,也总结下EF里面的一些细节性的东西。

一、问题呈现

项目使用EF 5.0,为了避免UI里面直接调用EF的Model,我们定义了一个中间的实体层DTO,每次查询数据的时候首先通过查询得到EF的Model,然后通过Automapper将EF的Model扁平化转换成DTO的model,就是这么一个简单的过程。为了模拟项目实际场景,博主随便写了一个控制台程序,主要的代码流程如下:

        static void Main(string[] args)
        {
       //1.创建Automapper的映射,并指定导航属性的转换对应关系 var config = new MapperConfiguration(cfg => { cfg.CreateMap<TM_STATION, DTO_TM_STATION>() .ForMember(dto => dto.TM_PLANT_ID, (map) => map.MapFrom(m => m.TM_WORKSHOP.TM_PLANT.TM_PLANT_ID)) .ForMember(dto => dto.NAME_C, (map) => map.MapFrom(m => m.TM_WORKSHOP.NAME_C)) .ForMember(dto => dto.NAME_C1, (map) => map.MapFrom(m => m.TM_WORKSHOP.TM_PLANT.NAME_C)) .ForMember(dto => dto.UlocName, (map) => map.MapFrom(m => m.TM_ULOC.NAME)) .ForMember(dto => dto.ArtName, (map) => map.MapFrom(m => m.TM_ART_LINE.NAME_C)) .ForMember(dto => dto.LineName, (map) => map.MapFrom(m => m.TM_LINE.NAME_C)) .ForMember(dto => dto.WorkShop, (map) => map.MapFrom(m => m.TM_WORKSHOP.NAME_C)); }); var Mappers = config.CreateMapper();

       //2.创建EF的上下文对象并查询得到EF的Model集合(这里是测试的Demo,所以直接new的EF的DBContext,实际项目中多了一个仓储) var context = new Entities(); var lstEFModel = context.Set<TM_STATION>().AsNoTracking().ToList(); //3.开启计时,使用AutoMapper转换对象 System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); sw.Start(); var listd = Mappers.Map<List<DTO_TM_STATION>>(lstEFModel); sw.Stop(); var s = sw.ElapsedMilliseconds; Console.WriteLine("总共转换" + lstEFModel.Count + "条数据。转换耗时:" + s + "毫秒"); Console.ReadKey(); }

三次测试的结果如下:

结果显示:82条数据,总共需要6秒左右。上述代码可以看到,这个实体导航属性很多,并且其中还有某些导航属性存在二级导航属性,尽管如此,82条数据需要6s转换,这个肯定是需要优化的。

二、原因分析

为什么会这么慢呢?刚开始,博主打算从Automapper下手,在想是不是Automapper组件的问题,可是查了一圈资料后发现,最新版的Automapper都是这样用的啊,就连官方文档也是这样写的,并且园子里其他人也有这样用,也没听说性能损耗这么严重的。排除了Automapper的原因,剩下的就是EF了。

1、刚开始,猜想会不会在查询导航属性的时候实时去数据库取的呢?要不然不可能82条数据要这么久。于是乎做了下面的尝试:

        static void Main(string[] args)
        {            
            //1.创建Automapper的映射,并指定导航属性的转换对应关系
            var config = new MapperConfiguration(cfg =>
            {
                cfg.CreateMap<TM_STATION, DTO_TM_STATION>()
                 .ForMember(dto => dto.TM_PLANT_ID, (map) => map.MapFrom(m => m.TM_WORKSHOP.TM_PLANT.TM_PLANT_ID))
                .ForMember(dto => dto.NAME_C, (map) => map.MapFrom(m => m.TM_WORKSHOP.NAME_C))
                .ForMember(dto => dto.NAME_C1, (map) => map.MapFrom(m => m.TM_WORKSHOP.TM_PLANT.NAME_C))
                .ForMember(dto => dto.UlocName, (map) => map.MapFrom(m => m.TM_ULOC.NAME))
                .ForMember(dto => dto.ArtName, (map) => map.MapFrom(m => m.TM_ART_LINE.NAME_C))
                .ForMember(dto => dto.LineName, (map) => map.MapFrom(m => m.TM_LINE.NAME_C))
                .ForMember(dto => dto.WorkShop, (map) => map.MapFrom(m => m.TM_WORKSHOP.NAME_C));
            });
            var Mappers = config.CreateMapper();
            
            //2.创建EF的上下文对象并查询得到EF的Model集合(这里是测试的Demo,所以直接new的EF的DBContext,实际项目中多了一个仓储)
            var lstEFModel = new List<TM_STATION>();
            using (var context = new Entities())
            {
                lstEFModel = context.Set<TM_STATION>().AsNoTracking().ToList();
            }

            //3.开启计时,使用AutoMapper转换对象
            System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
            sw.Start();
            var listd = Mappers.Map<List<DTO_TM_STATION>>(lstEFModel);
            sw.Stop();
            var s = sw.ElapsedMilliseconds;
            Console.WriteLine("总共转换" + lstEFModel.Count + "条数据。转换耗时:" + s + "毫秒");

            Console.ReadKey();
        }

结果抛了异常:

我们用using就EF的上下文对象包起来,表示出了using之后,上下文对象就自动释放,可是在Automapper转换的时候报了“对象已经释放”的异常,这正好说明我们之前的猜想是正确的!由于EF默认延时加载(context.Configuration.LazyLoadingEnabled)是开启的,每次去取数据的时候,导航属性都不会被直接取出来。也就是说,Automapper转换的时候是需要数据库连接的,每个对象转换的时候导航属性需要通过这个连接实时去数据库取。难怪这么慢呢,82条记录,从数据库取的次数那得有多少次,吓死宝宝了。知道了这个原因,就晓得努力的方向了。

2、知道了上面的原因,博主把关注点放在了AsNoTracking()的上面。将其转到定义看了下:

        // 摘要: 
        //     返回一个新查询,其中返回的实体将不会在 System.Data.Entity.DbContext 中进行缓存。
        //
        // 返回结果: 
        //     应用了 NoTracking 的新查询。
        public DbQuery<TResult> AsNoTracking();

大概的意思是,加了AsNoTracking()之后,每次的结果不会往DBContext中缓存,换言之,每次都是实时去数据库取最新的,原来罪魁祸首在这里。那当初为什么查询的时候要加上AsNoTracking()这个东西呢,博主网上查了下,它的作用主要有两个:

  1. 提高查询效率。不会缓存就意味着每次去数据库里面取,这样肯定能够提高查询效率;
  2. 保证了数据的实时性。也就是说,每次去数据库里面取到的结果都是最新的,这样能够保证数据的实时性。这个一般用在同一个上下文的情况,如果CURD每次都是一个不同的上下文,就没有这个必要了。

三、解决方案尝试

通过上面的尝试,貌似找到了问题的缘由,是不是这样呢?我们来试一试,其他代码都不变,仅仅把AsNoTracking()去掉。

        static void Main(string[] args)
        {            
            //1.创建Automapper的映射,并指定导航属性的转换对应关系
            var config = new MapperConfiguration(cfg =>
            {
                cfg.CreateMap<TM_STATION, DTO_TM_STATION>()
                 .ForMember(dto => dto.TM_PLANT_ID, (map) => map.MapFrom(m => m.TM_WORKSHOP.TM_PLANT.TM_PLANT_ID))
                .ForMember(dto => dto.NAME_C, (map) => map.MapFrom(m => m.TM_WORKSHOP.NAME_C))
                .ForMember(dto => dto.NAME_C1, (map) => map.MapFrom(m => m.TM_WORKSHOP.TM_PLANT.NAME_C))
                .ForMember(dto => dto.UlocName, (map) => map.MapFrom(m => m.TM_ULOC.NAME))
                .ForMember(dto => dto.ArtName, (map) => map.MapFrom(m => m.TM_ART_LINE.NAME_C))
                .ForMember(dto => dto.LineName, (map) => map.MapFrom(m => m.TM_LINE.NAME_C))
                .ForMember(dto => dto.WorkShop, (map) => map.MapFrom(m => m.TM_WORKSHOP.NAME_C));
            });
            var Mappers = config.CreateMapper();
            
            //2.创建EF的上下文对象并查询得到EF的Model集合(这里是测试的Demo,所以直接new的EF的DBContext,实际项目中多了一个仓储)
            var context = new Entities();
            var lstEFModel = context.Set<TM_STATION>().ToList();

            //3.开启计时,使用AutoMapper转换对象
            System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
            sw.Start();
            var listd = Mappers.Map<List<DTO_TM_STATION>>(lstEFModel);
            sw.Stop();
            var s = sw.ElapsedMilliseconds;
            Console.WriteLine("总共转换" + lstEFModel.Count + "条数据。转换耗时:" + s + "毫秒");

            Console.ReadKey();
        }

 还是来看看三次的测试结果

性能得到不少提升。

可是考虑到数据量并不大,感觉1.5秒左右还是不能令人满意,还想再次优化下。通过上文可以,我们反向理解,去掉了AsNoTracking()之后,每次查询都会在System.Data.Entity.DbContext对象中缓存。有了这个理论做基础,博主去掉AsNoTracking()之后,再次按照上面的代码使用了using测试,结果还是和上文相同:抛了对象已释放的异常。这说明转换导航属性是从DBContext缓存中取得,如果DBContext对象已经释放,自然取不到对应的导航属性。

到这一步,博主是这样理解EF机制的:为了保证查询的效率,EF会自动启用延时加载,所有的导航属性都需要在调用的时候去数据库或者上下文对象的缓存里面去取。那么,是否有一次取出所有导航属性的机制呢?考虑到这种情况,微软为我们提供了Include方法,我们需要哪些导航属性,可以使用Include将其查出,我们来看看最后改造的代码:

        static void Main(string[] args)
        {            
            //1.创建Automapper的映射,并指定导航属性的转换对应关系
            var config = new MapperConfiguration(cfg =>
            {
                cfg.CreateMap<TM_STATION, DTO_TM_STATION>()
                 .ForMember(dto => dto.TM_PLANT_ID, (map) => map.MapFrom(m => m.TM_WORKSHOP.TM_PLANT.TM_PLANT_ID))
                .ForMember(dto => dto.NAME_C, (map) => map.MapFrom(m => m.TM_WORKSHOP.NAME_C))
                .ForMember(dto => dto.NAME_C1, (map) => map.MapFrom(m => m.TM_WORKSHOP.TM_PLANT.NAME_C))
                .ForMember(dto => dto.UlocName, (map) => map.MapFrom(m => m.TM_ULOC.NAME))
                .ForMember(dto => dto.ArtName, (map) => map.MapFrom(m => m.TM_ART_LINE.NAME_C))
                .ForMember(dto => dto.LineName, (map) => map.MapFrom(m => m.TM_LINE.NAME_C))
                .ForMember(dto => dto.WorkShop, (map) => map.MapFrom(m => m.TM_WORKSHOP.NAME_C));
            });
            var Mappers = config.CreateMapper();
            
            //2.创建EF的上下文对象并查询得到EF的Model集合(这里是测试的Demo,所以直接new的EF的DBContext,实际项目中多了一个仓储)
            var context = new Entities();
            var lstEFModel = context.Set<TM_STATION>()
                .Include("TM_WORKSHOP")
                .Include("TM_LINE")
                .Include("TM_ART_LINE")
                .Include("TM_ULOC")
                .ToList();
           //3.开启计时,使用AutoMapper转换对象
            System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
            sw.Start();
            var listd = Mappers.Map<List<DTO_TM_STATION>>(lstEFModel);
            sw.Stop();
            var s = sw.ElapsedMilliseconds;
            Console.WriteLine("总共转换" + lstEFModel.Count + "条数据。转换耗时:" + s + "毫秒");

            Console.ReadKey();
        }

三次测试结果:

代码释疑:优化做到这一步基本就可以了。有园友可能又有了新的疑惑,确实,这样做Automapper的转换是可以了,因为需要的导航属性已经查询到了内存里面,在内存里面做这些转换是很快的,但是,你考虑过EF查询的性能了吗?如果你将所有的导航属性都查出来,那么当查询的数据量大了之后岂不是会很慢!这就是接下来想要说明的几点:

  1. 优化需要做到哪一步根据实际情况,如果你的项目对性能要求不太高,上面的1.5秒可以接受,那么我们直接用上面的那种方案即可。
  2. 如果确实对查询和转换性能要求都很高,并且你的系统数据量又比较大,那么建议从两个方面同时下手,查询方面使用延时加载;对象转换方面,你可以使用EmitMapper代替Automapper,为了效率更高,甚至你可以手工映射,关于映射工具的效率,可以看看此篇
  3. EF默认是延时加载(懒加载)的,使用Include是一种实时加载的方式,如果你不需要使用导航属性里面的东西,建议使用懒加载。

四、总结

以上通过一次查询优化简单分析了下EF的一些运行机制,文中所有观点来自博主自己的理解,如果有误,欢迎园友们指出,多谢。如果这篇文章能帮助你加深对EF的理解,请帮忙推荐,博主将会继续努力。

 

posted @ 2016-04-12 12:40  懒得安分  阅读(9773)  评论(39编辑  收藏  举报