03 高性能架构模式

大部分情况下,做架构设计主要都是基于已有的成熟模式,结合业务和团队的具体情况,进行一定的优化或者调整;即使少部分情况需要进行较大的创新,前提也是需要对已有的各种架构模式和技术非常熟悉。

0.1. 高性能数据库集群

虽然近十年来各种存储技术飞速发展,但关系数据库由于其 ACID 的特性和功能强大的 SQL 查询,目前还是各种业务系统中关键和核心的存储系统,很多场景下高性能的设计最核心的部分就是关系数据库的设计。

海量用户加上海量数据的特点,单个数据库服务器已经难以满足业务需要,必须考虑数据库集群的方式来提升性能。

高性能数据库集群:

  • 第一种方式是“读写分离”,其本质是将访问压力分散到集群中的多个节点,但是没有分散存储压力;
  • 第二种方式是“分库分表”,既可以分散访问压力,又可以分散存储压力

0.1.1. 读写分离

0.1.1.1. 原理

读写分离的基本原理是将数据库读写操作分散到不同的节点上,下面是其基本架构图。

image

读写分离的基本实现是:

  1. 数据库服务器搭建主从集群,一主一从、一主多从都可以。
  2. 数据库主机负责读写操作,从机只负责读操作。
  3. 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了所有的业务数据。
  4. 业务服务器将写操作发给数据库主机,将读操作发给数据库从机。

读写分离的实现逻辑并不复杂,但有两个细节点将引入设计复杂度:主从复制延迟分配机制

0.1.1.2. 复制延迟

以 MySQL 为例,主从复制延迟可能达到 1 秒,如果有大量数据同步,延迟 1 分钟也是有可能的。

主从复制延迟会带来一个问题:如果业务服务器将数据写入到数据库主服务器后立刻(1 秒内)进行读取,此时读操作访问的是从机,主机还没有将数据复制过来,到从机读取数据是读不到最新数据的,业务上就可能出现问题。

解决主从复制延迟有几种常见的方法:

  1. 写操作后的读操作指定发给数据库主服务器
  2. 读从机失败后再读一次主机
  3. 关键业务读写操作全部指向主机,非关键业务采用读写分离

0.1.1.3. 分配机制

将读写操作区分开来,然后访问不同的数据库服务器,一般有两种方式:程序代码封装中间件封装

程序代码封装指在代码中抽象一个数据访问层(也称为“中间层封装”),实现读写操作分离和数据库服务器连接的管理。例如,基于 Hibernate 进行简单封装,就可以实现读写分离,基本架构是:

image

程序代码封装的方式具备几个特点:

  1. 实现简单,可以根据业务做较多定制化的功能。
  2. 每个编程语言都需要自己实现一次,无法通用。
  3. 故障情况下,如果主从发生切换,则可能需要所有系统都修改配置并重启。

中间件封装指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供 SQL 兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器。其基本架构是:

image

数据库中间件的方式具备的特点是:

  1. 能够支持多种编程语言,对业务服务器提供标准 SQL 接口。
  2. 数据库中间件要支持完整的 SQL 语法和数据库服务器的协议。
  3. 数据库中间件自己不执行真正的读写操作,但所有的数据库操作请求都要经过中间件,中间件的性能要求很高。
  4. 数据库主从切换对业务服务器无感知,数据库中间件可以探测数据库服务器的主从状态(向某个测试表写入一条数据,成功的就是主机,失败的就是从机)。

目前开源数据库中间件方案:

  • MySQL Proxy没有GA
  • MySQL Router
  • 奇虎360 Atlas,基于MySQL Proxy实现

0.1.2. 分库分表

读写分离分散了数据库读写操作的压力,但没有分散存储压力,当数据量达到千万甚至上亿条的时候,单台数据库服务器的存储能力会成为系统的瓶颈,主要体现在这几个方面:

  • 数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。
  • 数据文件会变得很大,数据库备份和恢复需要耗费很长时间。
  • 数据文件越大,极端情况下丢失数据的风险越高。

