引言
本文代碼已提交至Github(版本號:
52553aa6fe8b34ff162a1fb33e8f58494b4d2c3f
),有興趣的同學可以下載來看看:https://github.com/ylw-github/taodong-shop
閱讀本文前,有興趣的同學可以參考我之前寫的聚合支付的文章:
- 《淘東電商項目(52) -聚合支付開篇》
- 《淘東電商項目(53) -銀聯支付案例源碼分析》
- 《淘東電商項目(54) -銀聯支付案例(同步與異步)》
- 《淘東電商項目(55) -支付系統核心表設計》
- 《淘東電商項目(56) -支付系統分佈式事務的解決方案》
- 《淘東電商項目(57) -聚合支付(支付令牌接口)》
- 《淘東電商項目(58) -聚合支付(基於設計模式自動跳轉支付接口)》
- 《淘東電商項目(59) -聚合支付(集成銀聯支付)》
- 《淘東電商項目(60) -聚合支付(集成支付寶)》
- 《淘東電商項目(61) -聚合支付(基於模板方法設計模式管理支付回調)》
- 《淘東電商項目(62) -聚合支付(基於模板方法設計模式管理支付回調-支付寶)》
- 《淘東電商項目(63) -聚合支付(多線程日誌收集)》
- 《淘東電商項目(64) -聚合支付(XXL-JOB任務調度平臺整合)》
- 《淘東電商項目(65) -聚合支付(異步對賬)》
本文講解聚合支付最後的一個問題 - 分佈式事務。舉個例子,比如要增加一個“積分功能”,當第三方服務器異步返回支付成功結果,請求我們的支付服務器時,同時也要做積分增加的功能,如何能保證,支付結果插入數據庫成功的同時保證積分一定能增加成功呢?這裏涉及到了分佈式事務的問題,本文主要基於Rabbit來解決這個問題。
本文目錄結構:
l____引言
l____ 1.原理圖
l____ 2.積分數據庫建表
l____ 3.核心代碼
l________ 3.1 集成RabbitMQ
l________ 3.2 生產者代碼
l________ 3.3 消費者代碼
l____ 4.測試
1.原理圖
如上圖,如果支付成功,第三方支付服務器會請求項目的支付服務,返回支付結果,這個時候,我們代碼要處理的是如下步驟:
- 更新訂單狀態爲“已支付”,即
status
爲1(注意,這裏的方法使用了@Transactional
事務註解修飾) - 更新了支付狀態之後,會使用MQ來生產消息,生產增加積分消息MSG
- 如果這個時候程序出錯,會回滾,也就是訂單的狀態在數據庫中沒有修改,而已經增加了積分。
針對以上的問題,做出瞭如下的解決方案:
- 對於第2個步驟,使用RabbitMQ的消息確認機制,保證消息一定可以投遞到RabbitMQ服務器的增加積分隊列,消費者使用手動簽收的方式,保證消息一定可以消費到,並把增加積分消息更新到數據庫的積分表中。
- 對於第3個步驟,如果程序出錯了,會回滾,因此數據庫部分的代碼不生效,訂單的支付狀態沒變,所以增加多了一個支付狀態補償隊列,當支付狀態補償消費者接收到消息後,會檢查支付狀態是否已經修改,如果沒有修改,則更新訂單的狀態。
從上面的解決步驟,可以知道,使用RabbitMQ保證了積分一定可以更新本地數據庫,同時訂單狀態一定可以修改,達到最終一致性的效果,同時解決了分佈式事務的問題。
2.積分數據庫建表
講解前,先貼上積分數據庫的建表語句:
CREATE TABLE `integral` (
`ID` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
`USER_ID` int(11) DEFAULT NULL COMMENT '用戶ID',
`PAYMENT_ID` varchar(1024) DEFAULT NULL COMMENT '支付ID',
`INTEGRAL` varchar(32) DEFAULT NULL COMMENT '積分',
`AVAILABILITY` int(11) DEFAULT NULL COMMENT '是否可用',
`REVISION` int(11) DEFAULT NULL COMMENT '樂觀鎖',
`CREATED_BY` varchar(32) DEFAULT NULL COMMENT '創建人',
`CREATED_TIME` datetime DEFAULT NULL COMMENT '創建時間',
`UPDATED_BY` varchar(32) DEFAULT NULL COMMENT '更新人',
`UPDATED_TIME` datetime DEFAULT NULL COMMENT '更新時間',
PRIMARY KEY (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=47 DEFAULT CHARSET=utf8 COMMENT=' ';
3.核心代碼
3.1 集成RabbitMQ
RabbitMQ的搭建本文不再詳述,之前有講解過,有興趣的童鞋可以參閱之前寫過的文章: 《消息中間件系列教程(04) -RabbitMQ -簡介&安裝》,下面開始講解項目集成。
①添加maven依賴:
<!-- 添加springboot對amqp的支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
②applicatoin.yml配置:
spring:
rabbitmq:
####連接地址
host: 127.0.0.1
####端口號
port: 5672
####賬號
username: guest
####密碼
password: guest
### 地址
virtual-host: integral_host
###開啓消息確認機制 confirms
publisher-confirms: true
publisher-returns: true
③RabbitMQ配置文件:
@Component
public class RabbitmqConfig {
// 添加積分隊列
public static final String INTEGRAL_DIC_QUEUE = "integral_queue";
// 補單隊列,
public static final String INTEGRAL_CREATE_QUEUE = "integral_create_queue";
// 積分交換機
private static final String INTEGRAL_EXCHANGE_NAME = "integral_exchange_name";
// 1.定義訂單隊列
@Bean
public Queue directIntegralDicQueue() {
return new Queue(INTEGRAL_DIC_QUEUE);
}
// 2.定義補訂單隊列
@Bean
public Queue directCreateintegralQueue() {
return new Queue(INTEGRAL_CREATE_QUEUE);
}
// 2.定義交換機
@Bean
DirectExchange directintegralExchange() {
return new DirectExchange(INTEGRAL_EXCHANGE_NAME);
}
// 3.積分隊列與交換機綁定
@Bean
Binding bindingExchangeintegralDicQueue() {
return BindingBuilder.bind(directIntegralDicQueue()).to(directintegralExchange()).with("integralRoutingKey");
}
// 3.補單隊列與交換機綁定
@Bean
Binding bindingExchangeCreateintegral() {
return BindingBuilder.bind(directCreateintegralQueue()).to(directintegralExchange()).with("integralRoutingKey");
}
}
③在RabbitMQ控制檯增加virtual-host:
④分配guest對新增的virtual-host有用戶權限:
3.2 生產者代碼
①生產者代碼(注意裏面用了消息確認機制,且使用訂單的id作爲全局唯一id來解決冪等性的問題):
/**
* description: 生產者投遞積分
* create by: YangLinWei
* create time: 2020/5/19 11:37 上午
*/
@Component
@Slf4j
public class IntegralProducer implements RabbitTemplate.ConfirmCallback {
@Autowired
private RabbitTemplate rabbitTemplate;
@Transactional
public void send(JSONObject jsonObject) {
String jsonString = jsonObject.toJSONString();
System.out.println("jsonString:" + jsonString);
String paymentId = jsonObject.getString("paymentId");
// 封裝消息
Message message = MessageBuilder.withBody(jsonString.getBytes())
.setContentType(MessageProperties.CONTENT_TYPE_JSON).setContentEncoding("utf-8").setMessageId(paymentId)
.build();
// 構建回調返回的數據(消息id)
this.rabbitTemplate.setMandatory(true);
this.rabbitTemplate.setConfirmCallback(this);
CorrelationData correlationData = new CorrelationData(jsonString);
rabbitTemplate.convertAndSend("integral_exchange_name", "integralRoutingKey", message, correlationData);
}
// 生產消息確認機制 生產者往服務器端發送消息的時候,採用應答機制
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
String jsonString = correlationData.getId();
System.out.println("消息id:" + correlationData.getId());
if (ack) {
log.info(">>>使用MQ消息確認機制確保消息一定要投遞到MQ中成功");
return;
}
JSONObject jsonObject = JSONObject.parseObject(jsonString);
// 生產者消息投遞失敗的話,採用遞歸重試機制
send(jsonObject);
log.info(">>>使用MQ消息確認機制投遞到MQ中失敗");
}
}
②調用生產者處的代碼,在支付結果異步回調處處理(銀聯支付結果異步回調處處理UnionPayCallbackTemplate
,注意發送MQ使用了@Async
註解,不阻塞當前線程)注意下面模擬拋異常了:
@Override
public String asyncService(Map<String, String> verifySignature) {
String orderId = verifySignature.get("orderId"); // 獲取後臺通知的數據,其他字段也可用類似方式獲取
String respCode = verifySignature.get("respCode");
// 判斷respCode=00、A6後,對涉及資金類的交易,請再發起查詢接口查詢,確定交易成功後更新數據庫。
System.out.println("orderId:" + orderId + ",respCode:" + respCode);
// 1.判斷respCode是否爲已經支付成功斷respCode=00、A6後,
if (!(respCode.equals("00") || respCode.equals("A6"))) {
return failResult();
}
// 根據日誌 手動補償 使用支付id調用第三方支付接口查詢
PaymentTransactionEntity paymentTransaction = paymentTransactionMapper.selectByPaymentId(orderId);
if (paymentTransaction.getPaymentStatus().equals(PayConstant.PAY_STATUS_SUCCESS)) {
// 網絡重試中,之前已經支付過
return successResult();
}
// 2.將狀態改爲已經支付成功
paymentTransactionMapper.updatePaymentStatus(PayConstant.PAY_STATUS_SUCCESS + "", orderId+"","yinlian_pay");
// 3.調用積分服務接口增加積分(處理冪等性問題) MQ
addMQIntegral(paymentTransaction); // 使用MQ
int i = 1 / 0; // 支付狀態還是爲待支付狀態但是 積分缺增加
return successResult();
}
/**
* 基於MQ增加積分
*/
@Async
public void addMQIntegral(PaymentTransactionEntity paymentTransaction) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("paymentId", paymentTransaction.getPaymentId());
jsonObject.put("userId", paymentTransaction.getUserId());
jsonObject.put("integral", 100);
integralProducer.send(jsonObject);
}
3.3 消費者代碼
①首先看看支付狀態補償消費者代碼(注意這裏使用了手動簽收):
/**
* description: 支付回調檢查狀態,是否爲已經支付完成
* create by: YangLinWei
* create time: 2020/5/19 1:52 下午
*/
@Component
@Slf4j
public class PayCheckStateConsumer {
@Autowired
private PaymentTransactionMapper paymentTransactionMapper;
// 死信隊列(備胎) 消息被拒絕、隊列長度滿了 定時任務 人工補償
@RabbitListener(queues = "integral_create_queue")
public void process(Message message, @Headers Map<String, Object> headers, Channel channel) throws IOException {
try {
String messageId = message.getMessageProperties().getMessageId();
String msg = new String(message.getBody(), "UTF-8");
log.info(">>>messageId:{},msg:{}", messageId, msg);
JSONObject jsonObject = JSONObject.parseObject(msg);
String paymentId = jsonObject.getString("paymentId");
if (StringUtils.isEmpty(paymentId)) {
log.error(">>>>支付id不能爲空 paymentId:{}", paymentId);
basicNack(message, channel);
return;
}
// 1.使用paymentId查詢之前是否已經支付過
PaymentTransactionEntity paymentTransactionEntity = paymentTransactionMapper.selectByPaymentId(paymentId);
if (paymentTransactionEntity == null) {
log.error(">>>>支付id paymentId:{} 未查詢到", paymentId);
basicNack(message, channel);
return;
}
Integer paymentStatus = paymentTransactionEntity.getPaymentStatus();
if (paymentStatus.equals(PayConstant.PAY_STATUS_SUCCESS)) {
log.error(">>>>支付id paymentId:{} ", paymentId);
basicNack(message, channel);
return;
}
// 安全期間 主動調用第三方接口查詢
String paymentChannel = jsonObject.getString("paymentChannel");
int updatePaymentStatus = paymentTransactionMapper.updatePaymentStatus(PayConstant.PAY_STATUS_SUCCESS + "",
paymentId, paymentChannel);
if (updatePaymentStatus > 0) {
basicNack(message, channel);
return;
}
// 繼續重試
} catch (Exception e) {
e.printStackTrace();
basicNack(message, channel);
}
}
private void basicNack(Message message, Channel channel) throws IOException {
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
}
}
②增加積分消費者代碼(注意這裏使用了手動簽收)::
/**
* description: 積分服務消費者
* create by: YangLinWei
* create time: 2020/5/19 2:10 下午
*/
@Component
@Slf4j
public class IntegralConsumer {
@Autowired
private IntegralMapper integralMapper;
@RabbitListener(queues = "integral_queue")
public void process(Message message, @Headers Map<String, Object> headers, Channel channel) throws IOException {
try {
String messageId = message.getMessageProperties().getMessageId();
String msg = new String(message.getBody(), "UTF-8");
log.info(">>>messageId:{},msg:{}", messageId, msg);
JSONObject jsonObject = JSONObject.parseObject(msg);
String paymentId = jsonObject.getString("paymentId");
if (StringUtils.isEmpty(paymentId)) {
log.error(">>>>支付id不能爲空 paymentId:{}", paymentId);
basicNack(message, channel);
return;
}
// 使用paymentId查詢是否已經增加過積分 網絡重試間隔
IntegralEntity resultIntegralEntity = integralMapper.findIntegral(paymentId);
if (resultIntegralEntity != null) {
log.error(">>>>paymentId:{}已經增加過積分", paymentId);
// 已經增加過積分,通知MQ不要在繼續重試。
basicNack(message, channel);
return;
}
Integer userId = jsonObject.getInteger("userId");
if (userId == null) {
log.error(">>>>paymentId:{},對應的用戶userId參數爲空", paymentId);
basicNack(message, channel);
return;
}
Long integral = jsonObject.getLong("integral");
if (integral == null) {
log.error(">>>>paymentId:{},對應的用戶integral參數爲空", integral);
return;
}
IntegralEntity integralEntity = new IntegralEntity();
integralEntity.setPaymentId(paymentId);
integralEntity.setIntegral(integral);
integralEntity.setUserId(userId);
integralEntity.setAvailability(1);
// 插入到數據庫中
int insertIntegral = integralMapper.insertIntegral(integralEntity);
if (insertIntegral > 0) {
// 手動簽收消息,通知mq服務器端刪除該消息
basicNack(message, channel);
}
// 採用重試機制
} catch (Exception e) {
log.error(">>>>ERROR MSG:", e.getMessage());
basicNack(message, channel);
}
}
// 消費者獲取到消息之後 手動簽收 通知MQ刪除該消息
private void basicNack(Message message, Channel channel) throws IOException {
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
}
}
4.測試
依次啓動Eureka註冊中心、xxlsso單點登錄系統、member會員服務、pay支付服務、pay-web支付門戶服務、還有integral積分服務,啓動後如下圖:
啓動RabbitMQ服務(我的是Mac系統,已經啓動的可以忽略):
cd /usr/local/Cellar/rabbitmq/3.8.2/sbin
./rabbitmq-server -detached
①模擬新增訂單,瀏覽器輸入:http://localhost:8600/cratePayToken?payAmount=999&orderId=20200513141452&userId=27&productName=玉米香腸
②確認提交訂單,瀏覽器輸入:http://localhost:8079/pay?payToken=pay_88c6262f3a494ae98d0873283514abf5
可以看到當前數據庫,訂單狀態爲未支付:
③按照提示,使用銀聯支付,一步一步直至支付完成:
可以看到,訂單支付狀態爲已支付(也就是說訂單支付狀態補償消費者已經接收到消息,並處理訂單爲已支付):
而且積分表也增加了一條數據(也是是說增加積分消費者已收到消息,並增加了一條積分數據):
本文完!