作者 | Allison Kaptur
譯者 | yixunx
十月初的時候我在貝洛奧裡藏特的巴西 Python 大會[1]上做了主題演講。這是稍加改動過的演講文稿。你可以在這裡[2]觀看演講影片。
我愛 bug
我目前是 Pilot.com[3] 的一位高階工程師,負責給創業公司提供自動記賬服務。在此之前,我曾是 Dropbox[4] 的桌面客戶端組的成員,我今天將分享關於我當時工作的一些故事。更早之前,我是 Recurse Center[5] 的導師,給身在紐約的程式員提供臨時的訓練環境。在成為工程師之前,我在大學攻讀天體物理學併在金融界工作過幾年。
但這些都不重要——關於我你唯一需要知道的是,我愛 bug。我愛 bug 因為它們有趣。它們富有戲劇性。除錯一個好的 bug 的過程可以非常迂迴曲折。一個好的 bug 像是一個有趣的笑話或者或者謎語——你期望看到某種結果,但卻事與願違。
在這個演講中我會給你們講一些我曾經熱愛過的 bug,解釋為什麼我如此愛 bug,然後說服你們也同樣去熱愛 bug。
Bug 1 號
好,讓我們直接來看第一個 bug。這是我在 Dropbox 工作時遇到的一個 bug。你們或許聽說過,Dropbox 是一個將你的檔案從一個電腦上同步到雲端和其他電腦上的應用。
+--------------+ +---------------+
| | | |
| 元資料伺服器 | | 塊伺服器 |
| | | |
+-+--+---------+ +---------+-----+
^ | ^
| | |
| | +----------+ |
| +---> | | |
| | 客戶端 +--------+
+--------+ |
+----------+
這是個極度簡化的 Dropbox 架構圖。桌面客戶端在你的電腦本地執行,監聽檔案系統的變動。當它檢測到檔案改動時,它讀取改變的檔案,並把它的內容 hash 成 4 MB 大小的檔案塊。這些檔案塊被存放在後端一個叫做塊伺服器的巨大的鍵值對資料庫中。
當然,我們想避免多次上傳同一個檔案塊。可以想見,如果你在編寫一份檔案,你應該大部分時候都在改動檔案最底部——我們不想一遍又一遍地上傳開頭部分。所以在上傳檔案塊到塊伺服器之前之前,客戶端會先和一個負責管理元資料和許可權等等的伺服器溝通。客戶端會詢問這個元資料伺服器它是需要這個檔案塊,還是已經見過這個檔案塊了。元資料伺服器會傳回每一個檔案塊是否需要上傳。
所以這些請求和響應看上去大概是這樣:客戶端說“我有一個改動過的檔案,分為這些檔案塊,它們的 hash 是 'abcd,deef,efgh'
。伺服器響應說“我有前兩塊,但需要你上傳第三塊”。然後客戶端會把那個檔案塊上傳到塊伺服器。
+--------------+ +---------------+
| | | |
| 元資料伺服器 | | 塊伺服器 |
| | | |
+-+--+---------+ +---------+-----+
^ | ^
| | '有, 有, 無' |
'abcd,deef,efgh' | | +----------+ | efgh: [內容]
| +---> | | |
| | 客戶端 +--------+
+--------+ |
+----------+
這是問題的背景。下麵是 bug。
+--------------+
| |
| 塊伺服器 |
| |
+-+--+---------+
^ |
| | '???'
'abcdldeef,efgh' | | +----------+
^ | +---> | |
^ | | 客戶端 +
+--------+ |
+----------+
有時候客戶端會提交一個奇怪的請求:每個 hash 值應該包含 16 個字母,但它卻發送了 33 個字母——所需數量的兩倍加一。伺服器不知道該怎麼處理它,於是會丟擲一個異常。我們收到這個異常的報告,於是去檢視客戶端的記錄檔案,然後會看到非常奇怪的事情——客戶端的本地資料庫損壞了,或者 python 丟擲 MemoryError,沒有一個合乎情理的。
如果你以前沒見過這個問題,可能會覺得毫無頭緒。但當你見過一次之後,你以後每次看到都能輕鬆地認出它來。給你一個提示:在那些 33 個字母的字串中,l
經常會代替逗號出現。其他經常出現的字元是:
l \x0c < $ ( . -
英文逗號的 ASCII 碼是 44。l
的 ASCII 碼是 108。它們的二進製表示如下:
bin(ord(',')): 0101100
bin(ord('l')): 1101100
你會註意到 l
和逗號只差了一位。問題就出在這裡:發生了位反轉。桌面客戶端使用的記憶體中的一位發生了錯誤,於是客戶端開始向伺服器傳送錯誤的請求。
這是其他經常代替逗號出現的字元的 ASCII 碼:
, : 0101100
l : 1101100
\x0c : 0001100
< : 0111100
$ : 0100100
( : 0101000
. : 0101110
- : 0101101
位反轉是真的!
我愛這個 bug 因為它證明瞭位反轉是可能真實發生的事情,而不只是一個理論上的問題。實際上,它在某些情況下會比平時更容易發生。其中一種情況是使用者使用的是低配或者老舊的硬體,而執行 Dropbox 的電腦很多都是這樣。另外一種會造成很多位反轉的地方是外太空——在太空中沒有大氣層來保護你的記憶體不受高能粒子和輻射的影響,所以位反轉會十分常見。
你大概非常在乎在宇宙中執行的程式的正確性——你的程式碼或許事關國際空間站中宇航員的性命,但即使沒有那麼重要,也還要考慮到在宇宙中很難進行軟體更新。如果你的確需要讓你的程式能夠處理位反轉,有很多硬體和軟體措施可供你選擇,Katie Betchold 還關於這個問題做過一個非常有意思的講座[6]。
在剛才那種情況下,Dropbox 並不需要處理位反轉。出現記憶體損壞的是使用者的電腦,所以即使我們可以檢測到逗號字元的位反轉,但如果這發生在其他字元上我們就不一定能檢測到了,而且如果從硬碟中讀取的檔案本身發生了位反轉,那我們根本無從得知。我們能改進的地方很少,於是我們決定無視這個異常並繼續程式的執行。這種 bug 一般都會在客戶端重啟之後自動解決。
不常見的 bug 並非不可能發生
這是我最喜歡的 bug 之一,有幾個原因。第一,它提醒我註意不常見和不可能之間的區別。當規模足夠大的時候,不常見的現象會以值得註意的頻率發生。
改寫面廣的 bug
這個 bug 第二個讓我喜歡的地方是它改寫面非常廣。每當桌面客戶端和伺服器交流的時候,這個 bug 都可能悄然出現,而這可能會發生在系統裡很多不同的端點和元件當中。這意味著許多不同的 Dropbox 工程師會看到這個 bug 的各種版本。你第一次看到它的時候,你 真的 會滿頭霧水,但在那之後診斷這個 bug 就變得很容易了,而調查過程也非常簡短:你只需找到中間的字母,看它是不是個 l
。
文化差異
這個 bug 的一個有趣的副作用是它展示了伺服器組和客戶端組之間的文化差異。有時候這個 bug 會被伺服器組的成員發現並展開調查。如果你的 伺服器 上發生了位反轉,那應該不是個偶然——這很可能是記憶體損壞,你需要找到受影響的主機並儘快把它從叢集中移除,不然就會有損壞大量使用者資料的風險。這是個事故,而你必須迅速做出反應。但如果是使用者的電腦在破壞資料,你並沒有什麼可以做的。
分享你的 bug
如果你在除錯一個難搞的 bug,特別是在大型系統中,不要忘記跟別人討論。也許你的同事以前就遇到過類似的 bug。若是如此,你可能會節省很多時間。就算他們沒有見過,也不要忘記在你解決了問題之後告訴他們解決方法——寫下來或者在組會中分享。這樣下次你們組遇到類似的問題時,你們都會早有準備。
Bug 如何幫助你進步
Recurse Center
在加入 Dropbox 之前,我曾在 Recurse Center 工作。它的理念是建立一個社群讓正在自學的程式員們聚到一起來提高能力。這就是 Recurse Center 的全部了:我們沒有大綱、作業、截止日期等等。唯一的前提條件是我們都想要成為更好的程式員。參與者中有的人有計算機學位但對自己的實際程式設計能力不夠自信,有的人已經寫了十年 Java 但想學 Clojure 或者 Haskell,還有各式各樣有著其他的背景的參與者。
我在那裡是一位導師,幫助人們更好地利用這個自由的環境,並參考我們從以前的參與者那裡學到的東西來提供指導。所以我的同事們和我本人都非常熱衷於尋找對成年自學者最有幫助的學習方法。
刻意練習
在學習方法這個領域有很多不同的研究,其中我覺得最有意思的研究之一是刻意練習的概念。刻意練習理論意在解釋專業人士和業餘愛好者的表現的差距。它的基本思想是如果你只看內在的特徵——不論先天與否——它們都無法非常好地解釋這種差距。於是研究者們,包括最初的 Ericsson、Krampe 和 Tesch-Romer,開始尋找能夠解釋這種差距的理論。他們最終的答案是在刻意練習上所花的時間。
他們給刻意練習的定義非常精確:不是為了收入而工作,也不是為了樂趣而玩耍。你必須盡自己能力的極限,去做一個和你的水平相稱的任務(不能太簡單導致你學不到東西,也不能太難導致你無法取得任何進展)。你還需要獲得即時的反饋,知道自己是否做得正確。
這非常令人興奮,因為這是一套能夠用來建立專業技能的系統。但難點在於對於程式員來說這些建議非常難以實施。你很難知道你是否處在自己能力的極限。也很少有即時的反饋幫助你改進——有時候你能得到任何反饋都已經算是很幸運了,還有時候你需要等幾個月才能得到反饋。對於在 REPL 中做的簡單的事情你可以很快地得到反饋,但如果你在做一個設計上的決定或者技術上的選擇,你在很長一段時間裡都無法得到反饋。
但是在有一類程式設計工作中刻意練習是非常有用的,它就是 debug。如果你寫了一份程式碼,那麼當時你是理解這份程式碼是如何工作的。但你的程式碼有 bug,所以你的理解並不完全正確。根據定義來說,你正處在你理解能力的極限上——這很好!你馬上要學到新東西了。如果你可以重現這個 bug,那麼這是個寶貴的機會,你可以獲得即時的反饋,知道自己的修改是否正確。
像這樣的 bug 也許能讓你學到關於你的程式的一些小知識,但你也可能會學到一些關於執行你的程式碼的系統的一些更複雜的知識。我接下來要講一個關於這種 bug 的故事。
Bug 2 號
這也是我在 Dropbox 工作時遇到的 bug。當時我正在調查為什麼有些桌面客戶端沒有像我們預期的那樣持續傳送日誌。我開始調查客戶端的日誌系統並且發現了很多有意思的 bug。我會挑一些跟這個故事有關的 bug 來講。
和之前一樣,這是一個非常簡化的系統架構。
+--------------+
| |
+---+ +----------> | 日誌伺服器 |
|日誌| | | |
+---+ | +------+-------+
| |
+-----+----+ | 200 ok
| | |
| 客戶端 |
| |
+-----+----+
^
+--------+--------+--------+
| ^ ^ |
+--+--+ +--+--+ +--+--+ +--+--+
| 日誌 | | 日誌 | | 日誌 | | 日誌 |
| | | | | | | |
| | | | | | | |
+-----+ +-----+ +-----+ +-----+
桌面客戶端會生成日誌。這些日誌會被壓縮、加密並寫入硬碟。然後客戶端會間歇性地把它們傳送給伺服器。客戶端從硬碟讀取日誌併傳送給日誌伺服器。伺服器會將它解碼並儲存,然後傳回 200。
如果客戶端無法連線到日誌伺服器,它不會讓日誌目錄無限地增長。超過一定大小之後,它會開始刪除日誌來讓目錄大小不超過一個最大值。
最初的兩個 bug 本身並不嚴重。第一個 bug 是桌面客戶端向伺服器傳送日誌時會從最早的日誌而不是最新的日誌開始。這並不是很好——比如伺服器會在客戶端報告異常的時候讓客戶端傳送日誌,所以你可能最在乎的是剛剛生成的日誌而不是在硬碟上的最早的日誌。
第二個 bug 和第一個相似:如果日誌目錄的大小達到了上限,客戶端會從最新的日誌而不是最早的日誌開始刪除。同理,你總是會丟失一些日誌檔案,但你大概更不在乎那些較早的日誌。
第三個 bug 和加密有關。有時伺服器會無法對一個日誌檔案解碼(我們一般不知道為什麼——也許發生了位反轉)。我們在後端沒有正確地處理這個錯誤,而伺服器會傳回 500。客戶端看到 500 之後會做合理的反應:它會認為伺服器停機了。所以它會停止傳送日誌檔案並且不再嘗試傳送其他的日誌。
對於一個損壞的日誌檔案傳回 500 顯然不是正確的行為。你可以考慮傳回 400,因為問題出在客戶端的請求上。但客戶端同樣無法修複這個問題——如果日誌檔案現在無法解碼,我們後也永遠無法將它解碼。客戶端正確的做法是直接刪除日誌檔案然後繼續執行。實際上,這正是客戶端在成功上傳日誌檔案並從伺服器收到 200 的響應時的預設行為。所以我們說,好——如果日誌檔案無法解碼,就傳回 200。
所有這些 bug 都很容易修複。前兩個 bug 出在客戶端上,所以我們在 alpha 版本修複了它們,但大部分的客戶端還沒有獲得這些改動。我們在伺服器程式碼中修複了第三個 bug 並部署了新版的伺服器。