基于上述原因,单个数据库服务器存储的数据量不能太大,需要控制在一定的范围内。为了满足业务数据存储的需求,就需要将存储分散到多台数据库服务器上。

0.1.2.1. 业务分库

业务分库指的是按照业务模块将数据分散到不同的数据库服务器。

image

虽然业务分库能够分散存储和访问压力,但同时也带来了新的问题:

  1. 原本在同一个数据库中的表分散到不同数据库中,导致无法使用 SQL 的 join 查询。
  2. 原本在同一个数据库中不同的表可以在同一个事务中修改,表分散到不同的数据库中,无法通过事务统一修改。
  3. 成本的代价,本来 1 台服务器搞定的事情,现在要 3 台,如果考虑备份,那就是 2 台变成了 6 台。

0.1.2.2. 分表

将不同业务数据分散存储到不同的数据库服务器,能够支撑百万甚至千万用户规模的业务,但如果业务继续发展,同一业务的单表数据也会达到单台数据库服务器的处理瓶颈。

单表数据拆分有两种方式:垂直分表和水平分表。示意图如下:

image

实际架构设计过程中并不局限切分的次数。单表进行切分后,是否要将切分后的多个表分散在不同的数据库服务器中,可以根据实际的切分效果来确定,并不强制要求单表切分为多表后一定要分散到不同数据库中。原因在于单表切分为多表后,新的表即使在同一个数据库服务器中,也可能带来可观的性能提升。

分表能够有效地分散存储压力且带来性能提升,但是也会引入复杂性:

  1. 垂直分表:适合将表中某些不常用且占了大量空间的列拆分出;垂直分表引入的复杂性主要体现在表操作的数量要增加。
  2. 水平分表:适合表行数特别大的表,单表行数超过 5000 万这个数字可以作为参考,关键看表的访问性能。当表的数据量达到千万级别时,就要警觉起来,这很可能是架构的性能瓶颈或者隐患。

水平分表引入更多的复杂性,主要表现在下面几个方面:

  • 路由:某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算,常见算法:
    • 范围路由:选取有序的数据列(例如,整形、时间戳等)作为路由的条件,不同分段分散到不同的数据库表中。范围路由设计的复杂点主要体现在分段大小的选取上,太小则子表多维护负责,太大则单表依然存在性能问题,一般建议分段大小在 100 万至 2000 万之间。范围路由的优点是可以随着数据的增加平滑地扩充新的表。范围路由的一个比较隐含的缺点是分布不均匀
    • Hash路由:选取某个列(或者某几个列组合)的值进行 Hash 运算,然后根据 Hash 结果分散到不同的数据库表中。Hash 路由设计的复杂点主要体现在初始表数量的选取上,太多则子表多维护负责,太少则单表依然存在性能问题,Hash 路由的优点是表分布比较均匀,缺点是扩充新的表很麻烦,所有数据都要重分布。
    • 配置路由:用一张独立的表来记录路由信息,设计简单,使用灵活,扩充表时,只需要迁移指定的数据,然后修改路由表就可以了。配置路由的缺点就是必须多查询一次,会影响整体性能;而且路由表本身如果太大,性能同样可能成为瓶颈。
  • join操作:数据分散在多个表中,如果需要与其他表进行 join 查询,需要在业务代码或者数据库中间件中进行多次 join 查询,然后将结果合并。
  • count()操作:物理上数据分散到多个表中,但某些业务逻辑上还是将这些表当作一个表来处理。常用的处理方式:
    • count()相加:在业务代码或者数据库中间件中对每个表进行 count() 操作,然后将结果相加。实现简单但是性能低下。
    • 记录数表:新建一张表,如表名为“记录数表”,包含 table_namerow_count 两个字段,每次插入或者删除子表数据成功后,都更新“记录数表”。性能大大优于上一个方法,但是复杂度增加,要操作多个表,且不能在一个事物中完成处理,最终导致数据不一致,记录数据表也会增加写的压力。
    • 定时更新记录数表:count()相加与记录数表的结合,定时通过 count() 相加计算表的记录数,然后更新记录数表中的数据。
  • order by操作:数据分散到多个子表中,排序操作无法在数据库中完成,只能由业务代码或者数据库中间件分别查询每个子表中的数据,然后汇总进行排序。

