大部分的.NET開發者都知道,要做大量的字串相加,StringBuilder比string相加快上N倍。這個效能差異源於String物件的特性,每次"動態相加"時必須捨棄原字串佔用的記憶體空間,重新配置記憶體儲存相加後的新字串內容。只是背後的原理實在曲折,於是我們腦海只會留下"串接字串千萬要用StringBuilder,用string相加會被人笑"的簡化結論。

前些時候協助做Code Review,看到一段SQL查詢程式出現有趣的寫法。

一般為了方便閱讀,將長長的SQL依SELECT, FROM, WHERE拆成多行是很好的寫作習慣;不過在這個例子中,StringBuilder被拿來串接靜態變數,直覺上並不能發揮提高效能的效果。依我的認知,靜態字串的相加會在編譯階段時轉化成單一字串,理論上會比動用StringBuilder物件來得快。但沒測過,我也不敢確定答案與速度差距。所以我準備了三種字串組合方式,在力求SQL指令要拆行以利閱讀的前題下,第一種用StringBuilder,第二種用加號相加,第三種則用@"..."讓字串內容得以直接換行。

排版顯示純文字
static string GetSqlString_1()
{
    StringBuilder sqlCmd;
    sqlCmd = new StringBuilder("");
    sqlCmd.Append(
"select customer.customername || ' ' as cstname,vendor.vbename || ' ' as vbename,");
    sqlCmd.Append(" tradepara.cparavalue || ' ' as Bank,");
    sqlCmd.Append(" vendor.brkcap ||' '|| vendor.brkcapvalue as CCASS,");
    sqlCmd.Append(" contract.tradername ||'      '|| contract.traderphone as Person,");
    sqlCmd.Append(" contract.bankno || ' ' as BIC,contract.traderemail || ' ' as email");
    sqlCmd.Append(" from sbtrade");
    sqlCmd.Append(" left join customer on sbtrade.corpid = customer.corpid");
    sqlCmd.Append(" and sbtrade.customerid = customer.customerid");
    sqlCmd.Append(" left join vendor on sbtrade.corpid = vendor.corpid");
    sqlCmd.Append(" and sbtrade.secbrkid = vendor.secbrkid");
    sqlCmd.Append(" left join tradepara on sbtrade.corpid = tradepara.corpid");
    sqlCmd.Append(" and tradepara.tradeid=:tradeid");
    sqlCmd.Append(" and sbtrade.customerid = tradepara.customerid");
    sqlCmd.Append(" and tradepara.cparaid='CUSTBANKNAME'");
    sqlCmd.Append(" left join contract on sbtrade.corpid = contract.corpid");
    sqlCmd.Append(" and sbtrade.mktcodeid = contract.mktcodeid");
    sqlCmd.Append(" and sbtrade.secbrkid = contract.secbrkid");
    sqlCmd.Append(" and contract.sbktype='CSI'");
    sqlCmd.Append(" and sbtrade.secaccount = contract.secaccount");
    sqlCmd.Append(" where sbtrade.corpid=:gCorpId and sbtrade.tradetype='BS'");
    sqlCmd.Append(" and to_char(sbtrade.tradedate,'yyyy/MM/dd') = :tradedate");
    sqlCmd.Append(" and sbtrade.mktcodeid=:mktcodeid");
    sqlCmd.Append(" and sbtrade.secbrkid = :secbrkid");
    sqlCmd.Append(" and sbtrade.secaccount = :secaccount");
    sqlCmd.Append(" and sbtrade.customerid = :customerid");
 
    return sqlCmd.ToString();
}
 
