涉及源码github地址:
https://github.com/lazy-share/lazy-couse-rabbitmq.git
持续关注,后续将继续更新相关文章:RabbitMQ集群搭建、RabbitMQ客户端源码分析等。
推荐相关文章阅读:
入门RabbitMQ
RabbitMQ核心原理和知识点
业务新需求:
1、某公司自主研发了一套电商系统,前期为了推广准备增加积分功能。
2、用户下单且支付成功则增加100积分,支付失败则扣减100积分,支付后暂时不支持退款。
复盘现状:
1、该电商系统采用微服务架构,使用基于http协议的springcloud框架实现,订单中心和积分中心目前独立应用部署,且独立DB。
2、现有线上逻辑是当用户下单成功用户支付后,当收到支付中心成功回调时判断预支付流水状态和订单状态后修改订单状态为已支付和修改预支付流水状态为success,现在需要增加调积分中心增加积分功能。
3、另外配套一个定时任务,定时关闭订单,在关闭订单时为了防止回调异常,所以会主动查询支付中心当前准备关闭订单的实际支付状态,如果确实没有支付则关闭订单,否则主动更新订单为已付款。如果补偿过程异常,则写入异常表由人工介入处理。
可行的解决方案:
1、由于积分中心属于独立springcloud微服务,所以可以通过springcloud feign(http)直接调积分中心增加积分服务接口,但是如果调用积分中心失败可能会影响订单状态的更新,虽然可以分开两个事务,但是仍然存在调用增加积分接口失败的可能,如果失败,即使不影响订单状态的修改,也会导致用户积分没有正确被增加,影响用户体验和对系统的信任,怎么补偿失败的调用是个问题。
2、可以通过本地DB记录消息表的方式加定时调度的方式来实现积分的增加,但是这种方式仍然需要积分中心给接口文档,且要开通访问网络权限等问题,如果中间变更字段则双方都要同步变更,变更服务器也是如此,要修改网络策略,也就是上下游系统存在强关联关系,不能乱搞,否则可能影响下游或上游系统。另外调度任务需要消耗订单中心服务器资源。同时防止超时问题导致重复调用,所以积分中心要么支持幂等性,要么提供根据业务唯一id查询处理结果的接口。
4、可以通过发送增加积分的消息到消息中间件,由积分中心订阅对应的队列进行消费,但是需要保证消息可靠发送以及可靠消费,最大程度保证不能丢失消息,目前公司已经搭建有现成的rabbitmq服务,所以可以直接对接上去,基于消息确认机制保证消息可靠性。但是由于rabbitmq由于网络问题可能导致错过某条消息的确认而重复发送消息给消费者,仍然需要积分中心接口支持幂等性设计。除此之外,如果过程中消息中间件挂了,也是个问题,这又回到第1个方案导致用户积分没有正确被增加的问题,即使rabbitmq采用高可用,但仍然不能保证100%可用。此时某个高级开发感慨道:如果能有一种方式既能够正确增加积分,又能解耦系统、又能够保证可用性跟订单中心保持一致那基本就完美了。
结论:
经过多次会议讨论,最终一致决定采用第2个方案 + 第3个方案配套起来解决来实现那位高级开发的感慨,整体设计草图如下:
其中红色部分为该需求新增的功能点。整体设计描述如下:
1、订单中心新增一张增加积分日志表。
2、当支付中心回调成功状态时,表示用户支付成功,则往本地积分日志表插入一条数据,状态为待发送,表示需要为该用户增加积分100。为了防止消息记录表随时间推移过大可以定时归档已经发送且3个月前的旧数据。
3、如果回调时更新订单状态失败,或插入积分日志时异常,则整个事务回滚,不更新订单状态且不插入积分日志。等待订单关闭任务调度时根据查询结果进行补偿(更新订单状态和插入积分日志),如果补偿过程异常,则写入异常表由人工介入处理,这部分是原有逻辑不变。
4、新增一个定时轮询积分日志表定时器,查询为待发送状态的数据发送到消息中间件rabbitmq,通过发送者确认机制保证数据可靠落盘,然后修改积分日志状态为已发送,如果过程任何异常则放弃等待下次轮询,如果某条消息轮询异常次数超过3次(例如消息中间件挂了)则发送邮件的方式由人工介入处理。
5、积分中心通过订阅对应队列的方式消费增加积分消息数据,一旦增加成功则发送ack确认消息给rabbitmq服务,rabbitmq服务将标记该消息为删除状态。
6、为了防止由于重复消费导致重复增加积分,积分中心还要支持幂等性。通过本地创建消息确认记录表的方式记录rabbitmq消息序列号,为了防止消息记录表随时间推移过大可以定时清除已经确认且3个月前的旧消息。
表设计
下面是订单中心积分日志表DDL:
下面是积分中心消息确认日志表DDL:
创建示例项目
为了更真实些,我们创建了两个数据库:ordercenter和integralcenter,那么接下来我们创建两个springboot项目module,数据库层面我们选择使用springdata,项目截图如下:
common: 通用模块,放置一些工具类等。
ordercenter: 订单中心项目
integralcenter: 积分中心项目
父pom.xml代码如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<modules>
<module>common</module>
<module>ordercenter</module>
<module>integralcenter</module>
</modules>
<groupId>com.lazy.course</groupId>
<artifactId>lazy-course-rabbitmq</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.xxx.common</groupId>
<artifactId>common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.11</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
订单中心关键代码
项目结构如下:
为了简便demo的演示,项目工程不创建service层,相关事务注解在相关方法上。
pom.xml代码如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lazy.course</groupId>
<artifactId>lazy-course-rabbitmq</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath> <!-- lookup parent from repository -->
</parent>
<groupId>com.xxx.ordercenter</groupId>
<artifactId>ordercenter</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ordercenter</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>com.xxx.common</groupId>
<artifactId>common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.properties文件内容如下:
# tomcat配置
server.port=8081
server.tomcat.accesslog.prefix=access_log
server.tomcat.accesslog.directory=./logs
server.tomcat.accesslog.enabled=true
# 应用配置
spring.application.name=ordercenter
# servler、json配置
server.servlet.context-path=/ordercenter/api
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
spring.jackson.default-property-inclusion=non_null
# 数据库配置
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/ordercenter?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=
# 日志配置
logging.config=classpath:logback.xml
logging.level.org.springframework.web=INFO
logging.level.org.hibernate=INFO
spring.output.ansi.enabled=DETECT
# jap、hibernate配置
spring.jpa.show-sql=false
spring.data.jpa.repositories.enabled=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.MySQL5Dialect
spring.jpa.properties.hibernate.format_sql=true
############# RabbitMQ 基本配置
spring.rabbitmq.host=192.168.137.101
spring.rabbitmq.port=5672
spring.rabbitmq.username=lazy
spring.rabbitmq.password=111111
spring.rabbitmq.virtual-host=vhost_hello
############# RabbitMQ 发送相关配置
# 启动发送重试机制
spring.rabbitmq.template.retry.enabled=true
# 初始化间隔1s
spring.rabbitmq.template.retry.initial-interval=1000ms
# 最大间隔5s
spring.rabbitmq.template.retry.max-interval=10000ms
# 要应用于上一个重试间隔的乘数
spring.rabbitmq.template.retry.multiplier=1.0
# 重试3次
spring.rabbitmq.template.retry.max-attempts=3
# 找不到路由队列时,将消息返回给发送者
spring.rabbitmq.publisher-returns=true
# 启动发送者确认模式
spring.rabbitmq.publisher-confirm-type=correlated
# 连接超时,默认永久等待,这里设置为5s
spring.rabbitmq.connection-timeout=5000ms
# 等待获取channel的等待时间,为0为创建,这里设置为30s
spring.rabbitmq.cache.channel.checkout-timeout=30000ms
# 当checkout-timeout > 0时才生效,否则每次不够就新创建
spring.rabbitmq.cache.channel.size=10
# 单个connection,多个channel共享一条TCP连接
spring.rabbitmq.cache.connection.mode=channel
############# RabbitMQ 消费相关配置,由于订单中心消费了死信队列,所以
############# 订单中心既是发送者,也是消费者
# direct:每个consumer都有独立的channel,线程共享channel
# simple: 每个线程都有独立的channel,消费者(container)共享channel,
# 官方建议:应用程序应该更喜欢每个线程使用一个Channel,而不是在多个线程之间共享同一Channel
# 所以这里推荐使用simple
spring.rabbitmq.listener.type=simple
# 手动确认消息模式
spring.rabbitmq.listener.simple.acknowledge-mode=manual
# 未确认交付的最大数量。一旦数量达到配置的数量,RabbitMQ将停止在通道上传递更多消息
# 就是你只要有一条消息没有ack,则RabbitMQ不会再发送消息给这个channel
spring.rabbitmq.listener.simple.prefetch=1
# 配置最大并发线程数量,也就是每个队列消费者(container)数量
spring.rabbitmq.listener.simple.max-concurrency=2
# 配置最小初始化线程数量,也就是每个队列消费者(container)数量
spring.rabbitmq.listener.simple.concurrency=1
# 启动时声明队列不可用则失败,运行时队列被删除则停止消费者(container)
spring.rabbitmq.listener.simple.missing-queues-fatal=true
# 默认情况下拒绝消息是否重新排队
spring.rabbitmq.listener.simple.default-requeue-rejected=false
# 启动应用时启动消费者(container)
spring.rabbitmq.listener.simple.auto-startup=true
# 多久发布一次空闲消费者(container)事件
spring.rabbitmq.listener.simple.idle-event-interval=5000ms
# 开启重试发送
spring.rabbitmq.listener.simple.retry.enabled=true
# 重试3次
spring.rabbitmq.listener.simple.retry.max-attempts=3
# 每次间隔3s
spring.rabbitmq.listener.simple.retry.initial-interval=3000ms
# 最大间隔10s
spring.rabbitmq.listener.simple.retry.max-interval=10000ms
# 每次间隔为上一次间隔时间 * multiplier
spring.rabbitmq.listener.simple.retry.multiplier=1.0
TMsgLogEntity.java类代码如下:
@Entity
@Table(name = "t_msg_log")
public class TMsgLogEntity implements Serializable {
private static final long serialVersionUID = 35151514L;
public static final String SEND = "SEND";
public static final String UN_SEND = "UN_SEND";
@Id
@Column(name = "id")
//主键自增
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "biz_id")
private Long bizId;
@Column(name = "biz_type")
private String bizType;
@Column(name = "biz_val")
private String bizVal;
@Column(name = "biz_user_id")
private Long bizUserId;
@Column(name = "msg_status")
private String msgStatus;
@Column(name = "retry_count")
private Integer retryCount = 0;
@Column(name = "create_time")
@JsonIgnore
private LocalDateTime createTime;
@Column(name = "last_update_time")
@JsonIgnore
private LocalDateTime lastUpdateTime;
@Transient
private String appId = "ordercenter";
...省略get set
}
IMsgLogRepository.java代码如下:
@Repository
@Transactional
public interface IMsgLogRepository extends JpaRepository<TMsgLogEntity, Long>, JpaSpecificationExecutor<TMsgLogEntity> {
@Query(
value = "update TMsgLogEntity t set t.msgStatus = 'SEND', t.lastUpdateTime = ?1 where t.id = ?2"
)
@Modifying
int updateStatusById(LocalDateTime lastUpdateTime, Long msgNumber);
}
RabbitmqConfig.java类代码如下:
@Configuration
public class RabbitmqConfig {
public static final String integral_dxl_exchange = "integral_dxl_exchange";
public static final String integral_dxl_queue = "integral_dxl_queue";
public static final String integral_dxl_binding_key = "integral_dxl_binding_key";
public static final String integral_exchange = "integral_exchange";
public static final String integral_queue = "integral_queue";
public static final String integral_binding_key = "integral_binding_key";
@Autowired
private SendIntegralCallback sendIntegralCallback;
@Autowired
private RabbitTemplate rabbitTemplate;
@PostConstruct
public void init() {
/**
* rabbitTemplate 是单例的,所以这里的配置基本是全局的
*
* 为rabbitTemplate注册全局发送确认异步监听器
* @param correlationData 回调的相关数据
* @param ack ack为真,nack为假
* @param cause 一个可选的原因,对于nack才可能有值,否则为null。
*/
rabbitTemplate.setConfirmCallback((CorrelationData correlationData, boolean ack, String cause) -> {
//todo 请注意,rabbitTemplate是单例的,所以建议一个应用公用一张消息表,通过bizType区分业务操作
try {
sendIntegralCallback.confirm(correlationData, ack, cause);
} catch (Exception e) {
//忽略确认过程的异常,下次定时任务会重发消息,超过次数便发邮件,且进入死信
e.printStackTrace();
}
});
rabbitTemplate.setEncoding("UTF-8");
rabbitTemplate.setMandatory(true);
//rabbitTemplate.convertAndSend(...) 接口会使用这里配置的消息转换
rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter() {
@Override
protected Message createMessage(Object objectToConvert, MessageProperties messageProperties) throws MessageConversionException {
//todo 这里可以自定义全局配置一些属性
return super.createMessage(objectToConvert, messageProperties);
}
});
/**
* rabbitTemplate.setMandatory(true);
*
* 当mandatory标志位设置为true时,如果exchange根据自身类型和消息routingKey无法找到一个合适的queue存储消息,
* 那么broker会调用basic.return方法将消息返还给生产者;
* 当mandatory设置为false时,出现上述情况broker会直接将消息丢弃
*/
rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
try {
//todo 请注意,rabbitTemplate是单例的,所以建议一个应用公用一张消息表,通过bizType区分业务操作
sendIntegralCallback.returnedMessage(message, replyCode, replyText, exchange, routingKey);
} catch (Exception e) {
e.printStackTrace();
}
});
//在发布消息之前被调用,从消息对象抽取数据配置到关联对象中
rabbitTemplate.setCorrelationDataPostProcessor((message, correlationData) -> {
try {
if (StringUtils.isEmpty(correlationData.getId())) {
TMsgLogEntity msgLogEntity = JSON.parseObject(
JSON.toJSONString(new String(message.getBody(), "UTF-8")), TMsgLogEntity.class);
correlationData.setId(String.valueOf(msgLogEntity.getId()));
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return correlationData;
});
}
//配置队列
@Bean
public Queue integralQueue() {
Map<String, Object> args = new HashMap<>();
//绑定名称为:integral_dxl_exchange 死信交换器
args.put("x-dead-letter-exchange", integral_dxl_exchange);
//路由到死信交换器路由键 route key
args.put("x-dead-letter-routing-key", integral_dxl_binding_key);
//arg1: 名称为积分队列
//arg2: true 表示持久类型队列
//arg3: 非排他队列
//arg4: 非自动删除
//arg5: 队列属性参数
return new Queue(integral_queue, true, false, false, args);
}
//配置Direct类型交换器,绑定键完全匹配类型
@Bean
public DirectExchange integralExchange() {
//arg1: 交换器名称
//arg2: 持久类型
//arg3: 取消最后绑定对象不自动删除
return new DirectExchange(integral_exchange, true, false);
}
//将队列和交换器绑定
@Bean
public Binding bindingIntegralExchange() {
//通过绑定键为:integral将队列和交换器绑定
return BindingBuilder.bind(integralQueue()).to(integralExchange()).with(integral_binding_key);
}
//配置死信队列
@Bean
public Queue integralDxlQueue() {
//arg1: 名称为积分死信队列
//arg2: true 表示持久类型队列
//arg3: 非排他队列
//arg4: 非自动删除
return new Queue(integral_dxl_queue, true, false, false);
}
//配置Direct类型死信交换器,绑定键完全匹配类型
@Bean
public DirectExchange integralDxlExchange() {
//arg1: 交换器名称
//arg2: 持久类型
//arg3: 取消最后绑定对象不自动删除
return new DirectExchange(integral_dxl_exchange, true, false);
}
//将队列和死信交换器绑定
@Bean
public Binding bindingIntegralDxlExchange() {
//通过绑定键为:integral将队列和交换器绑定
return BindingBuilder.bind(integralDxlQueue()).to(integralDxlExchange()).with(integral_dxl_binding_key);
}
}
通过RabbitmqConfig.java类配置了积分队列、积分交换器、积分死信交换器、积分死信队列以及绑定关系等信息,除此之外还配置了RabbitTemplate全局发送者确认异步回调、返回、消息转换、消息前置处理器等信息类,下面是发送确认异步回调相关代码:
SendIntegralCallback.java类代码如下:
@Component
public class SendIntegralCallback implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
private static Logger log = LoggerFactory.getLogger(SendIntegralCallback.class);
@Autowired
private IMsgLogRepository iMsgLogRepository;
@Autowired
private RabbitTemplate rabbitTemplate;
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
//ack
if (ack) {
//update TMsgLogEntity t set t.msgStatus = 'SEND', t.lastUpdateTime = ?1 where t.id = ?2
iMsgLogRepository.updateStatusById(LocalDateTime.now(), Long.valueOf(correlationData.getId()));
} else {
//nack
log.error("消息id: " + correlationData.getId() + "被拒绝,原因是:" + cause);
//重新发送
doSend(Long.valueOf(correlationData.getId()));
}
}
public void doSend(Long id) {
if (id == null) {
log.error("消息id: id == null ");
return;
}
//这里做重发
TMsgLogEntity row = iMsgLogRepository.findById(id).orElse(null);
if (row == null) {
log.error("消息id: " + id + "数据不存在,放弃重发 ");
return;
}
this.doSend(row);
}
//这里需要将更新发送状态和发送消息绑定一个事务
@Transactional
public void doSend(TMsgLogEntity row) {
if (row == null) {
log.error("数据不存在,放弃发送");
return;
}
if (TMsgLogEntity.SEND.equals(row.getMsgStatus())) {
return;
}
if (row.getRetryCount() > 3) {
String content = "消息id: " + row.getId() + " 超过最大重试次数,系统不再重新发送需要人工介入... ";
log.error(content);
//发送邮件
SendEmailHelper.sendEmail(content);
return;
}
//发送Rabbitmq
rabbitTemplate.convertAndSend(
RabbitmqConfig.integral_exchange,
RabbitmqConfig.integral_binding_key,
row,
new CorrelationData(String.valueOf(row.getId())));
//更新重试次数
row.setRetryCount(row.getRetryCount() == null ? 1 : row.getRetryCount() + 1);
iMsgLogRepository.saveAndFlush(row);
}
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
try {
String content = "消息:" + new String(message.getBody(), "UTF-8") + " 找不到可路由的队列"
+ " routingKey " + routingKey + " exchange " + exchange + " replyCode " + replyCode
+ " replyText " + replyText + " 需要人工介入处理";
log.error(content);
//发送邮件
SendEmailHelper.sendEmail(content);
} catch (UnsupportedEncodingException e) {
log.error("", e);
}
}
}
前面主要是集成springboot、springdata、rabbitmq等基础配置,接下来是控制器,控制器暂时只有一个支付回调Get接口,可以通过浏览器回车的方式进行测试,在支付成功状态时往积分日志表插入数据,代码如下:
OrderController.java类代码如下:
@RestController
@RequestMapping("/btb/orders")
public class OrderController {
//为了简化,之间调dao层
@Autowired
private IMsgLogRepository iMsgLogRepository;
/**
* 支付通知 ,参数只是模拟
*
* @param orderId 订单id
* @return
*/
@GetMapping("/pay_notify")
@Transactional //笔者这里为了方便直接在controller开启事务,实际业务代码是通过service层进行方法包装
public ResultDto pay_notify(
@RequestParam("orderId") Long orderId,
@RequestParam("status") String status) {
//todo 判断订单状态是否为已付款,如果是则直接返回不处理,认为是重复回调
//更新订单状态成功后,记录积分日志
Random r = new Random(10000);
TMsgLogEntity msgLogEntity = new TMsgLogEntity()
//orderId模拟假数据
.setBizId(orderId == null ? (long) r.nextInt(100000) : orderId)
.setBizVal("100")
//userId模拟假数据
.setBizUserId((long) r.nextInt(100000))
.setCreateTime(LocalDateTime.now())
.setLastUpdateTime(LocalDateTime.now())
.setMsgStatus(TMsgLogEntity.UN_SEND)
//从分布式发号器获取,这里是自增
// .setId(getId())
.setRetryCount(0);
if ("success".equals(status)) {
//todo do update order status to success
//增加积分
msgLogEntity.setBizType("add_integral");
} else {
//todo do update order status to fail
//扣减积分
msgLogEntity.setBizType("reduce_integral");
}
iMsgLogRepository.saveAndFlush(msgLogEntity);
return ResultDto.success();
}
}
插入积分日志数据后,我们需要一个定时任务轮询积分日志表,找到待发送状态的数据发送到积分交换器上,这里暂时用spring scheduling模块作为定时任务调度,代码如下:
SendIntegralJob.java类代码如下:
@Configuration
@EnableScheduling
public class SendIntegralJob {
@Autowired
private IMsgLogRepository iMsgLogRepository;
@Autowired
private SendIntegralCallback sendIntegralCallback;
private Logger log = LoggerFactory.getLogger(this.getClass());
//每天23:59分定时执行
// @Scheduled(cron = "0 59 23 * * ?")
//测试间隔30s执行一次
@Scheduled(cron = "0/30 * * * * ?")
public void process() {
log.info("开始执行发送积分消息");
//这里一次性查出全部 状态为待发送 、 重试次数小于等于3次的消息
//实际业务这里应该做个分页查询,避免一次读取大量数据放在内存
List<TMsgLogEntity> rows = iMsgLogRepository.findAll((Specification<TMsgLogEntity>) (root, query, cb) -> {
List<Predicate> predicate = new ArrayList<>();
//查询消息状态为待发送
predicate.add(cb.equal(root.get("msgStatus"), TMsgLogEntity.UN_SEND));
//重试次数小于等于3次的消息
predicate.add(cb.le(root.get("retryCount"), 3));
Predicate[] pre = new Predicate[predicate.size()];
return query.where(predicate.toArray(pre)).getRestriction();
});
if (CollectionUtils.isEmpty(rows)) {
log.info("没有需要发送的积分数据");
return;
}
for (TMsgLogEntity row : rows) {
sendIntegralCallback.doSend(row);
}
}
}
由于我们在RabbitmqConfig.java类中配置了发送确认异步,所以这里发送消息后直到到达rabbitmq服务后立即返回,真正落盘后rabbitmq会通过异步的方式回调前面配置的回调类进行发送成功的通知,我们在回调类对积分日志发送状态进行修改为已发送。
由于存在消费者拒绝的情况,我们前面为积分队列配置了死信交换器,当积分中心拒绝某条消息且不重新排队时,消息会被路由到死信队列,死信队列由订单中心监听消费,然后发送邮件给开发者通知数据异常问题,相关代码如下:
IntegralDxlConsumer.java类代码如下:
@Component
public class IntegralDxlConsumer {
private Logger log = LoggerFactory.getLogger(this.getClass());
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = RabbitmqConfig.integral_dxl_queue,
durable = "true",
autoDelete = "false",
exclusive = "false"),
exchange = @Exchange(value = RabbitmqConfig.integral_dxl_exchange,
durable = "true",
type = "direct",
autoDelete = "false"
),
key = RabbitmqConfig.integral_dxl_binding_key
))
@RabbitHandler
public void onMessage(Message message, Channel channel) throws Exception {
if (message.getBody() != null && message.getBody().length > 0) {
String jsonStr = new java.lang.String(message.getBody(), "UTF-8");
log.error("接收到dxl消息:" + jsonStr);
//先发送邮件
SendEmailHelper.sendEmail(jsonStr);
//todo 还可以继续写入系统异常消息表
try {
//再确认消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
log.info("收到dxl消息:" + jsonStr + " 正常确认");
} catch (Exception e) {
//spring.rabbitmq.listener.simple.prefetch=1
//如果前面报错,则消息会留在死信队列,不再给该消费者,
log.error("", e);
}
}
}
}
最后给出订单中心启动类OrdercenterApplication 代码:
@SpringBootApplication
public class OrdercenterApplication {
public static void main(String[] args) {
SpringApplication.run(OrdercenterApplication.class, args);
}
}
至此,订单中心关键代码基本全部给出,再次总结下逻辑:
1、通过pom引入自动配置项目将rabbitmq集成到springboot项目。
2、通过application.properties配置文件配置rabbitmq基本信息、发送确认信息、消费者信息的配置项,具体配置项作用可以看前面代码的注释。
3、创建entity和dao,来连接和查询数据库表。
4、创建RabbitmqConfig类来配置rabbitmq的交换器和队列信息,以及RabbitTemplate发送异步确认全局配置。
5、通过控制器接收支付成功回调,修改订单状态后创建积分日志,注意,它们是同一个事务。
6、通过定时任务轮询待发送状态的积分日志,然后发送到积分交换器上,通过路由键路由到积分队列,如果积分中心拒绝某条消息,则该消息会被路由到配置的死信队列,由订单中心自己监听消费死信队列,进行邮件的发送或往本地系统异常表写入,最终需要人工介入处理。
7、整个发送支持重试,均为3次,超过3次会发送邮件通知开发者。
8、由异步确认监听器监听发送确认ack,一旦收到某条消息的ack则修改为已发送。
积分中心项目关键代码
项目结构如下:
积分中心也是一样,为了简便,没有创建service层,相关事务注解在相关方法上。
pom.xml代码如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lazy.course</groupId>
<artifactId>lazy-course-rabbitmq</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath> <!-- lookup parent from repository -->
</parent>
<groupId>com.xxx.integralcenter</groupId>
<artifactId>integralcenter</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>integralcenter</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>com.xxx.common</groupId>
<artifactId>common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.properties配置文件代码如下:
# tomcat配置
server.port=8082
server.tomcat.accesslog.prefix=access_log
server.tomcat.accesslog.directory=./logs
server.tomcat.accesslog.enabled=true
# 应用配置
spring.application.name=integralcenter
# servler、json配置
server.servlet.context-path=/integralcenter/api
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
spring.jackson.default-property-inclusion=non_null
# 数据库配置
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/integralcenter?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=
# 日志配置
logging.config=classpath:logback.xml
logging.level.org.springframework.web=INFO
logging.level.org.hibernate=INFO
spring.output.ansi.enabled=DETECT
# jap、hibernate配置
spring.jpa.show-sql=false
spring.data.jpa.repositories.enabled=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.MySQL5Dialect
spring.jpa.properties.hibernate.format_sql=true
############# RabbitMQ 基本信息配置
spring.rabbitmq.host=192.168.137.101
spring.rabbitmq.port=5672
spring.rabbitmq.username=lazy
spring.rabbitmq.password=111111
spring.rabbitmq.virtual-host=vhost_hello
############# RabbitMQ 消费相关配置
# direct:每个consumer都有独立的channel,线程共享channel
# simple: 每个线程都有独立的channel,消费者(container)共享channel,
# 官方建议:应用程序应该更喜欢每个线程使用一个Channel,而不是在多个线程之间共享同一Channel
# 所以这里推荐使用simple
spring.rabbitmq.listener.type=simple
# 手动确认消息模式
spring.rabbitmq.listener.simple.acknowledge-mode=manual
# 未确认交付的最大数量。一旦数量达到配置的数量,RabbitMQ将停止在通道上传递更多消息
# 就是你只要有一条消息没有ack,则RabbitMQ不会再发送消息给这个channel
spring.rabbitmq.listener.simple.prefetch=1
# 配置最大并发线程数量,也就是每个队列消费者(container)数量
spring.rabbitmq.listener.simple.max-concurrency=2
# 配置最小初始化线程数量,也就是每个队列消费者(container)数量
spring.rabbitmq.listener.simple.concurrency=1
# 启动时声明队列不可用则失败,运行时队列被删除则停止消费者(container)
spring.rabbitmq.listener.simple.missing-queues-fatal=true
# 默认情况下拒绝消息是否重新排队
spring.rabbitmq.listener.simple.default-requeue-rejected=false
# 启动应用时启动消费者(container)
spring.rabbitmq.listener.simple.auto-startup=true
# 多久发布一次空闲消费者(container)事件
spring.rabbitmq.listener.simple.idle-event-interval=5000ms
# 开启重试
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.max-attempts=3
spring.rabbitmq.listener.simple.retry.initial-interval=1000ms
spring.rabbitmq.listener.simple.retry.max-interval=10000ms
spring.rabbitmq.listener.simple.retry.multiplier=1.0
# 连接超时,默认永久等待,这里设置为5s
spring.rabbitmq.connection-timeout=5000ms
# 等待获取channel的等待时间,为0为创建,这里设置为30s
spring.rabbitmq.cache.channel.checkout-timeout=30000ms
# 当checkout-timeout > 0时才生效,否则每次不够就新创建
spring.rabbitmq.cache.channel.size=10
# 单个connection,多个channel共享一条TCP连接
spring.rabbitmq.cache.connection.mode=channel
MsgLogDto.java类代码如下:
public class MsgLogDto implements Serializable {
private static final long serialVersionUID = 35151514L;
private Long id;
private Long bizId;
private String bizType;
private String bizVal;
private Long bizUserId;
private String msgStatus;
private Integer retryCount = 0;
private String appId;
...省略get set
}
处理幂等性确认日志表TMsgConfirmLogEntity.java类代码如下:
@Entity
@Table(name = "t_msg_confirm_log")
public class TMsgConfirmLogEntity implements Serializable {
private static final long serialVersionUID = 35151566614L;
@Id
@Column(name = "id")
//主键自增
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "msg_id")
private String msgId;
@Column(name = "app_id")
private String appId;
@Column(name = "create_time")
private LocalDateTime createTime;
@Column(name = "last_update_time")
private LocalDateTime lastUpdateTime;
...省略get set
}
IMsgConfirmLogRepository.java类代码如下:
@Repository
@Transactional
public interface IMsgConfirmLogRepository extends JpaRepository<TMsgConfirmLogEntity, Long>, JpaSpecificationExecutor<TMsgConfirmLogEntity> {
TMsgConfirmLogEntity findByMsgIdAndAndAppId(String msgId, String appId);
}
RabbitmqConfig.java类代码如下:
public class RabbitmqConfig {
public static final String integral_dxl_exchange = "integral_dxl_exchange";
public static final String integral_dxl_queue = "integral_dxl_queue";
public static final String integral_dxl_binding_key = "integral_dxl_binding_key";
public static final String integral_exchange = "integral_exchange";
public static final String integral_queue = "integral_queue";
public static final String integral_binding_key = "integral_binding_key";
}
核心消费者IntegRalConsumer类代码如下:
@Component
public class IntegralConsumer {
private Logger log = LoggerFactory.getLogger(this.getClass());
@Autowired
private IMsgConfirmLogRepository iMsgConfirmLogRepository;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = RabbitmqConfig.integral_queue,
durable = "true",
autoDelete = "false",
exclusive = "false",
arguments = {
@Argument(
name = "x-dead-letter-exchange",
value = RabbitmqConfig.integral_dxl_exchange
),
@Argument(
name = "x-dead-letter-routing-key",
value = RabbitmqConfig.integral_dxl_binding_key
)
}),
exchange = @Exchange(value = RabbitmqConfig.integral_exchange,
durable = "true",
type = "direct",
autoDelete = "false"
),
key = RabbitmqConfig.integral_binding_key
)
)
@RabbitHandler
public void onMessage(Message message, Channel channel) {
try {
if (message.getBody() == null || message.getBody().length < 1) {
log.error("接收到没有内容的消息,已被拒绝,路由到死信队列,如果有配置的话,否则丢弃");
//arg1: 交货标签
//arg2: 是否拒绝小于等于message.getMessageProperties().getDeliveryTag()的消息,否
//arg3: 是否重新入队 否:丢弃或路由到死信,如果有配置的话
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
return;
}
String content = new String(message.getBody(), "UTF-8");
MsgLogDto msgLogDto = JSONObject.parseObject(content, MsgLogDto.class);
if (StringUtils.isEmpty(msgLogDto.getAppId())) {
log.error("接收到没有appId的消息,已被拒绝,路由到死信队列,如果有配置的话,否则丢弃");
//arg1: 交货标签
//arg2: 是否拒绝小于等于message.getMessageProperties().getDeliveryTag()的消息,否
//arg3: 是否重新入队 否:丢弃或路由到死信,如果有配置的话
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
return;
}
if (msgLogDto.getId() == null) {
log.error("接收到没有id的消息,已被拒绝,路由到死信队列,如果有配置的话,否则丢弃");
//arg1: 交货标签
//arg2: 是否拒绝小于等于message.getMessageProperties().getDeliveryTag()的消息,否:只拒绝当条消息
//arg3: 是否重新入队 否:丢弃或路由到死信,如果有配置的话
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
return;
}
TMsgConfirmLogEntity confirmLogEntity = iMsgConfirmLogRepository.findByMsgIdAndAndAppId(msgLogDto.getId().toString(), msgLogDto.getAppId());
//幂等性处理
if (confirmLogEntity != null) {
log.error("该消息已经被处理,已被直接确认");
//arg1: 交货标签
//arg2: 是否确认小于等于message.getMessageProperties().getDeliveryTag()的消息,否:只确认当条消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
return;
}
//todo 这里处理增加或减少积分的业务操作
this.doChangeIntegral(msgLogDto);
//保存已经处理过的消息数据,防止重复消费
confirmLogEntity = new TMsgConfirmLogEntity()
.setAppId(msgLogDto.getAppId())
.setMsgId(String.valueOf(msgLogDto.getId()))
.setCreateTime(LocalDateTime.now())
.setLastUpdateTime(LocalDateTime.now());
iMsgConfirmLogRepository.saveAndFlush(confirmLogEntity);
//这里测试死信队列路由,生产不允许有这块代码
if (msgLogDto.getBizId() > 1000) {
throw new RuntimeException("测试死信队列路由");
}
//最后确认消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
log.info("收到消息:" + content + " 正常确认");
} catch (Exception e) {
log.error("", e);
//已经拒绝消息,发送给死信队列,如果有配置的话,否则丢弃
try {
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
} catch (IOException e1) {
log.error("发送异常后拒绝消息异常", e1);
}
}
}
private void doChangeIntegral(MsgLogDto msgLogDto) {
//增加积分
if ("add_integral".equals(msgLogDto.getBizType())) {
System.out.println("增加积分: " + msgLogDto.getBizVal());
//扣减积分
} else if ("reduce_integral".equals(msgLogDto.getBizType())) {
System.out.println("扣减积分: " + msgLogDto.getBizVal());
} else {
//throw not support exception
}
}
}
最后给出应用启动类代码:
@SpringBootApplication
public class IntegralcenterApplication {
public static void main(String[] args) {
SpringApplication.run(IntegralcenterApplication.class, args);
}
}
测试
1、启动Rabbitmq服务,启动订单中、启动积分中心,可以通过登录Rabbitmq Admin页面看到创建的队列信息截图如下:
2、通过浏览器回车下面的url模拟支付成功回调,此时应该记录新增积分日志:
http://127.0.0.1:8081/ordercenter/api/btb/orders/pay_notify?orderId=1&status=success
3、通过浏览器回车下面的url模拟支付失败回调,此时应该记录扣减积分日志:
http://127.0.0.1:8081/ordercenter/api/btb/orders/pay_notify?orderId=1&status=fail
4、通过浏览器回车下面的url模拟死信队列路由情况:
http://127.0.0.1:8081/ordercenter/api/btb/orders/pay_notify?orderId=1222&status=success
5、通过日志和数据库记录的变化可以看到整个需求正常运行,如果发送无法处理的错误基本通过发送邮件通知开发者进行人工处理,这种方式适用于大部分最终一致性分布式事务的处理方案设计。
---------------------- 正文结束 ------------------------
长按扫码关注微信公众号
Java软件编程之家