大型网站系统与Java中间件实践

分布式系统介绍

分布式系统基础知识

多进程

多进程相对于单进程多线程的方式来说,资源控制会更容易实现,此外,多进程中的单个进程问题,不会造成整体的不可用。分布式系统可以近似看作把单机多进程变为了多机多进程

网络IO

  • BIO:采用阻塞的方式实现,一个Socket套接字需要使用一个线程来处理。发生建立连接、读数据、写数据的操作时,都可能阻塞。
  • NIO:基于事件驱动思想,采用Reactor模式,一个线程中处理多个Socket套接字相关工作,即统一通过Reactor对所有客户端的Socket套接字事件做处理,然后派发到不同的线程中。
  • AIO:采用Proactor模式,与NIO的区别是,AIO在读写操作时,只需要调用相应的read/write方法,并且需要传入CompletionHandler(动作完成处理器),在动作完成后,会调用CompletionHandler;而NIO的通知是发生在动作之前,是在可读、可写的时候,Selector发现这些事件后调用Handler处理。它们最大的区别是,NIO在通知时可进行相关操作,而AIO在有通知时表示相关操作已经完成。

请求调用

  • 硬件负载均衡。

  • 软件负载均衡。也称为透明代理,如LVS,发起请求的一方以为是中间的代理提供了服务,而处理请求的一方以为是中间的代理请求的服务。

    两个不足:

    • 增加网络开销,一方面指的是流量,另一方面指的是延迟。如果使用LVS的TUN或DR模式,那么从处理请求服务器上的返回结果会直接到请求服务的机器上,不会再通过中间的代理,只有请求的数据包在过程中多了一次代理的转发。在发送请求的数据包小而返回结果的数据包大的场景下,优化效果明显,而如果发送请求的数据包很大则流量仍然很大。
    • 透明代理处于请求的必经路径上,如果代理出现问题,则所有的请求都会受到影响,需要考虑代理服务器的热备,但切换时未完成的请求还是会受到影响。
  • 名称服务。

    两个作用:1)收集提供请求处理的服务器地址信息;2)提供这些地址信息给请求发起方。

    优势:1)名称服务不是在请求的必经路径上,如果名称服务出问题,有不少方法可以保证请求处理的正常;2)发起请求的一方和提供处理的一方是直连的,减少了中间的路径以及可能的额外带宽的消耗。

  • 规则服务器控制路由。这个没看懂,请求发起方如何知道处理方的地址信息呢?

分布式系统的难点

  1. 缺乏全局时钟。在分布式系统中,每个节点都有自己的时钟,在通过相互发送消息进行协调时,如果仍然依赖时序,就会相对难处理。解决思路是,很多时候使用时钟,是为了区分两个动作的顺序,而不是一定要知道准确时间,这种情况,可以将工作交给一个单独的集群完成,通过这个集群来区分多个动作的顺序。
  2. 故障独立性。在分布式系统中,整个系统的一部分有问题而其他部分正常是经常出现的情况,这称之为故障独立性。
  3. 单点故障。避免单点的关键是把这个功能从单机实现变为集群实现,如果不能实现,那么一般还有两种选择:
    • 给单点做好备份,能够在出现问题时进行恢复,并且尽量做到自动恢复,降低恢复需要的时间。
    • 降低单点故障的影响范围。比如拆分到多数据库。
  4. 事务的挑战。

大型网站及其架构演进过程

session

session实现方式:在会话开始时,分配一个唯一的会话标识(SessionId),通过cookie把这个标识告诉浏览器,以后每次请求时,浏览器都会带上这个会话标识告诉Web服务器请求属于哪个会话。在Web服务器上,每个会话有独立的存储,保存不同会话的信息。如果遇到禁用cookie的情况,一般做法是把会话标识放到URL参数中。

分库分表

不同业务的数据从原来的一个数据库中拆分到了多个数据库中,就需要考虑如何处理原来单机中跨业务的事务。一种方法是使用分布式事务,其性能低于之前的单机事务;另一种方法是去掉事务或者不追求强事务支持,则原来在单库中可以使用表关联的查询也需要改变实现。

数据库水平拆分对应用的影响:

  • SQL路由问题。即进行数据库操作时需要知道操作的数据在哪。
  • 主键的处理。原来依赖单个数据库的一些机制需要变化,如自增字段,在分表情况下不能简单地继续使用了,在不同的数据库中也不能直接使用一些数据库的限制来保证主键不重复了。
  • 查询。由于同一业务的数据被拆分到不同的数据库中,因此一些查询需要从两个数据库中取数据,如果数据量太大而需要分页,就会比较难处理。

服务化

  • 业务功能之间的访问不再是单机内部的方法调用了,还引入了远程的服务调用。
  • 共享的代码不再是散落在不同应用中了,这些实现放在了各个服务中心。
  • 将与数据库的交互工作放到服务中心,可以降低数据库的连接数
  • 通过服务化,无论是Web应用还是服务中心,都可以由固定的小团队来维护系统,能够更好地保持稳定性,并能更好地控制系统本身的发展,且服务中心发布的次数远小于Web应用,从而减小了不稳定的风险。

构建Java中间件

Atomics

相比较于使用synchronized,使用如AtomicInteger后能让代码变得简洁,更重要的是性能得到了提升,原因在于AtomicInteger内部通过JNI的方式使用了硬件支持的CAS指令。

CountDonwLatch和CyclicBarrier

两者的差别是,CountDonwLatch是在多个线程上都进行了countDown后才会触发事件,唤醒await的线程;CyclicBarrier是一个栅栏,用于同步所有调用await方法的线程,并且等所有线程都到了await方法时,这些线程才一起返回继续各自的工作(因为使用CyclicBarrier的线程都会阻塞在await方法上,所以在线程池中使用CyclicBarrier时要格外小心,如果线程池的线程数过少,就会发生死锁)。此外,另一个差别是CyclicBarrier可以循环使用,而CountDonwLatch不能循环使用。

Exchanger

Exchanger用于在两个线程之间进行数据交换。线程会阻塞在Exchanger的exchange方法上,直到另一个线程也到了同一个Exchanger的exchange方法时,两者进行交换,然后两个线程会继续执行后续代码。

服务框架

服务调用端的设计

服务框架的使用方式

服务框架三个基础的属性:

  • interfaceName。
  • version。设置接口名就具备了可以进行远程调用的最基础属性,不过实际场景中,接口存在变化的可能性,有的是因为业务的发展需要修改接口中已有方法的参数或返回值,有的是因为实现代码本身重构的原因,可以通过版本号进行区分隔离。
  • group。如果对同一个接口的远程服务有很多机器,可以将这些远程服务的机器归组,然后调用者可以选择不同的分组来调用,实现不同调用者对于同一服务的调用隔离。

运行期服务框架与应用和容器的关系之间需要解决两个问题:

  • 服务框架自身的部署方式问题。解决方案有两种:
    • 把服务框架作为应用的一个依赖包并与应用一起打包。这种方式服务框架就成了一个库,随应用启动。但是,如果要升级服务框架,就需要更新应用本身,且服务框架没法接管classloader,就不能做一些隔离以及包的实现替换工作。
    • 把服务框架作为容器的一部分,这里是针对Web应用来说的,Web应用一般用Jetty、Tomcat等作为容器,就需要遵循不同容器所支持的方法,把服务框架作为容器的一部分。然而,有的情况下应用不需要容器,那么服务框架自身就需要变为一个容器来提供远程调用和远程服务的功能。
  • 实现自己的服务框架所依赖的一些外部jar包和应用自身依赖的jar包之间的冲突问题。通过ClassLoader技术,将服务框架自身用的类与应用用到的类都控制在User-Defined Class Loader级别,实现相互的隔离。Web容器对于多个Web应用的处理,以及OSGi对于不同的Bundle的处理都采用了类似的方法。