0.1.2.3. 实现方法

分库分表具体的实现方式也是“程序代码封装”和“中间件封装”,但实现会更复杂。

  • 读写分离实现时只要识别 SQL 操作是读操作还是写操作,通过简单的判断 SELECT、UPDATE、INSERT、DELETE 几个关键字就可以做到
  • 分库分表的实现除了要判断操作类型外,还要判断 SQL 中具体需要操作的表、操作函数(例如 count 函数)、order by、group by 操作等,然后再根据不同的操作进行不同的处理。

0.2. 高性能NoSQL

关系数据库非常成熟,强大的 SQL 功能和 ACID 的属性,但关系数据库存在如下缺点:

  1. 关系数据库存储行记录,无法存储数据结构
  2. 关系数据库的schema扩展不方便:因为表结构 schema 是强约束,操作不存在的列会报错,业务变化时扩充列也比较麻烦,需要执行 DDL语句修改,可能会长时间锁表。
  3. 关系数据库在大数据场景下I/O较高:因为即使只针对其中某一列进行运算,也会将整行数据从存储设备读入内存。
  4. 关系数据库的全文搜索功能比较弱:只能使用like全表扫描

针对上述问题,诞生了不同的 NoSQL 解决方案,它们在某些应用场景下比关系数据库表现好。

但 NoSQL 方案带来的优势,本质上是牺牲 ACID 中的某个或者某几个特性,因此 NoSQL 也不是银弹,而是 SQL 的补充NoSQL != No SQL,而是 NoSQL = Not Only SQL

常见的 NoSQL 方案分为 4 类:

  1. K-V存储:解决关系数据库无法存储数据结构的问题,以 Redis 为代表。
  2. 文档数据库:解决关系数据库强 schema 约束的问题,以 MongoDB 为代表。
  3. 列式数据库:解决关系数据库大数据场景下的 I/O 问题,以 HBase 为代表。
  4. 全文搜索引擎:解决关系数据库的全文搜索性能问题,以 Elasticsearch 为代表。

0.2.1. K-V存储

Key-Value 存储,其中 Key 是数据的标识,和关系数据库中的主键含义一样,Value 就是具体的数据。

Redis 是 K-V 存储的典型代表,一款开源的高性能 K-V 缓存和存储系统。Redis 的 Value 是具体的数据结构,包括 string、hash、list、set、sorted set、bitmap 和 hyperloglog,常被称为数据结构服务器。Redis 的缺点不支持完整的 ACID 事务,Redis 虽然提供事务功能,但 Redis 的事务和关系数据库的事务不可同日而语,Redis 的事务只能保证隔离性和一致性(I 和 C),无法保证原子性和持久性(A 和 D)。

在设计方案时,需要根据业务特性和要求来确定是否可以用 Redis,而不能因为 Redis 不遵循 ACID 原则就直接放弃

0.2.2. 文档数据库

文档数据库最大的特点就是 no-schema,可以存储和读取任意的数据。

目前绝大部分文档数据库存储的数据格式是 JSON(或者 BSON),因为 JSON 数据是自描述的,无须在使用前定义字段,读取一个 JSON 中不存在的字段也不会导致 SQL 那样的语法错误。

文档数据库no-schema特性带来的优势:

  1. 新增字段简单:业务上新增字段,程序代码直接读写即可
  2. 历史数据不会出错:历史数据无新增字段直接返回空值,代码进行兼容处理即可
  3. 可以很容易存储复杂数据:JSON能够描述复杂的数据结构

