vlambda博客
学习文章列表

数据库优化(四)从架构优层面化

在经过了前面的SQL优化、设计优化后,数据库性能还是存在问题,那么这个时候我们就需要考虑下架构上的优化了,从架构上来优化一般有会比较明显的效果,通常我们可以采用硬件升级、资源池化、分流、分片、缓存、Nosql的手段对架构进行优化,当然这些优化手段并不能盲目的使用,需要来针对不同的场景和公司和业务现状来选择不同的策略。


硬件升级

虽然硬件升级并算不上什么架构层面的优化,但是硬件升级的确是最简单而且效果最为立竿见影的方式,遇到性能问题我们第一步就应该考虑使用此方式,通过升级服务器的CPU、内存、磁盘来优化数据库性能通常是最快捷而且提升的性能又是整体性的,同时最省心的是硬件升级不会应用程序有任何影响,如果你的数据库服务器硬件配置还不高,然后又要求短期内的整体性的优化 ,那么请升级你的数据库硬件配置。当然随着配置的越来越高,硬件升级的成本也会也来越高,升级带来的效益也会越来越低,所以通过硬件升级的方式会有一个临界点,当超过这个临界点,硬件上升级的成本会成指数级的上升,达到普通公司无法承受的程度。



资源池

应用和数据库交互频繁的建立连接是非常消耗性能的,所以数据库后来也衍生了很多连接池技术。而我们这里并不是想在这么多成熟的连接池基础上去重复造轮子,而是学习这种资源池的思维,让消耗资源性能的操作预先做好,等到需要需要的时候直接使用即可,池化技术不仅能够减少资源频繁创建销毁过程中的消耗,同时也可以通过池化技术来限制避免无节制的创建资源导致应用崩溃。


连接池创建思路

1、创建连接池之前我们首先要对连接池的大概情况有个规划,我们需要考虑连接池最多创建多少线程,然后刚创建的时候也许我们没必要一次性把所有连接都创建出来,而是跟随请求动态的递增,刚开始也许需要的连接很少,所以我们可以为连接池设置一个初始值。

2、当请求获取连接时,如果连接池有空间连接则直接返回一个可用的连接。

3、当请求获取连接时,如果连接池没有空闲的连接,则先看下连接池连接数量是否达到最大值,没有则创建一个连接返回。

4、如果空闲连接没了、线程池数量也已经达到最大值了,则看任务队列是否已满,如果任务队列还有未满则提交一个任务放到队列里面,在有限的超时时间内等待执行。

5、如果空闲连接没了、线程池数量也已经达到最大值了,然后任务队列也满了,那没办法就只能拒绝请求了。


可能会遇到的问题

如果是数据库连接池,那么会有连接超时问题,数据库对每个连接的有wait_timeout的限制,当数据库连接闲置多长时间后会主动关闭连接,而当用户使用这个已经关闭的连接会出现错误。

解决思路:

方案一:使用一个专门的线程专门用于检测连接的可用性,可以对检测的线程发送一条select 1查询指令 来检测连接是否可用,然后把断开的连接移出连接池。

方案二:在获取连接之前先检测连接的可用性,但这种方案会有额外的开销。


注意事项

JDK的ThreadPoolExecutor线程池问题,当我们使用线程池时候不可避免的会使用到JDK的ThreadPoolExecutor 的线程池,但使用这个线程池会有两个问题。


问题一

ThreadPoolExecutor属于CPU密集型线程池,线程池默认只会创建与CPU核心数相当的线程,只有当执行队列满了之后才会创建新的线程(因为CPU密集型的操作创建线程过多没有太多意义,反而CPU频繁切换线程的开销会导致效率降低)。

这种线程池其实对于我们IO密集型的应用来说不是太适合的,因为系统IO的时间是要远远超过CPU计算时间,大部分系统时间都耗费在IO上,反而CPU会得不到有效的利用,这种情况下我们需要更多的线程能同时进行IO才做,这样同时执行任务的线程多了,系统效率就会提升。

所以在IO密集型的系统,我们创建线程池的思路是当请求来了优先创建线程,而不是放到队列里去,只有当线程池已经达到了最大线程数我们再把任务放到队列里, 就像tomcat的线程池就对此进行了改造。


问题二

ThreadPoolExecutor默认使用的无界队列,可无限制的往队列添加数据,因为可以无限制的往队列加任务,所以ThreadPoolExecutor在初始化线程数的基础上根本不会触发创建新线程的操作,这种情况下当并发请求量大或任务执行时间较长的时候,任务就会一直堆积会导致内存爆满,然后JVM会频繁触发full gc,导致应用变慢甚至崩溃,所以我们在创建ThreadPoolExecutor线程池的时候一定要使用有界队列。




