程式設計為什麼那麼難:從儲值卡扣款說起

林子er發表於2022-04-21

面向失敗程式設計是程式設計中最難的事情。

話說程式設計師小林的某一天:起床->吃飯->坐地鐵->到公司->敲程式碼->回家->玩遊戲->睡覺。

這一天的另一個版本:起床->吃飯->坐地鐵->到公司->突然要 24 小時健康碼->進不了公司->坐地鐵回去->地鐵停運了->上廁所->踩到屎滑倒->摔成腦震盪。

第二個版本充滿意外,貌似有些極端,但你我天天在新聞上看到類似的事情,說明它其實每天都在發生。

程式也是如此。

程式設計師小林給公司開發的某個系統,使用者量暴漲;三年後公司上市了,小林喜迎白富美。

另一個版本:上線後第二天被 SQL 注入刪庫了,造成大量投訴;小林被老闆痛罵一頓後,捲鋪蓋走人了。

程式的世界充滿意外,你我的每一行程式碼幾乎都是 bug。

寫出可用的系統很容易,但寫出健壯的系統很難。


一個”簡單“的例子

我們通過儲值卡消費這個例子來看看如此”簡單“的案例到底存在多少讓人眼花繚亂的失敗場景。

假設我們給某個加油站開發個儲值卡系統,使用者可以往裡面充錢,可以用儲值卡加油消費,類似你在理髮店、洗腳店開的那種充值卡。

我們看看車主加油消費的場景——而且只看這個場景中的”儲值卡扣款“這一個結點。

正常流程(簡化版)大致是這樣的:

image-20220420094234062

流程很簡單,加油員加完油後,使用者掏出手機掃碼進入付款頁面,輸入油槍、金額,選儲值卡支付,輸完密碼後點提交;後端建立訂單後調卡服務的扣款介面執行扣款(傳入卡號、訂單號、金額);卡服務扣款成功後返回告知使用者付款成功。

”這個需求大概要幾天開發?“產品經理問小林。

”五天。“小林覺得五天綽綽有餘。

”三天吧,這周我們就要上線。“

”那就三天。“小林覺得其實三天足夠——不就一兩個介面呼叫嘛,卡服務是現成的。

於是小林擼起袖子開始敲程式碼。進展比預想得要順利,兩天就敲完了(多少加了點班),一天測試完成,第四天就上線了!

某天夜裡,小林正在擼貓時,運營同學打來電話:某車主的卡被莫名其妙扣款了!

事情是這樣的:車主魯某加了 3000 元的油,選擇用儲值卡支付,結果系統提示扣款失敗,於是魯某換微信付款成功,開車走人了。

蹊蹺的是:魯某十分鐘後收到訊息說卡扣掉了 3000 元!

明明說支付失敗,怎麼扣了 3000?於是魯某打電話找油站鬧。

小林趕緊排查日誌,發現上圖中地第 3 步(調卡服務的扣款介面)超時了,於是業務系統告知前端扣款失敗

調卡服務扣款介面超時,業務系統能直接返回失敗給前端嗎?

不能!

因為介面超時並不能說明卡服務那邊實際上到底有沒有扣成功(有可能卡服務處理成功了,但返回的時候網路出問題;也有可能卡系統負載高,業務系統等待超時從而斷開連線)。

我們看看上面的異常是怎麼發生的:

image-20220420111711176

第四步超時後,業務後臺直接告知車主支付失敗,但實際上卡系統仍然在扣款!

那怎麼辦?告訴車主”請您稍後檢視支付結果?“

怎麼可能!

一個想法是超時後業務系統調卡服務的查詢介面,看看這筆訂單實際是否支付成功。

問題是,如果查詢介面呼叫也超時呢(卡系統負載高的情況下這個概率很大)?

另外,查詢介面返回沒有扣款成功就能直接告訴使用者扣款失敗嗎?

不能!

因為查詢介面查資料庫的時候,資料庫裡面沒有記錄,但有可能前面發起的那個扣款邏輯仍然在執行,稍後仍然會發生扣款。

既然怕查詢的時候扣款邏輯仍然在執行,那我們能不能等一會(比如五分鐘)再查結果呢(等那個可能的扣款執行流跑完)?

也不能!

因為車主在那等著呢!難道手機上一直在那轉圈,跟車主說現在負載高,請先喝杯茶,讓子彈飛一會?

因為必須要立即告知使用者處理結果,所以這種情況下(扣款超時且未查到扣款記錄)只能告訴使用者扣款失敗。

只不過,在告知使用者之前,業務系統需要先撤銷本次扣款申請,告訴儲值卡系統本次扣款流程不能執行了(回滾本次事務)。

於是小林做了如下優化:

image-20220420114150613

現在系統健壯多了,很久沒出現上次的問題了,小林又跑去擼貓了。

某天深夜,小林又接到運營同學電話:上次的問題重現了!

尼瑪,見鬼了!

