大型系统中的中间件

大型系统的发展离不开中间件的支持,从大型网站系统的发展历程中,我们也看到了中间件发挥的重要作用。下面我们了解一下最常见的服务框架、数据访问中间件和消息中间件以及相关的软负载中心,学习它们的设计过程以及在大型系统中解决的问题。

服务框架

在早期的互联网系统中,大多数网站系统如处于下图这个阶段,这样的系统结构在早期能够很好解决问题。随着网站规模扩大,业务复杂,应用开始变得臃肿。多个应用中会有大量重复冗余的代码,不利于系统的维护和稳定性。这种情况下,可以采用应用拆分的方法,将大应用拆分成小应用,降低冗余,但是仍然还在重复代码,不能从根本上解决问题。
img
另外可以采用服务化的方案,如下图所示,我们在底层的数据库、缓存系统和应用层之间增加一层服务层,服务可以有多层也可以相互访问,这样系统结构更清晰了,一些重复的代码都做成了服务。这样有两个好处:一方面服务由专门团队管理,提升了代码质量,另一方面,底层的资源由服务层来管理,结构清晰,开发效率高,也更加稳定。当然服务化带来的远程服务调用也需要进行管理,也就是所谓的服务治理,这也是很服务框架中很重要很关键的部分,关联着整个系统的稳定性。
img

服务框架的设计与实现

当把应用的结构改成包含有服务框架的多层结构时,服务化使得一些本地的调用变成了远程调用(RPC)。在这种改变下,服务框架的易用性和性能十分重要,下面我们分别看一下服务调用的实现。

远程调用方式

调用端的实现中重要的一步是跟据调用的服务名称来获取提供服务的机器地址列表,然后从可用的服务地址中选择一个可调用的机器,然后发送参数和调用的接口。在获取可选择服务机器地址时,常存在三种控制方式:(1)LVS或硬件负载均衡设备;(2)名称服务方式(3)规则服务器,该方式与名称服务很类似,只不过规则服务器常用于有状态的场景下;其中(2)(3)都是直连的方式。

服务的路由

在具体调用远程服务时需要通过服务名称进行寻址和路由,具体的控制需要由interfaceName+version(用于服务升级)+group(用于服务隔离、优先级设置等)来确定,这三部分通常在Java中通过Spring IOC注入为一个Bean。其次,我们需要解决服务框架和应用的依赖关系,通常有二种方式:(1)将服务框架做成应用的一个依赖包,随应用一起打包,但不够灵活,修改后需要重新打包;(2)将服务框架作为容器的一部分。最后,对于服务框架和应用的Jar包冲突问题,一般通过自定义类加载器来进行隔离。

网络通信

在完成了寻址和路由情况下,需要进行数据发送和通信,这里涉及到序列化与反序列化,这里一方面需要定义协议的通信方式(如HTTP协议)以及序列化协议(XML,Json),此外也应该考虑一些常用的其他特点,如是否支持数据压缩等。服务端根据服务名称、版本号、方法名称得到具体的服务接口,然后根据参数进行调用,最后把结果返回给调用端。
在具体的网络通信中,有BIO,NIO,AIO方式,其中NIO是一种常用的方式,它也是一种同步调用的方式,采用NIO的方式远程调用的方式如下图所示:
img
IO线程用于Socket连接发送数据,发送的数据也会首先放入数据队列。通信对象用于阻塞和唤醒请求的线程,并把结果传递给请求线程或通知超时。当采用异步调用方式时,常有3三种方式:
(1)Callback回调
如下图所示,调用方将数据放入数据队列后开始继续执行业务,IO线程收到远程调用结果后通知回调对象,然后执行回调方法,回调方法的执行最好在新的线程中,以免执行时间太长。
img
(2)Future:Java中的Future本身支持回调
(3)消息队列:依赖消息中间件来实现异步

服务管理

服务隔离

当某些服务执行非常慢时,为避免占用太多资源影响其他服务,可以将某些接口的服务调用进行隔离,使之路由到指定的集群。同样也可以对某些请求用户进行特殊处理,根据需求进行细粒度的服务规则设计。

多机房

当存在多个机房(同城、异地)部署时,为了更快速执行服务调用,可以在服务注册查找中心进行地址过滤,根据不同的调用者提供相同不同的调用者集群;此外也需要考虑不同机房处理能力是否相同,机房是否负载均衡等问题。

流控

为了保证服务系统的稳定性,防止恶意攻击,需要对服务进行流量控制。通常有二种设置方式:简单的0-1开关以及设置阈值(对接口设置不同阈值,也可以对根据不同调用方设置不同阈值)

服务框架实战

服务拆分:需要拆分服务往往是通用的基础功能
服务粒度:根据业务需求(方法、类)
分布式环境下请求合并:对于常用的请求,可以将它路由到同样的机器上,然后进行缓存或单机上任务合并(其他现场有同样的任务,可以等待完成,避免重复计算)
服务质量:最好、最差的服务;服务质量趋势
服务容量:服务水位展示与排序、历史趋势图
服务依赖:依赖服务展示、被依赖展示、依赖变化
服务分布:同机房、不同机房、服务负载均衡分布
服务统计:调用次数与排名、出错次数、响应时间统计、趋势
服务查询:容量、质量、次数等等
服务监视:关键数据采集、告警
服务报表:数据统计生成报表
服务上下线:指定服务、指定机器
服务路由:路由规则、管理、回滚
服务限流降级:根据调用来源、具体服务、流控、多版本
服务线程池:线程工作状态、不同业务线程池
服务授权:授权信息及规则、多版本支持与回滚

数据访问中间件

随着数据量和访问量的上升,数据库也会成为瓶颈,所以需要减少数据库的压力,常用的解决方案主要有三种:(1)对应用进行优化;(2)加入缓存系统和搜索引擎;(3)分布式数据库。然而分布式数据库相对于单机数据库也更加复杂,引入了很多新的问题。

分布式数据库的挑战与应对方案

垂直/水平拆分的困难