分流(读写分离)

当数据库出现并发瓶颈时,我们首先可以考虑的是对请求进行分流,把请求分摊个多个节点。而读写分离的策略就是把流量分配给多个数据库,从而减轻单台数据库的压力,通过把写入的请求分配到主库,把读取的请求分发的从库,通过数据库主从的方式来分摊流量。读写分离依赖于数据库的主从机制,通过配置数据库主从复制把主库的数据同步到从库,主库主要处理数据变更请求,从库处理查询请求。


数据库主从

把数据库分为一个主库和多个从库的方式,主库主要处理写入的请求,从库主要处理读数据的请求从而达到分流的效果,数据库主从复制的原理主要是通过binlog来同步事务型SQL的操作来实现。

1、主库事务操作完毕后都会写一条日志到binlog里。

2、然后主库会创建一个专门的log dump的线程,此线程会监控binlog日志,当有新日志时log dump线程会把log信息发送给从库的IO线程.

3、当从库的IO线程收到主库发送过来的日志后,从库的IO线程会把数据写入到从库自己的relay log中去。

4、 然后从库也创建了一个专门的SQL线程,这个线程会监控自己的relay log,当发现有新的log时,就会把用户把log 中的sql 在从库中进行回放。





读写分离带来的问题


问题一:主从复制延迟问题

因为从库和主库之间进行数据同步需要经过一系列的过程,那么这个过程就必定会有一定的延时性,这就会造成一个问题,当我们一个地方往主库中写入数据后,另外一个地方在从库对这个数据进行查询,因为延时的问题,那么查询的时候很可能查询不到最新的数据。


解决方案:

1、即时返回: 如果需要得到最新的数据,那么可以在写入数据成功之后就直接返回最新的数据给应用程序。

2、数据冗余: 在写入数据库的同时也写入一份数据到缓存,应用优先从缓存查询数据。

3、从主库读取:实时性强的业务场景,选择直接从主库的数据中读取数据。


问题二:应用层操作数据库的方式变更。

应用层需要与多个数据库进行交互,应用如何与多个数据库建立连接,如何区分不同的请求分别使用不同的数据源并且区分请求类型而使用功能不同的库。


解决方案:

1、动态数据源:在应用层面配置多个数据源,通过AOP根据操作类型实现数据源的切换,简单直观,但需要对应用进行改造。

2、第三方组件:使用shard-jdbc ,需要引入第三方jar包配置使用,轻量级对代码无侵入,不需要额外部署。

3、代理产品:   比如DBProxy 、Mycat,需要安装第三方代理产品,完全与应用区分,支持多种数据库,但会增加额外的运营成本,且代理本身的集群高可用。




分片(分库分表)

当数量越来越大时,数据备份的时间也加长,索引文件也会变大,当数据库无法缓存所有数据索引只能从磁盘读取数据的时候,我们查询数据的性能也必然会随之降低,那么当遇到这种因为数据量导致性能产生瓶颈的时候,我就可以采用数据分片的思路来解决问题。在数据库数据分片的方式就是进行分库分表,减少单个数据库和表的数据,来提升数据库的性能,数据库分片也是我们常说的分库分表,数据库分库分表通常以垂直拆分、水平拆分两种方式来进行。


垂直拆分

垂直拆分通常是指通过业务的维度来切分数据,把不同业务维度的数据来切分到不同的数据库,来达到数据拆分的目的。

通常垂直拆分会根据业务功能的模板的分类来对数据库进行垂直拆分,如果你的系统分为用户、订单、产品模块;那我们通常会把不同模块的数据库进行拆分。

数据库优化(四)从架构优层面化




水平拆分

水平拆分则是根据数据维度拆分,通常会先指定一个拆分规则,把特定规则的数据拆分到不同的数据库和表中。

拆分方式:

1、用ID 哈希方式拆分,对ID进行取余的方式决定数据存到哪个库或表。

2、按字段范围区间,比如时间、地区、属性,不同的时间区域数据存放到不同的数据表中。



分库分表带来的问题


问题一:无法使用数据库自增长ID了

因为数据拆分到了不同的数据库和表中,数据库的自增长ID就无法保证我们的ID唯一性了,所以我们需要自己来生成一个全局的唯一ID。

全局唯一ID的要求:最后生成的ID必须要保证的就是“全局唯一”性,然后因为日常检索的排序需求,这个ID最好能满足排序的需求,同时生成ID时可以自定义一些业务标识,方面我们直接通过ID就可以分析其所属业务场景和分类。


