記一次支付系統(tǒng)的設(shè)計體驗

轉(zhuǎn)載:記一次支付系統(tǒng)的設(shè)計體驗

0橡淑、寫在前面的話

支付系統(tǒng)是一個老生常談的話題,我也相信每個公司開發(fā)的支付系統(tǒng)不盡相同蕊梧,因為業(yè)務(wù)形態(tài)并不太一樣霞赫。

在此,我并不想講一個大而全的支付系統(tǒng)肥矢,個人也沒有能力去闡述端衰。

在我看來,一個支付系統(tǒng)應(yīng)提供支付渠道管理,支付網(wǎng)關(guān)旅东,基本支付/退款/轉(zhuǎn)賬能力惕味,支付記錄/明細,及其相關(guān)的監(jiān)控運維系統(tǒng)玉锌。

至于所謂的賬務(wù)清算,對賬功能疟羹,賬戶體系主守,風(fēng)控體系,現(xiàn)金流量管理榄融,應(yīng)該納入到「財務(wù)系統(tǒng)」参淫,大概是大佬們談?wù)摰亩际菑V義的「支付系統(tǒng)」吧!

而我今天只談狹義的「支付系統(tǒng)」愧杯。

目前涎才,支付的流程包含了三大部分:發(fā)起支付,發(fā)起退款力九,接收回調(diào)耍铜。

考慮到吞吐量的影響,將原先同步的編程方式改為異步的編程方式跌前,不出意外的話棕兼,將會使用到Java8的ExecutorService和CompletableFuture。

此外抵乓,還用到了公司其他的現(xiàn)成的東西:RabbitMQ伴挚,Redis,MongoDB灾炭。

我是打算將這套支付系統(tǒng)設(shè)計成與具體業(yè)務(wù)無關(guān)茎芋,可以納入到公司的公共平臺系統(tǒng)中。

具體是如何做到的蜈出,請接著往下讀田弥。

1、發(fā)起支付

這一部分講述的是客戶端和服務(wù)端如何配合完成一次支付請求掏缎。服務(wù)端必須要有一個意識皱蹦,最終發(fā)起支付的還是客戶端,服務(wù)端提供一些必要的參數(shù)配置信息眷蜈。

發(fā)起支付的架構(gòu)圖如下所示:

發(fā)起支付架構(gòu)圖

跟著標(biāo)注的序號沪哺,可以跟蹤到一個支付請求是如何發(fā)起的(Sequence Diagram就免了),流程描述如下:

Submit a pay task酌儒,當(dāng)客戶端需要發(fā)起支付的時候辜妓,起始是向支付任務(wù)隊列里面加入了一個新的支付任務(wù),這個過程是異步實現(xiàn)的。先根據(jù)客戶端提交的參數(shù)籍滴,構(gòu)造好一個新的支付任務(wù)酪夷;

Offer a task,開啟一個異步任務(wù)孽惰,做的事情就是向MQ中添加一個新的支付任務(wù)晚岭,等待被消費;

Pay task description勋功,一旦異步任務(wù)被成功創(chuàng)建坦报,將會把第一步構(gòu)造好的支付任務(wù)信息直接return給客戶端;

Poll a task狂鞋,與此同時片择,支付任務(wù)的消費者將新的支付任務(wù)poll下來進行執(zhí)行;

Send a pay request骚揍,這一步需要根據(jù)實際情況而定字管。并不是所有的支付請求都要先經(jīng)過第三方支付平臺,比如支付寶信不;而對于微信嘲叔,則還需要憑支付參數(shù)申請一個prepay_id,再經(jīng)由客戶端發(fā)起支付浑塞;

Response借跪,沒什么好說的,第三方渠道返回的支付必要參數(shù)酌壕;

Cache result掏愁,至此,一個支付任務(wù)可以算是完成了卵牍,可以將任務(wù)的執(zhí)行結(jié)果(無論成功與否)緩存在Redis中果港,隨時等待客戶端的回訪;

Query result糊昙,客戶端在提交支付任務(wù)后辛掠,間隔一定時間后(建議2~3s),發(fā)起一個結(jié)果查詢的請求释牺;

