今天處理了一件.NET服務故障案件。有個.NET開發的Windows服務,其任務為每隔幾分鐘查詢資料庫,取出待處理的作業項目,依其指示執行相關動作。狀況為資料庫仍有大量待處理項目,但服務未如預期取回資料逐筆消化。

幸運的是,程式設計時已加入頗為詳細的Log機制,很快地在Log檔發現記憶體不足錯誤訊息:

System.OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown.
   at System.Data.SqlClient.TdsParser.ReadSqlValue(SqlBuffer value, SqlMetaDataPriv md, Int32 length, TdsParserStateObject stateObj)
   at System.Data.SqlClient.SqlDataReader.ReadColumnData()
   at System.Data.SqlClient.SqlDataReader.ReadColumn(Int32 i, Boolean setTimeout)
   at System.Data.SqlClient.SqlDataReader.GetValueInternal(Int32 i)
   at System.Data.SqlClient.SqlDataReader.GetValue(Int32 i)
   at MyReadJobMethod(ObjectMaterializer`1 )
   at System.Data.Linq.SqlClient.ObjectReaderCompiler.ObjectReader`2.MoveNext()
   at System.Data.Linq.EntitySet`1.Load()
   at System.Data.Linq.EntitySet`1.GetEnumerator()
   …

並追蹤到疑似出錯的程式片段:

var q =
    from m in context.Jobs
    where m.IsToDo == true 
    orderby m.SchDateTime 
    select m;
List<JobBaseDTO> rtn = q.ToList<JobDTO>().ConvertAll(new Converter<JobDTO, JobBaseDTO>(myConverter);

進一步確認發現,系統因某個少見情境一次產生了五千多筆待處理項目,而每筆項目透過Foreign Key關聯到另一個附件檔案資料表(每筆約300KB),上述myConverter程序會在.ToList().ConvertAll()時一併載入附件檔到資料物件中。約略計算,5000 * 300KB = 1.5GB,差不多就超出32bit .NET程式所能使用的記憶體上限(約1.6-1.7GB),推測即為引發記憶體不足的主因。試著重啟服務,的確由Task Manager觀察到該.NET程式記憶體用量一路攀升到1.7GB,接著Log就出現OutOfMemoryException;而在手工調整待處理項目拆成多批後再次執行,.NET程式耗用記憶體便下降並恢復了正常運作,印證了推論。

回頭檢視這枚茶包,有幾點值得筆記之處:

  1. 隨處保留Log絕對是好習慣
    線上系統不比開發機器,很難透過Visual Studio逐行偵錯,有些錯誤更是一閃即逝死無對證,完整的Log常是破案的重要關鍵,也是本次能快速制伏茶包的功臣。
    不過保存Log時有些注意事項,在此順道一提:
    1) 針對身分證號、密碼、地址、電話、信用卡號等敏感個資,記得要排除、隱匿或部分遮蔽
        
    (例如: 身分證號寫成A12*****89,使其能約略比對就好)
    2) Log常包含眾多系統細節,資安管控上應視同正式台的機密資料
    3) 記錄細節一多,Log成長速度會很驚人,記得定期壓縮、搬移,以免Log耗光儲存空間
    4) 善用Log機制的Level設定(Error/Warn/Debug),以便因不同情境調整記錄詳細程度及Log大小
  2. 小心LINQ查詢的過量載入陷阱
    傳統使用ADO.NET DbCommand寫SELECT查詢時,大家多養成直覺用SELECT Col1, Col2取代SELECT *,只取回必要欄位以減少資料傳輸量及提升效率。但改用LINQ後,可以宣告SELECT o直接取得資料物件,不需明確列舉欄位名稱,對於會載入哪些資料反而不像ADO.NET時代直覺化,可能會不小心載入過多非立即要用的資料,損耗系統效能甚至造成記憶體過度負擔。 在必要時,可使用SELECT new { o.Col1, o.Col2 }或另外定義欄位較少的資料物件搭配DataContext.ExecuteStoreQuery<T>()改善。(當然,也可以用View或Stored Procedure來處理,嚴謹度更高但稍嫌麻煩。)
    另外,是否屬過度載入需權衡應用情境而定: 當資料筆數不多時,用一次DB查詢取回全部所需資料較有效率;但若資料筆數眾多,則先取回清單必要欄位,再逐一取回各筆完整資料,即時性較佳也避免塞爆記憶體。
  3. 魔術數字 -- 1.6G
    32位元程式可定址的記憶體空間為4GB,但有2G屬作業系統專用(Kernel Mode),我們程式(User Mode)能存取的只有2GB,再扣除一些基本需求,大約只剩1.6G可用。(註: 我唯一找到的較官方的資訊,是ASP.NET Support Team, Tom的Blog文章,數字為800MB – 1.2GB,但我和一些網友的經驗約在1.6左右[參考: 1 2 3],只是我找不到1.6-1.7G的官方佐證,就姑且當成魔術數字吧!)
    當.NET程式在x86環境執行時,請留意此一限制。

Comments

Be the first to post a comment

Post a comment