AlexiaChen / AlexiaChen.github.io

My Blog https://github.com/AlexiaChen/AlexiaChen.github.io/issues
87 stars 11 forks source link

架构设计------高性能存储 #78

Open AlexiaChen opened 4 years ago

AlexiaChen commented 4 years ago

高性能存储

关系型数据库

虽然IT行业这十几年取得飞跃式的发展,但是由于其强大的ACID事务特性,关系型数据库还是各种业务系统中的关键和核心存储。很多场景下的高性能设计也是关系数据库的设计。

各种数据库厂商,Oracle,MySQL,SQL Server在优化和提升单机数据库服务器的性能方面做了非常多的优化和改进。即使这样,这样的优化速度可能还是不能满足用户量增长,业务复杂的增加速度。所以必须考虑数据库集群来提高性能。

一个企业,考虑优化数据库的步骤应该是这样:

下面只会介绍关键点的细节。

读写分离

本质就是数据库读写操作分散到不同的节点上。基本实现如下:

注意,主从跟主备不是一个概念,备机是主机宕机后,随时顶上的角色,平时不提供访问。主从是从机是提供服务的

读写分离实现逻辑不复杂,但是有个问题就是,主机向从机同步数据的时候有延迟,以MySQL为例,可能达1秒,大量数据可能达到1分钟,这样时间内的不一致,会影响到业务系统的处理。比如,用户刚注册一个帐号(写操作,在主机上),然后登录(读操作,在从机上),业务层会返回该帐号没有注册,显然已经注册了。

当然,以上情况解决办法有很多:

和业务强耦合,对业务侵入比较大。新来的队友容易犯错。

所谓的”二次读取“,和业务松耦合,实现代价很小,只需要改下数据库访问的API,封装下就可以了。有点像配合缓存的系统,缓存中读不到,再去数据库中读取。

很好理解,比如新浪微薄系统,发帖,显然读写都是强一致的,但是点赞,转发这种业务,不需要太实时更新,最终一致即可。你注意观察会看到,点赞数很显然不是很精确的那种。再例如,微信的个人头像,有些人头像换了一个新的,但是如果你不点进去,那么头像还是以前那个旧头像,除非你点进去,新头像才会刷新。

当然,对于金融系统另说,因为金融系统因为业务的特殊性,很多地方需要强一致。

分库分表

读写分离分散了读写操作的压力,但是没有分散存储压力,当数据量达到上亿的时候,单台数据库的存储能力会成为系统瓶颈,主机是个亿级大表,从机也是亿级大表的完全复制备份。呵呵。

问题有很多,除了性能瓶颈还有安全方面:

业务分库

就是按照业务模块将各自的数据分散到不同的数据库服务器,比如,用户,订单,商品三个子系统,每个子系统的数据可以放在不同的三个数据库上。

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

分库后,原本在同一个数据库中的表分散到不同的数据库中,导致SQL的join无法使用,有些以前非常简单的代码会变复杂。

原本在同一个数据库中不同的表可以在同一个事务中修改,保证强一致。分库后,不同的表在不同的数据库无法统一修改,没有事务。怎么办?上分布式事务方案,2PC,3PC。比如MySQL支持XA接口,但是其性能据说比较低。非常老的Tuxedo也实现了XA标准,也支持。不过太老了,土死了,不过听说银行还有大量用Tuxedo。系统比较老。

但是阿里的一些规范不是直接说了么,不到万不得已,不要上分布式事务方案。

初创公司不建议这么搞,成本太大。你看,昆明的很多小公司,都是自己那一丁点大的机房,就几台服务器。上云,老板还觉得太贵。

分表

之前的分库,做的好的话,支撑百万甚至千万用户规模的业务问题不大,但是业务继续扩大的话,单个业务数据所在的同一个数据库中的某个单表数据量也会扩大,最终达到单机数据库服务器的处理瓶颈。这个时候就需要分表,分表又分垂直和水平拆分。

垂直拆分就是将一些表中的某些不常用的列拆分出去,独立为一个表。本质就是减小单表的列数。垂直拆分引入的复杂性,主要就是原来对表只需要操作一次,可能现在要对两个或者多个表操作多次。

