1. 程式人生 > >Java支付寶支付開發流程與原理【沙箱環境】【分散式事務解決方案】

Java支付寶支付開發流程與原理【沙箱環境】【分散式事務解決方案】

不管是支付寶支付,還是微信支付,還是銀聯支付等,大部分的支付流程都是相似的,學會了其中的思想,那麼其他支付方式也就很簡單了。

支付寶支付流程:

1、A網站以POST請求方式提交引數給支付寶介面,在支付寶端進行支付處理。

POST請求方式一定程度下保證了安全性,即在url上看不到引數,但可以在瀏覽器開發者工具中可以看到引數,為防止篡改,則可以採用一些加密協議,如:https、加簽名、加密手段(MD5加鹽、base64、DES、sha1)等。

在加密中又可以分為對稱加密(base64、des等)與非對稱加密(RSA公鑰與私鑰的互換)。

那麼在支付寶中主要使用什麼方式進行加密呢?  加簽名和RSA非對稱加密。


 

2、在支付寶介面中,把支付的結果通知給A網站(成功/失敗),以便更新訂單的狀態資訊。

那麼支付寶怎麼把支付結果返回給A網站呢?有兩種通知/回撥方式:1、同步通知(同步回撥)   2、非同步通知(非同步回撥)

同步通知:當A網站以post請求方式將引數提交給支付寶介面,支付寶會返回同步通知給A網站,意味著A網站需要提供一個介面給支付寶,而同步通知實際上是:本地瀏覽器的重定向操作,告知A網站支付成功還是失敗,不做訂單狀態的更改。

非同步通知:為了安全性考慮,一般需要進行訂單狀態的更改時,使用非同步通知,即支付寶伺服器使用httpclient技術呼叫A網站的介面進行通知,A網站解析報文,判斷到底是支付成功還是支付失敗

。非同步通知包含補償機制,即:支付寶把結果非同步通知給A網站,若A網站未及時響應給支付寶,則支付寶會進行補償重發,類似與MQ。所以在網路存在延遲的情況下,需要解決支付回撥的冪等性問題,解決方式跟MQ很相似——使用全域性ID。

簡而言之:

回撥方式:同步回撥、非同步回撥 

回撥場景: 告訴商戶支付通知結果

同步回撥: 整個支付流程完畢,使用同步方式將引數重定向給商戶平臺,一般場景用於展示結果。

非同步回撥: 第三方支付介面發一個後臺通知給商戶平臺,一般場景使用者修改訂單資訊。

在支付環境可能產生的問題:

安全性問題、支付回撥的冪等性問題(如充值1毛錢,可以購買500元的商品漏洞——html篡改資料),分散式事務問題(解決資料的雙方一致性問題,因為A網站和支付寶並不使用同一個資料庫),若A網站不能及時收到支付寶的非同步通知,則支付寶會重試補償,則應該在A網站內做冪等性判斷即可。

支付寶開發環境:

初次訪問需要進行認證,選擇自研開發者:

下載後匯入Demo到Eclipse(plus:貌似Demo並不是一個Maven工程)

匯入後修改AlipayConfig.java檔案(app_id、RSA2、公鑰,測試環境下修改閘道器等資訊)

加密方式

在支付領域,資料安全肯定是首要的任務,加密種類可分為:單向加密、對稱加密、非對稱加密

最安全的肯定是RSA:公鑰與私鑰的互換,效率不如單向加密和對稱加密高,但安全性很好,要想破解,必須知道公鑰和私鑰兩把金鑰,屬於非對稱加密。

單向加密:如MD5、SHA等不可逆【不能解密,只能加密】,主要用來驗證資料傳輸的過程中,是否被篡改過。

對稱加密:一方通過金鑰將資訊加密後,把密文傳給另一方,另一方通過這個相同的金鑰將密文解密,轉換成可以理解的明文。

明文 <-> 金鑰 <-> 密文   【可以加密,又可以解密】

常用對稱加密方案 DES、AES、Base64

非對稱加密:在支付領域一般都使用RSA非對稱加密。在通訊雙方,如果使用非對稱加密,一般遵從這樣的原則:公鑰加密,私鑰解密。同時,一般一個金鑰加密,另一個金鑰就可以解密。

因為公鑰是公開的,如果用來解密,那麼就很容易被人解密訊息。因此,私鑰也可以認為是個人身份的證明

如果通訊雙方需要互發訊息,那麼應該建立兩套非對稱加密的機制(即兩對公私鑰金鑰對),發訊息的一方使用對方的公鑰進行加密,接收訊息的一方使用自己的私鑰解密。