解决方案:

实现方案 优势 劣势
数据库生成 数据库生成唯一ID不用依赖其他组件,可有序递增,可增加业务标识 性能不高。
Redis自增 生成的ID有序可自增,性能比数据库要高,可增加业务标识 需要依赖于Redis,性能偏低。
UUID 不依赖任何组件,性能高 不具备有序性,不具备业务含义,生成的ID由32 个 16 进制数字组成的字符串比较占用空间。
Snowflake 有序递增,可增加业务标识 依赖于第三方组件,依赖于系统的时间戳,一旦系统时间不准,就有可能生成重复的 ID。



问题二:如何定位数据在哪个表

经过分库分表后就不能像以前一样直接查询某个表了,而是首先要根据其业务特性和数据规则定位到查询的数据在哪个表中,因为我们拆分数据的时候是根据某一个字段的规则进行拆分的,如果想要定位到数据的位置,那么我们每次查询的时候就必须带上分区键(分库分表依赖的字段)才行。

假如根据ID作为分库分表的字段. 那么每次查询都要带上ID,如果要通过名称查询数据,那么就需要通过名称先找到ID,再根据D查询数据,这样的话又会需要建立一个名称和ID的映射表才行。


问题三:Join查询问题。

经过分库分表后表会存在于不同的库中,所以我们就不能像之前一样通过Join去连表查询数据了。

解决方案:

1、冗余:可以冗余一些不太容易变化的数据避免使用Join查询,比如把那些不太容易变更的表(比如配置信息)冗余一份到每个库中,也可以通过把需要join查询的列冗余一个字段到当前表中(比如过在订单表冗余一个用户姓名字段)。

2、代码筛选:先把多个表的数据查询出来然后在代码里再去做筛选。


问题四:无法直接统计数据

拆分了数据之后同时也不能直接对表数据进行count了,因为数据都存在于不同的表,这样的话我们通常又会需要专门用一个表记录统计数据又或者放缓存里。


问题五:数分页问题

我们的运营系统通常需要查询的是某个业务块的所有数据,分页也是在所有数据的基础上进行的,而经过分库分表后数据被分布在不同的库和表中,这样我们就无法直接对数据进行分页了。

解决方案:

1、通常我们会通过中间键完成,中间键先把一定的数据查询到缓存里,然后在缓存里进行逻辑分页。但这种方式通常来说性能不佳,页数越靠后性能就会越慢,因为需要查询和扫描的数据越到后面越多了。

2、冗余一份全量数据,比如说把全量的数据放到Elasticsearch 、MogoDB里,后台通过查询Elasticsearch 查询数据进行分页。


问题六:分布式事务问题

对于单库来说,数据库事务ACID能保证数据的一致性,但是但我们操作的数据存在于不同的库来说,就需要一套全局的事务机制来保证数据的一致性了。解决方案有Spring的JTA、阿里开源的Seata强一致性事务;还有基于可靠消息的柔性事务。



缓存

缓存不仅可以加速数据读取,而且通常缓存可以帮数据库抵挡住大部分的查询请求,缓存也是在缓解数据库查询压力的一大杀器。通常我们可以在很多地方对数据进行缓存,从客户端缓存 到CDN缓存、代理缓存、分布式缓存、应用缓存、数据库缓存等,缓存的使用场景无处不在,但使用缓存也会有一些需要注意的事项。


使用缓存将会面临的问题

缓存的使用场景无处不在,但使用缓存也会有一些需要注意的事项,这里主要以分布式缓存案例来了解缓存存在的一些通用问题。


问题一:数据一致性问题

一般情况下缓存都只是为了我们提升数据检索性能的一个媒介,它不会作为最后的数据存储归宿,通常我们会把最终的数据保存到数据库中去。这样的情况下我们的数据就会分别存在于缓存和数据库中,当我们操作两个数据库和缓存的时候就会存在修改了数据库但是没更新缓存数据、又或者更新了缓存数据库的数据又没同步修改,像这种数据库的数据和缓存数据不同步的问题就属于缓存数据一致性问题。

缓存一致性问题主要原因是在于修改数据库的同时又需要去更新缓存,但是这个过程可能出现一些问题从而导致缓存数据与数据库的数据不一致,如果A、B请求对同一条数据进行变更,A先把用户年龄修改为18、B请求后把年龄修改为20,这个过程可能是以下两种情况。

正常情况下修改流程如下:

A先修改完数据库-->A修改缓存-->B修改数据-->B修改缓存。



在并发操作的情况下很有可能产生如下情况