水平拆分适合表的行数特别大的表,给个业界参考,单表行数超过5000万行再考虑水平拆分,不是绝对的,是个参考值。

水平拆分真的是复杂度是最高的,真的比较考验架构师的实战能力。因为你可以这么想像,在应用层,或者中间件层来自己造一部分TiDB这样的分布式数据库的功能。确实需要自己造一部分轮子。虽然有第三方的开源中间件方案。

下面会列举出一些水平拆分的复杂性出来:

  1. 路由

某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算。

选取有序的数据列(时间戳等,或者由snowflake算法生成,有个全局ID生成服务器生成有序数据列)作为路由的条件,不同的分段分散到不同的数据库表中。

分段大小建议在1000万到2000万之间,不然分段太小导致子表过多,太大还会存在性能瓶颈。

优点:随着数据增加平滑的扩充新表。也就是扩展成本小。

缺点:数据分布不均匀,新增的分段表可能数据量少。

选取某个列或者几个列的组合的值进行Hash,然后根据Hash的结果分散到不同的表中。当然最简单的就是取模,比如,user_id % 10 表示数据所属的数据表编号,有10个子表。

缺点: 增加子表的数量是非常麻烦的,所有数据都要重新分布。也就是扩展的时候比较麻烦。 优点: 数据分布比较均匀。

当然,针对此方案的缺点,业界有一致性Hash方案,也就是构造了一个Hash环,扩展的时候数据重新分布的量就比较小了。不是所有数据。

  1. join操作

水平拆分后,数据分散在多个表中,如果需要与其他表进行join查询,需要在业务代码或数据库中间件中进行多次join查询,然后将结果合并。

  1. count操作

水平拆分后,虽然物理上数据分散到多个表中,但是某些业务逻辑上还是会将这些表当作一个表来处理。所以处理上就有复杂度了。具体做法大概有两种方案:

最简单的做法,就是将每个子表都select count(),然后将结果相加。但是性能低。分成几张子表,就要进行几次count()。

其实就是维护一个数据库行的size数据,增加,删除行都在这个数据表上更新size字段。性能比上面的方案高不少,缺点就是,需要同步这张表,程序员写的时候很容易忘记更新size,造成数据不一致。而且前面提到过,分库分表后,这两种表很可能无法放在同一个事务里。这种方案要求比较实时,每次insert delete,都会从记录数表中update size这个字段,会增加数据库的写压力。

其实细想下业务,这种需要count(*)的业务一般都不需要太精确实时,不要求强一致。所以可以用一个后台定时任务(比如每一个小时更新下),来做count(*)相加,并更新记录数表。

  1. order by操作

水平拆分后,排序操作无法在数据库中完成,只能由业务代码或数据库中间件分别查询每个子表中的数据,然后汇总进行排序。

读写分离和分库分表的实现方法

抽象出一个数据访问层来实现读写分离,分库分表。业务层来调用数据访问层。数据访问层读写数据库。

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

一般这种方法,目前早就淘汰了。淘宝的TDDL就是这样的数据访问层。作者现在还在知呼呢,名字codefollower。

独立出来的一套系统,实现分库分表,读写分离操作。它并对业务服务器(业务层)提供SQL兼容的谢谢一,对于业务服务器来说,访问透明。

优点:支持多种编程语言,因为中间件提供标准SQL接口。 缺点: 中间件实现复杂,一般小公司没能力研发

中间件的实现复杂度:首先要支持完整的SQL语法和SQL协议,很复杂,细节特别多,很容易出bug。其次,中间件不执行真正的读写操作,但所有的数据库操作请求都要经过中间件,所以中间件实现的开始,就需要考虑高性能,一般普通的Java人员会高性能服务端编程吗?显然不会。最后,数据库的主从切换对业务服务器无感知,所以中间件就要做到可以探测数据库服务器的主从状态。

所以尽量还是推荐用大厂的开源中间件方案。MySQL官方提供了mysql-proxy和MySQL Router。Rounter可以读写分离,故障自动切换,负载均衡,连接池等。

之前推荐过360的中间件Atlas,它基于MySQL proxy。

