【架构入门 - 高性能篇】集群高性能

Posted by 王天一 on 2019-05-01

做到集群高性能其实非常简单,三个字【加机器】

但加机器之后带来的是机器之间配合和集群管理的复杂度,对应也会有解决方案

负载均衡

任务分配是高性能集群主要的复杂性体现,所以选择一个合适的负载均衡器和算法是必须的

分类

DNS

最简单的方式,一般用来实现地理级别的均衡(AWS Route53就是)。比如来自美国的访问就分配给位于美国的服务器,来自中国的访问就分配给位于中国的服务器

优点:

  • 简单、成本低:负载均衡交给了DNS服务器做,不需要自己维护设备
  • 提升访问速度:实现了就近访问,提升性能

缺点:

  • 更新不及时:DNS有一定的缓存时间,一旦修改DNS配置,还是会访问到之前的服务,导致失败,这也不是负载均衡的目的
  • 扩展性差:无法根据业务特点做一些特殊处理和负载均衡策略

硬件负载均衡

单独通过硬件设备来实现负载均衡,比如F5、A10

优点:

  • 功能强大:支持各个层级的负载均衡、全面的负载均衡算法
  • 性能强大:100万以上的并发
  • 稳定性高:商用负载均衡经过大规模使用表现稳定
  • 安全防护:防火墙、防DDOS功能

缺点:

软件负载均衡

单独通过负载均衡软件来实现负载均衡。比如Nginx(7层)、LVS(4层),区别在于协议和灵活性,Nginx支持HTTP、Email协议以及对应逻辑处理,LVS支持TCP,所有TCP协议基础的都能做

优点:

  • 简单:维护、部署
  • 便宜:不要钱
  • 灵活:4层和7层的选择、业务扩展的选择

缺点:

  • 性能一般
  • 功能不如硬件负载均衡
  • 没有防火墙

典型负载均衡架构

  • 地理级别:分别部署服务器到广州、北京、上海三个机房,用户访问时,DNS路由到对应机房
  • 集群级别:每个机房都用上F5,将请求路由到上海集群1或2或3/广州集群1或2或3/北京集群1或2或3
  • 机器级别:通过Nginx路由到机器1或2

算法

轮询

不关心服务器负荷状态,直接按顺序轮流分配到服务器上,只要服务器在运行,就分配,即使服务器负载特别高难以处理请求,这样明显不合理,但优点是简单

加权轮询

负载均衡系统根据配置的权重,将请求路由到对应服务器上,比如某些机器性能高,某些机器性能差,就将高权重分配给性能高的机器

负载最低优先

将请求路由到当前负载最低的服务器上,比如Nginx可以通过HTTP请求数量来判断服务器当前负载,还可以自己写一个根据CPU压力来分配的负载均衡系统,但是系统复杂度上升,甚至需要为负载均衡器单独开发算法并部署然后碰到一堆莫名其妙的问题,实际的应用场景反而没有轮询用的多

性能最优

将请求分配给处理速度最快的服务器,从而达到最快响应速度,但是同样存在判断处理速度的算法问题

Hash

根据请求的IP或者其他信息进行Hash运算,将相同Hash值的请求分配到同一台服务器上,这主要是为了满足业务需求,比如不用分布式Session的集群就可以通过这个方式来实现Session,用户的Session只保存在某台服务器上,后台不需要同步到所有机器或持久化下来;或者AB测试中将某些用户分配到固定的机器上实现粘性会话

缓存

除了任务分配、其他中间件的使用也是能够大大提升集群性能的,特别是缓存、消息队列,在特定的场景下有非常惊人的效果

比如在多读少写、不写的情景下,存储系统的写性能没问题,但读性能远远不够。下面是一些常见的缓存架构存在的问题,避免这些问题就能做出一个好的缓存架构

缓存问题

缓存穿透

查询一个数据库中不存在的数据,比如商品详情,查询一个不存在的ID,如果此时去查数据库,那么每次都会访问数据库,如果有人恶意破坏,很可能直接对数据库造成过大地压力

当通过某一个key去查询数据的时候,如果对应在数据库中的数据都不存在,我们将此key对应的value设置为一个默认的值。

缓存雪崩

在高并发的环境下,如果此时key对应的缓存失效,此时有多个进程就会去同时去查询数据库,然后再去同时设置缓存。这个时候如果这个key是系统中的热点key或者同时失效的数量比较多时,数据库访问量会瞬间增大,造成过大的压力

