記一次支付系統的設計體驗

0、寫在前面的話

支付系統是一個老生常談的話題,我也相信每一個公司開發的支付系統不盡相同,由於業務形態並不太同樣。web

在此,我並不想講一個大而全的支付系統,我的也沒有能力去闡述。數據庫

在我看來,一個支付系統應提供支付渠道管理,支付網關,基本支付/退款/轉帳能力,支付記錄/明細,及其相關的監控運維繫統。編程

至於所謂的帳務清算,對帳功能,帳戶體系,風控體系,現金流量管理,應該歸入到「財務系統」,大概是大佬們談論的都是廣義的「支付系統」吧!緩存

而我今天只談狹義的「支付系統」。bash

目前,支付的流程包含了三大部分:發起支付,發起退款,接收回調。服務器

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

此外,還用到了公司其餘的現成的東西:RabbitMQ,Redis,MongoDB。數據結構

我是打算將這套支付系統設計成與具體業務無關,能夠歸入到公司的公共平臺系統中。架構

具體是如何作到的,請接着往下讀。app

一、發起支付

這一部分講述的是客戶端和服務端如何配合完成一次支付請求。服務端必需要有一個意識,最終發起支付的仍是客戶端,服務端提供一些必要的參數配置信息。

發起支付的架構圖以下所示:


跟着標註的序號,能夠跟蹤到一個支付請求是如何發起的(Sequence Diagram就免了),流程描述以下:

  1. Submit a pay task,當客戶端須要發起支付的時候,起始是向支付任務隊列裏面加入了一個新的支付任務,這個過程是異步實現的。先根據客戶端提交的參數,構造好一個新的支付任務;
  2. Offer a task,開啓一個異步任務,作的事情就是向MQ中添加一個新的支付任務,等待被消費;
  3. Pay task description,一旦異步任務被成功建立,將會把第一步構造好的支付任務信息直接return給客戶端;
  4. Poll a task,與此同時,支付任務的消費者將新的支付任務poll下來進行執行;
  5. Send a pay request,這一步須要根據實際狀況而定。並非全部的支付請求都要先通過第三方支付平臺,好比支付寶;而對於微信,則還須要憑支付參數申請一個prepay_id,再經由客戶端發起支付;
  6. Response,沒什麼好說的,第三方渠道返回的支付必要參數;
  7. Cache result,至此,一個支付任務能夠算是完成了,能夠將任務的執行結果(不管成功與否)緩存在Redis中,隨時等待客戶端的回訪;
  8. Query result,客戶端在提交支付任務後,間隔必定時間後(建議2~3s),發起一個結果查詢的請求;
  9. Query,直接進Redis查找結果;
  10. Synchronize,這是一個異步的操做,將支付任務的執行結果「順便」同步到MongoDB中,並刪除Redis中緩存的任務執行結果。持久化到MongoDB主要是爲後續的容錯,重試,數據分析等提供落地的數據源;
  11. Return,由Redis返回給應用服務器;
  12. Return payment,應用服務器再將最終的支付對象返回給客戶端。

讓咱們更深刻一點,咱們來看三張Class Diagram:

① 先說說支付任務(PayTask)部分。PayTask和Payment兩個都是MongoDB中的Document對象,但在任務執行期間,PayTask是用Redis進行緩存的,方便客戶端隨時發起Query,任務執行成功後,會生成Payment對象,最終PayTask和Payment都會持久化到MongoDB中。在PayService中,有對支付任務的一些基本操做,包括任務提交,取消,重試,構建等等。


② 再說說任務的執行(runner)。這部分和RabbitMQ緊密相關,一旦一個支付任務造成了,就會放入任務執行隊列中,由消費者取出執行。在TaskRunner中,有兩個基本的接口方法:run(task)、retry(task),分別是執行任務和重試任務。在AbstractPayTaskRunner中已經封裝好了這兩個方法,繼承AbstractPayTaskRunner須要實現doTask方法,從返回值能夠看出,這個過程是異步化的。關於Retry機制,用戶能夠設置重試與否,一旦設置了TaskInfo.needRetry=true(不出意外,默認就是容許重試),就啓用了Retry機制。還能夠設置重試的次數(TaskInfo.retryTimes),默認三次,分別間隔1s,2s,3s,間隔時間以公差爲1的等差數列組成。固然不會讓用戶無限重試,系統內置有一個最大重試次數,最大重試次數內置爲5次。

爲何是5次?

你感覺一下,1s,2s,3s,4s,5s,整個請求鏈條就被拉長到了15s,這對客戶端簡直就是災難了!!


③ 接着說一下支付渠道(PayChannel)。這部分設計與具體的支付渠道對接聯繫比較緊密了,包括支付參數配置,支付參數處理,簽名/驗籤等等。


④ 最後解釋一下支付參數(PayParams)。


大部分仍是能看懂的,我解釋幾個關鍵的property:

1) appId,這是爲了區分不一樣的產品所設置的。現實中,頗有可能一個產品會申請與之對應的支付渠道,而後在支付平臺中建立應用,設置好對應的支付參數,系統將會分配一個appId,憑此值就能夠直接定位到各個支付參數。若是想再更完善一點,能夠再區分一下測試環境和正式環境;

