1. 程式人生 > >我的四年踩坑史以及思考

我的四年踩坑史以及思考

> 故障和問題是系統設計與開發的指示燈。 ## 引言 俗話說:好的戰士,是從槍林彈雨中打出來的。好的工程師,是從沼泥坑洞中踩出來的。 在平常的開發中,人很難主動去思考深入的東西。故障,層出不窮的問題,是開發人員想要回避的卻始終難以迴避的事情。從正面的角度來看,錯誤是人類進步的階梯。故而,每一個顯現的故障和問題,也能引導人更加深入地理解系統的執行,思考一些平時很少思考的東西,是很有益的禮物。在有讚的四年裡,我踩過不少坑,總結出來,期望對後來者有所啟發。
## 導圖 ![](https://img2020.cnblogs.com/blog/502996/202005/502996-20200509115618420-2086292266.png) ## 坑位及啟示 踩坑不是目標,從踩過的坑中汲取足夠的經驗教訓才划算。如何分析一個坑位呢 ?首先,應當從邏輯上嚴密地論證為什麼會出現這個問題,其嚴密性如 1+1=2 一樣無疑議;其次,帶來的啟示和指導是怎樣的,如何去防範類似的問題。
### 名字覆蓋出錯 或許出於對同行的莫可名狀的“不滿”情緒,程式猿看到不太順眼的地方,總有一種想要改掉它的衝動。但人在採取行動之前,又容易缺乏思考。因此,衝動常常招致小小的懲罰。 譬如說,我剛接手訂單匯出。看到報表檔名是:kdt_8fb888f9c9fad7840190d9d1531dddfc.csv 。 心想,這後面一串可真難看,商家也看不懂。為啥不改成更友好的形式呢 ? 於是,我修改成了 kdt_2020-05-02-13-49-12.csv 。 猜猜看,發生了什麼 ? 不同商家的報表發生了覆蓋,一個商家能下載到另一個商家的報表。一個商家的支付報表下載到了訂單報表的資料。嚴重的資料洩露問題。嗯,吃到了來到有讚的第一個 P1 。 為什麼會發生覆蓋呢 ? 容易理解,報表名稱沒有了區分性。只要不同店鋪或同一店鋪的不同人在同一時刻(精確到秒)同時匯出了報表,就會出現報表覆蓋。加上店鋪ID 是否能解決問題 ? 可以避免不同店鋪的覆蓋,但無法避免同一店鋪不同業務的覆蓋。因為這個報表名稱的函式被多個業務使用。不過,故障等級至少能降低到 P4 。多思考一個細節,故障等級就能降低一大截。由此可見,後面一串數字雖然難看了點,可是起到了很強的區分度的作用。詳情可閱:[“因修改報表名稱引發的“慘案””](https://www.cnblogs.com/lovesqcc/p/6271067.html) 啟示: **不要輕易修改看不順眼的東西**。頗與那句“不要輕易修改系統遺留程式碼”遙相呼應。其實,改還是要改的,只是改之前,要格外審慎,評估到位。在命名的問題上,一定要加上區分度強的名字空間。不然,很容易出現覆蓋,輕則程式執行不如預期,排查耗費大量時間;重則直接導致故障。這都是我經歷過的。
### 臨界分頁問題 來有讚的第一個七夕節。一位客滿妹紙提出了一個 jira : 訂單匯出重複匯出同一個訂單。起初,我單獨匯出這個訂單,沒有問題;匯出包含這個訂單的一個時段的所有訂單,也沒有問題;然而,進行指定條件的匯出時,這個訂單就是重複出現了。這可奇怪了:為什麼小批量匯出不重複,指定條件的匯出就重複 ? 為什麼這個訂單重複,而其它訂單不重複 ? 為什麼搜尋不重複,而匯出重複呢 ? 經過一整天昏天暗日的排查,終於找到了原因:這個訂單對應多個商品。訂單匯出通過 inner join 從資料庫分頁查詢訂單和商品資訊,查詢的SQL在分頁的臨界處,將這個訂單分別查出了兩次。而匯出是按批次寫入,不會做去重處理,因此最終報表裡匯出了兩次這個訂單的資訊。詳情可閱:[“InnerJoin分頁導致的資料重複問題排查”](https://www.cnblogs.com/lovesqcc/p/5759308.html) 啟示:**臨界處很容易出問題,而且很難排查**。在一對多的分頁查詢中,注意加 select distinct 。 如今回想起來,七夕節匯出了重複的訂單,這個訂單的重複正意味著我與自己 ?
### 誘導性資損 有一個多商品訂單,其中有一個商品發貨了,另外兩個商品沒有發貨。對於整個訂單來說,訂單狀態是待發貨。當時,訂單匯出只有一個訂單狀態。因此,匯出了待發貨狀態。商家看到待發貨,又將已發貨的商品重新發貨了一次,導致了資損。 在這個例項中,報表內容從邏輯上看是沒有問題的,但報表欄位設計上有點漏洞,對商家造成了誤導。商家又非常較真,非說有讚的錯誤導致了他重複發貨。最後,賠償了這個商家。我吃到了第二個 P1。 這個故事的啟示是: 現實往往不是單純的技術邏輯。**不能只活在技術的世界裡。產品和系統設計需要考慮對人的影響,避免商家做出誤操作**。在此事發生之後,訂單匯出增加了一個“商品發貨狀態” ,也梳理了並封堵了一切可能導致商家誤操作的資損潛在可能性。**涉及財產問題,要敏感而敬畏**。
### 批量處理失敗 有個商家,一次性上傳了 15000+ 個物流單號資訊,想要發貨,一直失敗。系統的邏輯是:後臺接收和解析商家上傳的發貨檔案,提取出待發貨的資訊,每 5000 個訂單號發貨資訊打包成一個訊息後批量推送到 NSQ 訊息佇列中。後臺有個任務指令碼從NSQ訊息佇列中取出打包的發貨訊息,打散成單個的發貨訊息,然後迴圈呼叫 Java 發貨服務化介面完成批量發貨。開始以為是前端或代理導致檔案上傳失敗,通過單步除錯才發現,是一次性寫入訊息元件的內容過長而導致失敗。 想到的第一個方案是,將 N 條發貨資料切分成 m 個組打包,分組傳送。嘗試了不同的 N 和 m, 這樣會導致多次呼叫推送訊息介面超時,不穩定。檢視推送訊息的介面程式碼,發現現有使用的方法是 push 單次推送。諮詢訊息同學,有一個批量訊息推送介面 bulkPush 。使用批量推送介面後,就沒有問題了。 批量發貨失敗的另一個事例是批量發貨阻塞:[“批量發貨阻塞啟示:深挖系統薄弱點”](https://www.cnblogs.com/lovesqcc/p/8046540.html)。 關於批量發貨,有個值得提及的點:有時,商家發現批量發貨後系統登記的運單號與發貨檔案裡的不一致或漏掉了某個訂單的發貨,還提供了發貨檔案。後來,對原始發貨檔案內容加了日誌後發現,上傳時商家要麼重複上傳了同一個訂單號的不同運單號,要麼就沒有相關訂單號的運單號資訊。可見,對原始檔案內容打日誌後,對解決此類糾紛是很有用的。 啟示:在處理小批量資料時,簡單起見,往往會迴圈呼叫單個的介面。這樣,很容易導致總呼叫開銷超時。**批量資料處理,就應該提供批量介面來處理**。
### 被遺忘的殺手 在做第一期週期購專案時,遇到了一個奇怪的問題:一個 N 期次的週期購訂單,僅當 N 次全部發貨完成後,訂單狀態才會變成已發貨。然而,在 QA 環境,僅僅發了一次貨,訂單狀態就變成了已發貨。 臨近上線時間,排查這個問題,排查到心理快崩潰。除錯過程:打日誌 -> 單步除錯 -> 排除干擾 -> 直連確認 -> 地毯式搜尋,終於找到罪魁禍首:藏在不起眼的角落裡的一個任務悄悄修改了訂單狀態。詳情可閱: [“被遺忘的殺手”](https://www.cnblogs.com/lovesqcc/p/6827515.html) 這個故事的啟示是: **作為系統業務處理的某個環節的任務指令碼,往往是極容易被忽視的**。忽視的後果是:要麼新需求漏改了,要麼不小心把不該改的改掉了。
### 阻塞之痛 很久以前,訂單匯出還是 PHP 的時代。商家發起一個匯出請求,推送一個匯出請求訊息。後臺接收這個請求訊息進行處理。每臺機器的匯出程序只有 8 個。 只要有若干個大流量(1-2w+)的 訂單匯出,就能把訂單匯出服務搞出問題。 深切於阻塞之痛,和大資料同學進行了一次合作,將訂單匯出遷到了 Java + ES + HBase 的技術棧。大資料同學在這個重構專案裡功不可沒。藉助 Java 的多執行緒和大資料技術,再經歷若干次迭代優化,訂單匯出浴火重生,一舉攻克了阻塞的難題。現在,150w+ 的訂單量匯出不在話下,不少訂單匯出都在 25-50w+ 之間,8w 訂單量匯出只要幾分鐘。 這個故事的啟示是:**如果技術方案有阻塞的固有瓶頸,那麼系統遲早會阻塞**。唯有技術重構,才能重生。
### 關聯軟體導致的困惑 為了跨平臺以及不受匯出訂單數量限制,訂單匯出的報表採用了 csv 格式 (excel 有最大行限制)。商家用 Excel 開啟 csv 格式的檔案後,出現了一些神奇的現象。你能猜到背後的內容嗎 ? ![](https://img2020.cnblogs.com/blog/502996/202005/502996-20200509120407098-4850450.png) 科學計數法的物流單號和支付流水號。 嗯,這個還能理解。商品編碼是 Feb-76 是什麼鬼 ? 2 月 76 日 ? 負數的電話號碼 ?猜猜看。 科學計數法,是因為 excel 開啟長數字時,會預設轉成可讀的科學計數法,但對於要處理原始運單號的系統來說,科學計數法可不友好,會導致發貨失敗;商品編碼 Feb-76 的背後內容是 76-2 ,嗯,我不知道 excel 這個預設轉換是哪位大神寫出來的;負數的電話號碼,是因為原始內容被 excel 識別成了數學計算式,默默地做了次算術。 這個例子的啟示是:**系統不是孤島**。 訂單匯出的報表常常用 Excel 來檢視和操作。即使訂單匯出沒有問題,與 Excel 聯用時也會產生困惑。 解答此類疑惑,也是在職責範圍內的。不僅要關注自己的系統,還要關注關聯的系統。
### 連續大流量將系統打掛 一切初始事物是自由無限制的。直到有一天被巨大的流量打懵。2018年4月16日,訂單匯出跪了。幾乎接近於崩潰,匯出介面響應非常慢,以至於前端直接報錯。最後只能通過重啟伺服器解決。事後排查發現: 當時有多個大流量匯出,都在幾十萬的訂單量匯出之間,匯出機器不多,訪問 Hbase 叢集大量超時,執行緒被 hang 住,最終無力支撐。 **大流量是導致系統崩潰的一大殺手**。在有了一定系統基礎的網際網路企業裡,除了影響面評估不足導致的功能型故障,大流量導致的效能型故障也出盡風頭。限制一段時間的請求數量和流量、設定合理的超時,弱依賴降級,是必備手段;將不同用途的執行緒池(提交任務和拉取資料)隔離;消除耗時操作,比如用 BatchGet 替代 Scan ,消減不必要的 IO 訪問等。詳情可閱: [“訂單匯出應對大流量訂單匯出時的設計問題”](https://www.cnblogs.com/lovesqcc/p/9277558.html) 經歷過一次大流量的洗禮,一個工程師才會走向真正的成熟。
### “記憶體殺手”大物件 對於響應敏感型應用,尤其呼叫量很大的底層服務,Full GC 是導致系統不穩定的重要原因之一。而大物件,則是容易造成 FullGC 的潛行者。 大物件,通常是一對多的關係導致。比如一個訂單有 50 個商品,或者像週期購訂單,可以有 100 期次的配送發貨。再加上訂單列表會一次拉取 20 個訂單,如果 20 個訂單都是大量商品訂單或者都是發過幾十期次的配送,那麼總資料量大小會很大,容易引起底層服務訂單詳情 GC,從而引發更大範圍的“業務地震”。詳情可閱:[“記一起Java大物件引起的FullGC事件及GC知識梳理”](https://www.cnblogs.com/lovesqcc/p/11181031.html) 啟示: 大物件,是需要謹防的另一種引發不穩定的因素。如何應對呢 ?一次呼叫的大物件數量限制,比如一次只能拉取 m 個週期購訂單;每個週期購只拉取最近 N 期次的配送資訊;大物件打散分到不同批次呼叫;綜合考慮多個系統之間的協作和影響。
### 過於自信的疏忽 即使是比較資深的工程師,如果對程式碼過於自信,而缺乏基本的測試,也會導致問題。這不,為了快速滿足業務方的一個需求,我只改了一行程式碼,沒有單測和迴歸測試就上戰場了,結果分頁搜尋訂單失效了。 ``` this.from = (page-1) * size 改成了 if (this.from == null) { this.from = (page-1) * size } ``` 最直接的啟示是: **單測和迴歸測試是保證不出低階問題的基本手段**。引申一下:**安全來源於意識到危險的存在**。如果不能意識到危險的存在,就很容易出問題。比如,我第一次用水果削皮器,就把手指削掉了,光榮掛彩。為什麼那麼多次拿刀都沒事,偏偏一個小小的削皮器就搞掉了我的小指甲 ? 因為我壓根兒就沒意識到,削皮器還有這種危險! 更“宿命”的一個結論是:**在寫下程式碼的一刻,命運就已經決定了**。是順利上線還是等著相會故障,早已在程式碼寫下的那一刻就確定了。因為程式碼執行是精確而確定的,沒有一點隨機性。想要安全上線,反覆多 check 程式碼吧,**對每一行改動都要推敲,是否會產生負面的影響**。
### 亂序訊息同步的不一致性 訂單狀態不一致,不一致,不一致,…… 發生了三次故障。多表同時更新的亂序訊息同步在機房切換下的固有問題。實質是,為了高可用的緣故,主備機房存在資訊冗餘,且主備機房之間同步存在延遲。機房切換過程中,同一個訂單的讀和寫分別在不同的機房,讀操作就容易導致讀取到舊的資訊。原理類似:一個寫執行緒更新了資料庫而未更新快取,而一個讀執行緒讀到了快取裡的舊內容。多表同步的原理可見:[“多表同步 ES 的問題”](https://www.cnblogs.com/lovesqcc/p/12358333.html) 啟示是: * **不能一次只前進一步**。俗話說,事不過三。第一次故障,知道了有這麼個原因,增加了批量掃描修復工具,加了對比後的自動補償,但由於 QA 環境無法復現主備讀寫不一致的情形,只是對補償環節做了測試。第二次故障時,發現由於新老資料的版本號是相同的,從而導致新的資料無法寫入最終儲存,自動補償未能生效。第三次故障時,才深入思考和梳理了整個過程,做了更多優化。 * **綜合考慮,消除可能導致不一致的場景**。 前兩次故障,都是發貨時更新交易訂單表然後立即更新交易商品表導致的。接受商品表訊息後,讀到了老的訂單狀態並寫入最終儲存。實際上,所需商品表的搜尋欄位在下單時就已經確定並同步了,在交易商品表更新時根本不需要再次同步。因此,我去掉了更新商品表的同步。這樣,徹底避免了發貨時可能導致的不一致(無法避免下單時可能的不一致)。這體現了“奧卡姆剃刀”原理:**如無必要,勿增實體**。 尤其增加的多餘實體還會帶來潛在風險。 * **更有效更及時的自動補償機制**。修復同步的方法很早就有了,就是更新交易表一次。之所以開始不用,是因為擔心一旦短時間有大量的訂單狀態不一致,就會大量更新交易表,短時大量的 binlog 訊息可能會下游造成影響。此外,也沒確定系統該如何互動。在優化補償機制時,發現其他的方案要麼在某種場景下難以實現補償,要麼存在更新出錯的風險。因此,找了一個合適的地方,將更新交易表的自動補償機制新增上去了。第二次故障發生時,其實最新的自動補償機制已經生效了,但是得過 10 分鐘後才生效。因此,自動補償還必須更及時。
### 墨菲定律 如果事情可能發生,那麼遲早會發生。墨菲定律一般形容不太好的事情即使小概率也會發生。退款業務,一向是個業務量比較恆定的業務。如果退款量太大,只能說明不對勁,而不是正常的業務狀態。因此,退款單的同步採用了順序佇列,足夠所需。 不過奇葩的事情總會有。商家借疫情的東風,做萬人團活動,但最終力所不及,庫存不足,系統短時間大量退款,導致退款同步短時間無法處理這麼多退款訊息,訊息處理延遲,最終導致故障。 這是一個常規業務量在特殊場景下觸發成大流量的場景,超過系統原來的設計所能承載的負荷。怎麼看待這個事情呢 ? 一方面,**系統設計理應能夠容納 10-20 倍的常規業務量,以備任何可能的特殊情況,而這也會付出更大的成本。這是一個成本與收益的衡量**。能夠接受怎樣的成本和負荷。 此外,退款同步採用順序佇列實際上是一種保守而通用的策略,避免任何情況下的多表同步的不一致。而實際上,退款狀態的同步和發貨狀態的同步是互斥的。也就是說,退款的時候無法發貨。要麼退款前發貨,要麼退款後發貨。因此,退款單同步退款狀態和發貨狀態,不需要順序佇列。改為非順序佇列後,退款單同步的吞吐量提升了幾十倍,不必再擔心大退款量的問題了。 這說明:**在具備通用方案解決問題的同時,也要根據具體問題的特殊性來設計更簡單的方案**。
### 墨菲定律2 PHP 實現的電子卡券匯出,走到了異常分支。異常分支有行打日誌的程式碼編譯不通過。結果導致電子卡券匯出任務程序始終起不來。詳情可閱: [“遺留問題,排雷會炸,不排也會炸!”](https://www.cnblogs.com/lovesqcc/p/11143831.html) 這個事例指出的問題是:很多開發同學不會在意異常分支,異常測試往往是一個空白。而一旦系統走到了異常分支,未料到的情況就發生了。 直接的啟示是:**不要忽略異常分支的測試**。起碼要保證編譯能通過吧(尤其動態語言不具備強型別校驗時) ! 其次,與那句“不要輕易修改系統的遺留程式碼”的箴言相反,如果不去排除,系統埋下的地雷會“定時爆炸”。頗符合好萊塢法則:Don't call me, I'll call you。 引申的結論是:**主動排除系統裡的坑。預防勝於治療**。一個故障的發生,既有實際的損失,又需要為故障覆盤耗費大量的精力。有這樣的時間,為什麼不去深挖系統裡的坑,及時填平呢 ? 開發與除錯也是同樣的道理: 與其花費大量時間去除錯,為何不花費這些時間使得程式更加嚴謹健壯呢 ?
### 區域性次要失敗導致了整體失敗 這種情形屢見不鮮。 曾幾何時,有個神奇的欄位傳給前端的值為 null ,整個頁面載入不出來了;曾幾何時,有個訂單的商品太多,迴圈單個呼叫介面呼叫超時了,整個頁面載入不出來了;曾幾何時,列表頁有個代付訂單因故查不到拋異常,整個買家列表頁載入不出來了。 **在處理整體流程時,需要評估區域性失敗是否可以接受**。是快速失敗,還是可以讓整體流程走下去 ? 查詢詳情時,如果是某個次要資訊因故獲取不到或某個弱依賴出錯,是不應該影響整體的輸出的。這是對系統健壯性的基本要求。能夠做到系統健壯性,時時放在心上,才能更快地成長為合格的工程師。
### 盲區 人總有留意不到的地方。**盲區是那些容易導致問題卻能讓人毫無察覺無從防範的地方**。 比如**隱式依賴**。如下程式碼所示,為什麼 isItemIncludingAha 能夠執行 ? map .toString 的返回字串不是 JSON 串啊 ! 對程式碼的追蹤發現,在某個遙遠的地方,將 extraMap 設定為了 JSONObject ,這才使得程式能夠正常執行。真是傑出的超距作用啊 !如果有人把 extraMap 又設定成了 Map ,那就等著線上故障吧。 ```java Map extraMap; // 宣告 Boolean isItemIncluded = isItemIncludingAha(extraMap.toString()); private Boolean isItemIncludingAha(String extra) { JSONObject itemExtra = JSONObject.parseObject(extra); return itemExtra.containsKey("aha") && "1".equals(itemExtra.get("aha")); } ``` 比如**髒資料**。表字段 item.promotionInfo 通常是一個 json 串 或者空串。因此,我先判斷非空,然後用 json 庫來解析它,再獲取 json 串的某個欄位的值。我認為 json 解析出來的應該非 null 了。然而, 對於某種型別訂單,promotionInfo 的值為 null ! 這就導致 json 解析出來的是 null ,而後面的方法呼叫就報了 NPE 。**NPE 真是 Java 開發者的如影隨形的好朋友**。幸好,我使用了 try-catch 捕獲並暫時隔離了這位好朋友。儘管部分開發同學認為 try-catch 有點“髒”,但它確實阻止了一次線上故障的發生。為了保衛線上,也是不遺餘力了。此外,我還有點疑惑:寫下大段大段的 if-elif-else 不覺得髒,為了保護線上不出意料之外的問題,寫個 try-catch 反而覺得“髒” 了 ?兩三行的 try-catch 與一次可能會引發實際損失的未預料的故障,孰輕孰重 ? 對於盲區,**多個心眼總是好的。 try-catch 是防身法寶之一** 。
## 小結 踩坑處處有,行路要謹慎。 本文主要分享了一些自己在有贊做訂單管理業務期間經歷過的故障、踩過的坑。於我而言,這些經歷見證了我逐步成熟的過程,引發的思考也很有價值。安全來源於意識到危險的存在。 這篇文章期望讓你明白系統的危險可能來源於哪裡,並有意識地做好防範。