來自同事的資料庫程式效能調校案例一則。
情境為一支同步來源及目的資料表的排程,先一次取回來源及目的資料表,逐一檢查資料是否已存在目的資料表,若不存在即執行Insert,若存在則執行 Update 更新欄位。因 Insert/Update 之前需進行特定轉換,故難以改寫為 Stored Procedure。排程有執行過慢問題,處理四萬筆資料耗時近 27 分鐘。
程式示意如下:
foreach (var src in srcList)
{
try
{
var target = findExistingData(src);
if (target == null)
{
AddTargetToDB(src);
}
else
{
UpdateTargetToDB(target, src);
}
}
catch (Exception e)
{
LogError(e);
}
}
同事加入多執行緒平行處理,改寫為 Parallel.ForEach 版本如下,很神奇地把時間縮短到 5 分鐘內完成!
var count = 0;
Parallel.ForEach(srcList, () => 0, (src, state, subtotal) =>
{
try
{
var target = FindExistingData(src);
if (target == null)
{
return AddTargetToDB(src);
}
else
{
return UpdateTargetToDB(target, src);
}
}
catch (Exception e)
{
LogError(e);
return 0;
}
},
rowsEffected =>
{
Interlocked.Add(ref count, rowsEffected);
});
加入平行處理可加速在預期之內,高達五倍的效能提升卻讓我大吃一驚!我原本預期,四萬次 Insert 或 Update 操作大批進入應該在資料庫端也會形成瓶頸,例如:若 Insert 或 Update 涉及 Unique Index,資料庫端需依賴鎖定機制防止資料重複,即使同時送入多個執行指令,進了資料庫還是得排隊執行。
仔細分析,此案例靠多核平行運算能產生的效益有限,效能提升主要來自節省網路傳輪的等待時間。為此,我設計了一個實驗:建主一個包含 12 個欄位的資料表,4 個 VARCHAR(16)、4 個 INT、4 個 DATETIME,使用以下程式測試用 foreach 及 Parallel.ForEach 分別執行 1024, 2048, 4096, 8192 筆資料的新增與更新並記錄時間,Parallel.ForEach 部分則加入同時執行的最大執行緒數目統計:
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Dapper;
using System.Diagnostics;
using System.Threading;
namespace BatchInsert
{
class Program
{
staticstring cs = "連線字串";
staticstring truncCommand = @"TRUNCATE TABLE Sample";
staticstring insertCommand = @"
INSERT INTO Sample (T1,T2,T3,T4,N1,N2,N3,N4,D1,D2,D3,D4)
VALUES (@T1,@T2,@T3,@T4,@N1,@N2,@N3,@N4,@D1,@D2,@D3,@D4)";
staticstring updateCommand = @"
UPDATE [dbo].[Sample]
SET [T2] = @T2, [T3] = @T3, [T4] = @T4
,[N1] = @N1, [N2] = @N2, [N3] = @N3, [N4] = @N4
,[D1] = @D1, [D2] = @D2, [D3] = @D3, [D4] = @D4
WHERE T1 = @T1";
staticvoid Main(string[] args)
{
Test(1024);
Test(2048);
Test(4096);
Test(8192);
Console.Read();
}
staticvoid Test(int count)
{
List<DynamicParameters> data = new List<DynamicParameters>();
for (var i = 0; i < count; i++)
{
var d = new DynamicParameters();
d.Add("T1", $"A{i:0000}", System.Data.DbType.String);
d.Add("T2", $"B{i:0000}", System.Data.DbType.String);
d.Add("T3", $"C{i:0000}", System.Data.DbType.String);
d.Add("T4", $"D{i:0000}", System.Data.DbType.String);
d.Add("N1", i, System.Data.DbType.Int32);
d.Add("N2", i, System.Data.DbType.Int32);
d.Add("N3", i, System.Data.DbType.Int32);
d.Add("N4", i, System.Data.DbType.Int32);
d.Add("D1", DateTime.Today.AddDays(i));
d.Add("D2", DateTime.Today.AddDays(i));
d.Add("D3", DateTime.Today.AddDays(i));
d.Add("D4", DateTime.Today.AddDays(i));
data.Add(d);
}
TestDbExecute(data, true, false);
TestDbExecute(data, true, true);
TestDbExecute(data, false, false);
TestDbExecute(data, false, true);
}
staticobject sync = newobject();
staticvoid TestDbExecute(List<DynamicParameters> data,
bool insert, bool parallel)
{
string cmdText = insert ? insertCommand : updateCommand;
using (SqlConnection cn = new SqlConnection(cs))
{
Stopwatch sw = new Stopwatch();
cn.Execute(truncCommand);
sw.Start();
if (!parallel)
{
foreach (var d in data)
{
cn.Execute(cmdText, d);
}
}
else
{
int threadCount = 0;
int maxThreadCount = 0;
Parallel.ForEach(data, (d) =>
{
lock (sync)
{
threadCount++;
if (threadCount > maxThreadCount)
maxThreadCount = threadCount;
}
using (var cnx = new SqlConnection(cs))
{
cnx.ExecuteReader(cmdText, d);
}
Interlocked.Decrement(ref threadCount);
});
Console.WriteLine("[MaxThreads={0}]", maxThreadCount);
}
sw.Stop();
Console.Write("{0} {1} {2}: {3:n0}ms\n",
data.Count, parallel ? "Parallel" : "Loop",
insert ? "Insert": "Update", sw.ElapsedMilliseconds);
}
}
}
}
找了一台內網的遠端 SQL 資料庫進行測試,從 1024 到 8192 四種筆數,使用 Parallel.ForEach 都節省近一半時間,成效卓著:
1024 Loop Insert: 8,372ms [MaxThreads=10] 1024 Parallel Insert: 4,668ms 1024 Loop Update: 8,737ms [MaxThreads=11] 1024 Parallel Update: 4,620ms 2048 Loop Insert: 16,665ms [MaxThreads=14] 2048 Parallel Insert: 8,358ms 2048 Loop Update: 16,545ms [MaxThreads=12] 2048 Parallel Update: 8,538ms 4096 Loop Insert: 36,444ms [MaxThreads=22] 4096 Parallel Insert: 17,925ms 4096 Loop Update: 33,724ms [MaxThreads=22] 4096 Parallel Update: 17,427ms 8192 Loop Insert: 67,885ms [MaxThreads=31] 8192 Parallel Insert: 35,011ms 8192 Loop Update: 65,761ms [MaxThreads=27] 8192 Parallel Update: 34,819ms
接著我改連本機資料庫執行相同測試,這一回加速效果很不明顯,甚至出現 Parallel.ForEach 比 foreach 迴圈還慢的狀況:
1024 Loop Insert: 5,073ms [MaxThreads=10] 1024 Parallel Insert: 4,772ms 1024 Loop Update: 4,342ms [MaxThreads=10] 1024 Parallel Update: 4,457ms 2048 Loop Insert: 8,144ms [MaxThreads=11] 2048 Parallel Insert: 8,672ms 2048 Loop Update: 8,540ms [MaxThreads=12] 2048 Parallel Update: 8,659ms 4096 Loop Insert: 17,477ms [MaxThreads=22] 4096 Parallel Insert: 17,860ms 4096 Loop Update: 18,089ms [MaxThreads=22] 4096 Parallel Update: 17,629ms 8192 Loop Insert: 33,393ms [MaxThreads=30] 8192 Parallel Insert: 35,364ms 8192 Loop Update: 35,869ms [MaxThreads=39] 8192 Parallel Update: 36,817ms
比較上述兩組結果,Parallel.ForEach 更新遠端資料庫的時間與更新本端資料庫的時間相近,逼近資料庫的極限,可解釋為藉由平行處理排除網站傳輸因素後,遠端資料庫的效能表現趨近本機資料庫。平行處理的加速效應只出現在連線遠端資料庫,用在本機資料庫反而有負面影響,也能研判效能提升主要來自節省網路傳輸等待時間。
【結論】
在對遠端執行大量批次更新時,使用 Parallel.ForEach 確實能藉著忽略網路傳輸等待縮短總執行時間,在網路傳輸愈慢的環境效益愈明顯。既然效能提升來自避免等待,改用 ExecuteNonQueryAsync 應該也能產生類似效果,但程式寫法比 Parallel.ForEach 曲折些。這類做法本質偏向暴力破解,形同對資料庫的壓力測試,若條件許可,可考慮改用 BULK INSERT、TVP等更有效率的策略。
【小插曲】
程式改寫 Parallel 版時,由於非同步執行進度不易掌握,使透過統計 ExecuteNoQuery() 傳回受影響筆數方式確認 Insert/Update 筆數無誤。原本預期不管是新增或修改,每次變更筆數都應該為 1,萬萬沒想到統計總數卻超過總資料筆數,貌似改為平行處理後執行結果不同,引發驚慌。
深入調查才發現:目的資料表掛有 Trigger, 在特定情況會連動其他資料表的資料,造成更新一筆但受影響筆數大於 1(要加上 Trigger 所異動的資料筆數)。 最後修改程式,改由受影響筆數 >0 判定是否執行成功,計數則一律+1,化解一場虛驚。