消息队列(Message Queue),顾名思义,是队列这种数据结构的一种实现。今天我们对其进行详细的了解
一、什么是消息队列
消息队列是一种队列,是一种存储消息的中间件,我们可以把他看成是一种存储消息的容器。提到队列,就不得不说元素的进出顺序,先进先出。消息队列也遵循这个原则。生产者如果按照123的顺序进行消息的发送,那消费者必定要通过123的顺序进行接收。即时在多个消费者订阅同一主题的消息时,我们也应该从代码上实现他的顺序操作问题。
消息队列是实现分布式架构的重要的一环。消息队列可以通过多个服务器集群的方式,生产者,消费者,NameServer,broker均可以集群实现。
二、消息队列有哪些优点呢
解耦
使用消息队列可以降低系统的耦合性。消息队列相当于一个中间件。他把不同的系统分为两种角色,生产者和消费者。生产者和消费者之间没有耦合,通过消息队列这个中间件进行信息的交流。比如A系统有个orderId的字段,当前要求B系统需要,我们可以直接把两者进行耦合进行添加,通过A和B的关联实现消息的互通。但是如果我们需求变更,需要添加更多的系统,这样就需要修改相应的代码,修改过程复杂。但是如果我们通过A作为生产者把消息发给队列,这样这件事就与A没有关系。之后不管添加几个或者移除几个需要用到orderId的系统,都不回影响到A。这种生产者消费者模式大大的降低了系统的耦合性。这也是分布式的要求。
异步实现
消息队列可以将同步方法实现为异步方法。通过队列的存储功能,我们可以优先解决系统的重要流程,把一些诸如SMS,邮件通知等一些非必要信息实现异步调用。再例如,我们当前业务,下单之后会向用户发送邮件和短信,由于这部分功能没有那么高的即时性,即便是有延迟实现也能接收。下单处理占用30ms,发邮件占用20ms,发短信占用20ms。那么我们整个方法一共会占用70ms。但是如果我们把相关的信息放入消息队列的话,通过另外的服务实现之后进行异步操作,那么整个下单只会占用30ms。这种效率的提升在处理高并发系统时十分关键
削峰
在面对高并发时间的时候,常常系统因为受不了压力而崩溃。比如双十一的下单狂潮,完全可能造成数据库崩溃。这时候我们可以把部分能接受的业务转化为通过消息队列实现。面对用户大量的请求,我们可以先把请求存入消息队列,在按照一定的顺序慢慢对队列里面的消息进行处理。这样可以应对高并发的压力
三、消息队列的缺点
任何事物都不是完美无缺的,有优点就一定有缺点。消息队列的缺点有以下方面:
系统的复杂度提高
可能代码的复杂度提升了,因为要考虑到数据的一致性,缺失数据,数据重复性等多方面的问题
系统的可用性降低
如果消息队列服务器出问题的话,可能整个系统就会瘫痪
四、为什么要用消息队列呢
- 处理高并发
- 降低耦合性
- 实现分布式架构
- 分布式架构事务处理(本次研究的重点)
五、代码和原理解析RocketMQ
接下来我们针对消息队列的原理进行解读
首先是RocketMQ的架构体系,体系图如下:
RocketMQ一共有四个角色:
生产者-Producer
生产者负责生产消息,并把它存入Broker中。此处的生产者是可以集群实现的。生产者发送消息的方式有同步、异步和单向三种方式
一、同步发送
同步消息发送适用于一些比较重要的情景业务之下,比如重要日志的保存,又比如重要邮件、短信的发送。这种方式要求收到接收方的正确反应之后才发送下一个数据包,否则不会发送数据
二、异步发送
异步发送算是最常用的一种发送方式。这种发送方式producer发出消息之后不必接受接收方的响应即可发送下一个数据包。一般情况下用于部分不重要日志的生成、非必要邮件的发送和视频上传之后通知转码服务等等。非重要业务逻辑的处理可以使用这种发送方式。这种发送方式的有点在于效率要比同步方式高得多
三、单向发送
单向发送是指异步发送中如果没有设置回调函数的场景,一般用于处理较大业务量的情况下,比如说日志系统的收集
生产者组-ProducerGroup
生产者组指的是具有同样行为的一组生产者。如果说一组生产者会发送同一类型的消息,则默认为这是一个生产者组。代码中我们可以通过如下方式设置生产者组:
//方式一,构造函数模式生成
DefaultMQProducer producer = new DefaultMQProducer("BatchProducerGroupName");
//方式二
producer.setProducerGroup("testGroup");
//默认构造函数下
public DefaultMQProducer() {
this(null, MixAll.DEFAULT_PRODUCER_GROUP, null);
}
public static final String DEFAULT_PRODUCER_GROUP = "DEFAULT_PRODUCER";
生产者组可以是不同的机器,也可以是同一机器的不同进程,也可以是一个进程的多个对象。同一个生产者组可以发送多个topic的消息。
主要工作内容
生产者主要是消息的发送方。发送消息时,先通过topic询问nameServer刺topic在哪个broker中,然后把消息发给broker。如果是同步的话还要判断该消息是否收到。
服务器-NameServer
NameServer是类似于Zookeeper一样的无状态的服务,也可以称为是注册中心。他的主要作用是处理生产者和消费者请求的topic名称并返回broker的相关信息给生产者和消费者。据说一开始这个项目阿里用的就是Zookeeper作为这个中间无状态的服务。
那么为什么要用这个注册中心呢,他的具体作用是什么呢?
- 维护topic和broker的信息
- 监控broker的状态
- 为client提供路由能力
broker会注册服务到NameServer,并且和NameServer建立长期的联系。producer同样会和NameServer保持长期联系,通过在服务集群的一个节点的连接,并不时的获取topic和broker的相关信息。producer会和master保持长期联系,而consumer会和master,slave同时保持长期联系
说到底,NameServer和Zookeeper的性质一样,都是一个无状态注册中心。通过这个注册中心,不仅可以实现配置管理,还能实现集群管理等
下面是NameServer源码结构
我们聚焦于NamesrvStartup来解析下整个服务的启动过程
首先是初始化NamesrvController ,然后开始启动函数
NamesrvController controller = createNamesrvController(args); start(controller);
初始化方法如下
public static NamesrvController createNamesrvController(String[] args) throws IOException, JoranException {
//初始化相关的版本信息
System.setProperty(RemotingCommand.REMOTING_VERSION_KEY, Integer.toString(MQVersion.CURRENT_VERSION));
//PackageConflictDetect.detectFastjson();
//构建命令行操作的指令
Options options = ServerUtil.buildCommandlineOptions(new Options());
//转化为对应的命令行--需要继续添加c参数的命令行,config配置文件的处理
commandLine = ServerUtil.parseCmdLine("mqnamesrv", args, buildCommandlineOptions(options), new PosixParser());
if (null == commandLine) {
System.exit(-1);
return null;
}
//初始化NamesrvConfig和NettyServerConfig---可见服务器的启动是通过netty启动的
final NamesrvConfig namesrvConfig = new NamesrvConfig();
final NettyServerConfig nettyServerConfig = new NettyServerConfig();
//默认监听端口号为9876
nettyServerConfig.setListenPort(9876);
//加载config配置信息
if (commandLine.hasOption('c')) {
String file = commandLine.getOptionValue('c');
if (file != null) {
InputStream in = new BufferedInputStream(new FileInputStream(file));
properties = new Properties();
properties.load(in);
MixAll.properties2Object(properties, namesrvConfig);
MixAll.properties2Object(properties, nettyServerConfig);
namesrvConfig.setConfigStorePath(file);
System.out.printf("load config properties file OK, %s%n", file);
in.close();
}
}
//加载printConfigItem相关信息
if (commandLine.hasOption('p')) {
InternalLogger console = InternalLoggerFactory.getLogger(LoggerName.NAMESRV_CONSOLE_NAME);
MixAll.printObjectProperties(console, namesrvConfig);
MixAll.printObjectProperties(console, nettyServerConfig);
System.exit(0);
}
//把命令行的命令加载到namesrvConfig
MixAll.properties2Object(ServerUtil.commandLine2Properties(commandLine), namesrvConfig);
//环境变量的配置
if (null == namesrvConfig.getRocketmqHome()) {
System.out.printf("Please set the %s variable in your environment to match the location of the RocketMQ installation%n", MixAll.ROCKETMQ_HOME_ENV);
System.exit(-2);
}
//日志相关
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
JoranConfigurator configurator = new JoranConfigurator();
configurator.setContext(lc);
lc.reset();
configurator.doConfigure(namesrvConfig.getRocketmqHome() + "/conf/logback_namesrv.xml");
log = InternalLoggerFactory.getLogger(LoggerName.NAMESRV_LOGGER_NAME);
//把日志相关信息加载到namesrvConfig和nettyServerConfig
MixAll.printObjectProperties(log, namesrvConfig);
MixAll.printObjectProperties(log, nettyServerConfig);
//新建控制器,该控制器内容不可改变
final NamesrvController controller = new NamesrvController(namesrvConfig, nettyServerConfig);
// remember all configs to prevent discard
//注册配置信息
controller.getConfiguration().registerConfig(properties);
return controller;
}
整个NamesrvController 的初始化方法基本是对一些配置信息和日志信息的加载,并且把相关信息注入到Namesrv的配置类和netty的配置类。通过Option对象生成命令行信息。加载配置的方法有以下两个
public static Options buildCommandlineOptions(final Options options) {
Option opt = new Option("h", "help", false, "Print help");
opt.setRequired(false);
options.addOption(opt);
//参数n指定namesrv的地址,可以是单机或者集群,如果是集群服务器,需要用";"隔开
opt =
new Option("n", "namesrvAddr", true,
"Name server address list, eg: 192.168.0.1:9876;192.168.0.2:9876");
opt.setRequired(false);
options.addOption(opt);
return options;
}public static Options buildCommandlineOptions(final Options options) {
//加载namesrv配置信息
Option opt = new Option("c", "configFile", true, "Name server config properties file");
opt.setRequired(false);
options.addOption(opt);
opt = new Option("p", "printConfigItem", false, "Print all config item");
opt.setRequired(false);
options.addOption(opt);
return options;
}
最后的注册方法是如下实现的
public Configuration registerConfig(Properties extProperties) {
if (extProperties == null) {
return this;
}
try {
readWriteLock.writeLock().lockInterruptibly();
try {
merge(extProperties, this.allConfigs);
} finally {
readWriteLock.writeLock().unlock();
}
} catch (InterruptedException e) {
log.error("register lock error. {}" + extProperties);
}
return this;
}
整个方法是通过锁实现了线程安全、merge方法把配置信息复制到当前类的配置信息文件中,实现了配置的保存
接下来我们看下start方法,即初始化之后整个系统是如何进行启动的。在启动所有服务之前,首先要对remotingServer进行初始化,并且要建立线程池和相关的线程等信息用于执行相关操作
public boolean initialize() {
//加载kv配置管理类
this.kvConfigManager.load();
//初始化remotingServer--netty的服务类,囊括通讯在内的多种功能
this.remotingServer = new NettyRemotingServer(this.nettyServerConfig, this.brokerHousekeepingService);
//根据配置文件构建线程池--构建固定大小的线程池--后面是定义的线程池工厂--这个线程池是用于执行remotingServer的一些操作的
this.remotingExecutor =
Executors.newFixedThreadPool(nettyServerConfig.getServerWorkerThreads(), new ThreadFactoryImpl("RemotingExecutorThread_"));
//注册这个过程
this.registerProcessor();
//主线程启动 为单个线程模式
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
NamesrvController.this.routeInfoManager.scanNotActiveBroker();
}
}, 5, 10, TimeUnit.SECONDS);
//会周期性的执行这条命令,首先是在第一个延迟之后(1),然后每隔一个周期执行一次(10)
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
NamesrvController.this.kvConfigManager.printAllPeriodically();
}
}, 1, 10, TimeUnit.MINUTES);
if (TlsSystemConfig.tlsMode != TlsMode.DISABLED) {
//监听器--用于通信安全的监听
// Register a listener to reload SslContext
try {
fileWatchService = new FileWatchService(
new String[] {
TlsSystemConfig.tlsServerCertPath,
TlsSystemConfig.tlsServerKeyPath,
TlsSystemConfig.tlsServerTrustCertPath
},
new FileWatchService.Listener() {
boolean certChanged, keyChanged = false;
@Override
public void onChanged(String path) {
if (path.equals(TlsSystemConfig.tlsServerTrustCertPath)) {
log.info("The trust certificate changed, reload the ssl context");
reloadServerSslContext();
}
if (path.equals(TlsSystemConfig.tlsServerCertPath)) {
certChanged = true;
}
if (path.equals(TlsSystemConfig.tlsServerKeyPath)) {
keyChanged = true;
}
if (certChanged && keyChanged) {
log.info("The certificate and private key changed, reload the ssl context");
certChanged = keyChanged = false;
reloadServerSslContext();
}
}
private void reloadServerSslContext() {
((NettyRemotingServer) remotingServer).loadSslContext();
}
});
} catch (Exception e) {
log.warn("FileWatchService created error, can't load the certificate dynamically");
}
}
return true;
}
本次研究限于个人水平,只能有限的了解到,rocketmq的服务是基于Netty实现的。而Netty的所有服务的交互都是基于此模块的,大概的通信结构图如下
第二,关于线程池方面。首先会生成一个固定大小的线程池,线程池的大小可以在配置文件中设置;之后生成主线程。并周期性的打印日志相关信息。初次之外,监听器也会在这个时候生成,用于监控模块间通讯的安全.
三、broker
broker是RocketMQ的核心内容,队列的大部分内容都是通过broker实现的,包括消息的存储和读取,消费持久化,消息的HA和服务端的过滤等。broker有两个角色,包括master和slave。master可以处理生产者发送的消息,也可处理consumer的订阅,把消息发送给消费者。同时能和namesrv进行通信,负责topic的等相关信息的确认。slave不能直接接收生产者传送的消息,slave其实算是一种备份,master写入的数据会备份到slave,当master不可用或者繁忙的时候的时候,slave也可以负责消息的订阅,读取等。通过这种master-slave的方式实现了高可用。消费者端不需要配置相关信息就可以通过切换读slave的方式实现高可用,但是生产者端需要通过配置相同name不同id的broker组实现多可用,创建topic的时候把多个消息放在多个broker组上,这样当一个broker不可用的时候,别的broker组的master仍然可用
broker的master和slave的联系:
一个master可以对应多个slave。对于master,其brokerId=0.对于slave涞水,其brokerId不为零。一个master可以对应多个slave,他们通过拥有同一个brokerName而被分为通过一个Broker set。日常使用至少需要两个brokerset
以下是消息领域模型的一些概念:
topic
topic相当于一级分类。我们把具有一致性或者相似型的一类消息定义为同一个topic。topic也是broker分类的最基本的字段。一个消息必须有topic
tag
同一个topic下的消息可能有所区别,tag可以代表这种区别。tag相当于二级分类,方便消息的灵活控制
group
组,标识具有相同角色的生产者和消费者的集合。在集群之下,我们把多个生产者/消费者加入到同一个组,当一个生产者down之后,我们可以利用同一个组的其他生产者/消费者替代,而不至于影响业务。同时如果加入一个新机器,我们只需要定义他的组名,就可以加入相关组
message queue
消息的物理管理单位。一个topic之下可以实现多个queue。在rocketmq中,所有的队列都是持久化并且无限长的数据结构。无限长指的是队列中的每个存储单元都是固定长度,访问其中的存储单元使用offset来访问(offset是long型的64位单位,被认为100年不会溢出)所以被认为是无线长度。我们同时也可以理解为queue是个无限长度的数组,而offset就是他的下标
broker源码解读:
首先是初始化BrokerController
public static BrokerController createBrokerController(String[] args) {
//根据netty的命令类获取版本号等相关信息
System.setProperty(RemotingCommand.REMOTING_VERSION_KEY, Integer.toString(MQVersion.CURRENT_VERSION));
//socket,网络传输包的容量设置
if (null == System.getProperty(NettySystemConfig.COM_ROCKETMQ_REMOTING_SOCKET_SNDBUF_SIZE)) {
NettySystemConfig.socketSndbufSize = 131072;
}
//socket,接收方包的容量设置
if (null == System.getProperty(NettySystemConfig.COM_ROCKETMQ_REMOTING_SOCKET_RCVBUF_SIZE)) {
NettySystemConfig.socketRcvbufSize = 131072;
}
try {
// PackageConflictDetect.detectFastjson();
//创建命令对象
Options options = ServerUtil.buildCommandlineOptions(new Options());
//主要加载三个重要的配置文件 configFile printConfigItem printImportantConfig
commandLine = ServerUtil.parseCmdLine("mqbroker", args, buildCommandlineOptions(options),
new PosixParser());
if (null == commandLine) {
System.exit(-1);
}
//初始化BrokerConfig,NettyServerConfig,NettyClientConfig--服务器对象和客户端对象
final BrokerConfig brokerConfig = new BrokerConfig();
final NettyServerConfig nettyServerConfig = new NettyServerConfig();
final NettyClientConfig nettyClientConfig = new NettyClientConfig();
nettyClientConfig.setUseTLS(Boolean.parseBoolean(System.getProperty(TLS_ENABLE,
String.valueOf(TlsSystemConfig.tlsMode == TlsMode.ENFORCING))));
//监听端口号
nettyServerConfig.setListenPort(10911);
//常量对象MessageStore的初始化
final MessageStoreConfig messageStoreConfig = new MessageStoreConfig();
//Broker有三个对象,master分两种
//首先是SYNC_MASTER,是同步的master,指的是写入master的消息必须同步写入他的对应的slave才能返回成功信息
//ASYNC_MASTER,指的是异步的master。写入此类master的信息会立即返回成功信息,在异步写入对应的slave
if (BrokerRole.SLAVE == messageStoreConfig.getBrokerRole()) {
int ratio = messageStoreConfig.getAccessMessageInMemoryMaxRatio() - 10;
//slave的主从读写分离机制,如果超过了内存的此限制,将使用从服务器
messageStoreConfig.setAccessMessageInMemoryMaxRatio(ratio);
}
if (commandLine.hasOption('c')) {
String file = commandLine.getOptionValue('c');
if (file != null) {
configFile = file;
InputStream in = new BufferedInputStream(new FileInputStream(file));
properties = new Properties();
properties.load(in);
properties2SystemEnv(properties);
MixAll.properties2Object(properties, brokerConfig);
MixAll.properties2Object(properties, nettyServerConfig);
MixAll.properties2Object(properties, nettyClientConfig);
MixAll.properties2Object(properties, messageStoreConfig);
BrokerPathConfigHelper.setBrokerConfigPath(file);
in.close();
}
}
MixAll.properties2Object(ServerUtil.commandLine2Properties(commandLine), brokerConfig);
//环境变量检测
if (null == brokerConfig.getRocketmqHome()) {
System.out.printf("Please set the %s variable in your environment to match the location of the RocketMQ installation", MixAll.ROCKETMQ_HOME_ENV);
System.exit(-2);
}
String namesrvAddr = brokerConfig.getNamesrvAddr();
if (null != namesrvAddr) {
try {
String[] addrArray = namesrvAddr.split(";");
for (String addr : addrArray) {
RemotingUtil.string2SocketAddress(addr);
}
} catch (Exception e) {
System.out.printf(
"The Name Server Address[%s] illegal, please set it as follows, \"127.0.0.1:9876;192.168.0.1:9876\"%n",
namesrvAddr);
System.exit(-3);
}
}
switch (messageStoreConfig.getBrokerRole()) {
case ASYNC_MASTER:
case SYNC_MASTER:
//master的brokerId=0
brokerConfig.setBrokerId(MixAll.MASTER_ID);
break;
case SLAVE:
//Slave的brokerId必须大于0
if (brokerConfig.getBrokerId() <= 0) {
System.out.printf("Slave's brokerId must be > 0");
System.exit(-3);
}
break;
default:
break;
}
//日志相关
if (messageStoreConfig.isEnableDLegerCommitLog()) {
brokerConfig.setBrokerId(-1);
}
messageStoreConfig.setHaListenPort(nettyServerConfig.getListenPort() + 1);
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
JoranConfigurator configurator = new JoranConfigurator();
configurator.setContext(lc);
lc.reset();
configurator.doConfigure(brokerConfig.getRocketmqHome() + "/conf/logback_broker.xml");
if (commandLine.hasOption('p')) {
InternalLogger console = InternalLoggerFactory.getLogger(LoggerName.BROKER_CONSOLE_NAME);
MixAll.printObjectProperties(console, brokerConfig);
MixAll.printObjectProperties(console, nettyServerConfig);
MixAll.printObjectProperties(console, nettyClientConfig);
MixAll.printObjectProperties(console, messageStoreConfig);
System.exit(0);
} else if (commandLine.hasOption('m')) {
InternalLogger console = InternalLoggerFactory.getLogger(LoggerName.BROKER_CONSOLE_NAME);
MixAll.printObjectProperties(console, brokerConfig, true);
MixAll.printObjectProperties(console, nettyServerConfig, true);
MixAll.printObjectProperties(console, nettyClientConfig, true);
MixAll.printObjectProperties(console, messageStoreConfig, true);
System.exit(0);
}
log = InternalLoggerFactory.getLogger(LoggerName.BROKER_LOGGER_NAME);
MixAll.printObjectProperties(log, brokerConfig);
MixAll.printObjectProperties(log, nettyServerConfig);
MixAll.printObjectProperties(log, nettyClientConfig);
MixAll.printObjectProperties(log, messageStoreConfig);
//初始化对象并注册避免信息丢失
final BrokerController controller = new BrokerController(
brokerConfig,
nettyServerConfig,
nettyClientConfig,
messageStoreConfig);
// remember all configs to prevent discard
controller.getConfiguration().registerConfig(properties);
//初始化
boolean initResult = controller.initialize();
if (!initResult) {
controller.shutdown();
System.exit(-3);
}
//钩子函数--程序停止或者以外宕机的情况下执行清理现场的代码
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
private volatile boolean hasShutdown = false;
private AtomicInteger shutdownTimes = new AtomicInteger(0);
@Override
public void run() {
synchronized (this) {
log.info("Shutdown hook was invoked, {}", this.shutdownTimes.incrementAndGet());
if (!this.hasShutdown) {
this.hasShutdown = true;
long beginTime = System.currentTimeMillis();
//关闭controller
controller.shutdown();
long consumingTimeTotal = System.currentTimeMillis() - beginTime;
//打印日志,包括相关消息
log.info("Shutdown hook over, consuming total time(ms): {}", consumingTimeTotal);
}
}
}
}, "ShutdownHook"));
return controller;
} catch (Throwable e) {
e.printStackTrace();
System.exit(-1);
}
return null;
}
接下来启动相关的所有服务即可。
四、consumer
consumer消费者,顾名思义所做的是从队列中获取信息并且对信息进行一定的处理。这时候涉及到许多问题,比如数据重复时的幂等问题,数据的执行顺序问题,事务失败的回滚问题,之后我们会一一分析
五、队列事务性消息的实现
1.什么是事务
事务指的是一组原子操作单元,从数据库的角度来看,是一组sql指令,如果某个原因使得其中的一条执行指令有错误,则撤销之前执行过的命令。或者通俗的说,一组原子操作要么全部执行,要么全不执行
2.事务的四个性质(ACID)
- 原子性(Atomicity):事务的原子性是指一组事务可以看作是一个原型的操作,任何执行失败的操作都会影响到整个整体的成功或者失败。一个事务,要么全部成功,要么全不成功
- 一致性(consistent):在事务执行的前后,事务的状态要保持一致。在事务管理的整个过程,事务的一致性是核心。AID都是为了C而服务的。所有的操作其实都是为了保持事务的一致性。事务在执行前后都有状态,我们需要确保提交前和提交之后的状态都是正确的。
- 隔离性(Isolated):隔离性是指事务执行的过程中,不能被其他的事务所影响。这样主要是为了处理鬓发失误引起的问题:
- 脏读(dirty read):一个事务读取了另一个事务未提交的数据
- 不可重复读(non-repeatable read):一个事务的操作导致了另一个事务前后两次读取到不同的事务。例如事务1第二次的的时候事务2对事务1第一次读的数据做了修改,导致事务1前后两次独到的数据不一致
- 幻读(phantom read):一个事务的操作导致另一个数据索要读取的数据消逝不见或者多了之前不存在的数据(多是insert或者delte的时候)
- 持久性(durable):事务一旦提交,对于对于数据库的影响是持久的,不可回滚。
3.如何保持分布式事务的一致性
事务的一致性在数据库方面有两个含义
- ACID的数据一致性
- CAP的数据一致性(CAP指的是分布式系统中一致性,可用性和分区容错性原则)
- 一致性:分布式系统中所有数据备份,在同一刻是否拥有相同的值
- 可用性:集群中某一个节点或者某一部分节点故障之后,集群还能否反应客户的读写请求
- 分区容错性:对通信的时限要求,如果所需要的时间限制不足的话,那么必须在C和A之间选择一个
- CAP的取舍策略:我们模拟一下场景,两台服务器S1和S2,他们分别有对应的数据库D1和V1。那么满足一致性的话,D1==V1;满足可用性的话,不论用户请求S1还是S2,服务区都会立即做出反应;满足容错性的话,S1或者S2任意一方宕机或者网络链接中断,都不会影响S1和S2彼此之间的正常运作。但是现在有如下状况:用户向S1请求修改数据库。D1变成了D2,但是S1和S2之间的通信中断,导致S2暂时不能同步更新为V2==D2.但是有用户向S2请求数据,由于数据还没有同步,不能返回给用户数据。这时候有如下两种解决方法:1.放弃一致性,响应数据给用户。2.放弃可用性,阻塞进程直到S1的数据同步到S2.所以说满足分区容错性的系统,并不能同时满足CA。我们需要对其进行取舍。共有如下三种策略:
1.CA舍弃P 舍弃分区策略,如果只有一个分区,那么可同时满足CA 2.CP舍弃A---Zookeeper 不考虑可用性,多个分区采取强一致性,那么可以保持数据的高一致性。但是同时一个节点的故障将导致所有节点的阻塞 3.AP舍弃C-----eureka 不考虑一致性,多个分区可以充分提高可用性。分区越多,用户越能够就近访问。提高了高可用性
接下来我们考虑到分布式数据库的一些问题。比如我们的下单操作,下单生成订单和减少余额可以看作是一个事务,但是订单和余额对应的数据库却放在了两台服务器之上,如果执行某个操作中途出现了bug,整个事件的回滚将变得很困难。
所以分布式的情况下,事务设计的将变得更加负责,大概有以下集中处理方式:
- 两阶段提交(2PC):两阶段提交是一种协议,这种协议基于一种名为协调器的实现。我们把整个事务的实现拆分为一下及部分:协调器和若干事务执行者,以之前下单为例子。下单的时候要生成日志,要进行商品库存和销量的修改,要进行订单的生成。我们把这些操作视为一个个模块。把这个消息通知给协调器。协调器把名为prepare的信息写到本地日志(回滚日志)。然后你协调器给各个模块发送prepare信息,对于不同的执行者这个prepare是不同的。事务执行者收到消息之后执行相应的操作,比如生成订单,生成日志等。但是执行后不提交,如果是成功的话则返回yes给协调器,否则返回no。协调器处理信息,如果收到的全是yes,则发送commit消息给各个事务执行者。否则返回rollback。
但是实际的应用中,这种协议方法却很少被应用,因为其所需要的条件很苛刻。如果网络稍微有点卡顿延迟,则时间代价会相当的高。并且事务执行的过程中会上锁,这种方式会导致数据库长时间的不可用。所以很少有人采用这种方式- 最终一致性(柔性事务):介于上述两阶段提交在分布式高并发情况下的表现,我们采取比较中和的手段,即不追求事务在执行的每一分每一秒中都是一致性的。而是追求最终结果的一致性。也就是说,我们可以接收事务在执行过程中短暂的不一致。从ACID的刚性一致性转化为BASE原则的柔性一致性
一、重试和幂等
首先我们介绍下分布式事务执行过程中不可以避免的一个问题,事务执行失败后该如何处理。
一般来说,对于分布式,条件不如单机情况下的稳定。因此,难免会遇到某个主机宕机,接口返回失败等情况。所以需要反复重试来解决这种错误,而不是以失败就回滚。在队列中,业务异常导致一条消息重复的发到了消息队列中,但是消息处理失败之后放到retry队列,从而进行反复的尝试。这是候我们急需方式来解决这种情况
重试就不得不提到幂等这个概念。在数学中f(x)=f(f(x)),我们称之为幂等函数。在编程中,一个系统,使用同样 的条件,请求一次和请求多次对这个系统的影响是一致的,我们称之为幂等。
幂等我们有以下两点需要注意:
MVCC指的是版本号,即数据更新的时候我们需要有对应的版本号,只有版本号一致的情况下才能操作成功,每个版本号只有一次执行成功的机会,失败后就需要重获版本号。这样减少了事务重复的可能。但是这可能不起太大的作用,因为job是反复执行的,会不断生成新的版本号
去重:通过业务逻辑来去重,比如点赞这个业务逻辑,我们可以针对user_id和comments_id做一个表,如果点赞成功就添加一条新的记录。由于标的索引是唯一的,所谓不会重复插入记录,自然也就不会出现错误的情况
二、异步确保
本文探讨的重点--如何通过消息队列实现事务的一致性----思路实现一
我们以转账为例,A转账给B100元,但是A和B放在不同的服务器和数据库上。
首先可以确保A和B是必须要实现的原子性事务,少了任何一个操作剩下的那个也将变得没有意义。但是如果直接用ACID的刚性事务性来实现的话,我们需要考虑到网络延迟的存在。可能发送消息失败的话,那么B将不会增加100元。如果返回通知失败的话,但是B的实际操作已经成功。并且在DB事务中添加网络操作的话,会使得事务的执行时间变得很长,对DB的影响极大
所以我们提出以下解决方式:(消息表)
首先对于生产者(producer),我们先要添加一张表,消息表,用于记录发送的消息以及消息的回执等内容;生产者再想消费者发送业务数据的时候,也要在消息表增加一个消息记录。由于这两个操作都是对生产者的DB进行的操作,所以我们把他们放在同一个事务里用来保持一致性。同时对于这张表,我们需要一个维护者把表中未发送的消息放入到消息队列之中,另外检测消息的执行是否超时或者失败,遇到这种异常的话就进行重试。我们允许消息重复,但是不允许消逝丢失,并且顺序不能被打乱
在接收方(consumer)我们必须实现幂等。出了上述的方法之外,我们还可以通过增加判重表来实现幂等。
缺点:上述方法的缺点是增加了不必要的开销和耦合性
三、事务消息(RocketMQ支持)
消息队列的事务消息概念相当于异步确保思路的优化。
首先消息的放松变成了两个阶段,准备和确认,生产方的步骤如下
1.发送prepared给MQ
2.执行本地的事务
3.根据本地事务的执行结果发送确认消息给MQ,决定确认prepared或者取消
这时如果第三步失败的话,怎么解决呢?RocketMQ会定期的扫描prepared消息,并询问发送方,是要确认还是取消。要求生产方需要实现Check接口
- 消费方只需要接收相关消息处理即可。由于消息队列的事务消息支持重试,如果消费方失败,消息队列会自动发起重试,不需要消费方自己实现消息的重试。
但是rocketMQ在新的版本中取消了回查的操作,所以回查部分现在需要自己解决,暂时先给出解决方法如下:
我们暂时以银行转账系统为例:
- 需要设计额外的表--转账消息确认表
第一步:A银行系统生成一条转账消息,以事务消息的方式写入RocketMQ,此时B银行系统不可见这条消息(Prepare阶段)
第二步:写入MQ成功后,回调A银行系统,对T1,T2表进行操作(很显然需要是一个事务)。我们重点关注下T2表,这个表是用来干嘛的呢?每条转账消息都会在T2表中,该表有2个特殊的字段:status,updatetime。
第三步:完成第二步,接下来发送确认消息给MQ,如果这个确认消息发送成功,那么这条转账消息,将对B银行系统可见。然后B银行系统,会在一个事务中完成对t3,t5的操作。
如果发送确认消息给MQ失败的处理思路:
首先,B银行系统,有一个定时任务(比如说每隔1MIN执行一次),扫描表t5,取得一段时间内的数据,发送给A银行系统。要知道t5中的数据,必然是A银行系统成功处理并发送确认消息成功的转账数据。为什么要发送给A银行系统呢,其实就是为了找到那些发送确认消息失败的转账数据。那么怎么发给A银行系统呢,这个方式比较多,可以考虑在来一个Topic,也可以考虑Netty等。发送给A银行系统,其实就是为了更新t2表的status,updatetime。
这里有一个关键,如何“扫描表t5,取得一段时间内的数据”?这就是t4的作用,在t4中记录一个time字段,每次定时任务启动,先更新time(比如设定为当前系统时间,设置前的的时间为old),然后扫描出t5中大于这个old时间的转账数据,如此循环往复。
其次,A银行系统,也有一个定时任务(可以根据业务消费能力定,可以大一些),扫描t2表(指定status及updatetime条件),将那些确认消息发送失败的转账消息找出来,更新updatetime并发送给MQ。
其实到这里,你可以发现RocketMQ的一个特点,就是将生产者和MQ绑定,而不需要特别处理消费者,这是为什么呢?因为消息只要发往RocketMQ成功,那么就意味着成功,为什么这么说?
前面,我们说过,消费者端消费消息只会产生2种错误,第一:timeout,第二:exception。要知道RocketMQ对于超时,会不断重试;对于消费异常,会根据消费端的返回码,会有重试机制保证。也就是,RocketMQ一定会让消息得到消费,如果消费有问题,只能是消费者的问题,而不会是RocketMQ的问题!
如果遇见消息失败但是重试无用的情况,只能选择人工介入了六、消息队列的顺序问题
在一些严格的逻辑中,消息的执行是有先后顺序的,比如传统的下单,都是生成订单之后才会生成订单日志,生成送积分操作等等。那么消息队列应该如何实现消息的顺序执行呢?
首先我们应该清楚,所谓的顺序执行对于消息M1 M2来说,我们需要保证M1先于M2进行消费。如果发送到两台服务器上面的话,我们不能保证M1优先于M2到达服务器集群,也不能保证M1被优先消费。最简单的方法就是把这一类型的消息发送到同一台服务器之上,这样就能能保证M1优先于M2到达服务器。但是,这种保证也仅仅是理论之上的,现实中由于网络延迟的存在,M2仍然有可能先被消费。即便是二者按照先后顺序到达服务端,二者发往不同的消费端,仍有可能导致M2先到服务端。这种解决方法是将M1 M2发送到同一个消费端,并且需要消费端进行响应成功M1之后才能发送M2。
- 保证生产者--MQServer--消费者是一对一的关系
这是最简单的一种方法,根据队列的性质,只要先发送,就一定会先执行。但是却有如下的缺点:
并行度不够造成吞吐量的限制
异常处理难以解决,一旦消费方出现问题,整个流程将被阻塞我们需要掌握以下原则:
- 不关注消息的乱序的应用大量存在
- 消息队列无序不代表消息无序
所以最好的解决方式是依赖业务层面对消息的顺序执行进行设计
RocketMQ的消息顺序发送是如下方法实现的:
1.发送消息时,会通过轮询所有队列的方式确认发送到哪一个队列(负载均衡策略),比如如下方法会根据订单号将消息先后发送到同一个队列中
// RocketMQ通过MessageQueueSelector中实现的算法来确定消息发送到哪一个队列上
// RocketMQ默认提供了两种MessageQueueSelector实现:随机/Hash
// 当然你可以根据业务实现自己的MessageQueueSelector来决定消息按照何种策略发送到消息队列中
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Integer id = (Integer) arg;
int index = id % mqs.size();
return mqs.get(index);
}
}, orderId);
获取到路由信息之后,会根据MessageQueueSelector的算法来选择一个队列,同一个orderId获取到的肯定的是同一个队列
private SendResult send() {
// 获取topic路由信息
TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());
if (topicPublishInfo != null && topicPublishInfo.ok()) {
MessageQueue mq = null;
// 根据我们的算法,选择一个发送队列
// 这里的arg = orderId
mq = selector.select(topicPublishInfo.getMessageQueueList(), msg, arg);
if (mq != null) {
return this.sendKernelImpl(msg, mq, communicationMode, sendCallback, timeout);
}
}
}
关于MessageQueueSelector的实现 官方提供了三种demo
rocketMQ支持自定义的筛选策略,但是必须要实现MessageQueueSelector接口:
public interface MessageQueueSelector {
MessageQueue select(final List<MessageQueue> mqs, final Message msg, final Object arg);
}
这个接口只有一个select方法,首先是根据hash值分配策略如下
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
int value = arg.hashCode();
if (value < 0) {
value = Math.abs(value);
}
value = value % mqs.size();
return mqs.get(value);
}
然后是根据随机数来分配的策略如下
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
int value = random.nextInt(mqs.size());
return mqs.get(value);
}
我们可以根据自己的业务来确认根据何种方式来实现负载均衡
七、重复性的解决
RocketMQ不解决重复问题,需要在自己的业务逻辑中实现去冲
去冲的思路之前我们曾经有过讲解
1.保证业务逻辑的幂等性
2.保证每条消息都有唯一性编号并且消息处理成功生成对应日志