服务调用者与服务提供者之间通信方式的选择

服务注册查找中心,对于调用者来说只是提供可用的服务提供者的列表。出于效率考虑,不是每次调用远程服务前都通过服务注册查找中心来查找可用地址,而是把地址缓存在调用者本地,当有变化时主动从服务注册查找中心发起通知,告知调用者可用的服务提供者列表的变化。

引入基于接口、方法、参数的路由

在实际的场景中,一般会用接口作为服务的粒度,即一个服务指一个接口的远程实现。

当某个服务提供者的某个接口方法是一个很慢的方法,它会导致请求的排队,解决方案有:

  • 隔离这些资源,从而使得快慢不同、重要级别不同的方法之间互不影响。
  • 从客户端角度,控制同一个集群中不同服务的路由并进行请求的隔离是一种可行方案。通过路由策略,让其中对于某些服务的请求到一部分机器,让另一些服务的请求到另一部分机器。具体来说,根据服务定位提供服务的那个集群地址,然后与接口路由规则中的地址一起取交集,得到的地址列表再进行接下来的负载均衡算法,最终得到一个可用的地址去进行调用。

多机房场景

避免跨机房的服务调用的解决方案:

  • 在服务注册查找中心做一些工作,通过它来甄别不同机房的调用者集群,给它们不同服务提供者的地址。
  • 通过路由完成,思路是服务注册查找中心给不同机房的调用者相同的服务提供者列表,在服务框架内部进行地址过滤,过滤的原则(如何识别机房)一般基于接口等路由规则进行集中配置管理。具体实践中,一方面需要考虑两个甚至多个机房的部署能力是否对等,也就是说通过路由使服务都走本地的话,负载是否均衡。另一方面,如果某个机房服务提供者大面积不可用,而另外机房的服务提供者是正常运营且有余量提供服务,那么该如何让服务提供者大面积不可用的机房调用者调用远程服务呢,这是需要解决的问题。
  • 实际中,每个机房的网段是不同的,可以帮助区分不同的机房。在多机房中还有一个问题,未必每个机房都是对称的,因此考虑使用虚拟机房的概念,即不以物理机房为单位做路由,而是把物理上的多个机房看作一个逻辑机房来处理路由规则。

服务调用端的流控处理

流量控制的两种方式:

  • 0-1开关。
  • 设定固定值,表示每秒可以进行的请求次数,超过这个请求数的话就拒绝对远程的青年过去,那些被拒绝的请求,可以直接返回给调用者,也可以进行排队。

基于以下两个维度考虑进行控制:

  • 根据服务端自身的接口、方法做控制,也就是针对不同的接口、方法设置不同的阈值,为了使服务端不同的接口、方法之间的负载互不影响。
  • 根据来源做控制,对于同样的接口、方法,根据不同的来源设置不同的限制,一般用在比较基础的服务上。

序列化和反序列化的处理

在具体实践中,通信协议和服务调用协议的扩展性、向后兼容性是需要重点考虑的。因为在实践中服务会越来越多,调用者也会越来越多,服务框架在升级时无法保证在同一时刻把所有使用到的地方都进行升级。在制定具体通信协议时,版本号、可扩展属性及发起方支持能力的介绍很重要。我们很难保证我们协议的扩展性可以支持未来所有的情况,因此显式地标明版本是必要的,这样另一端可以根据具体的版本号来进行相应的处理。可扩展性属性有点像键值对的定义,能方便我们对协议的扩展,避免一增加属性就要修改版本的情况。标明自身服务能力的介绍说为了方便接收端根据请求端的能力来进行相应的处理,例如对于服务调用的具体返回结果的数据来说,如果调用端支持调用,那么可以返回压缩后的数据。

网络通信实现的选择

使用NIO,IO线程负责和socket连接打交道,进行数据的收发,需要发送的数据都会进入数据队列,通信对象队列是保存了多个线程使用的通信对象,这个通信对象主要是为了阻塞请求线程,请求线程把数据放入数据队列后会生成一个通信对象,它会进入通信对象队列并且等待。通信对象用于唤醒请求线程,如果在远程调用超时前有结果返回,那么IO线程会通知通信对象,通信对象结束请求线程的等待,将结果传给请求线程,以进行后续的处理。此外,我们也有定时任务负责检查通信对象队列中的哪些通信对象已经超时了,然后这些通信对象会通知请求线程已经超时的事实。

支持多种异步服务调用方式

  • Callback。请求者设置回调对象,把数据写入数据队列后就继续自己的处理。当收到服务提供者的返回后,IO线程会通知回调对象,就可以执行回调方法了,而如果需要支持超时,同样可以使用定时任务来完成,如果已经超时但没返回,那么同样需要执行回调对象的方法,只是要告知是超时无结果。需要注意的是,建议用新线程来执行回调,而不是在IO线程或定时任务的线程中,不要因为回调本身的代码执行时间久等问题影响了IO线程或定时任务。
  • Future。使用Future方式,同样是先把Future放入队列,然后把数据放入队列,接着就在线程中进行处理,等到请求线程的其他工作处理结束后,就通过Future来获取通信结果并直接控制超时。
  • 可靠异步。可靠异步要保证异步请求能够在远处被执行,一般通过消息中间件完成这个保证。

服务提供端点设计与实现

服务端对请求处理的流程

具体流程:网络通信层=》协议解析、反序列化=》定位服务=》调用服务

这一流程涉及两个具体问题:

  • 在网络通信层,IO线程会进行通信的处理(一般是多个IO线程),在收到完整的数据包、完成协议解析得到序列化后的请求数据时,反序列化在什么线程进行时需要考虑的
  • 得到反序列化后的信息并定位服务后,调用服务在什么线程也是需要考虑的。

一般来说,调用服务一定是在工作线程(非IO线程)进行的,而反序列化的工作则取决于具体实现,在IO线程或工作线程中进行都有。

不同服务的线程池隔离

服务提供端点工作线程是一个线程池,路由到本地的服务请求会被放到线程池执行。如果客户端没有通过接口或方法进行路由,我们可以在服务提供端控制,即进行线程池隔离。在服务端,工作线程池不是一个,而是多个,当定位到服务后,根据服务名称、方法、参数来确定具体服务调用的线程池是哪个。

服务提供端的流控处理

在服务提供者看来,不同来源的服务调用者,0-1开关以及限制具体数值的QPS的方式都需要实现,并且在服务提供者这里,某个服务或方法可以对不同服务调用者进行不同的对待。这种做法就是对不同的服务调用者进行分级,确保优先级高的服务调用者被优先提供服务,这也是保证稳定性的策略。

服务升级

服务升级的两种情况:

  • 接口不变,只是代码本身的完善,这种情况只需要采用灰度发布的方式验证然后全部发布即可。
  • 修改原有接口。这又分为两种情况:
    • 在接口中增加方法。新方法的调用者使用新方法,原来的调用者继续使用原来的方法。
    • 对接口的某些方法修改调用的参数列表。这种情况有几种应对措施:
      • 通过版本号解决。这是比较常用的方法,新方法的调用者使用新版本的服务,原来的调用者继续使用原来版本的服务。
      • 在设计方法上考虑参数的扩展性。这是可行的方式,但是不太好,因为参数列表可扩展意味着采用类似Map的方式来传递参数,这样不直观,且对参数的校验会比较复杂。

实战中的优化

优雅和实用的平衡

服务提供者使用数据库进行数据的存储,使用缓存来缓解数据库的压力。服务提供者对外提供数据的读写服务,服务调用者通过调用服务提供者的读写服务进行数据库访问。这种方式比较优雅,但毕竟多一次调用就夺走了一次网络,尤其是服务调用者读取数据的频率非常高的情况,如果让服务调用者直接读取缓存会更合适。具体就是,把读取缓存的逻辑放到服务调用者执行,如果缓存缓存读取成功则结束,否则就到服务提供者哪里去进行读数据库、更新缓存的操作,而写操作仍直接由服务提供者处理。

