什么是中间件?
应用之间传递消息过程中的容器称为中间件。
为什么使用中间件?
解耦、异步、削峰
kafka
开发语言
- Kafka:Scala
通信
采用的是一套自行设计的基于TCP层的协议,以NIO作为网络通信的基础,把多条Message放在一起做压缩,提高压缩比率,从而在网络上传输的数据量会少一些。
主要结构
- 多个broker(节点)组成的集群,由zookeeper保存节点的元数据,并进行控制器选举。
- kafka的每条消息都有一个topic,表示消息的类型。
- 每个topic包含多个分区,这些分区会分布在不同的节点上,从所有分区选举一个首领,其他的分区为副本,首领负责读写操作,副本同步首领数据。
- 每个topic可能会有一个消费者组,每个分区只能由消费者组的一个消费者订阅消费。
消息传递过程
生产者发送消息:
序列化 ProducerRecord
每个消息是一个 ProducerRecord 对象,必须指定消息所属的 Topic 和消息值 Value,此外还可以指定消息所属的 分区 以及消息的 Key。
推送到分区
推送到分区kafka有三种策略:随机策略、轮询策略、key的hash策略。默认为轮询策略。
当然如果该消息指定了 key,则 partitioner(分区器)会根据 key 的哈希值来选择目标分区,将具有相同 key 的所有消息都路由到相同的分区中;若该消息未指定 key,则 partitioner 使用轮询的方式确认目标分区。
找到分区副本 leader
producer 先从 zookeeper 的 “/brokers/…/state”节点找到该 partition 的 leader,将消息发送给该 leader。
leader将消息追加到本地log, followers 副本从 leader 拉取消息,追加到各自的本地log,后向 leader 发送 ACK
leader 收到所有 ISR 中的 replication 的 ACK 后,增加 HW(high watermark,最后 commit 的 offset)并向 producer 发送 ACK。
消费者消费过程:
和 partition leader 建立 socket 连接
Consumer 端使用 zookeeper 用来注册 consumer 信息,其中包括 consumer 消费的 partition 列表等,同时也用来发现 broker 列表
拉取消息
订阅 topic 后,当 consumer 调用 pull(拉取模型)时,会自动加入相应的 Consumer Group;只要 consumer 持续 pull,consumer 将持续的从分配给他的 topic partitions 接收消息;
提交偏移量
消费者通过往一个叫作
_consumer_offset
的特殊主题发送消息,消息里包含每个分区的偏移量。 如果消费者一直处于运行状态,那么偏移量就没有什么用处。不过,如果有消费者退出或者新分区加入,此时就会触发再均衡。完成再均衡之后,每个消费者可能分配到新的分区,而不是之前处理的那个。为了能够继续之前的工作,消费者需要读取每个分区最后一次提交的偏移量,然后从偏移量指定的地方继续处理。 因为这个原因,所以如果不能正确提交偏移量,就可能会导致数据丢失或者重复出现消费心跳监测
consumer 会在后台持续向服务发送心跳,如果 consumer 进程崩溃或者在 session.timeout.ms 期间没有发送心跳,这个 consumer 将会被认为已经死掉了,他的分区将会被重新分配。
持久化
- 磁盘顺序读写:每个分区的副本都对应一个磁盘文件,新的消息会append到磁盘结尾。
- 分段、索引:磁盘文件如果很大,效率会降低。kafka通过将数据分段,并在每段数据加上索引,加快扫描速度。
- 零拷贝:磁盘到kafka的broker之间的传递通过sendfile系统调用,提供了零拷贝,无需CPU拷贝,大大提高了性能。
- 页缓存:kafka并不依赖内存,读取操作可以直接在Page Cache上进行,如果消费和生产速度相当,甚至不需要通过物理磁盘直接交换数据,这是Kafka高吞吐量的一个重要原因。
高可用
- 多节点:kafka是集群模式,存在一个控制器结点,和多个普通节点,控制器节点主要负责分区的管理和ISR副本的管理以及重分配,至于控制器节点选举和节点的健康状态由cookeeper管理。
- 多分区:每个topic都有多个分区,分区有多个副本。
- ISR副本集合:副本分区会同步首领分区的消息,同步完成的分区会加入ISR副本集合,如果首领分区出问题,会从ISR副本集合选取分区称为首领分区。
无消息丢失
- 生产者丢失:acks的配置设为-1,并且将
min.insync.replicas
配置项调高到大于1(最小ISR个数)。消息发送失败重发。 - 消费者丢失:自动提交位移enable.auto.commit设置为false。在消费完后手动提交位移。
- kafka内部丢失:unclean.leader.election.enable 设置为false,防止定期副本leader重选举
幂等
- 生产者:kafka提供了让producer支持幂等的配置操作。即:props.put(“enable.idempotence”, ture),利用produce事务。
- 消费者:可以对每条消息维持一个全局的id,每次消费进行去重。
RabbitMQ
开发语言
Erlang
通信
AMQP(高级消息队列协议)
AMQP消息必须有三部分,交换机,队列和绑定。生产者把消息发送到交换机,交换机与队列的绑定关系决定了消息如何路由到特定的队列,最终被消费者接收。
主要结构
- Broker:消息队列服务器实体
- produce:生成消息的程序
- consume:接收消息的程序
- vhost:一个broker里可以开设多个vhost,用作权限分离,把不同的系统使用的rabbitmq区分开,共用一个消息队列服务器,但看上去就像各自在用不用的rabbitmq服务器一样。
- Connection:一个网络连接,比如TCP/IP套接字连接。
- Channel:是建立在真实的TCP连接内的虚拟连接(是我们与RabbitMQ打交道的最重要的一个接口),AMQP协议规定只有通过Channel才能执行AMQP的命令。共用channel,减少TCP的创建。
- Exchnage:消息交换机,作用是接收来自生产者的消息,并根据路由键转发消息到所绑定的队列。
- Exchange Types:RabbitMQ常用的Exchange Type有fanout、direct、topic、headers这四种。
- Queue:消息队列载体,每个消息都会被投入到一个或多个队列。
- RoutingKey|BindingKey:Exchange根据消息的Routing Key和Exchange绑定Queue的Binding Key分配消息。生产者在将消息发送给Exchange的时候,一般会指定一个Routing Key,来指定这个消息的路由规则,而这个Routing Key需要与Exchange Type及Binding Key联合使用才能最终生效。 在Exchange Type与Binding Key固定的情况下(一般这些内容都是固定配置好的),我们的生产者就可以在发送消息给Exchange时,通过指定Routing Key来决定消息流向哪里。
消息传递过程
建立连接Connection。
由producer和consumer创建连接,连接到broker的物理节点上。
建立消息Channel。
Channel是建立在Connection之上的,一个Connection可以建立多个Channel。producer连接Virtual Host 建立Channel,Consumer连接到相应的queue上建立Channel。
发送消息。
由Producer发送消息到Broker中的Exchange中。
路由转发。
生产者Producer在发送消息时,都需要指定一个RoutingKey和Exchange,Exchange收到消息后可以看到消息中指定的RoutingKey,再根据当前Exchange的ExchangeType,按一定的规则将消息转发到相应的queue中去。
消息接收。
Consumer会监听相应的queue,一旦queue中有可以消费的消息,queue就将消息发送给Consumer端。
消息确认。
当Consumer完成某一条消息的处理之后,需要发送一条ACK消息给对应的Queue。Queue收到ACK信息后,才会认为消息处理成功,并将消息从Queue中移除;如果在对应的Channel断开后,Queue没有收到这条消息的ACK信息,该消息将被发送给另外的Channel。至此一个消息的发送接收流程走完了。消息的确认机制提高了通信的可靠性。
集群
在 RabbitMQ 集群中,节点类型可以分为两种:
- 内存节点:元数据存放于内存中。为了重启后能同步数据,内存节点会将磁盘节点的地址存放于磁盘之中,除此之外,如果消息被持久化了也会存放于磁盘之中,因为内存节点读写速度快,一般客户端会连接内存节点。
- 磁盘节点:元数据存放于磁盘中(默认节点类型),需要保证至少一个磁盘节点,否则一旦宕机,无法恢复数据,从而也就无法达到集群的高可用目的。
至少要有一个磁盘节点,如果唯一磁盘的磁盘节点崩溃,集群是可以保持运行的,但你不能更改任何东西。
普通集群模式:在普通集群模式下,集群中各个节点之间只会相互同步元数据,也就是说,消息数据不会被同步。这时节点挂了,消息就有丢失的风险,所以普通集群没法高可用。
镜像队列模式:镜像队列模式下,节点之间不仅仅会同步元数据,消息内容也会在镜像节点间同步,可用性更高。这种方案提升了可用性的同时,因为同步数据之间也会带来网络开销从而在一定程度上会影响到性能。
持久化
所谓持久化,就是RabbitMQ会将内存中的数据(Exchange 交换器,Queue 队列,Message 消息)固化到磁盘,以防异常情况发生时,数据丢失。
其中,RabblitMQ的持久化分为三个部分:
- 交换器(Exchange)的持久化
- 队列(Queue)的持久化
- 消息(Message)的持久化
理论上可以将所有的消息都设置为持久化,但是这样会严重影响RabbitMQ的性能。因为写入磁盘的速度比写入内存的速度慢得不止一点点。对于可靠性不是那么高的消息可以不采用持久化处理以提高整体的吞吐量。在选择是否要将消息持久化时,需要在可靠性和吞吐量之间做一个权衡。
将交换器、队列、消息都设置了持久化之后仍然不能百分之百保证数据不丢失,因为当持久化的消息正确存入RabbitMQ之后,还需要一段时间(虽然很短,但是不可忽视)才能存入磁盘之中。如果在这段时间内RabbitMQ服务节点发生了宕机、重启等异常情况,消息还没来得及落盘,那么这些消息将会丢失。
无消息丢失
- 生产者丢失:RabbitMQ使⽤发送⽅确认模式,确保消息正确地发送到RabbitMQ。如果RabbitMQ发⽣内部错误,从⽽导致消息丢失,会发送⼀条nack(not acknowledged,未确认)消息。
- 消费者丢失:ack确认机制,消费者消费完才通知队列(其中会出现重消费和失去订阅的情况,需要去重)
- RabbitMQ内部丢失:镜像队列集群、消息持久化。
幂等性
- 生产者幂等:在消息⽣产时,MQ内部针对每条⽣产者发送的消息⽣成⼀个inner-msg-id,作为去重和幂等的依据(消息投递失败并重传),避免重复的消息进⼊队列;
- 消费者幂等:在消息消费时,要求消息体中必须要有⼀个bizId(对于同⼀业务全局唯⼀,如⽀付ID、订单ID、帖⼦ID等)作为去重和幂等的依据,避免同⼀条消息被重复消费。
延迟消息队列
通过消息过期后进入死信交换器,再由交换器转发到延迟消费队列,实现延迟功能。
使用RabbitMQ-delayed-message-exchange插件实现延迟功能。
Redis
开发语言
C 语言
通信
- TCP协议:Redis底层网络通信协议其实是通过TCP来完成的。
- RESP协议:Redis客户端和服务器端使用的序列化协议,它是特意为Redis设计的
- pipeline管道:pipeline管道就是解决执行大量命令时、会产生大量数据来回次数而导致延迟的技术。其实原理比较简单,pipeline是把所有的命令一次发过去,避免频繁的发送、接收带来的网络开销,redis在打包接收到一堆命令后,依次执行,然后把结果再打包返回给客户端。
- 单线程:redis虽然是单线程,却是直接操作内存,同时使用了IO多路复用,异步非阻塞,效率依然可观。
文本事件处理器
文件事件处理器使用 I/O 多路复用模块同时监听多个 FD,当 accept、read、write 和 close 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器。
虽然整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,实现了同时对多个 FD 读写的监控,提高了网络通信模型的性能,同时也可以保证整个 Redis 服务实现的简单。
客户端
目前主流的客户端有三种,Jedis、Lettuce、Redisson,我们从几个方面比较以下它们
- Jedis: 提供比较全面的redis原生指令的支持,不支持异步,上层封装比较弱,集群特性支持度非常低,高级特性几乎没有。
- lettuce: 高级redis客户端,支持各种模式的redis连接和操作,高级特性几乎没有。
- Redisson: 高级redis客户端,支持各种模式的redis连接和操作,同时提供一大堆的实用功能。但是Redisson实际上对字符串操作支持性差。
个人倾向lettuce,如果需要分布式锁、分布式集合等分布式高级特性可以结合 Redisson 使用。
基础数据结构
String(字符串)
应用场景:
存储key-value键值对,这个比较简单不细说了
list(列表)
应用场景:
由于list它是一个按照插入顺序排序的列表,所以应用场景相对还较多的,例如:
- 消息队列:lpop和rpush(或者反过来,lpush和rpop)能实现队列的功能
- 朋友圈的点赞列表、评论列表、排行榜:lpush命令和lrange命令能实现最新列表的功能,每次通过lpush命令往列表里插入新的元素,然后通过lrange命令读取最新的元素列表。
hash (字典)
应用场景:
- 购物车:hset [key] [field] [value] 命令, 可以实现以用户Id,商品Id为field,商品数量为value,恰好构成了购物车的3个要素。
- 存储对象:hash类型的(key, field, value)的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。
set(集合)
应用场景:
- 好友、关注、粉丝、感兴趣的人集合:
- 首页展示随机:美团首页有很多推荐商家,但是并不能全部展示,set类型适合存放所有需要展示的内容,而srandmember命令则可以从中随机获取几个。
- 存储某活动中中奖的用户ID ,因为有去重功能,可以保证同一个用户不会中奖两次。
zset(有序集合)
应用场景:
- zset 可以用做排行榜,但是和list不同的是zset它能够实现动态的排序,例如: 可以用来存储粉丝列表,value 值是粉丝的用户 ID,score 是关注时间,我们可以对粉丝列表按关注时间进行排序。
- zset 还可以用来存储学生的成绩, value 值是学生的 ID, score 是他的考试成绩。 我们对成绩按分数进行排序就可以得到他的名次。
集群
redis高可用的三种模式:主从模式,哨兵模式,集群模式。
主从模式
redis多机器部署时,这些机器节点会被分成两类,一类是主节点(master节点),一类是从节点(slave节点)。一般主节点可以进行读、写操作,而从节点只能进行读操作。同时由于主节点可以写,数据会发生变化,当主节点的数据发生变化时,会将变化的数据同步给从节点,这样从节点的数据就可以和主节点的数据保持一致了。一个主节点可以有多个从节点,但是一个从节点会只会有一个主节点,也就是所谓的一主多从结构。
哨兵(主从)
主从模式下,当主服务器宕机后,需要手动把一台从服务器切换为主服务器,这就需要人工干预,费事费力,还会造成一段时间内服务不可用。这种方式并不推荐,实际生产中,我们优先考虑哨兵模式。这种模式下,master宕机,哨兵会自动选举master并将其他的slave指向新的master。
集群模式
无论是主从还是哨兵,内存可用性和扩展能力都不足。
所以在redis3.0上加入了 Cluster 集群模式,实现了 Redis 的分布式存储,对数据进行分片,也就是说每台 Redis 节点上存储不同的内容;
集群中redis两两之间并不是独立的,每个节点都会通过集群总线(cluster bus),与其他的节点进行通信。客户端可以连接任意一个node进行操作,就像操作单一Redis实例一样,当客户端操作的key没有分配到该node上时,Redis会返回转向指令,指向正确的node。
为了保证其高可用性,当一台机器宕机其上的数据也会丢失,采用了先前将的主从复制模式,一个master节点有n个从节点。
在集群节点数量过多的时候,节点之间需要不断进行 PING/PANG通讯,不必须要的流量占用了大量的网络资源。虽然Reds4.0对此进行了优化,但这个问题仍然存在。
Redis Cluster可以进行节点的动态扩容缩容,这一过程,在目前实现中,还处于半自动状态,需要人工介入。在扩缩容的时候,需要进行数据迁移。
而 Redis为了保证迁移的一致性,迁移所有操作都是同步操作,执行迁移时,两端的 Redis均会进入时长不等的阻塞状态,对于小Key,该时间可以忽略不计,但如果一旦Key的内存使用过大,严重的时候会接触发集群内的故障转移,造成不必要的切换。
数据分片(哈希槽)
Redis Cluster 采用虚拟哈希槽分区,所有的键根据哈希函数映射到 0 ~ 16383 整数槽内,每个key通过CRC16校验后对16384取模来决定放置哪个槽(Slot),每一个节点负责维护一部分槽以及槽所映射的键值数据。
计算公式:slot = CRC16(key) & 16383。
为什么RedisCluster会设计成16384个槽呢?
从网络带宽、节点数量、压缩率考虑。
持久化
通常redis将数据储存在内存中,但是为了防止宕机和重启丢失数据,提供了两种持久化方式。
RDB 快照方式
这种方式就是将内存中数据以快照的方式写入到二进制文件中 ,默认的文件名为dump.rdb。过程如下:
- fork一个子进程来执行执行RDB操作,会根据Redis主进程的内存生成临时的快照文件,持久化完成后会使用临时快照文件替换掉原来的RDB文件。
- 子进程完成RDB持久化后会发消息给主进程,通知RDB持久化完成(将上阶段内存副本中的增量写数据同步到主内存)
优点:RDB文件小,适合定时备份,灾难恢复;Redis加载RDB文件的速度比AOF快很多,因为RDB文件中直接存储的是内存数据,而AOF文件中存储的是一条条命令,需要重演命令。
缺点:时间力度大,若是两次RDB持久化间宕机,会丢失数据;fork子进程会阻塞主进程;存在老版本的Redis不兼容新版本RDB格式文件的问题。
AOF增量持久化
AOF日志是持续增量的备份,是基于写命令存储的可读的文本文件。过程如下:
- 主进程接收客户端请求写命令,写入到aof_buf(aof缓冲区)然后主进程就返回了 (redis的优化点)
- 有专门的子进程去调用fsync()函数把数据从aof_buf写入到aof文件(谁在说redis是单线程就对他说:哼 初级程序员)
所以每条命令不是立刻写到文件,因为只有缓冲区到达阈值,才会触发实际写入,这样时间粒度就会变大!就需要Redis进程实时调用 fsync 函数可以将内容强制从内核缓存到磁盘。
但是 fsync 是一个磁盘 IO 操作,它很慢! 所以Redis提供了3种同步选项;
- always:每一条AOF记录都立即同步到文件
- everysec:每秒同步一次
- no:永不主动同步
随着命令向AOF文件的写入,文件会越来越大,写入效率就越来越低,这时我们需要对AOF进行瘦身,瘦身会触发对无效命令的取消。
- fork 子进程创建新的AOF文件执行重写操作,将已有数据进行瘦身后的命令写入新的AOF文件。
- 重写过程中,新的命令会在主进程会写入到缓存区,也会写入AOF重写缓冲区。
- 子进程完成AOF重写后,向主进程发送信号,主进程提供函数,将重写缓冲区的内容写入新的AOF文件,重命名。
整个过程中,“fork子进程、写命令到AOF重写缓冲区、改名覆盖原有的AOF文件”这几个步骤会造成主进程阻塞。
优点: AOF只是追加日志文件,时间力度小,对服务器性能影响小,速度快。
缺点: AOF需要不断瘦身,而且即使瘦身后,相对于RDB,文件体积依然较大。AOF恢复数据,速度比RDB要慢。
混合持久化
RBD和AOF都有各自的缺点,redis4.0提供了一种混合持久化的方式。
- fork出的子进程先将共享的内存副本全量的以RDB方式写入aof文件
- 然后在将重写缓冲区的增量命令以AOF方式写入到文件,
- 写入完成后通知主进程更新统计信息,并将新的含有RDB格式和AOF格式的AOF文件替换旧的的AOF文件。
简单的说:新的AOF文件前半段是RDB格式的全量数据后半段是AOF格式的增量数据。
过期策略
用于处理过期的缓存数据,过期策略通常有以下三种:
定时过期
每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
惰性过期
只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
定期过期
每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
内存淘汰策略
Redis可以设置最大占用内存大小,那么内存就有用完的时候,这时候Redis定制了几种策略,用于处理内存不足时的需要申请额外空间的数据;
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。
阻塞分析
内在原因:不合理地使用API或数据结构、CPU饱和、持久化阻塞等
外在原因:CPU竞争、内存交换、网络问题等
事务
和众多其它数据库一样,Redis 作为 NoSQL 数据库也同样提供了事务机制。在 Redis 中,MULTI/EXEC/DISCARD/WATCH 这四个命令是我们实现事务的基石。
- MULTI:用于标记事务的开始,其后执行的命令都将被存入命令队列
- EXEC:执行在一个事务内命令队列中的所有命令,同时将当前连接的状态恢复为正常状态,即非事务状态。如果 WATCH 命令被使用,被watch的key被修改,事务不生效
- DISCARD:回滚事务队列中的所有命令,同时再将当前连接的状态恢复为正常状态,即非事务状态。如果 WATCH 命令被使用,该命令UNWATCH 所有的 Keys。
- WATCH:在 MULTI 命令执行之前,可以指定待监控的 Keys,然而在执行 EXEC 之前,如果被监控的 Keys 发生修改,EXEC 将放弃执行该事务队列中的所有命令。
- UNWATCH:取消当前事务中指定监控的 Keys,如果执行了 EXEC 或DISCARD 命令,则无需再手工执行该命令了。
redis的事务不是原子性的,语法错误会回滚操作,运行错误会继续执行。
缓存问题
redis通常被用作应用和数据库之间作缓存使用,这样当redis出现缓存问题,就可能对数据库造成冲击。
常见的缓存问题:
缓存雪崩
某一时刻,发生了较大面积的缓存失效,例如redis宕机、大批缓存key同时失效,大量的请求直接转发到数据库,数据库一旦撑不住就会导致整个服务瘫痪。
解决方案: 过期错峰、高可用、缓存降级
缓存击穿
和缓存雪崩的区别在于这里是针对某个 key 的缓存,通常是热点数据失效。
解决方案: 热点key不设置过期时间,或者把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建。
缓存穿透
缓存穿透是指查询一个一定不存在的数据。出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义,也很容易被攻击者作为系统漏洞进行攻击。
解决方案:无效key存为null、key规则校验、布隆过滤器
缓存预热
缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。
如果不进行预热,那么Redis初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。
缓存降级
缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。降级一般是有损的操作,所以尽量减少降级对于业务的影响程度。
在项目实战中通常会将部分热点数据缓存到服务的内存中,这样一旦缓存出现异常,可以直接使用服务的内存数据,从而避免数据库遭受巨大压力。