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