分布式环境中的请求合并

假设有个任务是从远处读取大量数据然后进行统计计算,如果有其他服务提供者在干活了,就不需要重复干活了。

在单机中判断是否有相同的任务在执行是很简单的,而在分布式环境中,则需要独立于服务调用者、服务提供者之外的节点来完成相关工作,即需要分布式锁来控制。但这需要权衡,因为分布式系统中,如果每个请求都要走一次分布式锁服务来进行控制,就会有额外开销。另一个思路是,在服务调用端不是把请求随机分发给服务提供者,而是根据一定的规则把同样的请求发送到同一个服务提供者上,然后在服务提供者的机器上做单机控制,这样通过路由策略的选择,可以不引入分布式锁服务,减少了复杂性。此外,对于比较消耗系统资源的操作,不论是使用分布式锁服务,还是采用路由的方式,在服务调用者上都可以进行单机多线程的控制,具体采用何种方式,需要根据具体场景和数据的支持来做出最后的决定。

数据访问层

数据库从单机到分布式的挑战和应对

数据库垂直拆分和水平拆分的困难

垂直拆分的影响:

  • 单机ACID保证被打破。数据到多机后,原来在单机通过事务来进行的处理逻辑都会受到很大的影响,面临的选择是要么放弃事务,修改实现,要们引入分布式事务。
  • Join操作变得困难,因为数据可能已经在两个数据库中了,需要应用或其他方式来解决。
  • 靠外键进行约束的场景会受到影响。

水平拆分的影响:

  • ACID被打破的情况。
  • Join操作被影响的情况。
  • 靠外键进行约束的场景会受到影响。
  • 依赖单库的自增序列生成唯一ID会受到影响。
  • 针对单个逻辑意思上的表的查询要跨库。

集群内数据一致性算法

集群内数据一致性算法有Quorum和Vector Clock:

  • Quorum是用来权衡分布式系统中数据一致性和可用性的,引入三个变量,N(数据复制节点数量)、R(成功读操作的最小节点数)、W(成功写操作的最小节点数)。如果W+R>N,可以保证强一致性,而如果W+R<=N,能够保证最终一致性。如果让W=N且R=1,则大大降低可用性,但一致性是最好的。
  • Vector Clock的思路是对同一份数据的每一次修改都加上"<修改者,版本号>"这样一个信息,用于记录修改者的信息和版本号,通过这样的信息来帮助我们解决一些冲突。

从工程上来说,如果能避免分布式事务的引入,那么还是避免为好;如果一定要引入分布式事务,那么可以考虑最终一致性方案,而不是强一致。从实现上来说,通过补偿机制不断重试,让之前因为异常而没有进行到底的操作继续进行,而不是回滚。如果还不能满足需求,那么基于Paxos算法的实现会是一个不错的选择。

应对多机的数据查询

跨库Join的解决思路:

  • 在应用层把原来数据库的Join操作分成多次的数据库操作。
  • 数据冗余,对常用信息进行冗余,这样把原来需要Join的操作变为单表查询。
  • 借助外部系统(如搜索引擎)解决一些跨库问题。

跨库查询的问题及解决

分表后,需要在应用层做合并(感觉类似于Spark SQL了),包括如下操作:

  • 排序。多个来源的数据查询出来后,在应用层进行排序的工作。如果从数据库中查询出的数据是已经排好序的,那么在应用层要进行的就是对多路的归并排序;如果查询出的数据未排序,则进行一个全排序。
  • 函数处理。使用Max、Min、Sum、Count等函数对多个数据来源的值进行相应的处理。
  • 求平均值。从多个数据源进行查询时,需要把SQL改为查询Sum和Count,然后对多个数据来源的Sum求和、Count求和后计算平均值。
  • 非排序分页。需要看具体实现所采取的策略,是同等步长地在多个数据源上分页处理,还是同等比例地分页处理。同等步长意味着来自不同数据源的记录数是一样的;同等比例意味着来自不同数据源的数据数占这个数据源符合条件的数据总数的比例是一样的。
  • 排序后分页。在取第一页结果时,应该考虑的最极端情况是合并后的结果可能都来自一个数据源,所以需要从每个数据源取足一页的数据,越往后翻页,承受的负担越重。因此,在访问量很大的系统中,应尽量避免这种操作,尤其是排序后需要翻很多页的情况。

数据访问层的设计和实现

对外提供数据访问层的方式

数据层负责解决应用访问数据库的各种共性问题,那么数据层会以以下几种方式呈现给应用:

  • 为用户提供专用API,不过不推荐,因为通用性差。
  • 通用方式。在Java应用中一般通过JDBC方式访问数据库,数据库本身可以作为一个JDBC实现,也就是暴露出JDBC的接口给应用,这时应用的使用成本就很低了,和使用远程数据库的JDBC驱动方式一样,迁移成本也低。
  • 基于ORM或类ORM接口的方式,可以说介于上面两种方式之间。可以在自己应用使用的ORM框架上再包装一层,用来实现数据层功能,对外暴露的接口仍然是原来框架的接口。这种做法的优势是某些功能实现成本低,且在兼容性方面有一定优势,但通用性比JDBC的方式要低。

不同提供方式在合并查询场景下的对比

相对于在ORM框架上的实现,专用API方式和JDBC方式都要与数据库JDBC驱动直接打交道,并且为了得到正确的排序分页结果也需要获取足够的数据,但是和使用ORM框架不同的是,这两种方式并不是一定要把所有数据都获取到应用端并生成对应的Java对象(取的数据是在各个数据源上是有序的)。

使用ORM框架可能会有一些框架自身的限制带来的困难。如,使用iBatis的同时想去动态改动SQL会比较困难,而这在直接给予JDBC驱动方式的实现中就没那么困难。

数据层整体流程

SQL解析=》规则处理=》SQL改写=》数据源选择=》SQL执行=》结果集返回合并处理。

SQL解析

SQL解析考虑的两个问题:

  • SQL支持程度。是否需要支持所有的SQL,根据场景决定。
  • 支持多少SQL方言。

解析时使用antlr、javacc还是其他工具,或者手写,看自己的选择。解析时可以利用缓存提升解析速度。

SQL解析中一个重要的事是根据执行的SQL得到被操作的表,根据参数及规则来确定目标数据源连接。这部分可以通过提示(hint)的方式实现,该方式会把一些关键信息传进来,而不用去解析整个SQL。使用这种方式的一般情况是:

  • SQL解析并不完备(在发展过程中遇到的问题)。
  • SQL不带有分库条件,但实际上是可以明确指定分库的。

规则处理

  • 固定哈希算法作为规则。即根据某个字段(如用户id)取模,然后将数据分散到不同的数据库和表中。除此之外,还经常根据时间维度,如天、星期、月等来存储数据,这一般用于数据产生后相关日期不进行修改的情况,否则就要涉及数据移动的问题了。根据时间取模多用于日志类或其他与时间维度密切相关的场景。通常将周期性的数据放在一起,这样进行数据备份、迁移或现有数据的清空都会很方便。对于扩容则处理比较复杂。
  • 一致性哈希算法作为规则。
  • 虚拟节点对一致性哈希算法的改进。
  • 映射表与规则自定义计算方式。映射表示根据分库分表字段的值得查表法来确定数据源的方法,一般用于对热点数据的特殊处理,或者在一些场景下对不完全符合规律的规则进行补充。常见的情况是以前面的方式为基础,配合映射表来用。规则自定义计算方式是最灵活的方式,它不是以配置的方式来做规则,而是通过比较复杂的函数计算来解决数据访问的规则问题。比如,假设根据id取模分成了4个库,但对于一些热点id,希望将其独立到另外的库,则可通过下面的表达式完成:return id in hotset ? 4 : id % 4

