架構師眼中的CRUD:你真的會寫狀態更新嗎?update有學問!

聲明

下面的故事,記錄的技術要點,是真實發生在我身上的。爲了記錄下這些知識點,同時讓大家以一個放鬆的心態去進行閱讀,將其改編成詼諧幽默的小故事。

登場人物介紹:
H兄:我們的項目總監、技術負責人,老大哥的形象,一般也是問題的最後裁判員
C大:我們的架構師同學,技術涉獵面廣,考慮問題全面,鬼點子也非常地多
小L:剛入職公司的應屆畢業生開發,對技術有着無比的熱情~需要的是時間磨練以及經驗!
大V:高級JAVA開發,有着3年以上開發經驗,正在朝架構師方向努力中!
Fox桑: 我們的測試同學,有着豐富的功能以及性能測試經驗,總能在測試過程中發現很多蟲子哦~

特別說明

本次的故事,素材依然來自我的工作經歷。這次的故事,是一個發生在我們團隊內部的真實案例,對於剛進入移動支付領域的同學來說,會是一個非常好的啓發,讓我們一起共勉。

背景知識

想要了解這個故事,首先我們得從移動支付的一般性流程說起。這裏因爲涉及到一些公司資產,有一些保密內容,因此我將整個移動支付系統模型做了一個簡化。因此,在實際生產過程中,今天這個故事中講到的數據模型以及流程,僅供大家學習研究之用,生產上是不夠用的,切記哦!

一般來說,我們的移動支付中會有幾個對象,訂單、商品、支付流水。他們的關係,大體上是這樣的:
在這裏插入圖片描述
一筆訂單,會有多個商品信息與之關,換句話說,可能會有一個或者多個商品,合併到一個訂單內進行支付。
支付流水,可能也會存在一個或者多個,爲什麼這麼說呢?暫且不論有沒有可能一筆訂單分微信和支付寶兩個方式拆分支付。比如我們要設計一個優惠券系統的話,優惠券的抵扣金額,也應該生成一筆支付流水。否則的話,日終對賬就會出現訂單總額與流水總額對不起來的情況。同時,給用戶展示的訂單信息中,不展示優惠券抵扣部分金額,其實也是說不過去的。因此,一般在設計過程中,一筆訂單會有多個支付流水。
當然,我們這次的故事,跟這個模型基本上沒有關係,只是作爲一個前提背景,我先給大家做一個簡單的介紹。
在來述說我們的支付,一般來說,當下主流的支付渠道的支付方式,都是異步的。比如,我們來看一下支付寶的支付API:
在這裏插入圖片描述
很明顯,我們創建訂單以及支付流水應該是在第1步就完成,那麼最終在支付寶完成支付,應當是在第7步才收到支付寶的異步回調通知。
毫無疑問,整個步驟是異步的。那麼用我們的時序圖把他畫出來,應該是這個樣子的:
在這裏插入圖片描述
再次聲明:上面的數據模型以及流程,我是做了大量簡化的,中間有很多異常處理流程的,僅供大家學習研究之用,生產上是不夠用的,切記哦!如果真的很想了解這一塊應用的,可以私信我,我們可以私下交流。

好,如果你看懂了上面的這些流程以及模型。那麼你可以開始看我們今天的故事了。

開發任務來了

這天,產品爸爸拿着macbook air筆記本電腦,英姿颯爽地走了過來:"H兄,你給排個期唄~看看咱們之前討論的app商城支付,啥時候落地啊?“。
”那就現在?“H兄很爽快,因爲這個需求已經被拖了半個月了,再不給個說法,估計要被產品爸爸吊打了。
需求很簡答, 就是做個簡單的app商城支付系統,因爲之前的商城商品都是積分兌換的,現在開始要增加用錢購買的功能。
根據我們剛纔說的一般流程,其實後臺只要開發下單和支付兩部分功能就行了。
在這裏插入圖片描述
然後,一番激烈的需求評審後。H兄直接就拍板了。因爲與支付渠道對接,需要一定的開發經驗,不太時候新手直接上,所以這部分工作就安排給了大V。
至於訂單系統嘛,邏輯比較簡答, 就是下單,然後維護訂單狀態,就交給我們的小L同學啦。

不就是流水狀態更新嘛,看我來搞定

