大型网站技术架构与Java中间件

2018/03/27 架构

分布式系统介绍

分布式系统的意义

  • 升级单机处理能力的性价比越来越低。
  • 单机处理能力存在瓶颈。
  • 出于稳定性和可用性的考虑。

分布式系统的基础知识

组成计算机的五要素

冯·诺依曼型计算机的这5个组成部分:

组成计算机的5要素

组成计算机的基本元素包括输入设备、输出设备、运算器、控制器和存储器,存储器又分为了内存和外存。 在计算机断电时,内存中存储的数据会丢失,而外存则仍然能够保持存储的数据。

线程与进程的执行模式

阿姆达定律

程序中的串行部分对于增加CPU核心来提升处理速度存在限制。

阿姆达尔定律

P指的是程序中可并行部分的程序在单核上执行时间的占比,N表示处理器的个数(总核心数)。 S(N)是指程序在N个处理器(总核心数)相对在单个处理器(单核)中的速度提升比。

程序中可并行代码的比例决定你增加处理器(总核心数)所能带来的速度提升的上限,是否能达到这个上限,还取决于很多其他的因素。 例如,当P=0.5时,可以计算出速度提升的上限就是2。而如果P=0.2,速度提升的上限就是1.25。

可见,在多核的时代,并发程序的开发或者说提升程序的并发性是多么重要。

互不通信的多线程模式

两个线程,没有交集,各自执行各自的任务和逻辑。

互不通信的多线程执行流程

基于共享容器协同的多线程模式

在另一些场景中需要在多个线程之间对共享的数据进行处理。

例如经典的生产者和消费者的例子,有一个队列用于生产和消费,那么,这个队列就是多个线程会共享的一个容器或者是数据对象,多个线程会并发地访问这个队列,

使用队列进行交互的多线程执行流程

对于这种在多线程环境下对同一份数据的访问,需要有所保护和控制以保证访问的正确性。对于存储数据的容器或者对象,有线程安全和线程不安全之分, 而对于线程不安全的容器或对象,一般可以通过加锁或者通过Copy On Write的方式来控制并发访问。

使用加锁方式时,如果数据在多线程中的读写比例很高,则一般会采用读写锁而非简单的互斥锁。对于线程安全的容器和对象,就可以在多线程环境下直接使用它们了。 在这些线程安全的容器和对象中,有些是支持并发的,这种方式的效率会比简单的加互斥锁的实现更好,例如在Java领域,JDK中的java.util.concurrent包中有很多这样的容器类。

不过,需要在这里提一点的是,有时通过加锁把使用线程不安全容器的代码改为使用线程安全容器的代码时,会遇到一个陷阱:

private HashMap<String, Integer> map = new HashMap<String, Integer>();
public synchronized void add(String key) {
	Integer value = map.get(key);
	if (value == null) {
		map.put(key, 1);
	} else {
		map.put(key, value + 1);
	}
}

private ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<String, Integer>();
public void add(String key) {
	// 这里的判断没有原子性,需要改用原子方法putIfAbsent
	Integer value = map.get(key);
	if (value == null) {
		map.put(key, 1);
	} else {
		map.put(key, value + 1);
	}
}
通过事件协同的多线程模式

除了并发访问的控制,线程间会存在着协调的需求。

通过事件协同的多线程执行流程

多进程模式

多进程和多线程有比较多的相似之处,也有不同。

  • 首先,对于前面提到的多线程会遇到的状况以及一些使用方式,多进程也有类似的场景,只是具体的实现方式上会存在不同。造成不同的最大原因是,线程是属于进程的,一个进程内的多个线程共享了进程的内存空间;而多个进程之间的内存空间是独立的,因此多个进程间通过内存共享、交换数据的方式与多个线程间的方式就有所不同。
  • 此外,进程间的通信、协调,以及通过一些事件通知或者等待一些互斥锁的释放方面,也会与多线程不一样。这些在不同的平台上所支持的方式不同。

多进程相对于单进程多线程的方式来说,资源控制会更容易实现,此外,多进程中的单个进程问题,不会造成整体的不可用。这两点是多进程区别于单进程多线程方式的两个特点。 当然,使用多进程会比多线程稍微复杂一些。多进程间可以共享数据,但是其代价比多线程要大,会涉及序列化与反序列化的开销。

而分布式系统是多机组成的系统,可以近似看做是把单机多进程变为了多机的多进程。当然,单机到多机也有些变化。 原来在单机OS上支持的功能现在都需要另外去实现了。多机系统也带来了一个好处,即当单个机器出问题时,如果处理得好,就不会影响整体的集群。

关乎故障的方面也会不一样:

  • 单线程和单进程多线程的程序在遇到机器故障、OS问题或者自身的进程问题时,会导致整个功能不可用。
  • 对于多进程的系统,如果遇到机器故障或者OS问题,那么功能也会整体不可用,而如果是多进程中的某个进程问题,那么是有可能保持系统的部分功能正常的——当然这取决于多进程系统自身的实现方式。
  • 而在多机系统中,如果遇到某些机器故障、OS问题或者某些机器的进程问题,都有机会来保证整体的功能大体可用——可能是部分功能失效,也可能是不再能承担正常情况下那么大的系统压力了。

网络通信基础知识

OSI与TCP/IP网络模型

经典的ISO的OSI七层模型:

ISO的OSI网络模型

平时工作中接触的,主要是TCP/IP的模型,两者的对应关系:

OSI与TCP/IP对照

网络IO实现方式
BIO

BIO即Blocking IO,采用阻塞的方式实现。也就是一个Socket套接字需要使用一个线程来进行处理。发生建立连接、读数据、写数据的操作时,都可能会阻塞。

主要问题是使得一个线程只处理一个Socket,如果是Server端,那么在支持并发的连接时,就需要更多的线程来完成这个工作。

BIO的工作方式

NIO

NIO即Nonblocking IO,基于事件驱动思想,采用的是Reactor模式。这在Java实现的服务端系统中也是采用比较多的一种方式。

相对于BIO,NIO的一个明显的好处是不需要为每个Socket套接字分配一个线程,而可以在一个线程中处理多个Socket套接字相关的工作。

Reactor模式:

Reactor模式

Reactor模式的工作方式,Reactor会管理所有的handler,并且把出现的事件交给相应的Handler去处理。

在NIO的方式下不是用单个线程去应对单个Socket套接字,而是统一通过Reactor对所有客户端的Socket套接字的事件做处理,然后派发到不同的线程中。 这样就解决了BIO中为支撑更多的Socket套接字而需要打开更多线程的问题。

Reactor模式在通信中的应用:

Reactor模式在通信中的应用

AIO

AIO即AsynchronousIO,就是异步IO。AIO采用Proactor模式。

Proactor模式

AIO与NIO的差别是,AIO在进行读/写操作时,只需要调用相应的read/write方法,并且需要传入CompletionHandler(动作完成的处理器); 在动作完成后,会调用CompletionHandler,当然,在不同的系统上会有一些细微的差异,不同的语言在SDK上也会有些差异,但总体就是这样的工作方式。 NIO的通知是发生在动作之前,是在可写、可读的时候,Selector发现这些事件后调用Handler处理。

AIO是在Java 7中引入的。在Java领域,服务端的代码目前基本都是基于NIO的。 而AIO和NIO的一个最大的区别是,NIO在有通知时可以进行相关操作,例如读或者写,而AIO在有通知时表示相关操作已经完成。

BIO、NIO、AIO这几种模型并不要求客户端和服务端采用同样的方式。 客户端和服务端之间的交互主要在于数据格式或者说是通信协议。在客户端,如果同时连接数不多,采用BIO也是一个很好的选择。

如何把应用从单机扩展到分布式

输入设备的变化

分布式系统由通过网络连接的多个节点组成,那么,输入设备其实可以分为两类,

  • 一种是互相连接的多个节点,在接收其他节点传来的信息时,该节点可以看做是输入设备;
  • 另外一种就是传统意义的人机交互的输入设备了。

输出设备的变化

输出设备和输入设备相仿,也可以看做有两种,

  • 一种是指系统中的节点在向其他节点传递信息时,该节点可以看做是输出设备;
  • 另外一种就是传统意义的人机交互的输出设备,例如终端用户的屏幕等。

控制器的变化

在单机系统中,控制器指的就是CPU中的控制器。 分布式系统是由多个节点通过网络连接在一起并通过消息的传递进行协调的系统。控制器主要的作用就是协调或控制节点之间的动作和行为。

使用硬件负载均衡的请求调用:

使用硬件负载均衡的请求调用

使用LVS的请求调用:

使用LVS的请求调用

这种方式主要的特点是代价低,而且可控性较强,即你可以相对自由地按照自己的需要去增加负载均衡的策略。

一般称上面的方式为透明代理。

这种方式存在两个不足。

  • 第一个不足是会增加网络的开销,
    • 这个开销一方面指的是流量,
      • 如果使用LVS的TUN或者DR模式,那么从处理请求服务器上的返回结果会直接到请求服务的机器,不会再通过中间的代理,只有请求的数据包在过程中多了一次代理的转发。在发送请求的数据包小而返回结果的数据包大的场景下,使用代理的模式与不使用代理的模式相比只有很小的流量增加,但是如果发送请求的数据包很大,那么流量增加还是比较明显的。
    • 另外一方面指的是延迟。
      • 而延时方面,这里只是根据这个结构提出了该问题,实际的影响很小。
  • 第二个不足是,这个透明代理处于请求的必经路径上,如果代理出现问题,那么所有的请求都会受到影响。
    • 需要要考虑代理服务器的热备份。
    • 不过,在切换时,当时未完成的请求还是会受到影响。

当总体来说,这是一种非常方便、直观的控制方式。

采用名称服务的直连方式的请求调用:

采用名称服务的直连方式的请求调用

与透明代理方式最大的区别是,在请求发起方和请求处理方这两个集群中间没有代理服务器这样的设备存在,而是请求发起方和请求处理方的直接连接。

在请求发起方和请求处理方的直接连接外部,有一个“名称服务”的角色,它的作用主要有两个:

  • 一个是收集提供请求处理的服务器的地址信息;
  • 另外一个是提供这些地址信息给请求发起方。

当然,名称服务只是起到了一个地址交换的作用,在发起请求的机器上,需要根据从名称服务得到的地址进行负载均衡的工作。 也就是说,原来在透明代理上做的工作被拆分到了名称服务和发起请求的机器上了。

这种方案也存在着自己的优势和不足。

  • 首先,这个名称服务不是在请求的必经路径上,也就是说,如果这个名称服务出现问题,在很多时候或者说有不少办法可以保证请求处理的正常。
  • 其次,发起请求的一方和提供请求的一方是直连的方式,也减少了中间的路径以及可能的额外带宽的消耗。
  • 而不方便的地方主要是代码的升级较复杂。

采用规则服务器控制路由的请求直连调用:

采用规则服务器控制路由的请求直连调用

这个方式与名称服务方式的不同在于,名称服务是通过跟请求处理的机器交互来获得这些机器的地址的, 而规则服务器的方式中,规则服务器本身并不和请求处理的机器进行交互,只负责把规则提供给请求发起的机器。

Master+Worker的方式:

Master+Worker的方式

存在一个Master节点来管理任务,由Master把任务分配给不同的Worker去进行处理。

运算器的变化

单机的计算能力有上限,而分布式系统中的运算器是运用多个节点的计算能力来协同完成整体的计算任务。

通过DNS服务器进行了调度和控制:

通过DNS服务器进行了调度和控制

负载均衡设备:

负载均衡设备

单日志处理服务器的日志处理:

单日志处理服务器的日志处理

Master控制的多日志处理服务器的日志处理:

Master控制的多日志处理服务器的日志处理

规则服务器管理的多日志处理服务器的日志处理:

规则服务器管理的多日志处理服务器的日志处理

使用规则服务器来分配任务可能存在的最大问题是任务分配不均衡。用Master节点的方式会对任务的分配做得更好些,不容易导致处理不均衡的问题。

存储器的变化

随着数据量的发展,需要把一台KV存储服务器扩展到两台来提供服务。

使用代理的多机Key-Value服务

代理服务器作为控制器转发来自于应用服务器的请求。而转发请求使用的策略与具体业务有非常密切的关系。一般可以根据请求的Key进行划分(Sharding)。

使用名称服务的Key-Value服务:

使用名称服务的Key-Value服务

让应用服务器与KV存储服务器直接连接,那么KV服务器的选择逻辑就放在了应用服务器上完成。

在实践中根据不同场景有两种实施经验:

  • 一个是通过规则服务器的配合,完成固定的Sharding策略;
  • 另外一个则是对等看待多台KV存储服务器,能够灵活地适应KV存储服务器的增加和减少,参考消息中间件部分的实现。

使用规则服务器的Key-Value服务:

使用规则服务器的Key-Value服务

规则服务器的规则不仅写明了如何对数据做Sharding,还包含了具体的目标KV存储服务器的地址。

通过Master控制的Key-Value服务:

Master是根据请求返回目标KV存储服务器地址,然后由应用服务器直接去访问对应的KV存储服务器。

相比名称服务的方式,Master是根据请求返回对应的KV存储服务器地址,而不是返回所有地址,所以具体的KV存储服务器选择工作在Master上就完成了,应用服务器上不需要有更多的逻辑; 相比规则服务器的方式,Master并不是把规则传给具体应用服务器,再由应用服务器去解析并完成规则下的路由选择,而是Master自身完成了这个事情后把结果传给应用服务器,应用服务器只需要根据返回的结果去访问这个KV存储服务器就可以了。 这种方式的具体应用很广泛。

节点增加参考《数据访问层》部分。

分布式系统的难点

缺乏全局时钟

因为同步本身就存在着时间差,因此需要有其他办法来解决这个问题。很多时候使用时钟,它可以区分两个动作的顺序,而不是一定要知道准确的时间。

对于这种情况,可以把这个工作交给一个单独的集群来完成,通过这个集群来区分多个动作的顺序

面对故障独立性

分布式系统由多个节点组成,整个分布式系统完全出问题的概率是存在的,但是在实践中出现更多的是某个或者某些节点有问题,而其他节点及网络设备等都没问题。 这种情况提醒在开发实现分布式系统时对问题的考虑要更加全面。

在分布式系统中,整个系统的一部分有问题而其他部分正常是经常出现的情况,称之为故障独立性。 在实现分布式系统的时候,必须要找到应对和解决故障独立性的办法。

处理单点故障

需要在分布式系统中尽量避免出现单点,尽量保证功能都是由集群完成的。

如果不能够把单机实现变为集群实现,那么一般还有另外两种选择:

  • 给这个单点做好备份,能够在出现问题时进行恢复,并且尽量做到自动恢复,降低恢复需要用的时间。
  • 降低单点故障的影响范围。

把原来的一个交易数据库拆为了两个(根据一定的规则做Sharding),那么,在单个数据库出现问题时,影响的就不会是全部范围了。 当且仅当这两台数据库同时发生故障才会影响全部范围。因此,如果把这个数据库拆成更多份,单个数据库出现问题的影响面就更小了。 需要多台机器同时出现问题才会出现严重故障,这个概率就比较低了。但是,一台数据库拆分到多台数据库后,出现故障的次数和总时间会比单台数据库的时候要多。 也就是说,增加了故障出现的次数和时间,降低了故障的影响面。

从本质上说,这种方式更多的是转移和交换,而没有真正解决或者帮助解决单点故障。

事务的挑战

两阶段提交(2PC)、最终一致、BASE、CAP、Paxos等。

大型网站架构演化发展历程

初始阶段的网站架构

基于Java技术用单机构建的交易网站

Initial-site-architecture

应用服务和数据服务分离

解决问题:越来越多的用户访问导致性能越来越差,越来越多的数据导致存储空间不足。

这时就需要将应用和数据分离。应用和数据分离后整个网站使用三台服务器:应用服务器、文件服务器和数据库服务器

  • 应用服务器需要处理大量的业务逻辑,因此需要更快更强大的CPU
  • 数据库服务器需要快速磁盘检索和数据缓存,因此需要更快的硬盘和更大的内存
  • 文件服务器需要存储大量用户上传的文件,因此需要更大的硬盘

应用与数据库分开的结构

Application-service-and-data-service-separation

使用缓存改善网站性能

数据缓存

解决问题:数据库压力太大导致访问延迟,进而影响整个网站的性能,用户体验受到影响。这时需要对网站架构进一步优化。

网站访问特点和现实世界的财富分配一样遵循二八定律:80%的业务访问集中在20%的数据上。

网站使用的缓存可以分为两种:

  • 缓存在应用服务器上的本地缓存和缓存在专门的分布式缓存服务器上的远程缓存。本地缓存的访问速度更快一些,但是受应用服务器内存限制,其缓存数据量有限,而且会出现和应用程序争用内存的情况
  • 远程分布式缓存可以使用集群的方式,部署大内存的服务器作为专门的缓存服务器,可以在理论上做到不受内存容量限制的缓存服务

Website-with-cache

加入缓存后的结构

在大型网站的源站中,有两个重要的使用缓存的场景:

用缓存来管理存储的方式:

用缓存来管理存储的方式

网站应用中使用缓存来降低对底层存储的读压力,需要注意的是缓存和数据存储中数据一致性的问题。

这种方式中,应用是不直接操作存储的,存储由缓存来控制。对于应用的逻辑来说这很简单, 但是对于缓存来说,因为需要保证数据写入缓存后能够存入存储中,所以缓存本身的逻辑会复杂些,需要有很多操作日志及故障恢复等。

应用直接管理缓存和存储的方式:

应用直接管理缓存和存储的方式

需要重点考虑的是缓存和存储数据一致性的问题,这里是指最终一致。 重点需要考虑的是缓存没有命中和数据更改的情况,以及更新存储中的数据后没来得及失效缓存的问题。

存储数据变更直接同步给缓存的方式:

存储数据变更直接同步给缓存的方式

对于全数据缓存比较合适,也就是说当存储的数据发生变化时,直接从存储去同步数据到缓存中,以更新缓存数据。 这样应用完全从缓存中读就行了。如果缓存的不是全数据,那么可以把同步数据变成失效数据,然后还是通过不命中的情况去进行缓存中的数据加载。

页面缓存

除了数据缓存外,还有页面缓存。数据缓存可以加速应用在响应请求时的数据读取速度,但是最终应用返回给用户的主要还是页面,有些动态产生的页面或页面的一部分特别热,就可以对这些内容进行缓存。 ESI就是针对这种情况的一个规范。从具体的实现上来说,可以采用ESI或者类似的思路来做,也可以把页面缓存与页面渲染放在一起处理。

对于ESI的处理是在Apache中进行。 Web服务器产生的请求响应结果返回给Apache,Apache中的模块会对响应结果做处理,找到ESI标签,然后去缓存中获取这些ESI标签对应的内容, 如果这些内容不存在(可能没有生成或者已经过期),那么Apache中的模板会通过Web服务器去渲染这些内容,并且把结果放入缓存中,用内容替换掉ESI标签,返回给客户的浏览器。 这种方式的职责分工比较清楚。不过Apache的ESI模块总是要对响应结果做分析,然后进行ESI相关的操作。如果在Web服务器处理时就能够直接把ESI相关的工作做完会是一个更好的选择。

Apache中的ESI模块

Apache中不再有ESI相关的功能了,而是在Web服务器中完成渲染及缓存相关的操作。这样的做法更高效,它把渲染与缓存的工作结合在了一起,而且这种做法只是看起来没有前一种方式分工清晰而已。

JBoss中的ESI功能

ESI标签处理流程:

ESI标签处理流程

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

ESI处理模块部署结构

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

使用应用服务器集群改善网站的并发处理能力

集群

解决问题:单一应用服务器能够处理的请求连接有限,在网站访问高峰期,应用服务器成为整个网站的瓶颈。

使用集群是网站解决高并发、海量数据问题的常用手段。当一台服务器的处理能力、存储空间不足时,不要企图去换更强大的服务器,对大型网站而言,不管多么强大的服务器,都满足不了网站持续增长的业务需求。这种情况下,更恰当的做法是增加一台服务器分担原有服务器的访问及存储压力。

对网站架构而言,只要能通过增加一台服务器的方式改善负载压力,就可以以同样的方式持续增加服务器不断改善系统性能,从而实现系统的可伸缩性。

Application-server-cluster

Session问题

Session Sticky

负载均衡器能够根据每次请求的会话标识来进行请求转发称为Session Sticky方式。

Session Sticky方式

这个方案可以让同样Session的请求每次都发送到同一个服务器端处理,非常利于针对Session进行服务器端本地的缓存。不过也带来了如下几个问题:

  • 如果有一台Web服务器宕机或者重启,那么这台机器上的会话数据会丢失。如如果会话中有登录状态数据,那么用户就要重新登录了。
  • 会话标识是应用层的信息,那么负载均衡器要将同一个会话的请求都保存到同一个Web服务器上的话,就需要进行应用层(第7层)的解析,这个开销比第4层的交换要大。
  • 负载均衡器变为了一个有状态的节点,要将会话保存到具体Web服务器的映射。和无状态的节点相比,内存消耗会更大,容灾方面会更麻烦。
Session Replication

Session Replication方式

通过同步就保证了不同Web服务器之间的Session数据的一致。

一般的应用容器都支持(包括了商业的和开源的)Session Replication方式,与Session Sticky方案相比,Session Replication方式对负载均衡器没有那么多的要求。

不过这个方案本身也有问题,而且在一些场景下,问题非常严重。

  • 同步Session数据造成了网络带宽的开销。只要Session数据有变化,就需要将数据同步到所有其他机器上,机器数越多,同步带来的网络带宽开销就越大。
  • 每台Web服务器都要保存所有的Session数据,如果整个集群的Session数很多(很多人在同时访问网站)的话,每台机器用于保存Session数据的内容占用会很严重。

这就是Session Replication方案。这个方案是靠应用容器来完成Session的复制从而使得应用解决Session问题的,应用本身并不用关心这个事情。 不过,这个方案不适合集群机器数多的场景。如果只有几台机器,用这个方案是可以的。

Session数据集中存储

把Session数据集中存储起来,然后不同Web服务器从同样的地方来获取Session。

集中存储Session方式

这个方案解决了Session Replication方案中内存的问题,而对于网络带宽,这个方案也比Session Replication要好。

问题:

  • 读写Session数据引入了网络操作,这相对于本机的数据读取来说,问题就在于存在时延和不稳定性,不过的通信基本都是发生在内网,问题不大。
  • 如果集中存储Session的机器或者集群有问题,就会影响的应用。

相对于Session Replication,当Web服务器数量比较大、Session数比较多的时候,这个集中存储方案的优势是非常明显的。

这个方案对于同一个会话的不同请求也是不限制具体处理机器的。和Session Replication以及Session数据集中管理的方案不同,这个方案是通过Cookie来传递Session数据的。

Cookie Based的方式

相对于前面的集中存储,这个方案不会依赖外部的一个存储系统,也就不存在从外部系统获取、写入Session数据的网络时延、不稳定性了。不过,这个方案依然存在不足:

  • Cookie长度的限制。知道Cookie是有长度限制的,而这也会限制Session数据的长度。
  • 安全性。Session数据本来都是服务端数据,而这个方案是让这些服务端数据到了外部网络及客户端,因此存在安全性上的问题。可以对写入Cookie的Session数据做加密,不过对于安全来说,物理上不能接触才是安全的。
  • 带宽消耗。这里指的不是内部Web服务器之间的带宽消耗,而是数据中心的整体外部带宽的消耗。
  • 性能影响。每次HTTP请求和响应都带有Session数据,对Web服务器来说,在同样的处理情况下,响应的结果输出越少,支持的并发请求就会越多。
总结

这4个方案都是可用的方案,不过对于大型网站来说,Session Sticky和Session数据集中存储是比较好的方案,而这两个方案又各有优劣,需要在具体的场景中做出选择和权衡。

数据库读写分离

采用数据库作为读库

解决问题:一部分读操作(缓存访问不命中、缓存过期)和全部的写操作需要访问数据库,在网站的用户达到一定规模后,数据库因为负载压力过高而成为网站的瓶颈。

目前大部分的主流数据库都提供主从热备功能,通过配置两台数据库主从关系,可以将一台数据库服务器的数据更新同步到另一台服务器上。网站利用数据库的这一功能,实现数据库读写分离,从而改善数据库负载压力

为了便于应用程序访问读写分离后的数据库,通常在应用服务器端使用专门的数据访问模块,使数据库读写分离对应用透明。

read-write-separation

这个结构的变化会带来两个问题:

  • 数据复制问题。
  • 应用对于数据源的选择问题。

搜索引擎其实是一个读库

引入搜索引擎的结构:

引入搜索引擎的结构

搜索集群(Search Cluster)的使用方式和读库的使用方式是一样的。只是构建索引的过程基本都是需要自己来实现的。

可以从两个维度对于搜索系统构建索引的方式进行划分,

  • 一种是按照全量/增量划分,
    • 全量方式用于第一次建立索引(可能是新建,也可能是重建),而增量方式用于在全量的基础上持续更新索引。
    • 当然,增量构建索引的挑战非常大,一般会加入每日的全量作为补充。
  • 一种是按照实时/非实时划分。
    • 实时/非实时的划分方式则体现在索引更新的时间上了。当然更倾向于实时的方式,之所以有非实时方式,主要是考虑到对数据源头的保护。

总体来说,搜索引擎的技术解决了站内搜索时某些场景下读的问题,提供了更好的查询效率。并且看到的站内搜索的结构和使用读库是非常类似的,可以把搜索引擎当成一个读库。

使用反向代理和CDN加速网站响应

解决问题:随着网站业务不断发展,用户规模越来越大,由于中国复杂的网络环境,不同地区的用户访问网站时,速度差别也极大。为了提供更好的用户体验,留住用户,网站需要加速网站访问速度。

主要手段有使用CDN和反向代理

CDN和反向代理的基本原理都是缓存,区别在于:

  • CDN部署在网络提供商的机房,使用户在请求网站服务时,可以从距离自己最近的网络提供商机房获取数据;
  • 而反向代理则部署在网站的中心机房,当用户请求到达中心机房后,首先访问的服务器是反向代理服务器,如果反向代理服务器中缓存着用户请求的资源,就将其直接返回给用户。

CDN-and-reverse-proxy

使用分布式文件系统和分布式数据库系统

解决问题:数据库经过读写分离后,从一台服务器拆分成两台服务器,但是随着网站业务的发展依然不能满足需求,这时需要使用分布式数据库。文件系统也是一样,需要使用分布式文件系统。

分布式数据库是网站数据库拆分的最后手段,只有在单表数据规模非常庞大的时候才使用。不到不得已时,网站更常用的数据库拆分手段是业务分库,将不同业务的数据库部署在不同的物理服务器上。

Distributed-file-system-and-database

常见的分布式存储系统有分布式文件系统、分布式Key-Value系统和分布式数据库。

  • 分布式文件系统就是在分布式环境中由多个节点组成的功能与单机文件系统一样的文件系统,它是弱格式的,内容的格式需要使用者自己来组织;
  • 而分布式Key-Value系统相对分布式文件系统会更加格式化一些;
  • 分布式数据库则是最格式化的方式。

分布式存储系统更多的是直接代替了主库。是否引入分布式系统则需要根据具体场景来选择。分布式存储系统通过集群提供了一个高容量、高并发访问、数据冗余容灾的支持。

引入分布式存储系统的结构

分布式文件系统

对一些图片、大文本的存储,使用数据库就不合适了。 可以考虑的一个方案是采用NAS网络存储设备,不过NAS本身的IO吞吐性能及扩展性在大型网站中会表现出比较明显的不足;另外一个方案是采用分布式文件系统。

分布式文件系统有很多具体产品,其中有很多是开源的系统(包括淘宝的TFS)。Google的GFS(Google File System),这是一个不开源的系统。

GFS结构图

主要由三部分构成,GFS Client(客户端)、GFS Master(在有些系统中被称为Namenode)、CFS chunkserver(在有些系统被称为DataNode)。

  • Client
    • 应用使用GFS的入口,Client负责从GFS Master上获取要操作的文件在ChunkServer中的具体地址,然后直接和ChunkServer通信,获取数据或者进行数据的写入、更新。
  • Master
    • 可以说是整个系统的大脑,这里维护了所有的文件系统元数据,包括名字空间、访问控制信息、文件与Chunk(数据块)的映射信息、Chunk的当前位置等。Master也控制整个系统范围内的一些活动,例如无效Chunk的回收、ChunkServer之前Chunk的迁移等。Master与ChunkServer之间通过周期性的心跳进行通信,检测对方是否在线。
  • ChunkServer
    • 这是文件数据存储的地方。在每个ChunkServer上会用Chunk(数据块)的方式来管理数据,每个Chunk是固定大小的文件,超过Chunk大小的文件会被分为多个Chunk进行存储,而对于小于Chunk大小的文件,则会将多个文件保存在一个Chunk中。

GFS主要解决了单机文件存储容量及安全性的问题,把多台廉价PC组成一个大的分布式的看起来像文件系统的集群,并对外提供文件系统的服务,可以满足业务系统对文件存储的需求。

HDFS就是采用Java的类GFS的实现。

使用NoSQL和搜索引擎

解决问题:随着网站业务越来越复杂,对数据存储和检索的需求也越来越复杂,网站需要采用一些非关系数据库技术如NoSQL和非数据库查询技术如搜索引擎

NoSQL和搜索引擎都是源自互联网的技术手段,对可伸缩的分布式特性具有更好的支持。应用服务器则通过一个统一数据访问模块访问各种数据,减轻应用程序管理诸多数据源的麻烦。

NOSQL-and-search-engine

NoSQL

从NoSQL的数据模型方面做一个区分:

  • Key-Value
    • 这是最基础的技术支撑,后续的产品都是基于Key-Value存储而发展起来的。但是Key-Value存储有一个很大的问题,即没有办法进行高效的范围查询。
  • Ordered Key-Value
    • 这是在Key-Value基础上的一个改进,Key是有序的,这样可以解决基于Key的范围查询的效率问题,不过在这个模型中,Value本身的内容和结构是由应用来负责解析和存储的,如果在多个应用中去使用的话,这种方式并不直观也不方便。
  • BigTable
    • BigTable是Google在2006发表的名为Bigtable:A Distributed Storage Systemfor Structured Data的论文中提到的一个产品,是一个结构化数据的分布式存储系统。
    • 从数据模型上讲,BigTable对Value进行了Schema的支持,Value是由多个Column Family组成,Column Family内部是Column,Column Family不能动态扩展,而Column Family内部的Column是可以动态扩展的。
  • Document,Full-Text Search
    • Document数据库有两个非常大的进步,
      • 一个是可以在Value中任意自定义复杂的Scheme,而不再仅仅是Map的嵌套;
      • 另一个是对索引方面的支持。而全文搜索则提供了对于数据内容的搜索的支持,当然,将全文搜索归属于NoSQL的范畴有些牵强。
  • Graph
    • 图(Graph)数据库可以看作是从有序Key-Value数据库发展而来的一个分支。主要是支持图结构的数据模型。

其中Full-Text Search和Graph在一些地方可能不归为NoSQL。

倒排索引

正排索引:

文章 关键词
Doc1 keyword1,keyword2,keyword4
Doc2 keyword1,keyword3,keyword5
Doc3 keyword2,keyword5,keyword8,keyword9
Doc4 keyword6,keyword7,keyword9

倒排索引:

关键词 文章
keyword1 Doc1,Doc2
keyword2 Doc1,Doc3
keyword3 Doc2
keyword4 Doc1
keyword5 Doc2,Doc3
keyword6 Doc4
keyword7 Doc4
keyword8 Doc3
keyword9 Doc3,Doc4

相对于正排索引,倒排索引是把原来作为值的内容拆分为索引的Key,而原来用作索引的Key则变成了值。搜索引擎比数据库的Like更高效的原因也在于倒排索引。

业务拆分

解决问题:大型网站为了应对日益复杂的业务场景,通过使用分而治之的手段将整个网站业务分成不同的产品线,如大型购物交易网站就会将首页、商铺、订单、买家、卖家等拆分成不同的产品线,分归不同的业务团队负责。

根据产品线划分,将一个网站拆分成许多不同的应用,每个应用独立部署维护。应用之间可以通过一个超链接建立关系(在首页上的导航链接每个都指向不同的应用地址),也可以通过消息队列进行数据分发,当然最多的还是通过访问同一个数据存储系统来构成一个关联的完整系统

Application-split

专库专用,数据垂直拆分

垂直拆分的意思是把数据库中不同的业务数据拆分到不同的数据库中。

数据库垂直拆分后的结构

这样的变化给带来的影响是什么呢?

应用需要配置多个数据源,这就增加了所需的配置,不过带来的是每个数据库连接池的隔离。 不同业务的数据从原来的一个数据库中拆分到了多个数据库中,那么就需要考虑如何处理原来单机中跨业务的事务。

  • 一种办法是使用分布式事务,其性能要明显低于之前的单机事务;
  • 而另一种办法就是去掉事务或者不去追求强事务支持,则原来在单库中可以使用的表关联的查询也就需要改变实现了。

对数据进行垂直拆分之后,解决了把所有业务数据放在一个数据库中的压力问题。并且也可以根据不同业务的特点进行更多优化。

垂直拆分后的单机遇到瓶颈,数据水平拆分

与数据垂直拆分对应的还有数据水平拆分。数据水平拆分就是把同一个表的数据拆到两个数据库中。 产生数据水平拆分的原因是某个业务的数据表的数据量或者更新量达到了单个数据库的瓶颈,这时就可以把这个表拆到两个或者多个数据库中。

  • 数据水平拆分与读写分离的区别是,读写分离解决的是读压力大的问题,对于数据量大或者更新量的情况并不起作用。
  • 数据水平拆分与数据垂直拆分的区别是,垂直拆分是把不同的表拆到不同的数据库中,而水平拆分是把同一个表拆到不同的数据库中。

水平拆分后给业务应用带来的影响:

  • 首先,访问用户信息的应用系统需要解决SQL路由的问题,因为现在用户信息分在了两个数据库中,需要在进行数据库操作时了解需要操作的数据在哪里。
  • 此外,主键的处理也会变得不同。原来依赖单个数据库的一些机制需要变化,例如原来使用Oracle的Sequence或者MySQL表上的自增字段的,现在不能简单地继续使用了。并且在不同的数据库中也不能直接使用一些数据库的限制来保证主键不重复了。
  • 最后,由于同一个业务的数据被拆分到了不同的数据库中,因此一些查询需要从两个数据库中取数据,如果数据量太大而需要分页,就会比较难处理了。

不过,一旦能够完成数据的水平拆分,将能够很好地应对数据量及写入量增长的情况。

拆分应用

根据业务的特性把应用拆开。

根据功能拆分应用

按功能拆分后的结构:

按功能拆分后的结构

分布式服务

解决问题:随着业务拆分越来越小,存储系统越来越庞大,应用系统的整体复杂度呈指数级增加,部署维护越来越困难。由于所有应用要和所有数据库系统连接,在数万台服务器规模的网站中,这些连接的数目是服务器规模的平方,导致存数据库接资源不足,拒绝服务。

既然每一个应用系统都需要执行许多相同的业务操作,比如用户管理、商品管理等,那么可以将这些共用的业务提取出来,独立部署。由这些可复用的业务连接数据库,提供共用业务服务,而应用系统只需要管理用户界面,通过分布式服务调用共用业务服务完成具体业务操作

Distributed-services

大型网站的架构演化到这里,基本上大多数的技术问题都得以解决,诸如跨数据中心的实时数据同步和具体网站业务相关的问题也都可以通过组合改进现有技术架构来解决。

既然大型网站架构解决了海量数据的管理和高并发事务的处理,那么就可以把这些解决方案应用到网站自身以外的业务上去(云服务)。

服务化结构:

服务化结构

  • 首先,业务功能之间的访问不仅是单机内部的方法调用了,还引入了远程的服务调用。
  • 其次,共享的代码不再是散落在不同的应用中了,这些实现被放在了各个服务中心。
  • 第三,数据库的连接也发生了一些变化,把与数据库的交互工作放到了服务中心,让前端的Web应用更加注重与浏览器交互的工作,而不必过多关注业务逻辑的事情。连接数据库的任务交给相应的业务服务中心了,这样可以降低数据库的连接数。而服务中心不仅把一些可以共用的之前散落在各个业务的代码集中了起来,并且能够使这些代码得到更好的维护。
  • 第四,通过服务化,无论是前端Web应用还是服务中心,都可以是由固定小团队来维护的系统,这样能够更好地保持稳定性,并能更好地控制系统本身的发展,况且稳定的服务中心日常发布的次数也远小于前端Web应用,因此这个方式也减小了不稳定的风险。

网站架构模式

分层

分层 功能
应用层 负责具体业务和视图展示
服务层 为应用层提供服务支持
数据层 提供数据存储访问服务
  • 通过分层,可以更好地将一个庞大的软件系统切分成不同的部分,便于分工合作开发和维护;各层之间具有一定的独立性,只要维持调用接口不变,各层可以根据具体问题独立演化发展而不需要其他层必须做出相应调整。
  • 分层架构也有一些挑战,就是必须合理规划层次边界和接口,在开发过程中,严格遵循分层架构的约束,禁止跨层次的调用(应用层直接调用数据层)及逆向调用(数据层调用服务层,或者服务层调用应用层)。
  • 大的分层结构内部还可以继续分层,如应用层可以再细分为视图层(美工负责)和业务逻辑层(工程师负责);服务层也可以细分为数据接口层(适配各种输入和输出的数据格式)和逻辑处理层。
  • 分层架构是逻辑上的,在物理部署上,三层结构可以部署在同一个物理机器上,但是随着网站业务的发展,必然需要对已经分层的模块分离部署,即三层结构分别部署在不同的服务器上,使网站拥有更多的计算资源以应对越来越多的用户访问。

虽然分层架构模式最初的目的是规划软件清晰的逻辑结构便于开发维护,但在网站的发展过程中,分层结构对网站支持高并发向分布式方向发展至关重要。因此在网站规模还很小的时候就应该采用分层的架构,这样将来网站做大时才能有更好地应对。

分割

如果说分层是将软件在横向方面进行切分,那么分割就是在纵向方面对软件进行切分。

网站越大,功能越复杂,服务和数据处理的种类也越多,将这些不同的功能和服务分割开来,包装成高内聚低耦合的模块单元

  • 一方面有助于软件的开发和维护
  • 另一方面,便于不同模块的分布式部署,提高网站的并发处理能力和功能扩展能力

比如在应用层,将不同业务进行分割,例如将购物、论坛、搜索、广告分割成不同的应用,由独立的团队负责,部署在不同的服务器上;在同一个应用内部,如果规模庞大业务复杂,会继续进行分割,比如购物业务,可以进一步分割成机票酒店业务、3C业务,小商品业务等更细小的粒度。而即使在这个粒度上,还是可以继续分割成首页、搜索列表、商品详情等模块,这些模块不管在逻辑上还是物理部署上,都可以是独立的。同样在服务层也可以根据需要将服务分割成合适的模块。

分布式

分层和分割的一个主要目的是为了切分后的模块便于分布式部署,即将不同模块部署在不同的服务器上,通过远程调用协同工作。

分布式意味着可以使用更多的计算机完成同样的功能,计算机越多,CPU、内存、存储资源也就越多,能够处理的并发访问和数据量就越大,进而能够为更多的用户提供服务。

但分布式在解决网站高并发问题的同时也带来了其他问题:

  • 首先,分布式意味着服务调用必须通过网络,这可能会对性能造成比较严重的影响
  • 其次,服务器越多,服务器宕机的概率也就越大,一台服务器宕机造成的服务不可用可能会导致很多应用不可访问,使网站可用性降低
  • 另外,数据在分布式的环境中保持数据一致性也非常困难,分布式事务也难以保证,这对网站业务正确性和业务流程有可能造成很大影响
  • 分布式还导致网站依赖错综复杂,开发管理维护困难。因此分布式设计要根据具体情况量力而行,切莫为了分布式而分布式

常用的分布式方案有以下几种:

  • 分布式应用和服务
    • 将分层和分割后的应用和服务模块分布式部署,除了可以改善网站性能和并发性、加快开发和发布速度、减少数据库连接资源消耗外;还可以使不同应用复用共同的服务,便于业务功能扩展。
  • 分布式静态资源
    • 网站的静态资源如JS,CSS,Logo图片等资源独立分布式部署,并采用独立的域名,即人们常说的动静分离。静态资源分布式部署可以减轻应用服务器的负载压力;通过使用独立域名加快浏览器并发加载的速度;由负责用户体验的团队进行开发维护有利于网站分工合作,使不同技术工种术业有专攻。
  • 分布式数据和存储
    • 大型网站需要处理以P为单位的海量数据,单台计算机无法提供如此大的存储空间,这些数据需要分布式存储。除了对传统的关系数据库进行分布式部署外,为网站应用而生的各种NoSQL产品几乎都是分布式的。
  • 分布式计算
    • 严格说来,应用、服务、实时数据处理都是计算,网站除了要处理这些在线业务,还有很大一部分用户没有直观感受的后台业务要处理,包括搜索引擎的索引构建、数据仓库的数据分析统计等。这些业务的计算规模非常庞大,目前网站普遍使用Hadoop及其MapReduce分布式计算框架进行此类批处理计算,其特点是移动计算而不是移动数据,将计算程序分发到数据所在的位置以加速计算和分布式计算。
  • 此外,还有可以支持网站线上服务器配置实时更新的分布式配置;分布式环境下实现并发和协同的分布式锁;支持云存储的分布式文件系统等。

集群

使用分布式虽然已经将分层和分割后的模块独立部署,但是对于用户访问集中的模块(比如网站的首页),还需要将独立部署的服务器集群化,即多台服务器部署相同应用构成一个集群,通过负载均衡设备共同对外提供服务。

因为服务器集群有更多服务器提供相同服务,因此可以提供更好的并发特性,当有更多用户访问的时候,只需要向集群中加入新的机器即可。
同时因为一个应用由多台服务器提供,当某台服务器发生故障时,负载均衡设备或者系统的失效转移机制会将请求转发到集群中其他服务器上,使服务器故障不影响用户使用。

缓存

  • CDN:即内容分发网络,部署在距离终端用户最近的网络服务商,用户的网络请求总是先到达他的网络服务商那里,在这里缓存网站的一些静态资源(较少变化的数据),可以就近以最快速度返回给用户,如视频网站和门户网站会将用户访问量大的热点内容缓存在CDN
  • 反向代理:反向代理属于网站前端架构的一部分,部署在网站的前端,当用户请求到达网站的数据中心时,最先访问到的就是反向代理服务器,这里缓存网站的静态资源,无需将请求继续转发给应用服务器就能返回给用户
  • 本地缓存:在应用服务器本地缓存着热点数据,应用程序可以在本机内存中直接访问数据,而无需访问数据库
  • 分布式缓存:大型网站的数据量非常庞大,即使只缓存一小部分,需要的内存空间也不是单机能承受的,所以除了本地缓存,还需要分布式缓存,将数据缓存在一个专门的分布式缓存集群中,应用程序通过网络通信访问缓存数据

使用缓存有两个前提条件:

  • 一是数据访问热点不均衡,某些数据会被更频繁的访问,这些数据应该放在缓存中;
  • 二是数据在某个时间段内有效,不会很快过期,否则缓存的数据就会因已经失效而产生脏读,影响结果的正确性。

网站应用中,缓存除了可以加快数据访问速度,还可以减轻后端应用和数据存储的负载压力,这一点对网站数据库架构至关重要,网站数据库几乎都是按照有缓存的前提进行负载能力设计的。

异步

在单一服务器内部可通过多线程共享内存队列的方式实现异步,处在业务操作前面的线程将输出写入到队列,后面的线程从队列中读取数据进行处理;在分布式系统中,多个服务器集群通过分布式消息队列实现异步,分布式消息队列可以看作内存队列的分布式部署。

异步架构是典型的生产者消费者模式,两者不存在直接调用,只要保持数据结构不变,彼此功能实现可以随意变化而不互相影响,这对网站扩展新功能非常便利。

除此之外,使用异步消息队列还有如下特性:

  • 提高系统可用性
    • 消费者服务器发生故障,数据会在消息队列服务器中存储堆积,生产者服务器可以继续处理业务请求,系统整体表现无故障。消费者服务器恢复正常后,继续处理消息队列中的数据。
  • 加快网站响应速度
    • 处在业务处理前端的生产者服务器在处理完业务请求后,将数据写入消息队列,不需要等待消费者服务器处理就可以返回,响应延迟减少。
  • 消除并发访问高峰
    • 用户访问网站是随机的,存在访问高峰和低谷,即使网站按照一般访问高峰进行规划和部署,也依然会出现突发事件,比如购物网站的促销活动,微博上的热点事件,都会造成网站并发访问突然增大,这可能会造成整个网站负载过重,响应延迟,严重时甚至会出现服务宕机的情况。使用消息队列将突然增加的访问请求数据放入消息队列中,等待消费者服务器依次处理,就不会对整个网站负载造成太大压力。

但需要注意的是,使用异步方式处理业务可能会对用户体验、业务流程造成影响,需要网站产品设计方面的支持。

冗余

要想保证在服务器宕机的情况下网站依然可以继续服务,不丢失数据,就需要一定程度的服务器冗余运行,数据冗余备份,这样当某台服务器宕机时,可以将其上的服务和数据访问转移到其他机器上。

访问和负载很小的服务也必须部署至少两台服务器构成一个集群,其目的就是通过冗余实现服务高可用。数据库除了定期备份,存档保存,实现冷备份外,为了保证在线业务高可用,还需要对数据库进行主从分离,实时同步实现热备份。

为了抵御地震、海啸等不可抗力导致的网站完全瘫痪,某些大型网站会对整个数据中心进行备份,全球范围内部署灾备数据中心。网站程序和数据实时同步到多个灾备数据中心。

自动化

通过减少人为干预,使发布过程自动化可有效减少故障。

发布过程包括诸多环节:

  • 自动化代码管理,代码版本控制、代码分支创建合并等过程自动化,开发工程师只要提交自己参与开发的产品代号,系统就会自动为其创建开发分支,后期会自动进行代码合并
  • 自动化测试,代码开发完成,提交测试后,系统自动将代码部署到测试环境,启动自动化测试用例进行测试,向相关人员发送测试报告,向系统反馈测试结果
  • 自动化安全检测,安全检测工具通过对代码进行静态安全扫描及部署到安全测试环境进行安全攻击测试,评估其安全性
  • 最后进行自动化部署,将工程代码自动部署到线上生产环境

网站需要对线上生产环境进行自动化监控,对服务器进行心跳检测,并监控其各项性能指标和应用程序的关键数据指标。

  • 如果发现异常、超出预设的阈值,就进行自动化报警,向相关人员发送报警信息,警告故障可能会发生
  • 在检测到故障发生后,系统会进行自动化失效转移,将失效的服务器从集群中隔离出去,不再处理系统中的应用请求
  • 待故障消除后,系统进行自动化失效恢复,重新启动服务,同步数据保证数据的一致性
  • 在网站遇到访问高峰,超出网站最大处理能力时,为了保证整个网站的安全可用,还会进行自动化降级,通过拒绝部分请求及关闭部分不重要的服务将系统负载降至一个安全的水平
  • 必要时,还需要自动化分配资源,将空闲资源分配给重要的服务,扩大其部署规模

安全

  • 通过密码和手机校验码进行身份认证
  • 登录、交易等操作需要对网络通信进行加密,网站服务器上存储的敏感数据如用户信息等也进行加密处理
  • 为了防止机器人程序滥用网络资源攻击网站,网站使用验证码进行识别
  • 对于常见的用于攻击网站的XSS攻击、SQL注入、进行编码转换等相应处理
  • 对于垃圾信息、敏感信息进行过滤
  • 对交易转账等重要操作根据交易模式和交易信息进行风险控制

大型网站核心架构要素

一般说来,除了当前的系统功能需求外,软件架构还需要关注性能、可用性、伸缩性、扩展性和安全性这5个架构要素,架构设计过程中需要平衡这5个要素之间的关系以实现需求和架构目标,也可以通过考察这些架构要素来衡量一个软件架构设计的优劣,判断其是否满足期望。

性能

衡量网站性能有一系列指标,重要的有响应时间、TPS、系统性能计数器等,通过测试这些指标以确定系统设计是否达到目标。 这些指标也是网站监控的重要参数,通过监控这些指标可以分析系统瓶颈,预测网站容量,并对异常指标进行报警,保障系统可用性。

因为性能问题几乎无处不在,所以优化网站性能的手段也非常多,从用户浏览器到数据库,影响用户请求的所有环节都可以进行性能优化。

  • 在浏览器端
    • 可以通过浏览器缓存、使用页面压缩、合理布局页面、减少Cookie传输等手段改善性能
    • 还可以使用CDN,将网站静态内容分发至离用户最近的网络服务商机房,使用户通过最短访问路径获取数据。可以在网站机房部署反向代理服务器,缓存热点文件,加快请求响应速度,减轻应用服务器负载压力
  • 在应用服务器端
    • 可以使用服务器本地缓存和分布式缓存,通过缓存在内存中的热点数据处理用户请求,加快请求处理过程,减轻数据库负载压力
    • 也可以通过异步操作将用户请求发送至消息队列等待后续任务处理,而当前请求直接返回响应给用户
    • 在网站有很多用户高并发请求的情况下,可以将多台应用服务器组成一个集群共同对外服务,提高整体处理能力,改善性能
  • 在代码层面
    • 可以通过使用多线程、改善内存管理等手段优化性能
  • 在数据库服务器端
    • 索引、缓存、SQL优化等性能优化手段都已经比较成熟
    • NoSQL数据库通过优化数据模型、存储结构、伸缩特性等手段在性能方面的优势也日趋明显

对于网站而言,性能符合预期仅仅是必要条件,因为无法预知网站可能会面临的访问压力,所以必须要考察系统在高并发访问情况下,超出负载设计能力的情况下可能会出现的性能问题 网站需要长时间持续运行,还必须保证系统在持续运行且访问压力不均匀的情况下保持稳定的性能特性

网站性能测试

性能测试指标

响应时间

指应用执行一个操作需要的时间,包括从发出请求开始到收到最后响应数据所需要的时间。响应时间是系统最重要的性能指标,直观地反映了系统的“快慢”。

常用系统操作响应时间表:

操 作 响应时间
打开一个网站 几秒
在数据库中查询一条记录(有索引) 十几毫秒
机械磁盘一次寻址定位 4毫秒
从机械磁盘顺序读取1MB数据 2毫秒
从SSD磁盘顺序读取1MB数据 0.3毫秒
从远程分布式缓存Redis读取一个数据 0.5毫秒
从内存中读取1MB数据 十几微秒
Java程序本地方法调用 几微秒
网络传输2KB数据 1微秒

测试程序通过模拟应用程序,记录收到响应和发出请求之间的时间差来计算系统响应时间。 但是记录及获取系统时间这个操作也需要花费一定的时间,如果测试目标操作本身需要花费的时间极少,比如几微秒,那么测试程序就无法测试得到系统的响应时间。实践中通常采用的办法是重复请求,比如一个请求操作重复执行一万次,测试一万次执行需要的总响应时间之和,然后除以一万,得到单次请求的响应时间。

并发数

指系统能够同时处理请求的数目,这个数字也反映了系统的负载特性。对于网站而言,并发数即网站并发用户数,指同时提交请求的用户数目。

网站系统用户数»网站在线用户数»网站并发用户数

在网站产品设计初期,产品经理和运营人员就需要规划不同发展阶段的网站系统用户数,并以此为基础,根据产品特性和运营手段,推算在线用户数和并发用户数。这些指标将成为系统非功能设计的重要依据。

测试程序通过多线程模拟并发用户的办法来测试系统的并发处理能力,为了真实模拟用户行为,测试程序并不是启动多线程然后不停地发送请求,而是在两次请求之间加入一个随机等待时间,这个时间被称作思考时间。

吞吐量

指单位时间内系统处理的请求数量,体现系统的整体处理能力。 对于网站,可以用“请求数/秒”或是“页面数/秒”来衡量,也可以用“访问人数/天”或是“处理的业务数/小时”等来衡量。TPS(每秒事务数)是吞吐量的一个常用量化指标,此外还有HPS(每秒HTTP请求数)、QPS(每秒查询数)等。

在系统并发数由小逐渐增大的过程中(这个过程也伴随着服务器系统资源消耗逐渐增大),系统吞吐量先是逐渐增加,达到一个极限后,随着并发数的增加反而下降,达到系统崩溃点后,系统资源耗尽,吞吐量为零。

而这个过程中,响应时间则是先保持小幅上升,到达吞吐量极限后,快速上升,到达系统崩溃点后,系统失去响应。

性能计数器

它是描述服务器或操作系统性能的一些数据指标。包括System Load、对象与线程数、内存使用、CPU使用、磁盘与网络I/O等指标。 这些指标也是系统监控的重要参数,对这些指标设置报警阈值,当监控系统发现性能计数器超过阈值时,就向运维和开发人员报警,及时发现处理系统异常。

System Load即系统负载,指当前正在被CPU执行和等待被CPU执行的进程数目总和,是反映系统忙闲程度的重要指标。

  • 多核CPU的情况下,完美情况是所有CPU都在使用,没有进程在等待处理,所以Load的理想值是CPU的数目。
  • 当Load值低于CPU数目的时候,表示CPU有空闲,资源存在浪费;
  • 当Load值高于CPU数目的时候,表示进程在排队等待CPU调度,表示系统资源不足,影响应用程序的执行性能。

在Linux系统中使用top命令查看,该值是三个浮点数,表示最近1分钟,10分钟,15分钟的运行队列平均进程数。

top - 23:35:04 up 5 days, 10:46,  2 user,  load average: 2.62, 2.70, 2.65
性能测试方法

性能测试是一个不断对系统增加访问压力,以获得系统性能指标、最大负载能力、最大压力承受能力的过程。

性能测试是一个总称,具体可细分为性能测试、负载测试、压力测试、稳定性测试。

  • 性能测试:以系统设计初期规划的性能指标为预期目标,对系统不断施加压力,验证系统在资源可接受范围内,是否能达到性能预期。
  • 负载测试:对系统不断地增加并发请求以增加系统压力,直到系统的某项或多项性能指标达到安全临界值,如某种资源已经呈饱和状态,这时继续对系统施加压力,系统的处理能力不但不能提高,反而会下降。
  • 压力测试:超过安全负载的情况下,对系统继续施加压力,直到系统崩溃或不能再处理任何请求,以此获得系统最大压力承受能力。
  • 稳定性测试:被测试系统在特定硬件、软件、网络环境条件下,给系统加载一定业务压力,使系统运行一段较长时间,以此检测系统是否稳定。在不同生产环境、不同时间点的请求压力是不均匀的,呈波浪特性,因此为了更好地模拟生产环境,稳定性测试也应不均匀地对系统施加压力。

性能测试曲线:

Performance-test-curve

  • 在开始阶段,随着并发请求数目的增加,系统使用较少的资源就达到较好的处理能力(a~b段),这一段是网站的日常运行区间,网站的绝大部分访问负载压力都集中在这一段区间,被称作性能测试
    • 测试目标是评估系统性能是否符合需求及设计目标
  • 随着压力的持续增加,系统处理能力增加变缓,直到达到一个最大值(c点),这是系统的最大负载点,这一段被称作负载测试
    • 测试目标是评估当系统因为突发事件超出日常访问压力的情况下,保证系统正常运行情况下能够承受的最大访问负载压力
  • 超过这个点后,再增加压力,系统的处理能力反而下降,而资源消耗却更多,直到资源消耗达到极限(d点),这个点可以看作是系统的崩溃点,超过这个点继续加大并发请求数目,系统不能再处理任何请求
    • 这一段被称作压力测试,测试目标是评估可能导致系统崩溃的最大访问负载压力

并发用户访问响应时间曲线:

Concurrent-user-access-response-time-curve

性能测试报告

Performance-test-report

性能优化策略

性能分析

排查一个网站的性能瓶颈和排查一个程序的性能瓶颈的手法基本相同:

  • 检查请求处理的各个环节的日志,分析哪个环节响应时间不合理、超过预期;
  • 然后检查监控数据,分析影响性能的主要因素是内存、磁盘、网络、还是CPU,是代码问题还是架构设计不合理,或者系统资源确实不足。

性能优化

根据网站分层架构,可分为Web前端性能优化、应用服务器性能优化、存储服务器性能优化3大类。

Web前端性能优化

一般说来Web前端指网站业务逻辑之前的部分,包括浏览器加载、网站视图模型、图片服务、CDN服务等,主要优化手段有优化浏览器访问、使用反向代理、CDN等。

浏览器访问优化

减少http请求

HTTP协议是无状态的应用层协议,意味着每次HTTP请求都需要建立通信链路、进行数据传输,而在服务器端,每个HTTP都需要启动独立的线程去处理。 这些通信和服务的开销都很昂贵,减少HTTP请求的数目可有效提高访问性能。

减少HTTP的主要手段是合并CSS、合并JavaScript、合并图片。 将浏览器一次访问需要的JavaScript、CSS合并成一个文件,这样浏览器就只需要一次请求。图片也可以合并,多张图片合并成一张,如果每张图片都有不同的超链接,可通过CSS偏移响应鼠标点击操作,构造不同的URL。

使用浏览器缓存

对一个网站而言,静态资源文件更新的频率都比较低,而这些文件又几乎是每次HTTP请求都需要的,如果将这些文件缓存在浏览器中,可以极好地改善性能。

通过设置HTTP头中Cache-Control和Expires的属性,可设定浏览器缓存,缓存时间可以是数天,甚至是几个月。

在某些时候,静态资源文件变化需要及时应用到客户端浏览器,这种情况,可通过改变文件名实现,即不是更新文件内容,而是生成一个新的文件并更新HTML文件中的引用。

使用浏览器缓存策略的网站在更新静态资源时,应采用批量更新的方法,一个文件一个文件逐步更新,并有一定的间隔时间,以免用户浏览器突然大量缓存失效,集中更新缓存,造成服务器负载骤增、网络堵塞的情况。

启用压缩

在服务器端对文件进行压缩,在浏览器端对文件解压缩,可有效减少通信传输的数据量。
文本文件的压缩效率可达80%以上,因此HTML、CSS、JavaScript文件启用GZip压缩可达到较好的效果。
但是压缩对服务器和浏览器产生一定的压力,在通信带宽良好,而服务器资源不足的情况下要权衡考虑。

CSS放在页面最上面、JavaScript放在页面最下面

  • 浏览器会在下载完全部CSS之后才对整个页面进行渲染,因此最好的做法是将CSS放在页面最上面,让浏览器尽快下载CSS。
  • JavaScript则相反,浏览器在加载JavaScript后立即执行,有可能会阻塞整个页面,造成页面显示缓慢,因此JavaScript最好放在页面最下面。
    • 但如果页面解析时就需要用到JavaScript,这时放在底部就不合适了。

减少Cookie传输

  • 一方面,Cookie包含在每次请求和响应中,太大的Cookie会严重影响数据传输,因此哪些数据需要写入Cookie需要慎重考虑,尽量减少Cookie中传输的数据量。
  • 另一方面,对于某些静态资源的访问,如CSS、Script等,发送Cookie没有意义,可以考虑静态资源使用独立域名访问,避免请求静态资源时发送Cookie,减少Cookie传输的次数。
CDN加速

CDN(Content Distribute Network,内容分发网络)的本质仍然是一个缓存,而且将数据缓存在离用户最近的地方, 由于CDN部署在网络运营商的机房,这些运营商又是终端用户的网络服务提供商,因此用户请求路由的第一跳就到达了CDN服务器, 当CDN中存在浏览器请求的资源时,从CDN直接返回给浏览器,最短路径返回响应,加快用户访问速度,减少数据中心负载压力。

CDN其实就是一种网络缓存技术,能够把一些相对稳定的资源放到距离最终用户较近的机房,一方面可以节省整个广域网的带宽消耗, 另外一方面可以提升用户的访问速度,改进用户体验。一般把一些相对静态的文件(例如图片、视频、JS脚本、一些页面框架)放在CDN中。

CDN系统

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

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

对CNAME再次进行解析,得到实际IP地址。在这次的解析中,会使用全局负载均衡DNS解析,也就是需要返回具体IP地址, 需要根据地理位置信息以及所在的ISP来确定返回的结果,这个过程才能让身处不同地域、连接不同接入商的用户得到最适合自己访问的CDN地址,才能做到就近访问,从而提升速度。

CDN中的几个关键技术:

全局调度

全局调度是完成用户就近访问的第一步,需要根据用户地域、接入运营商以及CDN机房的负载情况去调度。 前面两个调度因素需要一个尽可能精准的IP地址库,这是正确调用的前提(误识别的IP地址到地理位置的对应可能会把东北的用户调度到华南的站点去), 当然,做到100%的精准是不现实的。IP地址库的维护是一个持续和变化的过程,并且调度的策略随着CDN机房的增加也会变化。

例如,不可能在所有城市都设置CDN机房,假设刚开始河南整个省份没有CDN机房,可能河南靠北的城市使用天津的CDN,同时河南靠南的城市使用湖北的CDN会比较好, 而如果后来在郑州市建设了CDN机房的话,那么原来的调度策略就会修改了。 CDN的负载也是调度中的一个影响因素,举例来说,如果一个CDN机房距离你的位置比较近,但是它的负载已经很高,响应很慢,那么你的请求送到距离稍远的CDN机房反而会更快。

缓存技术

如果用户请求的内容不在CDN中的话,CDN会回到源站去加载内容,然后返回给用户。所以,如果CDN机房的请求命中率不高的话,那么起到的加速效果也是相对有限的。

要提升命中率,就需要CDN机房中有尽可能全面的数据,这要求CDN机房的缓存容量要足够大, 可以使用“内存+SSD+机器硬盘”的混合存储方式来提升整体的缓存容量,并且需要做好冷热数据的交换,在提升命中率时也尽量降低缓存的响应时间。

此外,当CDN的Cache没有命中要回源加载数据时,合并同样数据的请求也是一个很重要的优化,这样可以减少重复的请求,降低源站的压力。

最后,新增、变更数据后的CDN预加载也是一个提升命中率的办法。也就是在没有请求进来时,CDN主动去加载数据,做好准备。当然这个主动加载一般也需要源站有一个通知过来。

内容分发

这里提到的内容分发主要是对内容全部在CDN上不用回源的数据的管理和分发,例如一些静态页面等。 具体做法是在内容管理系统中进行编辑修改后,通过分发系统分发到各个CDN的节点上。分发的效率以及对分发文件一致性、正确性的校验是需要关注的点。

带宽优化

CDN提供了内容加速,很多请求和流量都压到了CDN上,那么如何能够比较有效地节省带宽会是一个很重要的事情,因为这直接关系到流量成本。 优化的思路是只返回必要的数据、用更好的压缩算法等。

在CDN的应用中,从传统意义上来讲,主要是把用户需要访问的内容放到离用户近的地方。可以发现大部分流量是从源站到CDN机房的流量, 也可以利用CDN机房距离目标用户近的地点,让一些上传的工作从CDN接入,然后再从CDN传到源站,这一方面可以提升用户的上传速度,另一方面也很好地利用了从CDN机房到源站的上行带宽。

反向代理
  • 和传统代理服务器可以保护浏览器安全一样,反向代理服务器也具有保护网站安全的作用,来自互联网的访问请求必须经过代理服务器,相当于在Web服务器和可能的网络攻击之间建立了一个屏障。
  • 除了安全功能,代理服务器也可以通过配置缓存功能加速Web请求。
    • 当用户第一次访问静态内容的时候,静态内容就被缓存在反向代理服务器上,这样当其他用户访问该静态内容的时候,就可以直接从反向代理服务器返回,加速Web请求响应速度,减轻Web服务器负载压力。
    • 事实上,有些网站会把动态内容也缓存在代理服务器上,当这些动态内容有变化时,通过内部通知机制通知反向代理缓存失效,反向代理会重新加载最新的动态内容再次缓存起来。
  • 此外,反向代理也可以实现负载均衡的功能,而通过负载均衡构建的应用集群可以提高系统总体处理能力,进而改善网站高并发情况下的性能。

应用服务器性能优化

应用服务器就是处理网站业务的服务器,网站的业务代码都部署在这里,是网站开发最复杂,变化最多的地方,优化手段主要有缓存、集群、异步等。

分布式缓存

回顾网站架构演化历程,当网站遇到性能瓶颈时,第一个想到的解决方案就是使用缓存。在整个网站应用中,缓存几乎无所不在,既存在于浏览器,也存在于应用服务器和数据库服务器;既可以对数据缓存,也可以对文件缓存,还可以对页面片段缓存。合理使用缓存,对网站性能优化意义重大。

网站性能优化第一定律:优先考虑使用缓存优化性能。

缓存的基本原理

缓存指将数据存储在相对较高访问速度的存储介质中,以供系统处理。

  • 一方面缓存访问速度快,可以减少数据访问的时间
  • 另一方面如果缓存的数据是经过计算处理得到的,那么被缓存的数据无需重复计算即可直接使用,因此缓存还起到减少计算时间的作用。

缓存的本质是一个内存Hash表,网站应用中,数据缓存以一对Key、Value的形式存储在内存Hash表中。Hash表数据读写的时间复杂度为O(1)

HashTable

缓存主要用来存放那些读写比很高、很少变化的数据,应用程序读取数据时,先到缓存中读取,如果读取不到或数据已失效,再访问数据库,并将数据写入缓存

合理使用缓存

过分依赖低可用的缓存系统、不恰当地使用缓存的数据访问特性:

  • 频繁修改的数据
    • 一般说来,数据的读写比在2:1以上,即写入一次缓存,在数据更新前至少读取两次,缓存才有意义。实践中,这个读写比通常非常高,缓存以后可能会被读取数百万次。
  • 没有热点的访问
    • 缓存使用内存作为存储,内存资源宝贵而有限,不可能将所有数据都缓存起来,只能将最新访问的数据缓存起来,而将历史数据清理出缓存。
    • 。如果应用系统访问数据没有热点,不遵循二八定律,即大部分数据访问并没有集中在小部分数据上,那么缓存就没有意义,因为大部分数据还没有被再次访问就已经被挤出缓存了。
  • 数据不一致与脏读
    • 一般会对缓存的数据设置失效时间,一旦超过失效时间,就要从数据库中重新加载。因此应用要容忍一定时间的数据不一致,在互联网应用中,这种延迟通常是可以接受的,但是具体应用仍需慎重对待。
    • 还有一种策略是数据更新时立即更新缓存,不过这也会带来更多系统开销和事务一致性的问题。
  • 缓存可用性
    • 缓存是为提高数据读取性能的,缓存数据丢失或者缓存不可用不会影响到应用程序的处理——它可以从数据库直接获取数据。
    • 但是随着业务的发展,缓存会承担大部分数据访问的压力,所以当缓存服务崩溃时,数据库会因为完全不能承受如此大的压力而宕机,进而导致整个网站不可用。这种情况被称作缓存雪崩,发生这种故障,甚至不能简单地重启缓存服务器和数据库服务器来恢复网站访问。
    • 实践中,有的网站通过缓存热备等手段提高缓存可用性:当某台缓存服务器宕机时,将缓存访问切换到热备服务器上。但是这种设计显然有违缓存的初衷,缓存根本就不应该被当做一个可靠的数据源来使用。
    • 通过分布式缓存服务器集群,将缓存数据分布到集群多台服务器上可在一定程度上改善缓存的可用性。当一台缓存服务器宕机的时候,只有部分缓存数据丢失,重新从数据库加载这部分数据不会对数据库产生很大影响。
  • 缓存预热
    • 缓存中存放的是热点数据,热点数据又是缓存系统利用LRU(最近最久未用算法)对不断访问的数据筛选淘汰出来的,这个过程需要花费较长的时间。
    • 新启动的缓存系统如果没有任何数据,在重建缓存数据的过程中,系统的性能和数据库负载都不太好,那么最好在缓存系统启动时就把热点数据加载好,这个缓存预加载手段叫作缓存预热(warm up)。
    • 对于一些元数据如城市地名列表、类目信息,可以在启动时加载数据库中全部数据到缓存进行预热。
  • 缓存穿透
    • 如果因为不恰当的业务、或者恶意攻击持续高并发地请求某个不存在的数据,由于缓存没有保存该数据,所有的请求都会落到数据库上,会对数据库造成很大压力,甚至崩溃。
    • 一个简单的对策是将不存在的数据也缓存起来(其value值为null)。
分布式缓存架构

分布式缓存指缓存部署在多个服务器组成的集群中,以集群方式提供缓存服务,其架构方式有两种:

  • 一种是以JBoss Cache为代表的需要更新同步的分布式缓存
  • 一种是以Memcached为代表的不互相通信的分布式缓存

JBoss Cache

JBoss Cache的分布式缓存在集群中所有服务器中保存相同的缓存数据,当某台服务器有缓存数据更新的时候,会通知集群中其他机器更新缓存数据或清除缓存数据,
JBoss Cache通常将应用程序和缓存部署在同一台服务器上,应用程序可从本地快速获取缓存数据,但是这种方式带来的问题是缓存数据的数量受限于单一服务器的内存空间,而且当集群规模较大的时候,缓存更新信息需要同步到集群所有机器,其代价惊人。

需要更新同步的JBoss Cache:

JBoss-Cache

Memcached

大型网站需要缓存的数据量一般都很庞大,可能会需要数TB的内存做缓存,这时候就需要另一种分布式缓存

Memcached采用一种集中式的缓存集群管理,也被称作互不通信的分布式架构方式。 缓存与应用分离部署,缓存系统部署在一组专门的服务器上,应用程序通过一致性Hash等路由算法选择缓存服务器远程访问缓存数据,缓存服务器之间不通信,缓存集群的规模可以很容易地实现扩容,具有良好的可伸缩性。

不互相通信的Memcached:

Memcached-not-communicating-with-each-other

简单的通信协议:

远程通信设计需要考虑两方面的要素:

  • 一是通信协议,即选择TCP协议还是UDP协议,抑或HTTP协议;
  • 一是通信序列化协议,数据传输的两端,必须使用彼此可识别的数据序列化方式才能使通信得以完成,如XML、JSON等文本序列化协议,或者Google Protobuffer等二进制序列化协议。

Memcached使用TCP协议(UDP也支持)通信,其序列化协议则是一套基于文本的自定义协议,非常简单,以一个命令关键字开头,后面是一组命令操作数。 例如读取一个数据的命令协议是get<key>。Memcached以后,许多NoSQL产品都借鉴了或直接支持这套协议。

高效的内存管理:

内存管理中一个令人头痛的问题就是内存碎片管理。操作系统、虚拟机垃圾回收在这方面想了许多办法:压缩、复制等。

Memcached使用了一个非常简单的办法——固定空间分配。Memcached将内存空间分为一组slab,每个slab里又包含一组chunk,同一个slab里的每个chunk的大小是固定的,拥有相同大小chunk的slab被组织在一起,叫作slab_class。
存储数据时根据数据的Size大小,寻找一个大于Size的最小chunk将数据写入。这种内存管理方式避免了内存碎片管理的问题,内存的分配和释放都是以chunk为单位的。
和其他缓存一样,Memcached采用LRU算法释放最近最久未被访问的数据占用的空间,释放的chunk被标记为未用,等待下一个合适大小数据的写入。

当然这种方式也会带来内存浪费的问题。数据只能存入一个比它大的chunk里,而一个chunk只能存一个数据,其他空间被浪费了。 如果启动参数配置不合理,浪费会更加惊人,发现没有缓存多少数据,内存空间就用尽了。

互不通信的服务器集群架构:

参看《数据存储伸缩性架构设计》部分

异步操作

使用消息队列将调用异步化,可改善网站的扩展性。事实上,使用消息队列还可改善网站系统的性能

不使用消息队列服务器:

servers-without-mq

使用消息队列服务器:

servers-with-mq

  • 在不使用消息队列的情况下,用户的请求数据直接写入数据库,在高并发的情况下,会对数据库造成巨大的压力,同时也使得响应延迟加剧。
  • 在使用消息队列后,用户请求的数据发送给消息队列后立即返回,再由消息队列的消费者进程(通常情况下,该进程通常独立部署在专门的服务器集群上)从消息队列中获取数据,异步写入数据库。由于消息队列服务器处理速度远快于数据库(消息队列服务器也比数据库具有更好的伸缩性),因此用户的响应延迟可得到有效改善。

消息队列具有很好的削峰作用——即通过异步处理,将短时间高并发产生的事务消息存储在消息队列中,从而削平高峰期的并发事务。

在电子商务网站促销活动中,合理使用消息队列,可有效抵御促销活动刚开始大量涌入的订单对系统造成的冲击。

使用消息队列消除并发访问高峰:

mq-eliminates-concurrent-peaks

需要注意的是,由于数据写入消息队列后立即返回给用户,数据在后续的业务校验、写数据库等操作可能失败,因此在使用消息队列进行业务异步处理后,需要适当修改业务流程进行配合。
如订单提交后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单,甚至商品出库后,再通过电子邮件或SMS消息通知用户订单成功,以免交易纠纷。

使用集群

在网站高并发访问的场景下,使用负载均衡技术为一个应用构建一个由多台服务器组成的服务器集群,将并发访问请求分发到多台服务器上处理,避免单一服务器因负载压力过大而响应缓慢,使用户请求具有更好的响应延迟特性

Use-load-balancing-to-improve-performance

三台Web服务器共同处理来自用户浏览器的访问请求,这样每台Web服务器需要处理的http请求只有总并发请求数的三分之一,根据性能测试曲线,使服务器的并发请求数目控制在最佳运行区间,获得最佳的访问请求延迟。

代码优化
多线程

多用户并发访问是网站的基本需求,大型网站的并发用户数会达到数万,单台服务器的并发用户也会达到数百。

从资源利用的角度看,使用多线程的原因主要有两个:IO阻塞与多CPU。

*当前线程进行IO处理的时候,会被阻塞释放CPU以等待IO操作完成,由于IO操作(不管是磁盘IO还是网络IO)通常都需要较长的时间,这时CPU可以调度其他的线程进行处理。理想的系统Load是既没有进程(线程)等待也没有CPU空闲,利用多线程IO阻塞与执行交替进行,可最大限度地利用CPU资源 *使用多线程的另一个原因是服务器有多个CPU,在这个连手机都有四核CPU的时代,除了最低配置的虚拟机,一般数据中心的服务器至少16核CPU,要想最大限度地使用这些CPU,必须启动多线程

启动线程数=[任务执行时间/(任务执行时间(IO等待时间)]]CPU内核数

最佳启动线程数和CPU内核数量成正比,和IO阻塞时间成反比。

  • 如果任务都是CPU计算型任务,那么线程数最多不超过CPU内核数,因为启动再多线程,CPU也来不及调度;
  • 相反如果是任务需要等待磁盘操作,网络响应,那么多启动线程有助于提高任务并发度,提高系统吞吐能力,改善系统性能。

编程上,解决线程安全的主要手段有如下几点:

  • 将对象设计为无状态对象
    • 所谓无状态对象是指对象本身不存储状态信息(对象无成员变量,或者成员变量也是无状态对象),这样多线程并发访问的时候就不会出现状态不一致,Java Web开发中常用的Servlet对象就设计为无状态对象,可以被应用服务器多线程并发调用处理用户请求。而Web开发中常用的贫血模型对象都是些无状态对象。不过从面向对象设计的角度看,无状态对象是一种不良设计。
  • 使用局部对象
    • 即在方法内部创建对象,这些对象会被每个进入该方法的线程创建,除非程序有意识地将这些对象传递给其他线程,否则不会出现对象被多线程并发访问的情形。
  • 并发访问资源时使用锁
    • 即多线程访问资源的时候,通过锁的方式使多线程并发操作转化为顺序操作,从而避免资源被并发修改。随着操作系统和编程语言的进步,出现各种轻量级锁,使得运行期线程获取锁和释放锁的代价都变得更小,但是锁导致线程同步顺序执行,可能会对系统性能产生严重影响。
资源复用

系统运行时,要尽量减少那些开销很大的系统资源的创建和销毁,比如数据库连接、网络通信连接、线程、复杂对象等。

从编程角度,资源复用主要有两种模式:单例(Singleton)和对象池(Object Pool)。

  • 由于目前Web开发中主要使用贫血模式,从Service到Dao都是些无状态对象,无需重复创建,使用单例模式也就自然而然了
  • Java开发常用的对象容器Spring默认构造的对象都是单例(需要注意的是Spring的单例是Spring容器管理的单例

对象池模式通过复用对象实例,减少对象创建和资源消耗。

  • 对于数据库连接对象,每次创建连接,数据库服务端都需要创建专门的资源以应对,因此频繁创建关闭数据库连接,对数据库服务器而言是灾难性的,同时频繁创建关闭连接也需要花费较长的时间。因此在实践中,应用程序的数据库连接基本都使用连接池(Connection Pool)的方式。数据库连接对象创建好以后,将连接对象放入对象池容器中,应用程序要连接的时候,就从对象池中获取一个空闲的连接使用,使用完毕再将该对象归还到对象池中即可,不需要创建新的连接。
  • 所谓的连接池、线程池,本质上都是对象池,即连接、线程都是对象,池管理方式也基本相同。
数据结构

Hash表的读写性能在很大程度上依赖HashCode的随机性,即HashCode越随机散列,Hash表的冲突就越少,读写性能也就越高, 目前比较好的字符串Hash散列算法有Time33算法,即对字符串逐字符迭代乘以33,求得Hash值,算法原型为:

hash(i) = hash(i 1) * 33 + str[i]

for (int i = 0; i < len; i++) { 
    hash = hash * 33 + Integer.valueOf(str[i]); 
} 

Time33虽然可以较好地解决冲突,但是有可能相似字符串的HashCode也比较接近。
这在某些应用场景是不能接受的,这种情况下,一个可行的方案是对字符串取信息指纹,再对信息指纹求HashCode,由于字符串微小的变化就可以引起信息指纹的巨大不同,因此可以获得较好的随机散列,

MD5-hash

垃圾回收

JVM分代垃圾回收机制:

JVM-generation-GC

在JVM分代垃圾回收机制中,将应用程序可用的堆空间分为年轻代(Young Generation)和年老代(Old Generation),又将年轻代分为Eden区(Eden Space)、From区和To区。
新建对象总是在Eden区中被创建,当Eden区空间已满,就触发一次Young GC(Garbage Collection,垃圾回收),将还被使用的对象复制到From区,这样整个Eden区都是未被使用的空间,可供继续创建对象, 当Eden区再次用完,再触发一次Young GC,将Eden区和From区还在被使用的对象复制到To区,下一次Young GC则是将Eden区和To区还被使用的对象复制到From区。
因此,经过多次Young GC,某些对象会在From区和To区多次复制,如果超过某个阈值对象还未被释放,则将该对象复制到Old Generation。
如果Old Generation空间也已用完,那么就会触发Full GC,即所谓的全量回收,全量回收会对系统性能产生较大影响,因此应根据系统业务特点和对象生命周期,合理设置Young Generation和Old Generation大小,尽量减少Full GC。
事实上,某些Web应用在整个运行期间可以做到从不进行Full GC。

存储性能优化

B+树 vs. LSM树

由于传统的机械磁盘具有快速顺序读写、慢速随机读写的访问特性,这个特性对磁盘存储结构和算法的选择影响甚大。

为了改善数据访问特性,文件系统或数据库系统通常会对数据排序后存储,加快数据检索速度,这就需要保证数据在不断更新、插入、删除后依然有序,传统关系数据库的做法是使用B+树

B+树原理示意图:

b+tree

B+树是一种专门针对磁盘存储而优化的N叉排序树,以树节点为单位存储在磁盘中,从根开始查找所需数据所在的节点编号和磁盘位置,将其加载到内存中然后继续查找,直到找到所需的数据。

目前数据库多采用两级索引的B+树,树的层次最多三层。因此可能需要5次磁盘访问才能更新一条记录(三次磁盘访问获得数据索引及行ID,然后再进行一次数据文件读操作及一次数据文件写操作)。

但是由于每次磁盘访问都是随机的,而传统机械硬盘在数据随机访问时性能较差,每次数据访问都需要多次访问磁盘影响数据访问性能。

目前许多NoSQL产品采用LSM树作为主要数据结构:

LSM-tree

LSM树可以看作是一个N阶合并树。数据写操作(包括插入、修改、删除)都在内存中进行,并且都会创建一个新记录(修改会记录新的数据值,而删除会记录一个删除标志),这些数据在内存中仍然还是一棵排序树,当数据量超过设定的内存阈值后,会将这棵排序树和磁盘上最新的排序树合并。当这棵排序树的数据量也超过设定阈值后,和磁盘上下一级的排序树合并。合并过程中,会用最新更新的数据覆盖旧的数据(或者记录为不同版本)。

在需要进行读操作时,总是从内存中的排序树开始搜索,如果没有找到,就从磁盘上的排序树顺序查找。

在LSM树上进行一次数据更新不需要磁盘访问,在内存即可完成,速度远快于B+树。当数据访问以写操作为主,而读操作则集中在最近写入的数据上时,使用LSM树可以极大程度地减少磁盘的访问次数,加快访问速度。

作为存储结构,B+树不是关系数据库所独有的,NoSQL数据库也可以使用B+树。同理,关系数据库也可以使用LSM,而且随着SSD硬盘的日趋成熟及大容量持久存储的内存技术的出现,相信B+树这一“古老”的存储结构会再次焕发青春。

RAID vs. HDFS
RAID

RAID(廉价磁盘冗余阵列)技术主要是为了改善磁盘的访问延迟,增强磁盘的可用性和容错能力。

常用RAID技术原理图:

RAID

RAID0

数据在从内存缓冲区写入磁盘时,根据磁盘数量将数据分成N份,这些数据同时并发写入N块磁盘,使得数据整体写入速度是一块磁盘的N倍。读取时也一样,因此RAID0具有极快的数据读写速度,但是RAID0不做数据备份,N块磁盘中只要有一块损坏,数据完整性就被破坏,所有磁盘的数据都会损坏。

RAID1

数据在写入磁盘时,将一份数据同时写入两块磁盘,这样任何一块磁盘损坏都不会导致数据丢失,插入一块新磁盘就可以通过复制数据的方式自动修复,具有极高的可靠性。

RAID10

结合RAID0和RAID1两种方案,将所有磁盘平均分成两份,数据同时在两份磁盘写入,相当于RAID1,但是在每一份磁盘里面的N/2块磁盘上,利用RAID0技术并发读写,既提高可靠性又改善性能,不过RAID10的磁盘利用率较低,有一半的磁盘用来写备份数据。

RAID3

一般情况下,一台服务器上不会出现同时损坏两块磁盘的情况,在只损坏一块磁盘的情况下,如果能利用其他磁盘的数据恢复损坏磁盘的数据,这样在保证可靠性和性能的同时,磁盘利用率也得到大幅提升。

在数据写入磁盘的时候,将数据分成N-1份,并发写入N-1块磁盘,并在第N块磁盘记录校验数据,任何一块磁盘损坏(包括校验数据磁盘),都可以利用其他N-1块磁盘的数据修复。

但是在数据修改较多的场景中,修改任何磁盘数据都会导致第N块磁盘重写校验数据,频繁写入的后果是第N块磁盘比其他磁盘容易损坏,需要频繁更换,所以RAID3很少在实践中使用。

RAID5

相比RAID3,方案RAID5被更多地使用。

RAID5和RAID3很相似,但是校验数据不是写入第N块磁盘,而是螺旋式地写入所有磁盘中。这样校验数据的修改也被平均到所有磁盘上,避免RAID3频繁写坏一块磁盘的情况。

RAID6

如果数据需要很高的可靠性,在出现同时损坏两块磁盘的情况下(或者运维管理水平比较落后,坏了一块磁盘但是迟迟没有更换,导致又坏了一块磁盘),仍然需要修复数据,这时候可以使用RAID6。

RAID6和RAID5类似,但是数据只写入N-2块磁盘,并螺旋式地在两块磁盘中写入校验信息(使用不同算法生成)。

几种RAID技术比较:

RAID类型 访问速度 数据可靠性 磁盘利用率
RAID0 很快 很低 100%
RAID1 很慢 很高 50%
RAID10 中等 很高 50%
RAID5 较快 较高 (N-1)/N
RAID6 较快 较(RAID5)高 (N-2)/N

RAID技术可以通过硬件实现,比如专用的RAID卡或者主板直接支持,也可以通过软件实现。RAID技术在传统关系数据库及文件系统中应用比较广泛,但是在大型网站比较喜欢使用的NoSQL,以及分布式文件系统中,RAID技术却遭到冷落。

HDFS

在HDFS(Hadoop 分布式文件系统)中,系统在整个存储集群的多台服务器上进行数据并发读写和备份,可以看作在服务器集群规模上实现了类似RAID的功能,因此不需要磁盘RAID。

HDFS以块(Block)为单位管理文件内容,一个文件被分割成若干个Block,当应用程序写文件时,每写完一个Block,HDFS就将其自动复制到另外两台机器上,保证每个Block有三个副本,即使有两台服务器宕机,数据依然可以访问,相当于实现了RAID1的数据复制功能。

当对文件进行处理计算时,通过MapReduce并发计算任务框架,可以启动多个计算子任务(MapReduce Task),同时读取文件的多个Block,并发处理,相当于实现了RAID0的并发访问功能。

HDFS

在HDFS中有两种重要的服务器角色:NameNode(名字服务节点)和DataNode(数据存储节点)。NameNode在整个HDFS中只部署一个实例,提供元数据服务,相当于操作系统中的文件分配表(FAT),管理文件名Block的分配,维护整个文件系统的目录树结构。DataNode则部署在HDFS集群中其他所有服务器上,提供真正的数据存储服务。

和操作系统一样,HDFS对数据存储空间的管理以数据块(Block)为单位,只是比操作系统中的数据块(512字节)要大得多,默认为64MB。HDFS将DataNode上的磁盘空间分成N个这样的块,供应用程序使用。

应用程序(Client)需要写文件时,首先访问NameNode,请求分配数据块,NameNode根据管理的DataNode服务器的磁盘空间,按照一定的负载均衡策略,分配若干数据块供Client使用。

当Client写完一个数据块时,HDFS会将这个数据块再复制两份存储在其他DataNode服务器上,HDFS默认同一份数据有三个副本,保证数据可靠性。因此在HDFS中,即使DataNode服务器有多块磁盘,也不需要使用RAID进行数据备份,而是在整个集群上进行数据复制,而且系统一旦发现某台服务器宕机,会自动利用其他机器上的数据将这台服务器上存储的数据块自动再备份一份,从而获得更高的数据可靠性。

HDFS配合MapReduce等并行计算框架进行大数据处理时,可以在整个集群上并发读写访问所有的磁盘,无需RAID支持。

原则

网站性能对最终用户而言是一种主观感受,性能优化的最终目的就是改善用户的体验,使他们感觉网站很快。离开这个目的,追求技术上的所谓高性能,是舍本逐末,没有多大意义。而用户体验的快或是慢,可以通过技术手段改善,也可以通过优化交互体验改善。

即使在技术层面,性能优化也需要全面考虑,综合权衡:

  • 性能提升一倍,但服务器数量也需要增加一倍
  • 或者响应时间缩短,同时数据一致性也下降

这样的优化是否可以接受?这类问题的答案不是技术团队能回答的。

归根结底,技术是为业务服务的,技术选型和架构决策依赖业务规划乃至企业战略规划,离开业务发展的支撑和驱动,技术走不远,甚至还会迷路。

可用性

衡量一个系统架构设计是否满足高可用的目标,就是假设系统中任何一台或者多台服务器宕机时,以及出现各种不可预期的问题时,系统整体是否依然可用

网站高可用的主要手段是冗余,应用部署在多台服务器上同时提供访问,数据存储在多台服务器上互相备份,任何一台服务器宕机都不会影响应用的整体可用,也不会导致数据丢失

  • 对于应用服务器而言,多台应用服务器通过负载均衡设备组成一个集群共同对外提供服务,任何一台服务器宕机,只需把请求切换到其他服务器就可实现应用的高可用,但是一个前提条件是应用服务器上不能保存请求的会话信息。否则服务器宕机,会话丢失,即使将用户请求转发到其他服务器上也无法完成业务处理
  • 对于存储服务器,由于其上存储着数据,需要对数据进行实时备份,当服务器宕机时需要将数据访问转移到可用的服务器上,并进行数据恢复以保证继续有服务器宕机的时候数据依然可用
  • 除了运行环境,网站的高可用还需要软件开发过程的质量保证。通过预发布验证、自动化测试、自动化发布、灰度发布等手段,减少将故障引入线上环境的可能,避免故障范围扩大

网站可用性的度量

网站可用性度量

网站不可用时间(故障时间)=故障修复时间点网故障发现(报告)时间点
网站年度可用性指标=(11网站不可用时间/年度总时间)年100%

对于大多数网站而言,2个9是基本可用,网站年度不可用时间小于88小时;
3个9是较高可用,网站年度不可用时间小于9小时;
4个9是具有自动恢复能力的高可用,网站年度不可用时间小于53分钟;
5个9是极高可用性,网站年度不可用时间小于5分钟。

高可用的网站架构

大型网站的分层架构及物理服务器的分布式部署使得位于不同层次的服务器具有不同的可用性特点。关闭服务或者服务器宕机时产生的影响也不相同,高可用的解决方案也差异甚大。

  • 位于应用层的服务器通常为了应对高并发的访问请求,会通过负载均衡设备将一组服务器组成一个集群共同对外提供服务,当负载均衡设备通过心跳检测等手段监控到某台应用服务器不可用时,就将其从集群列表中剔除,并将请求分发到集群中其他可用的服务器上,使整个集群保持可用,从而实现应用高可用。
  • 位于服务层的服务器情况和应用层的服务器类似,也是通过集群方式实现高可用,只是这些服务器被应用层通过分布式服务调用框架访问,分布式服务调用框架会在应用层客户端程序中实现软件负载均衡,并通过服务注册中心对提供服务的服务器进行心跳检测,发现有服务不可用,立即通知客户端程序修改服务访问列表,剔除不可用的服务器。
  • 位于数据层的服务器情况比较特殊,数据服务器上存储着数据,为了保证服务器宕机时数据不丢失,数据访问服务不中断,需要在数据写入时进行数据同步复制,将数据写入多台服务器上,实现数据冗余备份。当数据服务器宕机时,应用程序将访问切换到有备份数据的服务器上。
  • 网站升级的频率一般都非常高,大型网站一周发布一次,中小型网站一天发布几次。每次网站发布都需要关闭服务,重新部署系统,整个过程相当于服务器宕机。因此网站的可用性架构设计不但要考虑实际的硬件故障引起的宕机,还要考虑网站升级发布引起的宕机,而后者更加频繁,不能因为系统可以接受偶尔的停机故障就降低可用性设计的标准。

高可用的应用

应用层主要处理网站应用的业务逻辑,因此有时也称作业务逻辑层,应用的一个显著特点是应用的无状态性。

所谓无状态的应用是指应用服务器不保存业务的上下文信息,而仅根据每次请求提交的数据进行相应的业务逻辑处理,多个服务实例(服务器)之间完全对等,请求提交到任意服务器,处理结果都是完全一样的。

通过负载均衡进行无状态服务的失效转移

不保存状态的应用给高可用的架构设计带来了巨大便利,既然服务器不保存请求的状态,那么所有的服务器完全对等,当任意一台或多台服务器宕机,请求提交给集群中其他任意一台可用机器处理,这样对终端用户而言,请求总是能够成功的,整个系统依然可用。
对于应用服务器集群,实现这种服务器可用状态实时监测、自动转移失败任务的机制是负载均衡。

由于负载均衡在应用层实际上起到了系统高可用的作用,因此即使某个应用访问量非常少,只用一台服务器提供服务就绰绰有余,但如果需要保证该服务高可用,也必须至少部署两台服务器,使用负载均衡技术构建一个小型的集群。

应用服务器集群的Session管理

集群环境下,Session管理主要有以下几种手段:

  • Session复制
    • 应用服务器开启Web容器的Session复制功能,在集群中的几台服务器之间同步Session对象,使得每台服务器上都保存所有用户的Session信息,这样任何一台机器宕机都不会导致Session数据的丢失,而服务器使用Session时,也只需要在本机获取即可。
    • 这种方案虽然简单,从本机读取Session信息也很快速,但只能使用在集群规模比较小的情况下。当集群规模较大时,集群服务器间需要大量的通信进行Session复制,占用服务器和网络的大量资源,系统不堪负担。而且由于所有用户的Session信息在每台服务器上都有备份,在大量用户访问的情况下,甚至会出现服务器内存不够Session使用的情况。
  • Session绑定
    • Session绑定可以利用负载均衡的源地址Hash算法实现,负载均衡服务器总是将来源于同一IP的请求分发到同一台服务器上(也可以根据Cookie信息将同一个用户的请求总是分发到同一台服务器上,当然这时负载均衡服务器必须工作在HTTP协议层上,这种方法又被称作会话黏滞
    • 但是Session绑定的方案显然不符合对系统高可用的需求,因为一旦某台服务器宕机,那么该机器上的Session也就不复存在了,用户请求切换到其他机器后因为没有Session而无法完成业务处理。
  • 利用Cookie记录Session
    • 网站没有客户端,但是可以利用浏览器支持的Cookie记录Session
    • 受Cookie大小限制,能记录的信息有限;每次请求响应都需要传输Cookie,影响性能;如果用户关闭Cookie,访问就会不正常。但是由于Cookie的简单易用,可用性高,支持应用服务器的线性伸缩,而大部分应用需要记录的Session信息又比较小。因此事实上,许多网站都或多或少地使用Cookie记录Session。
  • Session服务器
    • 利用独立部署的Session服务器(集群)统一管理Session,应用服务器每次读写Session时,都访问Session服务器

Session-server

这种解决方案事实上是将应用服务器的状态分离,分为无状态的应用服务器和有状态的Session服务器,然后针对这两种服务器的不同特性分别设计其架构。

对于有状态的Session服务器,一种比较简单的方法是利用分布式缓存、数据库等,在这些产品的基础上进行包装,使其符合Session的存储和访问要求。如果业务场景对Session管理有比较高的要求,比如利用Session服务集成单点登录(SSO)、用户服务等功能,则需要开发专门的Session服务管理平台。

高可用的服务

可复用的服务模块为业务产品提供基础公共服务,大型网站中这些服务通常都独立分布式部署,被具体应用远程调用。可复用的服务和应用一样,也是无状态的服务,因此可以使用类似负载均衡的失效转移策略实现高可用的服务。

除此之外,具体实践中,还有以下几点高可用的服务策略:

分级管理

运维上将服务器进行分级管理,核心应用和服务优先使用更好的硬件,在运维响应速度上也格外迅速。

同时在服务部署上也进行必要的隔离,避免故障的连锁反应。低优先级的服务通过启动不同的线程或者部署在不同的虚拟机上进行隔离,而高优先级的服务则需要部署在不同的物理机上,核心服务和数据甚至需要部署在不同地域的数据中心。

超时设置

在应用程序中设置服务调用的超时时间,一旦超时,通信框架就抛出异常,应用程序根据服务调度策略,可选择继续重试或将请求转移到提供相同服务的其他服务器上。

异步调用

应用对服务的调用通过消息队列等异步方式完成,避免一个服务失败导致整个应用请求失败的情况。

当然不是所有服务调用都可以异步调用,对于获取用户信息这类调用,采用异步方式会延长响应时间,得不偿失。对于那些必须确认服务调用成功才能继续下一步操作的应用也不合适使用异步调用。

服务降级

服务可能因为大量的并发调用而性能下降,严重时可能会导致服务宕机。为了保证核心应用和功能的正常运行,需要对服务进行降级。

降级有两种手段:拒绝服务及关闭服务。

  • 拒绝服务:拒绝低优先级应用的调用,减少服务调用并发数,确保核心应用正常使用;或者随机拒绝部分请求调用,节约资源,让另一部分请求得以成功,避免要死大家一起死的惨剧。
    • 貌似Twitter比较喜欢使用随机拒绝请求的策略,经常有用户看到请求失败的故障页面,但是问下身边的人,其他人都正常使用,自己再刷新页面,也好了。
  • 关闭功能:关闭部分不重要的服务,或者服务内部关闭部分不重要的功能,以节约系统开销,为重要的服务和功能让出资源。
    • 淘宝在每年的“双十一”促销中就使用这种方法,在系统最繁忙的时段关闭“评价”、“确认收货”等非核心服务,以保证核心交易服务的顺利完成。
幂等性设计

应用调用服务失败后,会将调用请求重新发送到其他服务器,但是这个失败可能是虚假的失败。比如服务已经处理成功,但因为网络故障应用没有收到响应,这时应用重新提交请求就导致服务重复调用,如果这个服务是一个转账操作,就会产生严重后果。

服务重复调用是无法避免的,应用层也不需要关心服务是否真的失败,只要没有收到调用成功的响应,就可以认为调用失败,并重试服务调用。因此必须在服务层保证服务重复调用和调用一次产生的结果相同,即服务具有幂等性。

有些服务天然具有幂等性,比如将用户性别设置为男性,不管设置多少次,结果都一样。但是对于转账交易等操作,问题就会比较复杂,需要通过交易编号等信息进行服务调用有效性校验,只有有效的操作才能继续执行。

高可用的数据

保证数据存储高可用的手段主要是数据备份和失效转移机制。数据备份是保证数据有多个副本,任意副本的失效都不会导致数据的永久丢失,从而实现数据完全的持久化。而失效转移机制则保证当一个数据副本不可访问时,可以快速切换访问数据的其他副本,保证系统可用。

对于缓存服务器集群中的单机宕机,如果缓存服务器集群规模较大,那么单机宕机引起的缓存数据丢失比例和数据库负载压力变化都较小,对整个系统影响也较小。 扩大缓存服务器集群规模的一个简单手段就是整个网站共享同一个分布式缓存集群,单独的应用和产品不需要部署自己的缓存服务器,只需要向共享缓存集群申请缓存资源即可。 并且通过逻辑或物理分区的方式将每个应用的缓存部署在多台服务器上,任何一台服务器宕机引起的缓存失效都只影响应用缓存数据的一小部分,不会对应用性能和数据库负载造成太大影响。

CAP原理

参考《大型网站一致性的基础理论——CAP/BASE》

为了保证数据的高可用,网站通常会牺牲另一个也很重要的指标:数据一致性。

高可用的数据有如下几个层面的含义:

  • 数据持久性
    • 保证数据可持久存储,在各种情况下都不会出现数据丢失的问题。
    • 为了实现数据的持久性,不但在写入数据时需要写入持久性存储,还需要将数据备份一个或多个副本,存放在不同的物理存储设备上,在某个存储故障或灾害发生时,数据不会丢失。
  • 数据可访问性
    • 在多份数据副本分别存放在不同存储设备的情况下,如果一个数据存储设备损坏,就需要将数据访问切换到另一个数据存储设备上,如果这个过程不能很快完成(终端用户几乎没有感知),或者在完成过程中需要停止终端用户访问数据,那么这段时间数据是不可访问的。
  • 数据一致性
    • 在数据有多份副本的情况下,如果网络、服务器或者软件出现故障,会导致部分副本写入成功,部分副本写入失败。这就会造成各个副本之间的数据不一致,数据内容冲突。
      • 数据强一致
        • 各个副本的数据在物理存储中总是一致的;数据更新操作结果和操作响应总是一致的,即操作响应通知更新失败,那么数据一定没有被更新,而不是处于不确定状态。
      • 数据用户一致
        • 即数据在物理存储中的各个副本的数据可能是不一致的,但是终端用户访问时,通过纠错和校验机制,可以确定一个一致的且正确的数据返回给用户。
      • 数据最终一致
        • 这是数据一致性中较弱的一种,即物理存储的数据可能是不一致的,终端用户访问到的数据可能也是不一致的(同一用户连续访问,结果不同;或者不同用户同时访问,结果不同),但系统经过一段时间(通常是一个比较短的时间段)的自我恢复和修正,数据最终会达到一致。
    • 因为难以满足数据强一致性,网站通常会综合成本、技术、业务场景等条件,结合应用服务和其他的数据监控与纠错功能,使存储系统达到用户一致,保证最终用户访问数据的正确性。

CAP原理认为,一个提供数据服务的存储系统无法同时满足数据一致性(Consistency)、数据可用性(Availibility)、分区耐受性(Patition Tolerance,系统具有跨网络分区的伸缩性)这三个条件

CAP

在大型网站应用中,数据规模总是快速扩张的,因此可伸缩性即分区耐受性必不可少,规模变大以后,机器数量也会变得庞大,这时网络和服务器故障会频繁出现,要想保证应用可用,就必须保证分布式处理系统的高可用性。 所以在大型网站中,通常会选择强化分布式存储系统的可用性(A)和伸缩性(P),而在某种程度上放弃一致性(C)。
一般说来,数据不一致通常出现在系统高并发写操作或者集群状态不稳(故障恢复、集群扩容……)的情况下,应用系统需要对分布式数据处理系统的数据不一致性有所了解并进行某种意义上的补偿和纠错,以避免出现应用系统数据不正确。

数据备份

冷备

冷备的优点是简单和廉价,成本和技术难度都较低。

  • 缺点是不能保证数据最终一致,由于数据是定期复制,因此备份设备中的数据比系统中的数据陈旧,如果系统数据丢失,那么从上个备份点开始后更新的数据就会永久丢失,不能从备份中恢复。
  • 同时也不能保证数据可用性,从冷备存储中恢复数据需要较长的时间,而这段时间无法访问数据,系统也不可用。

因此,数据冷备作为一种传统的数据保护手段,依然在网站日常运维中使用,同时在网站实时在线业务中,还需要进行数据热备,以提供更好的数据可用性。

热备

数据热备可分为两种:异步热备方式和同步热备方式。

异步热备

异步方式是指多份数据副本的写入操作异步完成,应用程序收到数据服务系统的写操作成功响应时,只写成功了一份,存储系统将会异步地写其他副本(这个过程有可能会失败)。

数据异步热备:

Asynchronous-hot-backup

在异步写入方式下,存储服务器分为主存储服务器(Master)和从存储服务器(Slave),应用程序正常情况下只连接主存储服务器,数据写入时,由主存储服务器的写操作代理模块将数据写入本机存储系统后立即返回写操作成功响应,然后通过异步线程将写操作数据同步到从存储服务器。

同步热备

同步方式是指多份数据副本的写入操作同步完成,即应用程序收到数据服务系统的写成功响应时,多份数据都已经写操作成功。 但是当应用程序收到数据写操作失败的响应时,可能有部分副本或者全部副本都已经写成功了(因为网络或者系统故障,无法返回操作成功的响应)

Synchronous-hot-backup

同步热备具体实现的时候,为了提高性能,在应用程序客户端并发向多个存储服务器同时写入数据,然后等待所有存储服务器都返回操作成功的响应后,再通知应用程序写操作成功。

这种情况下,存储服务器没有主从之分,完全对等,更便于管理和维护。存储服务客户端在写多份数据的时候,并发操作,这意味着多份数据的总写操作延迟是响应最慢的那台存储服务器的响应延迟,而不是多台存储服务器响应延迟之和。其性能和异步热备方式差不多。

  • 一开始就为大型网站而设计的各种NoSQL数据库(如HBase)更是将数据备份机制作为产品最主要的功能点之一。
  • 关系数据库热备机制就是通常所说的Master-Slave同步机制。Master-Slave机制不但解决了数据备份问题,还改善了数据库系统的性能,实践中,通常使用读写分离的方法访问Slave和Master数据库,写操作只访问Master数据库,读操作只访问Slave数据库。
失效转移

若数据服务器集群中任何一台服务器宕机,那么应用程序针对这台服务器的所有读写操作都需要重新路由到其他服务器,保证数据访问不会失败,这个过程叫作失效转移。

失效转移操作由三部分组成:失效确认、访问转移、数据恢复。

失效确认

系统确认一台服务器是否宕机的手段有两种:心跳检测和应用程序访问失败报告

存储服务器失效确认:

Storage-server-failure-confirmation

对于应用程序的访问失败报告,控制中心还需要再一次发送心跳检测进行确认,以免错误判断服务器宕机,因为一旦进行数据访问的失效转移,就意味着数据存储多份副本不一致,需要进行后续一系列复杂的操作。

访问转移

确认某台数据存储服务器宕机后,就需要将数据读写访问重新路由到其他服务器上。

  • 对于完全对等存储的服务器(几台存储服务器存储的数据完全一样,称几台服务器为对等服务器,比如主从结构的存储服务器,其存储的数据完全一样),当其中一台宕机后,应用程序根据配置直接切换到对等服务器上。
  • 如果存储是不对等的, 那么就需要重新计算路由,选择存储服务器。

数据恢复

因为某台服务器宕机,所以数据存储的副本数目会减少,必须将副本的数目恢复到系统设定的值,否则,再有服务器宕机时,就可能出现无法访问转移(所有副本的服务器都宕机了),数据永久丢失的情况。 因此系统需要从健康的服务器复制数据,将数据副本数目恢复到设定值。

具体参看文章中其他部分

高可用网站的软件质量保证

网站发布

事实上,由于应用的不断发布,用户需要面对的是每周一到两次的宕机故障。

但是网站发布毕竟是一次提前预知的服务器宕机,所以过程可以更柔和,对用户影响更小。通常使用发布脚本来完成发布

Website-publishing-process

发布过程中,每次关闭的服务器都是集群中的一小部分,并在发布完成后立即可以访问,因此整个发布过程不影响用户使用。

自动化测试

目前大部分网站都采用Web自动化测试技术,使用自动测试工具或脚本完成测试。比较流行的Web自动化测试工具是ThoughtWorks开发的Selenium。Selenium运行在浏览器中,模拟用户操作进行测试,因此Selenium可以同时完成Web功能测试和浏览器兼容测试。

预发布验证

即使是经过严格的测试,软件部署到线上服务器之后还是经常会出现各种问题,甚至根本无法启动服务器。

主要原因是测试环境和线上环境并不相同,特别是应用需要依赖的其他服务,也许是数据库表结构不一致;也许是接口变化导致的通信失败;也许是配置错误导致连接失败;也许是依赖的服务线上环境还没有准备好,这些问题都有可能导致应用故障。

因此在网站发布时,并不是把测试通过的代码包直接发布到线上服务器,而是先发布到预发布机器上,开发工程师和测试工程师在预发布服务器上进行预发布验证,执行一些典型的业务流程,确认系统没有问题后才正式发布。

网站应用预发布:

Website-application-pre-release

此外,在网站应用中强调的一个处理错误的理念是快速失败(fast failed),即如果系统在启动时发现问题就立刻抛出异常,停止启动让工程师介入排查错误,而不是启动后执行错误的操作。

代码控制
  • 主干开发、分支发布
    • 代码修改都在主干(trunk)上进行,需要发布的时候,从主干上拉一个分支(branch)发布,该分支即成为一个发布版本,如果该版本发现Bug,继续在该分支上修改发布,并将修改合并(merge)回主干,直到下次主干发布。
  • 分支开发,主干发布
    • 任何修改都不得在主干上直接进行,需要开发一个新功能或者修复一个Bug时,从主干拉一个分支进行开发,开发完成且测试通过后,合并回主干,然后从主干进行发布,主干上的代码永远是最新发布的版本。

这两种方式各有优缺点。

  • 主干开发、分支发布方式,主干代码反应目前整个应用的状态,一目了然,便于管理和控制,也利于持续集成。
  • 分支开发,主干发布方式,各个分支独立进行,互不干扰,可以使不同发布周期的开发在同一应用中进行。

目前网站应用开发中主要使用的是分支开发、主干发布的方式:

version-control

自动化发布

火车发布模型:将每个应用的发布过程看作一次火车旅程,火车定点运行,期间有若干站点,每一站都进行例行检查,不通过的项目下车,剩下的项目继续坐着火车旅行,直到火车到达终点(应用发布成功)。

网站火车发布模型:

Website-Train-Release-Model

由于火车发布模型是基于规则驱动的流程,所以这个流程可以自动化。采用火车发布模型的网站会开发一个自动化发布的工具实现发布过程的自动化。根据响应驱动流程,自动构造代码分支,进行代码合并,执行发布脚本等。

分发应用

一般会采用Web的操作方式,通过专用通道把应用程序包从线下环境传送到线上的发布服务器。

考虑在每个机房都部署发布服务器,由机房内的发布服务器负责本机房的程序包的分发,而发布控制台的实现上,可以考虑只把程序包分发给所有发布服务器中的一台, 由这个发布服务器负责在多个机房的发布服务器之间分发,也可以由发布控制台负责把程序包分发给所有机房的发布服务器。

发布系统结构

另外一个要注意的点是,如果应用服务器数量过多的话,可以采用P2P技术来进行程序包的分发,进而加快分发速度。

启动校验

当完成应用程序包的分发工作后,需要去停止当前应用上的程序,并完成新应用的启动。应用重新启动后,需要进行校验从而完成这台应用服务器上的应用发布。 对应用的校验一般是由应用自身提供一个检测脚本或者页面,发布系统执行这个脚本或者访问页面后来判断返回的结果。

灰度发布

应用发布成功后,仍然可能发现因为软件问题而引入的故障,这时候就需要做发布回滚,即卸载刚刚发布的软件,将上一个版本的软件包重新发布,使系统复原,消除故障。

大型网站的主要业务服务器集群规模非常庞大,比如某大型应用集群服务器数量超过一万台。一旦发现故障,即使想要发布回滚也需要很长时间才能完成,只能眼睁睁看着故障时间不断增加却干着急。为了应付这种局面,大型网站会使用灰度发布模式,将集群服务器分成若干部分,每天只发布一部分服务器,观察运行稳定没有故障,第二天继续发布一部分服务器,持续几天才把整个集群全部发布完毕,期间如果发现问题,只需要回滚已发布的一部分服务器即可。

网站灰度发布模型:

Website-grayscale-publishing-model

灰度发布也常用于用户测试,即在部分服务器上发布新版本,其余服务器保持老版本(或者发布另一个版本),然后监控用户操作行为,收集用户体验报告,比较用户对两个版本的满意度,以确定最终的发布版本。这种手段也被称作AB测试。

产品改版Beta

面向最终用户的应用产品的改版会改变用户的习惯,对于这样的改变不会一刀切地直接推行,而会提供新旧应用的共存。 应用本身会根据策略引流用户(主要是对用户的引导),对于发布系统来说,把新旧两个应用作为两个应用集群处理就行了。

网站运行监控

监控数据采集

广义上的网站监控涵盖所有非直接业务行为的数据采集与管理,包括供数据分析师和产品设计师使用的网站用户行为日志、业务运行数据,以及供运维工程师和开发工程师使用的系统性能数据等。

用户行为日志收集

用户行为日志指用户在浏览器上所做的所有操作及其所在的操作环境,包括用户操作系统与浏览器版本信息,IP地址、页面访问路径、页面停留时间等,这些数据对统计网站PV/UV指标、分析用户行为、优化网站设计、个性化营销与推荐等非常重要。

具体用户行为日志收集手段有两种:

  • 服务器端日志收集
    • 这个方案比较简单,Apache等几乎所有Web服务器都具备日志记录功能,可以记录大部分用户行为日志,开启Web服务器的日志记录功能即可。其缺点是可能会出现信息失真,如IP地址是代理服务器地址而不是用户真实IP;无法识别访问路径等
  • 客户端浏览器日志收集
    • 利用页面嵌入专门的JavaScript脚本可以收集用户真实的操作行为,因此比服务器日志收集更加精准。其缺点是比较麻烦,需要在页面嵌入特定的JavaScript脚本来完成

此外,大型网站的用户日志数据量惊人,数据存储与计算压力很大,目前许多网站逐步开发基于实时计算框架Storm的日志统计与分析工具。

服务器性能监控

收集服务器性能指标,如系统Load、内存占用、磁盘IO、网络IO等对尽早做出故障预警,及时判断应用状况,防患于未然,将故障扼杀在萌芽时期非常重要。此外根据性能监控数据,运维工程师可以合理安排服务器集群规模,架构师及时改善系统性能及调整系统伸缩性策略。

目前网站使用比较广泛的开源性能监控工具是Ganglia,它支持大规模服务器集群,并支持以图形的方式在浏览器展示实时性能曲线。

监控管理

监控数据采集后,除了用作系统性能评估、集群规模伸缩性预测等,还可以根据实时监控数据进行风险预警,并对服务器进行失效转移,自动负载调整,最大化利用集群所有机器的资源。

系统报警

在服务器运行正常的情况下,其各项监控指标基本稳定在一个特定水平,如果这些指标超过某个阈值,就意味着系统可能将要出现故障,这时就需要对相关人员报警,及时采取措施,在故障还未真正发生时就将其扼杀在萌芽状态。

失效转移

除了应用程序访问失败时进行失效转移,监控系统还可以在发现故障的情况下主动通知应用,进行失效转移。

自动优雅降级

网站在监控管理基础之上实现自动优雅降级,是网站柔性架构的理想状态:

监控系统实时监控所有服务器的运行状况,根据监控参数判断应用访问负载情况

  • 如果发现部分应用负载过高,而部分应用负载过低,就会适当卸载低负载应用部分服务器,重新安装启动部分高负载应用,使应用负载总体均衡
  • 如果所有应用负载都很高,而且负载压力还在继续增加,就会自动关闭部分非重要功能,保证核心功能正常运行

伸缩性

衡量架构伸缩性的主要标准就是是否可以用多台服务器构建集群,是否容易向集群中添加新的服务器。加入新的服务器后是否可以提供和原来的服务器无差别的服务。集群中可容纳的总的服务器数量是否有限制

  • 对于应用服务器集群,只要服务器上不保存数据,所有服务器都是对等的,通过使用合适的负载均衡设备就可以向集群中不断加入服务器。
  • 对于缓存服务器集群,加入新的服务器可能会导致缓存路由失效,进而导致集群中大部分缓存数据都无法访问。虽然缓存的数据可以通过数据库重新加载,但是如果应用已经严重依赖缓存,可能会导致整个网站崩溃。需要改进缓存路由算法保证缓存数据的可访问性
  • 关系数据库虽然支持数据复制,主从热备等机制,但是很难做到大规模集群的可伸缩性,因此关系数据库的集群伸缩性方案必须在数据库之外实现,通过路由分区等手段将部署有多个数据库的服务器组成一个集群
  • 至于大部分NoSQL数据库产品,由于其先天就是为海量数据而生,因此其对伸缩性的支持通常都非常好,可以做到在较少运维参与的情况下实现集群规模的线性伸缩

网站架构的伸缩性设计

一般说来,网站的伸缩性设计可分成两类

  • 一类是根据功能进行物理分离实现伸缩
  • 一类是单一功能通过集群实现伸缩

  • 前者是不同的服务器部署不同的服务,提供不同的功能
  • 后者是集群内的多台服务器部署相同的服务,提供相同的功能
不同功能进行物理分离实现伸缩

每次分离都会有更多的服务器加入网站,使用新增的服务器处理某种特定服务。通过物理上分离不同的网站功能,实现网站伸缩性的手段,不仅可以用在网站发展早期,而且可以在网站发展的任何阶段使用。

具体又可分成如下两种情况:

纵向分离(分层后分离):将业务处理流程上的不同部分分离部署,实现系统伸缩性

Longitudinal-separation

横向分离(业务分割后分离):将不同的业务模块分离部署,实现系统伸缩性

Horizontal-separation

横向分离的粒度可以非常小,甚至可以一个关键网页部署一个独立服务,比如对于电商网站非常重要的产品详情页面,商铺页面,搜索列表页面,每个页面都可以独立部署,专门维护。

单一功能通过集群规模实现伸缩

将不同功能分离部署可以实现一定程度的伸缩性,但是随着网站访问量的逐步增加,即使分离到最小粒度的独立部署,单一的服务器也不能满足业务规模的要求。因此必须使用服务器集群,即将相同服务部署在多台服务器上构成一个集群整体对外提供服务。

计算一个服务的集群规模,需要同时考虑其对可用性、性能的影响及关联服务集群的影响。

具体来说,集群伸缩性又可分为应用服务器集群伸缩性和数据服务器集群伸缩性。这两种集群由于对数据状态管理的不同,技术实现也有非常大的区别。 而数据服务器集群也可分为缓存数据服务器集群和存储数据服务器集群,这两种集群的伸缩性设计也不大相同。

应用服务器集群的伸缩性设计

应用服务器应该设计成无状态的,即应用服务器不存储请求上下文信息,如果将部署有相同应用的服务器组成一个集群,每次用户请求都可以发送到集群中任意一台服务器上去处理,任何一台服务器的处理结果都是相同的。 这样只要能将用户请求按照某种规则分发到集群的不同服务器上,就可以构成一个应用服务器集群,每个用户的每个请求都可能落在不同的服务器上。

如果HTTP请求分发装置可以感知或者可以配置集群的服务器数量,可以及时发现集群中新上线或下线的服务器,并能向新上线的服务器分发请求,停止向已下线的服务器分发请求,那么就实现了应用服务器集群的伸缩性。

这里,这个HTTP请求分发装置被称作负载均衡服务器。

负载均衡是网站必不可少的基础技术手段,不但可以实现网站的伸缩性,同时还改善网站的可用性,可谓网站的杀手锏之一。

HTTP重定向负载均衡

利用HTTP重定向协议实现负载均衡

Redirect-load-balancing

  • 这种负载均衡方案的优点是比较简单。
  • 缺点是浏览器需要两次请求服务器才能完成一次访问,性能较差;重定向服务器自身的处理能力有可能成为瓶颈,整个集群的伸缩性规模有限;使用HTTP302响应码重定向,有可能使搜索引擎判断为SEO作弊,降低搜索排名。

因此实践中使用这种方案进行负载均衡的案例并不多见。

DNS域名解析负载均衡

这是利用DNS处理域名解析请求的同时进行负载均衡处理的一种方案

DNS-domain-name-resolution-load-balancing

每次域名解析请求都会根据负载均衡算法计算一个不同的IP地址返回,这样A记录中配置的多个服务器就构成一个集群,并可以实现负载均衡。

浏览器请求解析域名www.mysite.com,DNS根据A记录和负载均衡算法计算得到一个IP地址114.100.80.3,并返回给浏览器;浏览器根据该IP地址,访问真实物理服务器114.100.80.3。

  • DNS域名解析负载均衡的优点是将负载均衡的工作转交给DNS,省掉了网站管理维护负载均衡服务器的麻烦,同时许多DNS还支持基于地理位置的域名解析,即会将域名解析成距离用户地理最近的一个服务器地址,这样可加快用户访问速度,改善性能。
  • 但是DNS域名解析负载均衡也有缺点,就是目前的DNS是多级解析,每一级DNS都可能缓存A记录,当下线某台服务器后,即使修改了DNS的A记录,要使其生效也需要较长时间,这段时间,DNS依然会将域名解析到已经下线的服务器,导致用户访问失败;而且DNS负载均衡的控制权在域名服务商那里,网站无法对其做更多改善和更强大的管理。

事实上,大型网站总是部分使用DNS域名解析,利用域名解析作为第一级负载均衡手段,即域名解析得到的一组服务器并不是实际提供Web服务的物理服务器,而是同样提供负载均衡服务的内部服务器,这组内部负载均衡服务器再进行负载均衡,将请求分发到真实的Web服务器上。

反向代理负载均衡

利用反向代理服务器进行负载均衡

Reverse-proxy-load-balancing

在部署位置上,反向代理服务器处于Web服务器前面(这样才可能缓存Web响应,加速访问),这个位置也正好是负载均衡服务器的位置,所以大多数反向代理服务器同时提供负载均衡的功能,管理一组Web服务器,将请求根据负载均衡算法转发到不同Web服务器上。 Web服务器处理完成的响应也需要通过反向代理服务器返回给用户。
由于Web服务器不直接对外提供访问,因此Web服务器不需要使用外部IP地址,而反向代理服务器则需要配置双网卡和内部外部两套IP地址。

  • 由于反向代理服务器转发请求在HTTP协议层面,因此也叫应用层负载均衡。其优点是和反向代理服务器功能集成在一起,部署简单。
  • 缺点是反向代理服务器是所有请求和响应的中转站,其性能可能会成为瓶颈。
IP负载均衡

在网络层通过修改请求目标地址进行负载均衡

IP-load-balancing

用户请求数据包到达负载均衡服务器114.100.80.10后,负载均衡服务器在操作系统内核进程获取网络数据包,根据负载均衡算法计算得到一台真实Web服务器10.0.0.1,然后将数据目的IP地址修改为10.0.0.1,不需要通过用户进程处理。 真实Web应用服务器处理完成后,响应数据包回到负载均衡服务器,负载均衡服务器再将数据包源地址修改为自身的IP地址(114.100.80.10)发送给用户浏览器。

这里的关键在于真实物理Web服务器响应数据包如何返回给负载均衡服务器。

  • 一种方案是负载均衡服务器在修改目的IP地址的同时修改源地址,将数据包源地址设为自身IP,即源地址转换(SNAT),这样Web服务器的响应会再回到负载均衡服务器;
  • 另一种方案是将负载均衡服务器同时作为真实物理服务器集群的网关服务器,这样所有响应数据都会到达负载均衡服务器。

  • IP负载均衡在内核进程完成数据分发,较反向代理负载均衡(在应用程序中分发数据)有更好的处理性能。
  • 但是由于所有请求响应都需要经过负载均衡服务器,集群的最大响应数据吞吐量不得不受制于负载均衡服务器网卡带宽。对于提供下载服务或者视频服务等需要传输大量数据的网站而言,难以满足需求。
数据链路层负载均衡

数据链路层负载均衡是指在通信协议的数据链路层修改mac地址进行负载均衡

Data-link-layer-load-balancing

这种数据传输方式又称作三角传输模式,负载均衡数据分发过程中不修改IP地址,只修改目的mac地址,通过配置真实物理服务器集群所有机器虚拟IP和负载均衡服务器IP地址一致,从而达到不修改数据包的源地址和目的地址就可以进行数据分发的目的, 由于实际处理请求的真实物理服务器IP和数据请求目的IP一致,不需要通过负载均衡服务器进行地址转换,可将响应数据包直接返回给用户浏览器,避免负载均衡服务器网卡带宽成为瓶颈。这种负载均衡方式又称作直接路由方式(DR)。

使用三角传输模式的链路层负载均衡是目前大型网站使用最广的一种负载均衡手段。在Linux平台上最好的链路层负载均衡开源产品是LVS(Linux Virtual Server)。

负载均衡算法

负载均衡服务器的实现可以分成两个部分:

  1. 根据负载均衡算法和Web服务器列表计算得到集群中一台Web服务器的地址。
  2. 将请求数据发送到该地址对应的Web服务器上。
  • 轮询(Round Robin,RR):
    • 所有请求被依次分发到每台应用服务器上,即每台服务器需要处理的请求数目都相同,适合于所有服务器硬件都相同的场景。
  • 加权轮询(Weighted Round Robin,WRR):
    • 根据应用服务器硬件性能的情况,在轮询的基础上,按照配置的权重将请求分发到每个服务器,高性能的服务器能分配更多请求。
  • 随机(Random):
    • 请求被随机分配到各个应用服务器,在许多场合下,这种方案都很简单实用,因为好的随机数本身就很均衡。即使应用服务器硬件配置不同,也可以使用加权随机算法。
  • 最少连接(Least Connections):
    • 记录每个应用服务器正在处理的连接数(请求数),将新到的请求分发到最少连接的服务器上,应该说,这是最符合负载均衡定义的算法。同样,最少连接算法也可以实现加权最少连接。
  • 源地址散列(Source Hashing):
    • 根据请求来源的IP地址进行Hash计算,得到应用服务器,这样来自同一个IP地址的请求总在同一个服务器上处理,该请求的上下文信息可以存储在这台服务器上,在一个会话周期内重复使用,从而实现会话黏滞。

分布式缓存集群的伸缩性设计

不同于应用服务器集群的伸缩性设计,分布式缓存集群的伸缩性不能使用简单的负载均衡手段来实现。

和所有服务器都部署相同应用的应用服务器集群不同,分布式缓存服务器集群中不同服务器中缓存的数据各不相同,缓存访问请求不可以在缓存服务器集群中的任意一台处理,必须先找到缓存有需要数据的服务器,然后才能访问。这个特点会严重制约分布式缓存集群的伸缩性设计,因为新上线的缓存服务器没有缓存任何数据,而已下线的缓存服务器还缓存着网站的许多热点数据。

必须让新上线的缓存服务器对整个分布式缓存集群影响最小,也就是说新加入缓存服务器后应使整个缓存服务器集群中已经缓存的数据尽可能还被访问到,这是分布式缓存集群伸缩性设计的最主要目标。

Memcached分布式缓存集群的访问模型

Memcached分布式缓存访问模型:

Memcached-Distributed-Cache-Access-Model

应用程序通过Memcached客户端访问Memcached服务器集群,Memcached客户端主要由一组API、Memcached服务器集群路由算法、Memcached服务器集群列表及通信模块构成。

其中路由算法负责根据应用程序输入的缓存数据KEY计算得到应该将数据写入到Memcached的哪台服务器(写缓存)或者应该从哪台服务器读数据(读缓存)。

Memcached分布式缓存集群的伸缩性挑战

对余数Hash路由算法稍加改进,就可以实现和负载均衡算法中加权负载均衡一样的加权路由。事实上,如果不需要考虑缓存服务器集群伸缩性,余数Hash几乎可以满足绝大多数的缓存路由需求。

但是,当分布式缓存集群需要扩容的时候,事情就变得棘手了。

一种解决办法是在网站访问量最少的时候扩容缓存服务器集群,这时候对数据库的负载冲击最小。然后通过模拟请求的方法逐渐预热缓存,使缓存服务器中的数据重新分布。但是这种方案对业务场景有要求,还需要技术团队通宵加班

通过改进路由算法,使得新加入的服务器不影响大部分缓存数据的正确命中,目前比较流行的算法是一致性Hash算法。

分布式缓存的一致性Hash算法

一致性Hash算法通过一个叫作一致性Hash环的数据结构实现KEY到缓存服务器的Hash映射:

Consistent-hash

具体算法过程为:

先构造一个长度为0~2^32的整数环(这个环被称作一致性Hash环),根据节点名称的Hash值(其分布范围同样为0~232)将缓存服务器节点放置在这个Hash环上。
然后根据需要缓存的数据的KEY值计算得到其Hash值(其分布范围也同样为0~232),然后在Hash环上顺时针查找距离这个KEY的Hash值最近的缓存服务器节点,完成KEY到服务器的Hash映射查找。

当缓存服务器集群需要扩容的时候,只需要将新加入的节点名称(NODE3)的Hash值放入一致性Hash环中,由于KEY是顺时针查找距离其最近的节点,因此新加入的节点只影响整个环中的一小段,这样就能保证大部分被缓存的数据还可以继续命中。

具体应用中,这个长度为232的一致性Hash环通常使用二叉查找树实现,Hash查找过程实际上是在二叉查找树中查找不小于查找数的最小数值。当然这个二叉树的最右边叶子节点和最左边的叶子节点相连接,构成环。

计算机的任何问题都可以通过增加一个虚拟层来解决。

解决上述一致性Hash算法带来的负载不均衡问题,也可以通过使用虚拟层的手段:

将每台物理缓存服务器虚拟为一组虚拟缓存服务器,将虚拟服务器的Hash值放置在Hash环上,KEY在环上先找到虚拟服务器节点,再得到物理服务器的信息。

这样新加入物理服务器节点时,是将一组虚拟节点加入环中,如果虚拟节点的数目足够多,这组虚拟节点将会影响同样多数目的已经在环上存在的虚拟节点,这些已经存在的虚拟节点又对应不同的物理节点。

最终的结果是:新加入一台缓存服务器,将会较为均匀地影响原来集群中已经存在的所有服务器,也就是说分摊原有缓存服务器集群中所有服务器的一小部分负载。

使用虚拟节点的一致性Hash环:

Consistent-Hash-Ring-Using-Virtual-Nodes

显然每个物理节点对应的虚拟节点越多,各个物理节点之间的负载越均衡,新加入物理服务器对原有的物理服务器的影响越保持一致(这就是一致性Hash这个名称的由来)。

那么在实践中,一台物理服务器虚拟为多少个虚拟服务器节点合适呢?太多会影响性能,太少又会导致负载不均衡,一般说来,经验值是150,当然根据集群规模和负载均衡的精度需求,这个值应该根据具体情况具体对待。

数据存储服务器集群的伸缩性设计

数据存储服务器必须保证数据的可靠存储,任何情况下都必须保证数据的可用性和正确性。因此缓存服务器集群的伸缩性架构方案不能直接适用于数据库等存储服务器。存储服务器集群的伸缩性设计相对更复杂一些,具体说来,又可分为关系数据库集群的伸缩性设计和NoSQL数据库的伸缩性设计。

关系数据库集群的伸缩性设计

主从

关系数据库凭借其简单强大的SQL和众多成熟的商业数据库产品,占据了从企业应用到网站系统的大部分业务数据存储服务。市场上主要的关系数据都支持数据复制功能,使用这个功能可以对数据库进行简单伸缩。

Data-replication

在这种架构中,虽然多台服务器部署MySQL实例,但是它们的角色有主从之分,数据写操作都在主服务器上,由主服务器将数据同步到集群中其他从服务器,数据读操作及数据分析等离线操作在从服务器上进行。

除了数据库主从读写分离,前面提到的业务分割模式也可以用在数据库,不同业务数据表部署在不同的数据库集群上,即俗称的数据分库。这种方式的制约条件是跨库的表不能进行Join操作。

在大型网站的实际应用中,即使进行了分库和主从复制,对一些单表数据仍然很大的表,比如Facebook的用户数据库,淘宝的商品数据库,还需要进行分片,将一张表拆开分别存储在多个数据库中。

支持数据分片的分布式关系数据库产品

目前网站在线业务应用中比较成熟的支持数据分片的分布式关系数据库产品主要有开源的Amoeba和Cobar。

Cobar-deployment-model

Cobar是一个分布式关系数据库访问代理,介于应用服务器和数据库服务器之间(Cobar也支持非独立部署,以lib的方式和应用程序部署在一起)。应用程序通过JDBC驱动访问Cobar集群,Cobar服务器根据SQL和分库规则分解SQL,分发到MySQL集群不同的数据库实例上执行(每个MySQL实例都部署为主/从结构,保证数据高可用)。

Cobar系统组件模型:

Cobar-system-component-model

前端通信模块负责和应用程序通信,接收到SQL请求(select * from users where userid in (12,22,23))后转交给SQL解析模块, SQL解析模块解析获得SQL中的路由规则查询条件(userid in(12,22,23))再转交给SQL路由模块, SQL路由模块根据路由规则配置(userid为偶数路由至数据库A,userid为奇数路由至数据库B)将应用程序提交的SQL分解成两条SQL(select * from users where userid in (12,22); select * from users where userid in (23);)转交给SQL执行代理模块, 发送至数据库A和数据库B分别执行。数据库A和数据库B的执行结果返回至SQL执行模块, 通过结果合并模块将两个返回结果集合并成一个结果集,最终返回给应用程序,完成在分布式数据库中的一次访问请求。

Cobar的伸缩有两种:Cobar服务器集群的伸缩和MySQL服务器集群的伸缩。

Cobar服务器可以看作是无状态的应用服务器,因此其集群伸缩可以简单使用负载均衡的手段实现。而MySQL中存储着数据,要想保证集群扩容后数据一致负载均衡,必须要做数据迁移,将集群中原来机器中的数据迁移到新添加的机器中

具体迁移哪些数据可以利用一致性Hash算法(即路由模块使用一致性Hash算法进行路由),尽量使需要迁移的数据最少。但是迁移数据需要遍历数据库中每条记录(的索引),重新进行路由计算确定其是否需要迁移,这会对数据库访问造成一定压力。并且需要解决迁移过程中数据的一致性、可访问性、迁移过程中服务器宕机时的可用性等诸多问题。

实践中,Cobar利用了MySQL的数据同步功能进行数据迁移。数据迁移不是以数据为单位,而是以Schema为单位。在Cobar集群初始化时,在每个MySQL实例创建多个Schema(根据业务远景规划未来集群规模,如集群最大规模为1000台数据库服务器,那么总的初始Schema数≥1000)。集群扩容的时候,从每个服务器中迁移部分Schema到新机器中,由于迁移以Schema为单位,迁移过程可以使用MySQL的同步机制

Cobar-cluster-scaling-using-MySQL-synchronization-mechanism

同步完成时,即新机器中Schema数据和原机器中Schema数据一致的时候,修改Cobar服务器的路由配置,将这些Schema的IP修改为新机器的IP,然后删除原机器中的相关Schema,完成MySQL集群扩容。

在整个分布式关系数据库的访问请求过程中,Cobar服务器处理消耗的时间是很少的,时间花费主要还是在MySQL数据库端,因此应用程序通过Cobar访问分布式关系数据库,性能基本和直接访问关系数据库相当,可以满足网站在线业务的实时处理需求。事实上由于Cobar代替应用程序连接数据库,数据库只需要维护更少的连接,减少不必要的资源消耗,改善性能

但由于Cobar路由后只能在单一数据库实例上处理查询请求,因此无法执行跨库的JOIN操作,当然更不能执行跨库的事务处理。

相比关系数据库本身功能上的优雅强大,目前各类分布式关系数据库解决方案都显得非常简陋,限制了关系数据库某些功能的使用。但是当网站业务面临不停增长的海量业务数据存储压力时,又不得不利用分布式关系数据库的集群伸缩能力,这时就必须从业务上回避分布式关系数据库的各种缺点:避免事务或利用事务补偿机制代替数据库事务;分解数据访问逻辑避免JOIN操作等。

NoSQL数据库的伸缩性设计

NoSQL,主要指非关系的、分布式的数据库设计模式。也有许多专家将NoSQL解读为Not Only SQL,表示NoSQL只是关系数据库的补充,而不是替代方案。

  • 一般而言,NoSQL数据库产品都放弃了关系数据库的两大重要基础:以关系代数为基础的结构化查询语言(SQL)和事务一致性保证(ACID)。
  • 而强化其他一些大型网站更关注的特性:高可用性和可伸缩性。

HBase为可伸缩海量数据储存而设计,实现面向在线业务的实时数据访问延迟。HBase的伸缩性主要依赖其可分裂的HRegion及可伸缩的分布式文件系统HDFS实现。

HBase中,数据以HRegion为单位进行管理,也就是说应用程序如果想要访问一个数据,必须先找到HRegion,然后将数据读写操作提交给HRegion,由HRegion完成存储层面的数据操作。 每个HRegion中存储一段Key值区间[key1,key2)的数据,HRegionServer是物理服务器,每个HRegionServer上可以启动多个HRegion实例。 当一个HRegion中写入的数据太多,达到配置的阈值时,HRegion会分裂成两个HRegion,并将HRegion在整个集群中进行迁移,以使HregionServer的负载均衡。

HBase架构:

HBase-architecture

所有HRegion的信息(存储的Key值区间、所在HRegionServer地址、访问端口号等)都记录在HMaser服务器上, 为了保证高可用,HBase启动多个HMaser,并通过Zookeeper(一个支持分布式一致性的数据管理服务)选举出一个主服务器, 应用程序通过Zookeeper获得主HMaser的地址,输入Key值获得这个Key所在的HRegionServer地址,然后请求HRegionServer上的HRegion,获得需要的数据。

HBase数据寻址过程时序图:

HBase-data-addressing-process-timing-diagram

扩展性

衡量网站架构扩展性好坏的主要标准就是在网站增加新的业务产品时,是否可以实现对现有产品透明无影响,不需要任何改动或者很少改动既有业务功能就可以上线新产品。 不同产品之间是否很少耦合,一个产品改动对其他产品无影响,其他产品和功能不需要受牵连进行改动

网站可伸缩架构的主要手段是事件驱动架构和分布式服务

  • 事件驱动架构在网站通常利用消息队列实现,将用户请求和其他业务事件构造成消息发布到消息队列,消息的处理者作为消费者从消息队列中获取消息进行处理。通过这种方式将消息产生和消息处理分离开来,可以透明地增加新的消息生产者任务或者新的消息消费者任务
  • 分布式服务则是将业务和可复用服务分离开来,通过分布式服务框架调用。新增产品可以通过调用可复用的服务实现自身的业务逻辑,而对现有产品没有任何影响。可复用服务升级变更的时候,也可以通过提供多版本服务对应用实现透明升级,不需要强制应用同步变更
  • 大型网站为了保持市场地位,还会吸引第三方开发者,调用网站服务,使用网站数据开发周边产品,扩展网站业务。第三方开发者使用网站服务的主要途径是大型网站提供的开放平台接口

扩展性(Extensibility)

指对现有系统影响最小的情况下,系统功能可持续扩展或提升的能力。表现在系统基础设施稳定不需要经常变更,应用之间较少依赖和耦合,对需求变更可以敏捷响应。它是系统架构设计层面的开闭原则(对扩展开放,对修改关闭),架构设计考虑未来功能扩展,当系统增加新功能时,不需要对现有系统的结构和代码进行修改。

伸缩性(Scalability)

指系统能够通过增加(减少)自身资源规模的方式增强(减少)自己计算处理事务的能力。如果这种增减是成比例的,就被称作线性伸缩性。在网站架构中,通常指利用集群的方式增加服务器数量、提高系统的整体事务吞吐能力。

构建可扩展的网站架构

如何分解系统的各个模块、如何定义各个模块的接口、如何复用组合不同的模块构造成一个完整的系统,这是软件设计中最有挑战的部分。

设计网站可扩展架构的核心思想是模块化,并在此基础之上,降低模块间的耦合性,提高模块的复用性。

网站通过分层和分割的方式进行架构伸缩,分层和分割也是模块化设计的重要手段,利用分层和分割的方式将软件分割为若干个低耦合的独立的组件模块,这些组件模块以消息传递及依赖调用的方式聚合成一个完整的系统。

在大型网站中,这些模块通过分布式部署的方式,独立的模块部署在独立的服务器(集群)上,从物理上分离模块之间的耦合关系,进一步降低耦合性提高复用性。

模块分布式部署以后具体聚合方式主要有分布式消息队列和分布式服务。

利用分布式消息队列降低系统耦合性

如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响最小,这样系统的可扩展性无疑更好一些。

事件驱动架构

事件驱动架构(Event Driven Architecture):通过在低耦合的模块之间传输事件消息,以保持模块的松散耦合,并借助事件消息的通信完成模块间合作。

典型的EDA架构就是操作系统中常见的生产者消费者模式。
在大型网站架构中,具体实现手段有很多,最常用的是分布式消息队列

利用消息队列实现的事件驱动架构:

Event-driven-architecture-using-message-queues

消息队列利用发布—订阅模式工作,消息发送者发布消息,一个或者多个消息接收者订阅消息。 消息发送者是消息源,在对消息进行处理后将消息发送至分布式消息队列,消息接受者从分布式消息队列获取该消息后继续进行处理。 消息发送者和消息接受者之间没有直接耦合,消息发送者将消息发送至分布式消息队列即结束对消息的处理,而消息接受者只需要从分布式消息队列获取消息后进行处理,不需要知道该消息从何而来。 对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展设计。

消息接受者在对消息进行过滤、处理、包装后,构造成一个新的消息类型,将消息继续发送出去,等待其他消息接受者订阅处理该消息。因此基于事件(消息对象)驱动的业务架构可以是一系列的流程。

  • 由于消息发送者不需要等待消息接受者处理数据就可以返回,系统具有更好的响应延迟;
  • 同时,在网站访问高峰,消息可以暂时存储在消息队列中等待消息接受者根据自身负载处理能力控制消息处理速度,减轻数据库等后端存储的负载压力。
分布式消息队列

队列是一种先进先出的数据结构,分布式消息队列可以看作将这种数据结构部署到独立的服务器上,应用程序可以通过远程访问接口使用分布式消息队列,进行消息存取操作,进而实现分布式的异步调用

分布式消息队列架构原理:

MQ-Architecture-Principles

消息生产者应用程序通过远程访问接口将消息推送给消息队列服务器,消息队列服务器将消息写入本地内存队列后立即返回成功响应给消息生产者。 消息队列服务器根据消息订阅列表查找订阅该消息的消息消费者应用程序,将消息队列中的消息按照先进先出(FIFO)的原则将消息通过远程通信接口发送给消息消费者程序。

目前开源的和商业的分布式消息队列产品有很多,比较著名的如Apache ActiveMQ等,这些产品除了实现分布式消息队列的一般功能,在可用性、伸缩性、数据一致性、性能和可管理性方面也做了很多改善。

  • 在伸缩性方面,由于消息队列服务器上的数据可以看作是被即时处理的,因此类似于无状态的服务器,伸缩性设计比较简单。将新服务器加入分布式消息队列集群中,通知生产者服务器更改消息队列服务器列表即可。
  • 在可用性方面,为了避免消费者进程处理缓慢,分布式消息队列服务器内存空间不足造成的问题,如果内存队列已满,会将消息写入磁盘,消息推送模块在将内存队列消息处理完以后,将磁盘内容加载到内存队列继续处理。

为了避免消息队列服务器宕机造成消息丢失,会将消息成功发送到消息队列的消息存储在消息生产者服务器,等消息真正被消息消费者服务器处理后才删除消息。
在消息队列服务器宕机后,生产者服务器会选择分布式消息队列服务器集群中其他的服务器发布消息。

分布式消息队列可以很复杂,比如可以支持ESB(企业服务总线)、支持SOA(面向服务的架构)等;
也可以很简单,比如用MySQL也可以当作分布式消息队列:消息生产者程序将消息当作数据记录写入数据库,消息消费者程序查询数据库并按记录写入时间戳排序,就实现了一个事实上的分布式消息队列,而且这个消息队列使用成熟的MySQL运维手段,也可以达到较高的可用性和性能指标。

利用分布式服务打造可复用的业务平台

使用分布式服务是降低系统耦合性的另一个重要手段。

  • 如果说分布式消息队列通过消息对象分解系统耦合性,不同子系统处理同一个消息;
  • 那么分布式服务则通过接口分解系统耦合性,不同子系统通过相同的接口描述进行服务调用。

一个应用中聚合了大量的应用和服务组件,这个巨无霸给整个网站的开发、维护、部署都带来了巨大的麻烦。

Big-Mac-system-diagram

  1. 编译、部署困难
  2. 代码分支管理困难
  3. 数据库连接耗尽
  4. 新增业务困难

解决方案就是拆分,将模块独立部署,降低系统耦合性。拆分可以分为纵向拆分和横向拆分两种。

  • 纵向拆分:将一个大应用拆分为多个小应用,如果新增业务较为独立,那么就直接将其设计部署为一个独立的Web应用系统。
  • 横向拆分:将复用的业务拆分出来,独立部署为分布式服务,新增业务只需要调用这些分布式服务,不需要依赖具体的模块代码,即可快速搭建一个应用系统,而模块内业务逻辑变化的时候,只要接口保持一致就不会影响业务程序和其他模块。

Service-and-module-split-independent-deployment-of-distributed-service-architecture

  • 纵向拆分相对较为简单,通过梳理业务,将较少相关的业务剥离,使其成为独立的Web应用。
  • 而对于横向拆分,不但需要识别可复用的业务,设计服务接口,规范服务依赖关系,还需要一个完善的分布式服务管理框架。
Web Service与企业级分布式服务

WebService架构原理:

WebService-architecture-principle

  • 服务提供者通过WSDL(Web Services Description Language,Web服务描述语言)向注册中心(Service Broker)描述自身提供的服务接口属性
  • 注册中心使用UDDI(Universal Description, Discovery, and Integration,统一描述、发现和集成)发布服务提供者提供的服务
  • 服务请求者从注册中心检索到服务信息后,通过SOAP (Simple Object Access Protocol,简单对象访问协议 )和服务提供者通信,使用相关服务。

Web Service虽然有成熟的技术规范和产品实现,并在企业应用领域有许多成功的案例,但也有如下固有的缺点。

  1. 臃肿的注册与发现机制。
  2. 低效的XML序列化手段。
  3. 开销相对较高的HTTP远程通信。
  4. 复杂的部署与维护手段。

这些问题导致Web Service难以满足大型网站对系统高性能、高可用、易部署、易维护的要求。

大型网站分布式服务的需求与特点

对于大型网站,除了Web Service所提供的服务注册与发现,服务调用等标准功能,还需要分布式服务框架能够支持如下特性。

  • 负载均衡
    • 对热门服务,比如登录服务或者商品服务,访问量非常大,服务需要部署在一个集群上。分布式服务框架要能够支持服务请求者使用可配置的负载均衡算法访问服务,使服务提供者集群实现负载均衡。
  • 失效转移
    • 可复用的服务通常会被多个应用调用,一旦该服务不可用,就会影响到很多应用的可用性。因此对于大型网站的分布式服务而言,即使是很少访问的简单服务,也需要集群部署,分布式服务框架支持服务提供者的失效转移机制,当某个服务实例不可用,就将访问切换到其他服务实例上,以实现服务整体高可用。
  • 高效的远程通信
    • 对于大型网站,核心服务每天的调用次数会达到数以亿计,如果没有高效的远程通信手段,服务调用会成为整个系统性能的瓶颈。
  • 整合异构系统
    • 由于历史发展和组织分割,网站服务可能会使用不同的语言开发并部署于不同的平台,分布式服务框架需要整合这些异构的系统。
  • 对应用最少侵入
    • 网站技术是为业务服务的,是否使用分布式服务需要根据业务发展规划,分布式服务也需要渐进式的演化,甚至会出现反复,即使用了分布式服务后又退回到集中式部署,分布式服务框架需要支持这种渐进式演化和反复。当然服务模块本身需要支持可集中式部署,也可分布式部署。
  • 版本管理
    • 为了应对快速变化的需求,服务升级不可避免,如果仅仅是服务内部实现逻辑升级,那么这种升级对服务请求者而言是透明的,无需关注。但如果服务的访问接口也发生了变化,就需要服务请求者和服务提供者同时升级才不会导致服务调用失败。企业应用系统可以申请停机维护,同时升级接口。但是网站服务不可能中断,因此分布式服务框架需要支持服务多版本发布,服务提供者先升级接口发布新版本的服务,并同时提供旧版本的服务供请求者调用,当请求者调用接口升级后才可以关闭旧版本服务。
  • 实时监控
    • 对于网站应用而言,没有监控的服务是不可能实现高可用的。分布式服务框架还需要监控服务提供者和调用者的各项指标,提供运维和运营支持。
分布式服务框架设计

分布式服务框架Dubbo的架构原理:

Dubbo

服务消费者程序通过服务接口使用服务,而服务接口通过代理加载具体服务,具体服务可以是本地的代码模块,也可以是远程的服务,因此对应用较少侵入:
应用程序只需要调用服务接口,服务框架根据配置自动调用本地或远程实现。

服务框架客户端模块通过服务注册中心加载服务提供者列表(服务提供者启动后自动向服务注册中心注册自己可提供的服务接口列表),查找需要的服务接口,并根据配置的负载均衡策略将服务调用请求发送到某台服务提供者服务器。
如果服务调用失败,客户端模块会自动从服务提供者列表选择一个可提供同样服务的另一台服务器重新请求服务,实现服务的自动失效转移,保证服务高可用。

Dubbo的远程服务通信模块支持多种通信协议和数据序列化协议,使用NIO通信框架,具有较高的网络通信性能。

可扩展的数据结构

传统的关系数据库为了保证关系运算(通过SQL语句)的正确性,在设计数据库表结构的时候,就需要指定表的schema——字段名称,数据类型等,并要遵循特定的设计范式。这些规范带来的一个问题就是僵硬的数据结构难以面对需求变更带来的挑战,有些应用系统设计者通过预先设计一些冗余字段来应对,不过显然这是一种糟糕的数据库设计。

许多NoSQL数据库使用的ColumnFamily(列族)设计就是一个解决方案。ColumnFamily最早在Google的Bigtable中使用,这是一种面向列族的稀疏矩阵存储格式

ColumnFamily

而使用支持ColumnFamily结构的NoSQL数据库,创建表的时候,只需要指定ColumnFamily的名字,无需指定字段(Column),可以在数据写入时再指定,通过这种方式,数据表可以包含数百万的字段,使得应用程序的数据结构可以随意扩展。而在查询时,可以通过指定任意字段名称和值进行查询。

利用开放平台建设网站生态圈

大型网站为了更好地服务自己的用户,开发更多的增值服务,会把网站内部的服务封装成一些调用接口开放出去,供外部的第三方开发者使用,这个提供开放接口的平台被称作开放平台。 第三方开发者利用这些开放的接口开发应用程序(APP)或者网站,为更多的用户提供价值。网站、用户、第三方开发者互相依赖,形成一个网站的生态圈,既为用户提供更多的价值,也提高了网站和第三方开发者的竞争能力和盈利能力。

大型网站为了更好地服务自己的用户,开发更多的增值服务,会把网站内部的服务封装成一些调用接口开放出去,供外部的第三方开发者使用,这个提供开放接口的平台被称作开放平台。第三方开发者利用这些开放的接口开发应用程序(APP)或者网站,为更多的用户提供价值。网站、用户、第三方开发者互相依赖,形成一个网站的生态圈,既为用户提供更多的价值,也提高了网站和第三方开发者的竞争能力和盈利能力。

Open-platform

  • API接口:是开放平台暴露给开发者使用的一组API,其形式可以是RESTful、WebService、RPC等各种形式。
  • 协议转换:将各种API输入转换成内部服务可以识别的形式,并将内部服务的返回封装成API的格式。
  • 安全:除了一般应用需要的身份识别、权限控制等安全手段,开放平台还需要分级的访问带宽限制,保证平台资源被第三方应用公平合理使用,也保护网站内部服务不会被外部应用拖垮。
  • 审计:记录第三方应用的访问情况,并进行监控、计费等。
  • 路由:将开放平台的各种访问路由映射到具体的内部服务。
  • 流程:将一组离散的服务组织成一个上下文相关的新服务,隐藏服务细节,提供统一接口供开发者调用。

安全性

衡量网站安全架构的标准就是针对现存和潜在的各种攻击与窃密手段,是否有可靠的应对策略。

网站应用攻击与防御

XSS攻击

XSS攻击即跨站点脚本攻击(Cross Site Script),指黑客通过篡改网页,注入恶意HTML脚本,在用户浏览网页时,控制用户浏览器进行恶意操作的一种攻击方式。

常见的XSS攻击类型有两种

  • 一种是反射型,攻击者诱使用户点击一个嵌入恶意脚本的链接,达到攻击的目的。
  • 另外一种XSS攻击是持久型XSS攻击,黑客提交含有恶意脚本的请求,保存在被攻击的Web站点的数据库中,用户浏览网页时,恶意脚本被包含在正常页面中,达到攻击的目的

反射型XSS攻击:

Reflective-XSS

持久型XSS攻击:

Persistent-XSS

XSS防攻击主要手段有如下两种:

消毒

SS攻击者一般都是通过在请求中嵌入恶意脚本达到攻击的目的,这些脚本是一般用户输入中不使用的,如果进行过滤和消毒处理,即对某些html危险字符转义,如“>”转义为“&gt”、“<””转义为“&lt”等,就可以防止大部分攻击。为了避免对不必要的内容错误转义,如“3<5”中的“<”需要进行文本匹配后再转义,如“<img src=”这样的上下文中的“<”才转义。事实上,消毒几乎是所有网站最必备的XSS防攻击手段。

HttpOnly

最早由微软提出,即浏览器禁止页面JavaScript访问带有HttpOnly属性的Cookie。HttpOnly并不是直接对抗XSS攻击的,而是防止XSS攻击者窃取Cookie。对于存放敏感信息的Cookie,如用户认证信息等,可通过对该Cookie添加HttpOnly属性,避免被攻击脚本窃取。

Secured,属性使Cookie只能在https下传输

注入攻击

注入攻击主要有两种形式,SQL注入攻击和OS注入攻击。

SQL注入攻击:

Sql-injection

SQL注入攻击需要攻击者对数据库结构有所了解才能进行,攻击者获取数据库表结构信息的手段有如下几种:

  • 开源
    • 如果网站采用开源软件搭建,如用Discuz!搭建论坛网站,那么网站数据库结构就是公开的,攻击者可以直接获得。
  • 错误回显
    • 如果网站开启错误回显,即服务器内部500错误会显示到浏览器上。攻击者通过故意构造非法参数,使服务端异常信息输出到浏览器端,为攻击猜测数据库表结构提供了便利。
  • 盲注
    • 网站关闭错误回显,攻击者根据页面变化情况判断SQL语句的执行情况,据此猜测数据库表结构,此种方式攻击难度较大。

防御SQL注入攻击首先要避免被攻击者猜测到表名等数据库表结构信息,此外还可以采用如下方式。

消毒

和防XSS攻击一样,请求参数消毒是一种比较简单粗暴又有效的手段。通过正则匹配,过滤请求数据中可能注入的SQL,如“drop table”、“\b(?:update\b.*?\bset |delete\b\W*?\bfrom)\b”等。

参数绑定

使用预编译手段,绑定参数是最好的防SQL注入方法。目前许多数据访问层框架,如IBatis,Hibernate等,都实现SQL预编译和参数绑定,攻击者的恶意SQL会被当做SQL的参数,而不是SQL命令被执行。

除了SQL注入,攻击者还根据具体应用,注入OS命令、编程语言代码等,利用程序漏洞,达到攻击目的。

CSRF攻击

CSRF(Cross Site Request Forgery,跨站点请求伪造),攻击者通过跨站请求,以合法用户的身份进行非法操作。

CSRF的主要手法是利用跨站请求,在用户不知情的情况下,以用户的身份伪造请求。其核心是利用了浏览器Cookie或服务器Session策略,盗取用户身份。

CSRF

相应地,CSRF的防御手段主要是识别请求者身份。主要有下面几种方法:

表单Token

CSRF是一个伪造用户请求的操作,所以需要构造用户请求的所有参数才可以。表单Token通过在请求参数中增加随机数的办法来阻止攻击者获得所有请求参数:
在页面表单中增加一个随机数作为Token,每次响应页面的Token都不相同,从正常页面提交的请求会包含该Token值,而伪造的请求无法获得该值,服务器检查请求参数中Token的值是否存在并且正确以确定请求提交者是否合法。

验证码

相对说来,验证码则更加简单有效,即请求提交时,需要用户输入验证码,以避免在用户不知情的情况下被攻击者伪造请求。但是输入验证码是一个糟糕的用户体验,所以请在必要时使用,如支付交易等关键页面。

Referer check

HTTP请求头的Referer域中记录着请求来源,可通过检查请求来源,验证其是否合法。很多网站使用这个功能实现图片防盗链(如果图片访问的页面来源不是来自自己网站的网页就拒绝)。

其他攻击和漏洞

除了上面提到的常见攻击,还有一些漏洞也常被黑客利用:

Error Code

也称作错误回显,许多Web服务器默认是打开异常信息输出的,即服务器端未处理的异常堆栈信息会直接输出到客户端浏览器,这种方式虽然对程序调试和错误报告有好处,但同时也给黑客造成可乘之机。通过故意制造非法输入,使系统运行时出错,获得异常信息,从而寻找系统漏洞进行攻击。防御手段也很简单,通过配置Web服务器参数,跳转500页面(HTTP响应码500表示服务器内部错误)到专门的错误页面即可, Web应用常用的MVC框架也有这个功能。

HTML注释

为调试程序方便或其他不恰当的原因,有时程序开发人员会在PHP、JSP等服务器页面程序中使用HTML注释语法进行程序注释,这些HTML注释就会显示在客户端浏览器,给黑客造成攻击便利。程序最终发布前需要进行代码review或自动扫描,避免HTML注释漏洞。

文件上传

一般网站都会有文件上传功能,设置头像、分享视频、上传附件等。如果上传的是可执行的程序,并通过该程序获得服务器端命令执行能力,那么攻击者几乎可以在服务器上为所欲为,并以此为跳板攻击集群环境的其他机器。最有效的防御手段是设置上传文件白名单,只允许上传可靠的文件类型。此外还可以修改文件名、使用专门的存储等手段,保护服务器免受上传文件攻击。

路径遍历

攻击者在请求的URL中使用相对路径,遍历系统未开放的目录和文件。防御方法主要是将JS、CSS等资源文件部署在独立服务器、使用独立域名,其他文件不使用静态URL访问,动态参数不包含文件路径信息。

Web应用防火墙

ModSecurity是一个开源的Web应用防火墙,探测攻击并保护Web应用程序,既可以嵌入到Web应用服务器中,也可以作为一个独立的应用程序启动。ModSecurity最早只是Apache的一个模块,现在已经有Java、.NET多个版本,并支持Nginx。

ModSecurity采用处理逻辑与攻击规则集合分离的架构模式。

  • 处理逻辑(执行引擎)负责请求和响应的拦截过滤,规则加载执行等功能。
  • 而攻击规则集合则负责描述对具体攻击的规则定义、模式识别、防御策略等功能(可以通过文本方式进行描述)。

处理逻辑比较稳定,规则集合需要不断针对漏洞进行升级,这是一种可扩展的架构设计

ModSecurity

除了开源的ModeSecurity,还有一些商业产品也实现Web应用防火墙功能,如NEC的SiteShell。

网站安全漏洞扫描

网站安全漏洞扫描工具是根据内置规则,构造具有攻击性的URL请求,模拟黑客攻击行为,用以发现网站安全漏洞的工具。

许多大型网站的安全团队都有自己开发的漏洞扫描工具,不定期地对网站的服务器进行扫描,查漏补缺。市场上也有很多商用的网站安全漏洞扫描平台。

信息加密技术及密钥安全管理

为了保护网站的敏感数据,应用需要对这些信息进行加密处理,信息加密技术可分为三类:单项散列加密、对称加密和非对称加密。

单向散列加密

单向散列加密是指通过对不同输入长度的信息进行散列计算,得到固定长度的输出,这个散列计算过程是单向的,即不能对固定长度的输出进行计算从而获得输入信息

One-way-hash-encryption

利用单向散列加密的这个特性,可以进行密码加密保存,即用户注册时输入的密码不直接保存到数据库,而是对密码进行单向散列加密,将密文存入数据库,用户登录时,进行密码验证,同样计算得到输入密码的密文,并和数据库中的密文比较,如果一致,则密码验证成功

Password-preservation-and-verification

这样保存在数据库中的是用户输入的密码的密文,而且不可逆地计算得到密码的明文,因此即使数据库被“拖库”,也不会泄露用户的密码信息。

虽然不能通过算法将单向散列密文反算得到明文,但是由于人们设置密码具有一定的模式,因此通过彩虹表(人们常用密码和对应的密文关系表)等手段可以进行猜测式破解。

为了加强单向散列计算的安全性,还会给散列算法加点盐(salt),salt相当于加密的密钥,增加破解的难度。

常用的单向散列算法有MD5、SHA等。单向散列算法还有一个特点就是输入的任何微小变化都会导致输出的完全不同,这个特性有时也会被用来生成信息摘要、计算具有高离散程度的随机数等用途。

对称加密

所谓对称加密是指加密和解密使用的密钥是同一个密钥(或者可以互相推算)

Symmetric-encryption

对称加密通常用在信息需要安全交换或存储的场合,如Cookie加密、通信加密等。

对称加密的优点是算法简单,加解密效率高,系统开销小,适合对大量数据加密。缺点是加解密使用同一个密钥,远程通信的情况下如何安全的交换密钥是个难题,如果密钥丢失,那么所有的加密信息也就没有秘密可言了。

常用的对称加密算法有DES算发、RC算法等。对称加密是一种传统加密手段,也是最常用的加密手段,适用于绝大多数需要加密的场合。

非对称加密

不同于对称加密,非对称加密和解密使用的密钥不是同一密钥,其中一个对外界公开,被称作公钥,另一个只有所有者知道,被称作私钥。用公钥加密的信息必须用私钥才能解开,反之,用私钥加密的信息只有用公钥才能解开。理论上说,不可能通过公钥计算获得私钥。

Asymmetric-encryption

非对称加密技术通常用在信息安全传输,数字签名等场合。

信息发送者A通过公开渠道获得信息接收者B的公钥,对提交信息进行加密,然后通过非安全传输通道将密文信息发送给B,B得到密文信息后,用自己的私钥对信息进行解密,获得原始的明文信息。即使密文信息在传输过程中遭到窃取,窃取者没有解密密钥也无法还原明文。

数字签名的过程则相反,签名者用自己的私钥对信息进行加密,然后发送给对方,接收方用签名者的公钥对信息进行解密,获得原始明文信息,由于私钥只有签名者拥有,因此该信息是不可抵赖的,具有签名的性质。

在实际应用中,常常会混合使用对称加密和非对称加密。先使用非对称加密技术对对称密钥进行安全传输,然后使用对称加密技术进行信息加解密与交换。而有时,对同一个数据两次使用非对称加密,可同时实现信息安全传输与数字签名的目的。

非对称加密的常用算法有RSA算法等。HTTPS传输中浏览器使用的数字证书实质上是经过权威机构认证的非对称加密的公钥。

密钥安全管理

前述的几种加密技术,能够达到安全保密效果的一个重要前提是密钥的安全。不管是单向散列加密用到的salt、对称加密的密钥、还是非对称加密的私钥,一旦这些密钥泄露出去,那么所有基于这些密钥加密的信息就失去了秘密性。

信息的安全是靠密钥保证的。但在实际中经常看到,有的工程师把密钥直接写在源代码中,稍好一点的写在配置文件中,线上和开发环境配置不同的密钥。总之密钥本身是以明文的方式保存,并且很多人可以接触到,至少在公司内部,密钥不是秘密。

实践中,改善密钥安全性的手段有两种。

  • 一种方案是把密钥和算法放在一个独立的服务器上,甚至做成一个专用的硬件设施,对外提供加密和解密服务,应用系统通过调用这个服务,实现数据的加解密。由于密钥和算法独立部署,由专人维护,使得密钥泄露的概率大大降低。但是这种方案成本较高,而且有可能会成为应用的瓶颈,每次加密、解密都需要进行一次远程服务调用,系统性能开销也较大。
  • 另一种方案是将加解密算法放在应用系统中,密钥则放在独立服务器中,为了提高密钥的安全性,实际存储时,密钥被切分成数片,加密后分别保存在不同存储介质中,兼顾密钥安全性的同时又改善了性能

Key-security-management

应用程序调用密钥安全管理系统提供的加解密服务接口对信息进行加解密,该接口实现了常用的加密解密算法并可根据需求任意扩展。 加解密服务接口通过密钥服务器的密钥服务取得加解密密钥,并缓存在本地(定时更新)。 而密钥服务器中的密钥则来自多个密钥存储服务器,一个密钥分片后存储在多个存储服务器中,每个服务器都有专人负责管理。密钥申请者、密钥管理者、安全审核人员通过密钥管理控制台管理更新密钥,每个人各司其事,没有人能查看完整的密钥信息。

信息过滤与反垃圾

常用的信息过滤与反垃圾手段有以下几种:

文本匹配

文本匹配主要解决敏感词过滤的问题。通常网站维护一份敏感词列表,如果用户发表的信息含有列表中的敏感词,则进行消毒处理(将敏感词转义为***)或拒绝发表。

那么如何快速地判断用户信息中是否含有敏感词呢?如果敏感词比较少,用户提交信息文本长度也较短,可直接使用正则表达式匹配。但是正则表达式的效率一般较差,当敏感词很多,用户发布的信息也很长,网站并发量较高时,就需要更合适的方法来完成,这方面公开的算法有很多,基本上都是Trie树的变种,空间和时间复杂度都比较好的有双数组Trie算法等。

Trie算法的本质是确定一个有限状态自动机,根据输入数据进行状态转移。双数组Trie算法优化了Trie算法,利用两个稀疏数组存储树结构,base数组存储Trie树的节点,check数组进行状态检查。双数组Trie数需要根据业务场景和经验确定数组大小,避免数组过大或者冲突过多。

另一种更简单的实现是通过构造多级Hash表进行文本匹配。假设敏感词表包含敏感词:阿拉伯、阿拉汗、阿油、北京、北大荒、北风。那么可以构造如图所示的过滤树,用户提交的信息逐字顺序在过滤树中匹配。过滤树的分支可能会比较多,为了提高匹配速度,减少不必要的查找,同一层中相同父节点的字可放在Hash表中。该方案处理速度较快,稍加变形,即可适应各种过滤场景,缺点是使用Hash表会浪费部分内存空间,如果网站敏感词数量不多,浪费部分内存还是可以接受的。

Sensitive-word-filter-tree

有时候,为了绕过敏感词检查,某些输入信息会被做一些手脚,如“阿_拉_伯”,这时候还需要对信息做降噪预处理,然后再进行匹配。

分类算法

对海量的信息进行人工审核是不现实的,对广告贴、垃圾邮件等内容的识别比较好的自动化方法是采用分类算法。

以反垃圾邮件为例说明分类算法的使用。先将批量已分类的邮件样本(如50000封正常邮件,2000封垃圾邮件)输入分类算法进行训练,得到一个垃圾邮件分类模型,然后利用分类算法结合分类模型对待处理邮件进行识别。

Classification-algorithm-to-identify-spam

比较简单实用的分类算法有贝叶斯分类算法,这是一种利用概率统计方法进行分类的算法。贝叶斯算法解决概率论中的一个典型问题:一号箱子放有红色球和白色球各20个,二号箱子放有白色球10个,红色球30个,现在随机挑选一个箱子,取出来一个球的颜色是红色的,请问这个球来自一号箱子的概率是多少。

利用贝叶斯算法进行垃圾邮件的识别基于同样原理,根据已分类的样本信息获得一组特征值的概率,如“茶叶”这个词出现在垃圾邮件中的概率为20%,出现在非垃圾邮件中的概率为1%,就得到分类模型。然后对待处理邮件提取特征值,比如取到了茶叶这个特征值,结合分类模型,就可以判断其分类。贝叶斯算法得到的分类判断是一个概率值,因此会存在误判(非垃圾邮件判为垃圾邮件)和漏判(垃圾邮件判为非垃圾邮件)。

贝叶斯算法认为特征值之间是独立的,所以也被称作是朴素贝叶斯算法(Native Bayes),这个假设很多时候是不成立的,特征值之间具有关联性,通过对朴素贝叶斯算法增加特征值的关联依赖处理,得到TAN算法。更进一步,通过对关联规则的聚类挖掘,得到更强大的算法,如ARCS算法(Association Rule Clustering System)等。但是由于贝叶斯分类算法简单,处理速度快,仍是许多实时在线系统反垃圾的首选。

分类算法除了用于反垃圾,还可用于信息自动分类,门户网站可用该算法对采集来的新闻稿件进行自动分类,分发到不同的频道。邮箱服务商根据邮件内容推送的个性化广告也可以使用分类算法提高投送相关度。

黑名单

对于垃圾邮件,除了用分类算法进行内容分类识别,还可以使用黑名单技术,将被报告的垃圾邮箱地址放入黑名单,然后针对邮件的发件人在黑名单列表中查找,如果查找成功,则过滤该邮件。

黑名单也可用于信息去重,如将文章标题或者文章关键段落记录到黑名单中,以减少搜索引擎收录重复信息等用途。

黑名单可以通过Hash表实现,该方法实现简单,时间复杂度小,满足一般场景使用。但是当黑名单列表非常大时,Hash表需要占据极大的内存空间。例如在需要处理10亿个黑名单邮件地址列表的场景下,每个邮件地址需要8个字节的信息指纹,即需要8GB内存,为了减少Hash冲突,还需要一定的Hash空间冗余,假如空间利用率为50%,则需要16GB的内存空间。随着列表的不断增大,一般服务器将不可承受这样的内存需求。而且列表越大,Hash冲突越多,检索速度越慢。

在对过滤需求要求不完全精确的场景下,可用布隆过滤器代替Hash表。布隆过滤器是用它的发明者巴顿·布隆的名字命名的,通过一个二进制列表和一组随机数映射函数实现

Bloom-filter

仍以需要处理10亿邮件地址黑名单列表为例,在内存中建立一个2GB大小的存储空间,即16GB个二进制bit,并全部初始化为0。要将一个邮箱地址加入黑名单时,使用8个随机映射函数(F1,F2,…,F8)得到0~16GB范围内的8个随机数,从而将该邮箱地址映射到16GB二进制存储空间的8个位置上,然后将这些位置置为1。当要检查一个邮箱地址是否在黑名单中时,使用同样的映射函数,得到16GB空间8个位置上的bit,如果这些值都为1,那么该邮箱地址在黑名单中。

可以看到,处理同样数量的信息,布隆过滤器只使用Hash表所需内存的1/8。但是布隆过滤器有可能导致系统误判(布隆过滤器检查在黑名单中,但实际却并未放入过)。因为一个邮箱地址映射的8个bit可能正好都被其他邮箱地址设为1了,这种可能性极小,通常在系统可接受范围内。但如果需要精确的判断,则不适合使用布隆过滤器。

电子商务风险控制

风险

电子商务具有多种形式,B2B、B2C、C2C每种交易的场景都不相同,风险也各有特点,大致可分为以下几种:

  • 账户风险:包括账户被黑客盗用,恶意注册账号等几种情形。
  • 买家风险:买家恶意下单占用库存进行不正当竞争;黄牛利用促销抢购低价商品;此外还有良品拒收,欺诈退款及常见于B2B交易的虚假询盘等。
  • 卖家风险:不良卖家进行恶意欺诈的行为,例如货不对板,虚假发货,炒作信用等,此外还有出售违禁商品、侵权产品等。
  • 交易风险:信用卡盗刷,支付欺诈,洗钱套现等。
风控

大型电商网站都配备有专门的风控团队进行风险控制,风控的手段也包括自动和人工两种。机器自动识别为高风险的交易和信息会发送给风控审核人员进行人工审核,机器自动风控的技术和方法也不断通过人工发现的新风险类型进行逐步完善。

机器自动风控的技术手段主要有规则引擎和统计模型。

规则引擎

当交易的某些指标满足一定条件时,就会被认为具有高风险的欺诈可能性。比如用户来自欺诈高发地区;交易金额超过某个数值;和上次登录的地址距离差距很大;用户登录地与收货地不符;用户第一次交易等等。

大型网站在运营过程中,结合业界的最新发现,会总结出数以千计的此类高风险交易规则。一种方案是在业务逻辑中通过编程方式使用if…else…代码实现这些规则,可想而知,这些代码会非常庞大,而且由于运营过程中不断发现新的交易风险类型,需要不断调整规则,代码也需要不断修改……

网站一般使用规则引擎技术处理此类问题。规则引擎是一种将业务规则和规则处理逻辑相分离的技术,业务规则文件由运营人员通过管理界面编辑,当需要修改规则时,无需更改代码发布程序,即可实时使用新规则。而规则处理逻辑则调用规则处理输入的数据

Rule-engine-based-risk-control-system

统计模型

规则引擎虽然技术简单,但是随着规则的逐渐增加,会出现规则冲突,难以维护等情况,而且规则越多,性能也越差。目前大型网站更倾向于使用统计模型进行风控。风控领域使用的统计模型使用前面提到的分类算法或者更复杂的机器学习算法进行智能统计。根据历史交易中的欺诈交易信息训练分类算法,然后将经过采集加工后的交易信息输入分类算法,即可得到交易风险分值。

Statistical-Model-Based-Risk-Control-System

经过充分训练后的统计模型,准确率不低于规则引擎。分类算法的实时计算性能更好一些,由于统计模型使用模糊识别,并不精确匹配欺诈类型规则,因此对新出现的交易欺诈还具有一定预测性。

高可用与故障分析

分布式存储系统的高可用架构

对一个大规模集群的存储系统而言,服务器宕机、交换机失效是常态,架构师必须为这些故障发生时,保证系统依然可用而进行系统设计。在系统架构层面,保证高可用的主要手段是冗余:

服务器热备,数据多份存储。使整个集群在部分机器故障的情况下可以进行灵活的失效转移(Failover),保证系统整体依然可用,数据持久可靠。

Doris的整体架构:

Doris's-architecture

系统整体上可分为如下三个部分:

  • 应用程序服务器:它们是存储系统的客户,对系统发起数据操作请求。
  • 数据存储服务器:他们是存储系统的核心,负责存储数据、响应应用服务器的数据操作请求。
  • 管理中心服务器:这是一个由两台机器组成的主-主热备的小规模服务器集群,主要负责集群管理,对数据存储集群进行健康心跳检测;集群扩容、故障恢复管理;对应用程序服务器提供集群地址配置信息服务等。

其中数据存储服务器又根据应用的可用性级别设置数据复制份数,即每个数据实际物理存储的副本数目,副本份数越多,可用性级别越高,当然需要的服务器也越多。为了便于管理和访问数据的多个副本,将存储服务器划分为多个序列,数据的多个副本存储在不同的序列中(序列可以理解为存储集群中的子集群)。

应用服务器写入数据时,根据集群配置和应用可用性级别使用路由算法在每个序列中计算得到一台服务器,然后同时并发写入这些服务器中;应用服务器读取数据时,只需要随机选择一个序列,根据相同路由算法计算得到服务器编号和地址,即可读取。通常情况下,系统最少写入的副本份数是两份

Doris-system-call-timing-model

在正常状态下,存储服务器集群中的服务器互不感知,不进行任何通信;应用服务器也只在启动时从管理中心服务器获取存储服务器集群信息,除非集群信息发生变化(故障、扩容),否则应用服务器不会和管理中心服务器通信。一般而言,服务器之间通信越少,就越少依赖,发生故障时互相影响就越少,集群的可用性就越高。

不同故障情况下的高可用解决方案

分布式存储系统的故障分类

  • 瞬时故障:
    • 引起这类故障的主要原因是网络通信瞬时中断、服务器内存垃圾回收或后台线程繁忙停止数据访问操作响应。
    • 其特点是故障时间短,在秒级甚至毫秒级系统即可自行恢复正常响应。
  • 临时故障:
    • 引起这类故障的主要原因是交换机宕机、网卡松动等导致的网络通信中断;
    • 系统升级、停机维护等一般运维活动引起的服务关闭;
    • 内存损坏、CPU过热等硬件原因导致的服务器宕机;
    • 这类故障的主要特点是需要人工干预(更换硬件、重启机器等)才能恢复正常。通常持续时间需要几十分钟甚至几小时。
    • 故障时间可分为两个阶段:临时故障期间,临时故障恢复期间。
  • 永久故障:
    • 引起这类故障的主要原因只有一个:硬盘损坏,数据丢失。
    • 虽然损坏硬盘和损坏内存一样,可以通过更换硬盘来重新启动机器,但是丢失的数据却永远找不回来了,因此其处理策略也和前面两种故障完全不同,恢复系统到正常状态也需要更长的时间。
    • 故障时间可分为两个阶段:永久故障期间和永久故障恢复期间。

正常情况下系统访问结构

正常情况下Doris访问模型:

Doris-access-model-under-normal-circumstances

  • 应用程序在写数据时,需要路由计算获得两台不同的服务器,同时将数据写入两台服务器;
  • 而读数据时,只需要到这两台服务器上任意一台服务器读取即可。

瞬时故障的高可用解决方案

瞬时故障是一种严重性较低的故障,一般系统经过较短暂的时间即可自行恢复,遇到瞬时故障只需要多次重试,就可以重新连接到服务器,正常访问。

Doris-Instantaneous-Fault-Solution

如果经多次重试后,仍然失败,那么有可能不是瞬时故障,而是更严重的临时故障,这时需要执行临时故障处理策略。

当然也有可能是应用服务器自己的故障,比如系统文件句柄用光导致连接不能建立等,这时需要请求管理中心服务器进行故障仲裁,以判定故障种类。

Doris瞬时失效访问模型:

Doris-instantaneous-failure-access-model

临时故障的高可用解决方案

临时故障要比瞬时故障严重,系统需要人工干预才能恢复正常,在故障服务器未能恢复正常前,系统也必须保证高可用。由于数据有多份副本,因此读数据时只需要路由选择正常服务的机器即可;写数据时,正常服务的机器依然正常写入,发生故障的机器需要将数据写入到临时存储服务器,等待故障服务器恢复正常后再将临时服务器中的数据迁移到该机器,整个集群就恢复正常了。

Doris临时故障解决方案:

Doris-Temporary-Failure-Solution

其中临时服务器是集群中专门部署的服务器(根据可用性规划,临时服务器也可以部署为多台机器的集群),正常情况下,该服务器不会有数据写入,处于空闲状态,只有在临时失效的时候,才会写入数据。任何时候该服务器都不会提供读操作服务。

Doris临时故障访问模型:

Doris-Temporary-Failure-Access-Model

临时故障恢复期间Doris访问模型:

Doris-access-model-during-temporary-recovery

临时故障期间写入临时服务器的数据全部迁移到存储服务器2后,故障全部恢复,存储服务器2恢复到正常状态,系统可按正常情况访问。

永久故障的高可用解决方案

永久故障是指服务器上的数据永久丢失,不能恢复。由于故障服务器上的数据永久丢失,从临时服务器迁移数据就没有意义了,必须要从其他序列中正常的服务器中复制全部数据才能恢复正常状态。

永久故障发生期间,由于系统无法判断该故障是临时故障还是永久故障,因此系统访问结构和临时故障一样。当系统出现临时故障超时(超过设定时间临时故障服务器仍没有启动)或者人工确认为永久故障时,系统启用备用服务器替代原来永久失效的服务器,进入永久故障恢复

永久故障恢复期间Doris访问模型:

Doris-access-model-during-permanent-failure-recovery

秒杀活动的技术挑战

  • 对现有网站业务造成冲击
    • 秒杀活动只是网站营销的一个附加活动,这个活动具有时间短,并发访问量大的特点,如果和网站原有应用部署在一起,必然会对现有业务造成冲击,稍有不慎可能导致整个网站瘫痪。
  • 高并发下的应用、数据库负载
    • 用户在秒杀开始前,通过不停刷新浏览器页面以保证不会错过秒杀,这些请求如果按照一般的网站应用架构,访问应用服务器、连接数据库,会对应用服务器和数据库服务器造成极大的负载压力。
  • 突然增加的网络及服务器带宽
    • 假设商品页面大小200K(主要是商品图片大小),那么需要的网络和服务器带宽是2G(200K×10,000),这些网络带宽是因为秒杀活动新增的,超过网站平时使用的带宽。
  • 直接下单
    • 秒杀的游戏规则是到了秒杀时间才能开始对商品下单购买,在此时间点之前,只能浏览商品信息,不能下单。而下单页面也是一个普通的URL,如果得到这个URL,不用等到秒杀开始就可以下单了。

秒杀系统的应对策略

  • 秒杀系统独立部署
    • 为了避免因为秒杀活动的高并发访问而拖垮整个网站,使整个网站不必面对蜂拥而来的用户访问,可将秒杀系统独立部署;如果需要,还可以使用独立的域名,使其与网站完全隔离,即使秒杀系统崩溃了,也不会对网站造成任何影响。
  • 秒杀商品页面静态化
    • 重新设计秒杀商品页面,不使用网站原来的商品详情页面,页面内容静态化:将商品描述、商品参数、成交记录和用户评价全部写入一个静态页面,用户请求不需要经过应用服务器的业务逻辑处理,也不需要访问数据库。所以秒杀商品服务不需要部署动态的Web服务器和数据库服务器。
  • 租借秒杀活动网络带宽
    • 因为秒杀新增的网络带宽,必须和运营商重新购买或者租借。为了减轻网站服务器的压力,需要将秒杀商品页面缓存在CDN,同样需要和CDN服务商临时租借新增的出口带宽。
  • 动态生成随机下单页面URL
    • 为了避免用户直接访问下单页面URL,需要将该URL动态化,即使秒杀系统的开发者也无法在秒杀开始前访问下单页面的URL。办法是在下单页面URL加入由服务器端生成的随机数作为参数,在秒杀开始的时候才能得到。

秒杀系统架构设计

秒杀系统为秒杀而设计,不同于一般的网购行为,参与秒杀活动的用户更关心地是如何能快速刷新商品页面,在秒杀开始的时候抢先进入下单页面,而不是商品详情等用户体验细节,因此秒杀系统的页面设计应尽可能简单。

商品页面中的购买按钮只有在秒杀活动开始的时候才变亮,在此之前及秒杀商品卖出后,该按钮都是灰色的,不可以点击。

下单表单也尽可能简单,购买数量只能是一个且不可以修改,送货地址和付款方式都使用用户默认设置,没有默认也可以不填,允许等订单提交后修改;

只有第一个提交的订单发送给网站的订单子系统,其余用户提交订单后只能看到秒杀结束页面

除了上面提到的秒杀系统的技术挑战及应对策略,还有一些其他问题需要处理:

如何控制秒杀商品页面购买按钮的点亮

购买按钮只有在秒杀活动开始的时候才能点亮,在此之前是灰色的。如果该页面是动态生成的,当然可以在服务器端构造响应页面输出,控制该按钮是灰色还是点亮,但是为了减轻服务器端负载压力,更好地利用CDN、反向代理等性能优化手段,该页面被设计为静态页面,缓存在CDN、反向代理服务器上,甚至用户浏览器上。秒杀开始时,用户刷新页面,请求根本不会到达应用服务器。

解决办法是使用JavaScript脚本控制,在秒杀商品静态页面中加入一个JavaScript文件引用,该JavaScript文件中加入秒杀是否开始的标志和下单页面URL的随机数参数,当秒杀开始的时候生成一个新的JavaScript文件并被用户浏览器加载,控制秒杀商品页面的展示。这个JavaScript文件使用随机版本号,并且不被浏览器、CDN和反向代理服务器缓存。

秒杀商品点亮过程:

Panic-buying-product-lighting-process

如何只允许第一个提交的订单被发送到订单子系统

由于最终能够成功秒杀到商品的用户只有一个,因此需要在用户提交订单时,检查是否已经有订单提交。事实上,由于最终能够成功提交订单的用户只有一个,为了减轻下单页面服务器的负载压力,可以控制进入下单页面的入口,只有少数用户能进入下单页面,其他用户直接进入秒杀结束页面。

秒杀下单流程:

Buy-order-process

秒杀系统的整体架构:

structure-of-panic-buying-system

网站的典型故障

写日志也会引发故障

故障现象:

某应用服务器集群发布后不久就出现多台服务器相继报警,硬盘可用空间低于警戒值,并且很快有服务器宕机。登录到线上服务器,发现log文件夹里的文件迅速增加,不断消耗磁盘空间。

原因分析:

这是一个普通的应用服务器集群,不需要存储数据,因此服务器里配置的是一块100GB的小硬盘,安装完操作系统、Web服务器、Java虚拟机、应用程序后,空闲空间只有几十GB了,正常情况下这些磁盘空间足够了,但是该应用的开发人员将log输出的level全局配置为Debug。这样一次简单的Web请求就会产生大量的log文件输出,在高并发的用户请求下,很快就消耗完不多的磁盘空间。

经验教训:

  • 应用程序自己的日志输出配置和第三方组件日志输出要分别配置。
  • 检查log配置文件,日志输出级别至少为Warn,并且检查log输出代码调用,调用级别要符合其真实日志级别。
  • 有些开源的第三方组件也会不恰当地输出太多的Error日志,需要关闭这些第三方库的日志输出,至于哪些第三方库有问题,只有在遇到问题时才知道。

高并发访问数据库引发的故障

故障现象:

某应用发布后,数据库Load居高不下,远超过正常水平,持续报警。

原因分析:

检查数据库,发现报警是因为某条SQL引起的,这条SQL是一条简单的有索引的数据查询,不应该引发报警。继续检查,发现这条SQL执行频率非常高,远远超过正常水平。追查这条SQL,发现被网站首页应用调用,首页是被访问最频繁的网页,这条SQL被首页调用,也就被频繁执行了。

经验教训:

  • 首页不应该访问数据库,首页需要的数据可以从缓存服务器或者搜索引擎服务器获取。
  • 首页最好是静态的。

高并发情况下锁引发的故障

故障现象:

某应用服务器不定时地因为响应超时而报警,但是很快又超时解除,恢复正常,如此反复,让运维人员非常苦恼。

原因分析:

程序中某个单例对象(singleton object)中多处使用了synchronized(this),由于this对象只有一个,所有的并发请求都要排队获得这唯一的一把锁。一般情况下,都是一些简单操作,获得锁,迅速完成操作,释放锁,不会引起线程排队。但是某个需要远程调用的操作也被加了synchronized(this),这个操作只是偶尔会被执行,但是每次执行都需要较长的时间才能完成,这段时间锁被占用,所有的用户线程都要等待,响应超时,这个操作执行完后释放锁,其他线程迅速执行,超时解除。

经验教训:

  • 使用锁操作要谨慎。

缓存引发的故障

故障现象:

没有新应用发布,但是数据库服务器突然Load飙升,并很快失去响应。DBA将数据库访问切换到备机,Load也很快飙升,并失去响应。最终引发网站全部瘫痪。

原因分析:

缓存服务器在网站服务器集群中的地位一直比较低,服务器配置和管理级别都比其他服务器要低一些。人们都认为缓存是改善性能的手段,丢失一些缓存也没什么问题,有时候关闭一两台缓存服务器也确实对应用没有明显影响,所以长期疏于管理缓存服务器。结果这次一个缺乏经验的工程师关闭了缓存服务器集群中全部的十几台Memcached服务器,导致了网站全部瘫痪的重大事故。

经验教训:

  • 当缓存已经不仅仅是改善性能,而是成为网站架构不可或缺的一部分时,对缓存的管理就需要提高到和其他服务器一样的级别。

应用启动不同步引发的故障

故障现象:

某应用发布后,服务器立即崩溃。

原因分析:

应用程序Web环境使用ApacheAJBoss的模式,用户请求通过Apache转发JBoss。在发布时,Apache和JBoss同时启动,由于JBoss启动时需要加载很多应用并初始化,花费时间较长,结果JBoss还没有完全启动,Apache就已经启动完毕开始接收用户请求,大量请求阻塞在JBoss进程中,最终导致JBoss崩溃。除了这种Apache和JBoss启动不同步的情况,网站还有很多类似的场景,都需要后台服务准备好,前台应用才能启动,否则就会导致故障。这种情况被内部人戏称作“姑娘们还没穿好衣服,老鸨就开门迎客了”。

经验教训:

  • 老鸨开门前要检查下姑娘们是否穿好了衣服。就本例来说,在应用程序中加入一个特定的动态页面(比如只返回OK两个字母),启动脚本先启动JBoss,然后在脚本中不断用curl命令访问这个特定页面,直到收到OK,才启动Apache。

大文件读写独占磁盘引发的故障

故障现象:

某应用主要功能是管理用户图片,接到部分用户投诉,表示上传图片非常慢,原来只需要一两秒,现在需要几十秒,有时等半天结果浏览器显示服务器超时。

原因分析:

图片需要使用存储,最有可能出错的地方是存储服务器。检查存储服务器,发现大部分文件只有几百KB,而有几个文件非常大,有数百兆,读写这些大文件一次需要几十秒,这段时间,磁盘基本被这个文件操作独占,导致其他用户的文件操作缓慢。

经验教训:

  • 存储的使用需要根据不同文件类型和用途进行管理,图片都是小文件,应该使用专用的存储服务器,不能和大文件共用存储。批处理用的大文件可以使用其他类型的分布式文件系统。

滥用生产环境引发的故障

故障现象:

监控发现某个时段内,某些应用突然变慢,内部网络访问延迟非常厉害。

原因分析:

检查发现,该时段内网卡流量也下降,但是没有找到原因。过了一阵子才知道,原来有工程师在线上生产环境进行性能压力测试,占用了大部分交换机带宽。

经验教训:

  • 访问线上生产环境要规范,不小心就会导致大事故。

网站数据库有专门的DBA维护,如果发现数据库存在错误记录,需要进行数据订正,必须走数据订正流程,申请DBA协助。于是就有工程师为避免麻烦,直接写一段数据库更新操作的代码,悄悄放到生产环境应用服务器上执行,神不知鬼不觉地订正了数据。但是如果不小心写错了SQL,后果可想而知。

不规范的流程引发的故障

故障现象:

某应用发布后,数据库Load迅速飙升,超过报警值,回滚发布后报警消除。

原因分析:

发现该应用发布后出现大量数据库读操作,而这些数据本来应该从分布式缓存读取。检查缓存,发现数据已经被缓存了。检查代码,发现访问缓存的那行代码被注释掉了。原来工程师在开发的时候,为了测试方便,特意注释掉读取缓存的代码,结果开发完成后忘记把注释去掉,直接提交到代码库被发布到线上环境。

经验教训:

  • 代码提交前使用diff命令进行代码比较,确认没有提交不该提交的代码。
  • 加强code review,代码在正式提交前必须被至少一个其他工程师做过code review,并且共同承担因代码引起的故障责任。

不好的编程习惯引发的故障

故障现象:

某应用更新某功能后,有少量用户投诉无法正常访问该功能,一点击就显示出错信息。

原因分析:

分析这些用户,都是第一次使用该功能,检查代码,发现程序根据历史使用记录构造一个对象,如果该对象为null,就会导致NullPointException。

经验教训:

  • 程序在处理一个输入的对象时,如果不能明确该对象是否为空,必须做空指针判断。
  • 程序在调用其他方法时,输入的对象尽量保证不是null,必要时构造空对象(使用空对象模式)。

服务框架

网站功能持续丰富后的困境与应对

网站结构示意图:

网站结构示意图

随着压力的上升,更多想到的是增加应用服务器的数量,但是这给数据库的连接数带来了比较大的压力。 此外,随着网站规模的扩大,开发人员逐渐增多,于是每个应用都在变得复杂、臃肿——在多个应用中会有重复的代码,甚至在一个应用中, 由于多人维护加上平时小需求的快速开发,也有一些代码冗余。这样的状况影响了整体的研发效率,并且对稳定性也造成了一定的影响。

在这样的情况下,想到的一个方法,就是把应用拆小,保持每个应用都不那么大。具体有两种实现方案。

根据功能拆分应用:

根据功能拆分应用

  • 一方面是数据库的连接数的压力还在,
  • 另一方面是在这些系统之间会存在一些重复的代码。

例如,在一个电子商务网站中,可能会把商品管理、交易管理等功能分在不同的系统中,而这两个系统都会调用与用户相关的功能, 这两个系统就需要将用户功能的相关代码分别写一遍,这就造成代码重复了。当然也有使用共享库的方式,但是应用起来不太方便。

服务化方案:

服务化方案

在原来的应用和底层的数据库、缓存系统、文件系统等系统之间增加了服务层。

在最初的阶段一般会采用第一种方案,因为第一种方案在小范围实现的成本较低,并且整体上也非常容易把控,并没有引入很多新内容。 此外在第一种方案中,应用和应用之间很少直接交互,更多的是通过URL跳转。 而第二种方案,就是所谓的服务化方案,使得系统看起来更立体了,应用之间有了直接的访问。

服务化的方式也会带来很多好处,首先从结构上看,系统架构更为清晰了,比之前更立体了。 从稳定性上看,一些散落在多个应用系统中的代码也变成了服务,并由专门的团队进行统一维护,这一方面可以提高代码质量, 另一方面由于核心的相对稳定,修改和发布次数会减少,这也会提高稳定性。 最后,更加底层的资源统一由服务层管理,结构更加清晰,也更利于提高效率。

服务化的方式对于研发也会产生一些影响。以前的研发模式是由几个比较大的团队去负责几个很大的应用,然后这几个应用就构成了整个网站的应用。 而随着服务化的进行,应用数量会有飞速增长,加上有服务框架的支持,调用远程服务会变得简单,而系统内部的依赖关系会变得错综复杂。 服务化的方式会让多个规模不大的团队专注在某个具体的服务或者应用上,以这种方式来应对和解决问题。

服务框架的设计与实现

应用从集中式走向分布式所遇到的问题

服务化会使得原来的一些本地调用变为远程调用。对于这种改变,研发人员最关心的是提高易用性以及降低性能损失这两方面。

服务框架要解决的问题

寻址和路由:

一般规则服务器的方式更多地运用在有状态的场景。像数据这种状态要求很高的场景,或者缓存这种尽量要有状态的场景,都会用到规则服务器的方式来解决寻址的问题。 在无状态的服务场景中,则不太用规则服务器的方式来处理。

构造请求的数据包并进行通信:

构造请求数据包其实就是把对象变为二进制数据,也就是常说的序列化。

Socket通信。

服务器定位本地具体服务:

会有一个服务注册表,是根据名称和版本号对服务实例进行的管理。关于其中的对应关系,一般是在启动时构建初始值,并且提供运行时的修改,可以说是动态发布了服务。

通过反射或者其他方式调用服务,获取结果后Socket返回。

服务调用端的设计与实现

服务调用端具体工作:

服务调用端具体工作

运行期服务框架与容器的关系

服务框架的接入也就涉及了比较常规的Spring的方式,当然,你也可以通过代码的方式来实现。这看起来很简单,不过在实际中有两个很重要的问题需要解决:

服务框架自身的部署方式问题

一是服务框架自身的部署方式问题

  • 一种方案是把服务框架作为应用的一个依赖包并与应用一起打包。
    • 通过这种方式,服务框架就变为了应用的一个库,并随应用启动。存在的问题是,如果要升级服务框架,就需要更新应用本身,因为服务框架是与应用打包放在一起的;
    • 并且服务框架没有办法接管classloader,也就不能做一些隔离以及包的实现替换工作。
  • 另外一种方案是把服务框架作为容器的一部分,这里是针对Web应用来说的,而Web应用一般用JBoss、Tomcat、Jetty等作为容器,就要遵循不同容器所支持的方法,把服务框架作为容器的一部分。
    • 然而有的情况下应用不需要容器(不是Web应用,或者不使用现有容器),那么,服务框架自身就需要变为一个容器来提供远程调用和远程服务的功能。

服务框架作为Web应用的扩展:

服务框架作为Web应用的扩展

服务框架是Web容器的一部分:

服务框架是Web容器的一部分

服务框架本身作为容器:

服务框架本身作为容器

jar包之间的冲突问题

二是实现自己的服务框架所依赖的一些外部jar包与应用自身依赖的jar包之间的冲突问题。

  • 将服务框架自身用的类与应用用到的类都控制在User-Defined Class Loader级别,这样就实现了相互间的隔离。Web容器对于多个Web应用的处理,以及OSGi对于不同Bundle的处理都采用了类似的方法。

ClassLoader结构

此外,在实际中还会遇到需要在运行时统一版本的情况,那就需要服务框架比应用优先启动,并且把一些需要统一的jar包放到User-Defined Class Loader所公用的“祖先”ClassLoader中。

通信方式选择

采用了调用者和提供者直接建立连接的方式,并且引入了一个服务注册查找中心的服务。

调用者与服务提供者直连

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

负载均衡的实现上,随机、轮询、权重是比较常见的实现方式。 在服务提供者的机器能力对等的情况下,采用随机和轮询这两种方式比较容易实现; 在被调用的服务集群的机器能力不对等的情况下,使用权重计算的方式来进行路由比较合适。

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

除了前面介绍的基础的负载均衡策略外,还有更加细粒度地控制服务路由的需求。

服务提供者在执行调用者的请求时,内部的线程模型是一个线程对应一个请求,而总的线程数量有一个限制(一般采用线程池来管理所有的工作线程)。 当系统运行时,如果并发请求量比较大,可能所有工作线程已经全部在工作了,如果这时又有新的请求进来就需要排队,当然,这些都是非常正常的逻辑。 但是如果这个服务提供者的某个方法是一个很慢的方法,会出现什么情况呢?

服务的方法被调用的具体场景

从图中=可以很直观地看出来,因为IA.m1方法的执行时间非常长,所以线程很快就都陷入了执行IA.m1方法的状态,之后再进来的请求就都需要排队等待,而且等待的时间是非常长的。

实际中计算需要的线程总数是很困难的事情,从系统可用性和经济性的角度考虑的话,控制这些慢的方法对正常情况的影响是比较合理的思路。

  • 第一种思路是增加资源保证系统的能力是超出需要的,
  • 第二种思路是隔离这些资源,从而使得快慢不同、重要级别不同的方法之间互不影响。

从客户端的角度来说,控制同一个集群中不同服务的路由并进行请求的隔离是一种可行方案。 也就是说,虽然集群中每台机器部署的代码是一样的,提供的服务也是一样的,但是通过路由的策略,让其中对于某些服务的请求到一部分机器,让另一些服务的请求到另一部分机器。

基于接口的请求路由

采用的方案是通过客户端的路由把调用服务A的请求送到图中右上方的集群,把调用服务B的请求送到图中右下方的集群。

具体实现上,一般采用的方式是把路由规则进行集中管理(后面的章节会介绍规则集中管理的服务),在具体调用者端的服务框架上获取规则后进行路由的处理, 具体来说是根据服务定位提供服务的那个集群的地址,然后与接口路由规则中的地址一起取交集,得到的地址列表再进行接下来的负载均衡算法,最终得到一个可用的地址去进行调用。

把路由的规则做得更加细致一点,可以基于接口的具体方法来进行路由。 该方式的原理与基于接口路由的原理是一样的,只是在通过接口定位到服务地址列表后,根据接口加方法名从规则中得到一个服务地址列表,再和刚才的地址列表取交集。 支持方法的路由就可以解决同一接口中不同方法调用的隔离问题了。

还可以基于参数进行路由。基于参数进行路由的实现方式和上面的方法类似。 在具体应用中用得较少,因为一般到基于方法的路由就够用了。需要对一些特定参数进行特殊处理(例如针对不同用户的特别处理等)的情况才会使用基于参数的路由。

多机房场景

双机房示例

如果不做任何处理,服务注册查找中心会把服务提供者1的所有机器看做是一个集群,尽管它们分布在两个机房。 这样,分布在两个机房的调用者1就会对等地看待分布在不同机房的服务提供者1的机器。同城机房之间一般都采用光纤直接连接,带宽足够大,延迟也可以接受, 但是,如果能够避免跨机房调用,就能提升系统稳定性,把机房间的带宽用于必要的场景,将会是一个更好的实现。

有两种方案可以实现这个想法:

  • 一是在服务注册查找中心做一些工作,通过它来甄别不同机房的调用者集群,给它们不同服务提供者的地址。
  • 另一种方式是通过路由来完成。
    • 服务注册查找中心给不同机房的调用者相同的服务提供者列表,在服务框架内部进行地址过滤,过滤的原则(如何识别机房)一般是基于接口等路由规则进行集中配置管理。
      • 一方面需要考虑两个甚至多个机房的部署能力是否对等,也就是说通过路由使服务都走本地的话,负载是否均衡。
      • 此外还有一个异常的情况需要考虑,即如果某个机房的服务提供者大面积不可用,而另外机房的服务提供者是正常运营并且有余量提供服务,那么应该如何让服务提供者大面积不可用的机房的调用者调用远程的服务呢,这是需要面对和解决的问题。

在实际中,每个机房的网段是不同的,这可以帮助区分不同的机房。 在多机房中还有一个可能遇到的问题:未必每个机房都是对称的(指既有服务调用者又有相应的服务提供者),尤其在机房很多时,这个问题会更加明显。 这时,可以考虑采用虚拟机房的概念,也就是不以物理机房为单位来做路由,而是把物理上的多个机房看做一个逻辑机房来处理路由规则, 当然,也有可能是把一个物理机房拆成多个逻辑机房,具体需要根据业务和应用的特点来做出处理。

服务调用端的流控处理

有两种方式的控制:

  • 一种是0-1开关,也就是说完全打开不进行流控;
  • 另一种是设定一个固定的值,表示每秒可以进行的请求次数,超过这个请求数的话就拒绝对远程的请求了。那些被流量控制拒绝的请求,可以直接返回给调用者,也可以进行排队。

两个维度去考虑:

  • 根据服务端自身的接口、方法做控制,也就是针对不同的接口、方法设置不同的阈值,这是为了使服务端的不同接口、方法之间的负载不相互影响。
  • 根据来源做控制,也就是对于同样的接口、方法,根据不同来源设置不同的限制,这一般用在比较基础的服务上,也就是在多个集群使用同样的服务时,根据请求来源的不同级别等进行不同的流控处理。
序列化与反序列化处理

序列化和反序列化的方式很多,需要在易用性、跨语言、性能、序列化后数据长度等方面综合进行考量。

从两个方面来看协议的部分,一个是用于通信的数据报文的自定义协议,另一个是远程过程调用本身的协议。

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

需要注意的是反序列化工作使用什么线程的问题,一般是使用IO线程,不过这样会影响IO线程的工作效率;另一种方式是把反序列化工作从IO线程转移到其他线程去做,然后再把结果传到等待的请求线程。

网络通信实现的选择

调用端采用NIO的示意图

增加了IO线程、数据队列、通信对象队列和定时任务4个部分。

  • IO线程专门负责和SOCKET连接打交道,进行数据的收发。需要发送的数据都会进入数据队列,这样,每个请求线程就不需要直接和SOCKET连接打交道了,这也为复用SOCKET连接提供了可能。数据队列的长度是需要关注的方面,因为它可能会造成内存的溢出。
  • 通信对象队列是保存了多个线程使用的通信对象,这个通信对象主要是为了阻塞请求线程,请求线程把数据放入数据队列中后会生成一个通信对象,它会进入通信对象队列并且在通信对象队列上等待。
  • 通信对象用于唤醒请求线程。如果在远程调用超时前有执行结果返回,那么IO线程就会通知通信对象,通信对象则会结束请求线程的等待,并且把结果传给请求线程,以进行后续处理。
  • 此外,也有定时任务负责检查通信对象队列中的哪些通信对象已经超时了,然后这些通信对象会通知请求线程已经超时的事实。
支持多种服务调用方式

第一种方式是Oneway。Oneway是一种只管发送请求而不关心结果的方式。

在NIO方式下使用Oneway的话,会比前面的同步调用简单很多。

Oneway方式

只需要把要发送的数据放入数据队列,然后就可以继续处理后续的任务了;而IO线程也只需要从数据队列中读到数据,然后通过SOCKET连接送出去就好了。 Oneway方式不关心对方是否收到了数据,也不关心对方收到数据后做什么或有什么返回。这就基本等价于一个不保证可靠送达的通知。

第二种方式是Callback。这种方式下请求方发送请求后会继续执行自己的操作,等对方有响应时进行一个回调。

Callback方式

请求者设置了回调对象,把数据写入数据队列后就继续自己的处理了。 后面的IO线程的通信方式与之前看到的相同,只是当收到服务提供者的返回后,IO线程会通知回调对象,这时就执行回调的方法了。 而如果需要支持超时,同样可以通过定时任务的方式来完成,如果已经超时却没有返回,那么同样需要执行回调对象的方法,只是要告知是已经超时没有结果。 这里需要注意的一点是,如果不再引入新的线程,那么回调的执行要么是在IO线程中,要么是在定时任务的线程中了,还是建议用新的线程来执行回调,而不要因为回调本身的代码执行时间久等问题影响了IO线程或者定时任务。

第三种方式是Future。Java中Future是一个非常便利的方式。

Future方式

使用Future方式,同样是先把Future放入队列,然后把数据放入队列,接着就在线程中进行处理,等到请求线程的其他工作处理结束后,就通过Future来获取通信结果并直接控制超时。 IO线程仍然是从数据队列中得到数据后再进行通信,得到结果后会把它传给Future。

如果各服务之间存在依赖,那就只能等到前一个服务返回后才能进行后续的服务调用。 之所以能够方便地使用并行调用优化,就是因为有Future方式的支持。 并且因为底层使用的是NIO方式,所以并行方式并没有产生额外的开销,反而能使总体消耗时间缩短,在同样的线程数配置、同样硬件的情况下,会使得单位时间的处理能力得到明显提升(具体提升的幅度与单个请求内部远程调用的并行度有关)。

第四种方式是可靠异步。可靠异步要保证异步请求能够在远程被执行,一般是通过消息中间件来完成这个保证的。

四种常见的异步远程通信方式:

  • Oneway是一个单向的通知;
  • Callback则是回调,是一种很被动的方式,Callback的执行不是在原请求线程中;
  • 而Future是一种能够主动控制超时、获取结果的方式,并且它的执行仍然在原请求线程中;
  • 可靠异步方式能保证异步请求在远程被执行。

服务提供端的设计与实现

服务提供端具体工作:

服务提供端具体工作

服务端的工作有两部分,一是对本地服务的注册管理,二是根据进来的请求定位服务并执行。

如何暴漏远程服务

服务需要注册到服务注册查找中心后才能被服务调用者发现,所以,ProviderBean需要将自己所代表的服务注册到服务注册查找中心。 另外,当请求调用端定位到提供服务的机器并且请求被送到提供服务的机器上后,在本机也需要有一个服务与具体对象的对应关系,ProviderBean也需要在本地注册服务和对应服务实例的关系。

服务端对请求处理的流程

服务端的通信部分同样也不能用BIO来实现,而要采用NIO的方式来实现。 接收到请求后,通过协议解析及反序列化,可以得到请求发送端调用服务方法的具体信息,根据其中的服务名称、版本号找到本地提供服务的具体对象,然后再用传过来的参数调用相关对象的方法就可以了。

请求处理流程

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

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

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

执行不同服务的线程池隔离

之前在请求调用端重点介绍过路由的做法,其中提到了引入服务、方法、参数的路由,并且通过这样的方式解除了调用慢服务对于其他服务的影响。 在服务提供端,有另外一种方法来解决这个问题。

服务端线程池隔离。具体的做法其实十分类似于请求调用方根据接口、方法、参数进行的路由。 在服务提供端,工作线程池不是一个,而是多个,当定位到服务后,根据服务名称、方法、参数来确定具体执行服务调用的线程池是哪个。 这样,不同线程池之间就是隔离的,不会出现争抢线程资源的情况。

将执行不同服务的线程池隔离:

将执行不同服务的线程池隔离

服务提供端的流控处理

将执行服务的线程池隔离会带来服务端稳定性的提升,而流控同样是保证服务端稳定性的重要方式。

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

整个服务框架的功能可以分为服务调用者和服务提供者两方面,此外像序列化、协议、通信等是公用的功能。 在具体实现上,是把这些功能都放在一起形成一个完整的服务框架,而不是分为服务调用者框架和服务提供者框架, 因为其个服务调用者的服务提供者,可能是另一个服务提供者的服务调用者,它们是相对的。

整个服务框架作为一个产品,可以让集中在单机内部的调用变为远程的服务化。在具体应用的使用场景中,一个完整的服务框架可能需要被改变一些行为, 例如负载均衡的部分,默认是随机选择服务地址,在有些场景下就需要用权重。 因此,服务框架必须做到模块化且可配置;此外,一些特殊的场景需要使用者来具体扩展服务框架的原有功能。 这就要求服务框架被很好地模块化,且模块可替换,并留有一定的扩展点来扩展原有功能。

服务升级

  • 第一种情况是接口不变,只是代码本身进行完善。
    • 这样的情况处理起来比较简单,因为提供给使用者的接口、方法都没有变,只是内部的服务实现有变化。这种情况下,采用灰度发布的方式验证然后全部发布就可以了。
  • 第二种情况是需要修改原有的接口,这又分为以下两种情况。
    • 一是在接口中增加方法,这个情况比较简单,直接增加方法就行了。而且在这样的情况下,需要使用新方法的调用者就使用新方法,原来的调用者继续使用原来的方法即可。
    • 二是要对接口的某些方法修改调用的参数列表。这种情况相对复杂一些。有几种方式来应对:
      • 对使用原来方法的代码都进行修改,然后和服务端一起发布。这从理论上说是个办法,但是不太可行,因为这要求同时发布多个系统,而且一些系统可能并不会从调整参数后的方法那里受益。
      • 通过版本号来解决。这是比较常用的方式,使用老方法的系统继续调用原来版本的服务,而需要使用新方法的系统则使用新版本的服务。
      • 在设计方法上考虑参数的扩展性。这是一个可行的方式,但是不太好,因为参数列表可扩展一般就意味着是采用类似Map的方式来传递参数,这样不直观,并且对参数的校验会比较复杂。

实战中的优化

服务拆分

要拆分的服务是需要为多方提供公共功能的,对于那些比较专用的实现,查出来它们是独立部署在远程机器上来提供服务的,这不仅没必要,还会增加系统的复杂性。

服务的粒度

这是一个很难量化回答的问题,只能说需要根据业务的实际情况来划分服务。

优雅和实用的平衡

服务化的架构看起来比较优雅,可毕竟多一次调用就比之前多走了一次网络,一些功能直接在服务调用者的机器上实现会更加合适、经济。

服务提供者完成与缓存、数据库的交互

服务调用者直接读缓存

分布式环境中的请求合并

单机多线程的应用中可能会有一些操作比较消耗系统资源,如果能够进行一些合并的话,就会提升处理效率。

多线程重复计算

可以增加缓存来减少数据读取和计算的工作量。

解析完参数后,检查是否有其他线程在计算了,如果没有,则进行计算;如果已经有线程在计算相同的数据,就等待其他线程的计算结果。具体的实现上可以依赖Future方式。

引入合并请求后的线程处理流程

但是把这个思路移植到分布式的环境中时,会有新的问题要解决:

分布式环境下请求合并的问题

相对于单机的多线程,分布式环境会涉及多个节点。在单机中判断是否有同样任务在执行是很简单的,而在多机环境中,则需要由独立于服务调用者、服务提供者之外的节点来完成相关工作,也就是需要分布式的锁服务来控制。 但是这是需要进行权衡的,因为在分布式系统中,如果每个请求都要走一次分布式的锁服务来进行控制,就会有额外的开销。

另一个思路是,在服务调用端不是把请求随机分发给服务提供者,而是根据一定的规则把同样的请求发送到同一个服务提供者上,然后在服务提供者的机器上做单机控制,这样通过路由策略的选择,可以不引入分布式锁服务,减少了复杂性。

此外,对于比较消耗系统资源的操作,不论是使用分布式锁服务,还是采用路由的方式把请求送到特定机器,在服务调用者上都可以进行单机多线程的控制。具体采用何种方式,需要根据具体场景来决定,而且需要数据的支持来做出最后的决定。

为服务化护航的服务治理

服务治理是在系统采用服务框架后,为服务化保驾护航的功能集合。

以将服务治理分为管理服务和查看服务这两个方面,也就相当于数据的写和读:

管理需要去控制、操作整个分布式系统中的服务,而查看则是看运行时的状态或者一些具体信息、历史数据等。

服务查看具体包括哪些内容:

  • 服务信息:服务最基本的信息。
    • 服务编码,即数字化的服务编码
    • 支持编码的注册
    • 根据编码定位服务信息
  • 服务质量:根据被调用服务的出错率、响应时间等数据对服务质量进行的评估。
    • 最好、最差的服务排行
    • 各个服务的质量趋势
    • 各种查询条件的支持
  • 服务容量:根据所提供服务的总能力以及当前所使用容量进行的评估,其中能力是指对于请求数量方面的支撑情况。
    • 服务容量与当前水位的展示
    • 历史趋势图
    • 根据水位的高低排序
    • 各种查询条件的支持
  • 服务依赖:根据服务被调用以及服务调用其他服务的情况,给出服务与其上下游服务的依赖关系,里面除了服务间定性的依赖关系外,还有定量的数据信息。
    • 依赖服务展示
    • 被依赖展示
    • 依赖变化
  • 服务分布:提供同样服务的机器的具体分布情况,主要是看跨机房的分布情况。
    • 服务在不同机房分布
    • 服务在不同机柜分布
    • 分布不均衡服务列表
  • 服务统计:服务运行时信息的统计。
    • 调用次数统计和排名
    • 出错次数统计和排名
    • 出错率统计和排名
    • 响应时间统计和排名
    • 响应时间趋势
    • 出错率趋势
  • 服务元数据:服务基本信息的查看。
    • 服务的方法和参数
  • 服务查询:提供根据各种条件来检索服务进而查看服务的各种信息的功能。
    • 服务的应用负责人、测试负责人
    • 服务所属的应用名称
    • 服务发布时间
    • 服务提供者的地址列表
    • 服务容量
    • 服务质量
    • 服务调用次数
    • 服务依赖
    • 服务版本及归组信息等
  • 服务报表:主要提供非实时服务的各种统计信息的报表,包括不同时间段的对比以及分时统计的信息。
  • 服务监视:提供对于服务运行时关键数据的采集、规则处理和告警。注意这里是服务监视而非监控,主要是完成对于服务运行数据的收集和处理,但不提供控制,通过监视发现问题后再在相应的服务管理中进行管理工作。服务监视只提供用于决策的数据基础,并且根据已定义的规则进行告警。

服务管理的角度看一下有哪些事情要做:

  • 服务上下线:前面看到服务是通过ProviderBean自动注册的,在治理中还需要控制服务的上下线。
    • 针对一个服务所有机器的上线和下线操作
    • 针对指定机器的上线和下线操作
    • DoubleCheck控制
  • 服务路由:是对服务路由策略的管理,就是之前看到的基于接口、方法、参数的路由的集中管理。
    • 路由管理界面支持
    • 路由信息更改前后对比和验证
    • 路由配置多版本管理和回滚
    • DoubleCheck控制
  • 服务限流降级:对应的是之前介绍过的流控部分,服务降级是对服务对外流控的统一管理。当然,除了管理流控外,还集中管理了服务上的很多开关。例如,在服务调用或者执行的地方,除了限流还可以停止一些非重要功能的处理,以便主流程可以继续执行。
    • 根据调用来源限流
    • 根据具体服务限流
    • 针对服务开关降级
    • 流控、降级配置多版本管理和回滚
    • DoubleCheck控制
  • 服务归组:是在集中的控制台调整服务的分组信息,对应在服务提供者的配置属性中看到的group属性,可以在集中的控制台对服务的分组直接进行管理。
    • 归组规则的多版本管理和回滚
    • 归组规则预览
    • 归组规则的影响范围和评估
    • DoubleCheck控制
  • 服务线程池管理:是对于服务提供者的服务执行的工作线程池的管理。
    • 调用方的线程管理,主要是最大并发的管理
    • 服务端线程工作状况查询
    • 服务端针对不同服务的多个业务线程池的管理
    • DoubleCheck控制
  • 机房规则:是针对多机房、虚机房规则的管理。
    • 规则查询和发布校验
    • 规则多版本管理和回滚
    • DoubleCheck控制
  • 服务授权:随着服务和服务调用者的增多,一些重要服务的使用是需要有授权和鉴权的支持的,服务授权就是针对服务调用者的授权管理。
    • 授权信息查询
    • 授权规则多版本支持和回滚
    • DoubleCheck控制

服务框架与ESB的对比

企业服务总线(ESB)也是系统在采用服务化时的一个重要支撑产品。ESB的概念是从面向服务体系架构中(SOA)发展过来的,它是对多样系统中的服务调用者和服务提供者的解耦。 ESB本身也可以解决服务化的问题,它提供了服务暴露、接入、协议转换、数据格式转换、路由等方面的支持。

ESB与服务框架主要有两个差异。

  • 第一,服务框架是一个点对点的模型,而ESB是一个总线式的模型;
  • 第二,服务框架基本上是面向同构的系统,不会重点考虑整合的需求,而ESB会更多地考虑不同厂商所提供服务的整合。

ESB结构图

数据访问层

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

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

在不靠升级硬件的情况下,能够想到的处理方案就是给现有数据库减压。减压的思路有三个:

  • 一是优化应用,看看是否有不必要的压力给了数据库(应用优化);
  • 二是看看有没有其他办法可以降低对数据库的压力,例如引入缓存、加搜索引擎等;
  • 最后一种思路就是把数据库的数据和访问分到多台数据库上,分开支持。

数据拆分有两种方式,一个是垂直拆分,一个是水平拆分。

  • 垂直拆分就是把一个数据库中不同业务单元的数据分到不同的数据库里面;
  • 水平拆分是根据一定的规则把同一业务单元的数据拆分到多个数据库中。

将原来在一个数据库中的数据拆分到了不同的数据库中。所以原来单机数据库可以支持的特性现在就未必支持了。

垂直拆分会带来如下影响:

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

水平拆分会带来如下影响:

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

在实践中,只要是操作数据被拆分到不同库中的情况,就都会受到影响,例如原来的一些存储过程、触发器等也需要改写才能完成相应的工作了。

单机变为多机后,事务如何处理

分布式事务的知识

分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于分布式系统的不同节点上。 对于传统的单机上的事务,所有的事情都在这一台机器上完成,而在分布式事务中,会有多个节点参与。

分布式事务的模型与规范

X/Open组织(即现在的The Open Group)提出了一个分布式事务的规范——XA。

X/Open组织定义的分布式事务处理模型——X/Open DTP模型(X/Open Distributed Transaction Processing Reference Model)。 在X/Open DTP模型中定义了三个组件,即Application Program、Resource Manager和Transaction Manager。

  • Application Program(AP)
    • 即应用程序,可以理解为使用DTP模型的程序。它定义了事务边界,并定义了构成该事务的应用程序的特定操作。
  • Resource Manager(RM)
    • 资源管理器,可以理解为一个DBMS系统,或者消息服务器管理系统。应用程序通过资源管理器对资源进行控制,资源必须实现XA定义的接口。资源管理器提供了存储共享资源的支持。
  • Transaction Manager(TM)
    • 事务管理器,负责协调和管理事务,提供给AP应用程序编程接口并管理资源管理器。事务管理器向事务指定标识,监视它们的进程,并负责处理事务的完成和失败。
    • 事务分支标识(称为XID)由TM指定,以标识一个RM内的全局事务和特定分支。它是TM中日志与RM中日志之间的相关标记。两阶段提交或回滚需要XID,以便在系统启动时执行再同步操作(也称为再同步(resync)),或在需要时允许管理员执行试探操作(也称为手工干预)。

在这三个组件中,AP可以和TM、RM通信,TM和RM之间可以互相通信。DTP模型里面定义了XA接口,TM和RM通过XA接口进行双向的通信。

分布式事务AP/TM/RM之间的关系

AP和RM是一定需要的,而事务管理器TM是额外引入的。之所以要引入事务管理器,是因为在分布式系统中,两台机器理论上无法达到一致的状态, 需要引入一个单点进行协调。事务管理器控制着全局事务,管理事务的生命周期,并协调资源。

在DTP中还定义了其他几个概念:

  • 事务:一个事务是一个完整的工作单元,由多个独立的计算任务组成,这多个任务在逻辑上是原子的。
  • 全局事务:一次性操作多个资源管理器的事务就是全局事务。
  • 分支事务:在全局事务中,每一个资源管理器有自己独立的任务,这些任务的集合是这个资源管理器的分支任务。
  • 控制线程:用来表示一个工作线程,主要是关联AP、TM和RM三者的线程,也就是事务上下文环境。简单地说,就是用来标识全局事务和分支事务关系的线程。

整体DTP的模型:

DTP模型

  • AP与RM之间,可以使用RM自身提供的native API进行交互,这种方式就是使用RM的传统方式,并且这个交互不在TM的管理范围内。另外,当AP和RM之间需要进行分布式事务的时候,AP需要得到对RM的连接(此链接由TM管理),然后使用XA的native API来进行交互。
  • AP与TM之间,该例子中使用的是TX接口,也是由X/Open所规范的。它用于对事务进行控制,包括启动事务、提交事务和回滚事务。
  • TM与RM之间是通过XA接口进行交互的。TM管理了到RM的连接,并实现了两阶段提交。
两段提交

两阶段提交协议,即2PC,Two Phase Commitment Protocol。之所以称为两阶段提交,是相对于单库的事务提交方式来说的。 在单库上完成相关的数据操作后,就会直接提交或者回滚,而在分布式系统中,在提交之前增加了准备的阶段,所以称为两阶段提交。

第一阶段:

第一阶段

第二阶段:

第二阶段

出现问题的第一阶段:

出现问题的第一阶段

第一阶段出现问题后的第二阶段:

第一阶段出现问题后的第二阶段

前面对两阶段提交的介绍都是在理想状态下的情况。 在实际当中,由于事务管理器自身的稳定性、可用性的影响,以及网络通信中可能产生的问题,出现的情况会复杂很多。 此外,事务管理器在多个资源之间进行协调,它自身要进行很多日志记录的工作。网络上的交互次数的增多以及引入事务管理器的开销,是使用两阶段提交协议使分布式事务的开销增大的两个方面。

因此,在进行垂直拆分或者水平拆分后,需要想清楚是否一定要引入两阶段的分布式事务,在必要的情况下才建议使用。

大型网站一致性的基础理论——CAP/BASE

参考《CAP原理》

分布式事务希望在多机环境下可以像单机系统那样做到强一致,这需要付出比较大的代价。而在有些场景下,接收状态并不用时刻保持一致,只要最终一致就行。

CAP的涵义:

  • Consistency:all nodes see the same data at the same time.
    • 即所有的节点在同一时间读到同样的数据。这就是数据上的一致性(用C表示),也就是当数据写入成功后,所有的节点会同时看到这个新的数据。
  • Availability:a guarantee that every request receives a response about whether it was successful or failed.
    • 保证无论是成功还是失败,每个请求都能够收到一个反馈。这就是数据的可用性(用A表示),这里的重点是系统一定要有响应。
  • Partition-Tolerance:the system continues to operate despite arbitrary message loss or failure of part of the system.
    • 即便系统中有部分问题或者有消息的丢失,但系统仍能够继续运行。这被称为分区容忍性(用P表示),也就是在系统的一部分出现问题时,系统仍能继续工作。

但是,在分布式系统中并不能同时满足上面三项。

CAP理论

  • 选择CA,放弃分区容忍性,加强一致性和可用性。这其实就是传统的单机数据库的选择。
  • 选择AP,放弃一致性,追求分区容忍性及可用性。这是很多分布式系统在设计时的选择,例如很多NoSQL系统就是如此。
  • 选择CP,放弃可用性,追求一致性和分区容忍性。这种选择下的可用性会比较低,网络的问题会直接让整个系统不可用。

BASE模型。

BASE涵义如下:

  • Basically Available:基本可用,允许分区失败。
  • Soft state:软状态,接受一段时间的状态不同步。
  • Eventually consistent:最终一致,保证最终数据的状态是一致的。

当在分布式系统中选择了CAP中的A和P后,对于C,采用的方式和策略就是保证最终一致,也就是不保证数据变化后所有节点立刻一致,但是保证它们最终是一致的。 在大型网站中,为了更好地保持扩展性和可用性,一般都不会选择强一致性,而是采用最终一致的策略来实现。

比两阶段提交更轻量一些的Paxos协议

Paxos协议,它是一个比两阶段提交要轻量的保证一致性的协议。

在分布式系统中,节点之间的信息交换有两种方式,一种是通过共享内存共用一份数据;另一种是通过消息投递来完成信息的传递。 而在分布式系统中,通过消息投递的方式会遇到很多意外的情况,例如网络问题、进程挂掉、机器挂掉、进程很慢没有响应、进程重启等情况, 这就会造成消息重复、一段时间内部不可达等现象。Paxos协议是帮助解决分布式系统中一致性问题的一个方案。

拜占庭将军问题(Byzantine Faults)是指可能导致一个组件发生任意行为(常常是意料之外的)的故障。这个故障的组件可能会破坏应用的状态,甚至是恶意行为。 系统是建立在假设会发生这些故障,需要更高程度的复制并使用安全原语的基础上。

拜占庭位于现在土耳其的伊斯坦布尔,是东罗马帝国的首都。当时拜占庭罗马帝国国土辽阔,防御敌人的各个军队都分隔很远,将军与将军之间只能靠信差传消息。 在战争时,拜占庭军队内所有将军和副官必须达成共识,决定出是否有赢的机会才去攻打敌人的阵营。 但是,在军队内可能有叛徒或敌军的间谍,扰乱将军们的决定又扰乱整体军队的秩序,他们使得最终的决定结果并不代表大多数人的意见。 这时,在已知有成员谋反的情况下,其余忠诚的将军应该如何不受叛徒的影响达成一致的协议?拜占庭将军问题就此形成。

使用Paxos协议有一个前提,那就是不存在拜占庭将军问题。 也就是说,拜占庭将军问题是一个没有办法保证可信的通信环境的问题,Paxos的前提是有一个可信的通信环境,也就是说信息都是准确的,没有被篡改。

Paxos算法的提出过程是,虚拟了一个叫做Paxos的希腊城邦,并通过议会以决议的方式介绍Paxos算法。

首先把议员的角色分为了Proposers、Acceptors和Learners,议员可以身兼数职:

  • Proposers,提出议案者,就是提出议案的角色。
  • Acceptors,收到议案后进行判断的角色。Acceptors收到议案后要选择是否接受(Accept)议案,若议案获得多数Acceptors的接受,则该议案被批准(Chosen)。
  • Learners,只能“学习”被批准的议案,相当于对通过的议案进行观察的角色。

在Paxos协议中,有两个名词:

  • Proposal,议案,由Proposers提出,被Acceptors批准或否决。
  • Value,决议,议案的内容,每个议案都是由一个{编号,决议}对组成。

在角色划分后,可以更精确地定义问题:

  • 决议(Value)只有在被Proposers提出后才能被批准(未经批准的决议称为“议案(Proposal)”)。
  • 在Paxos算法的执行实例中,一次只能批准(Chosen)一个Value。
  • Learners只能获得被批准(Chosen)的Value。

对议员来说,每个议员有一个结实耐用的本子和擦不掉的墨水来记录议案,议员会把表决信息记在本子的背面,本子上的议案永远不会改变,但是背面的信息可能会被划掉。每个议员必须(也只需要)在本子背面记录如下信息:

  • LastTried[p],由议员p试图发起的最后一个议案的编号,如果议员p没有发起过议案,则记录为负无穷大。
  • PreviousVote[p],由议员p投票的所有表决中,编号最大的表决对应的投票,如果没有投过票则记录为负无穷大。
  • NextBallot[p],由议员p发出的所有LastVote(b,v)消息中,表决编号b的最大值。

基本协议的完整过程如下:

  • 议员p选择一个比LastTried[p]大的表决编号b,设置LastTried[p]的值为b,然后将NextBallot(b)消息发送给某些议员。
  • 从p收到一个b大于NextBallot[q]的NextBallot(b)消息后,议员q将NextBallot[q]设置为b,然后发送一个LastVote(b,v)消息给p,其中v等于PreviousVote [q](b≤NextBallot[q]的NextBallot(b)消息将被忽略)。
  • 在某个多数集合Q中的每个成员都收到一个LastVote(b,v)消息后,议员p发起一个编号为b、法定人数集为Q、议案为d的新表决。然后它会给Q中的每一个牧师发送一个BeginBallot(b,d)消息。
  • 在收到一个b=NextBallot[q]的BeginBallot(b,d)消息后,议员q在编号为b的表决中投出他的一票,设置PreviousVote [p]为这一票,然后向p发送Voted(b,q)消息。
  • p收到Q中每一个q的Voted(b,q)消息后(这里Q是表决b的法定人数集合,b=LastTried[p]),将d(这轮表决的法令)记录到他的本子上,然后发送一条Success(d)消息给每个q。
  • 一个议员在接收到Success(d)消息后,将决议d写到他的本子上。

Paxos不是那么容易理解的,不过总结一下核心的原则就是少数服从多数。

如果系统中同时有人提议案的话,可能会出现碰撞失败,然后双方都需要增加议案的编号再提交的过程。 而再次提交可能仍然存在编号冲突,因此双方需要再增加编号去提交。这就会产生活锁。

解决的办法是在整个集群当中设一个Leader,所有的议案都由他来提,这样就可以避免这种冲突了。 这其实是把提案的工作变为一个单点,而引发的新问题是如果这个Leader出问题了该如何处理,那就需要再选一个Leader出来。

集群内数据一致性算法实例

亚马逊Dynamo的论文中对Quorum和Vector Clock算法有比较详细的介绍。

Quorum,它是用来权衡分布式系统中数据一致性和可用性的。

引入三个变量:

  • N:数据复制节点数量。
  • R:成功读操作的最小节点数。
  • W:成功写操作的最小节点数。

如果W+R>N,是可以保证强一致性的,而如果W+R≤N,是能够保证最终一致性的。

根据前面的CAP理论,需要在一致性、可用性和分区容忍性方面进行权衡。例如,如果让W=N且R=1,就会大大降低可用性,但是一致性是最好的。

Vector Clock的思路是对同一份数据的每一次修改都加上“<修改者,版本号>”这样一个信息,用于记录修改者的信息及版本号,通过这样的信息来帮助解决一些冲突。

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

多机的Sequence问题与处理

Oracle里,提供对Sequence的支持;在MySQL里,提供对Auto Increment字段的支持,都能很容易地实现一个自增的不重复Id的序列。 在分库分表后,这就成了一个难题。可以从下面两个方向来思考和解决这个问题:

  • 唯一性
  • 连续性

如果只是考虑Id的唯一性的话,那么可以参考UUID的生成方式,或者根据自己的业务情况使用各个种子(不同维度的标识,例如IP、MAC、机器名、时间、本机计数器等因素)来生成唯一的Id。 这样生成的Id虽然保证了唯一性,但在整个分布式系统中的连续性不好。

连续性是指在整个分布式环境中生成的Id的连续性。在单机环境中,其实就是一个单点来完成这个任务,在分布式系统中,可以用一个独立的系统来完成这个工作。

一个实现方案:把所有Id集中放在一个地方进行管理,对每个Id序列独立管理,每台机器使用Id时都从这个Id生成器上取。

几个关键问题需要解决:

  • 性能问题。每次都远程取Id会有资源损耗。一种改进方案是一次取一段Id,然后缓存在本地,这样就不需要每次都去远程的生成器上取Id了。
    • 但是也会带来问题:如果应用取了一段Id,正在用时完全宕机了,那么一些Id号就浪费不可用了。
  • 生成器的稳定性问题。Id生成器作为一个无状态的集群存在,其可用性要靠整个集群来保证。
  • 存储的问题。这确实是需要去考虑的问题,底层存储的选择空间较大,需要根据不同类型进行对应的容灾方案。

两种储存方式:

在底层使用一个独立的存储来记录每个Id序列当前的最大值,并控制并发更新,这样一来Id生成器的逻辑就很简单了。

独立Id生成器方式

一种改变是直接把Id生成器舍掉,把相关的逻辑放到需要生成Id的应用本身就行了。 也就是说,去掉应用和存储之间的这个独立部署的生成器,而在每个应用上完成生成器要做的工作,即读取可用的Id或者Id段,然后给应用的请求使用。

生成器嵌入到应用的方式

不过因为没有中心的控制节点,并且不希望生成器之间还有通信(这会使系统非常复杂),因此数据的Id并不是严格按照进入数据库的顺序而增大的, 在管理上也需要有额外的功能,这些是需要权衡之处。

应对多机的数据查询

跨库Join

解决的思路:

  • 在应用层把原来数据库的Join操作分成多次的数据库操作。
    • 有用户基本信息的数据表,也有用户出售的商品的信息表,需求是查出来登记手机号为138XXXXXXXX的用户在售的商品总数。
    • 这在单库时用一个SQL的Join就解决了,而如果商品信息与用户信息分开了,就需要先在应用层根据手机号找到用户Id,然后再根据用户Id找到相关的商品总数。
  • 数据冗余,也就是对一些常用信息进行冗余,这样就可以把原来需要Join的操作变为单表查询。这需要结合具体业务场景。
  • 借助外部系统(例如搜索引擎)解决一些跨库的问题。
外键约束

外键约束的问题比较难解决,不能完全依赖数据库本身来完成之前的功能了。 如果要对分库后的单库做外键约束,就要求分库后每个单库的数据是内聚的,否则就只能靠应用层的判断、容错等方式了。

跨库查询的问题及解决
数据库分库分表的演化

合并查询问题产生的根源在于在进行水平分库分表时,把一张逻辑上的表分成了多张物理上的表。 例如,有一个用户信息表,根据用户Id进行分库分表后,物理上就会分成很多用户信息表。

数据分库分表的变化

随着数据量、访问量的上升,需要经历分库分表,此时用户信息在物理上是分布在多个数据库的多张表中的,也就是说一张逻辑上的表对应了多张物理上的表,在应用中,对这张逻辑表的查询就需要做跨库跨表的合并了。

这个场景和前面的跨库Join还不同,跨库Join是在不同的逻辑表之间的Join,在分库后这些Join可能需要跨多个数据库,而现在看到的合并查询是针对一个逻辑表的查询操作,但因为物理上分到了多个库多个表,因而产生了数据的合并查询。

分库分表后的查询问题

数据分库:

数据分库

数据分库分表:

数据分库分表

在这样的情况下,就需要对查询结果在应用上进行合并,这相对比较简单,但是在一些场景下需要进行较为复杂的操作:

  • 排序,即多个来源的数据查询出来后,在应用层进行排序的工作。如果从数据库中查询出的数据是已经排好序的,那么在应用层要进行的就是对多路的归并排序;如果查询出的数据未排序,就要进行一个全排序。
  • 函数处理,即使用Max、Min、Sum、Count等函数对多个数据来源的值进行相应的函数处理。
  • 求平均值,从多个数据来源进行查询时,需要把SQL改为查询Sum和Count,然后对多个数据来源的Sum求和、Count求和后,计算平均值,这是需要注意的地方。
  • 非排序分页,这需要看具体实现所采取的策略,是同等步长地在多个数据源上分页处理,还是同等比例地分页处理。
    • 同等步长的意思是,分页的每页中,来自不同数据源的记录数是一样的;
    • 同等比例的意思是,分页的每页中,来自不同数据源的数据数占这个数据源符合条件的数据总数的比例是一样的。
  • 排序后分页,这是把排序和分页放在一起的情况,也是最复杂的情况,最后需要呈现的结果是数据按照某些条件排序并进行分页显示。数据是来自不同数据源的,因此必须把足够的数据返回给应用,才能得到正确的结果,复杂之处就在于将足够的数据返给应用。

同步长:

多数据源等步长合并数据

同比例:

多数据源等比例合并数据

每个小方格代表了一条数据,其中的数字代表该条信息在第几页结果中出现。

排序负担:

内部排好序的数据源的数据:

内部排好序的数据源的数据

内部排好序的数据源的数据

排序合并后的第一页是由来自两个数据源的各2条数据组成;
排序合并后的第二页是全部来自数据源1的4条数据;
第三页则是由两个数据源的各2条数据组成;
而第四页是由来自数据源1的1条数据和数据源2的3条数据组成。

因此,要从数据源中取足够多的数据才能保证结果的正确。

在取第一页结果时,应该考虑的最极端情况是最终合并后的结果可能都来自一个数据源,所以需要从每个数据源取足一页的数据。 例如,第一页应该从每个数据源取4条数据,然后把这8条数据在应用中进行归并排序。 对于第二页,不是把每个数据源的第二页取回来进行合并排序,而是需要把每个数据源的前两页也就是前8条数据都取回来进行归并排序,才能得到正确的结果。 如果要取第100页的数据,就要从每个数据源取前100页数据进行归并排序,才能得到正确的结果。 也就是说越往后翻页,承受的负担越重。

排序分页是合并操作中最复杂的情况了,因此,在访问量很大的系统中,应该尽量避免这种方式,尤其是排序后需要翻很多页的情况。

数据访问层的设计与实现

数据访问层就是方便应用进行数据读/写访问的抽象层,在这个层上解决各个应用通用的访问数据库的问题。 在分布式系统中,也把数据访问层称为分布式数据访问层,有时也简称为数据层。

如何对外提供数据访问层的功能

对外提供数据访问层的方式
  • 第一种方式是为用户提供专有API,不过这种方式是不推荐的,它的通用性很差,甚至可以说没有通用性。
    • 一般来说采用这种方式是为了便于实现功能,或者这种方式对一些通用接口方式有比较大的改动和扩展。
    • 即便采用专有API方式,很多系统也会同时提供通用的访问方式,以便于应用的使用和切换。
  • 第二种方式是通用的方式。数据层自身可以作为一个JDBC的实现,也就是暴露出JDBC的接口给应用,这时应用的使用成本就很低了,和使用远程数据库的JDBC驱动的方式是一样的,迁移成本也非常低。
  • 还有一种方式是基于ORM或类ORM接口的方式,可以说这种方式介于上面两种方式之间。
    • 应用为了开发的高效和便捷,在使用数据库时一般会使用ORM或类ORM框架,例如iBatis、hibernate、Spring JDBC等,可以在自己应用使用的ORM框架上再包装一层,用来实现数据层的功能,对外暴露的仍然是原来框架的接口。
    • 这样的做法对于某些功能来说实现成本比较低,并且在兼容性方面有一定的优势,例如原来系统都用iBatis的话,对于应用来说,iBatis之上的封装就比较透明了。

不同接口数据层的结构

通过JDBC方式使用的数据层是兼容性和扩展性最好的,实现成本上也是相对最高的。 底层封装了某个ORM框架或者类ORM框架的方式具备一定的通用性(不能提供给另外的ORM/类ORM框架用),实现成本相对JDBC接口方式的要低。 而采用专有API的方式是在特定场景下的选择。

除了对外提供接口的方式的差别,在具体场景的实现上也会有差别。 专有API的方式和对外提供JDBC接口的方式都直接使用了下层数据库提供的JDBC驱动,因此更加灵活, 而基于ORM/类ORM框架的方式则在数据层和JDBC驱动之间隔了一个第三方的ORM/类ORM框架,这在有些场景下会造成一些影响。

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

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

因为直接使用JDBC,所以可以对多个数据源使用数据结构中对两个有序链表进行合并排序的方式,而且无论数据如何分布,最多只会浪费一个生成的对象。 在实际中,每页都会有数十条数据,在获取后面页的内容时,直接基于JDBC的优势是比较明显的。

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

按照数据层流程的顺序看数据层设计

数据层的整理流程:

数据层的整理流程

SQL解析阶段的处理

在具体实践中,SQL解析主要考虑的问题有两个。

  • 一是对SQL支持的程度,是否需要支持所有的SQL,这需要根据具体场景来做决定;
  • 二是支持多少SQL的方言,对于不同厂商超出标准SQL的部分要支持多少。这些问题没有标准答案,需要根据实际情况去做选择。

具体解析时是使用antlr、javacc还是其他工具,就看自己的选择了,当然也可以自己手写。

在进行SQL解析时,对于解析的缓存可以提升解析速度。当然需要注意缓存的容量限制,一般系统中执行的SQL数量相对可控,不过为了安全,解析的缓存需要加上数量上限。

通过SQL解析可以得到SQL中的关键信息,例如表名、字段、where条件等。而在数据层中,一个很重要的事情是根据执行的SQL得到被操作的表,根据参数及规则来确定目标数据源连接。 这一部分也可以通过提示(hint)的方式实现,该方式会把一些要素直接传进来,而不用去解析整个SQL语句。

使用hint方式的一般情况是:

  • SQL解析并不完备(这一般是在发展过程中遇到的问题)。
  • SQL中不带有分库条件,但实际上是可以明确指定分库的。
规则处理阶段
采用固定哈希算法作为规则

固定哈希的方式为,根据某个字段(例如用户id)取模,然后将数据分散到不同的数据库和表中。 除了根据id取模,还经常会根据时间维度,例如天、星期、月、年等来存储数据,这一般用于数据产生后相关日期不进行修改的情况,否则就要涉及数据移动的问题了。 根据时间取模多用在日志类或者其他与时间维度密切相关的场景。通常将周期性的数据放在一起,这样进行数据备份、迁移或现有数据的清空都会很方便。

根据id取模的例子:

根据id取模的例子

根据id分库分表的例子:

根据id分库分表的例子

通过id分库分表的例子(方式2):

通过id分库分表的例子(方式2)

固定哈希的规则设置和实现都很简单,不过如果扩容的话就会比较复杂。

一致性哈希算法带来的好处

参考《分布式缓存的一致性Hash算法》

一致性哈希所带来的最大变化是把节点对应的哈希值变为了一个范围,而不再是离散的。

在一致性哈希中,会把整个哈希值的范围定义得非常大,然后把这个范围分配给现有的节点。 如果有节点加入,那么这个新节点会从原有的某个节点上分管一部分范围的哈希值; 如果有节点退出,那么这个节点原来管理的哈希值会给它的下一个节点来管理。

一致性哈希

虚拟节点对一致性哈希的改进

参考《分布式缓存的一致性Hash算法》

新增一个节点时,除了新增的节点外,只有一个节点受影响,这个新增节点和受影响的节点的负载是明显比其他节点低的; 减少一个节点时,除了减去的节点外,只有一个节点受影响,它要承担自己原来的和减去的节点的工作,压力明显比其他节点要高。 这似乎要增加一倍节点或减去一半节点才能保持各个节点的负载均衡。如果真是这样,一致性哈希的优势就不明显了。

为了应对上述问题,引入虚拟节点的概念。即4个物理节点可以变为很多个虚拟节点,每个虚拟节点支持连续的哈希环上的一段。 而这时如果加入一个物理节点,就会相应加入很多虚拟节点,这些新的虚拟节点是相对均匀地插入到整个哈希环上的,这样,就可以很好地分担现有物理节点的压力了; 如果减少一个物理节点,对应的很多虚拟节点就会失效,这样,就会有很多剩余的虚拟节点来承担之前虚拟节点的工作,但是对于物理节点来说,增加的负载相对是均衡的。 所以可以通过一个物理节点对应非常多的虚拟节点,并且同一个物理节点的虚拟节点尽量均匀分布的方式来解决增加或减少节点时负载不均衡的问题。

映射表与规则自定义计算方式

映射表是根据分库分表字段的值的查表法来确定数据源的方法,一般用于对热点数据的特殊处理,或者在一些场景下对不完全符合规律的规则进行补充。 常见的情况是以前面的方式为基础,配合映射表来用。

最后要介绍的规则自定义计算方式是最灵活的方式,它已经不算是以配置的方式来做规则了, 而是通过比较复杂的函数计算来解决数据访问的规则问题,可以说是扩展能力最强的一种方式。可以通过自定义的函数实现来计算最终的分库。

假设根据id取模分成了4个库,但是对于一些热点id,希望将其独立到另外的库,那么通过类似下面的表达式就可以完成:

if (id in hotset){
	return 4;
}
return id%4;
为什么要改写SQL

前面介绍了规则对分库分表的支持,如何设定规则,也就是如何分库分表,没有绝对统一的原则,一般的标准是分库后尽可能避免跨库查询。

对于应用给数据层执行的SQL,除了根据规则确定数据源外,可能还需要修改SQL。为什么要修改呢?

在命名表时有一个需要做出选择之处,就是不同库中的表名是否要一样?

如果每个表的名字都是唯一的,看起来似乎不太优雅,但是可以避免很多误操作,另外,表名唯一在进行路由和数据迁移时也比较便利。

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

另外,还有一个需要修改SQL的地方,就是在进行跨库计算平均值的时候,不能从多个数据源取平均值,再计算这些平均值的平均值,而必须修改SQL获取到数量、总数后再进行计算。

对于没有经过SQL解析的SQL,在进行SQL替换时要特别注意,需要对各种情况全面思考,不要产生错误的替换。

如何选择数据源

决定了数据分组后,还需要决定访问分组中的哪个库。这些库一般是一写多读的(也有多写多读的), 根据当前要执行的SQL特点(读、写)、是否在事务中以及各个库的权重规则,计算得到这次SQL请求要访问的数据库。

分库分表后的结构(包含备库和读写分离等):

分库分表后的结构

执行SQL和结果处理阶段

在SQL执行的部分,比较重要的是对异常的处理和判断,需要能够从异常中明确判断出数据库不可用的情况。

实战经验
复杂的连接管理

在一些事务场景下会执行多个PreparedStatement方法,这要求在PreparedStatement具体执行SQL时,需要从Connection对象中获取同样的连接,并且如果连接有问题要报错。 也就是说需要对异常的情况有全面的考虑,而这些也是选择对外暴露JDBC接口的一个代价。

三层数据源的支持和选择

把整体DataSource分层后为应用提供的三层数据源实现,应用可以根据自己的需要灵活地进行选择。

三层数据源整体视图:

三层数据源整体视图

管理整个分库的数据源:

管理整个分库的数据源

在具体工程实践上,可以把配置集中在一个地方管理,这样使用配置的应用就可以去配置管理中心获取具体配置内容,修改时只需要修改配置管理中心中的值就可以了。

这个管理了整个业务的数据库集群的DataSource看起来是比较优雅的,是一个all-in-one的解决方案。但是在具体场景中,可能会比较重(不够轻量级),业务应用没有其他的选择,只能要么使用数据层的所有功能,要么就不用数据层。

可以对这个完整的DataSource的功能进行分层的。

管理分库后的读/写库的数据源:

管理分库后的读/写库的数据源

引入了groupDataSource,也就是分组的DataSource,用于管理整个业务数据库集群中的一组数据库。

groupDataSource相对于完整的DataSource来说,可以不管理具体的规则,也可以不进行SQL解析。 它是作为一个相对基础的数据源提供给业务的,那么,groupDataSource重点解决的问题是什么呢?

是在要访问这个分组中的数据库时,解决具体访问数据库的选择问题,具体的选择策略是groupDataSource要完成的重点工作, 包括根据事务、读/写等特性选择主备,以及根据权重在不同的库间进行选择。

应用完全知道有几个数据库分组,并且在应用内部决定了数据访问应该走的分组,如果有需要库分组的工作(例如查询合并),是需要应用自己来解决的。

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

对数据源分组之后,再进行数据源功能切分,构建AtomDataSource。

管理单库的数据源

通过AtomDataSource把单个数据库的数据源的配置集中存储,那么在定期更换密码、进行机房迁移等需要更改IP地址或改变端口时就会非常方便。 另外,通过AtomData-Source也可以帮助完成在单库上的SQL的连接隔离,以及禁止某些SQL的执行等和稳定性相关的工作。

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

从数据层的物理部署来说可以分为jar包方式和Proxy的方式。

如果采用Proxy方式的话,客户端与Proxy之间的协议有两种选择:数据库协议和私有协议。

独立部署的数据访问层

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

一个基础的Proxy的结构:

数据访问层内部结构

在接入应用的请求部分提供了MySQL协议和自身协议两种方式(这里用MySQL协议是为了举例), 而在连接数据库的部分,可以使用具体协议的适配器访问,也可以用数据库提供的JDBC驱动访问。 直接使用数据库协议是适配的方式,更加灵活,是直接在协议层来控制数据,也能够实现上述少一次转换就完成调用的工作。

读写分离的挑战和应对

读写分离结构

通过读写分离的方案,可以分担主库(Master)的读的压力。这里面存在一个数据复制的问题,也就是把主库的数据复制到备库(Slave)去。

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

读者对MySQL都比较熟悉,通过MySQL的Replication可以解决复制的问题,并且延迟也相对较小。 在多从库对应一主库的情况下,业务应用只要根据自身的业务特点把对数据延迟不太敏感的读切换到备库来进行就可以了。

一个要做读写分离的例子:

一个要做读写分离的例子

从成本上来说,Slave采用PC Server和MySQL的方案是比较划算的。那么对于一个主库,需要多台采用MySQL的PC Server来对应,每台PC Server对应原来Master中的一部分数据,也就是进行了分库。

多个分库合起来成为主库的读库:

多个分库合起来成为主库的读库

通过消息解决数据同步的方案:

通过消息解决数据同步的方案

应用通过数据层访问数据库,通过消息系统就数据库的更新送出消息通知,数据同步服务器获得消息通知后会进行数据的复制工作。 分库规则配置则负责在读数据及数据同步服务器更新分库时让数据层知道分库规则。 数据同步服务器和DB主库的交互主要是根据被修改或新增的数据主键来获取内容,采用的是行复制的方式。

可以说这是一个不优雅但是能够解决问题的方式。比较优雅的方式是基于数据库的日志来进行数据的复制。

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

数据分库条件不同的数据同步:

数据分库条件不同的数据同步

主库中,根据买家id进行了分库,把所有买家的订单分到了4个库中,这保证了一个买家查询自己的交易记录时都是在一个数据库上查询的,不过卖家的查询就可能跨多个库了。 可以做一组备库,在其中按照卖家id进行分库,这样卖家从备库上查询自己的订单时就都是在一个数据库中了。 那么,这就需要完成这个非对称的复制,需要控制数据的分发,而不是简单地进行镜像复制。

参看下面的《引入数据变更平台》

引入数据变更平台

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

数据变更平台

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

如何做到数据平滑迁移

对于没有状态的应用,扩容和缩容是比较容易的。而对于数据库,扩容和缩容会涉及数据的迁移。 如果接受完全停机的扩容或者缩容,就会比较容易处理,停机后进行数据迁移,然后校验并且恢复系统就可以了;但是如果不能接受长时间的停机,那该怎么办呢?

对数据库做平滑迁移的最大挑战是,在迁移的过程中又会有数据的变化。

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

有了平滑迁移的支持,在进行数据库扩容和缩容时就会相对标准化和容易了,否则恐怕每次的扩容都要变成一个项目才能完成了。

总结

随着数据量、访问量的增大,会对数据进行分库分表,这会为数据访问带来一些共性问题,数据层正是为此而产生的。 其实应用在进行数据读或写的时候,不仅会用到数据库,还会用到分布式文件系统、缓存系统、搜索系统等。 传统上来说,这些系统会提供不同的API给应用,应用要非常清楚自己要获取的数据的分布并采用不同的API处理。 可以考虑的一种策略是扩大数据层的覆盖,把这些不同来源的数据都包装在数据层的访问之下,对外提供统一的接口处理。

另外,知道在不同的查询场景下,会使用不同的方式和维度来构建索引以提高查询速度,这些对于使用来说都是透明的, 结合数据变更通知和迁移,可以实现多维度多形式的索引和一定限制条件下的分布式数据库。

可以看到应用有多种选择,而代理层除了可以使用DB的native的API方式外,还可以像应用一样使用各种方式来完成工作。 从应用到DB层就是一个链式的处理过程,并且多数组件都是对外提供JDBC的实现,这样也可以方便各个组件进行替换。

数据层的结构图:

数据层的结构图

消息中间件

消息中间件的价值

消息中间件的定义

“Message-oriented middleware(MOM)is software infrastructure focused on sending and receiving messages between distributed systems.”
“面向消息的中间件(MOM)是专注于在分布式系统之间发送和接收消息的软件基础架构。”

消息中间件

从传统意义上讲,消息中间件为带来了异步的特性,对系统进行了解耦,这对于大型分布式系统来说有非常重要的意义。

消息中间件对应用的解耦

消息中间件对服务调用进行解耦:

消息中间件对服务调用进行解耦

保证消息一定能被处理的方式:

保证消息一定能被处理的方式

对于需要感知状态的应用来说,需要定时轮询数据库以查看状态,并且在做完操作后,需要更改状态从而使得下次就不用再处理了。

可以说这是一个能解决问题的work around方法,实现也比较简单。不过也存在以下几个问题:

  • 增加了业务数据库的负担。
    • 一个状态字段所占的空间还可以接受,但是这个数据库需要被其他系统持续地定时轮询,并且进行更新,这就大大增加了数据库的负担。
  • 依赖的复杂和不安全。
    • 该方案使得发送短信的服务要依赖业务数据库,这导致依赖复杂并且不合理,另外,发送短信的服务对数据库记录有修改的权限,这也不安全。
  • 扩展性不好。
    • 对于前面的多个需要在业务动作成功后来做后续工作的系统,如果把该方式用于这样的系统的话,就需要增加很多个字段,或者使这些字段变得可共享又相互不能影响。并且会增加大量的定时对业务数据库的轮询请求。

对于这些问题,也期望通过消息中间件来解决。

互联网时代的消息中间件

JMS是Java Message Service的缩写,它是Java EE(企业版Java)中的一个关于消息的规范,而Hornetq、ActiveMQ等产品是对这个规范的实现。 如果是企业内部或者一些小型的系统,直接使用JMS的实现产品是一个经济的选择,而在大型系统中有一些场景不适合使用JMS。

在大型互联网中,采用消息中间件可以进行应用之间的解耦以及操作的异步,这是消息中间件的两个最基础的特点,也正是需要的。 在此基础上,着重思考的是消息的顺序保证、扩展性、可靠性、业务操作与消息发送一致性,以及多集群订阅者等方面的问题

如何消息发送一致性

消息发送的一致性定义

消息发送一致性是指产生消息的业务动作与消息发送的一致,就是说,如果业务操作成功了,那么由这个操作产生的消息一定要发送出去,否则就丢失消息了。 而另一方面,如果这个业务行为没有发生或者失败,那么就不应该把消息发出去。

消息发送的一致性很难保证么
  • 业务操作在前,发送消息在后,如果业务失败了还好(当然业务自己不觉得好),如果成功了,而这时这个应用出问题,那么消息就发不出去了。
  • 如果业务成功,应用也没有挂掉,但是这时消息系统挂掉了,也会导致消息发不出去。
JMS有办法么

首先看看JMS中几个比较重要的要素。

  • Destination,是指消息所走通道的目标定义,也就是用来定义消息从发送端发出后要走的通道,而不是最终接收方。Destination属于管理类的对象。
  • ConnectionFactory ,从名字就能看出来,是指用于创建连接的对象ConnectionFactory属于管理类的对象。
  • Connection,连接接口,所负责的重要工作是创建Session。
  • Session,会话接口,这是一个非常重要的对象,消息的发送者、接收者以及消息对象本身,都是由这个会话对象创建的。
  • MessageConsumer,消息的消费者,也就是订阅消息并处理消息的对象。
  • MessageProducer,消息的生产者,就是用来发送消息的对象。
  • XXXMessage,是指各种类型的消息对象,包括BytesMessage、MapMessage、ObjectMessage、StreamMessage和TextMessage 5种。

在JMS消息模型中,有Queue和Topic(在后面会详细介绍)之分,所以,Destination、ConnectionFactory、Connection、Session、MessageConsumer、MessageProducer都有对应的子接口。

各要素在Queue模型(PTP Domain)和Topic模型(Pub/Sub Domain)下的对应关系:

JMS Common PTP Domain Pub/Sub Domain
ConnectionFactory QueueConnectionFactory TopicConnectionFactory
Connection QueueConnection TopicConnection
Destination Queue Topic
Session QueueSession TopicSession
MessageProducer QueueSender TopicPublisher
MessageConsumer QueueReceiver TopicSubscriber

此外,在JMS的API中,很多以XA开头的接口,它们其实就是支持XA协议的接口。

XA系列接口与对应的非XA系列接口:

XA系列接口名称 对应的非XA接口名称
XAConnectionFactory ConnectionFactory
XAQueueConnectionFactory QueueConnectionFactory
XATopicConnectionFactory TopicConnectionFactory
XAConnection Connection
XAQueueConnection QueueConnection
XATopicConnection TopicConnection
XASession Session
XAQueueSession QueueSession
XATopicSession TopicSession

XA系列的接口集中在ConnectionFactory、Connection和Session上,而MessageProducer、QueueSender、TopicPublisher、MessageConsumer、QueueReceiver和TopicSubscriber则没有对应的XA对象。 这是因为事务的控制是在Session层面上的,而Session是通过Connection创建的,Connection是通过ConnectionFactory创建的,所以,这三个接口需要有XA系列对应的接口的定义。 Session、Connection、ConnectionFactory在Queue模型和Topic模型下对应的各个接口也存在相应的XA系列的对应接口。

下面展示了消息最重要的要素(消息、发送者、接收者)与几个基本元素之间的关系。

ConnectionFactory → Connection → Session → Message
    Destination + Session → MessageProducer
    Destination + Sessoin → MessageConsumer

在JMS中,如果不使用XA系列的接口实现,那么就无法直接得到发送消息给消息中间件及业务操作这两个事情的事务保证, 而JMS中定义的XA系列的接口就是为了实现分布式事务的支持(发送消息和业务操作很难做在一个本地事务中)。

但是这会带来如下问题:

  • 引入了分布式事务,这会带来一些开销并增加复杂性。
  • 对于业务操作有限制,要求业务操作的资源必须支持XA协议,才能够与发送消息一起来做分布式事务。这会成为一个限制,因为并不是所有需要与发送消息一起做成分布式事务的业务操作都支持XA协议。
有其他办法么

JMS是可以解决消息发送一致性的问题的,但是存在一些限制并且成本相对较高。

希望保证业务操作与发送相关消息的动作是一致的,而前面的简单方案不能完全保证,但是出现问题的概率并不大, 所以,希望找到一种解决方案,这种方案对正常流程的影响要尽可能小,而在有问题的场景能解决问题。

从这个方面看,即便可以做到业务操作都是支持XA的,如果采用这样的方式引入两阶段提交的话,那么还是把方案做得有些重了。

最终一致性方案的正向流程:

最终一致性方案的正向流程

  1. 业务处理应用首先把消息发给消息中间件,标记消息的状态为待处理。
  2. 消息中间件收到消息后,把消息存储在消息存储中,并不投递该消息。
  3. 消息中间件返回消息处理的结果(仅是入库的结果),结果是成功或者失败。
  4. 业务方收到消息中间件返回的结果并进行处理:
    • 如果收到的结果是失败,那么就放弃业务处理,结束。
    • 如果收到的结果是成功,则进行业务自身的操作。
  5. 业务操作完成,把业务操作的结果发送给消息中间件。
  6. 消息中间件收到业务操作结果,根据结果进行处理:
    • 如果业务失败,则删除消息存储中的消息,结束。
    • 如果业务成功,则更新消息存储中的消息状态为可发送,并且进行调度,进行消息的投递。

对每一个步骤可能产生的异常情况来进行分析:

  1. 业务应用发消息给消息中间件。如果这一步失败了,无论是网络的原因还是消息中间件的原因,或是业务应用自身的原因,都会看到业务操作没有做,消息也没有被存储在消息中间件中,业务操作和消息的状态是一样的,没有问题。
  2. 消息中间件把消息入库。如果这一步失败,无论是消息存储有问题,还是消息中间件收到业务消息后有问题,或是网络问题,可能造成的结果有两个。
    • 一个是消息中间件失效,那么业务应用是收不到消息中间件的返回结果的;
    • 二是消息中间件插入消息失败,并且有能力返回结果给应用,这时消息存储中都没有消息。
  3. 业务应用接收消息中间件返回结果异常。这里出现异常的原因可能是网络、消息中间件的问题,也可能是业务应用自身的问题。
    • 如果业务应用自身没问题,那么业务应用并不知道消息在消息中间件的处理结果,就会按照消息发送失败来处理,如果这时消息在消息中间件那里入库成功的话,就会造成不一致。
    • 如果是业务应用有问题,那么如果消息在消息中间件中处理成功的话,也就会造成不一致了;如果未处理成功,则还是一致的。
  4. 业务应用进行业务操作。这一步不会产生太大问题。
  5. 业务应用发送业务操作结果给消息中间件。如果这一步出现问题,那么消息中间件将不知道该如何处理已经存储在消息存储中的消息,可能会造成不一致。
  6. 消息中间件更新消息状态。如果这一步出现问题,与上一步所造成的结果是类似的。

从业务应用和消息中间件的视角来梳理:

从业务应用的视角分析异常情况:

异常情况 可能的状态
发送消息给消息中间件前失败 业务操作未进行,消息未入存储
消息发出后没有收到消息中间件的响应 业务操作未进行,消息存入存储,状态为待处理;</br>业务操作未进行,消息未入存储
收到消息中间件返回成功,但是没有来得及处理业务就失败 业务操作未进行,消息存入存储,状态为待处理

从消息中间件的视角分析异常情况:

异常情况 可能的状态
没有收到业务应用关于业务操作的处理结果 业务操作未进行,消息存入存储,状态为待处理;</br>业务操作未进行(回滚),消息存入存储,状态为待处理;</br>业务操作成功,消息存入存储,状态为待处理
收到业务应用的业务操作结果,处理存储中的消息状态失败 业务操作未进行,消息存入存储,状态为待处理;</br>业务操作未进行(回滚),消息存入存储,状态为待处理;</br>业务操作成功,消息存入存储,状态为待处理

对于各种异常情况遇到的状态有如下三种:

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

这三种情况中,第一种情况不需要进行额外的处理,因为本身就是一致的;第二种和第三种都需要了解业务操作的结果,然后来处理已经在消息存储中、状态是待处理的消息。

那么如何了解业务操作的结果?

由消息中间件主动询问业务应用,获取待处理消息所对应的业务操作的结果,然后业务应用需要对业务操作的结果进行检查, 并且把结果发送给消息中间件(业务处理结果有失败、成功、等待三种,等待是多出来的一种状态,代表业务操作还在处理中),然后消息中间件根据这个处理结果,更新消息状态。 可以说这是发送消息的一个反向的流程。

最终一致性方案的补偿流程:

最终一致性方案的补偿流程

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

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

正向流程是否带来了额外的负担?

解决一致性方案与传统方式的对比:

传统方式 解决一致性的方案
(1)业务操作 (1)发送消息给消息中间件
(2)发送消息给消息中间件 (2)消息中间件入库消息
(3)消息中间件入库消息 (3)消息中间件返回结果
(4)消息中间件返回结果 (4)业务操作
  (5)发送业务操作结果给消息中间件
  (6)更改存储中消息状态

解决一致性的方案是只增加了一次网络操作和一次更新存储中消息状态的操作,就是第5步和第6步两步。而前面4步和传统方式所做的事情都一样,只是顺序有所不同。 所以,整体上带来的额外开销并不大,而且还有可优化的点。

解决一致性的方案中,在业务应用那里是有一个固化的流程的,可以提供一个封装来方便业务应用的使用,伪代码如下。

Result postMessage(Message, PostMessageCallback) {
    //发送消息给消息中间件
    //获取返回结果
    //如果失败,返回失败
    //进行业务操作
    //获取业务操作结果
    //发送业务操作结果给消息中间件
    //返回处理结果
}

可以把实现逻辑封装在一个调用中,然后把业务的操作包装成一个对象传进来,然后整个流程就可以控制在这个方法中了。

当然,除了发送一致性的消息之外,也应该提供一个传统的发送消息的接口,也就是不支持发送一致性的发送接口。

此外,为了适应其他的场景(例如与现有的事务处理流程结合等),也会提供独立的接口,就会把这个流程的控制权交给业务应用自身。

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

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

如何解决这个问题,思路有如下三种:

  • 提供消息中间件系统的可靠性,但是没有办法保证百分之百可靠。
  • 对于消息中间件系统中影响业务操作进行的部分,使其可靠性与业务自身的可靠性相同。
  • 可以提供弱依赖的支持,能够较好地保证一致性。

第一种方案,提升消息中间件系统的可靠性是必须要做的事情,但是无法保证百分之百可靠。

第二种方案,让消息中间件系统中影响业务操作的部分与业务自身具有同样的可靠性,其实就是要保证如果业务能操作成功,就需要消息能够入库成功。 因为如果消息中间件出问题了,可以接受投递的延迟,但是需要保证消息入库,这样业务操作才可以继续进行。

应用和消息中间件一起操作消息表结构:

应用和消息中间件一起操作消息表结构

把消息中间件所需要的消息表与业务数据表放到同一个业务数据库中,这样,业务应用就可以把业务操作和写入消息作为一个本地事务来完成,然后再通知消息中间件有消息可以发送,这样就解决了一致性的问题。 虚线表示的,代表它不是一个必要的操作和依赖。消息中间件会定时去轮询业务数据库,找到需要发送的消息,取出内容后进行发送。

这个方案对业务系统有三个影响:

  • 需要用业务自己的数据库承载消息数据。
  • 需要让消息中间件去访问业务数据库。
  • 需要业务操作的对象是一个数据库,或者说支持事务的存储,并且这个存储必须能够支持消息中间件的需求。

进行一下变通,消息中间件不再直接与业务数据库打交道。消息表还是放在业务数据库中,完全由业务数据库来控制消息的生成、获取、发送及重试的策略。 这样,消息中间件就不需要与众多使用这种消息一致性发送的业务方的数据库打交道了,不过比较多的逻辑是从消息中间件的服务端移动到消息中间件的客户端,并且在业务应用上执行。 消息中间件更多的是管理接收消息的应用,并且当有消息从业务应用发过来后就只管理投递,把原来的调度、重投、投递等逻辑分到了客户端和服务端两边。

消息中间件不直接操作消息表结构

两种方式虽然已经解决了大部分问题,但是它们都要求业务操作是支持事务的数据库操作,具有一定的限制性。

考虑把本地磁盘作为一个消息存储,也就是如果消息中间件不可用,又不愿或不能侵入业务自己的数据库时,可以把本地磁盘作为存储消息的地方, 等待消息中间件恢复后,再把消息送到消息中间件中。所有的投递、重试等管理,仍然是在消息中间件进行,而本地磁盘的定位只是对业务应用上发送消息一定成功的一个保证。

应用本地记录消息结构

这种方式存在的风险是,如果消息中间件不可用,而且写入本地磁盘的数据也坏了的话,那么消息就丢失了。 这确实是个问题,所以,从业务数据上进行消息补发才是最彻底的容灾的手段,因为这样才能保证只要业务数据在,就一定可以有办法恢复消息了。

将本地磁盘作为消息存储的方式有两种用法:

  • 一是作为一致性发送消息的解决方案的容灾手段,也就是说该方式平时不工作,出现问题时才切换到该方式上;
  • 二是直接使用该方式来工作,这样可以控制业务操作本身调用发送消息的接口的处理时间,此外也有机会在业务应用与消息中间件之间做一些批处理的工作。

业务操作与发送消息一致性的方案所带来的两个限制:

  • 需要确定要发送的消息的内容。
    • 因为在业务操作做之前会把状态标记为待处理,这要求先能确定消息内容;
    • 这里可以有一个变通,即先把主要内容也就是能够标记该次业务操作特点的信息发过来,然后等业务操作结束后需要更新状态时再补全内容。
    • 不过这还是要求在业务操作之前能够确定一些索引性质的信息。
  • 需要实现对业务的检查。
    • 也就是说为了支持反向流程的工作,业务应用必须能够根据反向流程中发回来的消息内容进行业务操作检查,确认这个消息所指向的业务操作的状态是完成、待处理,还是进行中,否则,待处理状态的消息就无法被处理了。

消息模型对消息接收的影响

JMS Queue模型

JMS Queue模型

应用1和应用2发送消息到JMS服务器,这些消息根据到达的顺序形成一个队列,应用3和应用4进行消息的消费。 这里需要注意的是,应用3和应用4收到的消息是不同的,也就是说在JMS Queue的方式下,如果Queue里面的消息被一个应用处理了, 那么连接到JMS Queue上的另一个应用是收不到这个消息的,也就是说所有连接到这个JMS Queue上的应用共同消费了所有的消息。 消息从发送端发送出来时不能确定最终会被哪个应用消费,但是可以明确的是只有一个应用会去消费这条消息, 所以JMS Queue模型也被称为Peer To Peer(PTP)方式。

JMS Topic模型

JMS Topic模型

从发送消息的部分和JMS Topic内部的逻辑来看,JMS Topic和JMS Queue是一样的,二者最大的差别在于消息接收的部分, 在Topic模型中,接收消息的应用3和应用4是可以独立收到所有到达Topic的消息的。JMS Topic模型也被称为Pub/Sub方式。

JMS中客户端连接的处理和带来的限制

从连接角度看应用从Queue中接收消息:

从连接角度看应用从Queue中接收消息

从连接角度看应用从Topic中接收消息:

从连接角度看应用从Topic中接收消息

需要什么样的消息模型

所要满足的需求:

  • 消息发送方和接收方都是集群。
  • 同一个消息的接收方可能有多个集群进行消息的处理。
  • 不同集群对于同一条消息的处理不能相互干扰。

可以把集群和集群之间对消息的消费当做Topic模型来处理,而集群内部的各个具体应用实例对消息的消费当做Queue模型来处理。 可以引入ClusterId,用这个Id来标识不同的集群,而集群内的各个应用实例的连接使用同样的ClusterId。 当服务器端进行调度时,根据ClusterId进行连接的分组,在不同的ClusterId之间保证消息的独立投递,而拥有同样ClusterId的连接则共同消费这些消息。 这个策略是分两级来处理,把Topic模型和Queue模型的特点结合起来使用,从而达到多个不同的集群进行消息订阅的目的。

多集群订阅者解决方案:

多集群订阅者解决方案

如果一定要使用JMS的话,有一个变通的做法,就是把JMS的Topic和Queue也按照上面的思路级联起来使用:

通过JMS级联的解决方案

不过这种级联方式相对比较繁重,是多个独立的JMS服务器之间的连接,这比在消息中间件服务器端内部进行处理要复杂很多。好处是基本可以直接使用JMS的实现。 这里需要注意的是从Topic中发消息分派到不同的Queue中时,需要由独立的中转的消息订阅者来完成,并且对同一个Queue的中转只能由一个连接(Connection)完成; 为了实现高可用性,还需要备份节点在主节点出问题后承担工作。因此从长远考虑,满足这个需求还是自己实现比较合适。

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

非持久订阅,含义是消息接收者和消息中间件之间的消息订阅的关系的存续,与消息接收者自身是否处于运行状态有直接关系。 也就是说,当消息接收者应用启动时,就建立了订阅关系,这时可以收到消息;而如果消息接收者应用结束了,那么消息订阅关系也就不存在了,这时的消息是不会为消息接收者保留的; 当消息接收者应用再次启动后,又会重新建立订阅关系,之后的消息又可以正常收到。

非持久订阅

持久订阅的含义是,消息订阅关系一旦建立,除非应用显式地取消订阅关系,否则这个订阅关系将一直存在。 而订阅关系建立后,消息接收者会接收到所有消息,如果消息接收者应用停止,那么这个消息也会保留,等待下次应用启动后再投递给消息接收者。

持久订阅

因此,要做到可靠应该选择持久订阅这种订阅方式。

保证消息可靠性的做法

在持久订阅的前提下,整个消息系统是如何保证消息可靠的呢?

消息系统示意图

消息从发送端应用到接收端应用,中间有三个阶段需要保证可靠,分别是:

  • 消息发送者把消息发送到消息中间件,
  • 消息中间件把消息存入消息存储,
  • 消息中间件把消息投递给消息接收者。
消息发送端可靠性的保证

这是消息投递周期中的第一步,这一步并不复杂,需要注意消息发送者和消息中间件之间的调用返回结果的清晰设定,以及对于返回结果的全面处理。

发送者需要把消息的发送结果准确地传给应用,应用才能进行相关的判断和逻辑处理。 消息从发送者发送到消息中间件,只有当消息中间件及时、明确地返回成功,才能确认消息可靠到达消息中间件了; 返回错误、出现异常、超时等情况,都表示消息发送到消息中间件这个动作失败。

这里需要注意的是对异常的处理,可能出现的问题是在不注意的情况下吃掉了异常,从而导致错误的判断结果。

消息存储的可靠性保证

消息数据一定要放到外存储器上,要进行持久的存储。这会面临两个选择:

  • 持久存储部分的代码完全自主实现。
  • 利用现有的存储系统实现。

自主实现持久存储的功能需要慎重。一个成熟的存储系统是需要长时间的努力、沉淀和考验的。除非有充分的理由,否则不建议完全重新实现一个持久存储。 如果针对特定的场景,自主实现能够很好地提升性能、降低成本,或者有其他一些好处,那么还是值得开发定制的存储系统的。

采用现有的存储系统会面临比较多的选择,有传统的关系型数据库、分布式文件系统和NoSQL产品, 这些类型的产品各有所长,需要在保证存储可靠性的基础上,依据对消息存储的需求来选择。

实现基于文件的消息存储

完全实现消息存储需要解决的问题还是比较多的,也需要较多的投入。 而如果要提升单机存储的可靠性,应对断电、程序崩溃等问题,那么就要求去实现一些单机数据库存储引擎或者一些NoSQL的单机引擎的工作了。

困难有以下4点。

  • 完全重写一个可靠的单机的存储引擎,投入还是很大的。
  • 各种场景的测试没有遇到问题不代表没有问题,很可能是覆盖的场景还不够全面。保证存储的可靠性挑战比较大。
  • 由于关注吞吐量不关注消息顺序,会导致原本连续的消息存储的文件中有些消息不需要了,有些需要保留,就会形成文件的空洞。
    • 对消息的存储都是一系列的文件,如果不对这样的空洞进行处理,那么这些已经不用的消息就会消耗大量的磁盘空间,此外也会影响读消息的效率。
    • 们对文件进行整理。消除空洞所采用的做法是把需要保留的消息按照顺序写入新文件,然后直接删除原来的文件。这相当于一个持续的搬运过程,这个过程会增加写的负担。
  • 对消息的检索处理需要考虑索引对内存的消耗,必须考虑到索引不能完全加载到内存的情况,这涉及了内存和磁盘文件的交换功能,也涉及了如何能够保证处理过程的高效。
采用数据库作为消息存储

在使用关系型数据库来存储数据时,库表设计是比较关键的一点。在很多JMS的开源实现中,库表设计是相对比较复杂的,表与表之间会有一些相互的关联。

希望尽量避免获取数据时的表关联查询,所以希望一个消息只用一个单行的数据来解决。对于消息来说,可以把需要存储的数据分为三块:

  • 消息的Header信息
    • 主要是指消息的一些基本信息,例如消息Id、创建时间、投递次数、优先级、自定义的键值对属性等。
  • 消息的Body
    • 就是消息的具体内容,消息的Body是否与消息的Header信息放在一条记录中是需要考虑的。经过分析和验证,选择了把Header和Body放在了一起,其中一个因素是消息体的内容并不大。
  • 消息的投递对象
    • 是指单条消息要投递到的目标集群的ClusterId。

消息表:

消息表

投递表:

投递表

们投递消息时就从投递表中选取数据来进行调度。看起来没有很大问题,不过需要注意一点。 当消息进入数据库时,需要生成相关的投递表中的数据,当消息的投递有结果后,也要更新相应的投递表的信息(如果投递成功,那么需要删除对应的投递记录; 如果投递失败,需要更新投递次数以及下次投递时间,一般投递的间隔会越来越长)。 而对投递表的插入、删除、更新,在单条消息订阅集群数量多时会带来非常多的数据库记录的操作,引起的性能下降是很厉害的。

尝试把对投递的记录放到消息表中:

含投递记录的消息表

把投递列表直接合并到消息表中会带来如下两个问题:

  • 投递列表这个字段的长度是有限制的,这也就限制了投递者的数量。
    • 一个变通的做法是在单行放置多个投递列表字段,例如投递列表1、投递列表2等,然后在消息中间件中取出多个字段的数据后进行整合。
  • 无法按照单独的接收者来进行消息的调度。
    • 投递表独立时,一方面可以根据消息Id来确定需要投递的列表,另外也可以根据接收者的ClusterId来确定哪些消息需要投递,还可以根据下次投递时间来进行消息投递的调度。
    • 而把投递记录合并到消息表后,根据消息Id、下次投递时间来进行消息投递调度还是可以的,但是想根据接收者的ClusterId进行调度则无法直接做到。
    • 无法直接给单独的接收者ClusterId建立索引,并且调度的粒度只能基于单条消息,不能从消息的维度或者接收者的维度来灵活调度。这是提升性能后的牺牲。

通过一个具体实例来看一下这两种做法的差异。

假设消息的订阅者有3个集群——ClusterA、ClusterB和ClusterC,而对于消息的消费来说,ClusterA要能比ClusterB、ClusterC更加及时地处理消息。 在正常的情况下,上面两种设计其实都不会出问题,而在异常情况下则会有明显的不同。 假设ClusterA和ClusterC的集群整体出现了问题,而ClusterB是正常的,先看看投递表独立的情况,这时投递表中会有大量的ClusterA和ClusterC的投递记录需要处理消息, ClusterA恢复后需要较快处理堆积的消息,可以根据ClusterA来调度这些堆积的消息,而ClusterC可以慢慢恢复。

但是如果把投递信息记录在消息表中的一个字段里,那么就只能根据消息调度,必须在ClusterA恢复后把可能要投递给ClusterA的消息都尽快调度到系统中, 确认需要投递给ClusterA的话就要尽快投递。这种做法是不够经济的,尤其在堆积了很多消息的时候,这种处理方式的效率是比较低的。 这时可以采用的一个折中做法是,为需要尽快调度的集群建一个投递表,也就是在消息调度外增加一个针对特定集群的调度支持,这种做法看上去不优雅,不过比较好用。

考虑数据的容灾方案。

  • 单机的Raid。笔者使用Raid10时遇到过两个盘一起坏的情况,其他的单机Raid方式笔者没有用过,不过需要考虑单机本身的安全性。
  • 多机的数据同步。这要求不能有延迟,一般通过存储系统自身的机制完成,需要注意的是数据复制的方式,如果复制方式有延迟,那么也不完全安全。
  • 应用双写。这是通过应用来控制写两份的方案,主要应对的是存储系统自身数据复制有延迟的情况,不过这会让应用变得复杂。
基于双机内存的消息存储

正常情况下,消息持久存储是不工作的,而基于内存来存储消息则能够提供很高的吞吐量。一旦一个机器出现故障,则停止另一台机器的数据写操作,并把当前数据落盘。

双机内存消息存储结构:

双机内存消息存储结构

双机内存方案的故障处理:

双机内存方案的故障处理

新消息不会再进入,而正常的这台机器会把数据写入持久存储中以保证安全。 也就是说,只要不遇到两台基于内存的消息中间件机器同时出故障的情况,并且当一台出问题时,另一台将当时内存的消息写入持久存储的过程中不出问题的话,消息是很安全的。 这种方式适合于消息到了消息中间件后大部分消息能够及时被消费掉的情况,它可以很好地提升性能。

消息系统的扩容处理
消息中间件自身如何扩容

消息中间件本身没有持久状态,扩容相对容易。主要是让消息的发送者和消息的订阅者能够感知到有新的消息中间件机器加入到了集群,这是通过软负载中心完成的。

不同的消息中间件机器可能会共用存储,而同一个消息中间件机器也可能使用不同的存储,这都是为了提升可靠性。

消息中间件与存储的关系:

消息中间件与存储的关系

这里需要解决的问题是,在同一个存储中如何区分存储的消息是来自于哪个消息中间件应用的。 解决方案是给每条消息增加一个server标识的字段,当有新加入的消息中间件时,会使用新的server标识。 这一方案需要应对的问题是,如果有消息中间件应用长期不可用的话,就需要加入一个和它具有同样server标识的机器来代替它, 或者把通过这个消息中间件进入到消息系统中但还没有完成投递的消息分给其他机器处理,也就是让另一台机器承担剩余消息的投递工作。

消息存储的扩容处理

因为存储的扩容涉及数据,因此总是很麻烦的事情。不过在消息中间件的场景中,有一个天然的优势可以让存储扩容变得很简单。优势:

  • 不用保证消息顺序。
  • 提供从服务器端对消息投递的方式,不支持主动获取消息。

其实是不需要支持外部主动根据条件(例如消息Id)来查询消息的,这是怎么做到的呢?

  • 首先,消息发送到消息中间件时,消息中间件把消息入库,这时消息中间件是明确知道消息存储在哪里的,并且会进行消息的投递调度,所以,一定能找到消息。
  • 其次,由于在内存中进行调度的消息数量有限(受制于内存限制),因此会调度存储在数据库中的消息。而在调度时,更关心的是那些符合发送条件的消息,所以这个调度必然是需要跨所有库和表的,而这个过程中,需要投递的消息会把相关索引信息加载到内存,在这个过程之后,内存中的调度信息就自然有了存储节点信息。

总体来说,是通过服务端主动调度安排投递的方式绕开了根据消息Id取消息这个动作,所以可以实现数据库存储的便利扩容。

消息投递的可靠性保证
消息投递简介

最后一步是消息的投递,这一步和发送者发消息类似,处理相对简单。特别需要注意的是,消息中间件需要显式地收到接收者确认消息处理完毕的信号才能删除消息。 消息中间件不能够依据网络层判断消息是否已经送到接收者,进而决定消息是否删除,而一定要从应用层的响应入手。

消息接收者需要特别注意的是,不能在收到消息、业务没有处理完成时就去确认消息。 此外,需要特别注意的仍然是消息接收者在处理消息的过程中对于异常的处理,千万不要吃掉异常然后确认消息处理成功,这样就会“丢”消息了。

投递处理的优化

投递处理的第一个可优化之处是,在进行投递时一定要采用多线程的方式处理。 通过介绍可以看到是针对单条消息来进行调度,一种方式是每个线程处理一个消息并且等待处理结束后再进行下一条消息的处理。 每个线程处理一条消息时,会得到需要接收该消息的订阅者集群Id列表,然后从每个订阅者集群Id中选择一个连接来处理; 消息投递后需要等待结果,然后统一更新消息表中的消息状态。这种方式在正常情况下没有问题,而遇到异常情况时, 例如订阅者集群中有一个很慢的订阅者(这个场景与之前在服务框架中看到的某个操作很慢的情况类似),负责投递的所有线程会慢慢地被堵死,因此都需要等待这个慢的订阅者的返回。

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

收到消息的处理结果后,更新数据库的操作也有一个小但很重要的优化,那就是通过数据库的batch来处理消息的更新、删除操作,从而提升性能。

第二个可优化之处:

  • 单机多订阅者共享连接。
  • 消息只发送一次,然后传到单机的多订阅者生成多个实例处理。

订阅端消息的重复接收:

订阅端消息的重复接收

优化后的消息接收去重:

优化后的消息接收去重

对于同样的消息,消息中间件只需要向应用发一次消息,应用内部再根据本机的不同模块的订阅情况进行一次派发。

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

消息重复的产生原因

第一类原因是消息发送端应用的消息重复发送,有以下几种情况。

  • 消息发送端发送消息给消息中间件,消息中间件收到消息并成功存储,而这时消息中间件出现了问题,导致应用端没有收到消息发送成功的返回,因而进行重试产生了重复。
  • 消息中间件因为负载高响应变慢,成功把消息存储到消息存储中后,返回“成功”这个结果时超时。
  • 消息中间件将消息成功写入消息存储,在返回结果时网络出现问题,导致应用发送端重试,而重试时网络恢复,由此导致重复。

通过消息发送端产生消息重复的主要原因是消息成功进入消息存储后,因为各种原因使得消息发送端没有收到“成功”的返回结果,并且又有重试机制,因而导致重复。 一个解决办法是,重试发送消息时使用同样的消息Id,而不要在消息中间件端产生消息Id,这样可以避免这类情况的发生

第二类原因是消息到达了消息存储,由消息中间件进行向外的投递时产生重复,有以下几种情况。

  • 消息被投递到消息接收者应用进行处理,处理完毕后应用出问题了,消息中间件不知道消息处理结果,会再次投递。
  • 消息被投递到消息接收者应用进行处理,处理完毕后网络出现问题了,消息中间件没有收到消息处理结果,会再次投递。
  • 消息被投递到消息接收者应用进行处理,处理时间比较长,消息中间件因为消息超时会再次投递。
  • 消息被投递到消息接收者应用进行处理,处理完毕后消息中间件出问题了,没能收到消息结果并处理,会再次投递。
  • 消息被投递到消息接收者应用进行处理,处理完毕后消息中间件收到结果,但是遇到消息存储故障,没能更新投递状态,会再次投递

在投递过程中产生的消息重复接收主要是因为消息接收者成功处理完消息后,消息中间件不能及时更新投递状态造成的。

以采用分布式事务来解决,不过这种方式比较复杂,成本也高。另一种方式是要求消息接收者来处理这种重复的情况,也就是要求消息接收者的消息处理是幂等操作。

幂等(idempotence)是一个数学概念,常见于抽象代数中。有两种主要的定义:

  • 在某二元运算下,幂等元素是指被自己重复运算(或对于函数是为复合)的结果等于它自身的元素。
  • 某一元运算为幂等的时,其两次作用在任一元素后会和其作用一次的结果相同。例如,高斯符号便是幂等的。

消息接收端的处理是一个幂等操作。这样的做法降低了消息中间件的整体复杂性,不过也给使用消息中间件的消息接收端应用带来了一定的限制和门槛。

JMS的消息确认方式与消息重复的关系

在JMS中,消息接收端对收到的消息进行确认,有以下几种选择。

  • AUTO_ACKNOWLEDGE
    • 这是自动确认的方式,就是说当JMS的消息接收者收到消息后,JMS的客户端会自动进行确认。但是确认时可能消息还没来得及处理或者尚未处理完成,所以这种确认方式对于消息投递处理来说是不可靠的。
  • CLIENT_ACKNOWLEDGE
    • 这是客户端自己确认的方式,也就是说客户端如果要确认消息处理成功,告诉服务端确认信息时,需要主动调用Message接口的acknowledge()方法以进行消息接收成功的确认。这种方式把控制权完全交给了接收消息的客户端应用。
  • DUPS_OK_ACKNOWLEDGE
    • 这种方式是在消息接收方的消息处理函数执行结束后进行确认,一方面保证了消息一定是处理结束后才进行确认,另外一方面也不需要客户端主动调用Message接口的acknowledge()方法了。

上述三种确认方式是通过JMS的Connection在创建Queue或者Topic时设置的。

消息接收者对于消息的接收会出现下面两种情况。

  • at least once(至少一次)
    • 至少一次,就是说消息被传给消息接收者至少一次,也可能多于一次,这种情况类似前面小节的消息重复处理的情况。采用DUPS_OK_ACKNOWLEDGE或CLIENT_ACKNOWLEDGE模式并且在处理消息前没有确认的话,就可能产生这种现象。
  • at most once(至多一次)
    • 至多一次,这是采用AUTO_ACKNOWLEDGE或CLIENT_ACKNOWLEDGE模式并且在接收到消息后就立刻确认时会产生的情况。也就是说,消息从消息中间件送达接收端后就立刻进行了确认,而如果这时接收端出现问题,那就没有机会处理这个消息了,所以是at most once。

消息投递的其他属性支持

消息优先级

一般情况下消息是先到先投递,消息优先级的属性可以支持根据优先级(而不是依据到达消息中间件的时间)来确定投递顺序, 优先级高的消息即使到达消息中间件的时间较晚,也可以被优先调度。

另外在实践中会把消息分为不同类型,对于不同类型进行不同的处理,这可以部分完成优先级属性的工作。不过对于同种类型的消息还是需要优先级属性来进行区分。

订阅者消息处理顺序和分级订阅

一般来说,消息的多个订阅者之间是独立的,它们对消息的处理并不会相互造成影响。不过在一些特殊场景中,对于同样的消息,可能会希望有些订阅者处理结束后再让其他订阅者处理。

  • 一种方案是可以设定优先处理的订阅集群,也就是订阅者消息处理顺序的属性,可以在这个字段中设置有些处理的集群Id;
  • 另一种方案是分级订阅。

分级订阅结构

把优先接收者和一般接收者的接收分开,一般接收者处理成功后主动把消息投递到另外的消息中间件(也可以换一个消息类型), 然后一般接收者接收新产生的消息。这样的做法不需要消息中间件去做额外的支持,不过相当于重新发了消息,会多一次消息入库等操作。

自定义属性

消息自身的创建时间、类型、投递次数等属性属于消息的基础属性,在消息体外,支持自定义的属性会很便利,例如服务端消息过滤,以及接收端对于消息的处理,有了这个自定义属性会方便很多。 这个自定义的属性类似于HTTP的Header,一般是对于这条消息的抽象描述,方便服务端和接收端快速获取这条消息中的重要信息。

局部顺序

为了吞吐量而放弃顺序,这里要讲的一个概念是局部顺序。

局部顺序是指在众多的消息中,和某件事情相关的多条消息之间有顺序,而多件事情之间的消息则没有顺序。

交易网站上每天产生的交易很多,并且是由很多人产生的,那么不同人之间以及同一个人的不同笔交易之间的消息其实是相互无关的,不必保持顺序; 但是对于同一笔交易的状态变化所产生的消息,保证其顺序是很有价值的。

假设线上交易产生的消息状态依次是:创建→付款→发货→确认,现在有两笔独立的交易进行,在没有局部顺序属性时会是下面这样的情况:

无顺序消息进入消息中间件的情况

在完全有序的情况下,如果这些消息都能顺利处理,就不会出现什么问题; 而如果因为数据的原因或者程序的原因导致某条消息总是处理失败,那么为了保证处理顺序,后面的消息就会等待前面这一条消息处理完毕后才接着处理。 而对于不管顺序的方式,因为每笔交易的状态本身是有顺序的,如果前面一个状态没能被成功处理,后面即便调度到了处理,也是简单地返回失败,因为需要等待前一个状态的处理。

所以,希望达到的是局部顺序,即交易A的状态改变的各条消息之间有顺序,交易B的状态改变的各条消息之间也有顺序,但A和B之间的消息互不影响:

消息局部顺序示意图

也就是说在消息中间件内部,有非常多的逻辑上独立的队列。支持局部有序需要消息上有一个关键的属性,即区分某个消息应该与哪些消息一起排队的属性字段。

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

这里是另一种实现,主要是因为这里面的场景和应对的方案与前面局部顺序中的有很大不同。

虽然两个场景都需要支持多集群消息订阅,但是在消息订阅者端对于消息的处理有很大差别。

  • 在前面的做法(包括放弃对顺序的支持)的原因之一是,同一个消息订阅者处理不同的消息,成功与否可能会跟消息自身的内容相关;
  • 而现在介绍的场景一般不会因消息内容而导致失败,而是和这个订阅者及其依赖的系统是否可用有关。

进行数据复制时,源数据库上的数据变更变成消息进入消息中间件,这时只要目标数据库可用,这个处理就会成功, 而不会出现某些记录成功另外一些记录失败的情况,这就与内容没有密切的关系。

在这样的场景下,一个吞吐量大且支持顺序的消息中间件是很有价值的。数据变更通知平台,就是使用这种类型的消息中间件的一个具体场景。

此外,在这个场景下,对于接收端的设计也从原来的推(Push)模式变为了拉(Pull)模式, 这也是为了让消息接收者可以更好地控制消息的接收和处理,而消息中间件自身的逻辑也进行了简化。

保证顺序的消息队列结构:

保证顺序的消息队列结构

在消息中间件内部,有多个物理上的队列,进入到每个队列的消息则是严格按照顺序被接收和消费的,而消息中间件单机内部的队列之间是互不影响的。

具体实现中,消息的存储就写到本地文件中了,采用的是顺序写入的方式,其基本思路与基于文件的存储比较类似,也是为了提升写入的效率。

二者的差别是,这个场景中不存在文件的空洞,因为消息必须按照顺序去消费,所以,一个消息接收者在每一个它所接收的消息列队上有一个当前消费消息的位置,对于这个接收者来说,这个位置之前的消息就已经完成消费了。

在同一个列队中,不同的消费者分别维护自己的指针,并且通过指针的回溯,可以把消息的消费恢复到之前的某个位置继续处理。

如果有业务等的需要(例如消息需要补发),那么移动接收端的消费消息的位置指针就可以完成了。 在这样的方式下,接收端有比较大的自主控制权。而对于消息中间件来说,重要的是保证消息安全,然后根据接收端提供的位置获取消息传给接收端就可以了。

接收端的消息接收回溯支持

单机多队列的问题和优化

单机多队列的隔离完成了对消息的有序支持。在具体工程中,如果单机的队列数量特别多,性能就会有明显的下降,原因是队列数量很多时,消息写入就接近于随机写了。 一个改进措施是把发送到这台机器的消息数据进行顺序写入,然后再根据队列做一个索引,每个队列的索引是独立的,其中保存的只是相对于存储数据的物理队列的索引位置。 这里需要注意的一点是,在单机上,物理队列的数量的设置与磁盘数有关。

数据与索引队列

这样改进后带来的好处是:

  • 队列轻量化,单个队列数据量非常少。
  • 对磁盘的访问串行化,避免磁盘竞争,不会因为队列增加导致IOWAIT增高。

采用这个方案可以消除原来大量的数据的随机写,但是也有自身的缺点:

  • 写虽然完全是顺序写,但是读却变成了完全的随机读。
  • 读一条消息时,会先读逻辑队列,再读物理队列,增加了开销。
  • 需要保证物理队列与逻辑队列完全一致,增加了编程的复杂度。

对上述三个缺点需要进一步改进,以克服或者降低影响:

  • 随机读,尽可能让读命中PAGECACHE,减少IO读操作,所以内存越大越好。如果系统中堆积的消息过多,读数据访问磁盘时会不会由于随机读导致系统性能急剧下降呢?答案是否定的。
    • 访问PAGECACHE时,即使只访问1KB的消息,系统也会提前预读出更多数据,在下次读时,就可能命中内存。
    • 随机访问物理队列磁盘数据时,系统IO调度算法设置为NOOP方式,会在一定程度上将完全的随机读变成顺序跳跃读的方式,而顺序跳跃读会比完全的随机读的性能高5倍以上。另外4KB的消息在完全随机访问情况下,仍然可以达到每秒10000次以上的读性能。
  • 由于逻辑队列存储数据量极少,而且是顺序读,在PAGECACHE预读作用下,逻辑队列的读性能几乎与内存一致(即使在堆积情况下也如此)。所以可以忽略逻辑队列对读性能的阻碍。
  • 物理队列中存储了所有的元信息,类似于MySQL的binlog、Oracle的redolog,所以只要有物理队列在,即使逻辑队列数据丢失,仍然可以恢复回来。
解决本地消息存储的可靠性

消息的可靠性永远是一个很重要的话题,在这个方案中考虑采用消息同步复制的方式解决可靠性的问题。

  • 把单个的消息中间件机器变为主(Master)备(Slave)两个节点,Slave节点订阅Master节点上的所有消息,以进行消息的备份。不过需要注意这是一个异步的操作,Slave订阅收到的消息总会比Master略少一些,存在着丢失消息的可能。这种方式比较类似于MySQL的replication。
  • 同样是把单个节点扩展到Master/Slave两个节点,但是采用的是同步复制的方式,而非订阅的方式,也就是说Master收到消息后会主动写往Slave,并且收到了Slave的响应后才向消息发送者返回“成功”的消息。

对于消息数据安全性要求非常严格的场景,采用第二种方式更加安全和保险。

如何支持队列的扩容

扩容是整个系统中一个很重要的环节。在保证顺序的情况下进行扩容的难度会更大。

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

扩容示意图

其中有如下几个关键点:

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

Push和Pull方式的对比

  Push Pull
数据传输状态 保存在服务端 保存在消费端
传输失败,重试 服务端需要维护每次传输状态,遇到失败情况需要重试 不需要
数据传输实时性 非常实时 默认的短轮询方式的实时性依赖于Pull间隔时间,间隔越大实时性越低。长轮询模式的实时性与Push一致
流控机制 服务端需要依据订阅者的消费能力做流控 消费端可以根据自身消费能力决定是否去Pull消息

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

软负载中心的职责

服务注册查找中心可以用软负载中心来实现。

软负载中心在服务调用中的定位

在消息中间件中,消息发送者、消息订阅者对于消息中间件服务器的感知也是通过软负载中心来完成的。

软负载中心在消息中间件中的定位

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

一是聚合地址信息。 无论是服务框架中需要用到的服务提供者地址,还是消息中间件系统中的消息中间件应用的地址,都需要由软负载中心去聚合地址列表, 形成一个可供服务调用者及消息的发送者、接收者直接使用的列表。

地址聚合

二是生命周期感知。软负载中心需要能对服务的上下线自动感知,并且根据这个变化去更新服务地址数据, 形成新的地址列表后,把数据传给需要数据的调用者或者消息的发送者和接收者。

服务上下线感知

软件负载中心的结构

软负载中心包括两部分,一个是软负载中心的服务端,另一个是软负载中心的客户端。

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

使用软负载中心的应用与软负载中心的关系,还可以看出软负载中心内部有三部分重要的数据:

软负载中心与使用者

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

内容聚合功能的设计

内容聚合部分需要完成的工作主要是两个:

  • 保证数据正确性
    • 保证数据正确是基础的工作,内容聚合主要需要保证的是并发场景下的数据聚合的正确性,
    • 另外需要考虑的是发布数据的机器短时间上下线的问题,就是指发布数据的机器刚连接上来或发布数据刚传上来,然后就断线了;或者是断线以后很快又上线了,又发布数据了。内容聚合主要是在这些异常或者较为复杂的场景下保证数据的正确性。
  • 高效聚合数据
    • 高效地聚合数据非常重要,因为整个软负载中心可以说是系统的中枢,虽然软负载中心并不在服务调用或者消息投递的路径上,但是服务提供者、消息中间件等的服务地址列表都是由软负载中心进行管理的。
    • 因此高效地聚合数据会在软负载中心自身重启或者服务提供者大面积重启时带来很大的便利。

采用Java实现。可以使用一个Map的结构来进行数据管理,用dataId和数据分组的Id(group)作为Key,而对应的value就是聚合后的数据。 无论有数据新增还是因为服务提供者下线而需要删除数据,直接根据dataId和group定位到数据去处理就可以了。

逻辑在功能上是没有问题的,有以下几个关键点需要注意:

并发下的数据正确性保证

可以用ConcurrentHashMap线程安全地并发HashMap来管理所有的dataId的数据,这在并发上比Hashtable或加锁的HashMap要好很多。 而对于对应的Value的处理也是需要注意的地方。一种方式是使用LinkedList来实现,但是注意在进行数据增删时需要加锁,读取数据时也需要加锁,否则是非线程安全的。 另外也可以用一个ConcurrentHashMap来实现,其中的Key是产生这个数据信息的数据发布者的标识,而Value就是具体的数据。 而使用了ConcurrentHashMap也需要注意在新增dataId数据时的处理,因为这时可能是多个线程都会有新增,使用putIfAbsent并且进行返回值的判断,能够帮助正确地处理这个场景。

数据更新、删除的顺序保证

所发布数据的变化主要有新增、更新和删除,而处理的顺序一定要和真实世界中的顺序一致,这里很容易出现的问题是,在网络连接断开后删除数据与数据新增、更新的顺序问题。

为什么会产生顺序问题呢?这与的具体实现机制有关系。

多线程共同处理数据方案

采用NIO的方式通信,通过Selector的方式感知连接上的事件,包括数据可读、数据可写、建立连接、连接断开等事件, 然后把这些交给IO线程池中的线程处理,那么,更新、新增数据和连接断开要去删除数据就可能在两个线程中处理。

而如果是发布数据后很快断开,那么保证在内部按照顺序来处理就很关键,因为如果顺序不保证,就可能先处理了删除数据,然后再处理新增,这样数据就不对了。 一个解决的办法是在插入数据时判断当前产生数据的发布者的连接是否还存在。

这个部分在实现上需要特别注意,因为这种场景的发生概率虽然比较小,但是一旦出现问题就很难排查。

大量数据同时插入、更新时的性能保证

采用线程安全的容器,控制在并发时的处理顺序与实际顺序相同,这都是为了保证数据聚合功能的正确性,而性能也是需要特别关注的点。

ConcurrentHashMap是并发的线程安全的容器,但是在进行数据写的时候还是会有锁的开销的,而读的时候是无锁的(比较特殊的情况下会加锁)。 而在大量的数据插入和更新的场景,ConcurrentHashMap也会遇到性能问题。
对于同样的dataId,group对应的数据保存,采用LinkedList需要加锁,而使用ConcurrentHashMap则在数据更新时会遇到和插入、更新dataId一样的问题。

多线程处理同样数据产生的竞争:

多线程处理同样数据产生的竞争

这里可以进行一下优化,就是根据dataId,group进行分线程的处理,也就是说,保证同样的dataId,group的数据是在同一个线程中处理的, 这样可以把整个数据结构变成一个不需要锁的数据结构,并且也可以在数据处理上进行一定程度的合并。

读和写的操作之间怎么处理?

可以根据dataId,group分线程来处理数据新增、更改、删除的合并数据的请求,那么读取数据该怎么做呢? 可以把读的操作也放入相应的线程中处理,这样就可以使得保存数据的结构完全不用加锁就能保证线程安全。

改进后的方案增加了任务队列、对应的处理线程及对应的数据存储。 这样,针对同样数据的处理任务是在同一个线程中,可以直接使用线程不安全的容器; 而多线程的请求变成了一个顺序队列的操作,交给任务队列处理,任务队列是一个需要线程安全的实现, 但是因为这里的操作主要就是“任务加入队列”和“任务从队列中取出”,都是简单的操作,锁冲突的情况相对之前的加锁进行数据处理要好多了。 数据更新的线程如果需要等待更新结果,那就只要进行等待就可以了;而读取数据则一定需要等待任务执行结束后才能拿到数据结果。

同样数据单线程处理方案:

同样数据单线程处理方案

解决服务上下线的感知

软负载负责可用的服务列表,当服务可用时,需要自动把服务加到地址列表中,而服务不可用时,需要自动从列表中删除。 这就是所说的上下线感知,也是与使用硬件负载均衡需要配置服务列表相比的一个很大的优点。

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

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

这个方式有结构上的问题:

  • 软负载中心的服务器属于旁路,也就是说它并不在调用链上,当软负载中心自身的负载很高时,是可能产生误判的。
    • 例如,软负载中心压力很大,处理请求变慢,心跳数据来不及处理,会以为心跳超时而判定服务不在线,认为服务不可用并且把信息通知给服务的调用者,这会导致原本可用的服务被下线了。
  • 另外可能存在的问题是,如果服务发布者到软负载中心的网络链路有问题,而服务发布者到服务使用者的链路没问题,也会造成感知的问题

解决方法是在软负载中心的客户端上增加逻辑,当收到软负载中心通知的应用下线数据时,需要服务调用者进行验证才能接收这个通知。 但是这个方法带来的是对每个服务提供者的一次额外验证。

通过对于发布数据中提供的地址端口进行连接的检查

如果软负载中心自身负载很高,那么通过一段时间内长连接的心跳和数据通信来判断服务发布者是否在线存在着错判的可能, 通过外部的一个主动检查的方式去进行判定是一个补偿的方式,也就是当通过长连接的相关感知判断服务应用已经下线时,不直接认定这个服务已经下线, 而是交给另一个独立的监控应用去验证这个服务是否已经不在了,方式一般是通过之前发布的地址、端口进行一下连接的验证,如果不能连接,则确认机器确实下线了。

不过这种方式同样存在一个问题,即进行检查确认的这个系统也可能和服务提供者之间存在网络问题,而服务提供者与服务调用者之间是正常的。

解决方法也还是需要服务调用者进行最终确认,因为在系统中进行的实际业务调用通信是在服务调用者和服务提供者之间。

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

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

第一个差别是,消息中间件需要保证消息不丢失,每条消息都应该送到相关的订阅者,而软负载中心只需要保证最新数据送到相关的订阅者,不需要保证每次的数据变化都能让最终订阅者感知。

软负载中心客户端的数据接收

无论如何,订阅者最终收到的数据都是value=“Z”这个最新的数据,因为对于服务地址列表来说,只需要保证订阅者能够收到最新的数据就可以了。

第二个差别是关于订阅者的集群,也就是订阅者的分组。在消息中间件中,同一个集群中的不同机器是分享所有消息的,因为这个消息只要同一集群中的一台机器去处理了就行了。 而在软负载中心则不同,因为软负载中心维护的是大家都需要用的服务数据,所以,需要把这个数据分发给所有的机器。这也是消息中间件与软负载中心在数据分发方面的不同。

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

  • 数据压缩
    • 的数据都是和服务相关的信息,数据压缩可以很好地降低数据量,提升网络吞吐能力,使用CPU来换带宽,这对于软负载中心还是非常有用的。
    • 而且因为很多服务的订阅集群不止一个,每个集群中的机器也不止一个,所以一份数据需要投递的目标是很多的,压缩一次所带来的流量下降是很明显的。所以数据压缩是一定要考虑的方面。
  • 全量与增量的选择
    • 从前面的介绍可以看到发布者提供的服务信息数据会随着提供服务的机器的变化而变化,而每个变化都会引起这个服务的整体服务数据的更新,那么,是每次把整个最新的数据传给数据的订阅者呢,还是只把变化的数据传给服务的订阅者?

每次传递全量数据,整体的设计和逻辑会非常简单,缺点是传送的数据量大。而传递增量数据,每次传送的数据量小,但是逻辑会复杂很多。

建议在刚开始的实现中采用简单的方式,也就是传送全量数据,当全量的数据很大时,就需要考虑采用增量传送的方式来实现了。

针对服务化的特性支持

软负载数据分组

通过数据标识(dataId)和分组(group)来唯一确定数据。那么,为什么要引入分组呢?

分组主要是为了进行隔离,分组本身就是一个命名空间,用来把相同的dataId的内容分开,也就是给dataId加上了一个namespace。分组主要用在下面两种场景。

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

关于分组的方式,需要支持指定分组的API设置方式,以及根据IP地址自动归组的方式,根据IP地址自动进行归组可以带来更大的灵活性和运维的便利性。

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

软负载中心对机器上下线的自动感知,而机器的上下线还需要通过指令而非机器状态来控制,这个控制的支持必然是要放在软负载中心来完成。

之所以在机器的状态外进行控制,主要有下面两个考虑:

  • 优雅地停止应用
    • 如果靠机器是否存活来判断服务是否有效,那么只有关掉应用,才能将它从服务列表中拿到,那么这时正在执行的服务就失败了。
    • 应该先从服务列表中去掉这个机器,等待当时正在执行的服务结束,然后再停止应用。通过指令直接从软负载中心使机器下线,是可以帮助做到这一点的。
  • 保持应用场景,用于排错
    • 遇到服务的问题时,可以把出问题的服务留下一台进行故障定位和场景分析。
    • 这时需要把这台机器从服务列表中拿下来,以免有新的请求进来造成服务的失败。这也是需要软负载中心直接使服务下线的一个场景。

维护管理路由规则

路由规则,而这个规则本身需要进行统一的维护,软负载中心可以管理这些数据,不过这些数据与服务地址列表的特性不同。

笔者最初见到的是将一些类似服务地址列表这样的非持久数据和路由规则、消息订阅关系等持久数据放在一起处理的,这样做复用了数据推送、客户端缓存等基础组件, 但是也带来了比较多的问题。后来对不同特性的数据进行了拆分,参考《集中配置管理中心》

从单机到集群

当的系统规模还不大时,单机加上一个备份机器的方式就可以充当软负载中心。 备份机器只是在主机不能恢复时才使用,因为软负载中心管理的都是地址信息这样的运行时可聚合信息,所以这个方案相对也比较简单。

随着整个应用集群的规模越来越大,单机会遇到连接数以及数据推送方面的瓶颈(内存一般还不是问题),那么如何把单机方式的软负载中心变为一个集群就是一件很重要的事情了。

如果软负载中心从单机走向集群,需要解决的问题有什么呢?

  • 数据管理问题
    • 软负载中心聚合了整个分布式集群中的服务地址信息。在单机的情况下,这些数据都统一地存在这个软负载中心机器上,那么变为集群时,数据应该怎么维护保存呢?
  • 连接管理问题
    • 在单机时,所有的数据发布者和数据订阅者都会连接到这台软负载中心的机器上,而从单机变成集群时,这些数据发布者和数据订阅者的连接应该怎么管理呢?

这两个问题有不同的解决方案,从数据管理的维度展开介绍各个方案,并看一下在确定数据管理方式的情况下,连接管理对应的做法。

数据统一管理方案

这个方案是对数据进行统一管理,也就是把数据聚合放在一个地方,这样负责管理连接的机器就可以是无状态的了。

数据统一管理方案

可以看到,整个结构分为三层,聚合数据这一层就是在管理数据;而软负载中心的机器则是无状态的,不再管理数据; 对于数据发布者和订阅者来说,选择软负载中心集群中的任何一个机器连接皆可,因为软负载中心的机器是对等的。

对这个方案可以做一个改变,即把软负载中心集群中的机器的职责分开,就是把聚合数据的任务和推送数据的任务分到专门的机器上处理:

软负载应用分工后的数据统一管理方案

发布者和订阅者的连接是分开管理的,而集群中的应用分工更加明确。为了提升性能,在软负载中心负责数据推送的机器上是可以对聚合数据做缓存的。

数据统一管理方式主要是把数据抽出来集中进行管理,结构和职责都比较清晰。 不过需要注意的是必须保证“聚合数据”这个统一数据管理层的可用性,因为如果这部分出问题,又没有容灾策略,那么整个软负载中心就不能正常工作了。

数据对等管理方案

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

使用软负载中心的数据发布者和数据订阅者只需要去连接软负载中心集群中的任何一台机器就可以了,数据发布者只需要把数据发布给这一台机器,而数据订阅者只需要从这一台机器上进行订阅。

在软负载中心集群内部,各个节点之间会进行数据的同步,所以,一台软负载中心收到的数据会传给其他节点,也会收到其他节点同步过来的数据, 从而形成各个节点的数据都对等的状态。这种方式下,数据发布者和数据订阅者的客户端的逻辑都非常简单并易于实现。

数据对等管理方案

那么软负载中心的各个节点之间的数据怎么同步呢?

可以互相进行数据的发布,也就是说,如果软负载中心A需要把数据同步给软负载中心B,那么软负载中心A就作为一个数据发布者把数据发布给软负载中心B就可以了。 软负载中心B基本可以按照一个普通的数据发布者来处理A,差别是当B要把自己的数据发布给其他节点时,从A收到的数据是不需要发布的,因为A自己会去发布。

这个方式可以复用现有的软负载中心的客户端,不过也带来了同步效率的问题,因为服务提供者发布数据的量相对是很小的,而且是一旦有数据要发布,就直接去进行通信了。 而对于软负载中心节点间的数据同步,在发生变动时需要同步的数据量比较大,这时如果能够进行批量处理就会更加高效。 没有必要在软负载中心的节点中同步每次变化,只要合并这些变化后同步一次就可以了。

因此在相对大型的场景下,对于软负载中心集群内部节点间的数据同步,独立实现会比复用客户端的发布功能更加高效一些。 具体同步时,可以设置一个间隔,把这个间隔内的数据变化合并后再进行一次同步。

如果节点较多的话,那么整个同步的量会比较大,这时也同样可以对集群内的节点进行职责划分:

软负载应用分工后的数据对等管理方案

也是把软负载中心集群内的节点分为了两种,一种是进行数据分发的节点,另一种是进行数据聚合的节点,只负责和数据发布者连接, 聚合连接到自己节点上的数据发布者的数据,并且把聚合后的数据同步给进行数据分发的机器。 负责数据聚合的软负载中心的节点之间是没有联系的,负责数据分发的软负载中心的节点之间也没有联系,而每个负责数据聚合的软负载中心节点和每个负责数据分发的软负载中心节点都有一个连接。

如果第二种方案的聚合数据的节点数大于1的话,那么需要同步的数据量就比第一种方案小了,如果等于1,则两种方案一样。

如果整个集群管理的总体数据很多,超出了单机的限制的话,那么就需要根据dataId,group对数据进行分组管理,让每个节点管理一部分数据。也就是用规则对数据进行类似分库分表的操作。 不过如果走到这一步的话,数据订阅者可能就需要连接多个数据分发节点了。

数据分组且软负载应用分工后的数据对等管理方案

集中配置管理中心

配置具体可以分为非持久/聚合、持久/聚合、持久/非聚合和非持久/非聚合四类。

按照数据是否持久进行划分,软负载中心管理的是非持久数据,而集中配置管理中心则是为了管理持久数据,两者都可以支持聚合的数据。

对于集中配置管理中心来说,最为关心的是稳定性和各种异常情况下的容灾策略,其次是性能和数据分发的延迟。 集中配置管理中心存储的基本都是各个应用集群、中间件产品的关键管理配置信息,以及一些配置开关。 通过集中配置管理中心统一进行运行时的控制,通过改变配置的内容进而影响应用的行为。

集中配置管理中心的结构:

集中配置管理中心结构

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

集中配置管理中心集群这一层由多个集中配置管理中心的节点组成,这些节点是对等的。都可以提供数据给应用端,也都可以接收数据的更新请求并更改数据库。这些节点之间互不依赖。

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

对集中配置管理中心的使用分为了以下两部分。

  • 提供给应用使用的客户端
    • 主要是业务应用通过客户端去获取配置信息和数据,用于数据的读取。应用本身不去修改配置数据,而是根据配置来决定和更改自身应用的行为。
  • 为控制台或者控制脚本提供管理SDK
    • 这个SDK包括了对数据的读写,通过管理SDK可以进行配置数据的更改。

客户端实现和容灾策略

客户端通过HTTP协议与集中配置管理中心进行通信。采用HTTP协议而不是私有协议可以更方便地支持多种语言的客户端,而且可以方便地进行测试和问题定位。 那么,采用HTTP协议和集中配置管理中心进行交互,这相对于之前私有协议的Socket长连接来说是一种轮询的方式。 考虑到服务端的压力,轮询的间隔是不能太短的,而这样会影响获取数据的时效性。

在此基础上的一个改进是采用长轮询(Long Polling)的方式:

长轮询示意图

建立连接并且发送请求后,如果有数据,那么长轮询和普通轮询立刻返回; 如果没有数据,长轮询会等待,如果等到数据,那么就立刻返回,如果一直没有数据,则等到超时后返回,继续建立连接,而普通轮询就直接返回了。

可以看出,采用长轮询的方式,数据分发的实时性比普通轮询要好很多,和Socket长连接方式大体相同, 不过长轮询需要不断地建立连接,这是它相对于Socket长连接方式的弱点,可以说HTTP长轮询方式是HTTP普通轮询和Socket长连接方式的折中。

  • 数据缓存
    • 是指每次收到服务端的更新后对数据的缓存,缓存的作用是当服务端因忙而不能及时响应数据获取请求时,为应用提供一个可选的获取数据的方案。
    • 使用本地的缓存不能保证获取最新的数据,但是能保证获得比较新的数据。
    • 在一些场景下,应用需要的是获得相应的数据然后继续业务逻辑,是否是当下最新的数据可能不那么关键,这个时候本地的数据缓存就可以派上用场了。
  • 数据快照
    • 数据缓存能够缓存应用客户端获取到的最新数据,而数据快照保存的是最近几次更新的数据,数据是比缓存的数据旧一些,但是会保持最近的多个版本。
    • 数据快照用于服务端出现问题并且由于各种原因不能使用数据缓存时,例如缓存的最新的数据配置是一个有问题的配置,如果这时服务端不正常的话,就可以从更早几个版本的数据快照中进行恢复。
  • 本地配置
    • 正常情况下,应用通过集中配置使用服务端所给的配置、数据管理中心客户端,但是如果遇到服务端不工作,而且需要更新配置并使之生效的情况,就需要使用本地配置这个特性,也就是说,如果在本地配置的目录中有对应的数据配置内容的话,这个优先级是最高的。
    • 如果服务端出现问题或者客户端与服务端的通信出故障,最坏的情况也可以把新的配置分发到各个应用的某一特殊位置,使得这个本地配置生效从而解决服务端不可用的问题。
  • 文件格式
    • 这一点也很重要。如果是二进制数据格式,那么没有对应的工具是无法对配置进行修改的。
    • 而在客户端的容灾方面的最坏打算就是整个系统退化到一个单机的应用上,就会需要直接修改配置内容和数据,那么文本格式的限制就非常重要和关键了。

服务端实现和容灾策略

在集中配置管理中心服务端,主要使用了Nginx加Web应用的方式。和逻辑相关的部分在Web应用上实现。Nginx用于请求的处理和最后结果的返回,而供返回的数据则都在本地文件系统中。

相比通过Web应用从数据库中获取数据,然后再把数据传给Nginx,通过Nginx返回本地文件的数据要快很多,能够很好地提高系统的吞吐量,这也是很多网站的内容静态化的方式。 除了作为静态化去进行加速以外,本地文件处理还有一个很重要的职责就是进行数据库的容灾。有了本地文件,数据的读取就不再走数据库了,读取配置数据不需要数据的参与。

在服务端需要做的另外一件事情是和数据库的数据同步,包含两个方面:

  • 通过当前服务端更新数据库。
    • 由管理SDK的请求送到当前的服务端,服务端需要去更新数据库的数据,同时,服务端也更新自身的本地文件,还可以通知其他机器去更新数据,不过只是传送一个更新数据的通知,而不是传送所有数据,并且这个通知也不是更新其他服务端数据的唯一方式。
  • 定时检查服务端的数据与数据库中数据的一致性。
    • 这是为了确保服务端本地文件数据和数据库的内容的一致性,前面提到的如果数据更新通知不能送达其他服务端,那么其他服务端就需要靠定时地检查来保证与数据库中数据的一致性。

此外,根据IP地址的分组处理也是服务端的Web应用需要处理好的逻辑。

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

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

数据库策略

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

架构师领导艺术

关注人而不是产品

一定要坚信:一群优秀的人做一件他们热爱的事,一定能取得成功。不管过程多么曲折,不管外人看来多么不可思议不靠谱。

所以最好的软件项目管理不是制订计划,组织资源,跟踪修正项目进展,对成员进行激励和惩罚,而是发掘项目组每个成员的优秀潜能,让大家理解并热爱软件产品最终的蓝图和愿景。每个人都是为实现自我价值而努力,不是为了领工资而工作。

一旦做到这一点,项目组每个成员都会自我驱动,自觉合作,寻找达成目标的最优路径并坚韧不拔地持续前进。整个过程中,不需要拙劣的胡萝卜和大棒,最好的奖励就是最终要达成的目标本身,最大的惩罚就是这个美好的目标没有实现。

领导的真谛:寻找一个值得共同奋斗的目标,营造一个让大家都能最大限度发挥自我价值的工作氛围。

没有懒惰的员工,只有没被激发出来的激情。

是事情成就了人,而不是人成就了事。指望优秀的人来帮自己成事,不如做成一件事让自己和参与的人都变得优秀。

共享美好蓝图

架构师要和项目组全体成员共同描绘一个蓝图,这个蓝图是整个团队能够认同的,是团队共同奋斗的目标。

  • 蓝图应该是表述清楚的:产品要做什么、不做什么、要达到什么业务目标,都需要描述清楚。
  • 蓝图应该是形象的:产品能为用户创造什么价值、能实现什么样的市场目标、产品最终会长什么样,都需要形象地想象出来。
  • 蓝图应该是简单的:不管内部还是外部沟通,都能一句话说明白:在做什么。

蓝图应该写在软件架构设计文档的扉页、写在邮件的签名档、写在内部即时通信群的公告上。

在项目过程中,架构师要保持对目标蓝图的关注,对任何偏离蓝图的设计和决定保持警惕,错误的偏离要及时修正,必要的变更要经过大家讨论,并且需要重新获得大家的认同。

共同参与架构

不要只有架构师一个人拥有架构

架构师不要把架构当做自己的私有财产,为了维护架构的纯洁和架构师的威信而不让他人染指架构。让项目参与者对架构充分争论,大家越是觉得自己是项目架构的重要贡献者,就越是愿意对开发过程承担责任,越是愿意共同维护架构和改善软件。

让其他人维护框架与架构文档

框架是架构的重要组成部分,许多重要的架构设计通过框架实现来体现。但是在软件开发过程中,架构也需要根据需求不断发展演化,框架和架构文档也会随之调整。除非是重大的重构,否则架构师应该让项目组成员维护框架和架构文档,给项目组成员成长的机会也让自己有更多的时间去寻找更大的挑战。

学会妥协

很多时候,对架构和技术方案的反对意见,其实意味着架构和技术方案被关注、被试图理解和接受。架构师不应该对意见过于敏感,这时架构师应该做的是坦率地分享自己的设计思路,让别人理解自己的想法并努力理解别人的想法,求同存异。

对于技术细节的争论应该立即验证而不是继续讨论,当讨论深入到技术细节的时候也意味着问题已经收敛,对于整体架构设计,各方意见正趋于一致。

而当大家不再讨论架构的时候,表明架构已经融入到项目、系统和开发者中了,架构师越早被项目组遗忘,越表示架构非常成功;项目组越离不开架构师,越表示架构还有很多缺陷。

成就他人

每个人都有自己成就的目标,而工作是达成自我成就的一种手段:通过工作的挑战,发掘自我的潜能,重新认知自我和世界。

做成一个项目不但要给客户创造价值,为公司盈利,还要让项目成员获得成长。要让他们觉得通过这个项目,自己的知识技能和业务水平都得到了提高。项目结束时,大家会觉得不可思议:“如此完美的产品,如此有挑战的开发居然都是完成的”。而且每个人都觉得自己在项目中至关重要不可或缺。

架构师作为团队的技术领导者,在项目过程中不要去试图控制什么,带着一个弹性的计划和蓝图推进,团队会管好他们自己。你越是强加禁令,队伍就越是没有纪律;你越是强制,团队就越是不能独立自主;你越是从外面寻找帮助,大家就越是没有信心。

而一旦打造出一个优秀的团队,在以后的合作中,面临更大的挑战时,架构师就可以从容应对,因为你不是一个人在战斗。同时一个优秀的团队内部也会发生化学反应,创造出超出工作本身的机会,开启更美好的明天。

大型网站架构技术一览

1.前端架构

前端指用户请求到达网站应用服务器之前经历的环节,通常不包含网站业务逻辑,不处理动态内容。

浏览器优化技术

并不是优化浏览器,而是通过优化响应页面,加快浏览器页面的加载和显示,常用的有页面缓存、合并HTTP减少请求次数、使用页面压缩等。

CDN

内容分发网络,部署在网络运营商机房,通过将静态页面内容分发到离用户最近的CDN服务器,使用户可以通过最短路径获取内容。

动静分离,静态资源独立部署

静态资源,如JS、CSS等文件部署在专门的服务器集群上,和Web应用动态内容服务分离,并使用专门的(二级)域名。

图片服务

图片不是指网站Logo、按钮图标等,这些文件属于上面提到的静态资源,应该和JS、CSS部署在一起。这里的图片指用户上传的图片,如产品图片、用户头像等,图片服务同样使用独立部署的图片服务器集群,并使用独立(二级)域名。

反向代理

部署在网站机房,在应用服务器、静态资源服务器、图片服务器之前,提供页面缓存服务。

DNS

域名服务,将域名解析成IP地址,利用DNS可以实现DNS负载均衡,配置CDN也需要修改DNS,使域名解析后指向CDN服务器。

2.应用层架构

应用层是处理网站主要业务逻辑的地方。

开发框架

网站业务是多变的,网站的大部分软件工程师都是在加班加点开发网站业务,一个好的开发框架至关重要。一个好的开发框架应该能够分离关注面,使美工、开发工程师可以各司其事,易于协作。同时还应该内置一些安全策略,防护Web应用攻击。

页面渲染

将分别开发维护的动态内容和静态页面模板集成起来,组合成最终显示给用户的完整页面。

负载均衡

将多台应用服务器组成一个集群,通过负载均衡技术将用户请求分发到不同的服务器上,以应对大量用户同时访问时产生的高并发负载压力。

Session管理

为了实现高可用的应用服务器集群,应用服务器通常设计为无状态,不保存用户请求上下文信息,但是网站业务通常需要保持用户会话信息,需要专门的机制管理Session,使集群内甚至跨集群的应用服务器可以共享Session。

动态页面静态化

对于访问量特别大而更新又不很频繁的动态页面,可以将其静态化,即生成一个静态页面,利用静态页面的优化手段加速用户访问,如反向代理、CDN、浏览器缓存等。

业务拆分

将复杂而又庞大的业务拆分开来,形成多个规模较小的产品,独立开发、部署、维护,除了降低系统耦合度,也便于数据库业务分库。按业务对关系数据库进行拆分,技术难度相对较小,而效果又相对较好。

虚拟化服务器

将一台物理服务器虚拟化成多台虚拟服务器,对于并发访问较低的业务,更容易用较少的资源构建高可用的应用服务器集群。

3.服务层架构

提供基础服务,供应用层调用,完成网站业务。

分布式消息

利用消息队列机制,实现业务和业务、业务和服务之间的异步消息发送及低耦合的业务关系。

分布式服务

提供高性能、低耦合、易复用、易管理的分布式服务,在网站实现面向服务架构(SOA)。

分布式缓存

通过可伸缩的服务器集群提供大规模热点数据的缓存服务,是网站性能优化的重要手段。

分布式配置

系统运行需要配置许多参数,如果这些参数需要修改,比如分布式缓存集群加入新的缓存服务器,需要修改应用程序客户端的缓存服务器列表配置,并重启应用程序服务器。分布式配置在系统运行期提供配置动态推送服务,将配置修改实时推送到应用系统,无需重启服务器。

4.存储层架构

提供数据、文件的持久化存储访问与管理服务。

分布式文件

网站在线业务需要存储的文件大部分都是图片、网页、视频等比较小的文件,但是这些文件的数量非常庞大,而且通常都在持续增加,需要伸缩性设计比较好的分布式文件系统。

关系数据库

大部分网站的主要业务是基于关系数据库开发的,但是关系数据库对集群伸缩性的支持比较差。通过在应用程序的数据访问层增加数据库访问路由功能,根据业务配置将数据库访问路由到不同的物理数据库上,可实现关系数据库的分布式访问。

NoSQL数据库

目前各种NoSQL数据库层出不穷,在内存管理、数据模型、集群分布式管理等方面各有优势,不过从社区活跃性角度看,HBase无疑是目前最好的。

数据同步

在支持全球范围内数据共享的分布式数据库技术成熟之前,拥有多个数据中心的网站必须在多个数据中心之间进行数据同步,以保证每个数据中心都拥有完整的数据。在实践中,为了减轻数据库压力,将数据库的事务日志(或者NoSQL的写操作Log)同步到其他数据中心,根据Log进行数据重演,实现数据同步。

5.后台架构

网站应用中,除了要处理用户的实时访问请求外,还有一些后台非实时数据分析要处理。

搜索引擎

即使是网站内部的搜索引擎,也需要进行数据增量更新及全量更新、构建索引等。这些操作通过后台系统定时执行。

数据仓库

根据离线数据,提供数据分析与数据挖掘服务。

推荐系统

社交网站及购物网站通过挖掘人和人之间的关系,人和商品之间的关系,发掘潜在的人际关系和购物兴趣,为用户提供个性化推荐服务。

6.数据采集与监控

监控网站访问情况与系统运行情况,为网站运营决策和运维管理提供支持保障。

浏览器数据采集

通过在网站页面中嵌入JS脚本采集用户浏览器环境与操作记录,分析用户行为。

服务器业务数据采集

服务器业务数据包括两种,一种是采集在服务器端记录的用户请求操作日志;一种是采集应用程序运行期业务数据,比如待处理消息数目等。

服务器性能数据采集

采集服务器性能数据,如系统负载、内存使用率、网卡流量等。

系统监控

将前述采集的数据以图表的方式展示,以便运营和运维人员监控网站运行状况,做到这一步仅仅是系统监视。更先进的做法是根据采集的数据进行自动化运维,自动处理系统异常状况,实现自动化控制。

系统报警

如果采集来的数据超过预设的正常情况的阈值,比如系统负载过高,就通过邮件、短信、语音电话等方式发出报警信号,等待工程师干预。

7.安全架构

保护网站免遭攻击及敏感信息泄露。

Web攻击

以HTTP请求的方式发起的攻击,危害最大的就是XSS和SQL注入攻击。但是只要措施得当,这两种攻击都是比较容易防范的。

数据保护

敏感信息加密传输与存储,保护网站和用户资产。

8.数据中心机房架构

大型网站需要的服务器规模数以十万计,机房物理架构也需要关注。

机房架构

对于一个拥有十万台服务器的大型网站,每台服务器耗电(包括服务器本身耗电及空调耗电)每年大约需要人民币2000元,那么网站每年机房电费就需要两亿人民币。数据中心能耗问题已经日趋严重,Google、Facebook选择数据中心地理位置的时候趋向选择散热良好,供电充裕的地方。

机柜架构

包括机柜大小,网线布局、指示灯规格、不间断电源、电压规格(是48V直流电还是220V民用交流电)等一系列问题。

服务器架构

大型网站由于服务器采购规模庞大,大都采用定制服务器的方式代替购买服务器整机。根据网站应用需求,定制硬盘、内存、甚至CPU,同时去除不必要的外设接口(显示器输出接口,鼠标、键盘输入接口),并使空间结构利于散热。


以上概念总结于《大型网站技术架构 核心原理与案例分析》、《大型网站系统与Java中间件实践》

Search

    Post Directory