《大型网站技术架构》读书笔记

大型网站由简单网站发展而来。在发展过程中,工程师逐步为网站添加或改进架构。发展过程中的每一步改进,都应结合网站的具体业务和当时的确切需求来进行,因此不存在一个固定的发展套路,也不存在一步到位的大型网站设计与建设。

对一个网站,具有以下方面的要求:

  • 高性能
  • 可用性
  • 伸缩性
  • 扩展性
  • 安全性

本文介绍的各项技术,均旨在更好地满足以上一项或多项要求。但要注意,不同的网站,以及同一个网站在发展中的不同时期,有着不同的侧重点,因此工程师应恰当地选择应用哪些技术。

高性能技术

从不同侧面评价网站性能

不同的性能指标

“性能”一词的含义覆盖面很大,可以从三个侧面来度量相关客观指标以及进行优化:

  • 用户侧:用户使用浏览器浏览网页时的直观感受。虽然网站建设中的一切技术问题都将最终影响用户的浏览体验,但在这个侧面中,我们主要关注一些前端指标以及相应的优化手段
    • 响应时间:用户从发出请求到收到响应的时间。这是一个极其综合的指标,系统的任何一个部件的情况都会影响它
  • 开发人员侧:指的是运行在服务器上的Web程序及其相关子系统的性能
    • 并发数:系统能同时接收的请求的数量
    • 吞吐量:单位时间内,系统实际处理完毕的请求数,常用指标有TPS(每秒事务数)、HPS(每秒HTTP请求数)、QPS(每秒查询数)等等。一个运行中的系统的某一时刻,实际并发数越大,实际吞吐量也越大,但实际并发数若超过一个临界点,则实际并发数越大,实际吞吐量反而越小,直到系统耗尽硬件资源,发生崩溃,实际吞吐量为0
  • 运维人员侧:指的是基础设施(包括服务器硬件、数据中心、网络运营商等等)性能以及对基础设施的利用率
    • 操作系统指标,例如内存使用情况、CPU使用情况、磁盘读写速度等。我们通常希望对硬件资源的利用处在一个既不浪费也不紧张的状态,因此使用一些监控手段,在超过阈值的时候向运维或开发人员报警

进行性能测试以得到指标

性能测试包括负载测试、压力测试、稳定性测试。

  • 负载测试:向系统进行并发请求。目的是得到系统吞吐量中的临界点,即系统最大负载点
  • 压力测试:向系统进行大量并发请求直到系统崩溃。目的是得到系统在崩溃前能承受的最大并发数,即系统崩溃点
  • 稳定性测试:模拟线上环境,以不均匀、但较为符合实际情况的并发数,在一段较长时间内对系统进行测试。目的是测试系统的稳定性

优化指标

如果性能测试并没有达到预期,那么需要通过检查各环节的日志来分析哪个环节花费的时间不合理,并检查操作系统的监控数据,分析对内存、CPU、硬盘等硬件的利用情况。排除出现了明显的程序错误的情况,可以从以下三个方面进行优化。

Web前端优化

手段包括:

  • 使用CDN存储静态资源,例如图片、JS脚本等
  • 在反向代理服务器上放置静态资源的缓存。也可以在上面放置动态资源(例如帖子、博客文章等)的缓存,当动态资源有变化时,使用内部通知机制通知反向代理服务器更新

服务端性能优化

在这方面能做的工作最多。

引入缓存

在软件工程的任何场景下,解决性能问题的第一个方案就是引入缓存。缓存实质上就是一个内存中的哈希表。服务器通过将热点数据保存在本机内存中以供快速读取(即本地缓存),而无须访问数据库或者重新进行计算,来提高效率。如果大型网站的热点数据多到无法保存在单机的内存中,则可以额外再引入分布式缓存,程序通过网络通信访问各充当专门的缓存服务器的机器(内存通常都是TB级)上的缓存数据。

使用缓存时的潜在问题