Query萝衩,直接進Redis查找結(jié)果;

Synchronize没咙,這是一個異步的操作猩谊,將支付任務(wù)的執(zhí)行結(jié)果“順便”同步到MongoDB中,并刪除Redis中緩存的任務(wù)執(zhí)行結(jié)果祭刚。持久化到MongoDB主要是為后續(xù)的容錯牌捷,重試墙牌,數(shù)據(jù)分析等提供落地的數(shù)據(jù)源;

Return暗甥,由Redis返回給應(yīng)用服務(wù)器喜滨;

Return payment,應(yīng)用服務(wù)器再將最終的支付對象返回給客戶端撤防。

讓我們更深入一點虽风,我們來看三張Class Diagram:

① 先說說支付任務(wù)(PayTask)部分。PayTask和Payment兩個都是MongoDB中的Document對象寄月,但在任務(wù)執(zhí)行期間焰情,PayTask是用Redis進行緩存的,方便客戶端隨時發(fā)起Query剥懒,任務(wù)執(zhí)行成功后,會生成Payment對象合敦,最終PayTask和Payment都會持久化到MongoDB中初橘。在PayService中,有對支付任務(wù)的一些基本操作充岛,包括任務(wù)提交保檐,取消,重試崔梗,構(gòu)建等等夜只。

② 再說說任務(wù)的執(zhí)行(runner)。這部分和RabbitMQ緊密相關(guān)蒜魄,一旦一個支付任務(wù)形成了扔亥,就會放入任務(wù)執(zhí)行隊列中,由消費者取出執(zhí)行谈为。在TaskRunner中旅挤,有兩個基本的接口方法:run(task)、retry(task)伞鲫,分別是執(zhí)行任務(wù)和重試任務(wù)粘茄。在AbstractPayTaskRunner中已經(jīng)封裝好了這兩個方法,繼承AbstractPayTaskRunner需要實現(xiàn)doTask方法秕脓,從返回值可以看出柒瓣,這個過程是異步化的。關(guān)于Retry機制吠架,用戶可以設(shè)置重試與否芙贫,一旦設(shè)置了TaskInfo.needRetry=true(不出意外,默認就是允許重試)诵肛,就啟用了Retry機制屹培。還可以設(shè)置重試的次數(shù)(TaskInfo.retryTimes)默穴,默認三次,分別間隔1s褪秀,2s蓄诽,3s,間隔時間以公差為1的等差數(shù)列組成媒吗。當(dāng)然不會讓用戶無限重試仑氛,系統(tǒng)內(nèi)置有一個最大重試次數(shù),最大重試次數(shù)內(nèi)置為5次闸英。

為什么是5次锯岖?

你感受一下,1s甫何,2s出吹,3s,4s辙喂,5s捶牢,整個請求鏈條就被拉長到了15s,這對客戶端簡直就是災(zāi)難了N『摹秋麸!

③ 接著說一下支付渠道(PayChannel)。這部分設(shè)計與具體的支付渠道對接聯(lián)系比較緊密了炬太,包括支付參數(shù)配置灸蟆,支付參數(shù)處理,簽名/驗簽等等亲族。

④ 最后解釋一下支付參數(shù)(PayParams)炒考。

大部分還是能看懂的,我解釋幾個關(guān)鍵的property:

1) appId霎迫,這是為了區(qū)分不同的產(chǎn)品所設(shè)置的∑毖現(xiàn)實中,很有可能一個產(chǎn)品會申請與之對應(yīng)的支付渠道女气,然后在支付平臺中創(chuàng)建應(yīng)用杏慰,設(shè)置好對應(yīng)的支付參數(shù),系統(tǒng)將會分配一個appId炼鞠,憑此值就可以直接定位到各個支付參數(shù)缘滥。如果想再更完善一點,可以再區(qū)分一下測試環(huán)境和正式環(huán)境谒主;

2) amount朝扼,這里代表的是支付金額的意思,但是這套支付系統(tǒng)的金額單位統(tǒng)一設(shè)置成 人民幣【分】霎肯;

