随着互联网公司的业务的不断拓展,数据量以及用户的不断增多,越来越多的公司的系统架构从最开始的单系统架构转为了多系统间的分布式架构,这种方式能够大大减少系统间的耦合性,同时也能缓解系统压力;但是同时也带来了系统间数据不一致的问题,在接口的调用和数据的传输写入等操作中,往往很难保证事务的可靠性。
通常分布式事务有以下几种解决方案:
1.基于数据库XA/JTA协议的方式(需要数据库厂商支持;JAVA组件有atomikos等);
2.异步校对数据的方式(支付宝、微信支付主动查询支付状态、对账的形式);
3.基于可靠消息(MQ)的解决方案;
4.TCC编程式解决方案;
其中基于MQ的事务解决方案适用于异步场景,且通用性较强,拓展性较高,本文将使用中间件RabbitMQ来解决分布式事务的问题。
以外卖配送场景为例,这里将系统简化为两个系统,一个为订单系统,该系统记录了用户下单的信息;另一个系统为运单系统,记录了订单的一系列配送信息。
此时将会产生一个问题,两个系统都为独立的系统,数据也分别存储于不同的数据库,在用户下单后,需要将订单系统的这份下单数据存储一份数据到运单系统,然而因为是跨系统的接口调用,使用事务注解@Transactional能否解决数据一致性的问题呢?
3.1.订单数据库中的订单表:
CREATE TABLE `table_order` (
`order_id` varchar(100) NOT NULL COMMENT '订单号',
`user_id` varchar(255) DEFAULT NULL COMMENT '用户编号',
`order_content` varchar(255) DEFAULT NULL COMMENT '订单内容(买了哪些东西,送货地址)',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.2.运单数据库中的运单表:
CREATE TABLE `table_dispatch` (
`order_id` varchar(100) NOT NULL COMMENT '订单编号',
`dispatch_seq` varchar(255) DEFAULT NULL COMMENT '调度流水号',
`dispatch_status` varchar(255) DEFAULT NULL COMMENT '调度状态',
`dispatch_content` varchar(255) DEFAULT NULL COMMENT '调度内容(送餐员,路线)',
PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.3 运单系统代码:
3.3.1 运单系统 http API 向外提供接口:
/**
* 运单系统http API
*/
@RestController
@RequestMapping("/dispatch-api")
public class DispatchController {
@Autowired
private DispatchService dispatchService;
// 下订单后,添加调度信息
@GetMapping("/dispatch")
public String lock(String orderId) throws Exception {
Thread.sleep(3000L); // 此处模拟业务耗时,接口调用者会认为超时
dispatchService.dispatch(orderId); // 将外卖订单分配给送餐小哥
return "ok";
}
}
3.3.2 运单系统调度相关,将运单数据存入数据库:
/**
* 运单系统http API
*/
@RestController
@RequestMapping("/dispatch-api")
public class DispatchController {
@Autowired
private DispatchService dispatchService;
// 下订单后,添加调度信息
@GetMapping("/dispatch")
public String lock(String orderId) throws Exception {
Thread.sleep(3000L); // 此处模拟业务耗时,接口调用者会认为超时
dispatchService.dispatch(orderId); // 将外卖订单分配给送餐小哥
return "ok";
}
}
3.3.3 运单系统启动类
@SpringBootApplication
public class DispatchApplication {
public static void main(String[] args) throws Exception {
new SpringApplicationBuilder(DispatchApplication.class).web(WebApplicationType.SERVLET)
.run(args);
}
}
3.4 订单系统代码:
3.4.1 订单系统orderservice,该类做了两件事,一是将订单数据插入订单数据库,另一件事是远程调用运单系统的api接口,通过http接口的形式将订单信息传给运单系统,并且让运单系统存储运单信息。
@Service
public class OrderService {
@Autowired
OrderDatabaseService orderDatabaseService;
/** 创建订单 */
@Transactional(rollbackFor = Exception.class) // 订单创建整个方法添加事务
public void createOrder(JSONObject orderInfo) throws Exception {
// 1. 订单信息 - 插入订单系统,订单数据库(事务-1)
orderDatabaseService.saveOrder(orderInfo);
// 2. 通过http接口发送订单信息到 运单系统
String result = callDispatchHttpApi(orderInfo);
if (!"ok".equals(result)) {
throw new Exception("订单创建失败,原因[运单接口调用失败]");
}
}
/**
* 通过http接口发送 运单系统,将订单号传过去
*
* @return 接口调用结果
*/
public String callDispatchHttpApi(JSONObject orderInfo) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
// 链接超时时间 > 3秒
requestFactory.setConnectTimeout(3000);
// 处理超时时间 > 2 秒
requestFactory.setReadTimeout(2000);
RestTemplate restTemplate = new RestTemplate(requestFactory);
String httpUrl = "http://127.0.0.1:8080/dispatch-api/dispatch?orderId=" + orderInfo.getString("orderId");
String result = restTemplate.getForObject(httpUrl, String.class);
return result;
}
}
3.4.2 订单系统测试类:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = OrderApplication.class)
public class OrderApplicationTests {
@Before
public void start() {
System.out.println("开始测试##############");
}
@After
public void finish() {
System.out.println("结束##############");
}
@Autowired
OrderService orderService;
@Test
public void orderCreate() throws Exception {
// 订单号生成
String orderId = UUID.randomUUID().toString();
JSONObject orderInfo = new JSONObject();
orderInfo.put("orderId", orderId);
orderInfo.put("userId", "hy");
orderInfo.put("orderContent", "重庆火锅");
orderService.createOrder(orderInfo);
System.out.println("订单创建成功");
}
}
在测试结果中可以看到,订单系统因为调用接口超时而发生了报错:org.springframework.web.client.ResourceAccessException: I/O error on GET request for “http://127.0.0.1:8080/dispatch-api/dispatch”: Read timed out; nested exception is java.net.SocketTimeoutException: Read timed out。
那么,因为我们在 createOrder 这个方法上添加了Transactional注解,该方法应该要回滚,去数据库中查看数据:
订单系统数据库的表 table_order:
运单系统数据库的表 table_dispatch:
可以看到,订单表里面没有数据,可是运单表却有数据,原因是虽然调用方虽然超时,但是被调用方却依然还在处理代码,这种情况下导致一个订单虽然被取消,但是配送员却接到了一个根本不存在的订单,显然这样是有问题的。
消息中间件则是将软件与软件之间的交互方式进行存储和管理的一种技术,可以理解为是一个消息队列,数据发送方将(生产者)数据发送到消息中间件,然后由数据接收端(消费者)来读取消息中间件的数据,在消费者未读取到生产者发送的数据前,数据将会存储在消息中间件中;常见的消息中间件有Active MQ,Rabbit MQ,Rocket MQ,Kafka,Redis等,在这里我们使用Rabbit MQ来进行消息的存储和消费。
在订单创建,将订单数据存入 table_order 后,不再调用运单系统的接口去将数据存入 table_dispatch中,而是将数据发送至 Rabbit MQ 中,运单系统再从MQ中读取数据存入运单系统的表中;但是此时需要解决两个问题:1.生产可靠:保证生产者一定能将数据发送到Rabbit MQ中;2.消费可靠:保证消费者一定能将MQ中的数据取出来正确消费掉。
3.1 在订单数据库中创建本地记录表 tb_distributed_message
CREATE TABLE `tb_distributed_message` (
`unique_id` varchar(100) NOT NULL COMMENT '唯一ID',
`msg_content` varchar(255) DEFAULT NULL COMMENT '消息内容',
`msg_status` int(11) DEFAULT NULL COMMENT '是否发送到MQ:0:已发送;1:未发送',
`create_time` datetime DEFAULT NULL COMMENT '消息创建时间',
PRIMARY KEY (`unique_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.1.1 为了确保数据一定发送到MQ中,在同一事务中,增加一个记录表的操作,记录每一条发往MQ的数据以及它的发送状态;同时,还需要开启Rabbit MQ的发布确认机制(在配置文件中添加 publisher-confirms: true),开启确认发布机制后,MQ准确受理消息会返回回执,若订单系统收到MQ发送的回执,那么将之前存入本地记录表 tb_distributed_message 的 msg_status 从1改为0。
3.1.2 为了确保消费者一定能收到MQ中的消息,需要手动开启ACK模式(配置文件中添加acknowledge-mode: MANUAL),由消费者控制消息的重发/清除/丢弃,并且还需要保证幂等性,防止数据的重复处理,一次用户操作,只对应一次数据处理,可以根据ID或者业务数据来进行去重(可利用redis的特性进行去重操作),这里将orderid设置为主键,利用数据库主键的特性进行去重操作;另外因为某些特殊原因(因为系统和数据库间的网络原因/消息内容格式错误等),导致消费端数据处理一直失败,那么可以将数据直接丢弃或者转移到死信队列(DLQ)。
3.2 订单系统代码修改
3.2.1 OrderService中不再调用远程接口,将数据写入MQ
@Service
public class OrderService {
@Autowired
OrderDatabaseService orderDatabaseService;
@Autowired
MQService mQService;
/** 创建订单 */
@Transactional(rollbackFor = Exception.class) // 订单创建整个方法添加事务
public void createOrder(JSONObject orderInfo) throws Exception {
// 1. 订单信息 - 插入订单系统,订单数据库(事务-1)
orderDatabaseService.saveOrder(orderInfo);
// 向mq中发送数据
mQService.sendMsg(orderInfo);
}
}
3.2.2 发送消息,修改本地表类
/**
* 这是一个发送MQ消息,修改消息表的地方
*
*/
@Service
@Transactional(rollbackFor = Exception.class)
public class MQService {
private final Logger logger = LoggerFactory.getLogger(MQService.class);
@Autowired
JdbcTemplate jdbcTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
@PostConstruct
public void setup() {
// 消息发送完毕后,则回调此方法 ack代表发送是否成功
rabbitTemplate.setConfirmCallback(new ConfirmCallback() {
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
// ack为true,代表MQ已经准确收到消息
if (!ack) {
return;
}
try {
// 2. 修改本地消息表的状态为“已发送”。删除、修改状态
String sql = "update tb_distributed_message set msg_status=1 where unique_id=?";
int count = jdbcTemplate.update(sql, correlationData.getId());
if (count != 1) {
logger.warn("警告:本地消息表的状态修改不成功");
}
} catch (Exception e) {
logger.warn("警告:修改本地消息表的状态时出现异常", e);
}
}
});
}
/**
* 发送MQ消息,修改本地消息表的状态
*
* @throws Exception
*/
public void sendMsg(JSONObject orderInfo) throws Exception {
// 1. 发送消息到MQ
// CorrelationData 当收到消息回执时,会附带上这个参数
rabbitTemplate.convertAndSend("createOrderExchange", "", orderInfo.toJSONString(),
new CorrelationData(orderInfo.getString("orderId")));
}
}
在此过程中可能出现生产者发送消息失败或者回执消息发送失败,那么我们可以另外再开启一个定时任务去周期性地检查本地记录表,如果发现在一定时间内消息的状态没有改变,那么可以进行消息重发。
3.3 运单系统代码修改
3.3.1 添加消费者
@Component
public class OrderDispatchConsumer {
private final Logger logger = LoggerFactory.getLogger(OrderDispatchConsumer.class);
@Autowired
DispatchService dispatchService;
@RabbitListener(queues = "orderDispatchQueue")
public void messageConsumer(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag)
throws Exception {
try {
// mq里面的数据转为json对象
JSONObject orderInfo = JSONObject.parseObject(message);
logger.warn("收到MQ里面的消息:" + orderInfo.toJSONString());
Thread.sleep(5000L);
// 执行业务操作,同一个数据不能处理两次,根据业务情况去重,保证幂等性。 (这里可以用redis记录处理情况)
String orderId = orderInfo.getString("orderId");
// 这里就是一个分配快递员...
dispatchService.dispatch(orderId);
// ack - 告诉MQ,已经收到消息
channel.basicAck(tag, false);
} catch (Exception e) {
// 异常情况 :根据需要去: 重发/ 丢弃
// 重发一定次数后, 丢弃, 日志告警,防止mq一直重发进入死循环(redis记录每条消息的处理次数)
channel.basicNack(tag, false, false);
// 系统 关键数据,永远是有人工干预
}
// 如果不给回复,就等这个consumer断开链接后,mq-server会继续推送
}
}
将订单系统启动,可以看到 table_order表:
tb_distributed_message 表:
mq中收到一条数据:
接着将运单系统启动起来:
发现运单系统通过消费mq的数据, table_dispatch 已经插入了数据:
至此,该方法已经可以解决先前所出现的问题,有效保证了数据的一致性和可靠性。
现实生产过程中所碰到的分布式的事务问题是多种多样的,本文只是提供了一个解决分布式事务数据一致性的思想,主要使用的是中间件的功能,但是,具体情况还是需要根据相应的业务功能去使用不同的组件和方法去解决。