(2022.06.06 Mon)
随着数据量的提升,数据库的访问和更新速度会有下降。本文介绍几种提升数据库性能的方向和方法。
关系型数据库的优化:
- 索引(针对单机数据库)
- 读写分离
- 分库分表
- 使用缓存提升关系型数据库的性能
- 多种类型的数据库搭配使用应对不同场景
下面分别介绍这些方法。
索引
SQL在执行检索或更新时,会对全表进行遍历操作。而这种操作会浪费大量时间。索引(index)的使用极大的提升了数据查询的效率。
索引也是一个表,只是这个表对用户不可见,仅仅在执行对关系的查询和修改时调用。索引这个表包含被索引的字段、该字段的不同值和指向不同值的指针(pointer)。
索引可以针对一个列,可以针对多列,也包括对不同列的组合。
索引可以极大的提高查询的性能,但是限制关系的更新速度。一个关系设定的索引越多,更新关系时的速度就越慢。一般来说一个关系的索引不超过6个。
索引的更多内容,参考《SQL索引》一文。
读写分离
随着数据量增大,设置数据库集群是一个可行的解决方案。设置读写分离,可将访问压力分散到急群中的多个节点。后面会介绍分散存储压力的方法,即分库分表。
读写分离的特征如下:
- 数据库服务器搭建主从集群,一个主机和至少一个从机
- 主机负责读写,从机只负责读
- 主机数据复制同步到所有从机,所有服务器机器都保存了所有数据
- 业务服务器将写操作发给数据库主机,将读操作发给从机
从读写分离的特性中很容易注意到潜在的问题所在,即主从复制的时间问题。如果复制时间内有读操作,有可能无法读取最新数据。
针对主从复制延迟,有几种常见方法:
- 写操作后的读操作指定发送给数据库主服务器:对业务侵入(?)和影响大,容易出现bug
- 读从数据库失败后再读一次主机,即二次读取:如果二次读取次数多,则增加主机读压力,容易被黑客利用造成系统崩溃
- 关键业务读写操作全部指向主机,非关键业务采用读写分离:分关键业务数据的延迟可以容忍和接受
分库分表
读写分离能够分散访问压力,但无法减少存储压力。如果数据库过大,则主从的数据库都变得笨重。在数据量过大时,
- 即便加入索引,性能也会下降
- 数据库笨重,复制到从数据库的延迟可能难以满足性能要求
- 文件大,则极端情况下丢失数据的风险高,比如灾难时主从机都发生故障
基于此,需要将存储分散在多台服务器上。常见的分散存储方法有分库和分表两大类。
业务分库
顾名思义,将一个数据库按业务分为多个。比如电商网站将数据分为用户数据、产品数据和订单数据。
注意到业务分库会引入明显问题。
- join操作
数据分散到不同的数据库中,无法使用SQL的join操作。当需要使用join操作时,一种妥协的方案是依次从不同的数据库中经过条件筛选出不同的数据,显然这种方案的复杂度要高得多。 - 事务
事务的原子性难以保障。 - 成本
服务器增多带来的成本提升。
数据分表
当业务量足够大,比如电商网站的用户数上亿,数据全部存放在一个表中性能将受到影响。单表数据可做拆分,有垂直分表和水平分表两种方式。
垂直分表,即对数据表按列拆分,同一张表的不同列拆分后分别存储在不同的表中。拆除的列可能是不常用的、数据长的。实现和访问相对水平分表容易很多。
水平分表,适合表行数特别大的表,按行来分表。经验的分表长度在千万行级别到亿行级别。
水平分表引入了更高的复杂度,体现在:
- 路由:水平分表后哪些数据属于哪个分表,需要增加路由算法计算
- join操作:数据分散在多个表中,需要与其他表做join操作,需要在业务代码或数据库中间件进行多次join查询,并合并结果
- count()操作:数据物理上分散在多个表中,但逻辑上在一个表中,计算总数时需要特别处理
- order by操作:数据分散在多个表中,排序操作无法在数据库中完成,只能由业务代码或中间件分别查询每个子表中的数据,并汇总排序
路由
常见路由算法如下:
- 范围路由:如果分行的依据是自然递增的id类数据,可根据id的范围对数据做路由,不同范围的数据进到不同的数据表中。范围路由的复杂点在于分段大小的选取,太小则子表数量过多,维护复杂,太大则单表性能依然会遇到性能瓶颈。根据实际情况选取分段大小,经验值在100万到2000万之间。
范围路由的优点是随着数据量增大,扩充新表的过程不会突兀,新增表时已有的表和数据不需要改动。缺点时可能分布不均匀,如果id分布不均的话。 - hash路由:对某列/不同列的组合的值进行Hash运算,根据运算结果分布到不同的数据表中。比如可以根据id值,简单的使用
id//10000
的值作为数据所属的表编号。该方式设计的复杂点在于初始表数量的选择。并且在增加了子表之后所有数据要重新排布。
其优缺点和范围路由相反,优点是分布均匀,缺点是扩充新表麻烦,所有数据要重排。 - 配置路由:即用一张独立的表来记录路由信息。比如针对id,创建一张新表,包含id和table_id两列,根据id可以找到该数据存储在哪个table中。该法使用灵活,扩充新表只需要迁移指定的数据,并修改路由表即可。缺点是在查询时需要多一次,也就是查询配置路由表。
count()操作
count操作常见的处理方式如下
- count()相加:对每个表做count()操作,将结果相加。简单但是性能较低。
- 记录数表:新建一张表,记录table_name,row_count等信息,每次插入或删除子表数据成功后,则更新该数据记录表。该方法性能优于前一种,但是复杂度增加,对子表操作的同时也对记录数表做操作,增加了数据库的写压力,一次insert/delete操作要伴随着对计数表的update操作,所以可通过后台定时更新的方式更新记录表。同时如果业务遗漏,则导致数据不一致;难以在同一个事务中做处理,异常情况下子表成功计数表失败,则数据不一致。定时更新实际上是count()相加和记录数表的结合,即定时通过count()相加记录数,并更新。
读写分离和分库分表的实现
两种方法的实现本质上都是分配机制,将不同的SQL语句发送到不同的数据库服务器。常见的分配实现有两种,程序代码封装和中间件封装。
程序代码封装
在代码中抽象出一个数据访问层来实现读写分离和分库分表。比如基于hibernate进行简单封装。
特点:
- 实现简单,可根据业务做较多定制化的功能
- 每个编程语言都需要自己实现一次,无法通用,开发工作量大
- 故障情况下,如果主从发生切换,需要所有系统都修改配置和重启
淘宝的TDDL,i.e., taobao distributed data layer。
中间件封装
独立出一套系统出来成为中间件,该中间件对业务服务器提供SQL兼容的协议。对业务服务器来说,访问中间件和访问数据库没有区别。
特点:
- 支持多种编程语言,因数据库中间件对业务雾浮起提供的是标准SQL接口
- 要支持完整SQL语法和数据库服务器协议,细节多,容易出bug
- 中间件自己不执行读写操作,但所有数据库操作请求都要经过中间件,对性能要求高
- 中间件可以探测数据库服务器的主从状态
MySQL-proxy,MySQL Router。
考虑到实现的复杂程度,分库分表要比读写分离的实现复杂的多。
缓存cache
索引、读写分离、分库分表等方法可以提升存储系统的性能,但在某些业务场景下,性能提升不够,比如
- 需要经过复杂运算后得出的数据,存储系统优化无能为力,比如计算海量数据的count(*),MySQL往往耗费大量资源且运行缓慢
- 读多写少的数据
这些场景下需要用到缓存技术cache。缓存的基本原理是将可能重复使用的数据放在内存中,一次生成,多次使用,避免频繁访问存储系统,减轻后端数据库的压力。
缓存会把数据库中经常被查询的数据保存起来,比如热点数据,这样当用户来访问的时候,不需要直接访问后端数据库,而是直接获取缓存中的数据,降低了后端数据库的读取压力。如果说用户查询的数据缓存中没有,用户的查询请求就会转到后端数据库,当后端将数据返回给客户端时,同时会将数据缓存到缓存中,这样用户再次读取时,就可以直接从缓存中获取数据。
下面介绍四个在设计缓存时需要注意的问题,缓存穿透、缓存雪崩、缓存击穿和缓存热点。
缓存穿透
指的是用户查询在缓存中没有数据,进而到后端数据库中查询,而后端数据库中也没有该数据,直接返回空对象。这种方法可被黑客用于对数据库发起攻击。
通常在两种情况下发生缓存穿透,即存储数据不存在和生成缓存数据需要耗费大量时间或资源。
存储数据不存在
这种情况下的应对方案比较简单直接,如果查询存储系统中没有找到查询数据,则设置一个默认值到缓存中,再次查询会被阻断在缓存中,以避免造成后端数据库的系统负担。这种方法的缺点是占用缓存空间。
也可使用布隆过滤器 placeholder。缓存数据生成耗费大量时间或资源
缓存击穿
指的是用户查询的数据不在缓存中,却在后端数据库中。产生这种现象的原因可能是缓存中的key已经过期失效。
解决方案:
- 调整过期时间为永不过期,比如在redis中的
PERSIST
命令 - 分布式锁:分为上锁和解锁。上锁,当通过key查询数据时,首先查询缓存,缓存中没有,则通过分布式锁进行加锁,第一个获取锁的进程进入后端数据库查询,并将查询结果缓存到缓存中。解锁,当其他进程发现锁被进程占用,则等待,解锁后,其他进程依次访问被缓存的key。
缓存雪崩avalanche
指的是缓存中大量的key同时过期,造成直接访问后端数据库,引发性能急剧下降。缓存雪崩发生在并发量特别大的时候,某些突然过期的情况下,这比缓存击穿的影响更大。
解决方案:
- 设置key永不过期,同时启动后台线程定时更新缓存。该法还适合缓存预热,指的是系统上线后,将相关的缓存数据直接加载到缓存系统,而不是等待用户访问才来出发缓存加载。
- 设置key过期时间随机,避免大量key同时过期
- 更新锁:对缓存更新操作进行加锁保护,保证只有一个线程能够进行缓存更新,未能获取更新锁的线程要么等待锁释放后重新读取缓存,要么返回空值或默认值。
缓存热点
对于一些特别热点的数据,大部分甚至所有的请求都命中同一份缓存数据,则缓存服务器的压力剧增。解决方案是复制多份缓存,将请求分散到多个缓存服务器上,减轻缓存热点导致的单台缓存服务器的压力。
NoSQL配合SQL
关系型数据库仍然存在一些缺点:
- 存储的都是行记录,无法存储数据结构
- SQL数据库的schema扩展不便
- 在大数据场景下I/O较高
- 全文搜索功能较弱
不同的NoSQL解决方案针对SQL的不同缺点应运而生,配合SQL使用,但应该记得NoSQL数据库都牺牲了SQL的ACID特性中某个或某几个。
有如下几种NoSQL数据库:
- key-value存储:解决SQL无法存储数据结构的问题,如Redis
- 文档数据库:解决SQL强schema约束的问题,如MongoDB
- 列式数据库:解决SQL大数据场景下的I/O问题,如HBase
- 全文搜索引擎:解决SQL的全文搜索性能问题,如ElasticSearch
K-V存储
其中的key作为数据的标识,value是数据值。以Redis为代表的K-V存储提供了多种数据结构,包括string、hash、list、set、sorted set、bitmap和hyperlog等。
Redis的不同数据结构提供了各种各样的操作,比如List类型可以像操作queue一样操作,如果用MySQL实现就很复杂。
Redis的缺点是不支持完整的ACID事务,只能保证隔离性I和一致性C。不支持原子性A。下面逐项分析。
A:Redis不支持回滚操作,事务中间一条命令执行失败,既不会导致前面已经执行的命令回滚,也不会中断后面的命令执行。
C:Redis事务开始前后,数据库的完整性没有被破坏。
I:Redis不存在多个事务的问题,因为(截止到2022年6月为止)Redis是单进程单线程的工作模式。
D:Redis有两种持久性方法,即数据截面的RDB和命令序列的AOF。
文档数据库
以MongoDB为代表的文档数据库最大特点是no-schema,可以存储和读取任意的数据,目前绝大多数文档数据库存储的数据格式是JSON/BSON,因为JSON是自描述的,无需在使用前定义字段。
no-schema的特点带来了一些优势:
- 新增字段简单
- 历史数据不会出错
- 可以容易存储复杂数据
文档数据库的缺点之一是不支持事务,对事务要求严格的场景不适合使用文档数据库。另一个缺点是无法使用SQL中的join操作。更适合作为SQL数据库的补充。
列式数据库
指的是按照列来存储数据的数据库,与之对应的传统关系数据库被称为行式数据库。
传统关系数据库的行式存储优势如下:
- 业务同时读取多个列时效率高,一次磁盘操作就能够把一行数据中的各个列都读取到内存中。
- 能够一次性完成一行中多个列的写操作,保障了数据写操作的原子性和一致性。
而列式存储的优势如下:
- 节省I/O
- 较高的存储压缩比
针对列式存储的特点,一般将其应用于离线大数据analysis和统计。因其针对列操作,更新不多。
全文搜索引擎
关系型数据库在全文搜索的业务场景下力不从心,体现在
- 搜索条件可任意组合,如果通过索引,则索引的数量会非常多
- 模糊搜索的方式,索引无法满足,用
like
查询,会整表扫描,效率低下
比如有关系article(id, title, content),从该关系中找出content部分有“架构”两字的id。使用like
查询,则对整表扫描,如果行数多,则效率会非常低下。
全文搜索引擎(如ElasticSearch)的技术原理被称为倒排索引Inverted index,或反向索引。其原理是建立在单词到文档的索引。上面的article关系,经过倒排索引就表示为keyword(word, article_id)的形式,比如word字段的“架构”值,其对应了article_id是一个列表,保存的是所有含有架构二字的文章id。
在制作全文搜索引擎时,将原始表格转化为JSON文档或文档,基于JSON文档可以建立全文索引。
ElasticSearch索引基本原理:
分布式的文档存储方式,能存储和检索复杂的数据结构-序列化成JSON文档-以实时的方式。
在ES中,每个字段的所有数据都是默认被索引的,即每个字段都有为了快速检索设置的专用倒排索引。而且不像其他多数的数据库,它能在相同的查询中使用所有倒排索引,并以惊人的速度返回结果。
Reference
1 从零开始学架构-照着做,你也能成为架构师,李运华著,电子工业出版社