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