轉(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扩氢!