3) metadata擎颖,理論上榛斯,元數(shù)據(jù)這個字段沒啥限制,要是非要說有限制搂捧,那么就是字段長度了——5000個字符驮俗。這個字段的想象空間還是很大的:用于填寫豐富的交易相關(guān)信息,用于在增長智能系統(tǒng)產(chǎn)品中進行深入商業(yè)分析允跑。包括交易行為多維分析王凑、人群分析、產(chǎn)品轉(zhuǎn)化路徑聋丝、個性化推薦索烹、智能補貼、定向推送等弱睦“傩眨看產(chǎn)品經(jīng)理要怎么玩了;

5) credential况木,這個字段非常非常重要瓣戚,其中裝載的就是客戶端最終發(fā)起支付請求的憑證,會作為Payment對象的一部分返回給客戶端焦读;

MongoDB的document字段設(shè)計

解釋一下為什么要用MongoDB:

個人覺得,如果這個通用服務(wù)要得到較好的推廣(甚至是開源)舱权,用MySQL等關(guān)系型數(shù)據(jù)庫是不二之選矗晃,因為一個完整實用的系統(tǒng),必然是少不了數(shù)據(jù)庫的宴倍,如果一旦用了一些非傳統(tǒng)的東西张症,必然會提高一部分人的對接成本。有的人一看不符合團隊的技術(shù)棧鸵贬,直接就不考慮了俗他。

為什么我還是要用MongoDB呢?

① 團隊的技術(shù)棧里面有這么個東西阔逼,不用白不用兆衅;

② MongoDB普及程度實在是不要太高,還不用上點NoSQL的東西嗜浮,感覺自己分分鐘被OUT掉了羡亩;

③ 要存儲的數(shù)據(jù)結(jié)構(gòu)需要支持動態(tài)擴展的特性,我就看中MongoDB的靈活性危融,如下是要存儲的數(shù)據(jù)結(jié)構(gòu):

document_name = “Payment”

{"payId":"pay_Oyvrf9e9S1","method":"yoogurt.taxi.pay","version":"v1.0","timestamp":1473044885,"created":1473042835,"paid":false,"appId":"app_iPGa98ab9ev","channel":"wx","orderNo":"20161899798416","clientIp":"192.168.18.189","amount":10000,"subject":"充值訂單-¥100.0","body":"充值訂單-¥100.0","paidTime":null,"transactionNo":"","metadata":{"user_id":"170204469176","phone_number":"13811234567"},"credential":{"appId":"wx4932d1311e","partnerId":"1269774001","prepayId":"wx2016099","nonceStr":"1e99d8fe92ba","timeStamp":"1473042837","packageValue":"Sign=WXPay","sign":"1CECCEDEBE"},"extra":{},"statusCode":"","message":"","description":""}

其中畏铆,metadata,credential吉殃,extra這類字段辞居,并沒有一個特別固定的規(guī)范楷怒,用MySQL要冗余一下字段才行,或者針對每個渠道去分表瓦灶,想想都覺得煩鸠删!

MySQL

因為這套支付系統(tǒng)被設(shè)計成為支持多應(yīng)用,多渠道倚搬,所以此處用到MySQL存放一些應(yīng)用配置冶共。 E-R圖免了,直接上數(shù)據(jù)庫表結(jié)構(gòu):

① pay_channel:可供接入的支付渠道

② app_settings:支付應(yīng)用信息

③ app_channel:應(yīng)用已接入的支付渠道

④ alipay_settings:支付寶參數(shù)設(shè)置

⑤ wx_settings:微信app支付參數(shù)設(shè)置

如果想要增加支付渠道每界,只需要添加一張對應(yīng)的支付參數(shù)設(shè)置表

2捅僵、發(fā)起退款

不出意外,客戶在平臺的每筆訂單都可以發(fā)起退款眨层,而且還能分批退庙楚,也就是同一個訂單,可以多次發(fā)起退款申請趴樱,只要保證退款總額不超出實付總額馒闷。 架構(gòu)圖如下所示:

發(fā)起退款架構(gòu)圖

跟發(fā)起支付請求的流程有很多相似之處,不再一一解釋了叁征,兩個關(guān)鍵的地方說明一下:

客戶端發(fā)起退款請求的時候纳账,需要攜帶payId,就是支付對象的id捺疼。這就意味著疏虫,支付系統(tǒng)的調(diào)用方需要維護payId與orderNo的對應(yīng)關(guān)系,務(wù)必在客戶端發(fā)起退款請求之前啤呼,獲取到正確的payId卧秘;

承接上一步,這才有了圖中的第5官扣、6個步驟翅敌,從MongoDB中查詢之前的支付對象。第三方渠道通常會要求在退款的時候指定一個退款單號惕蹄,因為一筆訂單可以分多次退款蚯涮,所以不建議將訂單號作為退款單號使用。這里的退款單號由支付系統(tǒng)生成并維護卖陵。

這部分的執(zhí)行流程和之前類似恋昼,客戶端發(fā)起退款請求,形成一個退款任務(wù)(RefundTask)赶促,放入任務(wù)隊列中液肌,消費者取出并執(zhí)行各自的業(yè)務(wù)邏輯,退款成功會生成Refund對象鸥滨,并持久化到MongoDB中嗦哆。

MongoDB

document_name = "Refund"

{"payId":"pay_vfvS0m1","method":"yoogurt.taxi.pay","version":"v1.0","timestamp":1473044885,"created":1473042835,"refundId":"refund_kmrf9wSr1em","appId":"app_iGa8abLe9ev","orderNo":"20161899798416","clientIp":"192.168.18.189","amount":10000,"succeedTime":1473150835,"transactionNo":"64059968740554","refundStatus":"success","message":"","metadata":{"user_id":"170204469176","phone_number":"13811234567"},"description":""}

3谤祖、接收回調(diào)

這部分功能被設(shè)計成了事件驅(qū)動類型,所以webhooks當(dāng)仁不讓老速。

因為各個渠道的回調(diào)內(nèi)容都不盡相同粥喜,所以這部分設(shè)計會按支付渠道切分。

架構(gòu)圖如下:

處理回調(diào)事件

用戶在支付完畢后橘券,第三方支付渠道通過發(fā)起支付時指定的回調(diào)地址對商戶進行支付成功的異步通知额湘。

這部分的執(zhí)行流程和之前類似,在各自的PayChannel中解析好回調(diào)參數(shù)旁舰,形成一個回調(diào)事件(Event)锋华,并持久化到MongoDB中,然后再生成一個回調(diào)任務(wù)(EventTask)箭窜,放入任務(wù)隊列中毯焕,消費者取出并執(zhí)行各自的業(yè)務(wù)邏輯,這里的消費者就是上游的業(yè)務(wù)服務(wù)系統(tǒng)磺樱。

MongoDB

document_name = “Event”

{"eventId":"evt_la06Co7wq","created":1427555016,"eventType":"pay.succeeded","data":{"payId":"pay_OvP88CSm1","method":"yoogurt.taxi.pay","version":"v1.0","timestamp":1473044885,"created":1473042835,"paid":false,"appId":"app_iGa9aLe9ev","channel":"wx","orderNo":"20161899798416","clientIp":"192.168.18.189","amount":10000,"subject":"用戶充值-¥100.0","body":"充值訂單-¥100.0","paidTime":null,"transactionNo":"","statusCode":"","message":"","metadata":{"user_id":"170204469176","phone_number":"13811234567"},"credential":{"appId":"wx4932b511e","partnerId":"1269774001","prepayId":"wx201609051039","nonceStr":"1e9d8fddad","timeStamp":"1473042837","packageValue":"Sign=WXPay","sign":"1C0K3C95AKB"},"extra":{},"description":""},"retryTimes":0}

特別說明一下data字段:

如果是支付成功事件纳猫,則返回對應(yīng)的Payment對象;

如果是退款成功時間竹捉,則返回對應(yīng)的Refund對象

總結(jié)

可能有的讀者通篇看下來芜辕,覺得這并不是什么支付系統(tǒng),僅僅是對接了一下第三方支付渠道块差,勉強算是支付渠道網(wǎng)關(guān)吧侵续!