每個人生成一個“私鑰-公鑰”對,這個私鑰需要每個人自行進行保護!公鑰可以隨便分享,後面詳細說,同時,生成的這個“私鑰-公鑰”對還有個強大的功能就是,使用私鑰加密的資訊,只能由該私鑰對應的公鑰才能解密,使用公鑰加密的資訊,只能由該公鑰對應的私鑰才能解密!

初次使用支付寶沙箱環境,預設RSA2(SHA256)金鑰是未啟用狀態,需要手動配置應用公鑰!

支付步驟

使用tomcat執行專案,進入web頁面,如下顯示:

點選付款,使用賬號密碼登入。注意:不要使用真實環境登入,而是用沙箱賬號密碼登入

沙箱賬號密碼可以在這檢視:

登入密碼和支付密碼預設都為111111

支付成功後,跳轉到你所填寫的同步通知地址,實際為瀏覽器重定向。

若重定向後顯示:

trade_no:20180916xxxxxxxxx

out_trade_no:20180916xxx

total_amount:100000

則表示測試成功

DEBUG看底層執行原理

演示完成,下面我們使用斷點方式,debug執行看看底層是如何執行的。

首先我開啟google瀏覽器,開啟開發者工具,當我們點選付款時候,訪問的是alipay.trade.page.pay.jsp,在專案中找到該jsp

在13行打一個斷點,點選頁面按鈕

return_url代表同步通知本地瀏覽器重定向的url,而notify_url代表非同步通知url,精彩在後頭。

為什麼他要這麼做呢?實際上他在29行-33行把引數封裝為json格式,並把result動態生成為一個表單,我拷貝表單在本地生成.html檔案執行試試看。

此處有一個scirpt標籤,當頁面載入時候提交表單,表單的action為alipaydev.com,提交方式為POST請求,且內部封裝了兩個隱藏域,value為剛才的debug所示的封裝後的json,並提交給支付寶伺服器,雙擊1.html,執行結果為:

接下來,登入賬戶付款。

分別在notify_url.jsp和return_url.jsp打一個斷點

發現斷點先進入notify_url.jsp(非同步通知/非同步回撥),①接收支付寶傳遞過來的引數,②驗證簽名,防止被篡改,如果驗證簽名失敗,則有重試機制,直到A系統返回"success"給支付寶,支付寶才不會重試。實際上同步通知和非同步通知程式碼基本一樣,最後返回結果。需要考慮網路延遲的情況下,A系統與支付寶系統雙方資料一致性問題

所以需要A系統還需要做一個冪等性問題的判斷,在網路延遲的情況下,需要使用全域性id處理冪等性問題,全域性id可以參考訂單ID

專案中如何接入支付寶開發

1、引入依賴,該依賴包含了支付寶所需的sdk

    <dependency>
			<groupId>com.github.1991wangliang</groupId>
			<artifactId>alipay-sdk</artifactId>
			<version>1.0.0</version>
    </dependency>

2、支付服務需要提供兩個介面
1、建立token介面
2、使用token進行支付

資料庫支付表結構,非正式,僅供參考

