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

裝逼未遂的程式設計師發表於2017-12-31

0、寫在前面的話

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

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

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

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

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

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

考慮到吞吐量的影響,將原先同步的程式設計方式改為非同步的程式設計方式,不出意外的話,將會使用到Java8的ExecutorService和CompletableFuture。

此外,還用到了公司其他的現成的東西:RabbitMQ,Redis,MongoDB。

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

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

1、發起支付

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

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


跟著標註的序號,可以跟蹤到一個支付請求是如何發起的(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支付引數設定


如果想要增加支付渠道,只需要新增一張對應的支付引數設定表。

2、發起退款

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


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

  1. 客戶端發起退款請求的時候,需要攜帶payId,就是支付物件的id。這就意味著,支付系統的呼叫方需要維護payId與orderNo的對應關係,務必在客戶端發起退款請求之前,獲取到正確的payId;
  2. 承接上一步,這才有了圖中的第5、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": ""
} 
複製程式碼

3、接收回撥

這部分功能被設計成了事件驅動型別,所以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


相關文章