SQL改写

我们遇到的问题是,数据库从原来单库单表变为多库多表,这些分布在不同数据库中的表的结构一样,但表名未必一样。如果把原来的表分布在多库且每个库都只有一个表的话,那么这些表可以同名,但是如果单库不止一个表达话,那么就不能用相同的名字,一般是在逻辑表后面加后缀。

在命名表时需要做出一个选择,就是不同库中的表名是否要一样?如果每个表的名字都是唯一的,看起来不优雅,但可以避免很多误操作,且表名唯一在进行路由和数据迁移时也比较便利。

除了修改表名,SQL的一些提示中用到的索引名等,在分库分表时也需要进行相应的修改,需要从逻辑上的名字变为对应数据库中物理的名字。

另外,还有一个需要修改SQL的地方,就是在进行跨库计算平均值的时候。

实战

Java应用引入数据源后,一般用Spring做配置,需要实现一个DataSource对象,用该对象管理分库后的整体的数据库,或者说管理数据库集群。这个管理了整个业务的数据库集群的DataSource看起来比较优雅,是一个all-in-one的解决方案,但在具体场景中可能会比较重,业务要么使用数据层所有功能,要么就不用数据层。因此引入groupDataSource,用于管理整个业务数据库集群中的一组数据库。groupDataSource相对于完整的DataSource来说,可以不管具体的规则,也可以不进行SQL解析,是作为一个相对基础的数据源提供给业务的。groupDataSource解决的问题是在要访问该分组中的数据库时,解决具体访问数据库的选择问题,具体的选择策略是groupDataSource要完成对工作,包括根据事务、读/写等特性选择主备,以及根据权重在不同的库间进行选择。

如果用DataSource,对于应用来说只看到一个DataSource,可以少关心很多事情,不过可能会受到DataSource本身的限制;如果采用groupDataSource会有更大的自主权。如果采用完整DataSource,对于后端业务的数据库集群管理会更方便,例如进行一些扩容、缩容工作而不需要应用太多的感知;而使用groupDataSource就意味着绑定了分组数量,当进行扩容、缩容时需要应用进行较多配合。虽然使用groupDataSource不能进行整体扩容、缩容,但可以进行组内的扩容、缩容,主备切换等工作,这也是其最大价值。在一些活动或可预期的访问高峰前,可以给每个分组挂载上备库,通过配置管理中心更改配置,就可以让应用使用新的数据库,同样可以通过配置管理中心的配置更改下线数据库,以及主备库切换。

对数据源分组后,再进行数据源功能切分,构建AtomDataSource,其只管理一个具体的数据库。通过AtomDataSource把单个数据库的数据源配置集中存储,那么在定期更换密码、进行机房迁移等需要更改IP或改变端口时会非常方便。另外,通过AtomDataSource也可以帮助我们完成在单库上的SQL连接隔离,以及禁止某些SQL的执行等和稳定性相关的工作。

独立部署的数据访问层实现方式

从数据层的物理部署方式来说可以分为jar包方式和Proxy方式。若采用Proxy方式,客户端和Proxy之间的协议有两种选择:

  • 数据库协议。应用把Proxy看出一个数据库,然后使用数据库本身提供的JDBC实现就可以连接Proxy。因为应用到Proxy、Proxy到DB采用的都是数据库协议,所以如果使用同样的协议,例如都是MySQL协议,那么在一些场景下就可以减少一次MySQL协议到对象然后再从对象到MySQL协议的转换。不过采用这种方式时Proxy要完全实现一套相关数据库的协议,这个成本比较高,此外应用到Proxy之间也没办法做到连接复用。
  • 私有协议。Proxy对外提供的通信协议书自己设计的,并且需要一个独立的数据层客户端,这个协议的好处是,Proxy的实现会相对简单一些,并且应用到Proxy之间的连接是可以复用的。

读写分离的挑战与应对

最常见的是主从复制,也存在主库从库非对称的场景。

数据结构相同,多从库对应一主库的场景

  • 应用层通过数据层访问数据库后,通过消息系统将数据库更新消息发出去,数据同步服务器获得消息后会进行数据复制工作。分库规则配置负责在读数据及数据同步服务器更新分库时让数据层知道分库规则。数据同步服务器和DB主库的交互主要是根据被修改或新增的数据主键来获取内容,采用的是行复制方式。虽然不优雅但能解决问题。
  • 基于数据库日志进行数据复制。

主/备库分库方式不同的数据复制

有一些场景会进行非对称复制,指的是源数据和目标数据不是镜像关系,也指源数据库和目标数据库是不同的实现。比如,在主库中,根据买家id进行分库,把所有的买家id分到不同的库中,保证一个买家查询自己的交易记录时都是在一个数据库上查询的,不过卖家的查询就可能跨多个库了。可以做一组备库,按照卖家id进行分库。要完成这个非对称复制,需要控制数据分发,而不是简单地进行镜像复制。

引入数据变更平台

复制到其他数据库是数据变更的一种场景,还有其他场景也会关心数据的变更,例如搜索引擎的索引构建、缓存的失效等。可以构建一个通用的平台来管理和控制数据的变更。

引入Extractor和Applier,Extractor负责把数据源变更的信息加入到数据分发平台,而Applier的作用是把这些变更应用到相应的目标上,中间的数据分发平台是由多个管道组成。不同的数据变更来源需要有不同的Extractor来进行解析和变更进入数据分发平台的工作。进入到数据分发平台的变更信息就是标准化、结构化的数据了,根据不同的目标用不同的Applier把数据落地到目标数据源上就可以了。因此,数据分发平台构建好后,主要的工作是实现不同类型的Extractor和Applier,从而接入更多的数据源。

数据平滑迁移

对数据库做平滑迁移的挑战是,在迁移的过程中又有数据变化。可以考虑的方案是,在开始进行数据迁移时,记录增量日志,在迁移结束后,再对增量的变化进行处理。最后,把要被迁移到数据的写暂停,保证增量日志都处理完毕后,再切换规则,开放所有的写,完成迁移工作。

步骤:

  1. 开始扩容,并记录数据库的数据变更的增量日志;
  2. 数据开始复制到新表,也会有更新到增量日志中;
  3. 全量迁移结束后,把增量日志中的数据进行迁移,但这个做法并不能保证新表数据和源表数据一致,因为处理增量日志时,还会有新的增量日志进来,这是一个逐渐收敛的过程;
  4. 进行数据比对,可能会有新库数据和源库数据不同的情况,记录下来;
  5. 停止源数据库中要迁移走的数据的写操作,然后进行增量日志的处理,以使得新库表的数据是新的;
  6. 更新路由规则,所有新数据的读写到了新库表,完成了整个迁移过程。

消息中间件

消息中间件的价值

假设有一个用户登录系统,需要支持的一个功能是,用户登录成功后发送一条短信到用户的手机,算是安全选项,然后,需要把用户登录信息传给安全系统,安全系统进行安全策略相关的判断,最后,如果要继续加一些登录成功后需要被调用的系统,则登录系统会越来越复杂。登录系统被迫依赖非常多的系统。

从登录系统来看,这些系统不是登录系统必须依赖的,登录系统只需严重用户名和密码的合法性,所以其依赖的是能够提供用户名、密码的系统,而这些其他的系统其实是依赖登录系统的,因为它们关心登录是否成功这件事。

通过消息中间件解耦,登录系统负责向消息中间件发送消息,而其他系统则向消息中间件订阅这个消息,完成自己的工作。登录系统不用关心有多少个系统需要知道登录成功这件事,也不用关心如何通知它们,只需把登录成功这件事转为一个消息发送到消息中间件就可以了。

互联网时代的消息中间件