小L拿到需求之後,稍微想了一下整個業務流程,還認真的畫了流程圖,如下:
簡要流程
業務流程很簡單嘛,小L也沒多想,就開始咔咔地搞了,當開發到支付接口的時候,小L發現,大V的支付接口,其實是一個異步接口。於是小L將原來寫好的處理支付結果的代碼,放到了接收大V支付回調的MQ的消費者代碼中。

一般來說,一個異步處理機制,分爲請求提交,回調處理和主動查詢三部分。這個故事裏面,我們主要關注請求提交和回調處理,對主動查詢,大家只要做到心中有數,自己去實現的時候不能只依賴於下游系統的回調機制,還應當有自己的主動查詢機制。

沒過幾天,訂單系統就搞定了。當小L跑完自己的測試用例後, 大V那邊,也搞完了。兩個人馬上就進行了聯調,結果非常順利,沒過兩天就把所有他們能想到的點都測完了。於是,他們把代碼做了最後一次提交,打上tag之後,就讓Fox桑去做整體的測試了。

功能測試驗收通過

fox桑對首先按照他倆給的部署文檔,在測試環境把系統一點一點搭建好。然後對照着產品需求,以及前期整理好的測試用例,整體跑了一遍,發現功能上並沒有什麼問題。
下單,支付,訂單成功,功能ok。
下單,取消支付,訂單關閉,功能ok
下單,餘額不足,訂單關閉,功能ok
顯然,fox桑對於這次的測試結果比較滿意。於是,就開始準備進入壓力測試了。

壓測開始,然後。。。

壓測開始時,fox桑,換上了大V給他準備的支付擋板(擋板一定會返回支付成功)。

這裏解釋一下,什麼叫做擋板。
在我們做壓測的時候,數據肯定是隨意生成的。就上面的例子而言,做壓測的時候,顯然是不可能直接去向支付渠道發起支付的。也就是說,我們要在支付系統與支付渠道中間,添加一個支付擋板,用來給支付系統模擬支付系統的返回(支付系統以爲支付成功了,實際上並沒有去發起支付)以模擬整個鏈路。鏈路看起來就像是下面這個樣子的:在這裏插入圖片描述

壓測的成績也相對來說可以,有400TPS,對於目前的每天幾萬單的系統體量來說,fox桑覺得已經完全足夠了。
正當fox桑寫完壓測報告,準備清數據打完收工的時候,幾條看起來很奇怪的數據,引起了fox桑的注意,慢慢地,fox桑皺起了眉頭,發現事情好像不太對。

本地無法復現,大V居然也搞不定了

fox桑發現了什麼問題呢?原來,訂單的數據庫記錄中,有不少是待支付的!這說不通啊,擋板返回的都是支付成功,怎麼可能會出現沒有支付的訂單呢?
fox桑馬上叫來了大V,讓大V來找找這其中可能出現的原因。大V看了下兩邊的數據之後,發現支付流水錶中的數據,其實是正確的,也就是說,支付系統這邊的處理邏輯是OK的,回調給訂單的消息應該也是支付成功。但是實際上數據在訂單表中的狀態,卻被更新成了待支付。
大V馬上叫來了小L,一起看一下這其中的問題。他們仔仔細細地看了小L寫的訂單回調處理邏輯,以及程序運行的日誌。
在這裏插入圖片描述
見了鬼了,payStatus=2,支付成功,最後update返回的結果也是1,說明數據成功更新了啊。但是爲啥最後在數據庫裏看到的payStatus = 1 !!
這完全刷新了小L的三觀!!寫了這麼久的update,突然之間,感覺是如此的陌生!當一個程序員真的好難啊!連update都不會寫了,以後這漫漫長路可怎麼走下去啊!
小L和大V之後又找了幾個小時,又是拿數據本地模擬,又是在開發環境模擬,但始終無法復現這個問題。
他倆實在是沒辦法了,於是叫來了C大幫忙看看,或許C大能有辦法呢,誰讓他鬼點子多呢?
C大看了一眼程序的日誌,然後又看了小L更新數據用的SQL:

update t_trade_record set payStatus = 2 where pay_id = 'xxx' and update_time = 'xxx'

這裏說明一下,爲啥更新數據的時候,這裏小L加上了update_time的條件,主要是爲了防止多個服務同時更新數據的時候,可以檢測出來。如果其他服務更新了,那麼update_time就會增加,update就返回0了,程序就可以做出對應的處理了。