中间件的方案,现在也慢慢开始逐渐在淘汰的边缘了,虽然大厂可能还有很多项目在用它。看看Github的更新频率就知道了。

实现的复杂度:

读写分离还好,只要识别SQL操作是读操作还是写操作即可,简单的判断select,update,insert,delete几个关键字就可以了。分库分表的实现除了要判断操作类型,还要判断操作的表,操作函数,order by, group by,实现太复杂。宁愿用中间件。

NoSQL(Not only SQL)

看标题就知道它主要是用来做关系型数据库的补充,而不是代替。这个前提要明白。而且是以牺牲部分ACID特性的。

既然是作为补充,就说明关系型数据库的缺点还是有些的:

好了,这些上述缺点,其实业界都有开源的解决方案了,不用担心,NoSQL闪亮登场了。

内存KV数据库

这里主要看Redis了,Redis很多场景下可以完全替代memcached,memcached差不多早就过时了。Redis支持丰富的数据结构,它是个数据结构服务器。提供list,string,hash,set,sorted set, bitmap,hyperloglog等高级的数据结构。

牛逼吧,缺点就是并不完整支持ACID事务,虽然它提供事务功能,Redis的事务只保证隔离性和一致性(I和C),无法保证原子性和持久性(A和D)。

不支持。不支持回滚操作,事务中间有一条命令执行失败,既不会导致前面已经执行的命令被回滚,也不会中断后面的命令执行。

支持。Redis事务能保证事务开始之前和事务结束之后,数据库的完整性没有被破坏

支持。原理是事务全部在排队,串行的,因为Redis是单线程工作模式(网络模型,单Reactor,后面文章会讲)。好了,你看到了,串行排队,如果事务数量提交巨大,会阻塞其他客户端操作。

Redis提供两种持久化的方式,RDB和AOF。

RDB是只备份当前内存中的数据结构,事务执行完毕时,其数据还在内存中,并未立即写入磁盘,擦,事务执行完毕,数据还没有落盘,那么RDB肯定是不支持事务的持久性的。

AOF是先执行命令(操作数据结构的命令),执行成功后再将命令追加到日志文件中。即使AOF每次执行命令后立刻将日志文件刷盘,也可能丢失1条命令数据,所以,AOF也不能100%保证Redis事务的持久性。

好了,Redis就是个高性能的残废。所以需要根据自己的业务特点来决定是否用Redis。还是以微博系统举例,微博大部分业务是不需要太强调ACID特性的。

如果是做支付系统的,核心业务,我就没见过用Redis的。核心都需要ACID来保证的。

文档数据库

文档数据库最大特点就是no schema。可以存储和读取任意的数据,大部分的数据格式是JSON或BSON。json是自描述的,无需使用前定义字段,读取一个json中不存在的字段也不会导致SQL类似的错误。

这样直接带来显而易见的优势:

由于这种特点,文档数据库适合电商一类的业务场景,因为不同的商品,甚至同类的商品属性差异很大,参差不齐。用关系型难以描述,也会浪费资源。

这些no schema的特性带来的灵活自由的代价就是,文档数据库一般不支持事务,因为无法实现关系数据库的join操作。有些查询会显得没关系型那么方便。

在常见的电商系统中,可以使用关系型数据库存储商品库存,订单基础信息等关键信息(订单可是要支持事务的,不然怎么付钱?库存信息出错还怎么卖?),至于商品详细信息可以使用文档数据库来完成。

列式数据库

因为传统数据库被称为“行数据库”,那么列式数据库就应运而生了,它解决了行数据库的一些不足。

行式数据库按照行来存储数据,有以下优势:

你看,行式数据库还是很有优势的,在特定场景下。但是如果不是这样的业务场景呢?那么可能就是劣势,比如在对海量数据进行统计的场景下。

例如,计算某个城市体重超重的人员数据,按照脑袋中的逻辑的话,实际上只需要读取每个人的体重weight这一列进行统计即可,而行式存储即使最终只使用一列,也会将所有行数据读取出来,这带来的结果就是遍历读取整张二维表。可想而知,造成了多少次IO和内存浪费。如果采用列式存储,每个用户只需要读取体重weight的字段即可,就是weight列,IO大大减少。