将系统中key的缓存失效时间均匀地错开、或者通过分布式锁加锁保护

热点key

缓存中的某些Key(可能对应用与某个促销商品)对应的value存储在集群中一台机器,使得所有流量涌向同一机器,成为系统的瓶颈,该问题的挑战在于它无法通过增加机器容量来解决。

  1. 客户端热点key缓存:将热点key对应value并缓存在客户端本地,并且设置一个失效时间。
  2. 将热点key分散为多个子key,然后存储到缓存集群的不同机器上,这些子key对应的value都和热点key是一样的。

使用策略

延迟加载

读:当读请求到来时,先从缓存读,如果读不到就从数据库读,读完之后同步到缓存且添加过期时间
写:当写请求到来时,只写数据库

优点:仅对请求的数据进行一段时间的缓存,没有请求过的数据就不会被缓存,节省缓存空间;节点出现故障并不是致命的,因为可以从数据库中得到
缺点:缓存数据不是最新的;【缓存击穿】;【缓存雪崩】

直写

读:当读请求到来时,先从缓存读,如果读不到就从数据库读,读完之后同步到缓存且设置为永不过期
写:当写请求到来时,先写数据库然后同步到缓存,设置为永不过期

优点:缓存数据是最新的,无需担心缓存击穿、失效问题,编码方便
缺点:大量数据可能没有被读取的资源浪费;节点故障或重启会导致缓存数据的丢失直到有写操作同步到缓存;每次写入都需要写缓存导致的性能损失

永不过期的缓存会大量占用空间,可以设置过期时间来改进,但是会引进【缓存失效】问题,需要注意解决

如何选择

如果需要缓存与数据库数据保持实时一致,则需要选择直写方式
如果缓存服务很稳定、缓存的可用空间大、写缓存的性能丢失能够接受,选择直写方式比较方便实现
否则选择延迟加载,同时注意解决引进的问题

缓存集群架构

主从

用一个redis实例作为主机,其余的实例作为从机。主机和从机的数据完全一致,主机支持数据的写入和读取等各项操作,而从机则只支持与主机数据的同步和读取。因而可以将写入数据的命令发送给主机执行,而读取数据的命令发送给不同的从机执行,从而达到读写分离的目的。

问题是主从模式如果所连接的redis实例因为故障下线了,没有提供一定的手段通知客户端另外可连接的客户端地址,因而需要手动更改客户端配置重新连接。如果主节点由于故障下线了,那么从节点因为没有主节点而同步中断,因而需要人工进行故障转移工作。为了解决这两个问题,在2.8版本之后redis正式提供了sentinel(哨兵)架构。
哨兵

由Sentinel节点定期监控发现主节点是否出现了故障,当主节点出现故障时,由Redis Sentinel自动完成故障发现和转移,并通知应用方,实现高可用性。

集群

redis主从或哨兵模式的每个实例都是全量存储所有数据,浪费内存且有木桶效应。为了最大化利用内存,可以采用集群,就是分布式存储。集群将数据分片存储,每组节点存储一部分数据,从而达到分布式集群的目的。

上图是主从模式与集群模式的区别,redis集群中数据是和槽(slot)挂钩的,其总共定义了16384个槽,所有的数据根据一致哈希算法会被映射到这16384个槽中的某个槽中;另一方面,这16384个槽是按照设置被分配到不同的redis节点上。

但集群模式会直接导致访问数据方式的改变,比如客户端向A节点发送GET命令但该数据在B节点,redis会返回重定向错误给客户端让客户端再次发送请求,这也直接导致了必须在相同节点才能执行的一些高级功能(如Lua、事务、Pipeline)无法使用。另外还会引发数据分配的一致性hash问题可以参看这里

如何选择

  1. 集群的优势在于高可用,将写操作分开到不同的节点,如果写的操作较多且数据量巨大,且不需要高级功能则可能考虑集群
  2. 哨兵的优势在于高可用,支持高级功能,且能在读的操作较多的场景下工作,所以在绝大多数场景中是适合的
  3. 主从的优势在于支持高级功能,且能在读的操作较多的场景下工作,但无法保证高可用,不建议在数据要求严格的场景下使用

消息队列

缓存是在高并发读的场景下很有用,消息队列则是在需要异步交互的系统中发挥到作用,比如:

优点

  1. 减少请求响应时间。比如注册功能需要调用第三方接口来发短信,如果等待第三方响应可能会需要很多时间
  2. 服务之间解耦。主服务只关心核心的流程,其他不重要的、耗费时间流程是否如何处理完成不需要知道,只通知即可
  3. 流量削锋。对于不需要实时处理的请求来说,当并发量特别大的时候,可以先在消息队列中作缓存,然后陆续发送给对应的服务去处理

缺点

  1. 系统可用性降低。系统引入的外部依赖越多,越容易挂掉。
  2. 系统复杂度提高。保证消息没有重复消费?处理消息丢失的情况?保证消息传递的顺序性?

消息重复消费问题

消费端处理消息的业务逻辑保持幂等性,在消费端实现

利用一张日志表来记录已经处理成功的消息的ID,如果新到的消息ID已经在日志表中,那么就不再处理这条消息,在消息系统实现

消息丢失问题

生产者弄丢了数据?

生产者将数据发送到 RabbitMQ 的时候,可能数据就在半路给搞丢了
RabbitMQ 提供的事务功能,就是生产者发送数据之前开启 RabbitMQ 事务。然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错。但吞吐量会下来,因为太耗性能
可以开启confirm模式,你每次写的消息都会分配一个唯一的 id,然后如果写入了 RabbitMQ 中,RabbitMQ 会给你回传一个ack消息,告诉你说这个消息 ok 了。事务机制是同步的,但confirm机制是异步的,发送个消息之后就可以发送下一个消息,RabbitMQ 接收了之后会异步回调你一个接口通知你这个消息接收到了。所以用confirm机制

RabbitMQ 弄丢了数据?

RabbitMQ 自己挂掉导致数据丢失
开启 RabbitMQ 的持久化,消息写入之后会持久化到磁盘,哪怕是 RabbitMQ 自己挂了,恢复之后会自动读取之前存储的数据

消费端弄丢了数据?

RabbitMQ 如果丢失了数据,主要是因为你消费的时候,刚消费到,还没处理,结果进程挂了,比如重启了,RabbitMQ 认为你都消费了,这数据就丢了
关闭 RabbitMQ 的自动ack,可以通过一个 api 来调用就行,然后每次你自己代码里确保处理完的时候,再在程序里ack一把。RabbitMQ 就认为你还没处理完,这个时候 RabbitMQ 会把这个消费分配给别的 consumer 去处理,消息是不会丢的

消息顺序性

消息有序指的是可以按照消息的发送顺序来消费。例如:一笔订单产生了 3 条消息,分别是订单创建、订单付款、订单完成。消费时,要按照顺序依次消费才有意义
消息体通过hash分派到队列里,每个队列对应唯一一个消费者。比如下面的示例中,订单号相同的消息会被先后发送到同一个队列中:

在获取到路由信息以后,会根据MessageQueueSelector实现的算法来选择一个队列,同一个订单号获取到的肯定是同一个队列

消息集群模式

普通集群模式

在多台机器上启动多个 RabbitMQ 实例。你创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。

缺点是不能保证高可用、还有拉去数据的开销、以及单实例的性能瓶颈,所以这个方案是为了提高吞吐量的

镜像集群模式

每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。

缺点是即时满足了高可用,但因为同步数据量太重导致难以扩展节点,也没有在架构上实现负载均衡,可以参考Redis的集群模式进行优化。

Kafka的高可用性

Kafka 一个最基本的架构认识:为大数据而生的消息系统,由多个 broker 组成,每个 broker 是一个节点;你创建一个 topic,这个 topic 可以划分为多个 partition,每个 partition 可以存在于不同的 broker 上,每个 partition 就放一部分数据,从而可以存储大量数据且难以丢失

每个 partition 的数据都会同步到其它机器上,形成自己的多个 replica 副本。所有 replica 会选举一个 leader 出来,那么生产和消费都跟这个 leader 打交道,然后其他 replica 就是 follower。写的时候,leader 会负责把数据同步到所有 follower 上去,读的时候就直接读 leader 上的数据即可

如果某个 broker 宕机了,该 broker上面的 partition 在其他机器上都有副本的,如果这上面有某个 partition 的 leader,那么此时会从 follower 中重新选举一个新的 leader 出来,大家继续读写那个新的 leader 即可。这就有所谓的高可用性了

参考

https://time.geekbang.org/column/intro/81
https://github.com/xbox1994/2018-Java-Interview/blob/master/MD/数据库-Redis.md

号外号外

最近在总结一些针对Java面试相关的知识点,感兴趣的朋友可以一起维护~
地址:https://github.com/xbox1994/2018-Java-Interview