在大型网站中,缓存是如此的重要,以至于承担了大部分数据访问的压力。这使得确保缓存机制在任何时刻都能正常工作十分重要,否则数据库势必承担不住巨大的访问压力。为了确保缓存能在最初启动(包括重启)时——也就是缓存系统尚未能利用LRU(最近最久未用算法)积累一定热点数据时——就能发挥出作用,常常使用缓存预热的方法,即手动指定缓存系统加载一些常用数据到内存中。

若请求的数据并不在缓存之中,则请求将落到数据库上,这称为缓存穿透。有时会有攻击者通过大量发送对不存在的数据(连数据库里也没有,更别说缓存了)的请求来进行攻击,此时一种简单的做法是将不存在的数据也放到缓存中,例如保存为null。

缓存中的数据应当有一个失效时间。超过失效时间后,缓存系统会从数据库中重新加载最新数据。因此在重新加载之前,数据库中数据的更新并不会立刻体现,从而使得客户端获得一些滞后的信息(称为脏读)。有时这种情况是可以接受的,有时又是不能接受的,这时应当采用一些能够通知缓存立刻更新的机制,但这可能带来一些开销和一致性问题。

被缓存起来的信息如果变动频繁,或者并非会被频繁访问的热点数据,则降低了缓存的意义——这些数据还没被读取,就在缓存中被更新或是被LRU算法挤出了缓存。

分布式缓存

有两种分布式缓存系统:以JBoss cache为代表的同步更新的分布式缓存,和以Memcached为代表的互不通信的分布式缓存。

JBoss cache的用法通常是和Web程序运行在同一个机子中——你的每台服务器均运行着JBoss cache + Web程序,Web程序通过读取本机的JBoss cache来使用缓存。各服务器中的JBoss cache会同步更新缓存。该方案的缺点是,无论有多少台服务器,缓存中的内容还是受限于单台服务器的内容(每台服务器里的缓存内容都是一样的),且同步更新的代价较大。该方案通常用在企业级软件开发之中。

Memcached则分为Memcached客户端和Memcached服务端。Memcached服务端与Web程序分离部署,部署在各专门的缓存服务器之中。运行着Web程序的服务器则同时运行着Memcached客户端,Web程序通过读取本机的Memcached客户端来使用缓存,而Memcached客户端通过基于TCP的一套简单应用层协议与每一个缓存服务器上的Memcached服务端保持长连接的通信,来快速从某个Memcached服务端中取到数据。这种设计使得缓存服务器集群可以方便地添加或删减服务器。

引入负载均衡技术

引入缓存机制后,数据库读取不再是性能瓶颈,而运行着Web程序的单台的服务器成了性能瓶颈。在考虑优化Web程序的代码(这通常比较困难)之前,在更多的服务器上运行Web程序是首选方案。通过使用一个负载均衡服务器——即专门负责接收用户请求并将请求转发到不那么忙的服务器上的服务器,可以很轻松地提高网站的并发数和吞吐量。

引入消息队列

使用消息队列将对用户请求的响应异步化,可以提高网站的并发数。当Web程序收到用户发来的请求后,将该请求触发的服务器操作以消息的形式发送给消息队列(维护消息队列的程序可以在本机,也可能是在其它的消息队列服务器上),并立刻给用户返回响应。而消息队列中的消息,稍后将会由消息消费程序来读取,并做出相应的数据库IO操作。此时用户的请求才算真正处理完,但用户在此前已得到响应,得到了顺畅的体验。

消息队列主要解决了应用耦合、异步处理、流量削锋等问题。

程序代码优化
  • 进行多线程编程。对于计算密集型应用,多线程编程可利用所有的CPU资源提高计算速度,此时启动的进程数不超过CPU核心数;对IO密集型应用,多线程编程可将等待IO的空闲CPU利用起来,提高并发数
  • 资源复用:数据库连接、网络通信连接、线程、复杂对象等资源的创建是很耗费资源的,一般采用单例模式或“池”来复用这些资源
  • 注意对runtime的垃圾回收机制的触发,避免full CG
  • 使用合适的数据结构和算法

