天天看點

一起談.NET技術,DataReader 程式性能優化

  随着 .NET 平台上,LINQ、ORM 架構、Dynamic Data、... 各種資料通路技術不斷推陳出新,程式員也一直追着新技術跑,但對底層和代碼細節卻越來越難以掌控。當項目性能需要調優時,通常也隻能對資料庫加入更多索引,而多數人已難以對資料通路的代碼優化,且手寫 SQL 語句的功力似乎也持續退化中。

  大家在拼命追求新技術時,似乎已忘記微軟的 MCTS 證照,還有一門 ADO.NET 的科目 。且若有練好 ADO.NET 的基本功,當項目遇到特殊需求時,也才能手寫得出來。例如下圖 1 的「階層式下拉菜單 (Hierarchical DropDownList)」,功能很簡單,在實務上也常遇到,但單純靠 DataSource 控件難以實作,必須手寫 DataReader 來自定義細節。

  本帖的示例下載下傳點:

  (執行第一個示例,需要 VS 2008 或 IIS,以及 SQL Server 的 Northwind 資料庫)

  (執行第二個示例,需要 VS 2008 或 .NET 3.5,以及 SQL Server 的 AdventureWorks 資料庫)

   一、DataReader 的變化應用、用 Get 開頭的方法提升 DataReader 的性能 

一起談.NET技術,DataReader 程式性能優化

圖 1 以 DataReader 實作的 Hierarchical DropDownList

  上圖 1 為本帖下載下傳的第一個示例。在此 ASP.NET 示例中,我們不用 DataTable 以免速度較慢又浪費記憶體 (此卻為 DataSource 控件的預設選擇),改用一個 DataReader 對象,去兩個表中,各執行一句 SQL SELECT 語句,最後将兩個 result set 加載、合并到同一個 DropDownList 中。我在以前寫的 C# 代碼生成器 ,也用過類似的小技巧。

  此示例若您将前台的 DropDownList 控件,換成微軟去年九月新推出的 ComboBox 這個可選也可輸入的 AJAX 下拉菜單控件 ,此示例的 Code-behind 一行代碼都不用改,直接就可套用至 ComboBox 上。

一起談.NET技術,DataReader 程式性能優化
一起談.NET技術,DataReader 程式性能優化

示例一的 Code-behind 代碼

using (SqlConnection conn = new SqlConnection(strConnString))

{

    conn.Open();

    using (SqlCommand cmd = new SqlCommand())

    {

        cmd.Connection = conn;

        //利用分号「;」串連多句 SQL 語句,之後再一次丢進資料庫去執行,達成「批次(Batch)」讀取或更新資料庫的目的。

        //此技巧亦适用于 INSERT、UPDATE、DELETE 的「批次」執行。       

        cmd.CommandText = "SELECT TOP 5 CompanyName, CustomerID FROM Customers ; SELECT LastName, EmployeeID FROM Employees";

        using (SqlDataReader dr = cmd.ExecuteReader())

        {

            DropDownList1.Items.Add("客戶清單");

            int i = 1;    //目前的資料行

            while (dr.Read())

            {

                //.NET typed accessor

                DropDownList1.Items.Add("  " + dr.GetString(0));    //資料庫跑循環取回的字段1 (Name),當作選項的 Text

                DropDownList1.Items[i].Value = dr.GetString(1);          //資料庫跑循環取回的字段2 (ID),當作選項的 Value

                i++;

            }

            dr.NextResult();       //到下一個 ResultSet

            DropDownList1.Items.Add("員工清單");

            i++;

                DropDownList1.Items.Add("  " + dr.GetString(0));    //資料庫跑循環取回的字段1 (Name) ,當作選項的 Text

                //Index-based accessor

                DropDownList1.Items[i].Value = dr[1].ToString();         //資料庫跑循環取回的字段2 (ID),當作選項的 Value

                //亦可用 GetSql 開鬥的方法 (Provider-specific typed accessor)

                //DropDownList1.Items[i].Value = dr.GetSqlInt32(1).ToString();   //資料庫跑循環取回的字段2 (ID),當作選項的 Value

        }

    }

}

  在 DataReader 中,使用基于「序列号」的查找 (column ordinal),比基于「命名」的查找 (column field name) 更有效率。例如上面的示例一,我們用 dr.GetString(1) 或 dr[1].ToString(),而不是用 dr["字段名稱"]。可分類如下:

寫法 (1) 使用 DataReader 索引 + 基于「序列号」的查找,如:dr[1].ToString(),英文稱為:Index-based accessor

寫法 (2) 使用 DataReader 索引 + 基于「命名」的查找,如:dr["LastName"].ToString(),這是性能最不好的寫法

寫法 (3) 使用 Get 開頭的方法 + 基于「序列号」的查找,如:dr.GetString(1),英文稱為:.NET typed accessor

寫法 (4) 使用 GetSql 開頭的方法 + 基于「序列号」的查找,如:dr.GetSqlString(1),英文稱為:Provider-specific typed accessor

寫法 (5) 基于「序列号」+ GetOrdinal() 方法的查找。

  其中的性能優劣,由好到壞依序為:(4) --> (3) --> (1) --> (2)。一般人會為了友善維護,而採用 (2) 的基于「命名」查找的寫法,殊不知其為性能最差的寫法。待會本帖後續的第 (二) 點會有實際的示例和測試數據。而 (5) 的 GetOrdinal() 寫法,即是為了補足前述各種寫法的優缺點而誕生,在本帖後續的第 (三) 點會介紹到它。

  此外,性能最好的 (4),以 GetSql 開頭的方法,僅适用于 SQL Server 7 以上版本的資料庫。其底層采用 SQL Server 專屬的 TDS (表格式資料串流) 格式來交換資料,是以執行性能會比 (3) - 以 Get 開頭的方法,執行速度更快。而 OracleClient 命名空間,也提供了 GetOracle 開頭的方法可以使用,但 OleDb、ODBC 則沒有提供專屬的方法。

   二、用 Typed Accessors 提升 DataReader 的性能

  本示例,我們用 SqlDataReader 通路資料庫,各以前述的 (3)、(1)、(2) 的寫法,執行 100 次循環的查詢,測試其性能差距有多大。此 Console Mode 的示例二,代碼如下:

一起談.NET技術,DataReader 程式性能優化
一起談.NET技術,DataReader 程式性能優化

示例二 - DataReaderTypedAccessors

using System.Data.SqlClient;

namespace DataReaderTypedAccessors

class Program

static void Main(string[] args)

int loops = 100; //本示例循環的次數

int contactID;

string firstName;

string middleName = null;

string lastName;

int startTick = 0;

int elapsedTick;

string sqlConnectString = "Data Source=(local);Integrated security=SSPI;Initial Catalog=AdventureWorks;";

string sqlSelect = "SELECT ContactID, FirstName, MiddleName, LastName FROM Person.Contact";

Console.WriteLine("---DataReader column value access timing test, {0} iterations---\n", loops);

SqlConnection connection = new SqlConnection(sqlConnectString);

SqlCommand command = new SqlCommand(sqlSelect, connection);

connection.Open();

elapsedTick = 0;

for (int i = 0; i < loops; i++)

// Create the DataReader and retrieve all fields for each

// record using a typed accessor with a column ordinal

using (SqlDataReader dr = command.ExecuteReader())

startTick = Environment.TickCount;

while (dr.Read())

contactID = dr.GetInt32(0);

firstName = dr.GetString(1);

middleName = dr.IsDBNull(2) ? null : dr.GetString(2);

lastName = dr.GetString(3);

elapsedTick += Environment.TickCount - startTick;

Console.WriteLine("Typed accessor (寫法 3): Ticks = {0}", elapsedTick);

// record using a column ordinal

contactID = Convert.ToInt32(dr[0]);

firstName = Convert.ToString(dr[1]);

middleName = Convert.ToString(dr[2]);

lastName = Convert.ToString(dr[3]);