2) amount,這裏表明的是支付金額的意思,可是這套支付系統的金額單位統一設置成 人民幣【分】;

3) metadata,理論上,元數據這個字段沒啥限制,要是非要說有限制,那麼就是字段長度了——5000個字符。這個字段的想象空間仍是很大的:用於填寫豐富的交易相關信息,用於在增加智能系統產品中進行深刻商業分析。包括交易行爲多維分析、人羣分析、產品轉化路徑、個性化推薦、智能補貼、定向推送等。看產品經理要怎麼玩了;

5) credential,這個字段很是很是重要,其中裝載的就是客戶端最終發起支付請求的憑證,會做爲Payment對象的一部分返回給客戶端;

MongoDB的document字段設計

解釋一下爲何要用MongoDB:

我的以爲,若是這個通用服務要獲得較好的推廣(甚至是開源),用MySQL等關係型數據庫是不二之選,由於一個完整實用的系統,必然是少不了數據庫的,若是一旦用了一些非傳統的東西,必然會提升一部分人的對接成本。有的人一看不符合團隊的技術棧,直接就不考慮了。

爲何我仍是要用MongoDB呢?

① 團隊的技術棧裏面有這麼個東西,不用白不用;

② MongoDB普及程度實在是不要過高,還不用上點NoSQL的東西,感受本身分分鐘被OUT掉了;

③ 要存儲的數據結構須要支持動態擴展的特性,我就看中MongoDB的靈活性,以下是要存儲的數據結構:

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": ""
}
複製代碼

其中,metadata,credential,extra這類字段,並無一個特別固定的規範,用MySQL要冗餘一下字段才行,或者針對每一個渠道去分表,想一想都以爲煩!

MySQL

由於這套支付系統被設計成爲支持多應用,多渠道,因此此處用到MySQL存放一些應用配置。 E-R圖免了,直接上數據庫表結構:

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


② app_settings:支付應用信息


③ app_channel:應用已接入的支付渠道


④ alipay_settings:支付寶參數設置


⑤ wx_settings:微信app支付參數設置


若是想要增長支付渠道,只須要添加一張對應的支付參數設置表。

二、發起退款

不出意外,客戶在平臺的每筆訂單均可以發起退款,並且還能分批退,也就是同一個訂單,能夠屢次發起退款申請,只要保證退款總額不超出實付總額。 架構圖以下所示:


跟發起支付請求的流程有不少類似之處,再也不一一解釋了,兩個關鍵的地方說明一下:

  1. 客戶端發起退款請求的時候,須要攜帶payId,就是支付對象的id。這就意味着,支付系統的調用方須要維護payId與orderNo的對應關係,務必在客戶端發起退款請求以前,獲取到正確的payId;
  2. 承接上一步,這纔有了圖中的第五、6個步驟,從MongoDB中查詢以前的支付對象。第三方渠道一般會要求在退款的時候指定一個退款單號,由於一筆訂單能夠分屢次退款,因此不建議將訂單號做爲退款單號使用。這裏的退款單號由支付系統生成並維護。

這部分的執行流程和以前相似,客戶端發起退款請求,造成一個退款任務(RefundTask),放入任務隊列中,消費者取出並執行各自的業務邏輯,退款成功會生成Refund對象,並持久化到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": ""
} 
複製代碼

三、接收回調

這部分功能被設計成了事件驅動類型,因此webhooks當仁不讓。

由於各個渠道的回調內容都不盡相同,因此這部分設計會按支付渠道切分。

架構圖以下:


用戶在支付完畢後,第三方支付渠道經過發起支付時指定的回調地址對商戶進行支付成功的異步通知。

這部分的執行流程和以前相似,在各自的PayChannel中解析好回調參數,造成一個回調事件(Event),並持久化到MongoDB中,而後再生成一個回調任務(EventTask),放入任務隊列中,消費者取出並執行各自的業務邏輯,這裏的消費者就是上游的業務服務系統。

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
} 
複製代碼

特別說明一下data字段:

若是是支付成功事件,則返回對應的Payment對象;

若是是退款成功時間,則返回對應的Refund對象。

總結

可能有的讀者通篇看下來,以爲這並非什麼支付系統,僅僅是對接了一下第三方支付渠道,勉強算是支付渠道網關吧!

若是你有這種感覺,我也是很是認同的。

我的認爲這篇文章仍是比較接地氣的,沒有太多理論的東西,看到的更可能是實現層面的內容,就差貼代碼了!

坦白地講,第三方支付渠道對接了很多次,卻並無像如今這樣系統地去設計,去總結。

我用過幾回ping++的產品,在企業級聚合支付領域,ping++算是業界領先者了,因此,個人一些數據結構設計仍是與其有幾分類似的,ping++之後也會是我模仿和比較的對象。

此次也是個人支付系統實現所邁出的第一步,從此也會不斷豐富,完善我本身的支付系統。

但願對你有所幫助!

THANKS!


每日干貨分享,傳遞互聯網世界有價值的訊息,微信公衆號:jishuhui_2015

相關文章
相關標籤/搜索