我们需要考虑消息的顺序保证、扩展性、可靠性、业务操作与消息发送一致性,以及多集群订阅者等方面的问题。

如何解决消息发送一致性

消息发送一致性定义:产生消息的业务动作与消息发送的一致,即如果业务操作成功了,那么这个由操作系统产生的消息一定要发送出去,否则就丢失消息了;如果业务行为没有发生或失败,那么就不该把消息发出去。也就是说必须保证一致性的场景,只有ack还是不够,因为如果业务成功了,但一直ack不了就有问题了。

JMS解决方案:使用XA系列的接口实现,其是为了实现分布式事务的支持,带来的问题是:1)引入分布式事务,带来一些开销并增加复杂性;2)对于业务操作有限制,要求业务操作的资源必须支持XA协议,才能与发送消息一起来做分布式事务。

解决一致性的方案:

  1. 发送消息给消息中间件;
  2. 消息中间件入库消息;
  3. 消息中间件返回结果;
  4. 业务操作;
  5. 发送业务操作结果给消息中间件;
  6. 更改存储中消息状态。

对于上述方案,可能遇到的异常状态可分为:

  • 业务操作未成功,消息未入存储
  • 业务操作未成功,消息入存储,状态为待处理
  • 业务操作成功,消息入存储,状态为待处理

这三种情况,第一种不需要处理,因为其是一致的;第二种和第三种都需要了解业务操作的结果,然后来处理已经在消息存储中、状态为待处理的消息。了解业务操作的结果,需要由消息中间件主动询问业务应用,可以说这是发送消息的一个反向的流程,步骤为:

  1. 消息中间件询问待处理消息对应的业务操作结果;
  2. 业务应用对业务操作的结果进行检查;
  3. 业务发送业务结果给消息中间件(业务结果有成功、失败、等待三种,等待表示业务还在处理中);
  4. 消息中间件根据这个结果,更新消息状态。

这个反向流程也会有异常,不过这个4步流程就是为了确认业务处理结果,真正的操作只是根据业务处理结果来更改消息的状态,因此,前面3步都与查询有关,如果失败就失败了,最后一步的更新状态如果失败了,则定时重复这个反向流程,重复查询就可以了。

发送消息的正向流程和检查业务操作结果的反向流程结合起来,就是解决业务操作与发送消息一致性的方案,在大多数情况下,反向流程是不需要工作的。

如何解决消息中间件与使用者的强依赖问题

上面的解决业务操作和发送消息一致性的方案,更多地关注如何保持和解决一致性问题。但是忽略了一个问题,就是消息中间件变成了业务应用的必要依赖,即如果消息中间件出现问题(包括使用的消息存储、业务应用到消息中间件的网络等),会导致业务操作无法继续进行,即便当时业务应用和业务操作的资源都是可用的。

解决方案是,让消息中间件中影响业务操作的部分与业务自身具有同样的可靠性,就是保证如果业务能操作成功,就需要消息能够入库成功,因为如果消息中间件出问题了,可以接受投递的延迟,但要保证消息入库,这样业务才可以继续进行。把消息中间件所需要的消息表与业务数据表放到同一个业务数据库中,业务应用可以把业务操作和写入消息操作作为一个本地事务完成,再通知消息中间件有消息可以发送,就解决了一致性问题。消息到消息中间件可以通过消息中间件主动轮询,也可以业务应用轮询发送。

上述方法的影响是:1)需要用业务自己的数据库承载消息;2)数据库需要支持事务。可以进一步变通,考虑把本地磁盘作为一个消息存储,即如果消息中间件不可用,又不愿或不能侵入业务自己的数据库时,可以把本地磁盘作为存储消息的地方,等待消息中间件回复后,再把消息送到消息中间件。风险是如果消息中间件不可用,且写入本地磁盘的数据也坏了,那么消息就丢失了。所以,从业务数据上进行消息补发才是最彻底的容灾手段,因为这样才能保证只要业务数据在,就一定有办法恢复消息。

将本地磁盘作为消息存储的方式有两种,一是作为一致性发送消息的解决方案的容灾手段,只有出现问题时才会用该手段;二是直接使用该方式工作,可以控制业务操作本身调用发送消息的接口的处理时间,也有机会在业务应用和消息中间件之间做一些批处理工作。

消息订阅者订阅消息的方式

  • 非持久订阅。消息接收者和消息中间件之间的消息订阅的关系的存续,与消息接收者本身是否处于运行状态有直接关系。
  • 持久订阅。消息关系一旦建立,除非应用显式地取消订阅关系,否则这个订阅关系将一直存在。而订阅关系建立后,消息接收者会收到所有消息,即使接收者应用停止,消息也会保留,直到下次应用启动后再投递。

保证消息可靠性的做法

消息发送可靠性,使用ack。

消息存储可靠性:

  • 基于文件的消息存储。

  • 使用数据库作为消息存储。破坏范式,更多地考虑使用宽表、冗余数据的方式来实现。存储的冗余字段越多,表就更宽。这种方式带来的问题是占用的空间会增大,而且需要保证一致性,即一个字段修改时,所有表中的冗余字段都需要修改。好处是通过冗余字段让查询只走一个表,提升了性能。

    存储方式有:

    • 消息表和投递表分开存储。当消息进入数据库时,需要生成投递表中的数据,当投递有结果后,也要更新相应的投递表信息。对投递表的插入、更新、删除,在单条消息订阅集群数量多时会带来非常多的数据库记录操作,引起很大的性能下降。
    • 消息表和投递表一起存储。面临的问题是无法按照单独的接收者来进行消息的调度。

    存储自身的安全:

    • 单机的Raid。需要考虑单机本身的安全性。
    • 多机的数据同步。要求不能有延迟,一般通过存储系统自身的机制完成,如果复制有延迟,也不是完全安全。
    • 应用双写。通过应用来控制写两份,以应对存储系统自身数据复制有延迟的情况,不过会让应用变复杂。

    如果采用写入多个物理节点的方式,考虑到应用对于写入时间的要求,这两个节点的距离不能太大,一般是同机房或同城较近的两个机房,否则同步的双写会导致过大的延迟。但还需要考虑异地容灾,如果要求应用写入延迟低,则只能选择异步复制;如果接受异地数据比主写入点的数据有延迟,那么就让写入的应用有较长的等待,保证两边都写成,这需要权衡。

  • 基于双机内存的消息存储。使用文件系统或数据库进行消息存储时,由于磁盘IO的原因,系统性能会受到限制,一个改进方案是用混合方式进行存储的管理。正常情况下,消息持久存储不工作,而基于内存来存储消息能够提供很高的吞吐量,一旦一个机器出现故障,则停止另一台机器的数据写操作,并把当前数据落盘。这种方式适合于消息到了消息中间件后大部分消息能及时被消费的情况。

消息投递的可靠性保证

通过消息接收者的ack,决定消息中间件是否删除消息。消息接收者需要注意的是,不能在收到消息、业务没有处理完成时就去确认消息,且在处理过程中如果出现异常,不要吃掉异常然后确认消息处理成功,这样会“丢”消息。

投递处理优化:

  • 在进行投递时一定要用多线程的方式处理,每个线程处理一个消息并等待处理结束后再进行下一条消息的处理。每个线程处理一条消息时,会得到需要接收该消息的订阅者集群id列表,然后从每个订阅者集群id中选择一个连接来处理,消息投递后需要等待结果,然后统一更新消息表中的消息状态。这种方式在正常情况下没有问题,但遇到异常情况如订阅者集群中有一个很慢的订阅者,负责投递的所有线程会慢慢被堵死,因为都需要等待这个慢的订阅者返回。

    把处理消息结果返回的处理工作放到另外的线程池中来完成,也就是投递线程完成消息到网络的投递后就可以接着处理下一条消息,保证投递环节不会被堵死。而等待返回结果的消息会先放在内存中,不占用线程资源,等有了最终结果时,再放入另外的线程池中处理。这种方式把占用线程池的等待方式变为了靠网络收到消息处理结果后的主动响应方式。

    收到消息的处理结束后,更新数据库的操作也可以优化,通过数据库的batch来处理消息的更新、删除操作,从而提升性能。

  • 如果一个应用上有多个订阅者订阅同样的消息,即单机多订阅者,如果不优化,则会向这个机器发送多次同样的消息。优化点有:

    • 单机多订阅者共享连接。
    • 消息只发送一次,传到单机的统一订阅管理器中,由它进行消息分发到不同的订阅者。