垂直拆分把不同业务数据分到不同的数据库,而水平拆分根据一定的规则把同一业务的数据拆分到多个数据库,拆分后单机数据库的一些特性就不支持了。
(1)ACID事务被打乱,要么修改实现,要么依靠分布式事务
(2)Join操作困难,跨库Join需要特殊实现
(3)外键约束场景收到影响
(4)数据库Id自增生成
(5)水平拆分后同一个表的查询受影响

分布式Sequence处理

当水平拆分后,数据库的Sequecne及自增Id如何保证,这引发了2个问题:Id的唯一性和连续性。对唯一性可以采用UUID的生成方式,或者根据业务特点使用种子(IP、MAC、时间戳等),但不能保证Id的连续性。为了保证唯一性和连续性,可以将Id统一在一个地方进行管理,即使用Id生成器,所有节点通过Id生成器来获取Id,这种方式当然也存在一定的不足:(1)性能问题,(2)稳定性,单点问题,(3)存储问题,备份容灾

分布式数据查询

跨库Join:(1)将Join操作分成多次数据库操作,(2)数据冗余,数据表中增加常用的数据字段,(3)借助外部系统(搜索引擎等)
外键约束:依赖应用层的判断、容错
跨库常见查询有如下:
(1)简单查询:通过条件定位到某个库某个表
(2)复杂查询:先通过条件定位到某些库某些表,然后合并查询结果
(3)排序操作:若从单个库查出来已经有序,直接做一个归并排序;否则在应用层面上需要合并结果后做一个全排序
(4)函数处理:如max、sum、count等,则对每个来源数据分别进行函数处理,然后最后合并
(5)平均值:对所有来源数据先分别进行sum和count处理后,在计算avg
(6)非排序分页:一般有2种;等步长:每页每个数据源数据量相同;等比例:每页中按不同数据源比例显示不同数据量
(7)排序后分页:非常麻烦,必须从每个数据源得到足够的数据进行排序

数据访问层的设计与实现

数据访问层就是方便应用对数据进行读/写访问的抽象层,在这一层解决访问数据层的通用问题。通常,解决数据访问常有3种方案:(1)专有API方式:但通用性很差,扩展性差;(2)基于JDBC:数据访问层作为JDBC的实现,暴露出JDBC的接口给应用;(3)基于ORM方式:在ORM上再包装一层,实现数据层的功能,对外暴露的是原来框架的接口。下图展示了这三种方式的结构。
img
可以看出,基于JDBC成本最高,但兼容性和扩展性最好;基于ORM具备一定的通用性,实现成本相对较低。
下面我们继续分析一下数据层的整体流程,大致的步骤如下图所示:
img

SQL解析

SQL解析主要考虑的问题有2点:(1)SQL的支持程度,是否需要支持全部SQL;(2)支持多少SQL方言。另外SQL解析可以利用缓存来提升解析速度,也需要注意缓存的容量限制。SQL解析后得到一些关键信息,如表名、字段、条件等。

规则处理

固定hash算法做规则

该方法通常对某个字段取模,hash规则的设置和实现都比较简单,但是扩容时比较复杂,数据迁移很麻烦。

一致性hash算法

该算法增删节点时,影响节点少,数据迁移较少,但很难保证各个节点的负载均衡,可以通过虚拟节点来改进,使得节点负载均衡。

自定义规则

该方法自定义函数来解决数据访问规则,用于解决热点数据访问,在数据不完全符合规则时进行补充。

SQL改写

数据库分库分表后,需要对SQL进行改写,比如一个表变成多个数据库的多个表,表名、索引名称是否一样;分页操作处理;一些函数操作如max、sum、avg也需要特殊处理。

数据源的选择

对于SQL的执行,一方面由于数据库分库分表带来了变化,另一方面也由于数据库的主从备份、读写库、事务等导致SQL的执行必须选择合理的数据源。
数据源的配置一般是三层的结构,当数据库分库分组后,我们使用groupDataSource来进行配置和分组管理;在分库分组后,再按照数据源的功能进行切分,使用AtomDataSource来管理一个具体的数据库,数据访问三层结构如下图所示。
img

实现方式

对于数据访问层对应用的呈现方式,从数据层物理部署来说分为Jar包和Proxy方式。如果采用Proxy方式,客户端应用与数据访问层的协议有两种选择:数据库协议与私有协议,如下所示:
img

数据库协议

应用将Proxy当成一个数据库,使用JDBC实现连接Proxy,应用到Proxy,Proxy到DB都采用数据库协议,少了一次协议到对象然后对象到协议的转换,但实现成本高且不能复用。

私有协议

实现简单,应用需要一个数据库访问层客户端,并且应用到Proxy的连接可以复用。
从应用到数据库底层的整个结构如下图所示:
img

数据读写分离和复制

主从库对称

根据应用特点将数据延迟不敏感的读切换到备库,然后复制时注意延迟

主从库非对称

(1)多从对一主
使用消息系统,如下图所示:
img
通过消息系统将数据变更通知发出,数据同步服务器获取通知后根据分库规则进行数据复制
(2)主备分库方式不同
在大多数情况下,数据库复制是对等的,但由于业务需求,主数据库和备用数据库并非完全对等复制,即源数据库和目标数据库是不同的实现,如图所示:
img
主库中,根据买家id分库,备库中根据卖家id分库,这样在查询卖家订单时只用在备库中进行查询了,避免了跨库操作,但复制的过程也增加了数据的控制和分发,不再是简单的数据复制了!
(3)数据变更平台
在大型系统中,有一些其他场景也会关注数据的变更,如缓存系统(缓存失效)和搜索引擎(索引建立),可以构建一个通用的平台来管理和控制数据变更,如下图所示:
img
在系统中引入Extractors与Applier,其中Extractors把源数据变更信息加入数据分发平台,而Applier把变更通知应用到目标上,数据分发平台由多个管道组成。

数据平滑迁移