存储性能优化

  • 使用固态硬盘代替机械硬盘
  • 注意数据库系统采用的是B+树还是LSM树
  • 使用RAID或HDFS技术

高可用技术

网站的可用性指的是网站能被用户正常访问的特性。信息从网站流动到用户的浏览器的过程中,会经过大量环节,其中任何一个环节出现问题,都会导致用户无法正常使用网站。业界常用“N个9”来描述一个网站的可用性,例如,“3个9”表示“在99.9%的时间内可用”。不同的网站,以及同一个网站在不同的发展阶段,以及同一个网站对不同用户群体(如付费用户/免费用户),都有不同的可用性要求。

确保网站高可用,通常就是引入冗余

网站的三层

大型网站,通常会划分三层甚至更多。每一层都会依赖下一层的服务:

  1. 应用层:具体业务逻辑。例如文库、贴吧、知道、百科等产品的业务逻辑代码。产品之间互相独立
  2. 服务层:提供可复用的服务。例如账户服务、Session服务、登陆服务
  3. 数据层:提供数据的读写操作。例如数据库服务、文件服务、缓存服务

确保应用层上的可用性

应用层通过使用服务器集群(即负载均衡服务器 + 至少两台运行着Web程序的服务器)的方式实现冗余。负载均衡设备通过心跳检测机制,时刻监控每台服务器的情况。对于宕机的服务器,将其踢出集群,并将请求分发到其余的可用服务器上,从而保证应用层的可用性。可见负载均衡即提供了高性能,也提供了高可用。

注意,应用层服务器不应当在本机保存业务的上下文信息——也就是要确保应用程序的无状态,这样才能确保请求被分发到任何一个应用层服务器上时都能被同样地处理。

确保服务层上的可用性

在服务层上工作的服务器,同样通过使用集群的方式提供高可用。应用层的程序通过分布式服务调用框架来访问服务层。分布式服务调用框架是应用层程序,它负责通过心跳检测监控服务层上的服务器是否宕机,并进行负载均衡。

一般策略

  • 理由和应用层一样,服务层的程序应当也是无状态的
  • 分级管理:确保某些核心服务享有更高的优先级,例如部署在独立的物理机上(而非核心服务可以部署在虚拟机上以节省成本)
  • 超时设置:在代码中加入对请求超时情况的处理逻辑——重试,或者转向其它服务器
  • 异步调用:见前文“引入消息队列”一节
  • 服务降级:在特殊时刻,或许需要通过自动或手动地关闭一些非核心功能来确保核心功能有足够的资源保障正常运行。另一种方案是随机拒绝部分请求来节省资源
  • 幂等设计:实现了幂等设计,才能确保某功能(为确保调用成功而)被反复调用后数据的正确性

集群中的Session管理

如果使用了集群来提供Session服务,则因客户端的请求会被随机发送到任何一个服务器上,确保“请求总能使用正确的Session”成了一个问题。解决方案如下:

  • Session复制:对于集群规模较小的情况,将Session同步复制到每一个服务器上是简单易行的方法
  • Session绑定(会话粘滞):让负载均衡服务器确保来自同一IP或使用了同一Cookie的请求总会被发送到同一台服务器上。但该方案并不满足高可用性
  • 使用JWT等客户端保存Session信息的机制

确保数据层上的可用性

在数据层上实现冗余以确保可用性,就是意味着数据必须被随时备份,以防止数据丢失。数据在写入数据服务器时,应当被同步复制到多台数据服务器上,这样一来,一台数据服务器的宕机不会影响数据的可用性。

关于是否要确保缓存的高可用,业界存在争议,本文按下不表。