文档数据库的这个特点,特别适合电商和游戏这类的业务场景。因为不同商品的属性差别很大,即使同类商品也有不同的属性。

文档数据库 no-schema 的特性带来的这些优势也是有代价的,最主要的代价就是不支持事务。另外一个缺点就是无法实现关系数据库的 join 操作

在设计方案时,某些对事务要求严格的业务场景是不能使用文档数据库的

0.2.3. 列式数据库

列式数据库就是按照来存储数据的数据库,与之对应的传统关系数据库被称为“行式数据库”,因为关系数据库是按照来存储数据的。

行式存储的优势:

  • 业务同时读取多个列时效率高,因为这些列都是按行存储在一起的,一次磁盘操作就能够把一行数据中的各个列都读取到内存中。
  • 能够一次性完成对一行中的多个列的写操作,保证了针对行数据写操作的原子性和一致性;否则如果采用列存储,可能会出现某次写操作,有的列成功了,有的列失败了,导致数据不一致。

行式存储的优势是在特定的业务场景下才能体现,如果不存在这样的业务场景,那么行式存储的优势也将不复存在,甚至成为劣势,典型的场景就是海量数据进行统计。

列式存储的优势:

  • 节省I/O,只需要某一列时只读取该列
  • 更高的存储压缩比,能够节省更多的存储空间(行式存储3:1到5:1;列式存储8:1到30:1),因为单列的数据相似度比行来说更高,能够达到更高的压缩率。

需要频繁地更新多个列的场景下,列式存储的优势就变成了劣势,因为列式存储将不同列存储在磁盘上不连续的空间,导致更新多个列时磁盘是随机写操作;而行式存储时同一行多个列都存储在连续的空间,一次磁盘写操作就可以完成,列式存储的随机写效率要远远低于行式存储的写效率。列式存储高压缩率在更新场景下也会成为劣势,因为更新时需要将存储数据解压后更新,然后再压缩,最后写入磁盘。

在设计方案时,一般将列式存储应用在离线的大数据分析和统计场景中,这种场景主要针对部分列、单列进行操作,且数据写入后无需更新或删除

0.2.4. 全文搜索引擎

关系数据库通过索引实现快速查询,但在全文搜索场景下,索引无能为力,主要体现在:

  • 全文搜索的条件随意排列组合,如果通过索引来满足,则索引的数量会非常多。
  • 全文搜索的模糊匹配方式,索引无法满足,只能用 like 查询,而 like 查询是整表扫描,效率非常低。

0.2.4.1. 全文搜索基本原理

全文搜索引擎的技术原理被称为“倒排索引”(Inverted index),是一种索引方法,其基本原理是建立单词到文档的索引

被称为“倒排”索引,是和“正排“索引相对的,“正排索引”的基本原理是建立文档到单词的索引

正排索引:

文章ID文章名称文章内容
1敏捷架构设计原则架构、设计、架构师
2Java编程必知比会Java、编程、面向对象、类、架构、设计
3面向对象葵花宝典是什么设计、模式、对象、类、Java

正排索引适用于根据文档名称来查询文档内容。(注:文章内容仅为示范,文章内容实际上存储的是几千字的内容。)

倒排索引:

单词|文档ID列表| 架构|1,2 设计|1,2,3 Java|2,3

倒排索引适用于根据关键词来查询文档内容。(注:表格仅为示范,不是完整的倒排索引表格,实际上的倒排索引有成千上万行,因为每个单词就是一个索引。)

0.2.4.2. 全文搜索使用方式

全文搜索引擎的索引对象是单词文档,而关系数据库的索引对象是

为了让全文搜索引擎支持关系型数据的全文搜索,需要将关系型数据转换为文档数据。目前常用的转换方式是将关系型数据按照对象的形式转换为 JSON 文档,然后将 JSON 文档输入全文搜索引擎进行索引。

全文搜索引擎能够基于 JSON 文档建立全文索引,然后快速进行全文搜索。