对于无状态的应用,扩容和缩容比较容易,而对于数据库,扩容缩容会引起数据的迁移。若允许停机操作,处理相对容易,若不能停止,就比较麻烦了,因为迁移的过程中可能有新数据的变化。可以采取以下方案:
(1)开始进行数据迁移,并记录数据库的数据变更增量日志
(2)数据复制到新库,新的更新记录下来
(3)全量迁移结束后把增量日志数据也迁移进来,此时还会有新的增量日志,这是一个逐步收敛过程
(4)进行数据对比,记录源库和目标库不一样的数据
(5)停止需要迁移数据的写操作,增进增量数据处理,使得新库数据是最新的
(6)更新路由规则,所有新数据录入了新库,完成迁移操作

总结

随着数据量、访问量的增加,我们会进行分库和分表,这也带来了一些共性的问题。数据访问层正是为应用提供统一的接口。整个数据层的结构如下图所示,应用层有多种选择,代理层除了可以使用DB的native API方式外,也可以像应用一样使用各种方式来工作。从应用到DB就是一个链式的处理过程,这一过程中大多数的组件都是对外提供JDBC的实现,以方便各个组件进行替换。
img

消息中间件

初始消息中间件

消息中间件给应用带来了异步、解耦的特性,下面我们从一个具体的例子来看消息中间件如何做到应用解耦。
假如有一个应用登陆系统,主要功能为用户登陆成功后发送一条短信到用户手机,然后把用户登陆信息录入安全系统进行处理,此时的系统的结构示意图如下图所示:
img
但是如果还需要增加别的功能,就会反复修改登录系统来进行其他调用,这种直接调用非常不便维护和扩展。我们仔细分析可以看出,登录系统并不依赖于短信服务和安全系统服务,恰恰相反后者依赖于前者,如果引入消息中间件则可以将上面的结构解耦,通过消息传递来替代服务调用。登录系统不用关注有多少系统关注登陆成功事件,也不用关心如何通知,只需要往消息中间件发消息,其他系统订阅消息,系统之间互不干扰,系统结构示意图如下图所示:
img

消息中间件的关键点

在介绍消息中间件之前,我们先了解一下JMS(Java Message Service),它是Java EE的一个消息规范,ActiveMQ也是这个规范的具体实现。在消息中间件的设计中,有一些非常重要的因素需要保证,其中有消息的顺序保证、可靠性、扩展性、消息发送与业务操作的一致性、多集群订阅者等,下面我们分别分析这些方面。

消息发送一致性

消息一致性是指消息发送与产生消息的业务操作一致,若业务操作成功,则消息要发送出去;业务失败则消息不应该发出。在JMS中,基于XA系统的接口,利用了分布式事务来保证消息一致性,但JMS也存在一定的弊端:(1)分布式事务开销大,复杂性高;(2)业务操作的资源必须支持XA协议,才能与发出消息一起做分布式事务。既然JMS的XA协议比较不适应,那么我们可以设计一种解决方案,该方案对正常流程影响小,在出现问题后能够解决问题,即使用最终一致性的解决方案,方法如下图所示:
img
步骤如下:
(1)业务应用首先把消息发送给消息中间件,标记消息状态为待处理
(2)中间件收到消息把消息进行存储,但并不投递消息
(3)消息中间件存储后返回消息存储结果
(4)业务收到中间件结果进行处理,若失败则放弃执行业务,若成功则执行业务
(5)业务操作完成,将业务结果发送给消息中间件
(6)消息中间件收到业务执行结果进行后续处理,若业务失败则删除消息;若成功则更新消息为可发送,然后进行消息的投递
在这些过程中,难免会存在很多异常,那么我们分析下每一步异常可能导致的后果:
img
由此可以看出,主要的异常有三类:
(1)业务未执行,消息未存储
(2)业务未执行,消息存储,状态待处理
(3)业务执行,消息存储,状态待处理
第(1)种是正常情况,而后面两种需要中间件去主动询问业务执行情况,然后业务检查执行结果进行反馈(成功,失败,等待,等待表示业务还在执行中),该过程称为反向过程,是一个补偿方案,用于解决不一致的情况,如下图所示:
img
当然,该反向补偿方案也会存在异常,但前三步都是查询操作,不影响状态,最后一步更新失败可以重试,也没有特别大错误。所以,正向和反向的结合,保证了最终的一致性状态。该方案相比传统的方式不同之处如下:
img
可以看出,该方案相比传统的方案之多出了2步,所以额外开销并不是很大。

消息中间件与应用的依赖问题

在保证业务操作和发送消息的一致性方案中,我们更多关注了如何解决一致性问题,但是也导致了一个问题,消息中间件成为了业务应用的必要依赖。一旦消息中间件不可用,即使业务应用正常也将无法继续进行,可以采用的一个方案:将消息中间件中影响业务操作部分的可靠性与业务自身可靠性保持相同,业务成功消息必须入库,即使中间件出现了问题,可以接受延迟,但是必须保证消息入库。可行的一种设计思想如下:
img
将中间件的消息表与业务数据表放在一个数据库中,从而将业务操作与写入消息作为一个本地事务完成,然后通知消息中间件有消息可以发送,但这一步在图中是虚线,表示它并不是一个必要操作,中间件可以定时去轮询消息表,找到发送的消息。但该方式也有3个不足:
(1)需要业务数据库承载消息
(2)消息中间件需要访问业务数据库
(3)业务操作的对象是数据库,是必须支持事务的存储,这个存储也必须支持中间件
基于上述方式,我们进行改进,消息中间件不再与业务数据库打交道,完全由业务应用控制消息生成、重试,消息中间件更多只是接受消息并进行投递,如下图所示:
img
这两种方式虽然能解决大部分问题,但都依赖于支持事务的数据库,具有局限性,可以将本地磁盘作为消息的存储,消息中间件不可用时可以将消息存储在本地磁盘,等中间件恢复后再将消息发送到中间件,这样消息的管理、重试等也是在中间件进行,如下图所示:
img
但是若中间件不可用,写入本地磁盘也出错,则消息丢失了,还是需要补偿机制。本地磁盘作用有2个:(1)消息的容灾,(2)保证业务操作和存储消息的时间,也便于应用与消息中间件之间做一些批量处理,提升效率。