CAP原理

  • C-Consistency:数据一致性,指的是对数据的多份副本,总能保证它们是相同的
  • A-Availibility:数据可用性,指的是应用程序在任何时候都能拿到数据
  • P-PatitionTolerance:分区耐受性,指的是分布式存储系统的伸缩性

CAP原理认为,一个存储系统无法同时满足这三个特性。大型网站一般会确保可用性和分区耐受性得到满足,而对数据一致性做出妥协,包括:

  • 化数据一致性为用户一致:数据在各个副本中可能不完全相同,但通过纠错和校验机制,确保返回给用户的总是正确的数据
  • 化数据一致性为最终一致:数据在各个副本中可能不完全相同,返回给用户的数据也不总是正确的,但存储系统经过一段时间的自我恢复和修正,能确保数据最终一致

数据备份

  • 冷备份:传统的备份方式。指的是定期将数据复制到存储介质中并保管起来,用以未来可能的数据恢复。这种做法简单廉价,缺点是进行数据恢复时难免丢失一些未来得及备份的数据,且在数据恢复的过程中网站不可用
  • 热备份
    • 异步热备:该方式要求将存储服务器划分为主存储服务器(master)和从存储服务器(slave)。应用程序在正常情况下只连接主存储服务器,数据写入时,主存储服务器保存数据,返回成功响应,并将数据同步到从服务器上。该方式是关系型数据库的传统热备方式。实践中常常采用读写分离的机制,即写操作只访问Master,读操作只访问Slave
    • 同步热备:应用程序总是将数据同步写入所有的存储服务器之中

失效转移

当一台数据服务器宕机后,必须有一种机制确保对该服务器的所有读写被转移到其它可用的服务器上,这个过程叫做失效转移,由三部分组成:

  1. 失效确认:利用心跳检测机制或是Web程序访问失败的报告,可以确认某台数据服务器是否宕机
  2. 访问转移:失效确认后,需要通过路由计算,让Web程序连接到等效的服务器上
  3. 数据恢复:某台数据服务器的宕机将使得数据的副本减少。如果副本数量减少到一定值,可能无法再进行访问转移了,因此需要自动地确保副本数量充足

在网站发布时确保可用性

网站发布是一次预定的、可控的服务器宕机,为确保可用性,大型网站的更新发布一般采用自动化脚本按以下流程进行:

  1. 关闭负载均衡服务器上到某一批服务器的路由
  2. 关闭这批服务器的Web程序
  3. 复制新代码到这批服务器上并启动
  4. 打开负载均衡服务器上到这批服务器的路由
  5. 重复以上步骤直到所有服务器的代码得到更新

在正式发布之前,通常会把Web程序发布到测试服务器上进行测试。更大型的网站还会采用灰度发布,即并不在所有服务器上更新代码,而是在几天内分多批更新代码,以确认没有BUG,且利于回滚。

高伸缩技术

伸缩性指的是无需改变网站的软硬件设计,仅仅通过改变部署服务器的数量就可以扩大或缩小网站的服务处理能力。对于不同类型的服务器集群——也就是应用服务器集群、缓存服务器集群与数据服务器集群,增强伸缩性的方式是极为不同的。

应用程序服务器集群的伸缩

协调着整个应用服务器集群的,是负载均衡服务器。负载均衡服务器负责接收用户的请求,但随后进行的工作,可能是以下类型中的一种:

  • 使用HTTP重定向:负载均衡服务器通过返回302重定向响应,告知客户端去请求指定的服务器。该方案几乎没什么人使用
  • 应用层的反向代理:将请求转发给集群中的某台服务器,并把该服务器的响应发给客户端。该方案是最常见的反向代理方案,缺点是反向代理服务器可能会成为性能瓶颈
  • 网络层的反向代理:在网络层的层次上,负载均衡服务器修改收到的请求的IP数据报中的目的IP地址为集群中的某台应用程序服务器。而为了确保该应用程序服务器随后能将响应发回负载均衡服务器,负载均衡服务器还应修改IP数据报中的源IP地址为自己的IP(即源地址转换,SNAT);或是由负载均衡服务器充当一个网关服务器,应用服务器正常返回响应数据报即可。该方案比应用层的反向代理有更高的效率,但可能会使得反向代理服务器的网络带宽成为性能瓶颈
  • 链路层的反向代理:在链路层的层次上,负载均衡服务器修改收到的请求的I以太网帧中的目的MAC地址为集群中的某台应用程序服务器,该应用程序服务器可以直接将响应返回给客户端。这种模式称为三角传输,是大型网站应用最广的方案。开源方案有LVS