Elastcisearch 是分布式的文档存储方式。它能存储和检索复杂的数据结构——序列化成为 JSON 文档——以实时的方式。在 Elasticsearch 中,每个字段的所有数据都是默认被索引的。即每个字段都有为了快速检索设置的专用倒排索引。而且,不像其他多数的数据库,它能在相同的查询中使用所有倒排索引,并以惊人的速度返回结果。

0.2.5. 技术选型

关系型和NoSQL数据库的选型。考虑如下指标:

  • 数据量
  • 并发量
  • 实时性
  • 一致性要求
  • 读写分布和类型
  • 安全运维性等

根据这些指标,软件系统可分成如下类别:

  1. 管理型系统,如运营类系统,首选关系型。
  2. 大流量系统,如电商单品页的某个服务,后台选关系型,前台选内存型。
  3. 日志型系统,原始数据选列式,日志搜索选倒排索引。
  4. 搜索型系统,指站内搜索,非通用搜索,如商品搜索,后台选关系型,前台选倒排索引。
  5. 事务型系统,如库存、交易、记账,选关系型+缓存+一致性协议,或新型关系数据库。
  6. 离线计算,如大量数据分析,首选列式,关系型也可以。
  7. 实时计算,如实时监控,可以选时序数据库,或列式数据库。

0.3. 高性能缓存架构

通过各种手段来提升存储系统的性能,但在某些复杂的业务场景下,单纯依靠存储系统的性能提升不够的,典型的场景有:

  • 需要经过复杂运算后得出的数据,存储系统无能为力
  • 读多写少的数据,存储系统有心无力

缓存就是为了弥补存储系统在这些复杂业务场景下的不足,其基本原理是将可能重复使用的数据放到内存中,一次生成、多次使用,避免每次使用都去访问存储系统

缓存能够带来性能的大幅提升,以 Memcache 为例,单台 Memcache 服务器简单的 key-value 查询能够达到 TPS 50000 以上。

缓存虽然能够大大减轻存储系统的压力,但同时也给架构引入了更多复杂性。架构设计时如果没有针对缓存的复杂性进行处理,某些场景下甚至会导致整个系统崩溃。

0.3.1. 缓存穿透

缓存穿透是指缓存没有发挥作用,业务系统虽然去缓存查询数据,但缓存中没有数据,业务系统需要再次去存储系统查询数据。通常情况下有两种情况:

  1. 存储数据不存在:当数据确实不存在时,每次都要先查缓存再查存储,通常,业务上读取不存在的数据的请求量并不会太大,但出现异常攻击会拖跨存储系统,解决方案是如果数据确实不存在,在缓存中设置一个默认值,这样后续查询都读取默认值。
  2. 缓存数据生成耗费大量时间或者资源:数据在存储系统中存在,但是生成换成数据需要消耗较长时间或者消耗大量资源。如果在业务访问时缓存失效,那么访问压力都集中在存储系统上。

    具体的场景有:

    1. 分页缓存的有效期设置为 1 天,因为设置太长时间的话,缓存不能反应真实的数据。
    2. 通常情况下,用户不会从第 1 页到最后 1 页全部看完,一般用户访问集中在前 10 页,因此第 10 页以后的缓存过期失效的可能性很大。
    3. 竞争对手每周来爬取数据,爬虫会将所有分类的所有数据全部遍历,从第 1 页到最后 1 页全部都会读取,此时很多分页缓存可能都失效了。
    4. 由于很多分页都没有缓存数据,从数据库中生成缓存数据又非常耗费性能(order by limit 操作),因此爬虫会将整个数据库全部拖慢。

    通常的应对方案:

    1. 识别爬虫然后禁止访问,但这可能会影响 SEO 和推广;
    2. 做好监控,发现问题后及时处理,爬虫不是攻击,不会进行暴力破坏,对系统的影响是逐步的,监控发现问题后有时间进行处理。

0.3.2. 缓存雪崩