消息模型对消息接收影响

在JMS中,消息模型有Queue(点对点)和Topic(发布/订阅)两种,下面我们分别看一下两种模型的特点。

Queue模型

在Queue中,消息根据到达顺序形成一个队列,所有连接到Queue上的应用共同消费了所有的消息,一个消息只能被一个应用去消费,如下图所示:
img

Topic模型

Topic和Queue区别在于,每一个消息接收方可以接受全部消息,如下图所示:
img
现实业务中,常见的业务需求有:(1)消息发送方和接收方都是集群;(2)一个消息需要被不同集群消费;(3)一个消息在一个集群中只能被消费一次。在JMS中一个进程可以有多个Connection,支持发送方和接收方都是集群,但是Topic和Queue都难于保证一个消息只能被同一集群的某个节点消费,所以需要一种混合的方式。具体来说把Topic和Queue的特点结合起来,同一集群使用一个clusterId来消费消息,具体方式如下所示:
img

消息订阅模式

消息订阅模式分为持久/非持久方式。

非持久模式

消费者应用退出后,消息不会为消费者保存,消费者应用启动后又可以重新消费消息了。

持久订阅

消费者应用退出后,消息会保存下来,除非消费者应用取消订阅,消费者重启后会将消息不遗漏的发送给消费者。
可以看出,持久订阅是一种可靠的消息订阅模式。

保证消息可靠性

在持久订阅模式下,保证消息的可靠性对于消息中间件至关重要。我们知道,消息从发送端到接受端主要有3个过程:(1)消息发送者-消息中间件;(2)消息中间件对消息进行存储;(3)消息中间件-消息接受者,所以必须保证这三步都可靠,才能保证整个消息的可靠,下面分别从这三方面进行介绍。

消息发送端的可靠性

消息从发送者到中间件,只有当中间件及时、明确地返回成功才意味消息到达了中间件,返回错误、超时等情况都是发送动作失败。这里要注意中间件返回的异常被内部消化而没有发现异常。

消息存储的可靠性

消息存储采用的存储系统有很多,如关系型数据库、分布式文件系统、Nosql等,这些产品各有特色适用不同的场景下。
(1)文件系统
分布式文件系统的稳定性和性能有待提升,对于消息的检索也是不支持的,但消息直接存储在本地不需要额外的存储,针对机械硬盘的特点尽量进行顺序写和顺序读。此外,当消息被消费时,文件容易存在“空洞”现象(类似GC中的内存碎片),需要额外的整理操作。对消息的检索处理也需要考虑索引对内存消耗。
(2)关系型数据库
再利用关系型数据库进行消息存储时,数据库的设计会十分复杂,不会遵循常见的数据库范式设计,常采用数据冗余的方式实现。数据冗余避免了表关联查询,也需要考虑数据的备份和容灾。
(3)双机内存的消息存储
基于文件系统和关系型数据库的方式,系统性能受到限制,所以可以采用混合的方式进行存储和管理,如下图所示:
img
采用双内存的方式来保证数据可靠性,正常情况下消息持久存储不工作,当一台机器故障时,则停止另一台机器的写操作,然后将数据写入磁盘永久存储。只要不遇到两台机器同时故障,或者一台故障且另一台写入持久存储时错误,消息是可靠的。这种方式适合消息存储在中间件后被及时消费的情况,能很好提升性能。
(4)消息系统的扩容
1)消息中间件自身扩容:消息中间件没有持久状态,扩容相对容易。主要让发送者和接受者能够感知有新消息中间件加入到集群中,这是通过软负载中心实现的。不同的消息中间件可能共用存储,也可能使用不同的存储,如下图所示:
img
2)消息存储的扩容:消息的存储一方面不用保证消息有序;另一方面提供了服务端对消息的投递,不支持主动获取消息。
在数据访问层提到了数据库的分库分表、路由规则等,但消息中间件不需要支持外部主动去查询消息,因为发送者发送消息到中间件时,中间件肯定知道消息存储位置,投递时也知道消息在哪。

消息投递的可靠性

这一步需要保证消息中间件收到接受者处理信息完毕的信号才能删除消息,不能根据网络层判断消息是否已经送达,一定要从应用层入手。此外,接受者在处理消息异常时,不要吃掉异常而返回成功,这样会丢失消息。另一方面,投递消息时为了提升效率可以采取多线程的方式,将投递消息和处理返回结果线程分开,投递线程只投递消息,投递完一条消息继续投递其他消息,处理结果返回后再由处理线程处理。另一个优化地方是对同一一个应用的多个模块订阅同一消息,中间件可以避免多次投递消息,仅投递一次消息到应用,由应用在应用内复制消息提升消息投递性能。

消息重复

消息重复的原因有二类:发送端重复发送消息和中间件重复投递消息。

发送端消息重复发送

该步骤原因有二种:(1)发送端发送消息到中间件,中间件此时故障未返回消息,导致重复发送;(2)中间件返回结果超时(中间件负载高、网络延迟等),发送端重复发送消息,该问题可以通过在发送端产生消息Id来解决。

中间件重复投递

该步骤主要原因有2个:(1)消息中间件投递消息后未收到应用反馈结果(应用故障、中间件故障、网络问题)(2)中间件投递后超时了,中间件重试,该问题比较复杂,在中间件处理重复消息比较困难,常用做法是在消息接受端处理重复消息,也就是保证消息幂等性(多次执行得到同样结果),但这也给接受端应用带来了复杂设计。
消息接受者对消息执行一般会出现at least once 和 at most once两种情况,exactly once较难保证,需要额外处理,十分复杂。

消息投递其他属性

消息优先级

一般消息是先到先投递,为了对消息进行优先级处理,可以根据设置消息的优先级属性,也可以把消息设计不同的类型,同一类型的消息在根据优先级进行不同处理。

消息处理顺序和分级订阅