消息订阅者视角的消息重复产生和应对

消息重复的原因:

  • 消息重复发送,1)消息中间件存储成功后出现问题,导致发送端没有收到ack;2)消息中间件因为负载高响应变慢,存储成功后返回结果超时;3)返回结果时网络超时。解决方案是重试发送消息时使用相同的消息id进行去重。
  • 消息重复投递,1)接收端处理完毕后应用出问题了,消息中间件再次投递;2)返回时网络延迟;3)接收端处理时间长导致超时;4)返回时消息中间件出问题了,没能收到消息结果进行处理;5)中间件收到结果,但是遇到消息存储故障,未能更新投递状态。解决方案:1)分布式事务,但是比较复杂,成本也高;2)消息接收者对消息处理是幂等的,给接收端应用带来一定的限制和门槛。

消息投递的其他属性支持

  • 消息优先级。
  • 订阅者消息处理和分级订阅。一般消息的多个订阅者之间是独立的,它们对消息的处理并不会互相造成影响,不过在一些特殊场景中,对于同样的消息,可能希望有些订阅者处理结束后再让其他订阅者处理。这种情况,一种方案是可以设定优先处理的订阅者集群,即订阅者消息处理顺序的属性,可以在这个属性字段中设置处理的集群id。另一种方案是分级订阅,把优先订阅者和一般接收者分开,优先接收者处理成功后主动把消息投递到另外的中间件(也可以换一个消息类型),然后一般接收者接收新产生的消息,这种做法不需要消息中间件去做额外支持,但重发了消息,会多一次消息入库等操作。
  • 自定义属性。支持自定义属性会很便利,例如服务端消息过滤,以及接收端对于消息的处理,这个属性类似于HTTP的Header,一般是对这条消息的抽象描述,方便服务端和接收端快速获取这条消息中的重要信息。
  • 局部顺序。指在众多消息中,和某件事情相关的多条消息之间有顺序,而多件事件之间的消息则没有顺序。

保证顺序的消息队列的设计

前面讲述的场景,同一个消息订阅者处理不同的消息,成功与否可能跟消息自身的内容相关,比如手机充值场景,当付款消息发出来后,负责充值的系统会收到这个消息然后进行充值,这时能否充值成功不仅和负责充值的合作伙伴是否可用有关,还与消息内容有关,像输入的是长度合法但实际不存在的手机号码时充值会失败。

另一种场景是一般不会因消息内容而导致失败,而是和这个订阅者及其依赖的系统是否可用有关。比如数据复制场景,源数据库上的数据变更变成消息进入消息中间件,只要目标数据库可用,这个处理就会成功。这种场景,一个吞吐量大且支持顺序的消息中间件很有价值,且对于接收端的设计也从Push变为了Pull,这是为了让消息接收者可以更好地控制消息的接收和处理。具体实现中,消息的存储写入本地文件,采用的是顺序写入的方式,一个消息接收者在每一个它所接收的消息队列上有一个当前消费消息的位置(offset),对于这个接收者来说,这个位置之前的消息都完成了消费。在同一个队列中,不同的消费者分别维护自己的指针,并且通过指针的回溯,可以把消息的消费恢复到之前的某个位置继续处理。如果有业务等的需要(如消息补发),那么移动接收端的offset即可,这种方式下,接收端有比较大的自主控制权。而对于消息中间件来说,重要的是保证消息的安全,然后根据接收端提供的offset获取消息传给接收端即可。

单机多队列的问题和优化。

本地消息存储的可靠性。通过主从保证可靠性,复制方式选择异步则让从节点订阅主节点的所有消息,类似MySQL的replication,复制方式选择同步则让主节点收到消息后主动写往从节点,且收到从节点的响应后才向消息发送者返回成功消息。

支持队列扩容。基本策略是让向一个队列写入数据的消息发送者能够知道应该把消息写入迁移到新的队列中,并且也需要让消息订阅者知道当前的队列消费完数据后需要迁移到新队列去消费。几个关键点:

  • 原队列在开始扩容后需要有一个标志,即使有新消息过来也不接收。
  • 通知消息发送端新的队列位置。
  • 消息接收端对原来队列的定位会收到新旧两个位置,当旧队列数据接收完毕后,则只会关心新队列的位置,完成切换。

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

初识软负载中心

软负载中心有两个基础的职责:

  • 聚合地址信息。无论是服务框架中需要的服务提供者地址,还是消息中间件系统中的消息中间件应用的地址,都需要软负载中心去聚合地址列表,形成一个可供服务调用者及消息的发送者、接收者直接使用的列表。
  • 生命周期感知。软负载中心需要能对服务的上下线自动感知,并且根据这个变化去更新服务地址数据,形成新的地址列表后,把数据传给需要数据的调用者或消息的发送者和接受者。

软负载中心的结构

软负载中心包括两部分:

  • 软负载中心的服务端。负责感知提供服务的机器是否在线,聚合提供者的机器信息,并且负责把数据传给使用数据的应用。
  • 软负载中心的客户端。客户端承载两个角色,作为服务提供者,主要把服务提供者提供服务的具体信息传给服务端,并且随着服务的变化去更新数据;而作为服务使用者,主要是向服务端告知自己所需要的数据并负责去更新数据,还要进行本地的数据缓存,通过本地的数据缓存,使得每次去请求服务获得列表都是一个本地操作,从而提升效率和性能。

软负载中心内部一般有三部分重要的数据:

  • 聚合数据。即聚合后的地址信息列表,对于提供的服务信息,使用唯一的dataId来标识,并且对于同样的dataId是支持分组的(group),通过分组可以形成一个二维的结构。通过dataId和group可以定位到唯一的数据内容,即通过聚合完成的完整数据。这种方式其实就是key-value结构。
  • 订阅关系。在软负载中心,需要数据的应用(服务提供者等)把自己的需要的数据信息告诉软负载中心,这就是一个订阅关系,订阅的粒度和聚合数据的粒度是一致的,就是通过dataId和group来确定数据,那么就有dataId、group到数据订阅者的分组Id(consumerGroupId)的一个映射关系。当聚合的数据有变化时,也是通过订阅关系的数据找到需要通知的数据订阅者,然后进行数据更新的通知。
  • 连接数据。指连接到软负载中心的节点和软负载中心已经建立的连接的管理。使用软负载中心的应用时,无论是发布数据还是订阅数据,都会有一个自己的独立分组Id(groupId),而连接数据就是用这个groupId作为key,然后对应管理这个物理连接的,采用长连接方式。当订阅的数据产生变化时,通过订阅关系找到需要通知的groupId,在连接数据这里就能找到对应的连接,然后进行数据的发送,完成对应用的数据更新。

内容聚合功能设计

内容聚合是软负载中心负责的基础工作,要完成的工作有:

  • 保证数据正确性。内容聚合主要需要保证的是并发场景下的数据聚合的正确性,另外需要考虑的是发布数据的机器短时间上下线的问题,指发布数据的机器刚连接上来或发布数据刚传上来,然后就断线了;或是断线后很快又上线,又发布数据了。
  • 高效聚合数据。服务提供者、消息中间件等服务地址列表都是由软负载中心进行管理的,因此高效地聚合数据会在软负载中心自身重启或服务提供者大面积重启时带来很大的便利。

