大部分情况下,做架构设计主要都是基于已有的成熟模式,结合业务和团队的具体情况,进行一定的优化或者调整;即使少部分情况需要进行较大的创新,前提也是需要对已有的各种架构模式和技术非常熟悉。
虽然近十年来各种存储技术飞速发展,但关系数据库由于其
ACID
的特性和功能强大的SQL
查询,目前还是各种业务系统中关键和核心的存储系统,很多场景下高性能的设计最核心的部分就是关系数据库的设计。
海量用户加上海量数据的特点,单个数据库服务器已经难以满足业务需要,必须考虑数据库集群的方式来提升性能。
高性能数据库集群:
读写分离的基本原理是将数据库读写操作分散到不同的节点上,下面是其基本架构图。
读写分离的基本实现是:
读写分离的实现逻辑并不复杂,但有两个细节点将引入设计复杂度:主从复制延迟和分配机制。
以 MySQL 为例,主从复制延迟可能达到 1 秒,如果有大量数据同步,延迟 1 分钟也是有可能的。
主从复制延迟会带来一个问题:如果业务服务器将数据写入到数据库主服务器后立刻(1 秒内)进行读取,此时读操作访问的是从机,主机还没有将数据复制过来,到从机读取数据是读不到最新数据的,业务上就可能出现问题。
解决主从复制延迟有几种常见的方法:
将读写操作区分开来,然后访问不同的数据库服务器,一般有两种方式:程序代码封装和中间件封装。
程序代码封装指在代码中抽象一个数据访问层(也称为“中间层封装”),实现读写操作分离和数据库服务器连接的管理。例如,基于 Hibernate 进行简单封装,就可以实现读写分离,基本架构是:
程序代码封装的方式具备几个特点:
中间件封装指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供 SQL 兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器。其基本架构是:
数据库中间件的方式具备的特点是:
目前开源数据库中间件方案:
读写分离分散了数据库读写操作的压力,但没有分散存储压力,当数据量达到千万甚至上亿条的时候,单台数据库服务器的存储能力会成为系统的瓶颈,主要体现在这几个方面:
基于上述原因,单个数据库服务器存储的数据量不能太大,需要控制在一定的范围内。为了满足业务数据存储的需求,就需要将存储分散到多台数据库服务器上。
业务分库指的是按照业务模块将数据分散到不同的数据库服务器。
虽然业务分库能够分散存储和访问压力,但同时也带来了新的问题:
将不同业务数据分散存储到不同的数据库服务器,能够支撑百万甚至千万用户规模的业务,但如果业务继续发展,同一业务的单表数据也会达到单台数据库服务器的处理瓶颈。
单表数据拆分有两种方式:垂直分表和水平分表。示意图如下:
实际架构设计过程中并不局限切分的次数。单表进行切分后,是否要将切分后的多个表分散在不同的数据库服务器中,可以根据实际的切分效果来确定,并不强制要求单表切分为多表后一定要分散到不同数据库中。原因在于单表切分为多表后,新的表即使在同一个数据库服务器中,也可能带来可观的性能提升。
分表能够有效地分散存储压力且带来性能提升,但是也会引入复杂性:
水平分表引入更多的复杂性,主要表现在下面几个方面:
count()
操作:物理上数据分散到多个表中,但某些业务逻辑上还是将这些表当作一个表来处理。常用的处理方式:count()
相加:在业务代码或者数据库中间件中对每个表进行 count()
操作,然后将结果相加。实现简单但是性能低下。table_name
、row_count
两个字段,每次插入或者删除子表数据成功后,都更新“记录数表”。性能大大优于上一个方法,但是复杂度增加,要操作多个表,且不能在一个事物中完成处理,最终导致数据不一致,记录数据表也会增加写的压力。count()
相加与记录数表的结合,定时通过 count()
相加计算表的记录数,然后更新记录数表中的数据。分库分表具体的实现方式也是“程序代码封装”和“中间件封装”,但实现会更复杂。
关系数据库非常成熟,强大的 SQL
功能和 ACID
的属性,但关系数据库存在如下缺点:
schema
扩展不方便:因为表结构 schema
是强约束,操作不存在的列会报错,业务变化时扩充列也比较麻烦,需要执行 DDL语句修改,可能会长时间锁表。I/O
较高:因为即使只针对其中某一列进行运算,也会将整行数据从存储设备读入内存。like
全表扫描针对上述问题,诞生了不同的 NoSQL 解决方案,它们在某些应用场景下比关系数据库表现好。
但 NoSQL 方案带来的优势,本质上是牺牲 ACID 中的某个或者某几个特性,因此 NoSQL 也不是银弹,而是 SQL 的补充,NoSQL != No SQL
,而是 NoSQL = Not Only SQL
。
常见的 NoSQL 方案分为 4 类:
schema
约束的问题,以 MongoDB 为代表。I/O
问题,以 HBase 为代表。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 原则就直接放弃。
文档数据库最大的特点就是 no-schema
,可以存储和读取任意的数据。
目前绝大部分文档数据库存储的数据格式是 JSON(或者 BSON),因为 JSON 数据是自描述的,无须在使用前定义字段,读取一个 JSON 中不存在的字段也不会导致 SQL 那样的语法错误。
文档数据库no-schema
特性带来的优势:
文档数据库的这个特点,特别适合电商和游戏这类的业务场景。因为不同商品的属性差别很大,即使同类商品也有不同的属性。
文档数据库 no-schema
的特性带来的这些优势也是有代价的,最主要的代价就是不支持事务。另外一个缺点就是无法实现关系数据库的 join 操作。
在设计方案时,某些对事务要求严格的业务场景是不能使用文档数据库的。
列式数据库就是按照列来存储数据的数据库,与之对应的传统关系数据库被称为“行式数据库”,因为关系数据库是按照行来存储数据的。
行式存储的优势:
行式存储的优势是在特定的业务场景下才能体现,如果不存在这样的业务场景,那么行式存储的优势也将不复存在,甚至成为劣势,典型的场景就是海量数据进行统计。
列式存储的优势:
I/O
,只需要某一列时只读取该列需要频繁地更新多个列的场景下,列式存储的优势就变成了劣势,因为列式存储将不同列存储在磁盘上不连续的空间,导致更新多个列时磁盘是随机写操作;而行式存储时同一行多个列都存储在连续的空间,一次磁盘写操作就可以完成,列式存储的随机写效率要远远低于行式存储的写效率。列式存储高压缩率在更新场景下也会成为劣势,因为更新时需要将存储数据解压后更新,然后再压缩,最后写入磁盘。
在设计方案时,一般将列式存储应用在离线的大数据分析和统计场景中,这种场景主要针对部分列、单列进行操作,且数据写入后无需更新或删除。
关系数据库通过索引实现快速查询,但在全文搜索场景下,索引无能为力,主要体现在:
like
查询,而 like
查询是整表扫描,效率非常低。全文搜索引擎的技术原理被称为“倒排索引”(Inverted index),是一种索引方法,其基本原理是建立单词到文档的索引。
被称为“倒排”索引,是和“正排“索引相对的,“正排索引”的基本原理是建立文档到单词的索引。
正排索引:
文章ID | 文章名称 | 文章内容 |
---|---|---|
1 | 敏捷架构设计原则 | 架构、设计、架构师 |
2 | Java编程必知比会 | Java、编程、面向对象、类、架构、设计 |
3 | 面向对象葵花宝典是什么 | 设计、模式、对象、类、Java |
正排索引适用于根据文档名称来查询文档内容。(注:文章内容仅为示范,文章内容实际上存储的是几千字的内容。)
倒排索引:
单词|文档ID列表| 架构|1,2 设计|1,2,3 Java|2,3
倒排索引适用于根据关键词来查询文档内容。(注:表格仅为示范,不是完整的倒排索引表格,实际上的倒排索引有成千上万行,因为每个单词就是一个索引。)
全文搜索引擎的索引对象是单词和文档,而关系数据库的索引对象是键和行。
为了让全文搜索引擎支持关系型数据的全文搜索,需要将关系型数据转换为文档数据。目前常用的转换方式是将关系型数据按照对象的形式转换为 JSON 文档,然后将 JSON 文档输入全文搜索引擎进行索引。
全文搜索引擎能够基于 JSON 文档建立全文索引,然后快速进行全文搜索。
Elastcisearch 是分布式的文档存储方式。它能存储和检索复杂的数据结构——序列化成为 JSON 文档——以实时的方式。在 Elasticsearch 中,每个字段的所有数据都是默认被索引的。即每个字段都有为了快速检索设置的专用倒排索引。而且,不像其他多数的数据库,它能在相同的查询中使用所有倒排索引,并以惊人的速度返回结果。
关系型和NoSQL数据库的选型。考虑如下指标:
根据这些指标,软件系统可分成如下类别:
通过各种手段来提升存储系统的性能,但在某些复杂的业务场景下,单纯依靠存储系统的性能提升不够的,典型的场景有:
缓存就是为了弥补存储系统在这些复杂业务场景下的不足,其基本原理是将可能重复使用的数据放到内存中,一次生成、多次使用,避免每次使用都去访问存储系统。
缓存能够带来性能的大幅提升,以 Memcache 为例,单台 Memcache 服务器简单的 key-value 查询能够达到 TPS 50000 以上。
缓存虽然能够大大减轻存储系统的压力,但同时也给架构引入了更多复杂性。架构设计时如果没有针对缓存的复杂性进行处理,某些场景下甚至会导致整个系统崩溃。
缓存穿透是指缓存没有发挥作用,业务系统虽然去缓存查询数据,但缓存中没有数据,业务系统需要再次去存储系统查询数据。通常情况下有两种情况:
缓存数据生成耗费大量时间或者资源:数据在存储系统中存在,但是生成换成数据需要消耗较长时间或者消耗大量资源。如果在业务访问时缓存失效,那么访问压力都集中在存储系统上。
具体的场景有:
- 分页缓存的有效期设置为 1 天,因为设置太长时间的话,缓存不能反应真实的数据。
- 通常情况下,用户不会从第 1 页到最后 1 页全部看完,一般用户访问集中在前 10 页,因此第 10 页以后的缓存过期失效的可能性很大。
- 竞争对手每周来爬取数据,爬虫会将所有分类的所有数据全部遍历,从第 1 页到最后 1 页全部都会读取,此时很多分页缓存可能都失效了。
- 由于很多分页都没有缓存数据,从数据库中生成缓存数据又非常耗费性能(order by limit 操作),因此爬虫会将整个数据库全部拖慢。
通常的应对方案:
缓存雪崩是指当缓存失效(过期)后引起系统性能急剧下降的情况。
缓存雪崩的常见解决方法有两种:更新锁机制和后台更新机制。
对缓存更新操作进行加锁保护,保证只有一个线程能够进行缓存更新,未能获取更新锁的线程要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
对于采用分布式集群的业务系统,由于存在几十上百台服务器,即使单台服务器只有一个线程更新缓存,但几十上百台服务器一起算下来也会有几十上百个线程同时来更新缓存,同样存在雪崩的问题。因此分布式集群的业务系统要实现更新锁机制,需要用到分布式锁,如 ZooKeeper。
由后台线程来更新缓存,而不是由业务线程来更新缓存,缓存本身的有效期设置为永久,后台线程定时更新缓存。
后台定时机制需要考虑一种特殊的次使用mysql的这个缓冲来做查询使用的话场景,当缓存系统内存不够时,会“踢掉”一些缓存数据,从缓存被“踢掉”到下一次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程本身又不会去更新缓存,因此业务上看到的现象就是数据丢了。
解决的方式有两种:
适用场景:
相比更新锁机制要简单一些。
缓存预热指系统上线后,将相关的缓存数据直接加载到缓存系统,而不是等待用户访问才来触发缓存加载。
虽然缓存系统本身的性能比较高,但对于一些特别热点的数据,如果大部分甚至所有的业务请求都命中同一份缓存数据,则这份数据所在的缓存服务器的压力也很大。
缓存热点的解决方案就是复制多份缓存副本,将请求分散到多个缓存服务器上,减轻缓存热点导致的单台缓存服务器压力。缓存的数据是一样的,通过在缓存的 key 里面加上编号进行区分,每次读缓存时都随机读取其中某份缓存。
缓存副本设计有一个细节需要注意,不同的缓存副本不要设置统一的过期时间,否则就会出现所有缓存副本同时生成同时失效的情况,从而引发缓存雪崩效应。正确的做法是设定一个过期时间范围,不同的缓存副本的过期时间是指定范围内的随机值。
由于缓存的各种访问策略和存储的访问策略是相关的,因此上面的各种缓存设计方案通常情况下都是集成在存储访问方案中,可以采用“程序代码实现”的中间层方式或者独立的中间件来实现。