面向失敗編程是編程中最難的事情。
話說程序員小林的某一天:起床->吃飯->坐地鐵->到公司->敲代碼->回家->玩游戲->睡覺。
這一天的另一個版本:起床->吃飯->坐地鐵->到公司->突然要 24 小時健康碼->進不了公司->坐地鐵回去->地鐵停運了->上廁所->踩到屎滑倒->摔成腦震蕩。
第二個版本充滿意外,貌似有些極端,但你我天天在新聞上看到類似的事情,說明它其實每天都在發生。
程序也是如此。
程序員小林給公司開發的某個系統,用戶量暴漲;三年后公司上市了,小林喜迎白富美。
另一個版本:上線后第二天被 SQL 注入刪庫了,造成大量投訴;小林被老板痛罵一頓后,卷鋪蓋走人了。
程序的世界充滿意外,你我的每一行代碼幾乎都是 bug。
寫出可用的系統很容易,但寫出健壯的系統很難。
一個”簡單“的例子
我們通過儲值卡消費這個例子來看看如此”簡單“的案例到底存在多少讓人眼花繚亂的失敗場景。
假設我們給某個加油站開發個儲值卡系統,用戶可以往里面充錢,可以用儲值卡加油消費,類似你在理發店、洗腳店開的那種充值卡。
我們看看車主加油消費的場景——而且只看這個場景中的”儲值卡扣款“這一個結點。
正常流程(簡化版)大致是這樣的:
流程很簡單,加油員加完油后,用戶掏出手機掃碼進入付款頁面,輸入油槍、金額,選儲值卡支付,輸完密碼后點提交;后端創建訂單后調卡服務的扣款接口執行扣款(傳入卡號、訂單號、金額);卡服務扣款成功后返回告知用戶付款成功。
”這個需求大概要幾天開發?“產品經理問小林。
”五天。“小林覺得五天綽綽有余。
”三天吧,這周我們就要上線。“
”那就三天。“小林覺得其實三天足夠——不就一兩個接口調用嘛,卡服務是現成的。
于是小林擼起袖子開始敲代碼。進展比預想得要順利,兩天就敲完了(多少加了點班),一天測試完成,第四天就上線了!
某天夜里,小林正在擼貓時,運營同學打來電話:某車主的卡被莫名其妙扣款了!
事情是這樣的:車主魯某加了 3000 元的油,選擇用儲值卡支付,結果系統提示扣款失敗,于是魯某換微信付款成功,開車走人了。
蹊蹺的是:魯某十分鐘后收到消息說卡扣掉了 3000 元!
明明說支付失敗,怎么扣了 3000?于是魯某打電話找油站鬧。
小林趕緊排查日志,發現上圖中地第 3 步(調卡服務的扣款接口)超時了,于是業務系統告知前端扣款失敗。
調卡服務扣款接口超時,業務系統能直接返回失敗給前端嗎?
不能!
因為接口超時并不能說明卡服務那邊實際上到底有沒有扣成功(有可能卡服務處理成功了,但返回的時候網絡出問題;也有可能卡系統負載高,業務系統等待超時從而斷開連接)。
我們看看上面的異常是怎么發生的:
第四步超時后,業務后臺直接告知車主支付失敗,但實際上卡系統仍然在扣款!
那怎么辦?告訴車主”請您稍后查看支付結果?“
怎么可能!
一個想法是超時后業務系統調卡服務的查詢接口,看看這筆訂單實際是否支付成功。
問題是,如果查詢接口調用也超時呢(卡系統負載高的情況下這個概率很大)?
另外,查詢接口返回沒有扣款成功就能直接告訴用戶扣款失敗嗎?
不能!
因為查詢接口查數據庫的時候,數據庫里面沒有記錄,但有可能前面發起的那個扣款邏輯仍然在執行,稍后仍然會發生扣款。
既然怕查詢的時候扣款邏輯仍然在執行,那我們能不能等一會(比如五分鐘)再查結果呢(等那個可能的扣款執行流跑完)?
也不能!
因為車主在那等著呢!難道手機上一直在那轉圈,跟車主說現在負載高,請先喝杯茶,讓子彈飛一會?
因為必須要立即告知用戶處理結果,所以這種情況下(扣款超時且未查到扣款記錄)只能告訴用戶扣款失敗。
只不過,在告知用戶之前,業務系統需要先撤銷本次扣款申請,告訴儲值卡系統本次扣款流程不能執行了(回滾本次事務)。
于是小林做了如下優化:
現在系統健壯多了,很久沒出現上次的問題了,小林又跑去擼貓了。
某天深夜,小林又接到運營同學電話:上次的問題重現了!
尼瑪,見鬼了!
小林又跑去查日志,發現確實是扣款接口超時了,但撤銷接口調成功了(雖然調了幾次才成功)——那為毛還扣了錢啊?
想了半天,小林終于發現了問題:和前面提到的查詢問題一樣,撤銷的時候同樣無法保證那個該死的扣款流程已經跑完了啊!這次是因為撤銷邏輯確實執行了,但執行的時候扣款邏輯還在跑(還沒寫庫)!
所以撤銷接口必須考慮兩種情況:
- 撤銷的時候,扣款已經發生了——此時能正確撤銷;
- 撤銷的時候,扣款還沒發生,但扣款流程正在執行——此時撤銷會失敗,稍后錢仍然會被扣掉;
于是小林就想:既然扣款超時后立即調撤銷接口有可能因時序問題導致撤銷失敗,那我把撤銷操作做成異步調度不就行了嘛——在一段時間內(比如五分鐘)如果因未找到記錄而撤銷失敗,就稍后重試。
小林的撤銷邏輯是這樣的:
原本由業務系統同步調撤銷接口,現在改成走調度系統異步撤銷,業務系統投遞撤銷任務完成后立馬返回結果給客戶端。
因為有異步重試機制,撤銷總是能成功(除了實際中幾乎不會發生的極端情況),因而這次一定能保證不會意外扣錢!
小林同學抱著如釋重負的心態繼續擼貓。
然而,安穩日子沒過幾天,一個雷電交加的夜晚,手機再次響起:車主儲值卡消費的錢莫名其妙給人家退回去了!油站打電話要我們賠償!
小林趕緊查日志,發現場景是這樣的:車主王某用儲值卡支付 1000 元油款,失敗了;十幾秒后車主再次用儲值卡發起支付,成功了。
支付最終成功了,莫非人工退錢了?沒看到任何退款記錄啊?
抓耳撓腮,百思不得其解。小林只能打電話給儲值卡系統負責人小李。
小李一頓查日志,最終發現這筆錢是被調度系統調撤銷接口給撤銷了!
小林如夢方醒,才知道之前自己自鳴得意地犯了個天大的錯誤。
本次消費,業務系統共向儲值卡系統發起了兩次扣款申請——雖然都是同一筆訂單的扣款,卻是兩個獨立的事務。
小林(以及儲值卡系統)的錯誤在于,撤銷操作是作用在訂單上,而不是事務上。
在本次事故中,第一次扣款超時后,業務系統投遞了撤銷任務;而后車主又對該筆訂單(訂單號相同)發起了第二次扣款,成功了;與此同時,調度系統第一次撤銷失敗(卡系統未找到消費記錄,或者接口超時),一段時間后又發起第二次撤銷——而這個時候,車主已經完成了第二次扣款且成功了,于是這次的撤銷便作用在這個成功的扣款上(儲值卡系統的扣款和撤銷接口都是根據訂單號來的,它能保證同一筆訂單不會重復扣款,但撤銷的時候無法區分扣款是哪次發起的)。
我們畫下流程:
如圖,第二次的扣款被調度系統撤銷了。
小林和小李這才發現需要給扣款和撤銷接口增加事務編號。
之前扣款接口主要參數是 card_no、order_code、amount,現在變成 card_no、order_code、trans_id、amount。
之前撤銷接口參數是 order_code,現在變成 order_code、trans_id。
通過 trans_id 將扣款和撤銷綁定到同一個操作事務上,只會撤銷相應 trans_id 的扣款操作。
trans_id 由客戶端根據當前時間毫秒數生成(后面會說為啥取毫秒時間戳),它不一定需要全局唯一,只需要針對同一個訂單是唯一的即可。
加了事務的概念后,小林和小李發現壓根不需要通過調度系統不斷嘗試,只要保證撤銷接口調成功就能保證對應的扣款事務一定能夠被撤銷(或者阻止執行)。
現在撤銷接口做兩件事:
- 寫入一條撤銷記錄;
- 試圖撤銷掉已經產生的扣款;
撤銷邏輯如下:
再看看扣款的邏輯。
扣款記錄表大致長這樣子:
扣款邏輯如下:
- 先檢查是否存在該訂單的扣款記錄;
- 如果不存在,則走正常扣款流程;如果存在記錄,則要比較事務編號:如果已存在的那條事務編號小于當前的,則用當前的事務編號覆蓋,否則不做任何處理(后面會解釋這么做的原因);
流程圖如下:
現在我們看看當撤銷流程執行時,被撤銷的扣款事務處于不同狀態下的情況:
- 扣款事務執行失敗。此時壓根不會產生扣款;
- 扣款事務已經執行完畢,產生了實際扣款。此時撤銷流程會撤銷掉這筆扣款;
- 扣款事務正在執行中,還沒有寫庫,但稍后會寫庫。扣款事務實際寫庫之前,會先檢查是否存在對該事務的撤銷記錄,因為先前撤銷流程已經寫入了一條對該事務的撤銷記錄,扣款事務此時會查到撤銷記錄,從而阻止本次扣款事務寫庫(本次事務主動回滾)。
由于撤銷的時候是按事務編號來的,所以不會撤銷別的事務的扣款。
現在我們解釋下為何要用當前時間的毫秒時間戳作為事務編號。
回到上面車主王某的場景。王某第一次用卡支付超時,于是他決定重試。該場景中,卡系統接收到同一筆訂單的兩次扣款事務以及一次撤銷事務。假如兩次事務都嘗試寫庫,那么當后面的事務(不一定是第二次扣款的那個)嘗試寫庫時,肯定已經存在一條扣款記錄,此時后面這個事務要如何做?
- 用后者的事務編號替換掉前者的。
- 不做任何處理。
兩次事務的執行邏輯完全相同,產生的扣款記錄數據也是完全相同的——除了事務編號和扣款時間。
這里的關鍵是,我們無法確定第一次扣款、第二次扣款、對第一次扣款的撤銷這三個請求寫庫的先后順序。
所以,如果采用方案 1,替換事務編號,那么當第二次的提交先寫庫時,后面事務(第一次提交的扣款請求)的替換會導致事務編號變成了待撤銷的那個,因而很可能會被撤銷掉,這就會導致用戶付的錢莫名其妙被退回了。
如果采用方案 2,不做任何處理,那么當第一次的提交先寫庫時,事務編號就一直是待撤銷的那個,也會被撤銷掉,導致用戶付的錢莫名其妙被退回。
也就是說,無腦替換或不替換都是有問題的。
第一次扣款事務先寫庫的情況
第二次扣款事務先寫庫的情況
實際的業務場景是,對于同一筆訂單,無論發出多少次扣款請求,只允許一次成功,而且這次成功的扣款不能被誤撤銷。有很多方案可以實現這一點,不過有些方案需要增加額外表,有些則需要為同一筆訂單維護多條扣款記錄,這些都會帶來額外的復雜性。
我們采取事務序列號(毫秒時間戳)的方式來保證扣款事務的時序性,只允許后面覆蓋前面的,不允許反過來覆蓋。其基于這樣的事實:用戶如果對同一筆訂單發出多次扣款請求,那一定是前面扣款失敗了,因而業務系統會為前面那些失敗的扣款發出撤銷請求,所以只要保證僅允許后面覆蓋前面的事務,就不會造成誤撤銷(因為唯有最后那個扣款事務不會存在撤銷請求。感興趣的可參照上面的圖示推演一下)。
這里說的事務是指一次扣款處理流,不是指數據庫事務。
所以呢?
我不想編程了,說真的,這么個簡單的扣款場景就扯出這么多幺蛾子,太難了!
現實中比這復雜的場景多得是。
程序員到底是怎么活下來的?
答案是,他們的一生是在沒完沒了的 bug 中度過的。
90% 以上的 bug 都是因為對失敗場景考慮不周導致。
如果把現實看成事件流,那么事件流中的絕大多數節點都有不止一個出口分支(典型的是”正常“和”異常“)。2022 年 4 月 30 日晚,小林同學可能躺在床上玩游戲,也可能躺在 ICU。
系統(特別是業務系統)是對現實世界業務的反映,每個節點同樣存在多種可能。
典型的業務流分析步驟是這樣的:
幾乎所有的結點都要考慮失敗場景,而對于一些失敗場景的補償措施仍然可能失敗,如此遞歸,最終由自動補償系統(如漏單檢測/補償系統)或人工處理來兜底。
失敗的一大重要根源是分布式。
不要提什么單體架構,做 web 開發的,自入行第一天起就面對分布式系統。
典型的分布式是前后端交互。自 ajax 出世以來,前后端接口交互成為常態,接口失敗也是每個程序員都會遇到的問題。很大部分的前后端交互失敗的場景沒有得到很好地處理(特別是超時),比如沒有去重,導致重復寫入數據。
自從微服務橫行以來,后端開發人員無不被分布式事務搞得焦頭爛額。業界也總結了些解決方案,比如兩階段提交、SAGA、TCC 等,但真正實現起來都不簡單,一個看似簡單的業務都會搞得很復雜。所以業界又搞了些現成的開源方案如 seata、DTM。
還有救嗎?
好消息是,不是所有的系統都需要那么高的可靠性保證,也不是所有的失敗場景都要做補償處理。
你可能是在一家初創公司,別說系統一分鐘不可用了,就是庫被刪了估計也沒事。
你做的系統可能只是給內部人員用用,凡是遇到失敗就拋異常,大不了人工去修復數據也是可以的。
這些情況下,很可能你并不需要去開發高可用系統,他們更講究效率,把正常流程碼出來基本就完事了。
講究點可用性的,稍微把代碼寫好點,服務器配置堆高點,業務流程設計上注意點,基本也能規避大部分祭天性的問題。
等你公司真的發展成 BAT 那種了,是真正拼刀工的時候,萬分之一概率的異常場景可能就會讓系統天天宕機,賬戶天天少錢。那時候各種方案、架構、分析都要拿到桌面上來了。
所以,面向失敗編程誠然很難,但不代表你必須得天天面對著失敗抓耳撓腮,你需要評估你所負責的系統在成本、效率、健壯性上應做怎樣的取舍。
文章來自
https://www.cnblogs.com/linvanda/p/16172767.html