除了节省IO,列式存储还具备更高的存储压缩比,能节省更多存储空间,想象下,一张二维表中,显然同一列的数据都是同一类型的数据,数据相似度很高,所以压缩比当然更高(压缩算法一般都是对重复串进行建模,压缩完全随机的数据是不太可能的)。普通的行式数据库压缩率一般在3:1到5:1左右,而列式的压缩率一般在8:1到30:1左右。

最后讲点,行式和列式的原理吧。

行,列这两个概念是存储的概念。行式存储,就是同一行都在磁盘上同一个地方,行与行之间在磁盘上不连续,也就是读取一行一般是一次磁盘操作,读取多行就多次操作。列式存储就是同一列在磁盘上的同一个地方,列与列之间磁盘地址不连续。按照这个原理,就可能后面知道怎么选型了。一次事务操作,如果涉及到多个不连续的地址,当然开销大了。

所以,你看到了,列式数据库针对同一列数据进行数据分析的性能是很高的,一般应用在离线大数据分析和统计场景中,因为这种场景主要是针对少量的部分列进行操作,而且数据写入后一般无须再频繁更新删除。高压缩比,更新不频繁,相当于数据仓库,历史数据归档。

业界标杆---HBase,Hive,Cassandra。

注意,列式数据库也可以是关系型的,关系型是指更上层的逻辑层,列式是更加底层的存储概念。

全文搜索引擎

数据库缺陷

传统的关系数据库,通过索引达到快速查询的目的,但是在全文搜索的业务场景下,就无能为力了,你被SQL的like匹配折磨过吗?操作不当就扫描全表。

所以你看到了,全文搜索各种排列组合非常多,都是关键词 + 关键词搜索,关键词随意颠倒,倒有点像是用百度和Google来搜索知识了。恩,确实,你确实可以理解百度就是一个全文搜索引擎,只是它高级得多。

基本原理

这种全文搜索的原理叫---倒排索引(inverted index),有时候也叫反向索引。基本原理就是建立单词(key)-> 文档(value)的索引映射。与正排索引相反,正排是建立文档(key)->单词(value)的索引映射。

现在你可能理解了,倒排索引非常适合根据关键词来查询文档内容,经常写博客文章的,用这种业务功能用的多。

当然,实际情况做业务,一般这两种索引是结合一起用的。

与数据库结合

为了实现让全文搜索引擎支持关系型数据库的全文搜索,需要做一些转换操作,要把关系型数据转换为文档数据。

业界做法是将关系型的数据按照对象的形式转换为JSON文档,最后一张二维表的数据,每一行为一个JSON文档对象,最后是一个JSON文档对象集合。然后,把这个大集合输入到全文搜索引擎中。

全文搜索引擎能够基于JSON文档建立全文索引,然后快速进行全文搜索。如果用的是Elasticserach,JSON文档中的每个字段的所有数据都是默认被索引的。即每个字段都有为了快速检索设置的专用倒排索引,它能在相同的查询中使用所有倒排索引,以较为实时的速度返回结果。代价嘛,就是吃内存,你懂得,ES吃内存很正常的,本来就是空间换时间的技术。

我上面举了例子,读者也不能太傻,不懂变通。认为就是把单表转换成一个JSON文档集合。实际情况你可以多表结合转换为一个JSON文档集合。

缓存

存储系统够牛了,但是在一些复杂的业务场景下,还是不能满足性能要求。比如:

缓存能够带来性能的大幅度提升,比如memcached单台服务器,简单的KV查询能够达到5W+的TPS。Redis更牛,最近一段时间测试过,10W+ TPS。

缓存确实减轻了数据库的读压力,但是带来了一些其他的复杂性,需要考虑。以下说下问题。

缓存穿透

字面意思就是,在缓存中查无此人,读操作,只能跑数据库去查了。

通常发生这种不幸的事情有两种情况:

被访问的数据确实不存在,如果存储系统中没有某个数据,则不会在缓存中存储相应的数据。通常情况下,业务去读取不存在的数据的请求量不会太大,除非有人恶意攻击。