A先修改完数据库-->B修改数据-->B修改缓存-->A修改缓存,从下图我们也可以看见这种情况会造成数据库和缓存数据的不一致问题。


解决方案:

1、先修改数据库、再删除缓存

导致问题主要原因是在于修改缓存这个操作存在并发问题,既然修改有并发问题的话我们就可以采用不对缓存进行修改而直接删除对应的缓存方式、只要数据库修改数据之后直接把缓存的对应的key删除,然后程序在查询缓存时发现key不存在就从数据库读取数据,然后加载到缓存里面来、因为每次修改数据后缓存都是从数据库直接读取的数据,所以也就避免了因为修改缓存数据造成的不一致性问题。

2、更新缓存时加锁

既然是并发性修改导致的问题,那么我们也可以通过加锁的方式,A在修改数据之前先加锁,等数据库和缓存的数据都修改完毕之后再释放锁,B在A没有释放锁之前是没办法对数据进行操作的,所以也就避免了并发修改数据的可能,从而避免了缓存不一致问题。

3、设置较短的过期时间

设置一个比较短的过期时间,只要数据一过期,然后程序就会从数据库同步数据到缓存,这种方式虽然不是直接解决了缓存不一致性的问题,但是可以控制不一致数据存在的时长。



问题二:缓存穿透

缓存穿透是指当我们从缓存里面查不到数据而不得不请求从数据库去查询数据的情况,如果在并发请求量大的情况下,当然大部分的缓存穿透本身不是问题,因为缓存本身就是为了保存一些经常使用的活跃数据,对于那些经常不用的冷数据我们也必要长久的保存在内存中,毕竟内存资源是有限的,所以在缓存数据的时候我们也会设置一定的失效时间,当缓存失效了之后,我们的请求发现缓存没有数据就从数据库取读取数据再更新到缓存。

在读取数据时,因为缓存获取不到对应的数据而请求进入数据库查询数据的情况我们称为读缓存穿透,缓存穿透的的风险在于如果大量的请求直接穿透到数据库,有可能导致数据库承受不了并发请求的压力导致数据库瘫痪,从而导致系统整体的可用性问题,在系统中会有几种情况会出现缓存穿透。


缓存过期失效导致穿透

因为缓存失效需要重新从数据库查询数据同步到缓存,这种属于正常的穿透。


查询不存在的数据导致穿透

请求一个系统不存在的数据(比如说文章之类的),因为数据库根本就不存在对应的数据所以不会同步到缓存,每次请求过来发现缓存没有数据那么请求都会进入数据库。

解决方案:这种情况下的解决方案是如果数据不存在的情况下,缓存一个对应key的null值,当请求下次进来的时候发现缓存对应的null就直接返回结果,不再穿透到数据库查询。


恶意的攻击导致的穿透

如果说查询系统不存在的数据可能是用户无意的操作,要是明知道搜索的内存不存在还不断的请求,那么这种就属于恶意的攻击了,这种恶意的请求通常是有目的性的通过频繁的随机请求系统不存在的数据,造成请求穿透到数据库,从而导致系统风险,这种情况下如果我们就算缓存null 值也会有风险,因为这种请求通常是随机请求,每次查询的数据标识都有可能不同。

解决方案: 这种情况我们只能把所有数据库存在的ID,如果查询是根据文章ID查询的话,那就缓存所有文章的ID。事先把系统存对应文章的数据ID缓存到缓存中去,当请求过进入系统,先从这个缓存数据里判断系统是否存在对应的数据ID,如果不存在的话直接返回出去,避免请求进入到数据库层,当然我们不能简单把所有数据直接保存起来,常用的方式是采用“布隆过滤器”的算法来保存这些数据,采用布隆过滤器不仅在存储成本还是在判断效率上都会比我们直接存储数据要高得多。



Nosql

相对于传统数据库,非关系型数据库通常要比关系型数据库性能要快、而且变更方便,对内容格式要求宽松,增加删除内容属性不需像数据一样同步修改数据库的表结构。但是关系型数据库完善的事务机制,使得关系型数据库又是必须的,所以Nosql通常作为对关系型数据库的补充,在一些特定场景使用Nosql能让系统性能得到很大的改善,常用的NoSql数据库 有键值数据库、文档型数据库、列族数据库几种类型。

对于一些关系简单的热点数据我们可以使用redis这种键值数据库来存储。对于一些数据量大,数据格式多样化对查询性能又有要求的场景我们可以使用向MogoDB文档型的数据库来存储,而对于一些有全文搜索类的需求的我们可以使用Elasticsearch来满足搜索的需求。