毫不夸张的说咱们后端工程师,无论在哪家公司,呆在哪个团队,做哪个系统,遇到的第一个让人头疼的问题绝对是数据库性能问题。如果我们有一套成熟的方法论,能让大家快速、准确的去选择出合适的优化方案,我相信能够快速准备解决咱么日常遇到的80%甚至90%的性能问题。
从解决问题的角度出发,我们得先了解到问题的原因;其次我们得有一套思考、判断问题的流程方式,让我们合理的站在哪个层面选择方案;最后从众多的方案里面选择一个适合的方案进行解决问题,找到一个合适的方案的前提是我们自己对各种方案之间的优缺点、场景有足够的了解,没有一个方案是完全可以通吃通用的,软件工程没有银弹。
下文是我工作多年以来,曾经使用过的八大方案,结合了平常自己学习收集的一些资料,以系统、全面的方式整理成了这篇博文,也希望能让一些有需要的同行在工作上、成长上提供一定的帮助。
为什么数据库会慢?
无论是关系型数据库还是 NoSQL,任何存储系统决定于其查询性能的主要有三种:
-
查找的时间复杂度
-
数据总量
-
高负载
而决定于查找时间复杂度主要有两个因素:
-
查找算法
-
存储数据结构
无论是哪种存储,数据量越少,自然查询性能就越高,随着数据量增多,资源的消耗(CPU、磁盘读写繁忙)、耗时也会越来越高。
从关系型数据库角度出发,索引结构基本固定是B+Tree,时间复杂度是O(log n),存储结构是行式存储。因此咱们对于关系数据库能优化的一般只有数据量。
而高负载造成原因有高并发请求、复杂查询等,导致CPU、磁盘繁忙等,而服务器资源不足则会导致慢查询等问题。
该类型问题一般会选择集群、数据冗余的方式分担压力。
应该站在哪个层面思考优化?
从上图可见,自顶向下的一共有四层,分别是硬件、存储系统、存储结构、具体实现。
层与层之间是紧密联系的,每一层的上层是该层的载体;因此越往顶层越能决定性能的上限,同时优化的成本也相对会比较高,性价比也随之越低。
以最底层的具体实现为例,那么索引的优化的成本应该是最小的,可以说加了索引后无论是 CPU 消耗还是响应时间都是立竿见影降低。
然而一个简单的语句,无论如何优化加索引也是有局限的,当在具体实现这层没有任何优化空间的时候就得往上一层【存储结构】思考,思考是否从物理表设计的层面出发优化(如分库分表、压缩数据量等),如果是文档型数据库得思考下文档聚合的结果。
如果在存储结构这层优化得没效果,得继续往再上一次进行考虑,是否关系型数据库应该不适合用在现在得业务场景?
如果要换存储,那么得换什么样的 NoSQL?
所以咱们优化的思路,出于性价比的优先考虑具体实现,实在没有优化空间了再往上一层考虑。当然如果公司有钱,直接使用钞能力,绕过了前面三层,这也是一种便捷的应急处理方式。
该篇文章不讨论顶与底的两个层面的优化,主要从存储结构、存储系统中间两层的角度出发进行探讨。
八大方案总结
数据库的优化方案核心本质有三种:减少数据量、用空间换性能、选择合适的存储系统。
这也对应了开篇讲解的慢的三个原因:数据总量、高负载、查找的时间复杂度。
这里大概解释下收益类型:短期收益,处理成本低,能紧急应对,久了则会有技术债务;长期收益则跟短期收益相反,短期内处理成本高,但是效果能长久使用,扩展性会更好。
静态数据意思是,相对改动频率比较低的,也无需过多联表的,where 过滤比较少。动态数据与之相反,更新频率高,通过动态条件筛选过滤。
减少数据量
减少数据量类型共有四种方案:数据序列化存储、数据归档、中间表生成、分库分表。
就如上面所说的,无论是哪种存储,数据量越少,自然查询性能就越高,随着数据量增多,资源的消耗(CPU、磁盘读写繁忙)、耗时也会越来越高。
目前市面上的 NoSQL 基本上都支持分片存储,所以其天然分布式写的能力从数据量上能得到非常的解决方案。
而关系型数据库,查找算法与存储结构是可以优化的空间比较少,因此咱们一般思考出发点只有从如何减少数据量的这个角度进行选择优化,因此本类型的优化方案主要针对关系型数据库进行处理。
数据归档
注意点:别一次性迁移数量过多,建议低频率多次限量迁移。像MySQL由于删除数据后是不会释放空间的,可以执行命令OPTIMIZE TABLE释放存储空间,但是会锁表,如果存储空间还满足,可以不执行。
建议优先考虑该方案,主要通过数据库作业把非热点数据迁移到历史表,如果需要查历史数据,可新增业务入口路由到对应的历史表(库)。
在数据库以序列化存储的方式,对于一些不需要结构化存储的业务来说是一种很好减少数据量的方式,特别是对于一些M*N的数据量的业务场景,如果以M作为主表优化,那么就可以把数据量维持最多是M的量级。
另外像订单的地址信息,这种业务一般是不需要根据里面的字段检索出来,也比较适合。
这种方案我认为属于一种临时性的优化方案,无论是从序列化后丢失了部份字段的查询能力,还是这方案的可优化性都是有限的。
中间表(结果表)
中间表(结果表)其实就是利用调度任务把复杂查询的结果跑出来存储到一张额外的物理表,因为这张物理表存放的是通过跑批汇总后的数据,因此可以理解成根据原有的业务进行了高度的数据压缩。
以报表为例,如果一个月的源数据有数十万,我们通过调度任务以月的维度生成,那么等于把原有的数据压缩了几十万分之一。
接下来的季报和年报可以根据月报 *N 来进行统计,以这种方式处理的数据,就算三年、五年甚至十年数据量都可以在接受范围之内,而且可以精确计算得到。
那么数据的压缩比率是否越低越好?
下面有一段口诀:
-
字段越多,粒度越细,灵活性越高,可以以中间表进行不同业务联表处理。
-
字段越少,粒度越粗,灵活性越低,一般作为结果表查询出来。
数据序列化存储
分库分表
分库分表作为数据库优化的一种非常经典的优化方案,特别是在以前 NoSQL 还不是很成熟的年代,这个方案就如救命草一般的存在。
如今也有不少同行也会选择这种优化方式,但是从我角度来看,分库分表是一种优化成本很大的方案。这里我有几个建议:
-
分库分表是实在没有办法的办法,应放到最后选择。
-
优先选择 NoSQL 代替,因为 NoSQL 诞生基本上为了扩展性与高性能。
-
究竟分库还是分表?量大则分表,并发高则分库
-
不考虑扩容,一部做到位。因为技术更新太快了,每 3-5 年一大变。
拆分方式
只要涉及到这个拆,那么无论是微服务也好,分库分表也好,拆分的方式主要分两种:垂直拆分、水平拆分。
垂直拆分更多是从业务角度进行拆分,主要是为了降低业务耦合度;此外以 SQL Server 为例,一页是 8KB 存储,如果在一张表里字段越多,一行数据自然占的空间就越大,那么一页数据所存储的行数就自然越少,那么每次查询所需要 IO 则越高因此性能自然也越慢;因此反之,减少字段也能很好提高性能。
之前我听说某些同行的表有 80 个字段,几百万的数据就开始慢了。
水平拆分更多是从技术角度进行拆分,拆分后每张表的结构是一模一样的,简而言之就是把原有一张表的数据,通过技术手段进行分片到多张表存储,从根本上解决了数据量的问题。
路由方式
进行水平拆分后,根据分区键(sharding key)原来应该在同一张表的数据拆解写到不同的物理表里,那么查询也得根据分区键进行定位到对应的物理表从而把数据给查询出来。
路由方式一般有三种:区间范围、Hash、分片映射表。每种路由方式都有自己的优点和缺点,可以根据对应的业务场景进行选择。
区间范围根据某个元素的区间的进行拆分,以时间为例子,假如有个业务我们希望以月为单位拆分那么表就会拆分像 table_2022-04,这种对于文档型、ElasticSearch 这类型的 NoSQL 也适用,无论是定位查询,还是日后清理维护都是非常的方便的。那么缺点也明显,会因为业务独特性导致数据不平均,甚至不同区间范围之间的数据量差异很大。
Hash 也是一种常用的路由方式,根据 Hash 算法取模以数据量均匀分别存储在物理表里,缺点是对于带分区键的查询依赖特别强,如果不带分区键就无法定位到具体的物理表导致相关所有表都查询一次,而且在分库的情况下对于 Join、聚合计算、分页等一些 RDBMS 的特性功能还无法使用。
一般分区键就一个,假如有时候业务场景得用不是分区键的字段进行查询,那么难道就必须得全部扫描一遍?
其实可以使用分片映射表的方式。
简单来说就是额外有一张表记录额外字段与分区键的映射关系。
举个例子,有张订单表,原本是以 UserID 作为分区键拆分的,现在希望用 OrderID 进行查询,那么得有额外得一张物理表记录了 OrderID 与 UserID 的映射关系。因此得先查询一次映射表拿到分区键,再根据分区键的值路由到对应的物理表查询出来。
可能有些朋友会问,那这映射表是否多一个映射关系就多一张表,还是多个映射关系在同一张表?
我优先建议单独处理,如果说映射表字段过多,那跟不进行水平拆分时的状态其实就是一致的,这又跑回去的老问题。
用空间换性能
该类型的两个方案都是用来应对高负载的场景,方案有以下两种:分布式缓存、一主多从。
与其说这个方案叫用空间换性能,我认为用空间换资源更加贴切一些。因此两个方案的本质主要通数据冗余、集群等方式分担负载压力。
对于关系型数据库而言,因为他的ACID特性让它天生不支持写的分布式存储,但是它依然天然的支持分布式读。
分布式缓存
缓存层级可以分好几种:客户端缓存、API服务本地缓存和分布式缓存。
咱们这次只聊分布式缓存。一般我们选择分布式缓存系统都会优先选择 NoSQL 的键值型数据库,例如 Memcached、Redis,如今 Redis 的数据结构多样性,高性能,易扩展性也逐渐占据了分布式缓存的主导地位。
缓存策略也主要有很多种:Cache-Aside、Read/Wirte-Through、Write-Back,咱们用得比较多的方式主要 Cache-Aside,具体流程可看下图:
我相信大家对分布式缓存相对都比较熟悉了,但是我在这里还是有几个注意点希望提醒一下大家:
避免滥用缓存
缓存应该是按需使用,从 28 法则来看,80% 的性能问题由主要的 20% 的功能引起。
滥用缓存的后果会导致维护成本增大,而且有一些数据一致性的问题也不好定位。
特别像一些动态条件的查询或者分页,key 的组装是多样化的,量大又不好用 keys 指令去处理,当然我们可以用额外的一个 key 把记录数据的 key 以集合方式存储,删除时候做两次查询,先查 Key 的集合,然后再遍历 Key 集合把对应的内容删除。
这一顿操作下来无疑是非常废功夫的,谁弄谁知道。
避免缓存击穿
当缓存没有数据,就得跑去数据库查询出来,这就是缓存穿透。
假如某个时间临界点数据是空的例如周排行榜,穿透过去的无论查找多少次数据库仍然是空,而且该查询消耗 CPU 相对比较高,并发一进来因为缺少了缓存层的对高并发的应对,这个时候就会因为并发导致数据库资源消耗过高,这就是缓存击穿。
数据库资源消耗过高就会导致其他查询超时等问题。
该问题的解决方案也简单,对于查询到数据库的空结果也缓存起来,但是给一个相对快过期的时间。
有些同行可能又会问,这样不就会造成了数据不一致了么?
一般有数据同步的方案像分布式缓存、后续会说的一主多从、CQRS,只要存在数据同步这几个字,那就意味着会存在数据一致性的问题,因此如果使用上述方案,对应的业务场景应允许容忍一定的数据不一致。
不是所有慢查询都适用
一般来说,慢的查询都意味着比较吃资源的(CPU、磁盘I/O)。 举个例子,假如某个查询功能需要 3 秒时间,串行查询的时候并没什么问题,我们继续假设这功能每秒大概 QPS 为 100,那么在第一次查询结果返回之前,接下来的所有查询都应该穿透到数据库,也就意味着这几秒时间有 300 个请求到数据库,如果这个时候数据库 CPU 达到了 100%,那么接下来的所有查询都会超时,也就是无法有第一个查询结果缓存起来,从而还是形成了缓存击穿。
一主多从
常用的分担数据库压力还有一种常用做法,就是读写分离、一主多从。
咱们都是知道关系型数据库天生是不具备分布式分片存储的,也就是不支持分布式写,但是它天然的支持分布式读。
一主多从是部署多台从库只读实例,通过冗余主库的数据来分担读请求的压力,路由算法可有代码实现或者中间件解决,具体可以根据团队的运维能力与代码组件支持视情况选择。
一主多从在还没找到根治方案前是一个非常好的应急解决方案,特别是在现在云服务的年代,扩展从库是一件非常方便的事情,而且一般情况只需要运维或者 DBA 解决就行,无需开发人员接入。
当然这方案也有缺点,因为数据无法分片,所以主从的数据量完全冗余过去,也会导致高的硬件成本。
从库也有其上限,从库过多了会主库的多线程同步数据的压力。
选择合适的存储系统
NoSQL 主要以下五种类型:键值型、文档型、列型、图型、搜素引擎。
不同的存储系统直接决定了查找算法、存储数据结构,也应对了需要解决的不同的业务场景。
NoSQL的出现也解决了关系型数据库之前面临的难题(性能、高并发、扩展性等)。
例如,ElasticSearch 的查找算法是倒排索引,可以用来代替关系型数据库的低性能、高消耗的 Like 搜索(全表扫描)。而 Redis 的 Hash 结构决定了时间复杂度为 O(1),还有它的内存存储,结合分片集群存储方式以至于可以支撑数十万 QPS。
因此本类型的方案主要有两种:CQRS、替换(选择)存储,这两种方案的最终本质基本是一样的主要使用合适存储来弥补关系型数据库的缺点,只不过切换过渡的方式会有点不一样。
CQRS
CQS(命令查询分离)指同一个对象中作为查询或者命令的方法,每个方法或者返回的状态,要么改变状态,但不能两者兼备
讲解 CQRS 前得了解 CQRS,有些小伙伴看了估计还没不是很清晰,我这里用通俗的话解释:某个对象的数据访问的方法里,要么只是查询,要么只是写入(更新)。
而 CQRS(命令查询职责分离)基于 CQS 的基础上,用物理数据库来写入(更新),而用另外的存储系统来查询数据。
因此我们在某些业务场景进行存储架构设计时,可以通过关系型数据库的 ACID 特性进行数据的更新与写入,用 NoSQL 的高性能与扩展性进行数据的查询处理,这样的好处就是关系型数据库和 NoSQL 的优点都可以兼得,同时对于某些业务不适于一刀切的替换存储的也可以有一个平滑的过渡。
从代码实现角度来看,不同的存储系统只是调用对应的接口 API,因此 CQRS 的难点主要在于如何进行数据同步。
数据同步方式
一般讨论到数据同步的方式主要是分推和拉:
推指的是由数据变更端通过直接或者间接的方式把数据变更的记录发送到接收端,从而进行数据的一致性处理,这种主动的方式优点是实时性高。
拉指的是接收端定时的轮询数据库检查是否有数据需要进行同步,这种被动的方式从实现角度来看比推简单,因为推是需要数据变更端支持变更日志的推送的。
而推的方式又分两种:CDC(变更数据捕获)和领域事件。对于一些旧的项目来说,某些业务的数据入口非常多,无法完整清晰的梳理清楚,这个时候 CDC 就是一种非常好的方式,只要从最底层数据库层面把变更记录取到就可。
对于已经服务化的项目来说领域事件是一种比较舒服的方式,因为 CDC 是需要数据库额外开启功能或者部署额外的中间件,而领域事件则不需要,从代码可读性来看会更高,也比较开发人员的维护思维模式。
替换(选择)存储系统
因为从本质来看该模式与 CQRS 的核心本质是一样的,主要是要对 NoSQL 的优缺点有一个全面认识,这样才能在对应业务场景选择与判断出一个合适的存储系统。
这里我给大家介绍一本书马丁.福勒《NoSQL精粹》,这本书我重复看了好几遍,也很好全面介绍各种 NoSQL 优缺点和使用场景。
当然替换存储的时候,我这里也有个建议:加入一个中间版本,该版本做好数据同步与业务开关,数据同步要保证全量与增加的处理,随时可以重来。业务开关主要是为了后续版本的更新做的一个临时型的功能,主要避免后续版本更新不顺利或者因为版本更新时导致的数据不一致的情况出现
。在跑了一段时间后,验证了两个不同的存储系统数据是一致的后,接下来就可以把数据访问层的底层调用替换了。
如此一来就可以平滑的更新切换。
最后说一句
本文到这里就把八大方案介绍完了,这八个方案里,大部分都存在数据同步的情况,只要存在数据同步,无论是一主多从、分布式缓存、CQRS 都好,都会有数据一致性的问题导致,因此这些方案更多适合一些只读的业务场景。
当然有些写后既查的场景,可以通过过渡页或者广告页通过用户点击关闭切换页面的方式来缓解数据不一致性的情况。
最后,在这里再次提醒一句,每个方案都有属于它的应对场景,咱们只能根据业务场景选择对应的解决方案,没有通吃,没有银弹。