RabbitMQ消息可靠性投递就是保证消息生产者能够将消息百分百投递到RabbitMQ服务器,并在传递过程中不丢失。然而在生产环境中由于网络中断、网络不稳定等原因导致消息在投递过程中丢失,这或许会造成极大的损失。
消息投递过程:
处理任务A成功但由于网络原因消息在投递过程中丢在,会造成我们系统的不一致,以转账为例A银行某用户向B银行某用户转账,A系统用户扣款成功,发送消息给B系统给用户账号增加余额。然而消息发送失败或者在投递过程中丢失了,导致A用户扣款而B用户账号余额没有增加,这就导致了系统不一致性,造成了巨大的损失,为了避免类似的情况发生,就必须保证消息可靠性投递。
可靠性投递方案:
1、保证消息成功发出
2、保证RabbitMQ成功接收,并确认应答
3、保障消费者成功消费
4、加入补偿机制(定时任务,人工处理等)
首先我们需要解决的是任务A处理成功之后如何保证消息成功发送到RabbitMQ
1、消息入库,记录消息状态,消息发送失败可以再次发送
为了保证一致性,任务A处理成功,必须马上记录消息,两个操作必须在同一个事务当中,要么同时成功,要么同时失败
2、RabbitMQ确认应答,生产者收到确认应答,修改消息发送状态为成功
需要开启确认机制,生产者监听callback响应
3、定时任务处理失败消息
消息发送失败自然需要重新发送,我们需要执行定时任务来重新发送未成功的消息。如果消息发送成功,但是消息中间件返回确认失败,消息状态依旧是失败,定时任务还会继续发送消息,直到收到确认应答,修改消息状态为成功为止。这就导致一个任务的消息多次发送,消费者多次执行同样的操作,A用户一次转账,B用户多次增加余额,所以消费端需要做幂等处理,来保障同一个任务只处理一次,这个后面再做详解。如果定时任务多次执行还是没有收到确认消息,那么我们只能设置消息状态超时失败,采用人工处理。这样就可以保证我们消息的可靠性。
我们以转账为例,来具体实现(当然这只是简单案例,真实转账操作肯定不是这样的),首先要创建数据表:
生产端:
用户表:
DROP TABLE IF EXISTS `user`;
CREATE TABLE IF NOT EXISTS `user` (
`id` varchar(32) NOT NULL,
`name` varchar(20) NOT NULL COMMENT '用户名',
`account` float(8,2) NOT NULL DEFAULT '0.00' COMMENT '账号余额',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `user` (`id`, `name`, `account`, `create_time`, `update_time`) VALUES
('4028e7b76c2da10a016c2da1180f0000', '张三', 200.00, '2019-07-26 17:33:48', '2019-07-31 20:25:34');
转账记录表
CREATE TABLE `transfer_record` (
`id` varchar(32) NOT NULL ,
`fromUid` varchar(32) NOT NULL COMMENT '转出用户id',
`toUid` varchar(32) NOT NULL COMMENT '转入用户id',
`money` float(8,2) NOT NULL COMMENT '转出金额',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
ALTER TABLE `transfer_record`
ADD PRIMARY KEY (`id`);
任务表(消息记录表)
CREATE TABLE `task` (
`id` varchar(32) NOT NULL COMMENT '任务id',
`task_type` varchar(32) NOT NULL COMMENT '任务类型',
`mq_exchange` varchar(64) NOT NULL COMMENT '交换机名称',
`mq_routingkey` varchar(64) NOT NULL COMMENT 'routingkey',
`request_body` varchar(512) NOT NULL COMMENT '任务请求的内容',
`version` int(10) DEFAULT '0' COMMENT '乐观锁版本号',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0:未发送 1:已发送 2:超时失败',
`errormsg` varchar(512) DEFAULT NULL COMMENT '任务错误信息',
`try_count` tinyint(1) NOT NULL DEFAULT '0' COMMENT '任务重试次数',
`overtime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '任务超时时间',
`createTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT '0000-00-00 00:00:00'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
ALTER TABLE `task`
ADD PRIMARY KEY (`id`);
绑定队列交换机
@Configuration
public class RabbitMQConfig {
//添加转账任务交换机
public static final String EX_TRANSFER = "ex_transfer";
//添加转账消息队列
public static final String XC_TRANSFER = "xc_transfer";
//声明一个交换机
@Bean(EX_TRANSFER)
public Exchange EX_DECLARE2() {
return ExchangeBuilder.directExchange(EX_TRANSFER).durable(true).build();
}
//声明转账队列
@Bean(XC_TRANSFER)
public Queue QUEUE_DECLARE_3() {
Queue queue = new Queue(XC_TRANSFER,true,false,false);
return queue;
}
/**
* 绑定转账队列到转账交换机 .
* @param queue the queue
* @param exchange the exchange
* @return the binding
*/
@Bean
public Binding binding_queue_media_processtask_3(@Qualifier(XC_TRANSFER) Queue queue, @Qualifier(EX_TRANSFER) Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(XC_TRANSFER_BINGDING_KEY).noargs();
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
// 设置消息确认的回调方法,这里会接收到rabbitmq的确认应答
template.setConfirmCallback((CorrelationData correlationData, boolean b, String s)->{
if (b==true){
//收到rabbitmq应答,更改消息状态为1 已发送
taskDao.updateStatus(correlationData.getId(),1,new Date());
}else {
//做失败处理,省略
System.out.println("失败+++++++++++++++++++++");
}
});
//设置消息转换器,将对象类型消息就行转换
template.setMessageConverter(messageConverter());
return template;
}
//消息转换器
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
开启消息确认模式,生成端rabbitmq配置:
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
#开启消息确认模式
publisher-confirms: true
#开始返回机制,接收返回消息
publisher-returns: true
template:
#处理路由不到的消息,返回给生成者(交换机和路由键不存在)
mandatory: true
采用spring boot +spring data jpa 具体实体类和sql操作不再列出,都是简单的增删改查操作
转账与记录消息在本地事务当中,核心代码如下
@Transactional
public Map<String,Object> transferAccounts(String fromUid, float money,String toUid) {
Map<String,Object> map = new HashMap<>();
//用户表:减少转出用户余额
Optional<User> optionalUser = userDao.findById(fromUid);
if (!optionalUser.isPresent()){
map.put("status","false");
map.put("message","转账失败,用户不存在");
return map;
}
User user = optionalUser.get();
user.setAccount(user.getAccount()-money);
user.setUpdateTime(new Date());
userDao.save(user);
TransferRecord record = new TransferRecord(fromUid,toUid,money,new Date());
//转账记录表:添加转账记录
TransferRecord newRecord = transferRecordDao.save(record);
//任务表:添加待发送的消息
String jsonString = FastJsonConvertUtil.convertObjectToJSON(newRecord);
Calendar time = Calendar.getInstance();
Date currentTime = time.getTime();
time.add(Calendar.MINUTE, +1);// 1分钟之后的时间
//设置任务过期时间
Date overtime = time.getTime();
Task task = new Task("转账",RabbitMQConfig.EX_TRANSFER,"transfer",jsonString,0,0,overtime,currentTime);
Task res = taskDao.save(task);
map.put("status","true");
map.put("task",res);
return map;
}
我们可以看到对用户表、转账记录表以及保存消息操作都在同一个本地事务当中,他们要么同时成功要么同时失败。保证一但用户扣款成功,就有一条消息发送出去。
现在我们来发送这条消息:
@Override
public void transferPublish(Task task, String ex, String routingKey) {
//设置消息唯一id
CorrelationData correlationData = new CorrelationData(task.getId());
//将消息发送到相应的交换器
rabbitTemplate.convertAndSend(ex,routingKey,task,correlationData);
}
如果成功发送消息,rabbitmq收到消息的时候就会发送应答,我们可以在前面设置的回调当中收到应答。
补偿机制,通过定时任务再次发送意外失败或没有收到应答的消息:
@Autowired
TaskServiceImpl taskService;
@Autowired
TaskDao taskDao;
//项目启动后三秒钟,每隔10秒钟执行一次任务
@Scheduled(initialDelay = 3000,fixedDelay = 10000)
public void tranferTask(){
//每次最多取出100条没有失败任务
Pageable pageable = PageRequest.of(0,100);
//取出状态为0,未成功的消息
Page<Task> list = taskDao.findByStatusAndOvertimeBefore(pageable,0,new Date());
if (!list.isEmpty()){
List<Task> taskList = list.getContent();
taskList.forEach(task -> {
//乐观锁机制,每次取到消息都更新消息版本号,保证一条消息不会被多个节点同时发送
int res = taskService.updateTransferTask(task.getId(),task.getVersion());
//更新成功即说明该条消息,本节点能够发送
if (res>0){
//一条消息尝试三次以上即表示失败,不再发送,通过人工处理
if (task.getTryCount()>=3){
taskDao.updateStatus(task.getId(),2,new Date());
}else {
//尝试次数+1
taskDao.updateTryCountByTaskId(task.getId(),new Date());
try {
System.out.println("开始投递消息");
taskService.transferPublish(task,RabbitMQConfig.EX_TRANSFER,"transfer");
}catch (Exception e){
e.printStackTrace();
System.out.println("异常处理");
}
}
}
});
}
}
如果消息还是失败,就需要我们进行人工处理,通过以上操作,我们就保证了消息能最终到达rabbitmq供下游服务消费。
最后,我们就需要保证消费者能够收到消息并正确消费,这里我们还需要用到消费者确认机制,前面我们我们创建的是持久化的队列和消息,当消息投递给消费者时,如rabbitmq没有收到消费者确认签收的应答,消息会再次发送给消费者,直到收到应答。这就保证了消息一定要发送给消费者。但有一种极端情况是消费者收到消息并处理完成,但返回应答的时候出错了,可能导致消费者多次收到同一个消息,这里我们就需要做幂等处理,有一种简单的思路是当消费者在收到消息时可以将消息保存在数据库做持久化处理。并标记消息是否已经处理成功,当下次再收到消息时,需要先查询消息是否已经存在并且是处理完成的,如果是就直接返回应答。否则再次处理。同样的如果循环多次某个消息还是没有处理完成,标记超时,进行人工补偿处理。
消费端数据表和生产端数据表一致,同样需要用户表,转账记录表,任务表(也叫消息表)
消费端rabbitmq配置如下:
spring:
rabbitmq:
host: localhost
username: guest
password: guest
port: 5672
listener:
simple:
#手动签收
acknowledge-mode: manual
#最大的并发量
max-concurrency: 10
#最小的并发量
concurrency: 5
#限流,每个线程一次消费一条消息
prefetch: 1
交换机以及队列配置如生产端一致,生产端虽然已经配置了队列,但建议消费端还是再声明一下,rabbitmq不会做重复处理。最主要的是配置与生产端相对应的消息转换器。
核心代码如下:
@RabbitListener(queues = RabbitMQConfig.XC_TRANSFER)
public void receiveTask(@Payload Task task,Channel channel,@Headers Map<String,Object> headers) throws IOException {
//从消息中获取转账信息
String requestBody = task.getRequestBody();
Map<String,Object> map = FastJsonConvertUtil.convertJSONToObject(requestBody,Map.class);
String toUId = (String)map.get("toUid");
String fromUid = (String)map.get("fromUid");
Object obj = map.get("money");
BigDecimal bigDecimal = (BigDecimal) obj;
float money = bigDecimal.floatValue();
Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
//幂等处理,放在重复操作
Optional<Task> optionalTask = taskDao.findById(task.getId());
//如果任务不存在,处理任务
if (!optionalTask.isPresent()) {
taskDao.save(task);
try {
//用户增加余额,添加记录,修改消息状态
transferService.transferAccounts(fromUid,money,toUId,task);
channel.basicAck(deliveryTag,false);
}catch (Exception e){
e.printStackTrace();
channel.basicRecover(true);
}
}else {
//如果已经存在,失败次数小于三且未完成,则再次处理
if (optionalTask.get().getTryCount()<3&&optionalTask.get().getStatus()==0){
taskDao.updateTryCountByTaskId(task.getId(),new Date());
try {
transferService.transferAccounts(fromUid,money,toUId,task);
//应答成功
channel.basicAck(deliveryTag,false);
}catch (Exception e) {
e.printStackTrace();
//消息重回队列
channel.basicRecover(true);
}
}else {
//任务已经完成了,不做重复处理,拒绝消息,并从队列中删除
channel.basicReject(deliveryTag,false);
}
}
}
增加账号余额,同时添加转账记录,将转账消息设置为以成功处理。同样的,这三个操作应该在同一个本地事务当中。
@Transactional
public void transferAccounts(String fromUid, float money, String toUid, Task task) {
//开始转账
int res = userDao.transferAccounts(toUid,money,new Date());
if (res<1){
throw new RuntimeException("转账失败");
}
//添加转账记录
TransferRecord record = new TransferRecord(fromUid,toUid,money,new Date());
transferRecordDao.save(record);
//修改任务状态为成功
task.setStatus(1);
task.setUpdateTime(new Date());
taskDao.save(task);
throw new RuntimeException("转账失败");
}
这就是我们基于可靠消息的分布式事务处理,保证了从生产端到消费端的最终一致性。
参考文献:https://blog.csdn.net/u012092620/article/details/80222007