static string GetSqlString_2()
{
    string sql = 
"select customer.customername || ' ' as cstname,vendor.vbename || ' ' as vbename," +
    " tradepara.cparavalue || ' ' as Bank," +
    " vendor.brkcap ||' '|| vendor.brkcapvalue as CCASS," +
    " contract.tradername ||'      '|| contract.traderphone as Person," +
    " contract.bankno || ' ' as BIC,contract.traderemail || ' ' as email" +
    " from sbtrade" +
    " left join customer on sbtrade.corpid = customer.corpid" +
    " and sbtrade.customerid = customer.customerid" +
    " left join vendor on sbtrade.corpid = vendor.corpid" +
    " and sbtrade.secbrkid = vendor.secbrkid" +
    " left join tradepara on sbtrade.corpid = tradepara.corpid" +
    " and tradepara.tradeid=:tradeid" +
    " and sbtrade.customerid = tradepara.customerid" +
    " and tradepara.cparaid='CUSTBANKNAME'" +
    " left join contract on sbtrade.corpid = contract.corpid" +
    " and sbtrade.mktcodeid = contract.mktcodeid" +
    " and sbtrade.secbrkid = contract.secbrkid" +
    " and contract.sbktype='CSI'" +
    " and sbtrade.secaccount = contract.secaccount" +
    " where sbtrade.corpid=:gCorpId and sbtrade.tradetype='BS'" +
    " and to_char(sbtrade.tradedate,'yyyy/MM/dd') = :tradedate" +
    " and sbtrade.mktcodeid=:mktcodeid" +
    " and sbtrade.secbrkid = :secbrkid" +
    " and sbtrade.secaccount = :secaccount" +
    " and sbtrade.customerid = :customerid";
    return sql;
}
 
static string GetSqlString_3()
{
    string sql = @"
select customer.cstname || ' ' as customername,vendor.vbename || ' ' as vbename,
tradepara.cparavalue || ' ' as Bank, 
vendor.brkcap ||' '|| vendor.brkcapvalue as CCASS, 
contract.tradername ||'      '|| contract.traderphone as Person, 
contract.bankno || ' ' as BIC,contract.traderemail || ' ' as email 
from sbtrade 
left join customer on sbtrade.corpid = customer.corpid 
and sbtrade.customerid = customer.customerid 
left join vendor on sbtrade.corpid = vendor.corpid 
and sbtrade.secbrkid = vendor.secbrkid 
left join tradepara on sbtrade.corpid = tradepara.corpid 
and tradepara.tradeid=:tradeid 
and sbtrade.customerid = tradepara.customerid 
and tradepara.cparaid='CUSTBANKNAME' 
left join contract on sbtrade.corpid = contract.corpid 
and sbtrade.mktcodeid = contract.mktcodeid 
and sbtrade.secbrkid = contract.secbrkid 
and contract.sbktype='CSI' 
and sbtrade.secaccount = contract.secaccount 
where sbtrade.corpid=:gCorpId and sbtrade.tradetype='BS' 
and to_char(sbtrade.tradedate,'yyyy/MM/dd') = :tradedate 
and sbtrade.mktcodeid=:mktcodeid 
and sbtrade.secbrkid = :secbrkid 
and sbtrade.secaccount = :secaccount 
and sbtrade.customerid = :customerid ";
    return sql;
}

接著我跑以下的程式分別執行GetSqlString_1(),GetSqlString_2()及GetSqlString_3()各100萬次,事前預測是1最慢,2,3一樣快,沒想到,慢的程度比想像中大多了。

排版顯示純文字
Stopwatch sw = new Stopwatch();
int times = 1000000;
sw.Start();
for (int i = 0; i < times; i++)
    GetSqlString_1();
sw.Stop();
Console.WriteLine(string.Format("Test1={0}ms", sw.ElapsedMilliseconds));
sw.Reset();
sw.Start();
for (int i = 0; i < times; i++)
    GetSqlString_2();
sw.Stop();
Console.WriteLine(string.Format("Test2={0}ms", sw.ElapsedMilliseconds));
sw.Reset();
sw.Start();
for (int i = 0; i < times; i++)
    GetSqlString_3();
sw.Stop();
Console.WriteLine(string.Format("Test3={0}ms", sw.ElapsedMilliseconds));
return;

測試結果:

Test1=4152ms
Test2=6ms
Test3=7ms

【結論】

以StringBuilder提升字串相加效率主要應用於大量的反覆字串動態(in runtime)串接,靜態字串的串接在編譯時就會自動變成單一字串,牽扯到StringBuilder物件的建立與呼叫反而變慢許多。因此StringBuilder請應用在連續大量的Runtime動態字串相接才不會未得其利,反受其害。

多行式靜熊字串的表示,在C#中,我強烈推薦使用GetSqlString_3()的Literal String寫法! 用來表示多行文字的SQL語法、Javascript,極為簡潔方便。

【2009-09-07】 StringBuilder 平反文


