使用消息队列的原因
要是针对某些特定的业务场景,如果不使用消息队列会让系统的一些业务实现变得很复杂。这些场景很多,比如电商系统的订单与库存服务,考试系统的提交与日志服务等。将这些复杂的场景抽象起来,其实使用消息队列的场景集中在达到三个目的:解耦、异步、挫峰。
解耦
这里主要针对耦合度比较高的系统场景,举个简单的例子,比如说电商场景,订单服务需要被支付、仓库、商品等服务调用,而这其中订单服务就会出现很大的问题,它需要去时刻检测另外的服务是否还活着,如果调用失败了是不是还需要存储或者重发,一旦处理的逻辑不当会造成整个系统的数据不一致。
在计算机科学的世界里,没有什么问题是引入一个中间层解决不了的。(虽然这句话的下半句是但是这个中间层会引起新的问题。)
但是引入消息队列之后,场景就变成了,订单服务提交数据给消息队列服务器,无需关心另外系统的消费情况,即将订单服务与其他服务解耦开了。而这里面的消息队列就起着一种类似与C#中委托订阅,比较经典的Pub/Sub模型的作用。
异步
这里主要针对实际对响应时间要求比较高的应用场景,举个我曾经实际遇到过的场景,简单说就是一个电子签章的场景,涉及到显式签名、隐式签名与数据库中签名状态记录的更新。显示签名指在对应的文档模板上通过相应文档操作更新上显示的签名或图章,隐式签名指的是密码学的相关操作,数据库记录更新即更新相应记录,便于后续的操作。
如果采用一般的模式(无消息队列),签一次名的总时间为1500ms左右(密码与文档操作会消耗大量的时间),这无论是从用户体验与系统瓶颈来看都是不友好的。一般的应用产品,理论上每个请求应在200ms使用完成。
引入消息队列后,我们可以将显示与隐式签名的服务调用与数据库状态记录的更新解耦开,将签名签章请求发送给单独的消息队列服务器,只更新数据库的记录(起到类似与锁的作用),然后再由显式与隐式签名服务去消费消息队列里的请求。这样对于用户来说,一次响应的时间大概在10ms不到,因为不涉及长时间的运算,对于系统来说,有效的解决了系统的当前瓶颈,因为很多情况下,用户只是签名完成即可,不需要去下载真正的签名文档。
挫峰
顾名思义就是解决系统达到峰值并发引起的问题,比如一个正常的考试系统,当不考试的时候,系统应该是几乎没有请求的,而当有考试的时候,系统是有很大的并发量的,当并发量足够大,且没有redis,cache之类的东西,请求会容易把数据库直接搞挂,从而导致系统的崩溃,严重的可引起微服务系统的雪崩效应。
如果引入了消息队列,就可以解决这样的问题,这时消息队列起的是一个缓冲的作用,保证请求被后台程序稳定消费,将系统的峰值分摊到后面空闲的时候处理。
不使用消息队列的原因
缺点应该是比较明显,场景很复杂,引入消息队列会带来很多新的问题。
复杂
引入消息队列固然解决了一定棘手的问题,但仍然存在很多问题,比方说消息如何保证被可靠消费,消息如何保证没有在传输过程中失败。
可用性保证
和一些微服务中重要的组件一致,比方说配置中心,注册中心等,消息队列的可用性仍然需要可靠的保证,并需要一定的容错措施。
一致性问题
就像刚才在异步场景举的列子,用户固然接受到已经操作成功的消息,你还需要保证下面还未执行的操作顺利执行,如果执行失败也要有相应的处理措施,如果毫无处理,在某种意义上就造成了整个系统的数据不一致问题。
总的来说还是刚才说的那句名句,在计算机科学的世界里,没有什么是引入一个中间层解决不了的,但是这个中间层会带来新的问题。
消息队列的选型(各消息队列的优缺点分析)
世面上比较常用的是Kafka,ActiveMQ,RabbitMQ,RocketMQ消息队列
名称 | Kafka | ActiveMQ | RabbitMQ | RocketMQ |
---|---|---|---|---|
吞吐量 | 10w | 1w | 1w | 10w |
发布订阅主题数量造成的影响 | 较大 | 无 | 无 | 较小 |
效率 | 毫秒 | 毫秒 | 微秒 | 毫秒 |
可用性 | 极高 | 高 | 高 | 极高 |
使用架构 | 分布式 | 主从 | 主从 | 分布式 |
可靠性 | 极高 | 会丢 | 极高 | 需要优化达到不丢 |
功能 | 简单,一般用于大数据 | 完善 | 并发强 | 扩展性好 |
使用场景 | 大数据 领域 | 社区不活跃不建议 | 不可控但性能强 | 社区有黄的风险 |
问题的解决
保证高可用
RabbitMQ
上面提到,这个场景下的消息队列主要基于主从场景。RabbitMQ的操作主要有三种模式:single,normal cluster,image cluster来保证高可用,只需要在它的控制台选择相应的策略配置即可切换。
–针对single模式,没有必要讨论,只是本地运行学习使用。
–针对normal cluster模式,简单说就是普通集群部署的方式,在多台机器或现在很流行的容器中启动RabbitMQ实例,RabbitMQ使真实的队列只存在与其中的一个实例中,并在各个实例中同步更新队列的配置信息(一般称之为元数据),当我们需要找到真实队列的实例并开始消费时,我们可以通过元数据简单找到。也就是说,真实的消费场景是连接任意实例,这个实例去拉取真正实例的数据。这种模式的缺点比较明显,一个是如果真实队列所在的实例发生故障,那么可用性会造成影响,另一个是如何选择连接的实例,即如何保证各个实例负载均衡。总而言之,只是提升了一定的消息消费性能,并没有真正做到我们现在讲的高可用。
–针对image cluster模式,与上述的normal cluster场景有明显不同,实际的队列会存在于各个实例中,每个实例都相当于有一个实际队列的完整镜像,在写的时候,会从写入的实例开始使用分布式同步的方法将这个消息同步到各个实例,在读的时候,只需要采用随机,轮询等经典负载均衡算法连接合适的实例即可获取,没有拉取数据的成本与风险。优点很明显,实现了高可用,缺点也很明显,每个实例都存了一份数据,很难拓展,到后期每写入一个消息,成本都增加了。
Kafka
天然适合分布式的场景,架构和现在流行的K8S很像,就是各个层次的细化做的细致,广的来看,针对整体的结构,分成若干个broker节点,针对消息订阅发布的主题,则将其分成各个partition,将各个partition存放在各个broker节点上。
通俗的讲,就是数据不连续放在一个机器或容器上,而是分散开。这也是它和RabbitMQ最大的不同,也决定了它在大数据领域很有市场。
Kafka在0.8以前是没有高可用性保证的,而在0.8以后提供了一种叫作replica副本的机制。每个partition的数据会在写入完成后通过分布式的方式去同步到其他实例或机器中,也就是形成了各个夫本,然后会使用一个共识算法去选举一个leader replica出来,由它去管理其他的replica。这是一种中心化的做法,每次写入只需要写入leader,然后让leader去同步数据到所有的follower上,读的时候直接读取leader数据。从而达到了高可用,即使leader因为过劳挂了,只需要从其他的replica中重新使用共识算法选举出一个新的leader即可。
更细致的描绘一下,写的时候默认的共识方式是,写入leader,leader将数据从内存写入磁盘,其他broke自动去pull数据,并返回给leader pull的结果,当leader发现所有的broke都成功复制了数据,再返回成功写入的响应。当然可以更换别的共识策略。
更具体的可以看官方的文档。
保证幂等性
所谓幂等性即每个消息无论消费多少次对系统产生的结果都是一致的,在消息队列的场景下,要保证幂等性,首先要保证消息不被重复消费。
保证消息不被重复消费
拿kafka来说,kafka是通过偏移量来实现消息的消费,每次写入消息时候获取消息的偏移量来定位消息的位置,消息完成后,每隔一段时间将偏移量提交,来保证宕机后继续能使用上次的消息消费。
设想固然是美好的,但是这个操作并不是Atomic的操作。会存在还未提交过偏移量,但是消息的确已经被消费的那一刹那重启的问题,会导致重启回复后的消费再次被消费。
这种问题消息队列并没有给我们保证好,需要我们自己在业务里判断。
比方说简单的数据库写入消息,我们只需要下写入的时候判断一下这个请求是否已经被消费过即可,当然业务的场景会复杂,也需要采用不同的方法去操作。
进一步保证幂等性
有很多方法可以保证,比如说以下场景
– 普通关系型数据库的Create场景,先read一下,再判断是Create还是update
– Nosql,自身就支持幂等性。
– 复杂场景,使用分布式唯一id,可以用redis,uuid等方法。
– 使用数据库自身约束,具体场景具体分析。
保证高可靠性
即保证数据被可靠传输,拿RabbitMQ来说。众所周知,消息队列的引入同时会出现三个角色,即producer,customer和消息队列本身,那么问题必然出现在这三个角色其中一环。
RabbitMQ的解决方式
- producer发送到消息队列时丢失数据:原因就不用管了,比如网络坏了,波动了。为了避免这个问题,RabbitMQ提供了事务的支持,和数据库中的事务类似,当消息队列没有给生产者正确的写入响应时,生产者会认为没有正确执行这一事务,直接启动回滚机制重发消息,直到正确响应收到之后才会commit。当然这必然导致性能下降,为此RabbitMQ也提供了另一种confirm的模式,在生产者开启confirm之后,会生成一个全局id,如果成功响应,那么就成功返回,如果没有被成功响应,那么你可以根据自己的业务场景,结合这个全局唯一的id来进行自己操作,或重发或回调。两种模式存在本质上的区别,同步阻塞和异步调用的区别。一般来说使用异步调用,即confirm的模式。
- 消息队列丢失数据: 开启持久化模式,这个和redis类似,开启持久化之后消息不仅存在于内存中,还会被写入硬件磁盘中,便于回复。方法比较简单,即在初始化的时候将queue设置为持久化的模式,然后将消息的deliveryMode设置为2.但这样仍然会存在还未持久到磁盘时数据丢失。
- customer丢失数据:消费者接受到了数据,但是自己挂了。这个也比较简单,不经额外配置的话RabbitMQ的响应是HTTP的可靠保证机制类似的机制,即当这个数据被消费者收到之后,消费就已经默认完成了,我们为了避免这个问题的出现,只需要更换这个验证机制,把接受响应变成一个api接口调用的模式,直到自己的业务逻辑走完再返回给消息队列已经成功消费的机制即可。
Kafka的解决方式
和上面一样,分析三种情况
- producer丢失:不会出现这个问题,因为Kafka的写入机制在上文已经题到,只有各个broke都有对应的副本出现才会返回成功响应。当然可能因为一些配置调优,设置acks为非默认参数的话也会出现一定这样的情况,毕竟性能和可靠就是个跷跷板的两端。
- 消息队列丢失:一般来说不会出现,但是会有一种极端情况,比如说在重新选举leader的时候,平民节点还未同步数据,而这个还没同步的节点被选举成了新的leader,那么数据就丢失了。这种极端情况可以通过配置来解决,第一个配置主题replication.factor大于1(实际情况自己配),第二个配置服务端min.insync.replicas也大于1,确保还存在一个平民节点在leader挂了还有完整的数据(通俗的说,选了个副把手),第三个:设置acks=all,和上面题到的一样。第四个,设置retris=MAX,即让写入未成功的时候不断重试,保证不往下执行相应的逻辑。
- customer丢失:和RabbitMQ一样,将自带的相应关闭,然后写自己的逻辑去替换即可。
保持有序性
一般来说是不会有消息不按正确的顺序被消费的问题,但是还是存在一些特殊且不少见的场景
- RabbitMQ:存在只有一个消息队列对应多个消费者的情况,因为消费者处理数据的速度不一致,导致写入最终数据库的数据顺序发生了不一致(对数据顺序有要求的场景)。解决方法:产生多个实际队列,每个队列对应一个消费者,消费者内部处理多个队列,可以在内存中新建一个队列,然后分配给不同的消费者有序执行操作。
- Kafka:新建一个主题,产生三个分区。如果消费者使用多线程并发处理,会让数据的顺序丢失。比较常见的就是大数据Mapreduce的处理。必然涉及多线程导致数据顺序混乱。解决方法一种是更换单线程,但这在大数据场景是不可以的(学过的应该懂),另一种和RabbitMQ类似,在内存中建多个队列,将key存在同一个队列中,然后让多线程场景消费者每个线程处理一个队列,在大数据场景中就是针对每个队列,使用一个Map发生的线程去处理。(虽然也可以放到reduce函数去处理这个逻辑,但是这样会对数据的格式有另外的要求,比如有顺序id等。)
解决极端情况(延时、失效、队列满、消息积压过多)
- 大量消息长时间不被消费:如果这个事情已经发生,可以采用扩容的方法,比如说增加consumer实例数,增加队列的主题数等。
- 消息有效时间到了:会丢失大量数据,只能通过脚本将过期数据重新传入消息队列。
- 队列满:和上面差不多,脚本将过期数据重新传入。
消息队列可扩展的思想
- 弹性伸缩:和容器弹性计算的思想类似,根据数据的增加,增加存储的实例数,可伸缩的应对需求。
- 减少磁盘读取的成本:众所周知,磁盘的读取是比较慢的,时间消耗主要在磁盘的寻址问题,因此引入偏移量来减少寻址成本。
- 更具场景选择更好的共识算法去选举新的leader节点。
- 保证数据不被丢失,引入像binlog,三段提交,两段提交,镜像恢复等方式。