Quantcast
Channel: 黑暗執行緒
Viewing all articles
Browse latest Browse all 2311

使用非同步處理提升資料庫更新速度

$
0
0

來自同事的資料庫程式效能調校案例一則。

情境為一支同步來源及目的資料表的排程,先一次取回來源及目的資料表,逐一檢查資料是否已存在目的資料表,若不存在即執行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 INSERTTVP等更有效率的策略。

【小插曲】

程式改寫 Parallel 版時,由於非同步執行進度不易掌握,使透過統計 ExecuteNoQuery() 傳回受影響筆數方式確認 Insert/Update 筆數無誤。原本預期不管是新增或修改,每次變更筆數都應該為 1,萬萬沒想到統計總數卻超過總資料筆數,貌似改為平行處理後執行結果不同,引發驚慌。

深入調查才發現:目的資料表掛有 Trigger, 在特定情況會連動其他資料表的資料,造成更新一筆但受影響筆數大於 1(要加上 Trigger 所異動的資料筆數)。 最後修改程式,改由受影響筆數 >0 判定是否執行成功,計數則一律+1,化解一場虛驚。


Viewing all articles
Browse latest Browse all 2311

Trending Articles