一般情况下,多个消息订阅者之间互不干扰,但有时需要维持消息订阅者处理消息的顺序,这种情况下一方面可以设置优先处理消息的订阅者集群Id,即消息订阅者处理消息顺序,另一方面也可以分级处理,优先接受者处理消息成功后再把消息放入其他中间件,然后其他订阅者再处理,但这样重复发送了消息,多了一次消息入库操作。

自定义属性

一般除了消息自身的创建时间、类型、投递次数等属性,一些自定义的属性如消息过滤等对消息的特殊处理带来很多便利。

消息局部顺序

局部顺序性是指部分消息之间有处理顺序,但全局的消息之间没有顺序。如商品的购买-付款-发货,某一具体商品三个消息之间有顺序,但任何两个商品的消息之间无任何顺序,所以需要在消息上设置一个属性,来表明该消息与哪些消息有序。

保证顺序的消息中间件

在某些场景下,我们需要一种高效的支持顺序的多集群订阅消息中间件,如数据变更通知平台。在这种消息顺序场景下,接受端的设计也从Push模式改成Pull模式,这是为了方便接受者更好控制消息的接受和处理,消息中间件的设计如下图所示:
img
具体实现中,消息存储顺序的写入本地文件,但不存在文件空洞,因为消息是按顺序去消费的。接受端维护一个指针指向当前处理的消息,不同消费者维护各自的指针,并通过回溯指针重复消费消息,中间件更多关注消息的可靠性,消费者灵活控制消息的消费。

但当单机队列过多时,消息写入接近随机写入了,性能有明显的下降,改进思路是将消息进行顺序写入然后在每个队列上建立索引,每个队列索引是独立的,索引保存了存储数据的物理队列的位置,这样带来的好处有:(1)队列轻量化,每个队列数据量少;(2)磁盘访问串行化、避免磁盘竞争,不会导致IO等待增加。这样虽然消除了随机写但也带来了一些问题:(1)写是顺序写,但读确实随机读;(2)读消息时,先读逻辑队列再读物理队列增加了开销;(3)需要保证逻辑与物理队列一致,编程复杂
为了解决消息的可靠性,一般需要考虑消息的复制问题。一般有2种复制方案,同步和异步。
(1)异步:将消息中间件变为Master/Slave节点,Slave订阅Master的消息,进行消息的备份,该复制是一个异步的操作,Slave可能存在消息丢失风险
(2)同步:Master/Slave同步复制,Master收到消息也往Slave发送,收到Slave成功响应后返回成功给发送端,该方式更可靠
保证消息可靠后,也需要保证消息的扩容,在顺序情况下,扩容更加复杂。基本思路是发送端知道消息需要写入新的消息队列,消费者知道去新的队列获取消息。主要的关键点有:
(1)队列扩容后有一个标志,即使有新的消息过来也不再接受
(2)通知消息发送者新队列的位置
(3)消息接受端在消费消息时,队列上有新旧两个位置,旧队列接受完毕后,去新队列接受消息,完成新旧位置切换

Push和Pull方式对比

消息中间件Push和Pull实现方式的对比如下:
img

软负载中心与集中配置管理

在服务框架中,我们利用服务注册查找中心来定位服务器的地址;同样在消息中间件中,消息发送端和接受端也需要感知中间件服务器;这些都需要软负载中心来实现。可以看出软负载中心的最基本功能有如下二方面:
(1)聚合地址:软负载中心聚合地址列表,供使用方使用
(2)生命周期感知:软负载中心需要能够对服务器的上下线自动感知,并更新服务地址

软负载中心结构

软负载中心主要包含服务端和客户端两部分。
服务端负责感知提供服务机器是否在线,聚合提供服务机器的信息并传给应用;客户端主要有2个功能:(1)作为服务提供者,把服务提供者的信息主动传给服务器,并且随着信息变化去更新数据通知服务器;(2)作为服务使用者,从服务器获取需要的数据并更新数据,同时进行本地缓存,以提高效率和性能。下图展示了软负载中心与应用的关系:
img
可以看出,软负载中心内部主要有三部分重要数据:
(1)聚合数据
聚合后的地址信息列表,在软负载中心内部使用dataId来标识一个服务信息,同样的dataId支持分组(group)信息,则可以形成一个二维的结构,(dataId,group)可以定位到唯一的数据内容,在内部是一个key-value结构。
(2)订阅关系
对于需要数据的应用,需要把(dataId,group)信息告诉软负载中心,所以软负载中心维护了(dataId,group)到应用分组(consumerGroupId)列表的映射关系,一旦数据发生变化则通过映射关系找到通知的应用。
(3)连接数据
连接数据是指应用和软负载中心建立连接的管理,每一个应用都有一个groupId,连接数据使用这个groupId作为key来管理该连接,通过连接来传递数据。

内容聚合功能

内容聚合主要工作有2个:保证数据正确;高效聚合数据

保证数据的正确性

内容聚合主要保证并发场景下的数据聚合正确性,可以使用ConcurrentHashMap来保证数据的正确性。另一方面也需要考虑机器短时间内上下线问题等异常场景,数据的修改变化主要有新增、更新、删除操作,这里尤其需要注意网络断开后删除数据与新增数据的先后关系,比如发布了数据后马上下线,这时如果先删除数据再增加数据肯定会导致错误,所以数据的顺序性需要仔细考虑。

性能保证

在保证数据安全的前提下,更新操作的性能也是需要考虑。虽然ConcurrentHashMap有较好的性能,但是在大量修改数据情况下有大量的线程并发冲突,并不能保证较高的性能。所以可以考虑一个改进方案,我们可以把对同样的数据修改在同一个线程中处理,即对同样的(dataId,groupId)放在一个队列中,该队列是一个线程安全的容器,然后用同一个线程来从队列取数据进行更新操作,那么整体上一个线程处理同样的数据,减少了线程冲突提升了性能。

服务上下线感知

软负载中心判断服务器是否可用的方式主要有两种。

客户端与服务端的连接感知

软负载中心与应用之间通过长连接保持通信,通过心跳来判断服务是否在线,但该方法有一定缺陷。当软负载中心负载很高时会产生误判,将服务下线,或者网络通信故障也会导致软负载中心误判。此时可以通过服务调用方连接服务提供者来进一步确认。