有几个关键点需要注意:

  • 并发下的数据正确性的保证。
  • 数据更新、删除的顺序保证。
  • 大量数据同时插入、更新时的性能保证。优化的方法有根据dataId、group进行分线程处理,即保证同样的dataId、group的数据是在同一个线程中处理的,这样可以利用不需要锁的数据结构,也可以在数据处理上进行一定程度的合并。具体来说,增加任务队列、对应的处理线程以及对应的数据存储,将多线程请求变为一个顺序队列操作,交给任务队列处理,任务队列是需要线程安全的实现,但是因为这里的操作主要是“任务加入队列”和“任务从队列中取出”,都是简单的操作,锁冲突的情况比加锁进行数据处理要好很多。

解决服务上下线的感知

服务上下线感知主要有两种实现方式:

  • 通过客户端与服务端的连接感知。

    数据的发布者和接收者都与软负载中心的服务器维持一个长连接。对于服务提供者,软负载中心可以通过这个长连接上的心跳或数据的发布来判断服务发布者是否还在线,如果很久没有心跳或数据的发布,则判定为不在线;对于新上线的发布者,通过连接建立和数据发布就实现了上线通知。

    这种方式存在两个问题:

    • 软负载中心的服务器属于旁路,即它并不在调用链上,当软负载中心自身的负载很高时,是可能产生误判的。例如,软负载中心压力很大,处理请求变慢,心跳数据来不及处理,会以为心跳超时而判定服务不在线,认为服务不可用并且把信息通知给服务调用者,导致原本可用的服务被下线。
    • 如果服务发布者到软负载中心的网络链路有问题,而服务发布者到服务使用者的链路没有问题,也会造成感知问题。解决方法是在软负载中心的客户端上增加逻辑,当收到软负载中心通知的应用下线数据时,需要服务调用者进行验证才能接收这个通知,带来的是要对每个服务提供者进行一次额外验证。
  • 通过对于发布数据中提供的地址端口进行连接的检查。但仍然存在上述问题。

软负载中心的数据分发到特点和设计

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

  • 消息中间件需要保证消息不丢失,而软负载中心只需要保证最新数据送到相关的订阅者,不需要保证每次都数据变化都能让最终订阅者感知。
  • 在消息中间件中,同一个集群中的不同机器是分享所有消息的,因此消息只要同一集群中的一台机器去处理就行了,而软负载中心维护的是集群中所有机器都需要的服务数据,因此需要把数据分发给所有的机器。

提升数据分发性能需要注意的问题

  • 数据压缩。因为数据要投递的目标很多,压缩带来的流量下降是很明显的。
  • 全量和增量的选择。

针对服务化的特性支持

软负载数据分组

之前的数据分组就是为了进行隔离,分组本身就是一个命名空间,用来把相同的dataId内容分开,主要用于下面两种场景:

  • 根据环境进行区分。较多地用于线下环境,开发、测试环境需要对不同的环境、项目进行隔离和区分,可以对不同的服务提供者和调用者进行隔离,使之互不可见。
  • 分优先级的隔离。多用于线上运行系统的隔离,把提供同样服务的提供者用组的概念分开,重要的服务使用者会有专有的组来提供服务,而其他的服务使用者可能会用公用的一个默认组。

提供自动感知以外的上下线开关

在软负载中心控制机器的上下线,之所以在机器的状态外进行控制,有以下两个考虑:

  • 优雅地停止应用。通过指令直接从软负载中心使机器下线,让其不再接受新的处理,能够把正在执行的任务执行完后再进行真正地下线。
  • 保持应用场景,用于排错。遇到服务问题时,可以把出问题的服务留下一台进行故障定位和场景分析,这时需要把这台机器从服务列表中拿下来,以免有新的请求进来造成服务的失败。

从单机到集群

数据统一管理方案

  • 对数据进行统一管理,即把聚合数据放在一个地方,这样负责管理连接的软负载中心机器可以是无状态的了。
  • 把软负载中心集群中的机器的职责分开,即把聚合数据的任务和推送数据的任务分到专门的机器上处理,因此发布者和订阅者的连接可以分开管理,且推送数据的机器可以对聚合数据做缓存。

数据对等管理方案

除上面的数据统一管理方式外,另一种策略是将数据分散在各个软负载中心的节点上,并且把自己节点管理的数据分发到其他节点上,从而保证每个节点都有整个集群的全部数据,且节点的角色是对等的。

集中配置管理中心

对于集中配置管理中心来说,最为关心的是稳定性和各种异常情况下的容灾策略,其次是性能和数据分发的延迟,集中配置管理中心存储的基本是各个应用集群、中间件产品的关键配置信息,以及一些配置开关。

通过主备的持久存储来保存持久数据,一般采用关系型数据库如MySQL,通过两个节点的主备来解决持久数据安全的问题。

在集中配置管理中心的单个节点中,部署Nginx和一个Web应用,其中Web应用主要负责完成相关的程序逻辑(如数据库相关操作),以及根据IP等的分组操作(这个基于IP的分组类似于软负载中心中的基于IP的分组)。整个应用的逻辑都放在Web应用中,单机的本地文件则为了容灾和提升性能。客户端进行数据获取时,都是从Nginx直接获取本地文件并把数据返回给请求端。

客户端实现和容灾策略

采用HTTP协议和集中配置管理中心进行交互,相比较Socket长连接来说是一种轮询方式,考虑到服务端压力,轮询的间隔是不能太短的,而这样会影响数据的时效性。改进的方法是使用长轮询方式,建立连接并发送请求后,如果有数据,则长轮询和普通轮询立即返回;如果没有数据,长轮询会等待,如果等到数据,那就立刻返回,如果一直没有数据,则等到超时后返回,继续建立连接,而普通轮询直接返回了。

容灾策略:

  • 数据缓存。
  • 数据快照。数据快照保存的是比缓存数据旧一些的数据,但是会保存最近的多个版本,用于服务端出现问题并且由于各种原因不能使用数据缓存时(如缓存的最新数据配置是一个有问题的配置),从更早几个版本的数据快照中进行恢复。
  • 本地配置。正常情况下应用通过集中配置使用服务端所给的配置,但如果遇到服务端不工作,而且需要更新配置使其生效的情况,就需要使用本地配额这个特性。如果服务端出现问题或者客户端与服务端的通信出现故障,最坏的情况也可以把新的配置分发到各个应用的某一特殊位置,使得这个本地配置生效从而解决服务端不可用的问题。
  • 文件格式。如果是二进制数据格式,那么没有对应的工具则无法对配置进行修改。而我们在客户端的容灾方面的最坏打算就是整个系统退化到一个单机的应用上,就会需要直接修改配置内容和数据,那么文本格式的限制就非常重要和关键了。

服务端实现和容灾策略

在服务端做的事除了前面讲述的Nginx+Web应用的方式,还要做的事是和数据库的数据同步:

  • 通过当前服务端更新数据库。由管理SDK的请求送到当前服务端,服务端需要去更新数据库的数据,同时更新自身的本地文件,还可以通知其他机器去更新数据,不过只是传送一个更新数据的通知。
  • 定时检查服务端的数据和数据库中数据的一致性。这是为了确保服务端本地文件数据和数据库的内容的一致性。

容灾方面,如果有数据更新且主备数据库都不可用,那么就需要直接修改服务端的本地文件内容了。所以,配置本身的文本化也是容灾措施的前提。

在服务端,服务端节点更新数据后虽然会对其他节点进行通知,但是这个部分的设计和实现是节点间松耦合的,而不是节点强绑定关系,因为还是希望让每个集中配置管理中心的服务端节点没有相互的强依赖。

数据库策略

