1、分布式事务解决方案之最大努力通知
1.1 什么是最大努力通知
1)介绍
最大努力通知也是一种解决分布式事务的方案,下边是一个是充值的例子:
若通知失败,则充值系统按策略进行重复通知
3、账户系统接收到充值结果通知修改充值状态。
4、账户系统未接收到通知会主动调用充值系统的接口查询充值结果。
2)目标
通过上边的例子我们总结最大努力通知方案的目标:
目标:发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。
具体包括:
1、有一定的消息重复通知机制。
因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
2、消息校对机制。
如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息 信息来满足需求。
3)最大努力通知与可靠消息一致性不同
1、解决方案思想不同
可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知 方来保证。
最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接 收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
2、两者的业务应用场景不同
可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。
最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。
3、技术解决方向不同
可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。
最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消 息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)。
1.2 解决方案
通过对最大努力通知的理解,采用MQ的ack机制就可以实现最大努力通知。
方案1:
方案2:
本方案也是利用MQ的ack机制,与方案1不同的是应用程序向接收通知方发送通知,如下图:
方案1和方案2的不同点:
1、方案1中接收通知方与MQ接口,即接收通知方案监听 MQ,此方案主要应用与内部应用之间的通知。
2、方案2中由通知程序与MQ接口,通知程序监听MQ,收到MQ的消息后由通知程序通过互联网接口协议调用接收 通知方。此方案主要应用于外部应用之间的通知,例如支付宝、微信的支付结果通知。
1.3 RocketMQ实现最大努力通知型事务
1、业务说明
本实例通过RocketMq中间件实现最大努力通知型分布式事务,模拟充值过程。 本案例有账户系统和充值系统两个微服务,其中账户系统的数据库是bank1数据库,其中有张三账户。充值系统的 数据库使用bank1_pay数据库,记录了账户的充值记录。
2、程序组成部分
本示例程序组成部分如下:
数据库:MySQL-5.7.25 包括bank1和bank1_pay两个数据库
JDK:64位 jdk1.8.0_172
rocketmq 服务端:RocketMQ-4.8.0
rocketmq 客户端:RocketMQ-Spring-Boot-starter.2.0.2-RELEASE
微服务框架:spring-boot-2.1.3、spring-cloud-Greenwich.RELEASE
微服务及数据库的关系 :
notifymsg-demo-bank1 银行1,操作张三账户, 连接数据库bank1
notifymsg-demo-pay 银行2,操作充值记录,连接数据库bank1_pay
本示例程序技术架构如下:
3、环境准备
1)数据库
bank1
CREATE DATABASE `bank1` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
DROP TABLE IF EXISTS `account_info` ;
CREATE TABLE `account_info` (
`id` bigint (20) NOT NULL AUTO_INCREMENT,
`account_name` varchar (100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '户 主姓名',
`account_no` varchar (100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '银行卡号',
`account_password` varchar (100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '帐户密码',
`account_balance` double NULL DEFAULT NULL COMMENT '帐户余额',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic ;
INSERT INTO `account_info`
VALUES
(2, '张三的账户', '1', '', 10000) ;
DROP TABLE IF EXISTS `de_duplication` ;
CREATE TABLE `de_duplication` (
`tx_no` varchar (64) COLLATE utf8_bin NOT NULL,
`create_time` datetime (0) NULL DEFAULT NULL,
PRIMARY KEY (`tx_no`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic ;
bank1_pay
CREATE DATABASE `bank1_pay` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci' ;
CREATE TABLE `account_pay` (
`id` varchar (64) COLLATE utf8_bin NOT NULL,
`account_no` varchar (100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '账号',
`pay_amount` double NULL DEFAULT NULL COMMENT '充值余额',
`result` varchar (20) COLLATE utf8_bin DEFAULT NULL COMMENT '充值结果:success,fail',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic ;
2)mq
cd D:\developsoftware2\rocketmq-all-4.8.0-bin-release\bin
//启动 nameserver
start mqnamesrv.cmd
//启动broker
start mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true
3)启动eureka-server注册中心
4、代码实现-配置
bank1,bank2共同配置
引入依赖:
父工程锁定依赖
<properties>
<java.version>1.8</java.version>
<spring.cloud.version>Greenwich.RELEASE</spring.cloud.version>
<spring-cloud-alibaba.version>2.1.0.RELEASE</spring-cloud-alibaba.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring.cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.0.2</version>
</dependency>
</dependencies>
</dependencyManagement>
子工程引入依赖:
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.0.2</version>
</dependency>
</dependencies>
bank1配置:application.properties
server.port=56081
swagger.enable = true
eureka.client.serviceUrl.defaultZone = http://localhost:7001/eureka/
spring.datasource.driver-class-name = com.mysql.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost:3306/bank1?useUnicode=true
spring.datasource.username = root
spring.datasource.password = mysql
rocketmq.producer.group = producer_notifymsg_bank1
rocketmq.name-server = 127.0.0.1:9876
logging.level.root = info
logging.level.org.springframework.web = info
bank1_pay配置:application.properties
server.port=56082
swagger.enable = true
eureka.client.serviceUrl.defaultZone = http://localhost:7001/eureka/
spring.datasource.driver-class-name = com.mysql.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost:3306/bank1_pay?useUnicode=true
spring.datasource.username = root
spring.datasource.password = mysql
rocketmq.producer.group = producer_notifymsg_pay
rocketmq.name-server = 127.0.0.1:9876
logging.level.root = info
logging.level.org.springframework.web = info
5、代码实现-实现
notifymsg-bank1-demo
实现如下功能:
1、监听MQ,接收充值结果,根据充值结果完成账户金额修改。
2、主动查询充值系统,根据充值结果完成账户金额修改。
model:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AccountChangeEvent implements Serializable {
/**
* 账号
*/
private String accountNo;
/**
* 变动金额
*/
private double amount;
/**
* 事务号
*/
private String txNo;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AccountPay implements Serializable {
/**
* 事务号
*/
private String id;
/**
* 账号
*/
private String accountNo;
/**
* 变动金额
*/
private double payAmount;
/**
* 充值结果
*/
private String result;
}
Dao:
package com.zengqingfa.notifymsg.bank1.dao;
import org.apache.ibatis.annotations.*;
import org.springframework.stereotype.Component;
@Mapper
@Component
public interface AccountInfoDao {
/**
* 修改账户金额
* @param accountNo
* @param amount
* @return
*/
@Update("update account_info set account_balance=account_balance+#{amount} where account_no=#{accountNo}")
int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
/**
* 查询幂等记录,用于幂等控制
* @param txNo
* @return
*/
@Select("select count(1) from de_duplication where tx_no = #{txNo}")
int isExistTx(String txNo);
/**
* 添加事务记录,用于幂等控制
* @param txNo
* @return
*/
@Insert("insert into de_duplication values(#{txNo},now());")
int addTx(String txNo);
}
service:
public interface AccountInfoService {
/**
* 更新账户
* @param accountChange
*/
void updateAccountBalance(AccountChangeEvent accountChange);
/**
* 主动查询结果
* @param tx_no
* @return
*/
AccountPay queryPayResult(String tx_no);
}
package com.zengqingfa.notifymsg.bank1.service.impl;
import com.alibaba.fastjson.JSONObject;
import com.zengqingfa.notifymsg.bank1.dao.AccountInfoDao;
import com.zengqingfa.notifymsg.bank1.entity.AccountPay;
import com.zengqingfa.notifymsg.bank1.feign.PayClient;
import com.zengqingfa.notifymsg.bank1.model.AccountChangeEvent;
import com.zengqingfa.notifymsg.bank1.service.AccountInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
*
* @fileName: AccountInfoServiceImpl
* @author: zengqf3
* @date: 2021-4-23 10:01
* @description:
*/
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
private AccountInfoDao accountInfoDao;
@Autowired
private PayClient payClient;
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAccountBalance(AccountChangeEvent accountChange) {
//幂等
if (accountInfoDao.isExistTx(accountChange.getTxNo()) > 0) {
log.info("已处理消息:{}", JSONObject.toJSONString(accountChange));
return;
}
//更新
accountInfoDao.updateAccountBalance(accountChange.getAccountNo(), accountChange.getAmount());
accountInfoDao.addTx(accountChange.getTxNo());
}
@Override
public AccountPay queryPayResult(String tx_no) {
//主动查询充值系统充值结果
AccountPay accountPay = payClient.getPayResult(tx_no);
String result = accountPay.getResult();
log.info("充值结果:{}", result);
if ("success".equals(result)) {
//更新账户余额
AccountChangeEvent accountChangeEvent = new AccountChangeEvent();
accountChangeEvent.setAccountNo(accountPay.getAccountNo());
accountChangeEvent.setAmount(accountPay.getPayAmount());
accountChangeEvent.setTxNo(accountPay.getId());
updateAccountBalance(accountChangeEvent);
}
return accountPay;
}
}
mq监听:
package com.zengqingfa.notifymsg.bank1.mq;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.zengqingfa.notifymsg.bank1.entity.AccountPay;
import com.zengqingfa.notifymsg.bank1.model.AccountChangeEvent;
import com.zengqingfa.notifymsg.bank1.service.AccountInfoService;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
*
* @fileName: NotifyMsgListener
* @author: zengqf3
* @date: 2021-4-23 10:12
* @description:
*/
@RocketMQMessageListener(topic = "topic_notifymsg", consumerGroup = "notifymsg_consumer_group")
@Component
@Slf4j
public class NotifyMsgListener implements RocketMQListener<AccountPay> {
@Autowired
AccountInfoService accountInfoService;
@Override
public void onMessage(AccountPay accountPay) {
log.info("消费消息:{}", JSON.toJSONString(accountPay));
AccountChangeEvent accountChangeEvent = new AccountChangeEvent();
accountChangeEvent.setAmount(accountPay.getPayAmount());
accountChangeEvent.setAccountNo(accountPay.getAccountNo());
accountChangeEvent.setTxNo(accountPay.getId());
accountInfoService.updateAccountBalance(accountChangeEvent);
log.info("处理消息完成:{}", JSON.toJSONString(accountChangeEvent));
}
}
远程调用充值系统:
@FeignClient(value = "dtx-notifymsg-demo-pay", fallback = PayClientFallBack.class)
public interface PayClient {
@GetMapping(value = "/pay/payresult/{txNo}")
AccountPay getPayResult(@PathVariable("txNo") String txNo);
}
@Component
public class PayClientFallBack implements PayClient {
@Override
public AccountPay getPayResult(String txNo) {
AccountPay accountPay = new AccountPay();
accountPay.setResult("fail");
return accountPay;
}
}
controller:
package com.zengqingfa.notifymsg.bank1.controller;
import com.zengqingfa.notifymsg.bank1.entity.AccountPay;
import com.zengqingfa.notifymsg.bank1.service.AccountInfoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
*
* @fileName: AccountInfoController
* @author: zengqf3
* @date: 2021-4-23 10:29
* @description:
*/
@RestController
public class AccountInfoController {
@Autowired
private AccountInfoService accountInfoService;
//主动查询充值结果
@GetMapping(value = "/payresult/{txNo}")
public AccountPay result(@PathVariable("txNo") String txNo) {
AccountPay accountPay = accountInfoService.queryPayResult(txNo);
return accountPay;
}
}
notifymsg-bank1-pay-demo
需要实现如下功能:
1、充值接口 2、充值完成要通知
3、充值结果查询接口
entity:
package com.zengqingfa.notifymsg.bank1pay.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AccountPay implements Serializable {
/**
* 事务号
*/
private String id;
/**
* 账号
*/
private String accountNo;
/**
* 变动金额
*/
private double payAmount;
/**
* 充值结果
*/
private String result;
}
Dao:
package com.zengqingfa.notifymsg.bank1pay.dao;
import com.zengqingfa.notifymsg.bank1pay.entity.AccountPay;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springframework.stereotype.Component;
@Mapper
@Component
public interface AccountPayDao {
/**
* 插入充值记录
* @param accountPay
* @return
*/
@Insert("insert into account_pay(id,account_no,pay_amount,result) values(#{param.id},#{param.accountNo},#{param.payAmount},#{param.result})")
int insertAccountPay(@Param("param") AccountPay accountPay);
@Select("select id,account_no accountNo,pay_amount payAmount,result from account_pay where id=#{txNo}")
AccountPay findByIdTxNo(@Param("txNo") String txNo);
}
service:
public interface AccountPayService {
/**
* 充值
* @param accountPay
* @return
*/
AccountPay insertAccountPay(AccountPay accountPay);
/**
* 查询充值记录
* @param txNo
* @return
*/
AccountPay getAccountPay(String txNo);
}
package com.zengqingfa.notifymsg.bank1pay.service.impl;
import com.zengqingfa.notifymsg.bank1pay.dao.AccountPayDao;
import com.zengqingfa.notifymsg.bank1pay.entity.AccountPay;
import com.zengqingfa.notifymsg.bank1pay.service.AccountPayService;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
*
* @fileName: AccountPayServiceImpl
* @author: zengqf3
* @date: 2021-4-23 9:30
* @description:
*/
@Service
public class AccountPayServiceImpl implements AccountPayService {
@Autowired
AccountPayDao accountPayDao;
@Autowired
RocketMQTemplate rocketMQTemplate;
@Override
public AccountPay insertAccountPay(AccountPay accountPay) {
accountPay.setResult("success");
int count = accountPayDao.insertAccountPay(accountPay);
if (count > 0) {
//发送充值记录
rocketMQTemplate.convertAndSend("topic_notifymsg", accountPay);
return accountPay;
}
return null;
}
@Override
public AccountPay getAccountPay(String txNo) {
return accountPayDao.findByIdTxNo(txNo);
}
}
controller:
package com.zengqingfa.notifymsg.bank1pay.controller;
import com.zengqingfa.notifymsg.bank1pay.entity.AccountPay;
import com.zengqingfa.notifymsg.bank1pay.service.AccountPayService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
*
* @fileName: AccountPayController
* @author: zengqf3
* @date: 2021-4-23 9:43
* @description:
*/
@RestController
public class AccountPayController {
@Autowired
AccountPayService accountPayService;
/**
* 充值
* @param accountPay
* @return
*/
@GetMapping(value = "/paydo")
public AccountPay pay(AccountPay accountPay) {
//事务号
String txNo = UUID.randomUUID().toString();
accountPay.setId(txNo);
return accountPayService.insertAccountPay(accountPay);
}
/**
* 查询充值结果
* @param txNo
* @return
*/
@GetMapping(value = "/payresult/{txNo}")
public AccountPay getPayResult(@PathVariable("txNo") String txNo) {
return accountPayService.getAccountPay(txNo);
}
}
6、测试
- 充值系统充值成功,账户系统主动查询充值结果,修改账户金额。
- 充值系统充值成功,发送消息,账户系统接收消息,修改账户金额。
- 账户系统修改账户金额幂等测试。
1.4 小结
可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性,本案例使用了RocketMQ作为 消息中间件,RocketMQ主要解决了两个功能:
1、本地事务与消息发送的原子性问题。
2、事务参与方接收消息的可靠性。
可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消 息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。
/**
* 查询充值结果
* @param txNo
* @return
*/
@GetMapping(value = "/payresult/{txNo}")
public AccountPay getPayResult(@PathVariable("txNo") String txNo) {
return accountPayService.getAccountPay(txNo);
}
6、测试
- 充值系统充值成功,账户系统主动查询充值结果,修改账户金额。
- 充值系统充值成功,发送消息,账户系统接收消息,修改账户金额。
- 账户系统修改账户金额幂等测试。
1.4 小结
可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性,本案例使用了RocketMQ作为 消息中间件,RocketMQ主要解决了两个功能:
1、本地事务与消息发送的原子性问题。
2、事务参与方接收消息的可靠性。
可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消 息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。