簡介:類似於“防禦性駕駛”對駕駛安全的重要性,防禦性編碼目的概括起來就一條:將程式碼質量問題消滅於萌芽。要做到“防禦性編碼”,就要求我們充分認識到程式碼質量的嚴肅性,也就是“一旦你覺得這個地方可能出問題,那基本它就會(在某個時刻)出問題”。當然,實際情況比這個更嚴峻。由於大家的編碼經驗和風格差異,導致大家的意識邊界是大小不一的,那些潛伏在意識邊界之外的“危險”更加隱蔽和不可琢磨。在意識層面上,我們當然要摒棄“想當然”和“差不多”的思想,嚴肅評估這些問題發生的可能性,認真對待這些風險。但如若話題止步於此,那其實還是缺乏執行層面的指導意義的,激不起半點“漣漪”的。這個文章目的也更多是關注到“實操層面”的引導
作者 | 字白
來源 | 阿里開發者公眾號
一 防禦性編碼的意義
類似於“防禦性駕駛”對駕駛安全的重要性,防禦性編碼目的概括起來就一條:將程式碼質量問題消滅於萌芽。要做到“防禦性編碼”,就要求我們充分認識到程式碼質量的嚴肅性,也就是“一旦你覺得這個地方可能出問題,那基本它就會(在某個時刻)出問題”。當然,實際情況比這個更嚴峻。由於大家的編碼經驗和風格差異,導致大家的意識邊界是大小不一的,那些潛伏在意識邊界之外的“危險”更加隱蔽和不可琢磨。
在意識層面上,我們當然要摒棄“想當然”和“差不多”的思想,嚴肅評估這些問題發生的可能性,認真對待這些風險。但如若話題止步於此,那其實還是缺乏執行層面的指導意義的,激不起半點“漣漪”的。
這個文章目的也更多是關注到“實操層面”的引導。
二 如何防禦性編碼?
以下需關注的具體方面更多來自於我的習慣和觀察,並且統一用虛擬碼作問題示例。
歡迎大家把自己的“防禦性編碼心得”在評論區分享出來。
1 併發衝突問題
這個問題在實際專案中,被錯誤地忽視的比例相當高。它的外在表現形式五花八門,但關鍵點是:“當你的程式碼被併發呼叫時,它會怎麼表現?”
我們心裡要有個執行時的世界觀,程式碼執行的Context是這樣的:多執行緒 -> 多程式 -> 多機器 -> 多叢集。我們編碼時,要充分考慮程式碼在上述世界觀多點併發的可能性,及相應的潛在後果。
舉幾個具體的問題例子):
存在共享變數 或者 資料。(不限於堆記憶體,也可能是快取、DB、檔案等)
例子1:
- 有執行緒 A 和執行緒 B 兩個執行緒,需要更新「同一條」資料,會發生這樣的場景:
- 1、執行緒 A 更新資料庫(X = 1)
- 2、執行緒 B 更新資料庫(X = 2)
- 3、執行緒 B 更新快取(X = 2)
- 4、執行緒 A 更新快取(X = 1)
- 最終 X 的值在快取中是 1,在資料庫中是 2,發生不一致。
例子2:
// 某個 Spring singleton Bean 'aService' 存在一個呼叫來源標記,記錄呼叫來源是HSF還是HTTP。
// 先 記錄來源標記。
aService.setSource(source);
// 再結合source執行其他邏輯。例如將上面記錄的source 和 其他引數 插入資料庫.
aService.doSomethings(params);
如果這個程式碼被 HSF和 HTTP 同時呼叫就會發生問題。
例子3 :
- 在一個系統中,有兩個價格型別 small 和 large,業務邏輯要求 small <= large,且 small 和 large 有2個入口可以分別修改。
- 目前方案是:對要改變的small或large,增加上面大小關係校驗,不透過則攔截,例如 改動small的入口上,校驗改後的small <= 系統裡的large,不透過則不允許修改。
- 假如,最新需求要求:修改large的入口繼續攔截,但修改small的入口不再攔截,而是發現如果改後small > 系統的large,則將 系統large = 改後的small+0.1,讓 約束關係繼續成立。 這種改法有問題嗎?
答案:這種改法會有問題。即 small這個價格型別存有兩個鏈路同時修改,也是一種併發衝突問題。
舉個具體例子:
- 初始時,系統的small = 2; large = 2;
- 修改large 鏈路1:準備將 large 改為 3,檢查規則 3(改後large ) >= 2(系統small) 透過。準備寫入新的large (3)。
- 修改small 鏈路2:準備降 small 改為 4, 發現 4(改後small)> 2(系統large) 不符合規則,則 準備 自動修改 large = 4(改後small)+ 0.1 = 4.1。準備寫入 改後small = 4,自動改後 large = 4.1;
- 如果 鏈路2 最終先完成寫入,鏈路1再完成寫入。則 鏈路2寫入的 large=4.1 會被鏈路1 寫入的large=3 覆蓋。最終系統 large =3,而 系統small = 4;破壞了最初的small <= large 的約束。
- 未考慮叢集併發
// 在簡訊傳送服務中,控制對使用者的傳送頻率
timestamp = rateLimitService.getMsgTimestamp(userId);
if( timestamp == null ){
rateLimitService.putMsgTimestamp(userId, now);
sendMsg(msg);
}else if( timestamp - now > 1 hour ){
rateLimitService.putMsgTimestamp(userId, now);
sendMsg(msg);
}
這個例子在單機環境執行時沒有問題,但線上叢集多節點的話,那傳送頻率的控制就不對了。
- 非原子操作問題。
// 先查詢是否存在目標記錄
resultList = dbRepo.list(query);
// 有結果就更新,沒有就插入
if( resultList.size() > 0 ){
dbRepo.update(xxxx);
} else {
dbRepo.insert(xxxx);
}
如果這個程式碼被多個request 同時執行也會發生問題。
- 錯誤的發生併發
單個任務週期性的觸發,本來不會有併發問題。
但因單次執行時間變長,導致先後兩次執行時間出現重疊。
2 事務問題
對於先A再B後C的這類組合操作,要仔細考慮保障一致性的必要性,做好是否做事務保障的評估。
事務即要求:對一組的operation combo,要保障好執行順序,保障好context的一致性,保障好結果的一致性。
資料庫事務。 發生機率不高,大多會主動預防。
這個問題發生機率倒不高,也比較容易解決。
但要注意,事務執行耗時不要太久,以及避免死鎖問題發生。
- 上下文一致性問題。
以上傳並處理Excel檔案為例,假如實現分為 2 步:
1、前端呼叫後端API,上傳檔案到Server的某個臨時目錄。
2、前端 在上傳完成時,呼叫後端另一個API,通知 後端處理此檔案。
這個例子在叢集環境中就會出現機率性成功或失敗的情況,叢集節點數量越多,失敗機率越高。這是因為 前端的前後兩次請求呼叫到了不同節點上,執行上下文出現了不一致。
- 順序一致性問題。
常見的,例如對於 ECS執行狀態的時序訊息,如果下游消費者不是順序消費,而是並行消費,就可能導致最終記錄的狀態 與實際不符。
3 分散式鎖問題
分散式鎖日常也經常用到,在使用細節上存在一些容易忽略的盲點。
- 獲取鎖
1、是阻塞式等待鎖,還是等不到鎖重試,還是等不到鎖直接返回。
這個層面主要考量點,這個呼叫鏈路對時間和成功率要求是什麼。
例如,上游是使用者操作,那肯定不能阻塞在等鎖那裡太久;
2、鎖的key設計很關鍵。
合理設計lock key,能夠降低鎖碰撞的機率。
例如,你的lock 是加在一個BU層面上,還是加到某個人身上,那衝突機率顯然差別很大。
3、對於 持久鎖,在迴圈執行業務邏輯時,要做好鎖的狀態檢查。
RLock lock = redisson.getLock(lock);
lock.lock(-1L, TimeUnit.MINUTES);
// 獲取到鎖就持久佔有,避免反覆切換
while( !isStopped ){
if( lock.isHeldByCurrentThread() ){
// do some work
}else{
// try to acquire lock again.
}
SleepUtil.sleep(loopInterval, TimeUnit.MINUTES);
}
4、能用本地鎖 不用全域性鎖。
- 鎖超時
1、合理設定鎖的TTL,結合自己業務場景做取捨
例如,加鎖之後執行大量資料的batch計算的場景。
如果鎖TTL太長,那計算被異常中斷(如機器重啟)時,這個長TTL內是無法被其他節點/執行緒獲取到執行許可權的;但如果TTL設定太短,那可能還沒等執行完成,鎖就被意外搶走了。
2、注意watchDog機制
像Redisson之類的會有鎖的watchdog,超過設定或預設的時間,鎖就被偷偷釋放了。
- 釋放鎖
1、非必要情況下,避免強行釋放鎖,要檢查鎖的持有人是否是自己。
2、對於沒有TTL的鎖,要考慮極端情況下(程式被強制殺死、機器重啟)的鎖狀態管理。否則意外一旦出現,鎖就永遠丟失了。
4 快取問題
- 快取穿透問題
快取和資料庫都沒有的資料,但被大量請求,導致DB壓力過大。
常見的解決方式:對空值也進行快取,但TTL設定相對較短。
- 快取擊穿問題
一般是快取的熱點key發生過期失效,此時大量請求透過快取 擊中DB,導致DB壓力過大。
常見解決方式:快取查詢miss時,設定個互斥鎖,只允許一個request真實請求DB和重寫快取,避免大量請求湧入。
- 快取雪崩問題
快取中的大量資料在較短的時間段內集中過期。一般發生在流量一波波來,快取建立時間和TTL很接近。
常見解決方案:在TTL設定上不是一刀切,而是在一個合理範圍內隨機浮動,避免快取集中失效。
- 快取的一致性
一般情況下,一致性要求不會非常嚴格。但如果需要強一致性保障時,要考慮快取和DB之間的資料強一致性。
一種可能的方案:只在寫DB時才寫快取,讀DB操作不寫快取。DB和快取的寫操作要加鎖,避免併發問題。具體流程如下:
當寫DB請求發生時:
1、刪除 快取。此時讀操作快取會miss,讀取到DB中的老值。
2、寫入DB。此時讀操作快取會miss,讀取到DB中的新值。
3、寫入快取。此時讀操作快取會 hit,讀取到快取中的新值(與DB新值一致)。
需要注意的是:
1、快取針對資料庫所有的資料記錄,可能導致快取空間佔用高,實際利用率卻不高。
2、如果某個快取key 是熱點,或者 流量比較大,儘管快取“刪除-重寫入”間隔短,依然可能會引發 快取擊穿問題。
3、如果快取寫入失敗,需要有相應的補償機制再寫入,且需關注 補償寫入與其他正常寫入的衝突和時序問題。
- 快取命中率
這個本身不是問題,但命中率低說明快取的設計或使用存在問題,需要重新設計。
- 熱點key問題
如果特定快取節點CPU使用率遠高於其他節點,說明可能存在熱點key。這個時候需要合理對快取key做拆分,將流量進一步打散。
5 失敗處理問題
這類問題雖屬於低階問題,但往往比較隱蔽。在異常發生時,選擇相應處理action時,我們要頭腦非常清醒。
- 失敗處理
可能的處理方式:
1、failover。失敗立即重試。
2、failback。記錄失敗,後置處理。
3、failfast。直接失敗,返回異常。
4、failsafe。忽略失敗,繼續流程。
這裡不在於選擇那種處理方式,而是要“頭腦清醒”的結合自己場景需求做出選擇。
- 注意預設值
一些情況下,我們會初始化時設定一些預設值、預設狀態等,對於這些情況要充分考慮異常發生時是否存在風險。
例如,在最開始時,程式碼裡配置了當時的開城資訊,但這個狀態並沒有跟業務操作流程打通,也就是沒有辦法做到及時更新。
那隨著時間發展,開發了新的城市,那就可能產生問題。
6 switch配置問題
- 分批推送的時間間隔
switch釋出時,不同批次會有時間間隔,大部分場景下都可以容忍這個時間間隔。但個別情況下,可能引發諸如資料不一致等問題。
再使用switch時需要對這個問題做提前考慮,若不能容忍這種情況,那需要更換其他方案。
- 記憶體值與持久值
switch的邏輯是這樣:
1、switch會預設記錄程式碼中的預設值。此時並不是 持久值。
2、當在程式碼中修改預設值時,switch平臺也會顯示程式碼預設值。此時也並不是 持久值。
3、只有在switch平臺修改值並推送成功,swith平臺會儲存持久值。
4、switch儲存持久值之後,不管程式碼修改預設值還是去掉 @AppSwitch 配置,持久值都是存在的。
如果你看到switch平臺上展示了開關值,以為已經持久化,然後在程式碼裡就把預設值刪掉,此時也可能導致故障。
- 程式碼重構注意事項
做程式碼結構重構時,如果沒有指定switch的namespace,會導致你推送過的持久化開關失效,進而引發嚴重的線上故障。
關於應用級服務發現與介面級服務發現的區別和 dubbo 生態的解決方案,本文中不多贅述,可以參考劉軍前輩寫的文章文章《Dubbo 邁出雲原生重要一步 應用級服務發現解析》
簡單來說,應用級服務發現需要開發者關心介面之外還要關心應用名,註冊中心的冗餘資訊較少;介面級服務發現開發者只需要引入介面名,但註冊中心的冗餘資訊較多。
- 合理使用,避免濫用
switch 提供了簡單易用的配置化能力,但不要把應該正常編碼要考慮和處理的問題,丟到switch上做開關。否則,最後開關一大堆,維護越發困難,就隱藏了風險。
7 重大風險評估和處置
針對一個需求開發,我們需要評估風險及我們的承受能力。主要目的是 預防重大故障的發生,而不是要預防所有Bug。
關於風險處置,也沒有一個固定的標準。我建議是結合業務場景,評估風險機率和潛在問題的嚴重程度,最後來制定相應的解決方案。例如,如果發現有資損風險,那要採取一切手段把漏洞堵上;但如果只是小機率的漏掉釘釘通知,那增加相應的告警即可。
我們如何評估 重大風險呢?我建議分這麼幾個環節做評估:
1、梳理 關鍵的業務流。
2、梳理 每個業務流的關鍵環節
3、梳理 每個關鍵環節的關鍵邏輯 和 關鍵上下游。
4、結合自己場景,假定 關鍵邏輯 和 關鍵上下游 出現極端問題。例如 網路掛掉、機器重啟、高併發來臨、快取掛掉等。
這裡需要強調一點,並非所有模組都需要假定非常極端的情況,要結合自己實際業務要求、歷史風險等 來綜合判斷。
再舉個例子:
假設,有一個使用者資金轉賬系統,使用者可以透過App進行跨行轉賬操作。
那這個系統就要考慮到 轉賬超時、轉賬失敗等場景。同時還要考慮 轉賬超時 或 失敗時,是fail-fast 好,還是 fail-over好?
此外,還需要考慮到 App端的使用者互動設計,假如遭遇網路中斷或超時,且使用者看不到任何問題提示,那使用者很可能再次發起轉賬嘗試,最後轉了兩筆的錢。
這個評估過程看上去有點冗長,但其實對於瞭解自己系統和需求細節的人來講,應該是很容易做到的。如果做不到那就只能加強細節的理解和學習了。
三 最後
以研發同學為中心,向內看:需持續提升防禦性編碼的意識和實操能力;向外看:外部環境需要儘可能提供與之匹配的環境。
例如,在面臨有緊急DeadLine的需求時,防禦性編碼的執行完整度就會受到一定影響。
再次歡迎大家把自己的心得留言。
原文連結
本文為阿里雲原創內容,未經允許不得轉載。