负载均衡服务器通过负载均衡算法得出应该将请求转发至哪一个应用程序服务器。算法包括:

  • 轮询:将请求依次、轮流发往每一个应用程序服务器。这种算法适合所有服务器硬件条件相同的情况
  • 加权轮询:如果应用程序服务器们的硬件条件不同,可以通过调整权重,往高性能的服务器分配更多请求
  • 随机:完全随机地发送到各服务器,或者使用加权随机
  • 最少连接:记录每个应用程序服务器正在处理的连接数,将新请求发送到最少连接的服务器上
  • 源地址散列:将请求来源的IP地址进行哈希运算,得到相应的应用程序服务器。这样能实现会话粘滞

分布式缓存服务器集群的伸缩

分布式缓存服务器集群的伸缩性问题在于,新上线的缓存服务器没有任何缓存从而发挥不了作用,而下线的缓存服务器带走了缓存着的热点数据,造成损失。

以Memcached为例的一般机制

Memcached客户端使用路由算法来决定向哪一个Memcached服务器发起请求写入/获得缓存。该路由算法基于简单的余数哈希算法,根据key获得一个哈希值,并将哈希值模Memcached服务器的数目,得到该key值对应的缓存所在的服务器编号。但该算法的问题在于当Memcached服务器的数目增加或减少后,同样的key值将难以映射出与此前相同的服务器编号,导致缓存命中率大幅度下降。解决方案是在网站访问量最少的时候扩容/缩减缓存服务器集群,并通过模拟请求的方法进行缓存预热。

一致性Hash算法

一致性Hash算法使用一个称为一致性Hash环的数据结构来实现key到缓存服务器的映射,以充当一个缓存服务器的路由算法。具体过程为:

  1. 构造一个长度为0~2^32的整数环,将各缓存服务器节点根据节点名称的哈希值(同样在0~2^32之间)放置在环上
  2. 根据需要缓存的数据的key值计算得到其哈希值(同样在0~2^32之间),并在环上从该哈希值处顺时针查找到最近的、有缓存服务器的节点,即完成了key到缓存服务器的映射

当服务器集群需要扩容时,将新节点根据其名称加入到环的相应位置,如此做之后,大部分的key仍然能映射到与此前相同的服务器节点上。但该方案的问题在于新旧节点的负载压力不均衡,解决方案是通过把一个缓存服务器节点虚拟为多个能在环上均匀分布的虚拟节点(经验值为150),就能在概率上稀释新节点加入后造成的负载不平衡问题。

在实现上,一致性Hash环是一个二叉查找树,其最右边的叶子节点和最左边的叶子节点相连从而形成环。Hash查找过程即是在二叉查找树中查找不小于查找数的最小数值。

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

数据存储服务器之所以要集群,一是要通过前文所述的方法增强数据的可用性,二是大型网站的海量数据难以在单机上完整保存,三是分担单台数据服务器的访问压力(这是主要原因)。

关系型数据库集群的伸缩

对大型网站,数据库的单张表大到单机无法保存,此时会对表进行分片,即把一张表拆开,分别存储到多个数据库中。不同业务的数据表还会部署在不同的数据库集群上,这称为数据分库