Comments

# by chhuang

因為,平常大部分做 SQL 語法串接的時候,通常會接收使用者所輸入的資料。可是,從上面的例子裡面,並沒有看見任何任何變數並加入 SQL 語法之中。 想請教,如果上面三個函式都需要做參數的加入,那麼哪一種字串連接的方式較好? 又該如何去測試?? (1) StringBuilder 配合 Append 與 AppendFormat (通常我都這樣做) (2) '" + XXX + '" (3) string.Format(@" ... {0} ... (1) .. ... ", XXX, YYY)

# by Jeffrey

to chhuang,以上的例子,是用參數的! 例如: "and sbtrade.secaccount = :secaccount" 其中的:secaccount就是事後要加入的變數(ORACLE用:var,SQL則是用@var),你現在慣用的寫法我習慣稱作Ad-Hoc式的組裝SQL法,有很大的風險會被駭客或有心人士用SQL-Injection的方式執行惡意破壞或資料竊取動作(例如: 幫你Drop整個Table),非*常*危*險! 建議你參考我的ASP.NET防駭指南(http://blog.darkthread.net/blogs/darkthreadtw/archive/2007/04/13/asp-net-security-guide.aspx)裡關於SQL Injection的介紹,有已經用以上寫法的網站也快排時間去Review改寫,不開玩笑,真的很危險呢!

# by chhuang

(網路有點怪怪的,如有重複發言,煩請刪除) 其實資料庫已經做實體隔離,存取資料庫都是透過 WebService 去向資料庫做動作,而 WebService 的部分都有權限限定、安全性檢查、過濾條件,也做了許多 SQL Injection 的測試。不敢說 100%安全,但是應該有一定的安全性。 所以,撇開 SQL 語法不談,只討論字串串接上使用效率問題的話。當字串連接,需要串接使用者輸入的參數,或是函數中需要串接不斷計算出來的變數時,哪種方式才算是有效率的方法呢?

# by Jeffrey

to chhuang, 只要你"直接"將User Input的東西當成SQL指令的一部份,就有SQL Injection的風險,例如: sCmd = "SELECT * FROM youTable WHERE colA='" + Request["userInput"] + '""; 無論在權限控管/實體隔離上做了多少努力,都存在被"有權輸入資料者"動手腳的風險,但如果你每次都會仔細檢查Request["userInput"],排除掉所有無效或不合格式的輸入內容,那麼用Ad-Hoc也是安全的,只是實務上這些環節不易被管控好,常會百密一疏,因此大多數的架構師會建議使用SqlParameter/Oracle Parameter。只是想確定你已經了解其中的危險,勿見怪。 回到要串參數的議題上,非SQL指令,要用Loop不斷串接陸續計算出來的變數(指字串會一直變長),我會用StringBuilder.AppendFormt("....{0}....", var);。"頻繁的"迴圈中不該用str = str + "..."肯定是對的,至於什麼叫頻繁,我文中第一個連結有實測結果,可以參考。

# by chhuang

謝謝您的一些建議與指導,常常看您的 Blog 每次都能有些不一樣的收穫,真的很棒!

# by chhuang

謝謝您的一些建議與指導,常常看您的 Blog 每次都能有些不一樣的收穫,真的很棒!

# by wu

和一般的認知剛好相反, 所以說一大堆書籍的作者反而是錯的? 讓台灣一大般線上系統(包括jsp), 因改用 stringbuilder 串接 sql statement 在串接時的效能反而差了幾百倍。

# by dcapq

請問... 可以了解的使用String 進行 += 的動作效能會遠遜於使用StringBuilder 那到底是使用 string strText = "你輸入的是:" + txtInput.Text 還是用StringBuilder作Append("你輸入的是:" + txtInput.Text) 的效能較佳 是否只要不使用 += 其實string與變數串接仍然優於使用StringBuilder

# by Jeffrey

To wu, 這篇文章所說的是一個特例,靜態態字串相接,由於Compiler會直接將字串組好了,完全不用到任何Runtime的物件作業,才會產生這樣的懸殊差異。 文章旨在點出StringBuilder有其英勇的場合,也有吃龞的時候,提醒開發人員不要陷入"無論如何,字串相接一定要用StringBuilder"的迷思。

# by Jeffrey

To dcqpq, string相加之所以慢,是因為每一次相加,都要為相加後的結果重新找一塊記憶體來擺放,配置記憶體過程很耗資源。在文中所提的所"靜態"相接,是指要相接的內容在寫程式時已100%確定,不會因執行時期的任何因素而有所變化,因此Compiler在編譯時預先配好一塊記憶體給它就好了,Runtime並不需要做任何重新配置記憶體的動作。 至於string +=跟StringBuilder.Append誰快? 我個人的看法是,在大量Loop反覆對同一字串做相加的情境中,比較看得出差別,一般只做個兩三次字串相接,二者的差異有限。不過,一切還是要實測才能驗證。 這個議題好像不少人都很感興趣,過陣子再找時間做個追蹤報導好了。

# by wu

參考這篇: Java 5.0新增的StringBuilder類別 http://220.130.0.97/javaweek/javaweekly_old.php?ygdd=53 裡面的 for(int i=0; i<1000 ; i++)    x += "Java" + i + "dotNet"; 改成 for(int i=0; i<1000 ; i++)    x += "Java" + "i" + "dotNet"; 效能變好得多,時間用得明顯較少。 當初小弟以為,是有牽涉到整數 i 要「轉型」成字串後再串接,因此後者比前者效能好。後來看到李大大的文後,猜想可能關鍵是 runtime 的問題? 前者會在每次迴圈中一直變動字串內容,後者內容永遠相同,不知大大所說的 runtime 是否是指這個? 謝謝。

# by Jeffrey

To wu, 用你這個例子來分析,我覺得二者的效能差距如你所想的是來自於轉型,而與字串相加無關。所謂Runtime的沒效率,是指x字串一開始是0 byte,"JavaidotNet"共11bytes,所以第一次相加,要去找一個11bytes的空間來放"JavaidotNet",第二次相加,原本11bytes的空間會被棄用,再去要一個22bytes的空間去放"JavaidotNetJavaidotNet"... 因此Loop期間,Runtime不斷的棄用原來的記憶體空間再去要新的。而StringBuilder我猜會先要一塊大一點的空間,不夠用時再去要新的,減少了每次重新配置記憶體的過程,就跟"每次建Connection" vs "Connection Pooling"一樣,效率來自於免除了不必要的overhead. 由於你是用x += "...", 兩段程式中的字串長度跟內容都一直在變動,跟我文中提的寫Code時就決定好字串內容所定義的"靜態"有些不同。

# by wu

嗯嗯,所以結論: 大大本文一開始的範例, 因內容固定不變,是用 string 較好。 而 for(int i=0; i<1000 ; i++)    x += "Java" + "i" + "dotNet"; 因內容每次都會變動,是用 StringBuilder 較好。 感謝您的指教。 小弟覺得這個觀念應該要讓多一點人知道。因為寫網站的人,串 sql statement 時,不管是用 ad hoc 還是 sql parameter,常常都一串就幾十行。一堆書籍或文件,卻都叫大家要改用 StringBuiler 處理。

# by Ammon

字串是靜態不變的當然不要用 StringBuilder 但是如果是動態產生的字串 用 StringBuilder 速度不只比較快,CPU使用率也較少 以前曾經寫過聊天室,Server 的 CPU Usage 一直在 100%,原因就是因為用 + 去做字串串接 另外我的印象中 += 的效能比 + 好 改天測試一下下面四種哪種比較好 for(int i=0; i<1000 ; i++) x += i.ToString(); for(int i=0; i<1000 ; i++) x = x + i.ToString();; for(int i=0; i<1000 ; i++) x = i.ToString() + x; for(int i=0; i<1000 ; i++) sb.Append(i);

# by Jeffrey

To Ammon, 我好像該寫一篇文章為StringBuilder做一下平衡報導。就動態字串相加而言,StringBuilder比string相加快上N倍!!! 我做過一個測試,每次串接16個字元,跑65536次組出1M大小的字串,string += 要花125秒,而StringBuilder只要9ms。一萬兩千倍,差距夠大了吧? PS: 工商服務時間,我後來對String vs StringBuilder做了些深入研究寫成一篇雜誌稿,預計會發表在近期的RUN!PC上,有興趣的人可以留意下個月的RUN!PC。

Post a comment