CREATE TABLE `payment_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `userid` int(11) DEFAULT NULL,
  `typeid` int(2) DEFAULT NULL,
  `orderid` varchar(50) DEFAULT NULL,
  `price` decimal(10,0) DEFAULT NULL,
  `source` varchar(10) DEFAULT NULL,
  `state` int(2) DEFAULT NULL,
  `created` datetime DEFAULT NULL,
  `updated` datetime DEFAULT NULL,
  `platformorderid` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;

token介面如何建立?

詳細的支付流程:
生成支付令牌,支付令牌,假設有效期15分鐘
1、請求時,向支付表建立一條支付資訊
2、生成支付token,存到redis中,key為支付token,value為支付表的id,並設定有效期為15分鐘
3、返回支付token給客戶端
4、使用支付token,向redis查詢對應支付表的id
5、使用支付表的id,獲取支付資訊
6、封裝支付寶form表單提交引數

具體程式碼實現:

public ResponseBase createToken(@RequestBody PaymentInfo paymentInfo) {
		//1.建立支付請求資訊
		Integer insertResultCount = paymentInfoDao.savePaymentType(paymentInfo);
		if(insertResultCount <= 0 ){
			return setResultFail("建立支付訂單失敗");
		}
		//2.生成對應的token
		String payToken = TokenUtils.getPayToken();
		//3.存放在redis中,key為token,value為支付表的id 
		baseRedisService.setString(payToken, paymentInfo.getId()+"", ResponseConstants.PAY_TOKEN_MEMBER_TIME);
		//4.返回token給客戶端
		JSONObject jsonObject = new JSONObject();
		jsonObject.put("payToken", payToken);
		return setResultSuccess(jsonObject);
	}

如何使用token進行支付

思路:

1.對傳遞的token,並進行校驗

2.從redis中根據token獲取支付id,並進行校驗

3.使用支付id查詢資料庫,並進行校驗

4.從資料庫中找到訂單資訊,封裝為json,組裝form表單(result),輸出到頁面

Service層

public ResponseBase findToken(@RequestParam("payToken") String payToken){
		// 1.引數驗證
		if (StringUtils.isEmpty(payToken)) {
			return setResultFail("請傳遞payToken");
		}
		// 2.判斷token有效期
		// 3.使用token 查詢redis 找到對應的支付id
		String payID = (String) baseRedisService.getString(payToken);
		if (StringUtils.isEmpty(payID)) {
			return setResultFail("支付token不存在或已經過期");
		}
		// 4.使用支付id進行下單
		Long payIDInteger = Long.parseLong(payID);
		// 5.使用支付id查詢支付資訊
		PaymentInfo paymenInfo = paymentInfoDao.getPaymentInfo(payIDInteger);
		if (paymenInfo == null) {
			return setResultFail("未找到該支付資訊");
		}
		// 6.對接支付寶程式碼,返回提交支付form表單元素給客戶端,拷貝alipay.trade.page.pay.jsp中的程式碼
		// 獲得初始化的AlipayClient
		AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.gatewayUrl, AlipayConfig.app_id,
				AlipayConfig.merchant_private_key, "json", AlipayConfig.charset, AlipayConfig.alipay_public_key,
				AlipayConfig.sign_type);

		// 設定請求引數
		AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
		alipayRequest.setReturnUrl(AlipayConfig.return_url);
		alipayRequest.setNotifyUrl(AlipayConfig.notify_url);

		// 商戶訂單號,商戶網站訂單系統中唯一訂單號,必填
		String out_trade_no = paymenInfo.getOrderId();
		// 付款金額,必填
		String total_amount = paymenInfo.getPrice()+"";
		// 訂單名稱,必填
		String subject = "itcats.cn充值中心";
		// 商品描述,可空
		//String body = new String(request.getParameter("WIDbody").getBytes("ISO-8859-1"), "UTF-8");

		alipayRequest.setBizContent("{\"out_trade_no\":\"" + out_trade_no + "\"," + "\"total_amount\":\"" + total_amount
				+ "\"," + "\"subject\":\"" + subject + "\"," 
//				+ "\"body\":\"" + body + "\","
				+ "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

		// 若想給BizContent增加其他可選請求引數,以增加自定義超時時間引數timeout_express來舉例說明
		// alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no
		// +"\","
		// + "\"total_amount\":\""+ total_amount +"\","
		// + "\"subject\":\""+ subject +"\","
		// + "\"body\":\""+ body +"\","
		// + "\"timeout_express\":\"10m\","
		// + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");
		// 請求引數可查閱【電腦網站支付的API文件-alipay.trade.page.pay-請求引數】章節

		// 請求
		try{
			String result = alipayClient.pageExecute(alipayRequest).getBody();
			// 輸出
//		out.println(result);
			JSONObject data = new JSONObject();
			data.put("payHtml", result);
			return setResultSuccess(data);
		}catch(Exception e){
			return setResultFail("支付異常");
		}

	}

Controller層

//使用token進行支付
	@RequestMapping("/aliPay")
	public void aliPay(String payToken,HttpServletResponse response) throws IOException{
		response.setContentType("text/html;charset=utf-8");
		PrintWriter writer = response.getWriter();
		//1.引數驗證
		if(StringUtils.isEmpty(payToken)){
			return ;
		}
		//2.呼叫支付服務介面,返回支付寶html元素
		ResponseBase result = payServiceFeign.findToken(payToken);
		if(!result.getCode().equals(ResponseConstants.HTTP_RES_CODE_200)){
			writer.println(result.getMsg());
			return ;
		}
		//3.將html元素返回給客戶端,等於200,獲取html
		LinkedHashMap data = (LinkedHashMap) result.getData();
		String html = (String) data.get("payHtml");
		log.info("######輸出的html結果為#####:{}",html);
		//4.頁面渲染html
		writer.println(html);
		writer.close();
	}

支付寶回撥

回撥分為同步通知和非同步通知

【支付寶的回撥推薦列印日誌】,支付寶這邊會正常的將支付訊息存到支付寶資料庫,而若A系統某介面掛掉,支付寶無法獲取A系統返回的"success",則支付寶有重試機制,重試次數跟週期參照支付寶官方文件,重試本質上是為了保證支付寶資料庫和A系統資料庫的雙方資料一致性問題,但重試也帶來了一些問題,如介面冪等性問題(介面重複消費),所以在A系統介面中需要做冪等性處理,常見例子:記錄成功錄入支付寶資料庫,錄入後支付寶會通知A系統支付結果,若A系統存在網路延遲,則可能重複傳送,如支付寶充值100元,A系統送100積分,若A系統未做冪等性處理,則可能出現使用者獲得500積分、1000積分之類的情況。

同步回撥處理思路,可以參考支付寶Demo中的return_url.jsp

1、日誌處理

2、驗證簽名操作

3、從Map中取出引數【參照return_url.jsp,引數都封裝在Map中】

4、返回json資料給客戶端

具體程式碼:

Service層

// 同步通知
	public ResponseBase synCallBack(@RequestParam Map<String, String> params) {
		// 1.日誌記錄
		log.info("###支付寶同步通知開始###params:{}", params);
		// 2.驗籤操作,參考支付寶Demo的return_url.jsp
		try {
			boolean signVerified = AlipaySignature.rsaCheckV1(params, AlipayConfig.alipay_public_key, AlipayConfig.charset,
					AlipayConfig.sign_type); // 呼叫SDK驗證簽名
			log.info("###支付寶同步通知驗證引數###signVerified:{}",signVerified);
			// ——請在這裡編寫您的程式(以下程式碼僅作參考)——
			if (!signVerified) {
				return setResultFail("驗籤失敗");
			}
			// 商戶訂單號
			String outTradeNo = params.get("out_trade_no");
			// 支付寶交易號
			String tradeNo = params.get("trade_no");
			// 付款金額
			String totalAmount = params.get("total_amount");
			
			JSONObject data = new JSONObject();
			data.put("outTradeNo", outTradeNo);
			data.put("tradeNo", tradeNo);
			data.put("totalAmount", totalAmount);
			return setResultSuccess(data);
		} catch (AlipayApiException e) {
			log.error("支付寶同步通知出現異常,ERROR:{}",e);
			return setResultFail("同步通知出現異常");
		}finally {
			log.info("###支付寶同步通知結束###params:{}", params);
		}
	}

同步通知Controller層,負責把引數放在request並轉發到頁面

@Controller
@Slf4j
//參照return_url.jsp
@RequestMapping("/alipay/callback")
public class CallbackController {
	private static final String SUCCESS_PAY = "success_pay";
	@Autowired
	private CallbackServiceFeign callbackServiceFeign;
	/**
	 * 同步回撥地址,成功以輸出流生成的form表單頁面,並把引數放在form表單hidden域,失敗輸出失敗
	 * 需要修改本地專案拷貝過來的支付寶AlipayConfig.java中同步回撥地址
	 * @param request
	 * @return
	 * @throws IOException 
	 */
	
	@RequestMapping("returnUrl")
	public void synCallBack(HttpServletRequest request,HttpServletResponse response) throws IOException{
		response.setContentType("text/html;charset=utf-8");
		PrintWriter writer = response.getWriter();
		Map<String,String> params = new HashMap<String,String>();
		Map<String,String[]> requestParams = request.getParameterMap();
		for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
			String name = (String) iter.next();
			String[] values = (String[]) requestParams.get(name);
			String valueStr = "";
			for (int i = 0; i < values.length; i++) {
				valueStr = (i == values.length - 1) ? valueStr + values[i]
						: valueStr + values[i] + ",";
			}
			//亂碼解決,這段程式碼在出現亂碼時使用
			valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
			params.put(name, valueStr);
		}
		log.info("###支付寶同步通知CallbackController###synCallBack開始params:{}",params );
		ResponseBase res = callbackServiceFeign.synCallBack(params);
		//執行失敗,返回狀態碼不是200
		if(!res.getCode().equals(ResponseConstants.HTTP_RES_CODE_200)){
			//報錯頁面error.ftl
			writer.print("跳轉頁面失敗");;
		}
		//執行成功,返回狀態碼為200
		LinkedHashMap data = (LinkedHashMap) res.getData();
		//封裝引數到form表達,瀏覽器模擬提交,為隱藏引數,使用POST+隱藏域表單包裝
		String htmlFrom = "<form name='punchout_form'"
				+ " method='post' action='http://127.0.0.1/alipay/callback/synSuccessPage' >"
				+ "<input type='hidden' name='outTradeNo' value='" + data.get("out_trade_no") + "'>"
				+ "<input type='hidden' name='tradeNo' value='" + data.get("trade_no") + "'>"
				+ "<input type='hidden' name='totalAmount' value='" + data.get("total_amount") + "'>"
				+ "<input type='submit' value='立即支付' style='display:none'>"
				+ "</form><script>document.forms[0].submit();" + "</script>";
		log.info("###支付寶同步通知CallbackController###synCallBack結束params:{}",params );
		//輸出表單頁面
		writer.println(htmlFrom);
		writer.close();
		
	}
	
	//同步回撥解決get請求url地址暴露引數問題,這裡使用POST請求隱藏引數
		@RequestMapping(value = "/synSuccessPage", method = RequestMethod.POST)
		public String synSuccessPage(HttpServletRequest request, String outTradeNo, String tradeNo, String totalAmount) {
			request.setAttribute("outTradeNo", outTradeNo);
			request.setAttribute("tradeNo", tradeNo);
			request.setAttribute("totalAmount", totalAmount);
			return SUCCESS_PAY;
		}
}

非同步通知:

考慮重試機制導致的冪等性問題,但支付寶的重試一般不會並行執行,所以一般只需要根據全域性ID(訂單ID)進行冪等性判斷即可,如果支付失敗(如錢不夠了),支付寶也不會把訊息回撥過來。

以下程式碼涉及到分散式事務問題,plus訂單資料庫與支付資料庫是不同的資料來源

// 非同步通知
	public String asynCallBack(@RequestParam Map<String, String> params) {
		// 1.日誌記錄
		log.info("###支付寶同步通知開始###params:{}", params);
		// 2.驗籤操作,參考支付寶Demo的return_url.jsp
		try {
			boolean signVerified = AlipaySignature.rsaCheckV1(params, AlipayConfig.alipay_public_key,
					AlipayConfig.charset, AlipayConfig.sign_type); // 呼叫SDK驗證簽名
			log.info("###支付寶同步通知驗證引數###signVerified:{}", signVerified);
			// ——請在這裡編寫您的程式(以下程式碼僅作參考)——
			if (!signVerified) {
				return ResponseConstants.PAY_FAIL;
			}



			// 【重點程式碼】修改支付資料庫,先判斷,後設置,可以解決多次設定造成的冪等性問題
			// 根據訂單id查詢支付表,返回支付物件
			String outTradeNo = params.get("out_trade_no");
			//在支付寶中,解決全域性冪等性問題使用訂單號進行區分
			//如果擔心重試並行執行,可以考慮在這加入zookeeperLock.lock()
			PaymentInfo paymenInfo = paymentInfoDao.getByOrderIdPayInfo(outTradeNo);
			//zookeeperLock.unlock()
			if(paymenInfo == null){
				return ResponseConstants.PAY_FAIL;
			}
			//獲取支付狀態0 待支付、1支付成功 、2支付失敗
			//在支付寶的重試機制中,重試不會並行執行,重試都是有時間間隔的
			Integer state = paymenInfo.getState();
			if(state == 1){
				//已經支付過了,不要繼續重試,返回success
				return ResponseConstants.PAY_SUCCESS;
			}
			// 商戶訂單號
			// 支付寶交易號
			String tradeNo = params.get("trade_no");




			// 付款金額,實際開發中,這個金額應該從資料庫裡查,防止別人知道介面篡改金額
			String totalAmount = params.get("total_amount");
                      //或從資料庫中查詢出商品金額和totalAmount是否一致,不一致標記為異常訂單




			JSONObject data = new JSONObject();

			// 設定為已經支付狀態
			paymenInfo.setState(1);
			// 設定支付寶id
			paymenInfo.setPlatformorderId(tradeNo);
			// 對支付引數進行記錄
			paymenInfo.setPayMessage(params.toString());
			//手動begin事務
			Integer resultCount = paymentInfoDao.updatePayInfo(paymenInfo);
			if (resultCount <= 0) {
				// 更新支付表失敗,返回fail,讓支付寶重試
				return ResponseConstants.PAY_FAIL;
			}
			// 更新支付表成功
			// 呼叫訂單介面通知 更新訂單表
			ResponseBase orderResult = orderServiceFeign.updateOrder(1l, tradeNo , outTradeNo);
			if(!orderResult.getCode().equals(ResponseConstants.HTTP_RES_CODE_200)){
			//手動回滾事務 
				return ResponseConstants.PAY_FAIL;
			}
			//手動提交,如訂單表更新失敗,支付表也應該回滾

			return ResponseConstants.PAY_SUCCESS;
		} catch (AlipayApiException e) {
			log.error("支付寶同步通知出現異常,ERROR:{}", e);
			return ResponseConstants.PAY_FAIL;
		} finally {
			log.info("###支付寶同步通知結束###params:{}", params);
		}
	}

涉及分散式事務問題的程式碼,我把上面的部分程式碼拷貝下來

                        //手動begin事務
                        //更新支付寶表更新資料庫
			Integer resultCount = paymentInfoDao.updatePayInfo(paymenInfo);
			if (resultCount <= 0) {
				// 更新支付表失敗,返回fail,讓支付寶重試
				return ResponseConstants.PAY_FAIL;
			}
			// 更新支付表成功
			// 呼叫訂單介面通知 更新訂單表
			ResponseBase orderResult = orderServiceFeign.updateOrder(1l, tradeNo , outTradeNo);
			if(!orderResult.getCode().equals(ResponseConstants.HTTP_RES_CODE_200)){
			//手動回滾事務 
				return ResponseConstants.PAY_FAIL;
			}
			//手動提交,如訂單表更新失敗,支付表也應該回滾
			return ResponseConstants.PAY_SUCCESS;

常規的電商支付流程:先更改本地的支付寶資料庫,更新成功後,再更新訂單資料庫更新訂單狀態,但訂單狀態並不受本地事務的影響,在執行完更新訂單資料庫後丟擲異常,則訂單更新 orderServiceFeign.updateOrder(1l, tradeNo , outTradeNo);不能回滾,而支付更新paymentInfoDao.updatePayInfo(paymenInfo);受本地事務影響可以回滾,這便產生了分散式事務問題。

分散式事務:

就是一次大的操作由不同的小操作組成,這些小的操作分佈在不同的伺服器上,且屬於不同的應用,分散式事務需要保證這些小操作要麼全部成功,要麼全部失敗。本質上來說,分散式事務就是為了保證不同資料庫的資料一致性。

常見的分散式事務解決方案:

兩段提交協議(2pc)、三段提交協議(3pc)、TCC補償機制、MQ(補償機制)+冪等性處理、提供回滾介面、分散式資料庫支付寶流程等。

分散式理論:CAP理論和BASE理論

CAP理論

所謂的CAP理論即為:資料的一致性、服務的可用性、分割槽容錯

一致性

指“all nodes see the same data at the same time”,即更新操作成功並返回客戶端完成後,所有節點在同一時間的資料完全一致。
對於分散式事務一致性,可以分為從客戶端和服務端兩個不同的視角。

從客戶端來看,一致性主要指的是多併發訪問時更新過的資料如何獲取的問題。

從服務端來看,則是更新如何複製分佈到整個系統,以保證資料最終一致。

一致性是因為有併發讀寫才有的問題,因此在理解一致性的問題時,一定要注意結合考慮併發讀寫的場景。
從客戶端角度,多程序併發訪問時,更新過的資料在不同程序如何獲取的不同策略,決定了不同的一致性。

  • 對於關係型資料庫,要求更新過的資料能被後續的訪問都能看到,這是強一致性。
  • 如果能容忍後續的部分或者全部訪問不到,則是弱一致性。
  • 如果經過一段時間後要求能訪問到更新後的資料,則是最終一致性。

可用性

可用性指“Reads and writes always succeed”,即服務一直可用,而且是正常響應時間。
對於一個可用性的分散式系統,每一個非故障的節點必須對每一個請求作出響應。也就是,該系統使用的任何演算法必須最終終止。這是一個很強的定義:即使是嚴重的網路錯誤,每個請求必須終止,如對服務降級等操作。
高的可用性主要是指系統能夠很好的為使用者服務,不出現使用者操作失敗或者訪問超時等使用者體驗不好的情況。可用性通常情況下可用性和分散式資料冗餘,負載均衡等有著很大的關聯。

分割槽容錯

分割槽容錯性指“the system continues to operate despite arbitrary message loss or failure of part of the system”,即分散式系統在遇到某節點或網路分割槽故障的時候,仍然能夠對外提供滿足一致性和可用性的服務。
分割槽容錯性和擴充套件性緊密相關。在分散式應用中,可能因為一些分散式的原因導致系統無法正常運轉。好的分割槽容錯性要求能夠使應用雖然是一個分散式系統,而看上去卻好像是在一個可以運轉正常的整體。比如
現在的分散式系統中有某一個或者幾個機器宕掉了,其他剩下的機器還能夠正常運轉滿足系統需求,或者是機器之間有網路異常,將分散式系統分隔未獨立的幾個部分,各個部分還能維持分散式系統的運作,這樣就具有好的分割槽容錯性。

BASE理論

BASE理論是指,Basically Available(基本可用)、Soft-state( 軟狀態/柔性事務)、Eventual Consistency(最終一致性)。是基於CAP定理演化而來,是對CAP中一致性和可用性權衡的結果。核心思想:即使無法做到強一致性,但每個業務根據自身的特點,採用適當的方式來使系統達到最終一致性。

1、基本可用:指分散式系統在出現故障的時候,允許損失部分可用性,保證核心可用。但不等價於不可用。比如:搜尋引擎0.5秒返回查詢結果,但由於故障,2秒響應查詢結果;網頁訪問過大時,部分使用者提供降級服務等。

2、軟狀態:軟狀態是指允許系統存在中間狀態,並且該中間狀態不會影響系統整體可用性。即允許系統在不同節點間副本同步的時候存在延時,如介面被使用的時候不能影響整體可用性。

3、最終一致性:

系統中的所有資料副本經過一定時間後,最終能夠達到一致的狀態,不需要實時保證系統資料的強一致性。最終一致性是弱一致性的一種特殊情況。BASE理論面向的是大型高可用可擴充套件的分散式系統,通過犧牲強一致性來獲得可用性。ACID是傳統資料庫常用的概念設計,追求強一致性模型。

ACID,指資料庫事務正確執行的四個基本要素的縮寫。包含:原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、永續性(Durability)。

Base理論為柔性事務

柔性事務和剛性事務

柔性事務滿足BASE理論(基本可用,最終一致)
剛性事務滿足ACID理論

本文主要圍繞分散式事務當中的柔性事務的處理方式進行討論。

柔性事務分為

  1. 兩階段型
  2. 補償型
  3. 非同步確保型
  4. 最大努力通知型幾種。 由於支付寶整個架構是SOA架構,因此傳統單機環境下資料庫的ACID事務滿足了分散式環境下的業務需要,以上幾種事務類似就是針對分散式環境下業務需要設定的。

什麼是XA介面

XA–eXtended Architecture 在事務中意為分散式事務 
XA由協調者(coordinator,一般為transaction manager)和參與者(participants,一般在各個資源上有各自的resource manager)共同完成。在MySQL中,XA事務有兩種。

什麼是JTA

作為java平臺上事務規範JTA(Java Transaction API)也定義了對XA事務的支援,實際上,JTA是基於XA架構上建模的,在JTA 中,事務管理器抽象為javax.transaction.TransactionManager介面,並通過底層事務服務(即JTS)實現。像很多其他的java規範一樣,JTA僅僅定義了介面,具體的實現則是由供應商(如J2EE廠商)負責提供,目前JTA的實現主要由以下幾種:
1.J2EE容器所提供的JTA實現(JBoss)
2.獨立的JTA實現:如JOTM,Atomikos.這些實現可以應用在那些不使用J2EE應用伺服器的環境裡用以提供分佈事事務保證。如Tomcat,Jetty以及普通的java應用。

2PC兩段提交

所謂的兩個階段是指:第一階段:準備階段(投票階段)和第二階段:提交階段(執行階段)

XA一般由兩階段完成,稱為two-phase commit(2PC)。 
階段一為準備階段,即所有的參與者準備執行事務並鎖住需要的資源。參與者ready時,向transaction manager彙報自己已經準備好。 
階段二為提交階段。當transaction manager確認所有參與者都ready後,向所有參與者傳送commit命令。 
如下圖所示:

XA的效能問題 
XA的效能很低。一個數據庫的事務和多個數據庫間的XA事務效能對比可發現,效能差10倍左右。因此要儘量避免XA事務,例如可以將資料寫入本地,用高效能的訊息系統分發資料。或使用資料庫複製等技術。 
只有在這些都無法實現,且效能不是瓶頸時才應該使用XA。

3PC三段提交

三階段提交(Three-phase commit),也叫三階段提交協議(Three-phase commit protocol),是二階段提交(2PC)的改進版本。

與兩階段提交不同的是,三階段提交有兩個改動點。

1、引入超時機制。同時在協調者和參與者中都引入超時機制。
2、在第一階段和第二階段中插入一個準備階段。保證了在最後提交階段之前各參與節點的狀態是一致的。

也就是說,除了引入超時機制之外,3PC把2PC的準備階段再次一分為二,這樣三階段提交就有CanCommit、PreCommit、DoCommit三個階段。

CanCommit階段

3PC的CanCommit階段其實和2PC的準備階段很像。協調者向參與者傳送commit請求,參與者如果可以提交就返回Yes響應,否則返回No響應。

1.事務詢問 協調者向參與者傳送CanCommit請求。詢問是否可以執行事務提交操作。然後開始等待參與者的響應。

2.響應反饋 參與者接到CanCommit請求之後,正常情況下,如果其自身認為可以順利執行事務,則返回Yes響應,並進入預備狀態。否則反饋No

PreCommit階段

協調者根據參與者的反應情況來決定是否可以執行事務的PreCommit操作。根據響應情況,有以下兩種可能。

假如協調者從所有的參與者獲得的反饋都是Yes響應,那麼就會執行事務的預執行。

1.傳送預提交請求 協調者向參與者傳送PreCommit請求,並進入Prepared階段。

2.事務預提交 參與者接收到PreCommit請求後,會執行事務操作,並將undo和redo資訊記錄到事務日誌中。

3.響應反饋 如果參與者成功的執行了事務操作,則返回ACK響應,同時開始等待最終指令。

假如有任何一個參與者向協調者傳送了No響應,或者等待超時之後,協調者都沒有接到參與者的響應,那麼就執行事務的中斷。

1.傳送中斷請求 協調者向所有參與者傳送abort請求。

2.中斷事務 參與者收到來自協調者的abort請求之後(或超時之後,仍未收到協調者的請求),執行事務的中斷。

doCommit階段

該階段進行真正的事務提交,也可以分為以下兩種情況。

執行提交

1.傳送提交請求 協調接收到參與者傳送的ACK響應,那麼他將從預提交狀態進入到提交狀態。並向所有參與者傳送doCommit請求。

2.事務提交 參與者接收到doCommit請求之後,執行正式的事務提交。並在完成事務提交之後釋放所有事務資源。

3.響應反饋 事務提交完之後,向協調者傳送Ack響應。

4.完成事務 協調者接收到所有參與者的ack響應之後,完成事務。

中斷事務 協調者沒有接收到參與者傳送的ACK響應(可能是接受者傳送的不是ACK響應,也可能響應超時),那麼就會執行中斷事務。

1.傳送中斷請求 協調者向所有參與者傳送abort請求

2.事務回滾 參與者接收到abort請求之後,利用其在階段二記錄的undo資訊來執行事務的回滾操作,並在完成回滾之後釋放所有的事務資源。

3.反饋結果 參與者完成事務回滾之後,向協調者傳送ACK訊息

4.中斷事務 協調者接收到參與者反饋的ACK訊息之後,執行事務的中斷。

在doCommit階段,如果參與者無法及時接收到來自協調者的doCommit或者rebort請求時,會在等待超時之後,會繼續進行事務的提交。(其實這個應該是基於概率來決定的,當進入第三階段時,說明參與者在第二階段已經收到了PreCommit請求,那麼協調者產生PreCommit請求的前提條件是他在第二階段開始之前,收到所有參與者的CanCommit響應都是Yes。(一旦參與者收到了PreCommit,意味他知道大家其實都同意修改了)所以,一句話概括就是,當進入第三階段時,由於網路超時等原因,雖然參與者沒有收到commit或者abort響應,但是他有理由相信:成功提交的機率很大。 )

2PC與3PC的區別

相對於2PC,3PC主要解決的單點故障問題,並減少阻塞,因為一旦參與者無法及時收到來自協調者的資訊之後,他會預設執行commit。而不會一直持有事務資源並處於阻塞狀態。但是這種機制也會導致資料一致性問題,因為,由於網路原因,協調者傳送的abort響應沒有及時被參與者接收到,那麼參與者在等待超時之後執行了commit操作。這樣就和其他接到abort命令並執行回滾的參與者之間存在資料不一致的情況。

TCC

TCC(Try-Confirm-Cancel),則是將業務邏輯分成try、confirm/cancel兩個階段執行,具體介紹見TCC事務機制簡介。其事務處理方式為:
1、 在全域性事務決定提交時,呼叫與try業務邏輯相對應的confirm業務邏輯;
2、 在全域性事務決定回滾時,呼叫與try業務邏輯相對應的cancel業務邏輯。
可見,TCC在事務處理方式上,是很簡單的:要麼呼叫confirm業務邏輯,要麼呼叫cancel邏輯

MQ分散式事物

 採用時效性高的 MQ,由對方訂閱訊息並監聽,有訊息時自動觸發事件
採用定時輪詢掃描的方式,去檢查訊息表的資料。

其他補償

做過支付寶交易介面的同學都知道,我們一般會在支付寶的回撥頁面和接口裡,解密引數,然後呼叫系統中更新交易狀態相關的服務,將訂單更新為付款成功。同時,只有當我們回撥頁面中輸出了 success 字樣或者標識業務處理成功相應狀態碼時,支付寶才會停止回撥請求。否則,支付寶會每間隔一段時間後,再向客戶方發起回撥請求,直到輸出成功標識為止。
其實這就是一個很典型的補償例子,跟一些 MQ 重試補償機制很類似。

一般成熟的系統中,對於級別較高的服務和介面,整體的可用性通常都會很高。如果有些業務由於瞬時的網路故障或呼叫超時等問題,那麼這種重試機制其實是非常有效的。

當然,考慮個比較極端的場景,假如系統自身有 bug 或者程式邏輯有問題,那麼重試 1W 次那也是無濟於事的。那豈不是就發生了“明明已經付款,卻顯示未付款不發貨”類似的悲劇?

其實為了交易系統更可靠,我們一般會在類似交易這種高級別的服務程式碼中,加入詳細日誌記錄的,一旦系統內部引發類似致命異常,會有郵件通知。同時,後臺會有定時任務掃描和分析此類日誌,檢查出這種特殊的情況,會嘗試通過程式來補償並郵件通知相關人員。

在某些特殊的情況下,還會有“人工補償”的,這也是最後一道屏障。