在项目开发中,EntityFramework作为一个非常成熟的ORM框架,被广泛使用。其简单易用的特点,极大的加快的开发进度。但是由此产生的性能问题也被很多人所诟病。
N+1 条SQL语句问题就是一个比较典型的EntityFramework性能问题,在高并发环境,有时甚至可以使应用程序崩溃。
本文就来讨论一下该问题的解决方案。
N+1 问题一般出现在1对多数据对象的查询中,本文以银行分行(Branch)和ATM机两个对象,作为应用场景。一个Branch对应多个ATM机。对象关系如下图所示,对象定义请参看附录。
查询需求: 查询分行编码小于200的分行,以及这些分行中ATM机供应商是Id是1 (IBM)的ATM机的数据。
查询代码如下:
var branches = _dbContext.Branches
.AsNoTracking()
.Where(b => b.BranchCode < 200)
.ToList();
foreach(Branch b in branches){
b.ATMs = _dbContext.Atms
.Where(a => a.VendorId == 1 && a.BranchId == b.Id ).ToList();
}
上述代码中,首先执行Branch的查询,找到所有BranchCode 小于200的分行信息。产生一条SQL语句。
后面的foreach循环遍历每个查到的分行,根据分行Id和供应商Id,进行查询,EntityFramework会为每个Branch生成一条SQL语句。
符合条件的Branch有99家,一共产生100条SQL查询语句。
在高并发环境,上述如此低效的代码显然是不能忍受的。每产生一条SQL语句,就意味着一次和SQL Server数据库的交互。100条SQL语句就是100次交互。
如果我们能让EntityFramework只生成一条SQL语句,并完成所有查询。这样程序效率就能有极大的提升,以下两种解决方案就也基于此想法。
Branch和ATM两个对象是1对多的关系,我们完全可以通过表连接的方式将ATM表的内容一次加载到内存中,再进行过滤。
实现代码如下:
var branches = _dbContext.Branches
.AsNoTracking()
.Where(b => b.BranchCode < 200)
.Include( b => b.ATMs)
.ToList();
foreach (Branch b in branches){
b.ATMs = b.ATMs.Where(a => a.VendorId== 1).ToList();
}
SELECT [t].[Id], [t].[BranchCode], [t].[BranchManager], [t].[BranchName], [t].[BuildDate], [t].[CreatedBy], [t].[C
reatedOn], [t].[DeletedBy], [t].[DeletedOn], [t].[IsDeleted], [t].[ModifiedBy], [t].[ModifiedOn], [t].[RowVersion], [t].
[Status], [t0].[Id], [t0].[ATMName], [t0].[BranchId], [t0].[CityId], [t0].[CreatedBy], [t0].[CreatedOn], [t0].[DeletedBy
], [t0].[DeletedOn], [t0].[IsDeleted], [t0].[ModifiedBy], [t0].[ModifiedOn], [t0].[RowVersion], [t0].[VendorId]
FROM [t_branch] AS [t]
LEFT JOIN [t_atm] AS [t0] ON [t].[Id] = [t0].[BranchId]
WHERE [t].[BranchCode] < 200
ORDER BY [t].[Id], [t0].[Id]
上述解决方案的优点是只执行一条SQL完成全部查询,缺点是在现实应用中,[t_atm]表很大,有2万条数据,这些数据全部参与了左连接操作。
基于Include解决方案的缺点,我们提出了基于Select方法的解决方案,该方案避免[t_atm]表全部数据参与连表操作。代码如下:
var branches = _dbContext.Branches.AsNoTracking()
.Where(b => b.BranchCode < 200)
.Select( b => new Branch(){
Id= b.Id,
BranchName = b.BranchName,
BranchCode = b.BranchCode,
BranchManager = b.BranchManager,
BuildDate = b.BuildDate,
Status = b.Status,
ATMs = b.ATMs.Where( a => a.VendorId == 1).ToList()
})
.ToList();
该解决方案产生的SQL代码如下:
SELECT [t].[Id], [t].[BranchName], [t].[BranchCode], [t].[BranchManager], [t].[BuildDate], [t].[Status], [t1].[Id]
, [t1].[ATMName], [t1].[BranchId], [t1].[CityId], [t1].[CreatedBy], [t1].[CreatedOn], [t1].[DeletedBy], [t1].[DeletedOn]
, [t1].[IsDeleted], [t1].[ModifiedBy], [t1].[ModifiedOn], [t1].[RowVersion], [t1].[VendorId]
FROM [t_branch] AS [t]
LEFT JOIN (
SELECT [t0].[Id], [t0].[ATMName], [t0].[BranchId], [t0].[CityId], [t0].[CreatedBy], [t0].[CreatedOn], [t0].[De
letedBy], [t0].[DeletedOn], [t0].[IsDeleted], [t0].[ModifiedBy], [t0].[ModifiedOn], [t0].[RowVersion], [t0].[VendorId]
FROM [t_atm] AS [t0]
WHERE [t0].[VendorId] = 1
) AS [t1] ON [t].[Id] = [t1].[BranchId]
WHERE [t].[BranchCode] < 200
ORDER BY [t].[Id], [t1].[Id]
我们可以清楚的看到通过Select语句,生成的SQL将[t_atm]表中数据先进行一次过滤,然后将查询结果再用于表连接,从而减少参与表连接的数据。
将N+1问题代码,Include方法和Select方法的解决方案代码各自执行10000次。为了避免缓存影响,每个解决方案执行10000次后,代码重新编译执行。
实验数据的数量级是[t_branch]1000条数据, [t_atm]的数据量是20000条数据。
解决方案 | 执行时间(10000次累计时间(s)) |
---|---|
N+1原始代码 | 559.761213s |
Include解决方案 | 501.9647395 |
Select解决方案 | 366.2152222s |
从实验结果来看,Select解决方案是最佳的,Include解决方案虽然带来了性能的提升,但是并不明显。
因此建议大家,如果要在EntityFramewrok进行表连接操作,可以优先选择Select语句。因为该语句可以帮助我们在连表操作之前,进行一次子查询,过滤掉无关的数据。
[Table("t_atm")]
public class ATM : DeletableEntity
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity), Column(Order = 0)]
public int Id {
get; set; }
[Required, Column(Order = 1), MaxLength(100)]
public string ATMName {
get; set; }
[Required, Column(Order = 2)]
public int BranchId {
get; set; }
public virtual Branch Branch {
get; set; }
[Required, Column(Order = 3)]
public int CityId {
get; set; }
[Required, Column(Order = 4)]
public int VendorId {
get; set; }
public virtual Vendor Vendor {
get; set; }
}
[Table("t_branch")]
public class Branch : DeletableEntity
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity), Column(Order = 0)]
public int Id {
get; set; }
[Required, Column(Order = 1), MaxLength(100)]
public string BranchName {
get; set; }
[Required, Column(Order = 2)]
public string BranchManager {
get; set; }
[Required, Column(Order = 3)]
public int BranchCode {
get; set; }
[Column(TypeName = "datetime", Order = 4), Required]
public DateTime BuildDate {
get; set; }
[Required, Column(Order = 5)]
public BranchStatus Status {
get; set; } = BranchStatus.Open;
[Required, Column(Order = 6)]
public virtual List<ATM> ATMs {
get; set; } = new List<ATM>();
}