数据库在设计时需要支持配置的版本管理,也就是随着配置内容的更改,老的版本是需要保留的,这主要是为了方便进行配置变更的diff以及回滚。而数据库本身需要主备进行数据的容灾考虑。

构建大型网站的其他要素

加速静态内容访问速度的CDN

整个CDN系统分为CDN源站和CDN节点,CDN源站提供CDN节点使用的数据源头,而CDN节点部署在距离最终用户比较近的地方,加速用户对站点的访问。

引入CDN后浏览器访问网站的流程:

  1. 用户向浏览器提交要访问的域名;
  2. 浏览器对域名进行解析,由于CDN对域名解析过程进行了调整,所以得到的是该域名对应的CNAME记录;
  3. 对CNAME再次进行解析,得到实际的IP地址。在这次解析中,会使用全局负载均衡DNS解析,也就是需要返回具体IP地址,根据地理位置信息以及所在的ISP来确定返回的结果,这个过程才能让身处不同地域、连接不同接入商的用户得到最适合自己访问的CDN地址,才能做到就近访问;
  4. 得到实际IP地址后,向服务器发出访问请求;
  5. CDN根据请求的内容是否在本地缓存进行不同的处理:
    • 如果存在,直接返回结果;
    • 如果不存在,则CDN请求源站,获取内容,再返回结果;

CDN的几个关键技术:

  • 全局调度。需要考虑用户地域、接入运营商以及CDN机房的负载情况去调度。
  • 缓存技术。如果CDN机房的请求命中率不高,那么加速效果就有限。解决方案有:1)扩大缓存容量,如内存+SSD+机械硬盘的混合存储方式,并做好冷热数据的交换,在提高命中率时也尽量降低缓存的响应时间;2)合并同样数据的请求,减少对重复数据的请求,降低源站压力;3)新增、变更数据后CDN进行预加载,即CDN主动加载数据,一般需要源站有一个通知过来。
  • 内容分发。主要是对内容全部在CDN上不用回源的数据的管理和分发,如一些静态页面。具体做法是在内容管理系统中进行编辑修改后,通过分发系统分发到各个CDN节点上。分发的效率和分发文件的一致性,正确性的校验是需要关注的点。
  • 带宽优化。CDN通过了内容加速,很多流量到了CDN上,优化方式是只返回必要的数据、用更好的压缩算法。

缓存系统

应用使用缓存的场景:

  • 使用缓存降低对底层存储的读压力。应用不直接操作存储,存储由缓存控制,对于应用的逻辑来说这很简单,但对缓存来说,需要保证数据写入缓存后能够存入存储中,所以缓存本身的逻辑会复杂些,需要有很多操作日志及故障恢复。
  • 应用直接与缓存和存储交互。应用写数据时更新存储,然后使缓存失效;读数据时先读缓存,缓存没有数据再去读存储,并把数据写入缓存。重点考虑的是缓存没有命中和数据更改的情况,以及更新存储中的数据后没来得及失效缓存的问题。
  • 全数据缓存方案。当存储的数据发生改变时,从存储去同步数据到缓存中,以更新缓存,应用完全从缓存中读取即可。也可以不是全数据缓存,可以把同步数据变成失效数据,缓存未命中则去存储中加载数据。

大型网站使用缓存的另一个场景是对于Web应用的页面渲染内容的缓存。对页面分块,其中有相对静态的内容和动态的内容,如果整个页面采用在服务端渲染的方式,我们希望相对静态的内容可以进行缓存而不是每次都要重新渲染。具体的实现技术为ESI(Edge Side Includes),是通过在返回的页面中加上特殊的标签,然后根据标签的内容去用缓存进行填充的一个过程。

处理ESI标签的具体工作可以在Java的应用容器中做,也可以放在Java应用容器前置的服务器做,对比如下:

  • 渲染页面和ESI处理在一个进程中,处理效率会提升,当页面内容是内部对象时就可以处理ESI标签了,而如果放在前置Web服务器,需要对内容再进行一次扫描,定位到ESI标签后再处理。
  • ESI放在前置Web服务器上处理,对于后端来说可以不单独考虑ESI标签的问题,例如当后端处理请求有Java应用、PHP应用,甚至还有其他原因时,可以统一把ESI处理放在Web服务器上,这样后端就只用处理请求,而不必对每个应用都去处理ESI的工作。

搜索系统

倒排索引

以一个例子来解释正排索引和倒排索引。假设有多篇文章,每篇文章都有自己的关键词。

正排索引:通过文章可以找到这篇文章中的关键词。如果搜索系统通过正排索引来检索,则需要看每篇文章的关键词,效率不高。

倒排索引:给定关键词,找到该关键词出现在哪些文章中。相对于正排索引,倒排索引把原来作为值的内容拆分为索引的Key,而原来用作索引的Key则变为了值。如何确定倒排索引的关键字,取决于如何对索引的内容进行分词。

查询预处理

查询预处理主要负责对用户输入的搜索内容进行分词以及分词后的分析,包括一些同义词的替换及纠错。这部分的工作将影响最后的搜索质量。

相关度计算

当经过了查询分析器的处理后,查询会在搜索引擎上被执行,对于返回的结果,需要计算和搜索内容的相关度后展示给用户。相关度计算是在不指定按照某个字段排序的基础上对搜索结果排序,排序的原则就是被搜索到的内容与要搜索的内容之间的相关度。

相关度计算方式有很多,如向量空间模型、概率模型等方法。相关度计算本身会依赖查询预处理的处理效果,相关度计算的最终体现在搜索结果的质量。

发布系统

发布系统的工作:

  • 分发应用。
    • 多机房情况下,可以考虑在每个记分都部署发布服务器,由机房内的发布服务器负责本机房的程序包分发。发布控制台在实现上,可以考虑只把程序包发给所有发布服务器中的一台,由该服务器负责在多个机房的发布服务器间分发,也可以由发布控制台负责把程序包分发给所有机房的发布服务器。
    • 如果应用服务器数量过多,可以采用P2P技术进行程序包分发,加快分发速度。
  • 启动校验。应用重新启动后,需要进行校验从而完成这台应用服务器上的应用发布。对应用的校验一般是由应用自身提供一个检测脚本或页面,发布系统执行这个脚本或访问页面来判断返回结果。在停止应用时,需要用软负载中心将这个应用先移除,再等该应用结束所有请求的处理后关闭,然后进行新应用的启动和检查,检查通过后,再把这个应用加入到负载均衡或软负载上,并对外提供服务。
  • 灰度发布。
  • 产品改版Beta。

应用监控系统

  • 数据监视维度:系统数据和应用自身数据。系统数据如CPU使用率、内存使用情况、交换分区使用情况、当前系统负载、IO情况等;应用自身数据则是不同应用有不同的数据,一般是调用次数、成功率、响应时间、异常数量等维度的数据。
  • 数据记录。日志记录、统计的方式记录日志。
  • 数据采集。服务器主动推或采集服务器主动拉。
  • 展现与告警。中心采集服务器收集的数据会集中存储,采用图表的方式提供Web页面的展示,并根据设置的告警条件和接收人进行告警。

依赖管理系统

使用和请求相关的唯一一个traceId记录所有的操作并串起来,traceId需要在跨系统调用时进行传递。除了tracId外,有一个index可以记录依赖的层次和顺序,每个应用在本机磁盘进行日志的记录,从而再把日志收集到统一的地方后进行拼装,形成一个调用的时序图。

系统容量规划

把某个应用系统集群能够提供的并发能力和当前的压力比做一个水桶的容量和水位,那么准确知道各个系统的容量和当前高峰时的水位是一件很重要的事,因为还是希望优先通过扩大容量来支持更多的请求,而不是首选降级方案。主要通过测试来确定。

posted @ 2023-09-24 08:55  sjmuvx  阅读(91)  评论(0编辑  收藏  举报