上線之後真的出狀況,退回去只是先讓正在用的人不受影響,問題本身還在那裡,沒有真正解決。今天要講的是退回去之後,怎麼真的把問題查清楚,而不是猜一個看起來合理的原因就直接改。
除錯這件事,跟前面走過的幾段最大的不同,是它一開始手上完全沒有一份現成的規劃或藍圖可以拿來對照。需求定義有逼問出來的規格,架構有分析出來的方向,實作有核對用的驗收條件,除錯這一段,一開始手上通常只有一個異常的現象,跟一堆散落的懷疑,怎麼從這種模糊的起點,一步步收斂到真正的原因,是今天要講的重點。
換個角度看,除錯這件事其實是在逼自己憑空重建一份規劃,只是這份規劃的目標不是要做出什麼,是要理解眼前這個系統實際上是怎麼運作的。前面幾段的規劃是在決定要做什麼,除錯這一段的規劃,是在還原一個已經存在、卻還沒被完全理解的事實,性質不一樣,但需要的紀律其實很像,都是從模糊到清楚,一步步收斂。
這個對比也讓我重新理解,為什麼除錯常常被當成一種比較低階、不那麼需要規劃能力的工作,實際上它需要的收斂能力,跟規劃一個全新的功能相差無幾,只是收斂的對象從一個還不存在的未來,換成了一個已經存在、卻被完全隱藏起來的事實,兩者真正用到的思考方式,本質上其實是同一套。

看到一段錯誤訊息或者一個異常的畫面,很容易直接盯著程式碼看,覺得哪裡邏輯怪怪的,就先改一版看看。這種做法最大的問題是,就算改完之後異常真的消失了,也沒辦法確定是不是真的解決了原本的問題,還是剛好改動的地方跟問題無關,只是巧合讓症狀暫時不見了。
這種巧合特別危險,因為它會給人一種錯誤的安心感,覺得問題已經解決,實際上原本的成因根本沒被動到,只是這次剛好沒有再次觸發而已。等到條件又剛好符合,異常會再次出現,而且因為上一次以為已經解決了,這次反而會更困惑,覺得明明修過了怎麼又發生,這種困惑本身,就是沒有真正重現、驗證過就宣布解決的代價。
更麻煩的是,這種假性解決會慢慢消耗掉信任感,第二次遇到同樣的異常時,會開始懷疑上一次到底有沒有真的搞清楚狀況,甚至進一步懷疑整個排查過程本身的可信度。這種信任感一旦被消耗掉,之後每一次宣布問題已經解決,都得多花額外的力氣去說服自己、也說服別人這次是真的解決了,這個代價遠比第一次就好好重現一次、認真驗證一次要高出許多。
我自己現在的習慣是,動手改之前,一定要先想辦法把問題重現出來,用什麼樣的操作、什麼樣的資料,可以穩定地讓這個異常再發生一次。重現不出來,就代表對這個問題的理解還不夠,這時候去改程式碼,改的其實是自己腦中一個沒驗證過的猜測,不是真正對應到問題本身。重現出來之後,這個重現的方法本身會變成後續驗證有沒有真的修好的依據,沒有這個依據,修完之後只能憑感覺說應該好了。
重現這件事有時候比修正本身還要花時間,尤其是那種只在特定條件下才會出現的問題,光是找出那個特定條件到底是什麼,就可能要花上不少工夫。但這段時間我認為值得花,因為省略這一步換來的,是後面每一個修正動作都建立在不確定的地基上,改了十次可能有九次都是白改,因為根本不知道自己到底改對了沒有。
找到問題之後,常常會同時冒出好幾個可能的原因,這個地方感覺不對,那個地方好像也怪怪的。這種時候如果一次把所有覺得可疑的地方都改掉,就算異常真的消失了,也不知道到底是哪個改動真正起了作用,其他改動可能根本是不必要的,甚至可能悄悄引入了新的問題,只是還沒被發現。
這種一次改一堆的做法,還有一個更隱性、更容易被忽略的風險,就是那些其實不必要的改動,會永遠留在程式碼裡,變成一段沒有任何人知道為什麼存在的邏輯。之後有人想清理程式碼,看到這段不知道用途的東西,不敢輕易刪掉,因為不確定刪了會不會影響到什麼,這種沒有明確理由的改動,會慢慢變成整個系統裡的雜訊,越積越多。
一次只改一處的另一個好處,是每一個改動都能清楚對應到一個明確的理由,這個改動究竟是為了驗證哪個假設、後來又證實了有沒有效果。這樣的紀錄留下來,之後回頭看程式碼的異動歷史,每一筆都講得出道理,不會出現一堆看起來莫名其妙、卻不敢刪的可疑改動,整個系統的異動歷史也會因此變得更值得信任。
我自己現在會逼自己一次只驗證一個假設,先想清楚如果這個假設是對的,應該會觀察到什麼現象,改這一處,用重現的方法跑一次,確認異常有沒有消失。沒有消失,就把這個改動退回去,換下一個假設繼續驗證,不會一次把好幾個懷疑的地方全部一起改掉。這樣做每一輪花的時間可能比較多,但每一輪結束之後,都能明確知道這個假設是對是錯,不會落到最後問題解決了、卻說不清楚到底是哪裡真正發揮作用的情況。
每驗證完一個假設,不管結果是對是錯,我都會順手記一句話,這個假設是什麼、驗證的結果如何。這份紀錄看起來瑣碎,實際上在比較棘手的問題上非常有用,因為除錯拖久了,很容易忘記自己前面已經排除過哪些可能性,沒有這份紀錄,很可能繞了一圈又回頭去驗證一個早就排除掉的假設,白白浪費時間。
這份紀錄也讓我在真的需要找別人幫忙、或者換角色重新看的時候,能很快把前面走過的路交代清楚,不用花時間口頭重述一遍整個過程,也不容易在重述的時候不小心漏掉某個重要的細節。一份寫下來的紀錄,永遠比憑記憶重述來得準確,這件事在除錯這種細節很多、很容易記混的場景裡,特別明顯。