最後去看了一眼出問題的數據庫的數據,然後心中似乎有了答案,但是還不能完全確定。
甩下一句:“我大概知道問題出在那裏了,我去找運維要個東西來證實我的想法!”。

原來binlog還能這麼玩

C大找運維去要什麼了呢?過了半個小時,C大回來了,拿着一個文件mysql-bin.000001。
原來C大去找運維拿mysql數據庫的binlog去了,目的就是爲了去查找壓測期間,數據更新的記錄。
C大將binlog拷貝到本地,然後用本地安裝的mysql數據庫中的mysqlbinlog組件。嫺熟地敲下這條命令:

mysqlbinlog --base64-output=decode-rows -v -d xxx --start-datetime='2020-03-10 14:33:06' --stop-datetime='2018-03-20 14:34:07' mysql-bin.000001  > 1.sql

然後,就得到了一個1.sql的文件,裏面記錄了標識爲xxx的數據庫,從2020-03-10 14:33:06至2018-03-20 14:34:07的所有數據更新記錄。
在這裏插入圖片描述
根據小L提供的訂單編號,C大很快就找到了這期間,這條數據的所有操作。
數據的payStatus變更路徑如下: insert(0) -> update(2) -> update(1)

C大說:“明白了吧,這就是你日誌提示更新成功,但最後結果是1的原因!數據確實在中間被更新成了2,但是最後又被更新成了1。”

原來,由於加了支付擋板,再加上壓測數據的密集程度增加,使得CPU壓力升高,原本能在幾毫秒的過程中完成的提交支付後的更新,被拉到支付回調更新以後才執行!

在這裏插入圖片描述
但是,小L不是對update_time做時間判斷了嗎?爲什麼還是會有問題呢?

原因就在於,update_time只是精確到1秒,如果更新時序問題發生在1秒鐘之內,那麼這種寫法,也就無法避免出現這個問題了。
就好比我們在使用CAS進行多線程更新的時候,也無法避免ABA的問題。

知道了問題以後,小L對自己的SQL進行了修改,這個問題就得以解決了。只有當訂單記錄是已創建的時候,才更新成待支付,其他狀態說明狀態已經發生變更,則不進行處理了。

update t_trade_record set payStatus = 1 where pay_id = 'xxx' and update_time = 'xxx' and pay_status = 0

如何寫好狀態更新流程,其實有套路

有很多辦法,能夠幫助我們管理好數據狀態的變更。防止一些極端情況下出現的數據狀態混亂的問題。

  1. 是否只需要保證數據的最終一致性。

在分佈式系統中,有一個著名的CAP原則,我們往往會選擇高可用以及分區容錯來提高系統的吞吐量和可用性,但需要犧牲系統數據的強一致性,取而代之使用數據的最終一致性來保證系統的最終結果正確。

  1. 搞清業務邏輯,然後針對需要變更狀態的數據,繪製一下各個狀態的流轉圖。
    在這裏插入圖片描述

在圖中,我們就能清晰地看到有狀態的業務數據模型,在各個條件下的狀態流轉。這裏有個套路:“與結束有直接箭頭關聯的狀態,我們稱之爲最終狀態,數據一旦進入最終狀態,就不應該再被變更。” 這就能很好地指導我們去寫這個業務數據的update,對於這種已經是終態的數據,我們在寫SQL更新的時候,就能夠寫形如 stat <> 1 and stat <> 2…,來防止這些進入最終狀態的數據,因爲時序問題,又被更新成了中間狀態,從而保證了數據的最終一致性!

  1. 業務邏輯複雜,狀態非常多的時候,我們在寫代碼的時候,可以考慮使用狀態機模式。

總結

上面這個例子,很好地解釋了爲什麼一個業務系統運行了很久都沒有出現問題,也很少發佈版本,但是線上環境再某一天突然就出現了大量的問題。
很多問題,其實都是隱藏在高併發下,在一般的低負載情形下,是很難復現的。因此,有時候我們去做性能測試,並不單單是因爲業務場景的併發需求有多高。而是有助於我們去評估系統的容量,對系統中的配置參數調優以及發現一些低負載情況下無法發現的程序bug。
今天的故事就到這裏,希望大家能夠有所收穫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章