假设有两个用户,其银行账户不在一个银行中,服务不同,数据库也不同。现在要实现一个功能:用户A给B转账100块,具体怎么做才能保证资金安全?
关键点是什么?本质上是如何解决分布式事务问题
常见的分布式事务中间件:TxLCN、GTS、TCC-Transaction
常见的分布式事务解决方案:
业务上是否追求强一致性?还是可以接受一定程度的延迟?
很多场景不求事务的强一致性,只需达到事务的最终一致性,比如上文中用户A扣钱后,B账户的钱是不是立马就到账了?
这种情况下事务消息可以很好的满足需求。
上面提到了,要保证本地事务与消息的发送在一个事务中,如果以A给B转账100块为例子(A和B分别处在2个微服务中,对应2个数据库),具体怎么做呢?
场景一
先执行A扣钱100本地事务,再发送给B一条扣钱100消息,行么?
假设碰到网络问题,消息发送失败了。A扣了100,B却没加钱,肯定不行
场景二
那先发给B发送一条扣钱100消息,再执行A扣钱本地事务,行么?
如果消息发送成功了,这时候A服务所在数据库宕机了,岂不是B所在系统消费了消息,B加了100,A却没扣钱,也不对
问题关键点是什么?
只要A扣钱和发送消息不是一个原子操作,即不在一个事务中完成,那么,无论先后顺序如何,都会出现数据不一致性问题
我搞个本地消息表不就行了?
那么A扣钱成功的同时,一定会有一条对应B扣钱的消息记录在数据库中,然后A所在系统单独启动一个定时器去扫描该消息表,并将状态为待发送的消息,投递到消息服务器中,失败重试,直到消息发送成功
这种方案行不行?当然可以,那么缺点又是什么?显而易见
业务方需要单独设计消息表,及定时发送消息的定时器,增加了与业务无关的开发负担
RocketMq通过将本地事务与消息的发送放在一个本地事务中,来保证
再介绍RocketMq消息事务前,先介绍下几个关键名词
概念 | 解释 |
---|---|
prepare消息 | 又名Half Message,半消息,标识该消息处于"暂时不能投递"状态,不会被Comsumer所消费,待服务端收到生成者对该消息的commit或者rollback响应后,消息会被正常投递或者回滚(丢弃)消息 |
RMQ_SYS_TRANS_HALF_TOPIC | prepare消息在被投递到Mq服务器后,会存储于Topic为RMQ_SYS_TRANS_HALF_TOPIC的消费队列中 |
RMQ_SYS_TRANS_OP_HALF_TOPIC | 在prepare消息被commit或者rollback处理后,会存储到Topic为RMQ_SYS_TRANS_OP_HALF_TOPIC的队列中,标识prepare消息已被处理 |
两个核心概念:两阶段提交、事务状态定时回查
上面已经提到,因为消息发送是一个远程调用,由于网络的不稳定,无法和本地事务的执行处于一个原子操作中,针对这个缺点,RocketMQ基于两阶段提交协议做了如下改动
第一阶段:生产者向MQ服务器发送事务消息(prepare消息),服务端确认后回调通知生产者执行本地事务(此时消息为Prepare消息,存储于RMQ_SYS_TRANS_HALF_TOPIC队列中,不会被消费者消费)
第二阶段:生产者执行完本地事务后(业务执行完成,同时将消息唯一标记,如transactionId与该业务执行记录同时入库,方便事务回查),根据本地事务执行结果,返回Commit/Rollback/Unknow状态码
1、服务端若收到Commit状态码,则将prepare消息变为提交(正常消息,可被消费者消费)
2、收到Rollback则对消息进行回滚(丢弃消息)
3、若状态为Unknow,则等待MQ服务端定时发起消息状态回查,超过一定重试次数或者超时,消息会被丢弃
引用一张流程图来说明消息事务的两阶段提交
在第二阶段中,生产者在本地事务执行完成后,需要向MQ服务器返回响应状态码,发送状态码的过程也是通过Netty发送网络请求,假设由于网络原因发送失败怎么办?本地事务已经提交/回滚了,但是Commit/Rollback状态码却没发出去,那么MQ服务器上这条prepare消息状态岂不是无法被投递/回滚
因此,MQ服务端会定时扫描存储于RMQ_SYS_TRANS_HALF_TOPIC中的消息,若消息未被处理,则向消费发送者发起回调检查,检查消息对应本地事务执行状态。从而保证消息事务状态最终能和本地事务的状态一致。上图中的4、5、6就是MQ服务端定时回查步骤。
public interface TransactionListener {
/**
* 半消息发送成功时,调用该方法执行本地事务
*
* @param msg Half(prepare) message
* @param arg Custom business parameter
* @return Transaction state
*/
LocalTransactionState executeLocalTransaction(final Message msg, final Object arg);
/**
* 消息发送端未返回本地事务执行状态时,broker定时调用该接口获取本地事务执行状态
*
* @param msg Check message
* @return Transaction state
*/
LocalTransactionState checkLocalTransaction(final MessageExt msg);
}
问题:假设有这么一个需求,用户下单后如果30分钟未支付,则该订单需要被关闭。你会怎么做?
最简单的做法,可以服务端启动个定时器,隔个几秒扫描数据库中待支付的订单,如果(当前时间-订单创建时间)>30分钟,则关闭订单。
这种方案优点是实现简单,缺点呢?
定时扫描意味着隔个几秒就得查一次数据库,频率高的情况下,如果数据库中订单总量特别大,这种高频扫描会对数据库带来一定压力,待付款订单特别多时(做个爆品秒杀活动,或者啥促销活动),若一次性查到内存中,容易引起宕机,需要分页查询,多少也会有一定数据库层面压力
那么有没其他解决方案?关键有2点设计要求
RocketMQ延时消息能够完美的解决上述需求,正常的消息在投递后会立马被消费者所消费,而延时消息在投递时,需要设置指定的延时级别,即等到特定的时间间隔后消息才会被消费者消费,这样就将数据库层面的压力转移到了MQ中,也不需要手写定时器,降低了业务复杂度,同时MQ自带削峰及消息堆积功能,能够很好的应对业务高峰
RocketMQ
//设置延迟级别
public void setDelayTimeLevel(int level) ;
ONS
/**
* 设置消息的定时投递时间(绝对时间),最大延迟时间为7天.
*/
public void setStartDeliverTime(final long value);
设置消息延时级别的方法是setDelayTimeLevel()
,目前RocketMQ不支持任意时间间隔的延时消息,只支持特定级别的延时消息,什么意思呢?
看下MQ中默认延时级别配置,延时级别配置代码在MessageStoreConfig#messageDelayLevel中
String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h"
延时级别1对应延时1秒后发送消息
延时级别2对应延时5秒后发送消息
延时级别3对应延时10秒后发送消息
以此类推。。
延时消息的关键点在于Producer生产者需要给消息设置特定延时级别,消费端代码与正常消费者没有差别。
public class Producer {
public static void main(String[] args) throws MQClientException, InterruptedException {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
//设置namesrv地址
producer.setNamesrvAddr("111.231.110.149:9876");
//启动生产者
producer.start();
//发送10条消息
for (int i = 0; i < 10; i++) {
try {
Message msg = new Message("TopicTest" /* Topic */,
"TagA" /* Tag */,
("test message" + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);
//设置消息延时级别 3对应10秒后发送
msg.setDelayTimeLevel(3);
SendResult sendResult = producer.send(msg);
System.out.printf("%s%n", sendResult);
} catch (Exception e) {
e.printStackTrace();
Thread.sleep(1000);
}
}
/*
* Shut down once the producer instance is not longer in use.
*/
producer.shutdown();
}
}
为什么事务消息中的半消息及延迟消息中的投递的消息不会被Consumer立即消费呢?
以延迟消息为例,延时消息在发送时,设置了delayLevel,两个问题
先看下Message#setDelayTimeLevel方法代码,可以看到延迟级别设置后,消息体的属性里多了一个PROPERTY_DELAY_TIME_LEVEL的属性,其值为“Delay”,value为延迟级别
public void setDelayTimeLevel(int level) {
this.putProperty(MessageConst.PROPERTY_DELAY_TIME_LEVEL, String.valueOf(level));
}
public static final String PROPERTY_DELAY_TIME_LEVEL = "DELAY";
延时消息发送时核心就是两点
如何判断延时消息到点应该被消费了呢?ScheduleMessageService#start 定时器实现
DeliverDelayedMessageTimerTask中取出消息,与当前时间做比较,如果应该投递,恢复其topic,queueId,重新投递消息
总结下
开源版本延迟消息缺点:固定了Level,不够灵活,最多只能支持18个Level
public static void main(String[] args) {
Timer timer = new Timer();
//在3秒后执行run方法,之后每隔1秒执行一次run方法
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("执行任务");
}
}, 3000);
}
public static void main(String[] args) {
ScheduledExecutorService scheduledExecutorService
= Executors.newSingleThreadScheduledExecutor();
scheduledExecutorService.schedule(
() -> System.out.println("执行任务"), 3000,
TimeUnit.MILLISECONDS);
}
其原理都是基于最小堆实现的延迟队列DelayQueue
插入任务的时间复杂度为Olog(n),消息TPS较高时性能仍不够快,有没O(1)复杂度的方案呢?
部分内容摘自
https://www.cnblogs.com/hzmark/p/mq-delay-msg.html
Netty、Kafka中使用TimeWheel来优化I/O超时的操作
ticksPerWheel:槽位数
tick:每个槽位的时间间隔
假设这个延迟时间为X秒,那么X%(ticksPerWheel * tick)可以计算出X所属的TimeWheel中位置
TimeWheel的size为8,那么延迟1秒和9秒的消息都处在一个链表中。如果用户先发了延迟9秒的消息再发了延迟1秒的消息,他们在一个链表中所以延迟1秒的消息会需要等待延迟9秒的消息先投递。显然这是不能接受的,那么如何解决这个问题?
显然,如果对TimeWheel一个tick中的任务进行排序显然就解决了上面的问题
TPS较大时带来严重性能消耗,无法接受
能不能通过扩大时间轮的方式避免延迟9和延迟1落到一个tick位置上?
假设支持30天,精度为1秒,那么ticksPerWheel=30 * 24 * 60 * 60,这样每一个tick上的延迟都是一致的,不存在上述的问题(类似于将RocketMQ的Level提升到了30 * 24 * 60 * 60个)
但是TimeWheel需要被加载到内存操作,这显然是无法接受的。
对于指定的一个 Topic,所有消息按照严格的先入先出(FIFO)的顺序来发布和消费
缺点:性能较差,等于单线程处理
对于指定的一个 Topic,所有消息根据 Sharding Key 进行区块分区。同一个分区内的消息按照严格的 FIFO 顺序进行发布和消费。Sharding Key 是顺序消息中用来区分不同分区的关键字段,和普通消息的 Key 是完全不同的概念
如电商系统中的订单创建,以订单 ID 作为 Sharding Key,那么同一个订单相关的创建订单消息、订单支付消息、订单退款消息、订单物流消息都会按照发布的先后顺序来消费
Producer端
public interface MessageQueueSelector {
//根据arg选择一个消息队列
MessageQueue select(final List<MessageQueue> mqs, final Message msg, final Object arg);
}
Consumer端
public interface MessageListenerOrderly extends MessageListener {
/**
* 顺序消息消息方法
*/
ConsumeOrderlyStatus consumeMessage(final List<MessageExt> msgs,
final ConsumeOrderlyContext context);
}
public class Producer {
public static void main(String[] args) {
try {
MQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
((DefaultMQProducer) producer).setNamesrvAddr("111.231.110.149:9876");
producer.start();
// orderId为1的订单发送100条消息
new Thread(() -> {
Integer orderId = 1;
sendMessage(producer, orderId);
}).start();
// orderId为2的订单发送100条消息
new Thread(() -> {
Integer orderId = 2;
sendMessage(producer, orderId);
}).start();
Thread.sleep(1000*10);
producer.shutdown();
} catch (MQClientException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static void sendMessage(MQProducer producer, Integer orderId) {
for (int i = 0; i < 100; i++) {
try {
Message msg =
new Message("TopicTestjjj", "TagA", i + "",
(orderId + "").getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
// 根据orderId选择消息队列
Integer id = (Integer) arg;
int index = id % mqs.size();
return mqs.get(index);
}
}, orderId);
System.out.println("message send,orderId:"+orderId);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
public class Consumer {
public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
consumer.setNamesrvAddr("111.231.110.149:9876");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe("TopicTestjjj", "TagA");
//模拟消费者并行消费
consumer.setConsumeThreadMin(3);
consumer.setConsumeThreadMin(6);
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
// context.setAutoCommit(false);
for (MessageExt msg : msgs) {
System.out.println("queueId:"+msg.getQueueId()+",orderId:"+new String(msg.getBody())+",i:"+msg.getKeys());
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
consumer.start();
System.out.printf("Consumer Started.%n");
}
}
要保证消息的顺序消费,有三个关键点
第一点,消息顺序发送,多线程发送的消息无法保证有序性,因此,需要业务方在发送时,针对同一个业务编号(如同一笔订单)的消息需要保证在一个线程内顺序发送,在上一个消息发送成功后,在进行下一个消息的发送。对应到mq中,消息发送方法就得使用同步发送,关键点在于单线程同步顺序发送消息
第二点,消息顺序存储,mq的topic下会存在多个queue,要保证消息的顺序存储,同一个业务编号的消息需要被发送到一个queue中。对应到mq中,需要使用MessageQueueSelector来选择要发送的queue,即对业务编号进行hash,然后根据队列数量对hash值取余,将消息发送到一个queue中。关键点在于根据业务唯一编号Hash后选择同一消息队列(分区)
第三点,消息顺序消费,要保证消息顺序消费,同一个queue就只能被一个消费者所消费,因此对broker中消费队列加锁是无法避免的。同一时刻,一个消费队列只能被一个消费者消费,消费者内部,也只能有一个消费线程来消费该队列。即,同一时刻,一个消费队列只能被一个消费者中的一个线程消费。关键点在于保证一个队列同一个时刻只能被一个消费者中一个线程消费
锁定MessageQueue,向broker申请锁定队列,RebalanceImpl#updateProcessQueueTableInRebalance
锁定单线程消费,synchronized申请独占锁,ConsumeMessageOrderlyService.ConsumeRequest