小林又跑去查日誌,發現確實是扣款介面超時了,但撤銷介面調成功了(雖然調了幾次才成功)——那為毛還扣了錢啊?

想了半天,小林終於發現了問題:和前面提到的查詢問題一樣,撤銷的時候同樣無法保證那個該死的扣款流程已經跑完了啊!這次是因為撤銷邏輯確實執行了,但執行的時候扣款邏輯還在跑(還沒寫庫)!

所以撤銷介面必須考慮兩種情況:

  1. 撤銷的時候,扣款已經發生了——此時能正確撤銷;
  2. 撤銷的時候,扣款還沒發生,但扣款流程正在執行——此時撤銷會失敗,稍後錢仍然會被扣掉;

於是小林就想:既然扣款超時後立即調撤銷介面有可能因時序問題導致撤銷失敗,那我把撤銷操作做成非同步排程不就行了嘛——在一段時間內(比如五分鐘)如果因未找到記錄而撤銷失敗,就稍後重試。

小林的撤銷邏輯是這樣的:

image-20220420143256054

原本由業務系統同步調撤銷介面,現在改成走排程系統非同步撤銷,業務系統投遞撤銷任務完成後立馬返回結果給客戶端。

因為有非同步重試機制,撤銷總是能成功(除了實際中幾乎不會發生的極端情況),因而這次一定能保證不會意外扣錢!

小林同學抱著如釋重負的心態繼續擼貓。

然而,安穩日子沒過幾天,一個雷電交加的夜晚,手機再次響起:車主儲值卡消費的錢莫名其妙給人家退回去了!油站打電話要我們賠償!

小林趕緊查日誌,發現場景是這樣的:車主王某用儲值卡支付 1000 元油款,失敗了;十幾秒後車主再次用儲值卡發起支付,成功了。

支付最終成功了,莫非人工退錢了?沒看到任何退款記錄啊?

抓耳撓腮,百思不得其解。小林只能打電話給儲值卡系統負責人小李。

小李一頓查日誌,最終發現這筆錢是被排程系統調撤銷介面給撤銷了!

小林如夢方醒,才知道之前自己自鳴得意地犯了個天大的錯誤。

本次消費,業務系統共向儲值卡系統發起了兩次扣款申請——雖然都是同一筆訂單的扣款,卻是兩個獨立的事務

小林(以及儲值卡系統)的錯誤在於,撤銷操作是作用在訂單上,而不是事務上。

在本次事故中,第一次扣款超時後,業務系統投遞了撤銷任務;而後車主又對該筆訂單(訂單號相同)發起了第二次扣款,成功了;與此同時,排程系統第一次撤銷失敗(卡系統未找到消費記錄,或者介面超時),一段時間後又發起第二次撤銷——而這個時候,車主已經完成了第二次扣款且成功了,於是這次的撤銷便作用在這個成功的扣款上(儲值卡系統的扣款和撤銷介面都是根據訂單號來的,它能保證同一筆訂單不會重複扣款,但撤銷的時候無法區分扣款是哪次發起的)。

我們畫下流程:

image-20220420152519360

如圖,第二次的扣款被排程系統撤銷了。

小林和小李這才發現需要給扣款和撤銷介面增加事務編號

之前扣款介面主要引數是 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. 試圖撤銷掉已經產生的扣款;

撤銷邏輯如下:

image-20220420205541356

再看看扣款的邏輯。

扣款記錄表大致長這樣子:

image-20220420184516421

扣款邏輯如下:

  1. 先檢查是否存在該訂單的扣款記錄;
  2. 如果不存在,則走正常扣款流程;如果存在記錄,則要比較事務編號:如果已存在的那條事務編號小於當前的,則用當前的事務編號覆蓋,否則不做任何處理(後面會解釋這麼做的原因);

流程圖如下:

image-20220420205249011

現在我們看看當撤銷流程執行時,被撤銷的扣款事務處於不同狀態下的情況:

  1. 扣款事務執行失敗。此時壓根不會產生扣款;
  2. 扣款事務已經執行完畢,產生了實際扣款。此時撤銷流程會撤銷掉這筆扣款;
  3. 扣款事務正在執行中,還沒有寫庫,但稍後會寫庫。扣款事務實際寫庫之前,會先檢查是否存在對該事務的撤銷記錄,因為先前撤銷流程已經寫入了一條對該事務的撤銷記錄,扣款事務此時會查到撤銷記錄,從而阻止本次扣款事務寫庫(本次事務主動回滾)。

由於撤銷的時候是按事務編號來的,所以不會撤銷別的事務的扣款。

現在我們解釋下為何要用當前時間的毫秒時間戳作為事務編號。

回到上面車主王某的場景。王某第一次用卡支付超時,於是他決定重試。該場景中,卡系統接收到同一筆訂單的兩次扣款事務以及一次撤銷事務。假如兩次事務都嘗試寫庫,那麼當後面的事務(不一定是第二次扣款的那個)嘗試寫庫時,肯定已經存在一條扣款記錄,此時後面這個事務要如何做?

  1. 用後者的事務編號替換掉前者的。
  2. 不做任何處理。