如果你有這種感受彪蓬,我也是非常認同的饵撑。

個人認為這篇文章還是比較接地氣的耍共,沒有太多理論的東西,看到的更多是實現(xiàn)層面的內(nèi)容起趾,就差貼代碼了!

坦白地講警儒,第三方支付渠道對接了不少次训裆,卻并沒有像現(xiàn)在這樣系統(tǒng)地去設(shè)計,去總結(jié)蜀铲。

我用過幾次ping++的產(chǎn)品边琉,在企業(yè)級聚合支付領(lǐng)域,ping++算是業(yè)界領(lǐng)先者了记劝,所以变姨,我的一些數(shù)據(jù)結(jié)構(gòu)設(shè)計還是與其有幾分相似的,ping++以后也會是我模仿和比較的對象厌丑。

這次也是我的支付系統(tǒng)實現(xiàn)所邁出的第一步定欧,今后也會不斷豐富渔呵,完善我自己的支付系統(tǒng)。

希望對你有所幫助砍鸠!

THANKS扩氢!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市爷辱,隨后出現(xiàn)的幾起案子录豺,更是在濱河造成了極大的恐慌,老刑警劉巖饭弓,帶你破解...
    沈念sama閱讀 222,378評論 6 516
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件双饥,死亡現(xiàn)場離奇詭異,居然都是意外死亡示启,警方通過查閱死者的電腦和手機兢哭,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,970評論 3 399
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來夫嗓,“玉大人迟螺,你說我怎么就攤上這事∩峥В” “怎么了矩父?”我有些...
    開封第一講書人閱讀 168,983評論 0 362
  • 文/不壞的土叔 我叫張陵,是天一觀的道長排霉。 經(jīng)常有香客問我窍株,道長,這世上最難降的妖魔是什么攻柠? 我笑而不...
    開封第一講書人閱讀 59,938評論 1 299
  • 正文 為了忘掉前任球订,我火速辦了婚禮,結(jié)果婚禮上瑰钮,老公的妹妹穿的比我還像新娘冒滩。我一直安慰自己,他們只是感情好浪谴,可當(dāng)我...
    茶點故事閱讀 68,955評論 6 398
  • 文/花漫 我一把揭開白布开睡。 她就那樣靜靜地躺著,像睡著了一般苟耻。 火紅的嫁衣襯著肌膚如雪篇恒。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,549評論 1 312
  • 那天凶杖,我揣著相機與錄音胁艰,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛蝗茁,可吹牛的內(nèi)容都是我干的醋虏。 我是一名探鬼主播,決...
    沈念sama閱讀 41,063評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼哮翘,長吁一口氣:“原來是場噩夢啊……” “哼颈嚼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起饭寺,我...
    開封第一講書人閱讀 39,991評論 0 277
  • 序言:老撾萬榮一對情侶失蹤阻课,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后艰匙,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體限煞,經(jīng)...
    沈念sama閱讀 46,522評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,604評論 3 342
  • 正文 我和宋清朗相戀三年员凝,在試婚紗的時候發(fā)現(xiàn)自己被綠了署驻。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,742評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡健霹,死狀恐怖旺上,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情糖埋,我是刑警寧澤宣吱,帶...
    沈念sama閱讀 36,413評論 5 351
  • 正文 年R本政府宣布,位于F島的核電站瞳别,受9級特大地震影響征候,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜祟敛,卻給世界環(huán)境...
    茶點故事閱讀 42,094評論 3 335
  • 文/蒙蒙 一疤坝、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧馆铁,春花似錦跑揉、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,572評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽衣撬。三九已至乖订,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間具练,已是汗流浹背乍构。 一陣腳步聲響...
    開封第一講書人閱讀 33,671評論 1 274
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人哥遮。 一個月前我還...
    沈念sama閱讀 49,159評論 3 378
  • 正文 我出身青樓岂丘,卻偏偏與公主長得像,于是被迫代替她去往敵國和親眠饮。 傳聞我的和親對象是個殘疾皇子奥帘,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,747評論 2 361

推薦閱讀更多精彩內(nèi)容