除錯的過程裡,常常會遇到已經認真改了一輪,異常卻還是原封不動一樣,這種時候很容易的反應是繼續在原本的方向上加碼,換個角度改同一段邏輯,或者改得更徹底一點。但如果同一類的錯誤反覆出現,通常代表的並不是改得不夠用力,是方向本身從一開始就搞錯了,在一條錯的路上加倍努力,只會把同一道牆撞出更深的凹痕,不會因為撞得更用力就突然撞穿。
這種加碼的衝動,背後其實是一種可以理解的心理,已經投入了時間跟精力在這個方向上,放棄等於承認前面的努力白費了,繼續下去至少感覺還有希望。但這種心理跟真正該不該繼續走這條路,是兩件完全不同的事,投入了多少不該影響接下來要不要繼續投入,這件事說起來是常識,做起來卻常常被忽略,因為放棄一個已經投入的方向,感覺上的成本比實際上的成本高得多。
我自己現在會用一個簡單的方式提醒自己不要陷入這種心理,就是把已經花掉的時間跟接下來還要不要繼續投入這兩件事,刻意分開想。已經花掉的時間,不管接下來怎麼決定,都不會再回來,真正該問的問題只有一個,從現在這個時間點開始,繼續走這個方向,是不是最好的選擇,跟前面已經花了多少完全無關。
我自己現在遇到這種情況,會強迫自己停下來喘一口氣,換一個完全不同的角度重新想這個問題,而不是繼續埋頭在同一個假設上修修改改。這件事說起來簡單,但真的卡在問題裡的時候,很容易被一種「已經改了這麼多次,應該快找到答案了」的心理拉著,繼續往同一個方向鑽下去,反而更難靜下心來承認,方向可能從一開始就選錯了。
我自己抓的具體判準是,只要連續兩輪,同一類的異常都沒有真正被解決,就算是觸發換方向的明確訊號,不會等到改了五次、六次還在原地打轉才承認方向錯了。這個數字訂得低一點,看起來會比較常需要「放棄」原本的方向,但實際上省下來的時間,遠比多堅持那幾輪划算得多,因為方向真的錯的時候,堅持得越久,浪費掉的時間就越多,這筆帳不難算,難的是在當下真的願意承認方向錯了。
換方向不代表把前面驗證過的東西全部丟掉,前面排除掉的假設,還是有價值的,至少縮小了問題可能出現的範圍。換方向真正的意思是,不要再從原本那個角度繼續深挖,改從一個完全不同的切入點重新想,前面驗證過的結果,可以當成這個新角度思考時的背景資訊,不是重頭來過、什麼都不算數。
這種累積式的排除法,讓每一輪的努力都不會白費,就算最後找到的答案跟一開始的方向完全無關,前面排除掉的那些假設,還是幫忙縮小了搜尋的範圍,讓新的角度可以更快聚焦到真正可疑的地方,不用再從一片空白重新開始想,這種累積感也讓人比較容易撐過那段還沒找到答案的煎熬時期。
真的卡住的時候,我會刻意換一個完全不同的角度重新看這個問題,而不是把原本的角度再看一次、看仔細一點。同一個角度反覆檢查,容易的盲點是每次都帶著同樣的假設在找,找不到的地方永遠找不到,因為問題可能根本不在那個假設涵蓋的範圍裡。
這種情況我自己遇過不只一次,同一段邏輯反覆看了好幾遍,越看越覺得應該沒有問題,後來換個角度,不看邏輯本身,改看實際流進來的資料長什麼樣子,才發現問題出在邏輯完全沒考慮到的資料型態上。邏輯本身沒有錯,錯的是對輸入資料範圍的假設,這種問題不管把邏輯看幾遍都看不出來,因為問題根本不在邏輯裡。換一個角度,可能是從程式邏輯轉成從實際資料下手,或者從技術細節轉成從使用情境下手,這種切換角度的動作,比在同一個角度上更仔細地看三遍,更容易找到真正被忽略的地方。
我自己會找一個完全沒看過前面除錯過程的角色,把問題重新描述一次,讓它從零開始想,不要帶著前面已經排除掉的假設去想。這個角色因為沒有先入為主的包袱,反而容易注意到一些一直被忽略、卻其實很明顯的線索,這件事跟前面講過的道理一樣,自己反覆檢查自己已經看過的東西,很難跳出原本的框架。
交代給這個角色的時候,我會盡量只給客觀的現象描述,不會把自己已經想過、又排除掉的假設一併講出去。這件事一開始我沒有這麼講究,會順手把自己的猜測也交代進去,後來發現這樣做反而讓對方也不自覺順著我的猜測方向去想,等於變相把自己的盲點也傳染過去,換角色這個動作因此失去了原本該有的效果。只描述現象、不帶判斷,才能真正讓對方從一個乾淨的起點開始想。
這件事讓我特別注意到,換角色這個動作要真正有效,關鍵不在換了誰,而在交代的內容乾不乾淨。如果交代的時候夾帶了太多自己的立場,就算對方是一個全新的角色,實際上也只是在用一個新的聲音,重複同一套舊的思路,這種換角色是假的,只是形式上換了,實質上還是同一個腦子在想。
找到真正的原因、也改完之後,我不會就這樣算了,會回頭用一開始重現問題的那個方法,再跑一次,確認異常真的不再出現。這一步不能省略,因為改動本身有可能沒有真正命中問題的根源,只是剛好讓症狀在表面上消失了,用同一個重現方法驗證過,才能真正確認這次改動是有效的,不是憑感覺覺得應該修好了。
這道驗證的手續跟前面講過的完成判準是同一件事,完成不是感覺做完了,是拿出具體的證據,這裡的證據就是用同一個重現方法再跑一次的結果。少了這一步,即使改動的方向真的對了,也只能算是一個有把握的猜測,不能算是真正驗證過的結論,這兩者聽起來很像,實際上的可靠程度差很多,遇到真正重要的問題時,這個差別不能被含糊帶過。
這件事也讓我想到,重現方法本身值得留下來,不要用完就丟。這次遇到的問題,很可能在未來以類似的形式再出現一次,留著這個重現方法,下次可以直接拿來驗證是不是同一類問題,不用每次都重新想辦法重現一遍。
累積久了,這些重現方法會變成一份針對這個系統特有的問題檔案庫,每一筆都對應到一次真實發生過的異常,包含怎麼重現、後來發現的原因是什麼、怎麼修好的。這份檔案庫的價值,會隨著累積的數量慢慢浮現,遇到一個新的異常,先去這份檔案庫裡找有沒有類似的案例,常常比從頭開始猜原因快得多。
這份檔案庫也會反過來幫我看出這個系統比較脆弱的地方在哪裡,如果某一類的問題反覆出現在檔案庫裡,就算每次的表現形式不太一樣,這種重複出現的模式本身就是一個訊號,代表系統裡有某個更根本的設計問題,一直沒有被真正處理,只是每次都用局部的修法把單一次的症狀壓下去。看到這種模式,我會考慮花時間處理那個更根本的問題,而不是繼續每次都只治標。
除錯這件事拆開來仔細看,也一樣有機械性跟判斷性這兩種不同性質的分別。收集錯誤訊息、整理相關的日誌、照著已知的步驟重現問題,這種偏機械性質的工作,用中等等級的資源去做就足夠了。真正需要判斷力的,是根據收集到的資訊去推測可能的原因、決定要先驗證哪一個假設,這種需要拿捏優先順序、需要理解系統整體運作的判斷,我會留給比較強的資源去處理,或者乾脆自己直接判斷。
這個分法讓整個除錯的過程效率高上不少,不需要每一個環節都動用最貴、最強的判斷力,機械性的資訊蒐集可以快速跑完,省下來的時間,可以留給真正需要仔細想的假設驗證跟原因推測。
這裡有一點值得特別留意,蒐集資訊這件事雖然性質機械,範圍卻要交代得很清楚,不是隨便抓一堆看起來相關的資料就算數。我會明確講清楚要蒐集哪個時間區間、哪些跟這次異常直接相關的紀錄,範圍抓太大,會花很多時間在整理跟這次問題無關的雜訊上,抓太小,又可能漏掉真正重要的線索,這個範圍的拿捏,本身也需要一點經驗,不是完全不用判斷力。
第一次交代範圍的時候,我通常會抓得稍微寬一點,寧可多蒐集一點,也不要一開始就漏掉可能有用的資訊,之後真的需要縮小範圍,再回頭篩選就好,這個方向比一開始抓太窄、事後才發現漏了東西還要再重蒐集一次划算得多,重新蒐集的成本,通常比一開始多花一點時間整理雜訊要高上不少。
跟前面一直反覆強調的道理一樣,一個明顯到一眼就能看出原因的小問題,並不需要每次都搬出重現、假設驗證、換角度檢查這一整套繁瑣的流程。真正值得認真走完整套流程的,是那種原因完全不明顯、改了一輪還是沒能解決的問題,這種問題如果沒有紀律地一步步驗證下去,很容易在來回猜測裡耗掉更多時間,反而比一開始就好好走完整套流程還要沒有效率。
判準還是同一個,這個問題看起來到底有多棘手,第一次嘗試的修法心裡有沒有明顯的把握。有明顯把握、風險也很低的,直接改下去驗證一下就好,不用想太多。改了一次沒解決、或者一開始就看不出明顯原因的,才值得走完整套流程,一步一步紮實地驗證。
這個判準跟前面講過的道理一樣,遇到自己也抓不準的時候,我會偏向多走一點流程,而不是急著直接動手去改。這個習慣要付出的代價,是偶爾會為了一個其實很簡單的問題,多走了幾道確認的手續,但比起因為輕估了難度、隨手一改就以為解決了,其實根本沒對到真正的原因,這個代價還是划算得多,畢竟後者要付出的代價,通常都得等到問題又再發生一次,才會真正被人發現。
老實講清楚這套做法自己的界限。重現問題、驗證假設,能解決的是能夠被穩定觸發的那類異常,解決不了的,是那種時有時無、跟特定時機或環境狀態高度相關的問題,這類問題本質上就比較難纏。這種問題很可能怎麼樣都重現不出來,或者重現的成功率非常低,這種時候整套流程一開始就會卡在第一步,因為連驗證假設所需要的基礎,都根本還沒真正建立起來。
遇到這種狀況,我會退而求其次,先想辦法讓問題真的發生的時候,能留下更多可用的線索,而不是堅持一定要能穩定重現,才願意真正動手處理。有時候問題本身就是難以完全重現的,這種時候誠實承認這一點,把力氣放在多留一點有用的線索上,比硬要湊出一個勉強可以重現的條件,更加務實、也更加誠實。
這種難以重現的問題,我會先加強紀錄的細緻程度,讓下次問題再發生的時候,能留下比這次更多、更完整的資訊,等於是用時間去換取更多的線索,這次沒查出來,並不代表這件事白做了,累積下來的紀錄,會讓下一次發生的時候,有更多材料可以拿來推測真正的原因。這種持續累積的態度,跟一次性非要當場破案的心態不一樣,前者更適合這種本質上就難以重現的問題。
我自己會不斷提醒自己,難以重現不等於這件事不重要,有些最麻煩的問題,恰好就是那種發生頻率很低、卻一旦真的發生就影響很大的類型。這種問題不能因為一時難查就放著不管,只是要先調整好自己的心態,接受這件事可能得花上好幾次發生的累積,才能真正找到根源,並不是每一個問題,都能夠在第一次發生的當下就被徹底解決掉。
這種調整心態的過程,其實也是在練習跟前面講過的那種不確定感相處,只是這裡的不確定感拉得更長,可能要橫跨好幾週、甚至好幾個月,才能真正累積到足夠的線索。能不能在這麼長的時間跨度裡,持續保持記錄的紀律,不因為一時查不出來就放棄,是這種難纏問題最終能不能被解決的關鍵。
真的動手排查的時候,前面每一段辛苦留下的規劃文件、架構決策、判斷卡,全部都會變成排查時最直接、最值得信賴的地圖。這個功能當初為什麼這樣設計、當初有沒有考慮過現在遇到的這種情況,這些問題如果能直接翻到當初的紀錄找答案,排查的速度會快很多,不用憑猜測去還原當初的思路。
我自己實際排查的時候,第一件事往往不是先看程式碼,是先翻當初的判斷卡,看有沒有哪一條標記過「這裡先簡化處理,之後可能要補」之類的紀錄。這種紀錄常常直接命中問題的根源,因為當初留下這個標記的自己,其實已經預見了這裡可能會出狀況,只是當下選擇先擱置,沒有立刻處理,這種提前留下的線索,比事後從零開始猜有效率得多。
這件事讓我對「留下標記」這個習慣有了更深一層的體會,當初留標記的時候,只是單純想誠實記錄一個還沒處理的缺口,沒想到這個標記到了真正出問題的那一刻,會直接變成排查時最有價值的起點。誠實記錄這件事的回報,常常要拖到很久以後才會顯現,這也是為什麼堅持這個習慣特別需要一點耐心,短期內幾乎看不到任何具體的好處,甚至會覺得這件事有點多餘。
沒有這些紀錄的話,排查一個問題,就等於要先花不少時間重新理解整個系統當初到底是怎麼想的,這件事本身就已經很花時間,而且非常容易理解錯,因為現在的理解,跟當初設計時真正的考量,這中間可能早就已經有了不小的落差。前面每一段紮紮實實留下的東西,到了真正出問題的這一刻,價值才會完全顯現出來,這也是為什麼從一開始就不能把留紀錄這件事,當成一件可有可無、隨時能省略的多餘工作。
回頭看整個除錯的過程,重現、驗證假設、換角度檢查、修完再驗證一次,這幾個步驟拆開來看都不複雜,湊在一起,其實都在對抗同一種傾向,急著下結論、急著證明自己已經找到答案。這種急躁的心情在真正卡住的時候特別容易冒出來,因為卡住本身就讓人不舒服,很想趕快結束這種不確定的狀態。整套流程存在的意義,就是逼自己在真正確定之前,不要提早放棄那種不確定感,因為提早放棄換來的,往往是一個看起來解決了、實際上沒有解決的假象。
這種對抗不確定感的能力,我覺得是除錯這件事裡最反直覺、卻也最重要的部分。大部分的訓練都在教怎麼找答案,很少教怎麼跟「還沒找到答案」這種狀態相處,能不能忍住不提早下結論,往往比技術能力本身更能決定一次除錯做得好不好,技術能力決定的是找答案的效率,忍住不亂猜答案的能力,決定的是找到的答案到底可不可靠。這兩種能力都重要,但後者比前者更容易被忽略,因為它不會出現在任何一份履歷或技術清單上,只有真的在現場撐過那段煎熬,才會知道自己有沒有這種能耐。
這件事也讓我重新理解了,為什麼經驗豐富的人,除錯起來不一定比較快,卻通常比較準確。經驗多的人可能一樣會忍不住想先猜一個答案,但他們往往更清楚猜測跟驗證過的結論之間的差距,會更自律地把猜測跟結論分開處理,不會把猜測直接當成結論拿去用,這種自律,比純粹靠時間累積出來的技術經驗更難培養,卻也更值得刻意花時間去培養。這也是我自己越來越確定的一件事,比起學會某個工具怎麼用,這種分得清楚猜測跟結論的紀律,才是真正撐得久、換了工具也不會失效的東西。