兩次事務的執行邏輯完全相同,產生的扣款記錄資料也是完全相同的——除了事務編號和扣款時間。

這裡的關鍵是,我們無法確定第一次扣款、第二次扣款、對第一次扣款的撤銷這三個請求寫庫的先後順序

所以,如果採用方案 1,替換事務編號,那麼當第二次的提交先寫庫時,後面事務(第一次提交的扣款請求)的替換會導致事務編號變成了待撤銷的那個,因而很可能會被撤銷掉,這就會導致使用者付的錢莫名其妙被退回了。

如果採用方案 2,不做任何處理,那麼當第一次的提交先寫庫時,事務編號就一直是待撤銷的那個,也會被撤銷掉,導致使用者付的錢莫名其妙被退回。

也就是說,無腦替換或不替換都是有問題的。

image-20220420220641797

第一次扣款事務先寫庫的情況

image-20220420221122078

第二次扣款事務先寫庫的情況

實際的業務場景是,對於同一筆訂單,無論發出多少次扣款請求,只允許一次成功,而且這次成功的扣款不能被誤撤銷。有很多方案可以實現這一點,不過有些方案需要增加額外表,有些則需要為同一筆訂單維護多條扣款記錄,這些都會帶來額外的複雜性。

我們採取事務序列號(毫秒時間戳)的方式來保證扣款事務的時序性,只允許後面覆蓋前面的,不允許反過來覆蓋。其基於這樣的事實:使用者如果對同一筆訂單發出多次扣款請求,那一定是前面扣款失敗了,因而業務系統會為前面那些失敗的扣款發出撤銷請求,所以只要保證僅允許後面覆蓋前面的事務,就不會造成誤撤銷(因為唯有最後那個扣款事務不會存在撤銷請求。感興趣的可參照上面的圖示推演一下)。

這裡說的事務是指一次扣款處理流,不是指資料庫事務。


所以呢?

我不想程式設計了,說真的,這麼個簡單的扣款場景就扯出這麼多么蛾子,太難了!

現實中比這複雜的場景多得是。

程式設計師到底是怎麼活下來的?

答案是,他們的一生是在沒完沒了的 bug 中度過的。

90% 以上的 bug 都是因為對失敗場景考慮不周導致。

如果把現實看成事件流,那麼事件流中的絕大多數節點都有不止一個出口分支(典型的是”正常“和”異常“)。2022 年 4 月 30 日晚,小林同學可能躺在床上玩遊戲,也可能躺在 ICU。

系統(特別是業務系統)是對現實世界業務的反映,每個節點同樣存在多種可能。

典型的業務流分析步驟是這樣的:

image-20220420225002673

幾乎所有的結點都要考慮失敗場景,而對於一些失敗場景的補償措施仍然可能失敗,如此遞迴,最終由自動補償系統(如漏單檢測/補償系統)或人工處理來兜底。

失敗的一大重要根源是分散式。

不要提什麼單體架構,做 web 開發的,自入行第一天起就面對分散式系統。

典型的分散式是前後端互動。自 ajax 出世以來,前後端介面互動成為常態,介面失敗也是每個程式設計師都會遇到的問題。很大部分的前後端互動失敗的場景沒有得到很好地處理(特別是超時),比如沒有去重,導致重複寫入資料。

自從微服務橫行以來,後端開發人員無不被分散式事務搞得焦頭爛額。業界也總結了些解決方案,比如兩階段提交、SAGA、TCC 等,但真正實現起來都不簡單,一個看似簡單的業務都會搞得很複雜。所以業界又搞了些現成的開源方案如 seata、DTM。


還有救嗎?

好訊息是,不是所有的系統都需要那麼高的可靠性保證,也不是所有的失敗場景都要做補償處理。

你可能是在一家初創公司,別說系統一分鐘不可用了,就是庫被刪了估計也沒事。

你做的系統可能只是給內部人員用用,凡是遇到失敗就拋異常,大不了人工去修復資料也是可以的。

這些情況下,很可能你並不需要去開發高可用系統,他們更講究效率,把正常流程碼出來基本就完事了。

講究點可用性的,稍微把程式碼寫好點,伺服器配置堆高點,業務流程設計上注意點,基本也能規避大部分祭天性的問題。

等你公司真的發展成 BAT 那種了,是真正拼刀工的時候,萬分之一概率的異常場景可能就會讓系統天天當機,賬戶天天少錢。那時候各種方案、架構、分析都要拿到桌面上來了。

所以,面向失敗程式設計誠然很難,但不代表你必須得天天面對著失敗抓耳撓腮,你需要評估你所負責的系統在成本、效率、健壯性上應做怎樣的取捨

相關文章