记一次支付系统的设计体验
0、寫在前面的話
支付系統(tǒng)是一個(gè)老生常談的話題,我也相信每個(gè)公司開發(fā)的支付系統(tǒng)不盡相同,因?yàn)闃I(yè)務(wù)形態(tài)并不太一樣。
在此,我并不想講一個(gè)大而全的支付系統(tǒng),個(gè)人也沒(méi)有能力去闡述。
在我看來(lái),一個(gè)支付系統(tǒng)應(yīng)提供支付渠道管理,支付網(wǎng)關(guān),基本支付/退款/轉(zhuǎn)賬能力,支付記錄/明細(xì),及其相關(guān)的監(jiān)控運(yùn)維系統(tǒng)。
至于所謂的賬務(wù)清算,對(duì)賬功能,賬戶體系,風(fēng)控體系,現(xiàn)金流量管理,應(yīng)該納入到「財(cái)務(wù)系統(tǒng)」,大概是大佬們談?wù)摰亩际菑V義的「支付系統(tǒng)」吧!
而我今天只談狹義的「支付系統(tǒng)」。
目前,支付的流程包含了三大部分:發(fā)起支付,發(fā)起退款,接收回調(diào)。
考慮到吞吐量的影響,將原先同步的編程方式改為異步的編程方式,不出意外的話,將會(huì)使用到Java8的ExecutorService和CompletableFuture。
此外,還用到了公司其他的現(xiàn)成的東西:RabbitMQ,Redis,MongoDB。
我是打算將這套支付系統(tǒng)設(shè)計(jì)成與具體業(yè)務(wù)無(wú)關(guān),可以納入到公司的公共平臺(tái)系統(tǒng)中。
具體是如何做到的,請(qǐng)接著往下讀。
1、發(fā)起支付
這一部分講述的是客戶端和服務(wù)端如何配合完成一次支付請(qǐng)求。服務(wù)端必須要有一個(gè)意識(shí),最終發(fā)起支付的還是客戶端,服務(wù)端提供一些必要的參數(shù)配置信息。
發(fā)起支付的架構(gòu)圖如下所示:
跟著標(biāo)注的序號(hào),可以跟蹤到一個(gè)支付請(qǐng)求是如何發(fā)起的(Sequence Diagram就免了),流程描述如下:
讓我們更深入一點(diǎn),我們來(lái)看三張Class Diagram:
① 先說(shuō)說(shuō)支付任務(wù)(PayTask)部分。PayTask和Payment兩個(gè)都是MongoDB中的Document對(duì)象,但在任務(wù)執(zhí)行期間,PayTask是用Redis進(jìn)行緩存的,方便客戶端隨時(shí)發(fā)起Query,任務(wù)執(zhí)行成功后,會(huì)生成Payment對(duì)象,最終PayTask和Payment都會(huì)持久化到MongoDB中。在PayService中,有對(duì)支付任務(wù)的一些基本操作,包括任務(wù)提交,取消,重試,構(gòu)建等等。
② 再說(shuō)說(shuō)任務(wù)的執(zhí)行(runner)。這部分和RabbitMQ緊密相關(guān),一旦一個(gè)支付任務(wù)形成了,就會(huì)放入任務(wù)執(zhí)行隊(duì)列中,由消費(fèi)者取出執(zhí)行。在TaskRunner中,有兩個(gè)基本的接口方法:run(task)、retry(task),分別是執(zhí)行任務(wù)和重試任務(wù)。在AbstractPayTaskRunner中已經(jīng)封裝好了這兩個(gè)方法,繼承AbstractPayTaskRunner需要實(shí)現(xiàn)doTask方法,從返回值可以看出,這個(gè)過(guò)程是異步化的。關(guān)于Retry機(jī)制,用戶可以設(shè)置重試與否,一旦設(shè)置了TaskInfo.needRetry=true(不出意外,默認(rèn)就是允許重試),就啟用了Retry機(jī)制。還可以設(shè)置重試的次數(shù)(TaskInfo.retryTimes),默認(rèn)三次,分別間隔1s,2s,3s,間隔時(shí)間以公差為1的等差數(shù)列組成。當(dāng)然不會(huì)讓用戶無(wú)限重試,系統(tǒng)內(nèi)置有一個(gè)最大重試次數(shù),最大重試次數(shù)內(nèi)置為5次。
為什么是5次?
你感受一下,1s,2s,3s,4s,5s,整個(gè)請(qǐng)求鏈條就被拉長(zhǎng)到了15s,這對(duì)客戶端簡(jiǎn)直就是災(zāi)難了!!
③ 接著說(shuō)一下支付渠道(PayChannel)。這部分設(shè)計(jì)與具體的支付渠道對(duì)接聯(lián)系比較緊密了,包括支付參數(shù)配置,支付參數(shù)處理,簽名/驗(yàn)簽等等。
④ 最后解釋一下支付參數(shù)(PayParams)。
大部分還是能看懂的,我解釋幾個(gè)關(guān)鍵的property:
1) appId,這是為了區(qū)分不同的產(chǎn)品所設(shè)置的。現(xiàn)實(shí)中,很有可能一個(gè)產(chǎn)品會(huì)申請(qǐng)與之對(duì)應(yīng)的支付渠道,然后在支付平臺(tái)中創(chuàng)建應(yīng)用,設(shè)置好對(duì)應(yīng)的支付參數(shù),系統(tǒng)將會(huì)分配一個(gè)appId,憑此值就可以直接定位到各個(gè)支付參數(shù)。如果想再更完善一點(diǎn),可以再區(qū)分一下測(cè)試環(huán)境和正式環(huán)境;
2) amount,這里代表的是支付金額的意思,但是這套支付系統(tǒng)的金額單位統(tǒng)一設(shè)置成 人民幣【分】;
3) metadata,理論上,元數(shù)據(jù)這個(gè)字段沒(méi)啥限制,要是非要說(shuō)有限制,那么就是字段長(zhǎng)度了——5000個(gè)字符。這個(gè)字段的想象空間還是很大的:用于填寫豐富的交易相關(guān)信息,用于在增長(zhǎng)智能系統(tǒng)產(chǎn)品中進(jìn)行深入商業(yè)分析。包括交易行為多維分析、人群分析、產(chǎn)品轉(zhuǎn)化路徑、個(gè)性化推薦、智能補(bǔ)貼、定向推送等。看產(chǎn)品經(jīng)理要怎么玩了;
5) credential,這個(gè)字段非常非常重要,其中裝載的就是客戶端最終發(fā)起支付請(qǐng)求的憑證,會(huì)作為Payment對(duì)象的一部分返回給客戶端;
MongoDB的document字段設(shè)計(jì)
解釋一下為什么要用MongoDB:
個(gè)人覺(jué)得,如果這個(gè)通用服務(wù)要得到較好的推廣(甚至是開源),用MySQL等關(guān)系型數(shù)據(jù)庫(kù)是不二之選,因?yàn)橐粋€(gè)完整實(shí)用的系統(tǒng),必然是少不了數(shù)據(jù)庫(kù)的,如果一旦用了一些非傳統(tǒng)的東西,必然會(huì)提高一部分人的對(duì)接成本。有的人一看不符合團(tuán)隊(duì)的技術(shù)棧,直接就不考慮了。
為什么我還是要用MongoDB呢?
① 團(tuán)隊(duì)的技術(shù)棧里面有這么個(gè)東西,不用白不用;
② MongoDB普及程度實(shí)在是不要太高,還不用上點(diǎn)NoSQL的東西,感覺(jué)自己分分鐘被OUT掉了;
③ 要存儲(chǔ)的數(shù)據(jù)結(jié)構(gòu)需要支持動(dòng)態(tài)擴(kuò)展的特性,我就看中MongoDB的靈活性,如下是要存儲(chǔ)的數(shù)據(jù)結(jié)構(gòu):
document_name = “Payment”
{"payId": "pay_Oyvrf9vP880STm1e9G5CSCm1","method": "yoogurt.taxi.pay","version": "v1.0","timestamp": 1473044885,"created": 1473042835,"paid": false,"appId": "app_KiPGa98abDmLe9ev","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": "wx4932b5159d18311e","partnerId": "1269774001","prepayId": "wx201609051033574da13955420883291539","nonceStr": "1e99d8ffdde926ed9cbdf4d2e614abad","timeStamp": "1473042837","packageValue": "Sign=WXPay","sign": "1CECCE6B13C956DEBA88800B3DEC4DBE"},"extra": {},"statusCode": "","message": "","description": "" } 復(fù)制代碼其中,metadata,credential,extra這類字段,并沒(méi)有一個(gè)特別固定的規(guī)范,用MySQL要冗余一下字段才行,或者針對(duì)每個(gè)渠道去分表,想想都覺(jué)得煩!
MySQL
因?yàn)檫@套支付系統(tǒng)被設(shè)計(jì)成為支持多應(yīng)用,多渠道,所以此處用到MySQL存放一些應(yīng)用配置。 E-R圖免了,直接上數(shù)據(jù)庫(kù)表結(jié)構(gòu):
① pay_channel:可供接入的支付渠道
② app_settings:支付應(yīng)用信息
③ app_channel:應(yīng)用已接入的支付渠道
④ alipay_settings:支付寶參數(shù)設(shè)置
⑤ wx_settings:微信app支付參數(shù)設(shè)置
如果想要增加支付渠道,只需要添加一張對(duì)應(yīng)的支付參數(shù)設(shè)置表。
2、發(fā)起退款
不出意外,客戶在平臺(tái)的每筆訂單都可以發(fā)起退款,而且還能分批退,也就是同一個(gè)訂單,可以多次發(fā)起退款申請(qǐng),只要保證退款總額不超出實(shí)付總額。 架構(gòu)圖如下所示:
跟發(fā)起支付請(qǐng)求的流程有很多相似之處,不再一一解釋了,兩個(gè)關(guān)鍵的地方說(shuō)明一下:
這部分的執(zhí)行流程和之前類似,客戶端發(fā)起退款請(qǐng)求,形成一個(gè)退款任務(wù)(RefundTask),放入任務(wù)隊(duì)列中,消費(fèi)者取出并執(zhí)行各自的業(yè)務(wù)邏輯,退款成功會(huì)生成Refund對(duì)象,并持久化到MongoDB中。
MongoDB
document_name = "Refund"
{"payId": "pay_Oyvrf9vP880STm1e9G5CSCm1","method": "yoogurt.taxi.pay","version": "v1.0","timestamp": 1473044885,"created": 1473042835,"refundId": "refund_kmw1vrf9wSrP1e9Gkp05CSCm1","appId": "app_KiPGa98abDmLe9ev","orderNo": "20161899798416","clientIp": "192.168.18.189","amount": 10000,"succeedTime": 1473150835,"transactionNo": "6405996874204000684260056054","refundStatus": "success","message": "","metadata": {"user_id": "170204469176","phone_number": "13811234567"},"description": "" } 復(fù)制代碼3、接收回調(diào)
這部分功能被設(shè)計(jì)成了事件驅(qū)動(dòng)類型,所以webhooks當(dāng)仁不讓。
因?yàn)楦鱾€(gè)渠道的回調(diào)內(nèi)容都不盡相同,所以這部分設(shè)計(jì)會(huì)按支付渠道切分。
架構(gòu)圖如下:
用戶在支付完畢后,第三方支付渠道通過(guò)發(fā)起支付時(shí)指定的回調(diào)地址對(duì)商戶進(jìn)行支付成功的異步通知。
這部分的執(zhí)行流程和之前類似,在各自的PayChannel中解析好回調(diào)參數(shù),形成一個(gè)回調(diào)事件(Event),并持久化到MongoDB中,然后再生成一個(gè)回調(diào)任務(wù)(EventTask),放入任務(wù)隊(duì)列中,消費(fèi)者取出并執(zhí)行各自的業(yè)務(wù)邏輯,這里的消費(fèi)者就是上游的業(yè)務(wù)服務(wù)系統(tǒng)。
MongoDB
document_name = “Event”
{"eventId": "evt_la06CoQAiPojSgJKe5gt3nwq","created": 1427555016,"eventType": "pay.succeeded","data": {"payId": "pay_Oyvrf9vP880STm1e9G5CSCm1","method": "yoogurt.taxi.pay","version": "v1.0","timestamp": 1473044885,"created": 1473042835,"paid": false,"appId": "app_KiPGa98abDmLe9ev","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": "wx4932b5159d18311e","partnerId": "1269774001","prepayId": "wx201609051033574da13955420883291539","nonceStr": "1e99d8ffdde926ed9cbdf4d2e614abad","timeStamp": "1473042837","packageValue": "Sign=WXPay","sign": "1CECCE6B13C956DEBA88800B3DEC4DBE"},"extra": {},"description": ""},"retryTimes": 0 } 復(fù)制代碼特別說(shuō)明一下data字段:
如果是支付成功事件,則返回對(duì)應(yīng)的Payment對(duì)象;
如果是退款成功時(shí)間,則返回對(duì)應(yīng)的Refund對(duì)象。
總結(jié)
可能有的讀者通篇看下來(lái),覺(jué)得這并不是什么支付系統(tǒng),僅僅是對(duì)接了一下第三方支付渠道,勉強(qiáng)算是支付渠道網(wǎng)關(guān)吧!
如果你有這種感受,我也是非常認(rèn)同的。
個(gè)人認(rèn)為這篇文章還是比較接地氣的,沒(méi)有太多理論的東西,看到的更多是實(shí)現(xiàn)層面的內(nèi)容,就差貼代碼了!
坦白地講,第三方支付渠道對(duì)接了不少次,卻并沒(méi)有像現(xiàn)在這樣系統(tǒng)地去設(shè)計(jì),去總結(jié)。
我用過(guò)幾次ping++的產(chǎn)品,在企業(yè)級(jí)聚合支付領(lǐng)域,ping++算是業(yè)界領(lǐng)先者了,所以,我的一些數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)還是與其有幾分相似的,ping++以后也會(huì)是我模仿和比較的對(duì)象。
這次也是我的支付系統(tǒng)實(shí)現(xiàn)所邁出的第一步,今后也會(huì)不斷豐富,完善我自己的支付系統(tǒng)。
希望對(duì)你有所幫助!
THANKS!
每日干貨分享,傳遞互聯(lián)網(wǎng)世界有價(jià)值的訊息,微信公眾號(hào):jishuhui_2015
轉(zhuǎn)載于:https://juejin.im/post/5a47914d6fb9a0451b04e3c4
總結(jié)
以上是生活随笔為你收集整理的记一次支付系统的设计体验的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 从Jetty、Tomcat和Mina中提
- 下一篇: HierarchicalDataTemp