进行以上工作后,应用程序再想使用数据库,通常就需要使用一个数据库访问代理来屏蔽如此复杂的细节了。以开源的分布式关系数据库访问代理Cobar为例。Cobar可独立部署在服务器上为应用程序提供服务,应用程序通过一般的SQL语句向Cobar服务器发起查询,而Cobar服务器则解析SQL语句,根据相应规则分解出多条SQL语句,并根据路由算法向SQL数据库服务器集群中的多台特定数据库发起SQL查询,汇总结果后返回给应用程序。

Cobar还能帮助SQL数据库服务器集群进行扩容,例如自动分拆各数据库中的数据到一个新的数据库中。Cobar服务器也可以形成集群,在这方面和应用程序服务器集群的伸缩问题是一样的,不再赘述。

分布式关系数据库访问代理的功能一般很有限,例如无法使用事务或者表连接等功能,就算支持这些功能,也是以牺牲性能为代价的。

NoSQL数据库集群的伸缩

NoSQL数据库在可伸缩性和高可用性上弥补了传统关系型数据库的不足。开源的、可伸缩性优良的NoSQL系统有HBase等。

高可扩展技术

网站的扩展性指的是为网站添加新功能(而不需要对现有系统的结构和代码进行修改)时的便利程度,这也是一般软件工程的低耦合要求。

分布式消息队列

消息队列机制是事件驱动架构的最常见实现。各新旧模块间的通信一概通过消息队列来进行,这使得各新旧模块之间没有耦合,可扩展性强。

引入专门的消息队列服务器,它维护着消息队列及其管理程序。任何的消息生产者通过一个远程访问接口将消息推送给消息队列服务器,消息队列服务器将消息推入本地内存队列。消息队列服务器还根据消息订阅列表,查找对队首的消息感兴趣的消费者,将该消息通过远程访问接口发送给它。

消息队列服务器在内存队列已满时,可以将消息写入磁盘;消息队列服务器可以形成集群,该集群的伸缩性不是问题,只需要通知生产者服务器更新一个消息队列服务器列表即可。为避免消息服务器宕机造成消息丢失,消息的生产者应当保留自己发送的消息,直到确认自己的消息被消费后才删除。

分布式服务

分布式服务指的是将网站拆分成一个个独立的模块,它们之间通过接口互相调用。早期,Web Service技术曾是分布式服务领域中的热门词汇,但如今已被分布式服务框架替代。分布式服务框架提供一揽子的功能:

  • 提供高效的模块间通信手段
  • 充当各(采用了不同技术与平台的)模块间的适配器
  • 监控、统计各模块的工作情况
  • 对各模块进行版本管理
  • 负载均衡与失效转移
  • ...

分布式服务框架不应当对各模块的代码造成过多侵入。

数据的可扩展性

对于关系型数据库,每个表的关系的设计是难以变更的,这使得数据库设计者常常为表添加一些冗余字段以留作扩展的余地。非关系型数据库在这方面有独特的优势。

高安全性技术

用户账户信息保存

用户注册时输入的密码不应直接保存到数据库,而是对它进行单向散列加密(MD5、SHA算法等),将密文存入数据库;用户登录时,将提供的密码进行同样的三列计算,并和数据库中的密文比较,如果一致,密码验证成功。

为加强单向散列计算的安全性,还会给散列算法加点盐——即密钥,以防止在被拖库后,不法之徒使用彩虹表(一种记载了常用密码和对应密文的映射的表)进行猜测式破解

妥善保存密钥

为安全起见,密钥和安全算法可以放在独立服务器上,封装出一个加密/解密模块供应用程序调用。但这么做会造成性能问题。另一种方案是,将加密算法放在应用系统中,密钥则放在独立服务器中。密钥可以被切分成多个部分,分别保管在不同地方以进一步增强安全性。

信息过滤

常见的敏感词检测算法基本都是Tire算法及其变体,更简单的实现有“多级Hash表”,再简单一些的可以使用正则匹配。

对于黑名单功能,屏蔽列表很长的情况下可以使用布隆过滤器,但要注意它是一种概率算法。

附录:网站架构发展图