查找bug是程序員的家常便飯,我身邊的人喜歡讓用戶來重現問題。當然他們也會從正式服務器上下載錯誤log,然後嘗試分析log,不過當錯誤不是那種不經思考就可識別的情況,他們就會將問題推向用戶,甚至怪罪程序依賴的平臺。他們常用的藉口就是“這個問題很難重現,需要持續監控,而且不知道要監控幾天”。下次出現,同樣是這個說法。
編程珠璣一書的作者說,“對付問題而不是程序”,這是方向。程序員一旦有了方向就是全世界最聰明的人,反之則會用最聰明的頭腦做最蠢的事情,說最蠢的話。查找錯誤的方向就是基於科學的方法理解問題、解決問題。
查找錯誤的一般方法
- 列出問題出現的可能原因(清單)。
- 針對問題發現的可能性進行排序清單。
- 假設問題就如清單其中一項所述,然後(編寫程序)去重現問題。
- 循環3,並且在不可能出現的行畫上一條刪除線。
你可以說,這是窮舉法。以下是查找問題的過程簡述。
問題描述
前幾天被突然叫到會議室,老闆讓暫停所有的工作,優先處理一個緊急問題。然後屏幕上出現了以下的一段log:
“事務(進程 ID 78)與另一個進程被死鎖在 鎖 資源上,並且已被選作死鎖犧牲品。請重新運行該事務。; nested exception is com.microsoft.sqlserver.jdbc.SQLServerException: 事務(進程 ID 78)與另一個進程被死鎖在 鎖 資源上,並且已被選作死鎖犧牲品。請重新運行該事務。” (後面的幾乎類似,除了ID編號和時間,所以就省略了) |
看完了log,老闆接着說明這個問題的緊急性--系統正處於驗收階段。
列出可能的清單
針對這個問題,我google了一下,發現大部分文章描述sqlserver的死鎖時,經常談到兩種場景。
場景1:
事務A: delele from table1 where id= 1; select * from table2; 事務B: delele from table2 where id= 1; select * from table1; |
要理解這個場景,首先要了解兩種鎖的區別:X鎖和S鎖。簡單的說,就是當試圖刪除、更新表時,我們會在該表上加一個X鎖,這個X鎖是排他鎖,影響是除非表上的X鎖被釋放,不然其它人無法再加如何鎖;而S鎖是一個共享鎖,它允許同一時間有多個共享鎖存在,但是除了X鎖以外。
假設場景1是並行執行的,事務1開始對table1加上了X鎖,同時事務2也對table2加上了X鎖。接下來它們又分別試圖請求,在對方已經加上了X鎖的表上加上S鎖。從前面得知,X鎖是排他鎖,而事務的特性是,所有的鎖資源都會在事務完成以後才釋放。 這時候事務間各自擁有對方請求的資源,又同時請求對方擁有的資源,並且要釋放自身的資源,先要請求到對方的資源,就發生死鎖了。
場景2:
事務A:delete from table1 where id =(1,..,n); 事務B:select c1,c2,c3 from table1 where id = (1,..,n); table1的索引如下,物理索引id,邏輯索引c1,c2。 |
據說這個場景的死鎖比較普遍,但是很難理解,因爲它們由始至終只操作一個表。要理解這個,還需從sqlserver中的bookmark search說起:如果select 語句中,查詢的欄位不包含在邏輯索引中,比如c3,那麼sqlserver將試圖在使用物理索引id來查找c3的值。
現在再來看一下過程中發生的鎖,select語句會在邏輯索引(c1,c2)上加上S鎖,然後爲了返回不在邏輯索引的欄位(c3),它還需要在物理索引(id)上加上S鎖;而更新呢?我們都知道,更新表時,我們需要對物理索引(id)加上排他鎖以完成表的更新,並且隨後還會被要求更新邏輯索引(c1,c2)。所以事務A先請求對id列的X鎖,隨後請求c1,c2列的X鎖;而事務B會先請求c1,c2的S鎖,接着請求id的S鎖來返回c3的值。死鎖就這樣發生了。
幸虧這個場景發生的前提是頻繁查詢以及頻繁更新表。
至此,我們的清單中有了兩項場景1與場景2。根據2/8原則,先嚐試最有可能的往往事半功倍。我隨之放棄了猜測,馬上進入驗證階段。開始先給它們排個序,我將場景1放在第一項,原因是它比場景2更容易被發現。因爲場景2的特點是隨機,即使有這樣的兩個事務併發(這種情況太普遍了),死鎖也是很難出現。
重現問題
假設清單第一項(場景1)是問題的原因,那麼它應該是這樣的:系統中肯定存在這樣的兩個事務,一個事務對產生死鎖的表(暫時稱爲deadlockedtable)讀取,並且對另外的表(暫時稱爲causelockedtable)更新;而另一事務對deadlockedtable更新,對causelockedtable讀取。
以此,我查找了項目的所有事務代碼,沒有找到。
接下來,我在這個選項上劃上一條刪除線。
開始選擇清單的下一項,然後繼續
關於場景2在系統中的確存在,這要從系統的業務談起,出現錯誤的系統是一個考勤管理系統,客戶公司有大約2000人使用系統,系統每天凌晨都會計算2000人當天的考勤排配表。由於公司嚴格的考勤機制,員工上班前都會查看自己當天的考勤排配表;對於那些請假、出差的員工,他們在系統中請假被批准後,系統需要重新計算其考勤。這裏一直提到考勤結果,是因爲出現死鎖的表就是考勤結果表,而出現死鎖的時間剛好爲員工上班高峯期及凌晨計算全公司員工排配表兩個時段,並且很隨機及短暫。
這和場景2描述的很類似--頻繁更新和查詢。
問題時如何重新這個異常。
2000人的公司,由於三班倒的機制,我按照大約只有二分之一的員工在某個點上班,1/10的併發機率,100個併發。該公司的員工總有5%左右的人需要申請請假、調休或者出差等等。他們經常被統一時間批准申請。所以計算的併發可能爲50。
模擬代碼如下
public class PerformanceTest extends BaseTestCase { private int logLevel = 0; public void setLogLevel(int i) { private static class TaskStats { class PerfTask implements Callable<TaskStats> { public void runOnce() { long rbegin = System.currentTimeMillis(); long rend = System.currentTimeMillis(); long bend = System.currentTimeMillis(); Thread.yield(); @Override private void run(int nrIterations, int nrThreads) { ExecutorService threadPool = Executors.newFixedThreadPool(nrThreads); List<Callable<TaskStats>> tasks = new ArrayList<Callable<TaskStats>>(); if (logLevel >= 1) { if (logLevel >= 1) { System.out.println("started"); } if (logLevel >= 1) { System.out.println("go"); } if (logLevel >= 1) { System.out.println("finish"); } TaskStats aggregate = new TaskStats(); System.out.println("-----------------------------------------"); threadPool.shutdown(); @Test |
爲了儘可能的接近真實情況,這個代碼最好在兩臺以上的電腦上運行,並且要注意避免sql共享機制,這點可以讓參數隨機產生。
程序跑了10分鐘後,死鎖果真出現了,如log錯誤描述一般。