缓存雪崩是指当缓存失效(过期)后引起系统性能急剧下降的情况。

  1. 当缓存过期被清除后,业务系统需要重新生成缓存,
  2. 因此需要再次访问存储系统,再次进行运算,这个处理步骤耗时几十毫秒甚至上百毫秒。
  3. 而对于一个高并发的业务系统来说,几百毫秒内可能会接到几百上千个请求。
  4. 由于旧的缓存已经被清除,新的缓存还未生成,并且处理这些请求的线程都不知道另外有一个线程正在生成缓存,因此所有的请求都会去重新生成缓存,都会去访问存储系统,从而对存储系统造成巨大的性能压力。
  5. 这些压力又会拖慢整个系统,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。

缓存雪崩的常见解决方法有两种:更新锁机制后台更新机制

0.3.2.1. 更新锁

对缓存更新操作进行加锁保护,保证只有一个线程能够进行缓存更新,未能获取更新锁的线程要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

对于采用分布式集群的业务系统,由于存在几十上百台服务器,即使单台服务器只有一个线程更新缓存,但几十上百台服务器一起算下来也会有几十上百个线程同时来更新缓存,同样存在雪崩的问题。因此分布式集群的业务系统要实现更新锁机制,需要用到分布式锁,如 ZooKeeper。

0.3.2.2. 后台更新

由后台线程来更新缓存,而不是由业务线程来更新缓存,缓存本身的有效期设置为永久,后台线程定时更新缓存

后台定时机制需要考虑一种特殊的次使用mysql的这个缓冲来做查询使用的话场景,当缓存系统内存不够时,会“踢掉”一些缓存数据,从缓存被“踢掉”到下一次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程本身又不会去更新缓存,因此业务上看到的现象就是数据丢了。

解决的方式有两种:

  1. 后台线程除了定时更新缓存,还要频繁地去读取缓存(例如,1 秒或者 100 毫秒读取一次),如果发现缓存被“踢了”就立刻更新缓存,这种方式实现简单,但读取时间间隔不能设置太长,因为如果缓存被踢了,缓存读取间隔时间又太长,这段时间内业务访问都拿不到真正的数据而是一个空的缓存值,用户体验一般
  2. 业务线程发现缓存失效后,通过消息队列发送一条消息通知后台线程更新缓存。可能会出现多个业务线程都发送了缓存更新消息,但其实对后台线程没有影响,后台线程收到消息后更新缓存前可以判断缓存是否存在,存在就不执行更新操作。这种方式实现依赖消息队列,复杂度会高一些,但缓存更新更及时,用户体验更好

适用场景:

相比更新锁机制要简单一些。

  • 单机多线程的场景
  • 分布式集群的场景
  • 业务刚上线的时候进行缓存预热

缓存预热指系统上线后,将相关的缓存数据直接加载到缓存系统,而不是等待用户访问才来触发缓存加载。

0.3.3. 缓存热点

虽然缓存系统本身的性能比较高,但对于一些特别热点的数据,如果大部分甚至所有的业务请求都命中同一份缓存数据,则这份数据所在的缓存服务器的压力也很大

缓存热点的解决方案就是复制多份缓存副本,将请求分散到多个缓存服务器上,减轻缓存热点导致的单台缓存服务器压力。缓存的数据是一样的,通过在缓存的 key 里面加上编号进行区分,每次读缓存时都随机读取其中某份缓存。

缓存副本设计有一个细节需要注意,不同的缓存副本不要设置统一的过期时间,否则就会出现所有缓存副本同时生成同时失效的情况,从而引发缓存雪崩效应。正确的做法是设定一个过期时间范围,不同的缓存副本的过期时间是指定范围内的随机值。

0.3.4. 缓存方案实现

由于缓存的各种访问策略和存储的访问策略是相关的,因此上面的各种缓存设计方案通常情况下都是集成在存储访问方案中,可以采用“程序代码实现”的中间层方式或者独立的中间件来实现。

上次修改: 2 June 2020