解决办法也有,简单的,如果查询存储系统中的数据没有找到,则直接设置一个默认值(空值或者是某个具体值)并存到缓存中,这样第二次读取缓存就会获取默认值,不会压到数据库。

还有种办法,就是建立一个布隆过滤器,在过滤器中找不到某个数据直接就return,业务就不 要继续往下走了。关于这个,我写过一篇文章,实现高性能布隆过滤器的: https://github.com/AlexiaChen/AlexiaChen.github.io/issues/29 如果不想自己写,毕竟做业务的,尽量避免造轮子,Google出了叫guava的Java基础库,里面就有布隆过滤器的实现,跟我写的思路差不多,Hash的方案也参考过文章提到的那篇论文。

当然,还有其他办法,这个百度就有很多解决方案。

存储系统中存在数据,但生成缓存数据需要耗费比较长的时间,同时耗费大量资源。

典型的案例是:商品分页,由于数据量大,不能缓存所有数据,只能按照分页缓存,但是难以预测用户会访问哪些分页,当用户点击某个分页,按照分页计算和生成缓存,通常情况下是没问题的,但是如果被竞争对手的爬虫来访问遍历,系统就会出现问题。如果在缓存过期以后,又会重新生成大量缓存。耗费资源。

这种情况并没有太好的解决方案,牛逼点的话,就是识别爬虫,然后禁止访问,但是可能又会影响SEO和推广(搜索引擎是有爬虫的)。

缓存雪崩

就是缓存失效(过期)后引起大量的读操作瞬间读取数据库,导致性能急剧下降,甚至压跨数据库。

现在明白了,缓存雪崩肯定会发生了,主要解决办法就是发生了,怎样应对的问题。

首先,先来慢速复现下雪崩的细节:

  1. 大量读请求几乎同时过来,查询缓存没查到,因为数据过期被缓存自动删除了。

  2. 其中一个读请求去读取数据库的最新数据,准备生成缓存。

  3. 但是其他剩余的读请求并不知道有一个读请求正在生成缓存了,它们也去数据库读取数据,生成缓存。

  4. 数据库被压跨

雪崩一般有两种解决方案:

对缓存更新的一个读线程进行加锁保护,保证只有一个线程能够进行缓存更新,未能获得更新锁的线程要不等待锁释放阻塞起来,要不返回空值或默认值。

这样随之有个需要注意的问题就是,对于采用分布式集群的业务系统,由于存在几十上百的服务器,即使单台服务器只有一个线程更新缓存,但是几十上百的服务器一起算下来也会有几十上百个线程更新缓存,一是没必要,二是也存在雪崩问题。因此分布式集群的业务系统要完美实现更新锁机制,需要用到分布式锁,这个分布式锁可以基于Redis实现(网络上确实有大量文章写的是基于Redis的),也可以用Zookeeper。最完美还是用Zookeeper。

不要觉得分布式锁有多高大上哈,其实就是个分布式集群内的全局Mutex。你可以简单这么理解。

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

当然,也会有对应的问题,当缓存系统内存不够,它自己会“踢掉”一些缓存数据,从缓存被“踢掉”到下次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程本身又不会去更新缓存,因此业务上看到的现象就是数据丢了。有两种解决方案:

  1. 定时读取

后台线程除了定时更新缓存,还要频繁地去读取缓存,如果发现缓存被“踢了”就立刻更新缓存,这种方式实现简单,但是读取间隔不能设置太长。不然这段时间内业务访问不到真正的数据。用户体验下降。

  1. 消息队列通知

业务线程发现缓存失效后,通过消息队列发送一条消息通知后台线程更新缓存。多个业务线程都发送消息给后台线程,后台线程可以判断有没有必要更新即可,就是发现缓存存在,就不更新了呗。这种方式依赖消息队列,复杂度会高点,但缓存更新及时,用户体验会好。

后台更新方案相比分布式锁要简单些,适合单机也适合分布式集群。

缓存热点

因为正因为缓存它缓存了热点数据,但是对于特别热点的数据,它还是会有压力,殊不知新浪微博瘫痪多次。

解决办法就是复制多份缓存,将请求分散到多个缓存服务器上,减轻缓存热点导致的单台缓存服务器压力。恩,这就是缓存分布式集群方案。

EOF