2014年1月17日星期五

Entity Framework与ADO.NET批量插入数据性能测试

Entity Framework是.NET平台下的一种简单易用的ORM框架,它既便于Domain Model和持久层的OO设计,也提高了代码的可维护性。但在使用中发现,有几类业务场景是EF不太擅长的,比如批量写入大量同类数据,为此本人做了一些对比测试,以供大家参考。

现假设我们需要做一个用户批量导入的功能,需要从某处导入1k~1w个User到SQLServer数据库,本人听说过的常见做法有如下几种:

  1. 使用ADO.NET单条SqlCommand执行1w次(根据常识作为EF的替代其性能还不够格,所以就不做测试了)
  2. 使用StringBuilder拼接SQL语句,将1w条Insert语句拼接成1到若干条SqlCommand执行
  3. 使用EntityFramework的基本功能进行插入
  4. 使用SqlBulkCopy进行批量插入
  5. 使用存储过程,其中的2种分支分别对应上述1、2用例,另外还有1种表参数存储过程。

数据库准备工作:

复制代码
 1 CREATE DATABASE BulkInsertTest   2 GO   3    4 USE BulkInsertTest   5 GO   6    7 CREATE TABLE [dbo].[User](   8     [Id] [int] IDENTITY(1,1) NOT NULL,   9     [Name] [nvarchar](50) NOT NULL,  10     [Birthday] [date] NOT NULL,  11     [Gender] [char](1) NOT NULL,  12     [Email] [nvarchar](50) NOT NULL,  13     [Deleted] [bit] NOT NULL,  14  CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED   15 (  16     [Id] ASC  17 )WITH (PAD_INDEX  = OFF, STATISTICS_NORECOMPUTE  = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS  = ON, ALLOW_PAGE_LOCKS  = ON) ON [PRIMARY]  18 ) ON [PRIMARY]  19   20 GO  21   22 CREATE PROCEDURE [dbo].[InsertUser]   23     @Name nvarchar(50)  24            ,@Birthday date  25            ,@Gender char(1)  26            ,@Email nvarchar(50)  27            ,@Deleted bit  28 AS  29 BEGIN  30     INSERT INTO [BulkInsertTest].[dbo].[User]  31            ([Name]  32            ,[Birthday]  33            ,[Gender]  34            ,[Email]  35            ,[Deleted])  36      VALUES  37            (@Name,@Birthday,@Gender,@Email,@Deleted)  38   39 END  40   41 /* Create a table type. */  42 CREATE TYPE LocationTableType AS TABLE   43 ( Name nvarchar(50)  44            ,Birthday date  45            ,Gender char(1)  46            ,Email nvarchar(50)  47            ,Deleted bit );  48 GO  49   50 /* Create a procedure to receive data for the table-valued parameter. */  51 CREATE PROCEDURE [dbo].[InsertUsers]  52     @Users LocationTableType  53     AS   54     SET NOCOUNT ON  55     INSERT INTO [dbo].[User]  56            ([Name]  57            ,[Birthday]  58            ,[Gender]  59            ,[Email]  60            ,[Deleted])  61         SELECT *  62         FROM  @Users;  63   64 GO
复制代码

创建DbContext和User Entity的C#代码:

复制代码
 1 using System;   2 using System.ComponentModel.DataAnnotations;   3 using System.ComponentModel.DataAnnotations.Schema;   4 using System.Data.Entity;   5    6 namespace ConsoleApplication5   7 {   8     public class MyDbContext : DbContext   9     {  10         public MyDbContext() : base("MyDbContext") { }  11   12         public MyDbContext(string connectionString) :  13             base(connectionString)  14         {  15               16         }  17   18         public DbSet<User> Users { get; set; }  19     }  20   21     [Table("User")]  22     public class User  23     {  24         [Key]  25         public int Id { get; set; }  26   27         public string Name { get; set; }  28   29         public DateTime Birthday { get; set; }  30   31         public string Gender { get; set; }  32   33         public string Email { get; set; }  34   35         public bool Deleted { get; set; }  36     }  37 }
复制代码

测试程序C#代码:

复制代码
  1 using System;    2 using System.Data;    3 using System.Data.SqlClient;    4 using System.Diagnostics;    5 using System.Linq;    6 using System.Text;    7     8 namespace ConsoleApplication5    9 {   10     class Program   11     {   12         private const string ConnectionString = "Data Source=.;Initial Catalog=BulkInsertTest;User=sa;Password=IGTtest1";   13         private const int Times = 10;   14         private const int Entries = 10000;   15    16         static void Main(string[] args)   17         {   18             long sumBulkCopyTime = 0, sumSqlCmdsTime = 0, sumMultiSpTime = 0, sumTableSpTime = 0, sumEfTime = 0;   19             long maxBulkCopyTime = 0, maxSqlCmdsTime = 0, maxMultiSpTime = 0, maxTableSpTime = 0, maxEfTime = 0;   20             for (int i = 0; i < Times; i++)   21             {   22                 long bulkCopyTime = InsertBySqlBulkCopy();   23                 sumBulkCopyTime += bulkCopyTime;   24                 maxBulkCopyTime = Math.Max(maxBulkCopyTime, bulkCopyTime);   25    26                 long sqlCmdsTime = InsertBySqlCmds();   27                 sumSqlCmdsTime += sqlCmdsTime;   28                 maxSqlCmdsTime = Math.Max(maxSqlCmdsTime, sqlCmdsTime);   29    30                 long multiSpTime = InsertByMultiStoreProcedure();   31                 sumMultiSpTime += multiSpTime;   32                 maxMultiSpTime = Math.Max(maxMultiSpTime, multiSpTime);   33    34                 long tableSpTime = InsertByTableStoreProcedure();   35                 sumTableSpTime += tableSpTime;   36                 maxTableSpTime = Math.Max(maxTableSpTime, tableSpTime);   37    38                 long efTime = InsertByEntityFramework();   39                 sumEfTime += efTime;   40                 maxEfTime = Math.Max(maxEfTime, efTime);   41             }   42             Console.WriteLine(new string('-', 40));   43             Console.WriteLine("Time Cost of SqlBulkCopy:            avg:{0}ms, max:{1}ms", sumBulkCopyTime / Times, maxBulkCopyTime);   44             Console.WriteLine("Time Cost of SqlCommands:            avg:{0}ms, max:{1}ms", sumSqlCmdsTime / Times, maxSqlCmdsTime);   45             Console.WriteLine("Time Cost of MultiStoreProcedure:    avg:{0}ms, max:{1}ms", sumMultiSpTime / Times, maxMultiSpTime);   46             Console.WriteLine("Time Cost of TableStoreProcedure:    avg:{0}ms, max:{1}ms", sumTableSpTime / Times, maxTableSpTime);   47             Console.WriteLine("Time Cost of EntityFramework:        avg:{0}ms, max:{1}ms", sumEfTime / Times, maxEfTime);   48             Console.ReadLine();   49         }   50    51         private static long InsertBySqlCmds()   52         {   53             Stopwatch stopwatch = Stopwatch.StartNew();   54             using (var connection = new SqlConnection(ConnectionString))   55             {   56                 SqlTransaction transaction = null;   57                 connection.Open();   58                 try   59                 {   60                     transaction = connection.BeginTransaction();   61                     StringBuilder sb = new StringBuilder();   62                     for (int j = 0; j < Entries; j++)   63                     {   64                         sb.AppendFormat(@"INSERT INTO dbo.[User] ([Name],[Birthday],[Gender],[Email],[Deleted])   65 VALUES('{0}','{1:yyyy-MM-dd}','{2}','{3}',{4});", "name" + j, DateTime.Now.AddDays(j), 'M', "user" + j + "@abc.com", 0);   66                     }   67                     var sqlCmd = connection.CreateCommand();   68                     sqlCmd.CommandText = sb.ToString();   69                     sqlCmd.Transaction = transaction;   70                     sqlCmd.ExecuteNonQuery();   71                     transaction.Commit();   72                 }   73                 catch   74                 {   75                     if (transaction != null)   76                     {   77                         transaction.Rollback();   78                     }   79                     throw;   80                 }   81             }   82             stopwatch.Stop();   83             Console.WriteLine("SqlCommand time cost: {0}ms", stopwatch.ElapsedMilliseconds);   84             return stopwatch.ElapsedMilliseconds;   85         }   86    87         private static long InsertByMultiStoreProcedure()   88         {   89             Stopwatch stopwatch = Stopwatch.StartNew();   90             using (var connection = new SqlConnection(ConnectionString))   91             {   92                 SqlTransaction transaction = null;   93                 connection.Open();   94                 for (int i = 0; i < 10; i++)   95                 {   96                     try   97                     {   98                         transaction = connection.BeginTransaction();   99                         StringBuilder sb = new StringBuilder();  100                         for (int j = 0; j < Entries/10; j++)  101                         {  102                             sb.AppendFormat(@"EXECUTE [dbo].[InsertUser] '{0}','{1:yyyy-MM-dd}','{2}','{3}',{4};",  103                                             "name" + j, DateTime.Now.AddDays(j), 'M', "user" + j + "@abc.com", 0);  104                         }  105                         var sqlCmd = connection.CreateCommand();  106                         sqlCmd.CommandText = sb.ToString();  107                         sqlCmd.Transaction = transaction;  108                         sqlCmd.ExecuteNonQuery();  109                         transaction.Commit();  110                     }  111                     catch  112                     {  113                         if (transaction != null)  114                         {  115                             transaction.Rollback();  116                         }  117                         throw;  118                     }  119                 }  120             }  121             stopwatch.Stop();  122             Console.WriteLine("MultiStoreProcedure time cost: {0}ms", stopwatch.ElapsedMilliseconds);  123             return stopwatch.ElapsedMilliseconds;  124         }  125   126         private static long InsertByTableStoreProcedure()  127         {  128             Stopwatch stopwatch = Stopwatch.StartNew();  129             var table = PrepareDataTable();  130             using (var connection = new SqlConnection(ConnectionString))  131             {  132                 SqlTransaction transaction = null;  133                 connection.Open();  134                 try  135                 {  136                     transaction = connection.BeginTransaction();  137                     var sqlCmd = connection.CreateCommand();  138                     sqlCmd.CommandText = "InsertUsers";  139                     sqlCmd.CommandType = CommandType.StoredProcedure;  140                     sqlCmd.Parameters.Add(new SqlParameter("@Users", SqlDbType.Structured));  141                     sqlCmd.Parameters["@Users"].Value = table;  142                     sqlCmd.Transaction = transaction;  143                     sqlCmd.ExecuteNonQuery();  144                     transaction.Commit();  145                 }  146                 catch  147                 {  148                     if (transaction != null)  149                     {  150                         transaction.Rollback();  151                     }  152                     throw;  153                 }  154             }  155             stopwatch.Stop();  156             Console.WriteLine("TableStoreProcedure time cost: {0}ms", stopwatch.ElapsedMilliseconds);  157             return stopwatch.ElapsedMilliseconds;  158         }  159   160         private static long InsertBySqlBulkCopy()  161         {  162             Stopwatch stopwatch = Stopwatch.StartNew();  163   164             var table = PrepareDataTable();  165             SqlBulkCopy(table);  166   167             stopwatch.Stop();  168             Console.WriteLine("SqlBulkCopy time cost: {0}ms", stopwatch.ElapsedMilliseconds);  169             return stopwatch.ElapsedMilliseconds;  170         }  171   172         private static DataTable PrepareDataTable()  173         {  174             DataTable table = new DataTable();  175             table.Columns.Add("Name", typeof (string));  176             table.Columns.Add("Birthday", typeof (DateTime));  177             table.Columns.Add("Gender", typeof (char));  178             table.Columns.Add("Email", typeof (string));  179             table.Columns.Add("Deleted", typeof (bool));  180             for (int i = 0; i < Entries; i++)  181             {  182                 var row = table.NewRow();  183                 row["Name"] = "name" + i;  184                 row["Birthday"] = DateTime.Now.AddDays(i);  185                 row["Gender"] = 'M';  186                 row["Email"] = "user" + i + "@abc.com";  187                 row["Deleted"] = false;  188                 table.Rows.Add(row);  189             }  190             return table;  191         }  192   193         private static void SqlBulkCopy(DataTable dataTable)  194         {  195             using (var connection = new SqlConnection(ConnectionString))  196             {  197                 SqlTransaction transaction = null;  198                 connection.Open();  199                 try  200                 {  201                     transaction = connection.BeginTransaction();  202                     using (var sqlBulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.Default, transaction))  203                     {  204                         sqlBulkCopy.BatchSize = dataTable.Rows.Count;  205   206                         sqlBulkCopy.DestinationTableName = "[User]";  207                         //sqlBulkCopy.ColumnMappings.Add("Id", "Id");  208                         sqlBulkCopy.ColumnMappings.Add("Name", "Name");  209                         sqlBulkCopy.ColumnMappings.Add("Birthday", "Birthday");  210                         sqlBulkCopy.ColumnMappings.Add("Gender", "Gender");  211                         sqlBulkCopy.ColumnMappings.Add("Email", "Email");  212                         sqlBulkCopy.ColumnMappings.Add("Deleted", "Deleted");  213                           214                         sqlBulkCopy.WriteToServer(dataTable);  215                     }  216                     transaction.Commit();  217                 }  218                 catch  219                 {  220                     if (transaction!=null)  221                     {  222                         transaction.Rollback();  223                     }  224                     throw;  225                 }  226             }  227         }  228   229         private static long InsertByEntityFramework()  230         {  231             Stopwatch stopwatch = Stopwatch.StartNew();  232             using (MyDbContext context = new MyDbContext(ConnectionString))  233             {  234                 context.Configuration.AutoDetectChangesEnabled = false;  235                 context.Configuration.ValidateOnSaveEnabled = false;  236                 for (int i = 0; i < Entries; i++)  237                 {  238                     context.Users.Add(new User()  239                                            {  240                                                Name = "name" + i,  241                                                Birthday = DateTime.Now.AddDays(i),  242                                                Gender = "F",  243                                                Email = "user" + i + "@abc.com",  244                                                Deleted = false  245                                            });  246                 }  247                 context.SaveChanges();  248             }  249   250             stopwatch.Stop();  251             Console.WriteLine("EntityFramework time cost: {0}ms", stopwatch.ElapsedMilliseconds);  252             return stopwatch.ElapsedMilliseconds;  253         }  254     }  255 }
复制代码

插入1000行测试结果:

插入10000行测试结果:

分析与结论:单从性能上来说,SqlBulkCopy和表参数StoreProcedure胜出,且完胜Entity Framework,所以当EF实在无法满足性能要求时,SqlBulkCopy或表参数SP可以很好的解决EF批量插入的性能问题。但衡量软件产品的标准不仅仅只有性能这一方面,比如我们还要在设计美学和性能之间进行权衡。当插入数据量较小或是低压力时间段自动执行插入的话,EF仍然是不错的选择。从代码可维护性方面来看ADO.NET实现的可读性、重构友好型都弱于EF实现,所以对于需求变动较多的领域模型而言这几种解决方法都需要更多的设计抽象和单元测试,以此来确保产品的持续发展。从影响范围来看,在ADO.NET实现方式中SqlBulkCopy和拼接Sql字符串的方案不需要额外加入存储过程,所以可以在不影响数据库部署的前提下与EF的实现相互替换。

 

关于SqlBulkCopy请参考:Bulk Copy Operations in SQL Server

为了比较优雅使用SqlBulkCopy,有人写了一种AsDataReader扩展方法请参考:LinqEntityDataReader

根据MSDN的说法,由于表参数存储过程的启动准备消耗时间较小,所以1k行(经验)以下插入性能将胜于SqlBulkCopy,而随着插入行数的增多,SqlBulkCopy的性能优势将体现出来,另外两种方案相比还有一些其他方面的差异,从本测试的实际结果来看,SqlBulkCopy在首次插入1k条数据时确实耗时稍长一点。具体请参考:Table-Valued Parameters vs. BULK INSERT Operations

另外还有人做过SqlBulkCopy和SqlDataAdapter插入的性能对比:High performance bulk loading to SQL Server using SqlBulkCopy

没有评论:

发表评论