通过地址和端口进行连接检查

该方式是一种补偿的方式,通过一个监控应用去连接服务器的地址和端口,进行进一步的确认,该方法同样也存在网络通信故障缺陷,还是需要应用调用发和应用提供方通信做进一步检查。

软负载中心的数据分发设计

数据分发与消息订阅的区别

数据分发目的是保证数据订阅者能够收到可用的服务地址列表,消息订阅是使得每一条消息都能被获取,二者区别主要有两个。
(1)消息中间件的消息订阅需要保证每一条消息都送到订阅者,软负载中心只需要保证最新的数据送到订阅者,并不需要保证每次的变化都让订阅者感知。
(2)订阅者集群的分组,消息中间件中所有集群共享消息,每一条消息都只需要被一台机器消费;软负载中心需要分发消息到所有的订阅者

提升数据分发性能

(1)数据压缩:降低流量、节省带宽
(2)全量与增量:数据变化时,需要发送最新数据到订阅者。全量发送实现简单,效率低;增量实现复杂,效率高

针对服务化特性的支持

软负载分组

通过(dataId,group)唯一确定数据,使用group目的是把相同的dataId内容分开,相当于多了一个namepspace,可以应用于不同环境的隔离(测试,线上)以及分优先级的隔离

上下线开关

机器的上下线,需要由软负载中心来控制,主要的目的有2个:
(1)优雅停止应用:直接停止服务会导致正在执行的任务失败,应当从服务列表去除机器,然后执行完当前的任务再停止应用
(2)应用排错:当服务出错时,可以把出错的机器下线,以免新的请求进来

维护管理路由规则

路由规则需要由软负载中心来统一管理。

软负载中心集群化

当应用规则不大时,利用单机加备份机器可以充当软负载中心,但随着应用集群规模扩大,单机的推送数据能力有限,需要一个集群来处理。从单机到集群也引入了新的变化,集群需要处理2类问题:数据管理问题和连接管理问题,解决方案也有数据统一管理方案和数据对等管理。

数据统一管理

该方案把数据聚合放在一起,这样负责管理连接的机器可以是无状态的了,方案如下图所示:
img
系统分为三层:聚合数据这一层负责管理数据;软负载中心负责管理连接,并且这层机器都是对等的。这里可以做一个改进,把软负载中心的职责分开,即把数据聚合和数据推送分开,如下图所示:
img
这样发布者和订阅者的连接是分开的,这样能够提升性能,在负责推送的机器上也可以做缓存。但这两种方式都必须保证“聚合数据”这一点必须可靠。

数据对等方案

该方案将数据分散在各个软负载中心节点上,并相互之间进行数据复制和同步,如下图所示。但该方式同步开销太大,实现中可以间隔一定时间进行批量同步。
img
同样,也可以将软负载中心节点职责分开,如下图所示:
img
数据聚合节点之间没有关系,数据分发节点之间也没有关系,但数据聚合节点会将数据分发给数据分发节点,这种方式可以提升效率。但如果数据量很大时,单个节点无法存储所有数据,则需要按照(dataId,group)进行分组管理,这样的话应用可能根据实际情况连接多个数据分发节点来获取需要的数据,如下图所示:
img

集中配置管理中心

软负载中心负责管理服务地址列表、路由规则、消息订阅关系等,这些数据可以按照是否持久以及是否需要聚合两个维度进行分类。其中持久是指数据本身与数据发布者的生命周期无关,如消息订阅、路由规则、数据库访问分库分表等,非持久指数据本身与发布数据者生命周期有关,如服务地址列表;聚合是指数据是否需要合并,如地址列表,订阅关系等需要合并。具体来说可以分为:持久/聚合、持久/非聚合、非持久/聚合、非持久/非聚合4类。
软负载中心用于管理非持久的数据,集中配置中心用于管理持久数据,它们都可以支持数据的聚合。集中配置管理比较关心数据的可靠性、稳定性,然后进一步考虑数据分发的性能。
集中配置管理的大致结构如下图所示:
img
通过主备方式来保存持久数据,集中配置管理这一层由多个对等的节点构成,它们负责提供数据给应用也负责数据库的更新。在单个节点中,有Web应用和Nginx构成,Web应用主要负责相关的程序逻辑,单机文件(Local File)为了容灾和提升性能,客户端通过Nginx直接从本地文件获取数据。
集中配置中心的主要提供的功能有2个:
(1)提供客户端给应用:应用通过客户端来读取配置信息
(2)为控制台提供SDK:SDK支持数据的读写,进行配置的修改
下面我们具体看一下集中配置中心的容灾策略。

客户端实现和容灾策略

客户端通过HTTP协议与集中配置管理中心进行通信,考虑到服务端的压力,轮询间隔不能太短,但太长会影响数据的实时性。所以可以改进普通轮询,使用一种长轮询的方式,该方式特点如下:建立连接后,如有数据,长轮询与普通轮询直接返回,若没有数据,普通轮询直接返回,而长轮询会等待数据直到超时,然后重新建立连接。长轮询相对普通轮询实时性好,但是需要不断建立连接,这是相对Socket长连接的弱点,是在降低服务器负载下的一个折中方案。
对于容灾,客户端提供了4个特性:(1)数据缓存:缓存一方面提高了效率,另一方面在服务器不可用情况下也保证系统可用,但数据实时行不高;(2)数据快照:数据快照保存了最近的几次更新数据,比缓存的数据旧一些,但保存了最近的几个版本,可以在服务器和缓存均不可用时提供数据,也可以用于数据恢复。(3)本地配置:应用需要使用服务器给的配置工作,本地配置保存了这些配置,在服务器不可用时,本地配置优先级最高。(4)文件格式:在最坏情况下,系统退化为一个单机应用,需要直接修改配置数据,那么配置文件的格式十分重要了。

服务端实现和容灾策略

