上一篇Spring Cloud消息中间件抽象Stream介绍了Spring Cloud Stream的rabbitmq例子,本文介绍Spring Cloud Stream实现事务性消息的例子。
文章目录
什么是事务性消息
通过场景来看:
生成订单记录 -> MQ -> 增加积分,我们需要保证消息的发送与订单数据的插入要么都成功,要么都失败。
我们是应该先创建订单,还是先发送MQ消息?
1、先发送MQ消息:如果消息发送成功,而订单创建失败,没办法把消息收回来。
2、先创建订单:如果订单创建成功后MQ消息发送失败,抛出异常,因为两个操作在一个事务代码块中,所以订单数据会回滚。
但是网络是不稳定的,如果MQ端确实收到了这条消息,只是返回给客户端的响应丢失了,就出现跟1一样的问题。
这就是事务性消息的需求:本地事务 和 消息的发送 需要具有原子性。
RocketMQ事务性消息原理
RocketMQ支持这种事务性消息,它的主要逻辑分为两个流程:
-
事务消息发送及提交
1、发送 half消息
2、MQ服务端 响应消息写入发送结果
3、根据发送结果执行 本地事务 (如果写入失败,此时half消息 不可见, 本地逻辑不执行)
4、根据本地事务状态执行 Commit 或者 Rollback (Commit操作生成消息索引,消息对消费者 可见 ) -
回查流程:
1、对于长时间没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次 回查
2、Producer收到回查消息,检查回查消息对应的 本地事务状态
3、根据本地事务状态,重新 Commit 或者 Rollback
逻辑时序图:
消费端一致性实现思路
从上面的原理可以发现 事务消息 仅仅只是保证本地事务和MQ消息发送形成整体的 原子性 ,而投递到MQ服务器后,并无法保证消费者一定能消费成功。
消费者消费失败 后的处理方式,建议时记录异常信息然后 人工处理, 并不建议回滚上游服务数据,我们可以利用MQ的两个特性 重试 和 死信队列 来协助消费者处理。
生产端编码实践
安装并运行rocketmq,可以参考:https://zhuanlan.zhihu.com/p/85500306
github源码地址: https://github.com/guzhangyu/learn-spring-cloud/tree/master/spring-cloud-stream/spring-cloud-stream-transaction-sender
pom依赖
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.0.3</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
<!-- <version>2.2.1.RELEASE</version>-->
</dependency>
application.yaml配置
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test?serverTimeZone=UTC&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
cloud:
stream:
rocketmq:
binder:
name-server: 192.168.2.174:9876
enable-msg-trace: true
bindings:
output:
producer:
group: erp
transactional: true
bindings:
output:
destination: update-account-score
mybatis:
type-aliases-package: com.learn.springcloud.dao
mapper-locations: classpath:mybatis/mapper/*.xml
消息发送代码
发送消息的类
import com.learn.springcloud.dao.OrderMapper;
import com.learn.springcloud.entity.Order;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Date;
/**
* @Author zhangyugu
* @Date 2020/8/13 3:45 下午
* @Version 1.0
*/
@Service
public class RocketMqTestService {
@Autowired
RocketMQTemplate rocketMQTemplate;
@Autowired
OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public void testTransaction() {
Order order = new Order();
order.setTradeId(1L);
order.setItemId(1L);
order.setItemName("item1");
order.setItemPrice(new BigDecimal("0.3"));
order.setNum(4);
order.setAccountId(1L);
order.setGmtCreate(new Date());
orderMapper.insert(order);
// 事务id
String transactionId = "trans-1";
rocketMQTemplate.sendMessageInTransaction("erp",
"update-account-score",
MessageBuilder.withPayload(order)
.setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId)
.setHeader("share_id", 3).build(),
4L
);
System.out.println(" prepare 消息发送成功");
// 这里消息发送只是prepare发送,
// 后面消息队列中prepare成功后,在TestTransactionListener中的executeLocalTransaction的方法中决定是否要提交本地事务
}
}
发送之后用于控制原子性的类
import com.alibaba.fastjson.JSON;
import com.learn.springcloud.dao.OrderMapper;
import com.learn.springcloud.entity.Order;
import org.apache.rocketmq.spring.annotation.RocketMQTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionState;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import java.util.List;
/**
* @Author zhangyugu
* @Date 2020/8/13 3:55 下午
* @Version 1.0
*/
@RocketMQTransactionListener(txProducerGroup = "erp")
public class TestTransactionListener implements RocketMQLocalTransactionListener {
@Autowired
OrderMapper orderMapper;
/**
* rocketmq 消息发送成功之后,提交本地事务
*/
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
Order order = JSON.parseObject(new String((byte[])message.getPayload()), Order.class);
Long args = (Long) o;
System.out.println(String.format("half message\npayload:%s, arg:%s, transactionId:%s", order, args, message.getHeaders().get(RocketMQHeaders.TRANSACTION_ID)));
return RocketMQLocalTransactionState.COMMIT;
}
/**
* rocketmq 回查时,告诉它要提交,还是回滚
* @param message
* @return
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
Order order = JSON.parseObject(new String((byte[])message.getPayload()), Order.class);
List<Order> orders = orderMapper.queryByTradeAndItem(order.getTradeId(), order.getItemId());
// 根据message去查询本地事务是否执行成功,如果成功,则commit
if(orders.size() > 0){
return RocketMQLocalTransactionState.COMMIT;
}else {
return RocketMQLocalTransactionState.ROLLBACK;
}
}
}
运行结果
half message
payload:Order(id=null, tradeId=1, itemId=1, itemName=item1, itemPrice=0.3, num=4, accountId=1, gmtCreate=Sat Aug 15 13:53:14 CST 2020, gmtModify=null), arg:4, transactionId:trans-1
prepare 消息发送成功
从这个运行结果可以看出,在消息发送之后,收到rocketmq的发送结果通知后才提交的本地事务。
消费端编码实践
github源码地址: https://github.com/guzhangyu/learn-spring-cloud/tree/master/spring-cloud-stream/spring-cloud-stream-transaction-receiver
pom依赖
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.0.3</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
</dependency>
application.yaml 配置
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test?serverTimeZone=UTC&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
cloud:
stream:
rocketmq:
binder:
name-server: 192.168.2.174:9876
enable-msg-trace: true
bindings:
input:
consumer:
delayLevelWhenNextConsume: -1
bindings:
input:
destination: update-account-score
group: erp
consumer:
concurrency: 20
maxAttempts: 2
inputDlq:
destination: update-account-score
group: '%DLQ%${spring.cloud.stream.bindings.input.group}'
consumer:
concurrency: 20
mybatis:
type-aliases-package: com.learn.springcloud.dao
mapper-locations: classpath:mybatis/mapper/*.xml
消息接收代码
import com.learn.springcloud.dao.AccountMapper;
import com.learn.springcloud.entity.Account;
import com.learn.springcloud.entity.Order;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.messaging.SubscribableChannel;
import org.springframework.transaction.annotation.Transactional;
/**
* @Author zhangyugu
* @Date 2020/8/13 4:21 下午
* @Version 1.0
*/
@EnableBinding(TestInput.class)
@Slf4j
public class TestConsumer {
@Autowired
AccountMapper accountMapper;
@StreamListener(TestInput.TEST_INPUT)
@Transactional(rollbackFor = Exception.class)
public void input(Order order) {
// throw new IllegalArgumentException("测试失败");
Account account = accountMapper.selectById(order.getAccountId());
if(account == null){
throw new IllegalArgumentException("该用户不存在: " + order.getAccountId());
}
account.setScore(account.getScore() + 1);
accountMapper.updateScore(account);
}
@StreamListener(TestInput.TEST_DLQ_INPUT)
@Transactional(rollbackFor = Exception.class)
public void dlqInput(Order order) {
// throw new IllegalArgumentException("dlq测试失败");
log.error("update-account-score失败:{}", order);
}
}
interface TestInput {
String TEST_INPUT = "input";
String TEST_DLQ_INPUT = "inputDlq";
@Input(TEST_INPUT)
SubscribableChannel input();
@Input(TEST_DLQ_INPUT)
SubscribableChannel inputDlq();
}
疑问阐述
1、yaml配置中的group 和 destination到底有什么用?
结合RocketMq-console-ng可以发现,在发送的时候topic用的是destination,但是进入重试队列或者死信队列时却用的是%RETRY%group 和 %DLQ%group。
从RocketMq的概念来看:
Producer Group 标识发送同一类消息的Producer,通常发送逻辑一致。发送普通消息时,仅标识使用,并无特别用处。若事务消息,如果发送某条消息的producer-A宕机,使得事务消息一直处于PREPARED状态并超时,则broker会回查同一个group的其他producer,确认这条消息应该commit 还是 rollback。
Consumer Group标识一类Consumer的集合名称,这类Consumer通常消费一类消息,且消费逻辑一致。同一个Consumer Group下的各个实例将共同消费topic的消息,起到负载均衡的作用。
注:RocketMQ要求同一个Consumer Group的消费者必须要拥有相同的注册信息,即必须要听一样的topic(并且tag也一样)。
Topic 标识一类消息的逻辑名称,消息的逻辑管理单位。无论消息生产还是消费,都需要指定Topic。
从这段话来看,发送的时候destination作为topic;但是消费的时候如果失败,则会根据group作为topic名称的构造依据,来新建重试和死信队列。
2、用来控制发送消息 和 本地事务 的原子性的注解RocketMQTransactionListener 并没有体现destination,而仅仅到group级别,这是何解?难道要在方法内部判断是哪个topic的消息?