Console.WriteLine("Column ordinal (寫法 1): Ticks = {0}", elapsedTick);

// record using a column field name

contactID = Convert.ToInt32(dr["ContactID"]);

firstName = Convert.ToString(dr["FirstName"]);

middleName = Convert.ToString(dr["MiddleName"]);

lastName = Convert.ToString(dr["LastName"]);

Console.WriteLine("Column name (寫法 2): Ticks = {0}", elapsedTick);

Console.WriteLine("\nPress any key to continue.");

Console.ReadKey();

  執行結果如下圖 2,寫法 (1) 的 Column ordinal,比寫法 (2) 的 Column name,速度快上約 20 - 25 % 。且當循環加大到 1000 次時,比例仍差不多。

  而寫法 (3) 的 Typed accessor,比寫法 (1) 的 Column ordinal 速度快上 27 %,比寫法 (2) 的 Column name,速度快上約 37 % 。當循環加大到 1000 次時,比例會有所變化。

  由于 Typed accessor 可避免 Column ordinal、Column name 裡重複的 boxing、unboxing 轉型動作,是以必然對性能有所助益。加上可搭配 IsDBNull() 方法,來處理資料庫的欄位為 NULL 的問題 (參考上方代碼),是以是程式員手動撰碼提取資料的首選寫法。

一起談.NET技術,DataReader 程式性能優化

圖 2 示例二的執行結果,三種寫法各自耗費的時間

  在這個示例中,您還會發現,按 Ctrl + F5 執行但不調拭,會比挾 F5 執行并調拭,執行速度要快很多。

  三、用 Column Ordinals 提升 DataReader 的性能 

  我們已經知道,基于「序列号」的查找 (column ordinal),比基于「命名」的查找 (column field name) 更有效率。不過在實務上維護項目時,表的字段序号 (或稱索引) 有可能會變動或增減,或在改寫公司前人的 SQL 語句時,亦有可能導緻欲查詢的字段與真正表中的字段不符。此時我們可透過 GetOrdinal() 方法來處理此種問題,先以字段名稱,來查找所對應到此字段的正确序列号,避免寫死序列号所産生的字段提取不正确。

  以下是 MSDN 的示例 [2] ,GetOrdinal 先執行區分大小寫的查找;如果失敗,則進行另一次不區分大小寫的搜尋。我們先調用單一次 GetOrdinal 方法,取得特定字段的序列号,再将結果配置設定給一或多個變量,以便在後續的 DataReader 循環、實際提取值時來引用這些序列号。

private static void ReadGetOrdinal(string connectionString)

string queryString = "SELECT DISTINCT CustomerID FROM dbo.Orders;";

using (SqlConnection connection = new SqlConnection(connectionString))

SqlCommand command = new SqlCommand(queryString, connection);

SqlDataReader reader = command.ExecuteReader();

// Call GetOrdinal and assign value to variable.

int customerID = reader.GetOrdinal("CustomerID");

// Use variable with GetString inside of loop.

while (reader.Read())

Console.WriteLine("CustomerID={0}", reader.GetString(customerID));

reader.Close();

  以下是本帖提供下載下傳的的第三個示例,原理相同,但我們将 GetOrdinal 方法所提取到的 ContactID、FirstName、LastName 三個字段的序列号,分别搭配本帖前述的寫法 (1)、寫法 (3)、寫法 (4),提取出内容相同的三個 result set,而寫法 (3)、寫法 (4) 還能搭配 IsDBNull() 方法來使用。

一起談.NET技術,DataReader 程式性能優化
一起談.NET技術,DataReader 程式性能優化

示例三 - DataReaderColumnOrdinals

namespace DataReaderColumnOrdinals

int coContactID, coFirstName, coLastName;

string sqlSelect = "SELECT TOP 5 * FROM Person.Contact";

//建立 DataReader 并取得多個字段的「列序号 (ordinals)」。

//應避免在循環中調用 GetOrdinal 方法,以免影響性能

using (SqlDataReader drSchema = command.ExecuteReader(CommandBehavior.SchemaOnly))