在集中配置管理中心中,Web应用中实现了主要的业务逻辑,Nginx用于请求的处理和结果的返回,供返回的数据都在本地文件系统中。通过Nginx直接返回本地文件中的数据比通过Web应用从数据库获取数据快很多,能够明显提升系统吞吐量;此外本地文件也可以用于数据库的容灾。
服务器端也需要考虑数据的同步问题,主要有2个方面:(1)当有数据更新时,通过SDK请求服务端更新数据库,并且同时更新本地文件,也要通知其他集群更新数据;(2)定时检查服务端数据是否与数据库数据一致,确保本地文件与数据库数据一致,也需要保证数据更新通知不能送达其他服务器时,服务器需要定时检查本地文件与数据库数据是否是一致的。

数据库策略

数据库一方面需要保证主备方式来达到容灾目,另一方面需要支持配置的版本管理,方便配置对比及配置回滚。

大型网站其他要素

大型网站的发展中,虽然中间件起到了十分关键的作用,软负载中心和集中配置管理中心也起到了桥梁的作用,但还有一些其他的组件也十分重要,下面我们列举一些常见的组件。

CDN

CDN(Content Delivery Network)内容分发系统,CDN将用户需要的内容分发到离用户近的地方,这样可以使用户能够就近获取所需要的内容,提升效率。CDN系统分为CDN源站和CDN节点,源点提供数据源头,节点一般部署在离用户比较近的地方,如下图所示:
img
CDN本质是一种网络缓存技术,把一些先对稳定的资源放在离用户比较近的地方,节省带宽,提升网络速度,一般把静态文件(图片、视频、JS、页面框架)放在CDN中。下面我们看一下有无CDN的访问过程区别。
无CDN时,浏览器访问网站过程如下:
(1)用户向浏览器提交域名;
(2)浏览器对域名进行解析,得到IP地址
(3)浏览器向IP地址发送请求
(4)浏览器获取返回数据,进行渲染
加入CDN后,访问网站的过程发生了变化,如下图所示:
img
(1)用户提交访问域名
(2)浏览器进行域名解析,CDN对域名解析进行调整,得到域名对应的CNAME记录
(3)对CNAME再次进行解析,得到IP。这一过程使用全局负载均衡DNS解析,根据地址位置信息以及所在的ISP来返回结果,让不同地区不同接入商的用户得到最适合的CDN
(4)得到IP发起请求
(5)CDN根据请求内容是否在本地缓存进行不同处理:若在直接返回,否则CDN请求源站获取内容后返回
可以看出CDN几个关键技术:
(1)全局调度:这一步需要根据用户区域,接入运营商,以及CDN负载情况进行调度
(2)缓存技术:CDN缓存需要保证有足够多数据,当没有命中的时候去源站获取数据可以进行合并批量操作来加快速度;此外缓存预加载也是一个提高命中率的办法
(3)内容分发:对CDN的数据进行管理,数据的分发效率和一致性问题
(4)带宽优化:CDN流量很大,节省带宽和数据压缩等

存储支持

在大型网站中,存储系统是一个很重要的支撑系统,大型网站刚开始一般从关系型数据库开始,关系型数据库建立在key-value基础上,但仅仅使用关系型数据库并不能满足大型网站系统的存储需求。

分布式文件系统

对于图片、文本的存储,使用关系型数据库就不再适合了,一般采用分布式文件系统。常见的分布式文件系统有Google的GFS以及Java版本的HDFS等,解决了单机文件系统存储不足及安全性问题,把多台机器组成一个分布式文件系统,提供文件系统服务。

NoSQL

处于分布式文件系统和关系型数据库之间的数据库系统都可以被称为NoSQL;NoSQL和SQL都是基于key-value发展而来,下面我们看一下常见的Nosql数据库系统。
(1)Key-Value
这是最基础的技术支撑,但不支持高效的范围查询
(2)Ordered-Key-Value
这是对Key-Value的一个改进,Key是有序的,可以解决范围查询效率问题。
(3)Big Table
Google的结构化数据的分布式存储系统,对Value进行了Schema支持,Value由多个Column Family组成,Column Family内部是Column。Hbase是对BigTable的一个Java开源实现。
(4)Document
Document数据库可以在Value中自定义复杂Scheme,不再是简单的Map嵌套,同时有支持索引和全文搜索。
(5)Graph
支持图结构的数据模型。

缓存系统

缓存系统是一种非持久的存储,为了加速对数据的读取。开源的缓存系统有Redis和Memcache,可以降低对底层数据库的读压力。但缓存系统需要注意缓存和数据库一致性问题。
大型网站系统中缓存的另一种重要场景是对Web应用的页面渲染内容缓存,相对静态的内容可以进行缓存,不用每次进行渲染,具体实现技术有ESI等。

搜索系统

当网站规模较小时,一些查询可以依靠数据库的Like查询来实现,但这种查询效率较低,也不够智能,随着数据量的增大,需要使用搜索技术来解决查询问题。

倒排索引

倒排索引是搜索引擎中一项重要技术,倒排是相对于正向索引来说。正向索引的key为文章id,value为文章的单词,而倒排索引的key为单词,value为文章id,所以当需要搜索关键词时,倒排索引十分方便。但也要需要设计如何分词,即key的选取问题,需要根据实际需求合理设计。

查询预处理

预处理需要对用户输入的搜索内容进行分词,然后进行一些预处理包括同义词替换纠错等。

相关度计算

再经过查询分析处理后,搜索引擎会返回处理的结果,但同时也需要对搜索的结果进行排序,这里需要计算结果的相关度,相关度有向量空间模型、概率模型等。

数据计算

再解决了数据的存储后,需要解决的问题是数据计算,从实时行角度来看,计算分为离线计算和实时计算。

离线计算

离线计算是业务产生的数据离开生产环境后进行的计算。把数据从生产环境移动到离线存储中,然后进行数据处理的过程,实效性较差,延迟高。MapReduce是著名的离线计算模型,包含了map和reduce两个阶段,Hadoop是MapReduce的一个开源实现,利用了HDFS进行存储,而Spark则基于内存的集群计算,效率更高。

在线计算