//SqlDataReader.GetOrdinal 方法: 在給定「列 (字段)」名稱的情況下,擷取各自對應的列序号

coContactID = drSchema.GetOrdinal("ContactID");

coFirstName = drSchema.GetOrdinal("FirstName");

coLastName = drSchema.GetOrdinal("LastName");

//輸出字段序列号 (column ordinals)

Console.WriteLine("--- 字段在表中的序列号 Column ordinals ---");

Console.WriteLine("ContactID = {0}, FirstName = {1}, LastName = {2}",

coContactID, coFirstName, coLastName);

Console.WriteLine("\n--- Index-based accessor (寫法 1) ---");

// Output fields using the column ordinals as accessors

Console.WriteLine("{0}\t{1}, {2}",

dr[coContactID], dr[coLastName], dr[coFirstName]);

Console.WriteLine("\n--- .NET typed accessor (寫法 3) ---");

dr.IsDBNull(coContactID) ?

"NULL" : dr.GetInt32(coContactID).ToString(),

dr.IsDBNull(coLastName) ?

"NULL" : dr.GetString(coLastName),

dr.IsDBNull(coFirstName) ?

"NULL" : dr.GetString(coFirstName));

Console.WriteLine("\n--- Provider-specific typed accessor (寫法 4) ---");

//GetSqlString() 方法後面,不用再加上 ToString() 轉型

"NULL" : dr.GetSqlInt32(coContactID).ToString(),

"NULL" : dr.GetSqlString(coLastName),

"NULL" : dr.GetSqlString(coFirstName));

connection.Close();

一起談.NET技術,DataReader 程式性能優化

圖 3 性能優劣,由好到壞依序為:寫法 (4) --> 寫法 (3) --> 寫法 (1)

  四、用 GetValues 方法,一次提取記錄中所有字段的值 

  由于 Get 開頭的方法,一次隻能提取記錄中某一個字段的值,為了提升執行性能,從 ADO.NET 2.0 開始,新增了一個 SqlDataRecord.GetValues 方法 [3] 。它會傳回一個 Object 類型的數組,裡面包含目前這一條記錄中,所有字段的值。

  但 GetValues 方法,隻能接收一個 Object 類型的數組,如下方代碼,在撰碼時雖然很友善,但就有些文檔宣稱它可提升性能,這點我是較不認同。

一起談.NET技術,DataReader 程式性能優化
一起談.NET技術,DataReader 程式性能優化

示例四 - GetValues 方法

using (SqlConnection conn = new SqlConnection(strConnString))

conn.Open();

using (SqlCommand cmd = new SqlCommand())

cmd.Connection = conn;

cmd.CommandText = "SELECT * FROM Employees";

using (SqlDataReader dr = cmd.ExecuteReader())

Object[] objArray; //包含目前資料行中,所有字段的值

objArray = new Object[dr.FieldCount]; //dr.FieldCount 表示字段的數量,以此決定數組的大小

                dr.GetValues(objArray); //将整條記錄存儲在數組中

               //從數組中,第一、第二個字段的資料取出

                DropDownList1.Items.Add(objArray[0].ToString() + ", " + objArray[1].ToString());

  此外,DataReader 還有許多實務上很友善的功能,例如:GetName() 方法,可用字段的序列号,查找其對應的字段名稱。若未曾翻閱過這些 ADO.NET 的 API 或書籍,而隻會用滑鼠拖放 DataSource 控件、edmx、xsd,在開發上很友善,但仍有不足之處,應該要對 ADO.NET 有一定的認識 (可惜部落格園和一些論壇,連這個分類項都沒有)。 

  新的一年,在大家一窩蜂地追求新技術、新架構、新的 IDE 工具時,若您的公司要招聘新的程式員,當心遇到對 .NET 4.0、VS 2010、LINQ、EDM、Dynamic Data、Silverlight、... 都能琅琅上口,在 VS 裡拖拉控件他也一把罩,但對基礎類庫和 API 特性卻毫不熟悉的人,或需求稍微變化就不會寫的人 [9]。