在线计算是一种实时的计算模型,比较常见的方式是流式计算,如Storm。

发布系统

当完成应用的开发和测试后,需要对应用进行上线来提供服务。当需要管理的应用服务器很多时,如何保证发布过程不影响用户体验,如何支持灰度发布时就十分复杂了。发布应用时关键的几个要点如下。

分发应用

我们首先需要高效地把程序包分发到线上机器中,这一过程如下图所示:
img
在多机房情况下,我们考虑在每个机房都部署发布服务器,由发布服务器负责本机房的程序包分发,发布控制台可以将程序包分发给任意一台发布服务器,由发布服务器之间相互分发,也可以将程序包分发给所有机房的发布服务器。另一方面,当应用服务器过多时候,可以采用P2P技术进行程序包的分发,加快分发速度。

启动校验

当完成程序包的分发后,需要停止当前的应用程序并启动新的应用程序。新应用程序包启动后,需要进行校验,一般通过校验脚本来判断返回结果是否正确。
在停止应用时,若采取暴力的方式,会影响当时正在执行的请求服务,所以需要首先控制不让新的请求进来,在完成所有请求后再关闭应用。这里一般通过软负载均衡中心来完成,首先从软负载中心移除设备然后优雅关闭应用,当新的程序启动校验后,再把机器加入软负载中心,进而提供服务。在控制应用下线、重启、上线时,需要保证整个集群的可用机器数量,否则可能导致可用机器无法承受负载,所以操作的应用服务器比例一定要调控合理。

灰度发布

应用虽然进行了严格的测试,但为了保证万无一失,还是需要进行灰度发布,即对应用进行分批发布,逐步扩大新应用在整个集群的比例直到最后全部完成,并在灰度发布中记录关键数据、状态。

应用监控

应用上线后,需要对应用的运行情况以及出现问题进行监视和控制。

数据监视维度

系统数据(CPU、内存、IO等)和应用自身数据(调用次数、成功率、响应时间、异常数量)

数据记录方式

进行监视的数据,需要考虑被采集数据的记录方式。系统自身数据记录到本地磁盘,应用数据记录在应用自身的目录中,也可以把应用日志通过网络发送到采用服务器情况,以此来减轻本地写日志压力,在写日志时可以批量写入,间隔一段时间统计。

数据采集方式

监控中心获取集群中各个服务器的数据有二种方式。服务器自己推送数据到监控中心,需要考虑监控中心的负载能力;监控中心从服务器拉取数据。

展现与告警

采用图表提供Web展示,根据告警条件和接受人进行告警,通过短信、手机APP来告警。除此之外,需要对应用进行监控,出现问题时可以通过重启应用解决,更精细化的做法可以进行降级。降级是指遇到大量请求且不能扩容时进行功能限制。

依赖管理系统

在大型的分布式系统中,系统中有各种各样的应用集群,这些集群和底层系统之间有相互的依赖关系,随着网站功能的增多,应用之间的关系也越来越复杂,理清这些依赖关系并进行管理十分重要。首先,我们需要知道应用在完成某个功能时依赖那些系统,也需要知道哪些是强依赖哪些是弱依赖。
依赖的检测有静态和动态两种方式。静态检测分析应用的代码调用情况获取依赖关系,但不能检测出依赖的强弱性;动态监测在运行阶段监测功能调用关系,并可以进行强弱依赖的检测。Google发表的论文Dapper中,通过一种更细粒度的方式检测应用依赖情况,应用调用时都会传递一个traceId,以此来构造应用调用链。
对于依赖的控制,主要通过白名单和黑名单机制完成,应用的识别通过IP和应用名完成,另外也可以通过密码的方式进行应用的鉴权。

多机房问题

多机房主要用于容灾,以及改进不同区域用户访问速度,有同城机房和异地机房。
同城的机房主要用于容灾,突破单机房集群规模限制。对于数据库系统,则会把主备放在不同的机房,当主数据库故障时,需要进行主备切换;此外在软负载中心也需要尽量避免跨机房调用。
异地机房比较难于处理,两地间通信延迟较高。首先把数据同步到异地机房,把一些数据延迟不敏感系统部署到异地,一般是一些只读的系统,这样便于异步的用户快速访问应用。最后将写应用也放在异地,这一步是最复杂的。

系统容量规划

有了监控的依赖管理系统,能够及时发现问题并进行补救,但还需要知道系统的容量和水位。我们把系统的能够提供的并发能力和当前的压力分别称为容量和水位。知道各个系统的容量和水位是一件很重要的事情,希望通过扩容来支持更多的请求,而不是降级方案。

容量的测量是一个基础的工作,最终希望能够预测系统的容量增长曲线,以便规划服务器数量等来降低成本,当然预测准确十分困难,需要结合过去的增长规律和人为判断。当然首先需要弄清楚以下几件事情:(1)系统的高峰期水位;(2)各个系统的容量;(3)设置警戒线,水位高于警戒线就增加容量。其中,系统水位通过监控系统得到数据,系统的容量需要通过测试来获取。首先需要保证依赖的服务系统不是瓶颈,测试时也需要贴近用户的真实请求,注意观察系统的响应时间。
对于提供服务的应用,通过负载均衡设备使得单台服务器承受更多的请求,注意处理时间的变化,一旦时间很长说明负载很大,我们通过单机容量来计算整个集群的容量,但这些是对于无状态服务的测量,但对于数据库等有状态的服务,放大请求量是一个可行的方案,将一次读取重复多次,这适用于读操作;对于写操作不能单纯采用此方法,因为会引发脏数据,可以复制一份数据库,让测试走测试数据库,在请求中增加参数区分正常请求和测试请求,来达到使用不同的数据库的目的。
以上是对于集群的单个机器测试,而对于整个集群的全站压测则是十分困难的。

内部私有云

内部私有云为大型系统的运维带来了很多便利,私有云要求我们的资源能够动态扩展,并在不需要时动态收缩,判断扩容还是缩容与系统容量和水位有很大关系,这也涉及到人工智能的一些技术来实现智能化。

谢谢大佬的打赏!