mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
mod
This commit is contained in:
145
极客时间专栏/后端存储实战课/高速增长篇/08 | 一个几乎每个系统必踩的坑儿:访问数据库超时.md
Normal file
145
极客时间专栏/后端存储实战课/高速增长篇/08 | 一个几乎每个系统必踩的坑儿:访问数据库超时.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<audio id="audio" title="08 | 一个几乎每个系统必踩的坑儿:访问数据库超时" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/7f/71c4e6d1e585326f93bfa777d3fb9e7f.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
每一个创业公司,它的系统随着公司的发展一起成长的过程中,都难免会发生一些故障或者是事故,严重的会影响业务。搞技术的同学管这个叫:坑儿,分析解决问题的过程,称为:填坑儿。而访问数据库超时这个坑儿,是我见过的被踩的次数最多的一个坑儿,并且这个坑儿还在被不停地踩来踩去。
|
||||
|
||||
今天这节课,我和你分享一个典型的数据库超时案例。我也希望你通过和我一起分析这个案例,一是,吸取其中的经验教训,日后不要再踩类似的坑儿;二是,如果遇到类似的问题,你能掌握分析方法,快速地解决问题。最重要的是,学习存储系统架构设计思想,在架构层面限制故障对系统的破坏程度。
|
||||
|
||||
## 事故排查过程
|
||||
|
||||
我们一起来看一下这个案例。
|
||||
|
||||
每一个做电商的公司都梦想着做社交引流,每一个做社交的公司都梦想着做电商将流量变现。我的一个朋友他们公司做社交电商,当年很有前途的一个创业方向,当时也是有很多创业公司在做。
|
||||
|
||||
有一天他找到我,让我帮他分析一下他们系统的问题。这个系统从圣诞节那天晚上开始,每天晚上固定十点多到十一点多这个时段,大概瘫痪一个小时左右的时间,过了这个时段系统自动就恢复了。系统瘫痪时的现象就是,网页和App都打不开,请求超时。
|
||||
|
||||
这个系统的架构是一个非常典型的小型创业公司的微服务架构。系统的架构如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/18/b7edc46baa597b4bd6a25ee5c744b318.png" alt="">
|
||||
|
||||
整个系统托管在公有云上,Nginx作为前置网关承接前端所有请求,后端按照业务,划分了若干个微服务分别部署。数据保存在MySQL中,部分数据用Memcached做了前置缓存。数据并没有按照微服务最佳实践的要求,做严格的划分和隔离,而是为了方便,存放在了一起。
|
||||
|
||||
这样的存储设计,对于一个业务变化极快的创业公司来说,是合理的。因为它的每个微服务,随时都在随着业务改变,如果做了严格的数据隔离,反而不利于应对需求变化。
|
||||
|
||||
听了我朋友对问题的描述,我的第一反应是,每天晚上十点到十一点这个时段,是绝大多数内容类App的访问量高峰,因为这个时候大家都躺在床上玩儿手机。初步判断,这个故障是和访问量有关系的,看下面这个系统每天的访问量的图,可以印证这个判断。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/f7/b6bd0e5d44075011680003338ff4bef7.png" alt="">
|
||||
|
||||
**基于这个判断,排查问题的重点应该放在那些服务于用户访问的功能上。**比如说,首页、商品列表页、内容推荐这些功能。
|
||||
|
||||
在访问量峰值的时候,请求全部超时,随着访问量减少,**系统能自动恢复,基本可以排除后台服务被大量请求打死的可能性**,因为如果进程被打死了,一般是不会自动恢复的。排查问题的重点应该放在MySQL上。观察下面这个MySQL的CPU利用率图,发现问题:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/d7/c73f64774a451cc6ce74d6b99535f0d7.png" alt="">
|
||||
|
||||
从监控图上可以看出来,故障时段MySQL的CPU利用率一直是100%。这种情况下,MySQL基本上处于一个不可用的状态,执行所有的SQL都会超时。
|
||||
|
||||
MySQL这种CPU利用率高的现象,绝大多数情况都是由慢SQL导致的,所以我们优先排查慢SQL。MySQL和各大云厂商提供的RDS都能提供慢SQL日志,分析慢SQL日志,是查找类似问题原因最有效的方法。
|
||||
|
||||
一般来说,慢SQL的日志中,会有这样一些信息:SQL、执行次数、执行时长。通过分析慢SQL找问题,并没有什么标准的方法,主要还是依靠经验。
|
||||
|
||||
首先,你需要知道的一点是,当数据库非常忙的时候,它执行任何一个SQL都很慢。所以,并不是说,慢SQL日志中记录的这些慢SQL都是有问题的SQL。大部分情况下,导致问题的SQL只是其中的一条或者几条。不能简单地依据执行次数和执行时长进行判断,但是,单次执行时间特别长的SQL,仍然是应该重点排查的对象。
|
||||
|
||||
通过分析这个系统的慢SQL日志,首先找到了一个特别慢的SQL。
|
||||
|
||||
这个SQL支撑的功能是一个红人排行榜,这个排行榜列出粉丝数最多的TOP10红人。
|
||||
|
||||
```
|
||||
select fo.FollowId as vid, count(fo.id) as vcounts
|
||||
from follow fo, user_info ui
|
||||
where fo.userid = ui.userid
|
||||
and fo.CreateTime between
|
||||
str_to_date(?, '%Y-%m-%d %H:%i:%s')
|
||||
and str_to_date(?, '%Y-%m-%d %H:%i:%s')
|
||||
and fo.IsDel = 0
|
||||
and ui.UserState = 0
|
||||
group by vid
|
||||
order by vcounts desc
|
||||
limit 0,10
|
||||
|
||||
```
|
||||
|
||||
**这种排行榜的查询,一定要做缓存**。在这个案例中,排行榜是新上线的功能,可能忘记做缓存了,通过增加缓存可以有效地解决问题。
|
||||
|
||||
给排行榜增加了缓存后,新版本立即上线。本以为问题就此解决了,结果当天晚上,系统仍然是一样的现象,晚高峰各种请求超时,页面打不开。
|
||||
|
||||
再次分析慢SQL日志,排行榜的慢SQL不见了,说明缓存生效了。日志中的其他慢SQL,查询次数和查询时长分布的都很均匀,也没有看出明显写的有问题的SQL。
|
||||
|
||||
回过头来再看MySQL CPU利用率这个图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/1e/c330355300eca211e5b1fad50709e91e.png" alt="">
|
||||
|
||||
把这个图放大后,发现一些规律:
|
||||
|
||||
1. CPU利用率,以20分钟为周期,非常规律的波动;
|
||||
1. 总体的趋势与访问量正相关。
|
||||
|
||||
那我们是不是可以猜测一下,对MySQL的CPU利用率的“贡献”来自两部分:红线以下的部分,是正常处理日常访问请求的部分,它和访问量是正相关的。红线以上的部分,来自某一个以20分钟为周期的定时任务,和访问量关系不大。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/2f/2ebd674e2f5ef41065ca8eb3589eb62f.png" alt="">
|
||||
|
||||
排查整个系统,没有发现有以20分钟为周期的定时任务,继续扩大排查范围,排查周期小于20分钟的定时任务,最终定位了问题。
|
||||
|
||||
App的首页聚合了非常多的内容,像精选商品、标题图、排行榜、编辑推荐等等。这些内容包含了很多的数据库查询。当初设计的时候,给首页做了一个整体的缓存,缓存的过期时间是10分钟。但是需求不断变化,首页需要查询的内容越来越多,导致查询首页的全部内容越来越慢。
|
||||
|
||||
通过检查日志发现,刷新一次缓存的时间竟然要15分钟。缓存是每隔10分钟整点刷一次,因为10分钟内刷不完,所以下次刷新就推迟到了20分钟之后,这就导致了上面这个图中,红线以上每20分钟的规律波形。
|
||||
|
||||
由于缓存刷新慢,也会很多请求无法命中缓存,请求直接穿透缓存打到了数据库上面,这部分请求给上图红线以下的部分,做了很多“贡献”。
|
||||
|
||||
找到了问题原因,做针对性的优化,问题很快就解决了。新版本上线之后,再没有出现过“午夜宕机”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/7f/6886630263c150d8af3b5a2ff97eb67f.png" alt="">
|
||||
|
||||
对比优化前后MySQL的CPU利用率,可以明显地看出优化效果。
|
||||
|
||||
## 如何避免悲剧重演
|
||||
|
||||
到这里问题的原因找到了,问题也圆满解决了。单从这个案例来看,问题的原因在于,开发人员犯了错误,编写的SQL没有考虑数据量和执行时间,缓存的使用也不合理。最终导致在忙时,大量的查询打到MySQL上,MySQL繁忙无法提供服务。
|
||||
|
||||
作为系统的开发人员,对于这次事故,我们可以总结两点经验:
|
||||
|
||||
第一,在编写SQL的时候,一定要小心谨慎地仔细评估。先问自己几个问题:
|
||||
|
||||
- 你的SQL涉及到的表,它的数据规模是多少?
|
||||
- 你的SQL可能会遍历的数据量是多少?
|
||||
- 尽量地避免写出慢SQL。
|
||||
|
||||
第二,能不能利用缓存减少数据库查询次数?在使用缓存的时候,还需要特别注意的就是缓存命中率,要尽量避免请求命中不了缓存,穿透到数据库上。
|
||||
|
||||
以上两点,是开发人员需要总结的问题。不过你想没想过,谁能保证,整个团队的所有开发人员以后不再犯错误?保证不了吧?那是不是这种的悲剧就无法避免了呢?
|
||||
|
||||
其实,还是有办法的。不然,那些大厂,几万开发人员,每天会上线无数的Bug,系统还不得天天宕机?而实际情况是,大厂的系统都是比较稳定的,基本上不会出现全站无法访问这种情况。
|
||||
|
||||
靠的是什么?靠的是架构。
|
||||
|
||||
优秀的系统架构,可以在一定程度上,减轻故障对系统的影响。针对这次事故,我给这个系统在架构层面,提了两个改进的建议。
|
||||
|
||||
第一个建议是,上线一个定时监控和杀掉慢SQL的脚本。这个脚本每分钟执行一次,检测上一分钟内,有没有执行时间超过一分钟(这个阈值可以根据实际情况调整)的慢SQL,如果发现,直接杀掉这个会话。
|
||||
|
||||
这样可以有效地避免一个慢SQL拖垮整个数据库的悲剧。即使出现慢SQL,数据库也可以在至多1分钟内自动恢复,避免数据库长时间不可用。代价是,可能会有些功能,之前运行是正常的,这个脚本上线后,就会出现问题。但是,这个代价还是值得付出的,并且,可以反过来督促开发人员更加小心,避免写出慢SQL。
|
||||
|
||||
第二个建议是,做一个简单的静态页面的首页作为降级方案,只要包含商品搜索栏、大的品类和其他顶级功能模块入口的链接就可以了。在Nginx上做一个策略,如果请求首页数据超时的时候,直接返回这个静态的首页作为替代。这样后续即使首页再出现任何的故障,也可以暂时降级,用静态首页替代。至少不会影响到用户使用其他功能。
|
||||
|
||||
这两个改进建议都是非常容易实施的,不需要对系统做很大的改造,并且效果也立竿见影。
|
||||
|
||||
当然,这个系统的存储架构还有很多可以改进的地方,比如说对数据做适当的隔离,改进缓存置换策略,做数据库主从分离,把非业务请求的数据库查询迁移到单独的从库上等等,只是这些改进都需要对系统做比较大的改动升级,需要从长计议,在系统后续的迭代过程中逐步地去实施。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我和你一起分析了一个由于慢SQL导致的全站故障的案例。在“破案”的过程中,有一些很有用的经验,这些经验对于后续你自己“破案”时会非常有用。比如说:
|
||||
|
||||
1. 根据故障时段在系统忙时,推断出故障是跟支持用户访问的功能有关。
|
||||
1. 根据系统能在流量峰值过后自动恢复这一现象,排除后台服务被大量请求打死的可能性。
|
||||
1. 根据CPU利用率曲线的规律变化,推断出可能和定时任务有关。
|
||||
|
||||
在故障复盘阶段,除了对故障问题本身做有针对性的预防和改进以外,更重要的是,在系统架构层面进行改进,让整个系统更加健壮,不至于因为某一个小的失误,就导致全站无法访问。
|
||||
|
||||
我给系统提出的第一个自动杀慢SQL的建议,它的思想是:系统的关键部分要有自我保护机制,避免外部的错误影响到系统的关键部分。第二个首页降级的建议,它的思想是:当关键系统出现故障的时候,要有临时的降级方案,尽量减少故障带来的影响。
|
||||
|
||||
这些架构上的改进,虽然并不能避免故障,但是可以很大程度上减小故障的影响范围,减轻故障带来的损失,希望你能仔细体会,活学活用。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你想一下,以你个人的标准,什么样的SQL算是慢SQL?如何才能避免写出慢SQL?欢迎你在留言区与我交流互动。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对工作有所帮助,也欢迎把它分享给你的朋友。
|
||||
92
极客时间专栏/后端存储实战课/高速增长篇/09 | 怎么能避免写出慢SQL?.md
Normal file
92
极客时间专栏/后端存储实战课/高速增长篇/09 | 怎么能避免写出慢SQL?.md
Normal file
@@ -0,0 +1,92 @@
|
||||
<audio id="audio" title="09 | 怎么能避免写出慢SQL?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/71/1e127a220e7d7c6a4f20e3c17b4ef871.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
通过上节课的案例,我们知道,一个慢SQL就可以直接让MySQL瘫痪。今天这节课,我们一起看一下,怎么才能避免写出危害数据库的慢SQL。
|
||||
|
||||
所谓慢SQL,就是执行特别慢的SQL语句。什么样的SQL语句是慢SQL?多慢才算是慢SQL?并没有一个非常明确的标准或者说是界限。但并不是说,我们就很难区分正常的SQL和慢SQL,在大多数实际的系统中,慢SQL消耗掉的数据库资源,往往是正常SQL的几倍、几十倍甚至几百倍,所以还是非常容易区分的。
|
||||
|
||||
但问题是,我们不能等着系统上线,慢SQL吃光数据库资源之后,再找出慢SQL来改进,那样就晚了。那么,怎样才能在开发阶段尽量避免写出慢SQL呢?
|
||||
|
||||
## 定量认识MySQL
|
||||
|
||||
我们回顾一下上节课的案例,那个系统第一次全站宕机发生在圣诞节平安夜,故障之前的一段时间,系统并没有更新过版本,这个时候,其实慢SQL已经存在了,直到平安夜那天,访问量的峰值比平时增加一些,正是增加的这部分访问量,引发了数据库的雪崩。
|
||||
|
||||
这说明,**慢SQL对数据库的影响,是一个量变到质变的过程,对“量”的把握,就很重要**。作为一个合格的程序员,你需要对数据库的能力,有一个定量的认识。
|
||||
|
||||
影响MySQL处理能力的因素很多,比如:服务器的配置、数据库中的数据量大小、MySQL的一些参数配置、数据库的繁忙程度等等。但是,通常情况下,这些因素对于MySQL性能和处理能力影响范围,大概在几倍的性能差距。所以,我们不需要精确的性能数据,只要掌握一个大致的量级,就足够指导我们的开发工作了。
|
||||
|
||||
一台MySQL数据库,大致处理能力的极限是,每秒一万条左右的简单SQL,这里的“简单SQL”,指的是类似于主键查询这种不需要遍历很多条记录的SQL。根据服务器的配置高低,可能低端的服务器只能达到每秒几千条,高端的服务器可以达到每秒钟几万条,所以这里给出的一万TPS是中位数的经验值。考虑到正常的系统不可能只有简单SQL,所以实际的TPS还要打很多折扣。
|
||||
|
||||
我的经验数据,一般一台MySQL服务器,平均每秒钟执行的SQL数量在几百左右,就已经是非常繁忙了,即使看起来CPU利用率和磁盘繁忙程度没那么高,你也需要考虑给数据库“减负”了。
|
||||
|
||||
另外一个重要的定量指标是,到底多慢的SQL才算慢SQL。这里面这个“慢”,衡量的单位本来是执行时长,但是时长这个东西,我们在编写SQL的时候并不好去衡量。那我们可以用执行SQL查询时,需要遍历的数据行数替代时间作为衡量标准,因为查询的执行时长基本上是和遍历的数据行数正相关的。
|
||||
|
||||
你在编写一条查询语句的时候,可以依据你要查询数据表的数据总量,估算一下这条查询大致需要遍历多少行数据。如果遍历行数在百万以内的,只要不是每秒钟都要执行几十上百次的频繁查询,可以认为是安全的。遍历数据行数在几百万的,查询时间最少也要几秒钟,你就要仔细考虑有没有优化的办法。遍历行数达到千万量级和以上的,我只能告诉你,这种查询就不应该出现在你的系统中。当然我们这里说的都是在线交易系统,离线分析类系统另说。
|
||||
|
||||
遍历行数在千万左右,是MySQL查询的一个坎儿。MySQL中单个表数据量,也要尽量控制在一千万条以下,最多不要超过二三千万这个量级。原因也很好理解,对一个千万级别的表执行查询,加上几个WHERE条件过滤一下,符合条件的数据最多可能在几十万或者百万量级,这还可以接受。但如果再和其他的表做一个联合查询,遍历的数据量很可能就超过千万级别了。所以,每个表的数据量最好小于千万级别。
|
||||
|
||||
如果数据库中的数据量就是很多,而且查询业务逻辑就需要遍历大量数据怎么办?
|
||||
|
||||
## 使用索引避免全表扫描
|
||||
|
||||
使用索引可以有效地减少执行查询时遍历数据的行数,提高查询性能。
|
||||
|
||||
数据库索引的原理也很简单,我举个例子你就明白了。比如说,有一个无序的数组,数组的每个元素都是一个用户对象。如果说我们要把所有姓李的用户找出来。比较笨的办法是,用一个循环把数组遍历一遍。
|
||||
|
||||
有没有更好的办法?很多办法是吧?比如说,我们用一个Map(在有些编程语言中是Dictionary)来给数组做一个索引,Key保存姓氏,值是所有这个姓氏的用户对象在数组中序号的集合。这样再查找的时候,就不用去遍历数组,先在Map中查找,然后再直接用序号去数组中拿用户数据,这样查找速度就快多了。
|
||||
|
||||
这个例子对应到数据库中,存放用户数据的数组就是表,我们构建的Map就是索引。实际上数据库的索引,和编程语言中的Map或者Dictionary,它们的数据结构都是差不多的,基本上就是各种B树和HASH表。
|
||||
|
||||
绝大多数情况下,我们编写的查询语句,都应该使用索引,避免去遍历整张表,也就是通常说的,避免全表扫描。你在每次开发新功能,需要给数据库增加一个新的查询时,都要评估一下,是不是有索引可以支撑新的查询语句,如果有必要的话,需要新建索引来支持新增的查询。
|
||||
|
||||
但是,增加索引付出的代价是,会降低数据插入、删除和更新的性能。这个也很好理解,增加了索引,在数据变化的时候,不仅要变更数据表里的数据,还要去变更每个索引。所以,对于更新频繁并且对更新性能要求较高的表,可以尽量少建索引。而对于查询较多更新较少的表,可以根据查询的业务逻辑,适当多建一些索引。
|
||||
|
||||
怎么写SQL能更好地使用索引,查询效率更高,这是一门手艺,需要丰富的经验,不是通过一节课的学习能练成的。但是,我们是有方法,可以评估写出来的SQL的查询性能怎么样,是不是一个潜在的“慢SQL”。
|
||||
|
||||
逻辑不是很复杂的单表查询,我们可能还可以分析出来,查询会使用哪个索引。但如果是比较复杂的多表联合查询,我们单看SQL语句本身,就很难分析出查询到底会命中哪些索引,会遍历多少行数据。MySQL和大部分数据库,都提供一个帮助我们分析查询功能:执行计划。
|
||||
|
||||
## 分析SQL执行计划
|
||||
|
||||
在MySQL中使用执行计划也非常简单,只要在你的SQL语句前面加上**EXPLAIN**关键字,然后执行这个查询语句就可以了。
|
||||
|
||||
举个例子说明,比如有一个用户表,包含用户ID、姓名、部门编号和状态这几个字段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/48/437d6d3fb610431cfb9044781a8faa48.png" alt="">
|
||||
|
||||
我们希望查询某个二级部门下的所有人,查询条件就是,部门代号以00028开头的所有人。下面这两个SQL,他们的查询结果是一样的,都满足要求,但是,哪个查询性能更好呢?
|
||||
|
||||
```
|
||||
SELECT * FROM user WHERE left(department_code, 5) = '00028';
|
||||
SELECT * FROM user WHERE department_code LIKE '00028%';
|
||||
|
||||
```
|
||||
|
||||
我们分别查看一下这两个SQL的执行计划:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/74/4b50e4e1192714379ff3a4697d02a774.png" alt="">
|
||||
|
||||
我带你一起来分析一下这两个SQL的执行计划。首先来看rows这一列,rows的含义就是,MySQL预估执行这个SQL可能会遍历的数据行数。第一个SQL遍历了四千多行,这就是整个User表的数据条数;第二个SQL只有8行,这8行其实就是符合条件的8条记录。显然第二个SQL查询性能要远远好于第一个SQL。
|
||||
|
||||
为什么第一个SQL需要全表扫描,第二个SQL只遍历了很少的行数呢?注意看type这一列,这一列表示这个查询的访问类型。ALL代表全表扫描,这是最差的情况。range代表使用了索引,在索引中进行范围查找,因为第二个SQL语句的WHERE中有一个LIKE的查询条件。如果直接命中索引,type这一列显示的是index。如果使用了索引,可以在key这一列中看到,实际上使用了哪个索引。
|
||||
|
||||
通过对比这两个SQL的执行计划,就可以看出来,第二个SQL虽然使用了普遍认为低效的LIKE查询条件,但是仍然可以用到索引的范围查找,遍历数据的行数远远少于第一个SQL,查询性能更好。
|
||||
|
||||
## 小结
|
||||
|
||||
在开发阶段,衡量一个SQL查询语句查询性能的手段是,估计执行SQL时需要遍历的数据行数。遍历行数在百万以内,可以认为是安全的SQL,百万到千万这个量级则需要仔细评估和优化,千万级别以上则是非常危险的。为了减少慢SQL的可能性,每个数据表的行数最好控制在千万以内。
|
||||
|
||||
索引可以显著减少查询遍历数据的数量,所以提升SQL查询性能最有效的方式就是,让查询尽可能多的命中索引,但索引也是一把双刃剑,它在提升查询性能的同时,也会降低数据更新的性能。
|
||||
|
||||
对于复杂的查询,最好使用SQL执行计划,事先对查询做一个分析。在SQL执行计划的结果中,可以看到查询预估的遍历行数,命中了哪些索引。执行计划也可以很好地帮助你优化你的查询语句。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你想一下,在讲解SQL执行计划那个例子中的第一个SQL,为什么没有使用索引呢?
|
||||
|
||||
```
|
||||
SELECT * FROM user WHERE left(department_code, 5) = '00028';
|
||||
|
||||
```
|
||||
|
||||
欢迎你在留言区与我讨论,如果你觉得今天学到的知识对你有帮助,也欢迎把它分享给你的朋友。
|
||||
149
极客时间专栏/后端存储实战课/高速增长篇/10 | 走进黑盒:SQL是如何在数据库中执行的?.md
Normal file
149
极客时间专栏/后端存储实战课/高速增长篇/10 | 走进黑盒:SQL是如何在数据库中执行的?.md
Normal file
@@ -0,0 +1,149 @@
|
||||
<audio id="audio" title="10 | 走进黑盒:SQL是如何在数据库中执行的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/25/48534d44b998b341b746b78961120925.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
上一节课我们讲了怎么来避免写出慢SQL,课后我给你留了一道思考题:在下面这两个SQL中,为什么第一个SQL在执行的时候无法命中索引呢?
|
||||
|
||||
```
|
||||
SELECT * FROM user WHERE left(department_code, 5) = '00028';
|
||||
SELECT * FROM user WHERE department_code LIKE '00028%';
|
||||
|
||||
```
|
||||
|
||||
原因是,这个SQL的WHERE条件中对department_code这个列做了一个left截取的计算,对于表中的每一条数据,都得先做截取计算,然后判断截取后的值,所以不得不做全表扫描。你在写SQL的时候,尽量不要在WEHER条件中,对列做任何计算。
|
||||
|
||||
到这里这个问题就结束了么?那我再给你提一个问题,这两个SQL中的WHERE条件,虽然写法不一样,但它俩的语义不就是一样的么?是不是都可以解释成:department_code这一列前5个字符是00028?从语义上来说,没有任何不同是吧?所以,它们的查询结果也是完全一样的。那凭什么第一条SQL就得全表扫描,第二条SQL就可以命中索引?
|
||||
|
||||
对于我们日常编写SQL的一些优化方法,比如说我刚刚讲的:“尽量不要在WEHER条件中,对列做计算”,很多同学只是知道这些方法,但是却不知道,为什么按照这些方法写出来的SQL就快?
|
||||
|
||||
要回答这些问题,需要了解一些数据库的实现原理。对很多开发者来说,数据库就是个黑盒子,你会写SQL,会用数据库,但不知道盒子里面到底是怎么一回事儿,这样你只能机械地去记住别人告诉你的那些优化规则,却不知道为什么要遵循这些规则,也就谈不上灵活运用。
|
||||
|
||||
今天这节课,我带你一起打开盒子看一看,SQL是如何在数据库中执行的。
|
||||
|
||||
数据库是一个非常非常复杂的软件系统,我会尽量忽略复杂的细节,用简单的方式把最主要的原理讲给你。即使这样,这节课的内容仍然会非常的硬核,你要有所准备。
|
||||
|
||||
数据库的服务端,可以划分为**执行器(Execution Engine)** 和 **存储引擎(Storage Engine)** 两部分。执行器负责解析SQL执行查询,存储引擎负责保存数据。
|
||||
|
||||
## SQL是如何在执行器中执行的?
|
||||
|
||||
我们通过一个例子来看一下,执行器是如何来解析执行一条SQL的。
|
||||
|
||||
```
|
||||
SELECT u.id AS user_id, u.name AS user_name, o.id AS order_id
|
||||
FROM users u INNER JOIN orders o ON u.id = o.user_id
|
||||
WHERE u.id > 50
|
||||
|
||||
```
|
||||
|
||||
这个SQL语义是,查询用户ID大于50的用户的所有订单,这是很简单的一个联查,需要查询users和orders两张表,WHERE条件就是,用户ID大于50。
|
||||
|
||||
数据库收到查询请求后,需要先解析SQL语句,把这一串文本解析成便于程序处理的结构化数据,这就是一个通用的语法解析过程。跟编程语言的编译器编译时,解析源代码的过程是完全一样的。如果是计算机专业的同学,你上过的《编译原理》这门课,其中很大的篇幅是在讲解这一块儿。没学过《编译原理》的同学也不用担心,你暂时先不用搞清楚,SQL文本是怎么转换成结构化数据的,不妨碍你学习和理解这节课下面的内容。
|
||||
|
||||
转换后的结构化数据,就是一棵树,这个树的名字叫抽象语法树(AST,Abstract Syntax Tree)。上面这个SQL,它的AST大概是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/5b/651cf39892c7ab057b0d7b3c6a93d85b.png" alt="">
|
||||
|
||||
这个树太复杂,我只画了主要的部分,你大致看一下,能理解这个SQL的语法树长什么样就行了。执行器解析这个AST之后,会生成一个逻辑执行计划。所谓的执行计划,可以简单理解为如何一步一步地执行查询和计算,最终得到执行结果的一个分步骤的计划。这个逻辑执行计划是这样的:
|
||||
|
||||
```
|
||||
LogicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
|
||||
LogicalFilter(condition=[$0 > 50])
|
||||
LogicalJoin(condition=[$0 == $6], joinType=[inner])
|
||||
LogicalTableScan(table=[users])
|
||||
LogicalTableScan(table=[orders])
|
||||
|
||||
```
|
||||
|
||||
和SQL、AST不同的是,这个逻辑执行计划已经很像可以执行的程序代码了。你看上面这个执行计划,很像我们编程语言的函数调用栈,外层的方法调用内层的方法。所以,要理解这个执行计划,得从内往外看。
|
||||
|
||||
1. 最内层的2个LogicalTableScan的含义是,把USERS和ORDERS这两个表的数据都读出来。
|
||||
1. 然后拿这两个表所有数据做一个LogicalJoin,JOIN的条件就是第0列(u.id)等于第6列(o.user_id)。
|
||||
1. 然后再执行一个LogicalFilter过滤器,过滤条件是第0列(u.id)大于50。
|
||||
1. 最后,做一个LogicalProject投影,只保留第0(user_id)、1(user_name)、5(order_id)三列。这里“投影(Project)”的意思是,把不需要的列过滤掉。
|
||||
|
||||
把这个逻辑执行计划翻译成代码,然后按照顺序执行,就可以正确地查询出数据了。但是,按照上面那个执行计划,需要执行2个全表扫描,然后再把2个表的所有数据做一个JOIN操作,这个性能是非常非常差的。
|
||||
|
||||
我们可以简单算一下,如果,user表有1,000条数据,订单表里面有10,000条数据,这个JOIN操作需要遍历的行数就是1,000 x 10,000 = 10,000,000行。可见,这种从SQL的AST直译过来的逻辑执行计划,一般性能都非常差,所以,需要对执行计划进行优化。
|
||||
|
||||
如何对执行计划进行优化,不同的数据库有不同的优化方法,这一块儿也是不同数据库性能有差距的主要原因之一。优化的总体思路是,在执行计划中,尽早地减少必须处理的数据量。也就是说,尽量在执行计划的最内层减少需要处理的数据量。看一下简单优化后的逻辑执行计划:
|
||||
|
||||
```
|
||||
LogicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
|
||||
LogicalJoin(condition=[$0 == $6], joinType=[inner])
|
||||
LogicalProject(id=[$0], name=[$1]) // 尽早执行投影
|
||||
LogicalFilter(condition=[$0 > 50]) // 尽早执行过滤
|
||||
LogicalTableScan(table=[users])
|
||||
LogicalProject(id=[$0], user_id=[$1]) // 尽早执行投影
|
||||
LogicalTableScan(table=[orders])
|
||||
|
||||
```
|
||||
|
||||
对比原始的逻辑执行计划,这里我们做了两点简单的优化:
|
||||
|
||||
1. 尽早地执行投影,去除不需要的列;
|
||||
1. 尽早地执行数据过滤,去除不需要的行。
|
||||
|
||||
这样,就可以在做JOIN之前,把需要JOIN的数据尽量减少。这个优化后的执行计划,显然会比原始的执行计划快很多。
|
||||
|
||||
到这里,执行器只是在逻辑层面分析SQL,优化查询的执行逻辑,我们执行计划中操作的数据,仍然是表、行和列。在数据库中,表、行、列都是逻辑概念,所以,这个执行计划叫“逻辑执行计划”。执行查询接下来的部分,就需要涉及到数据库的物理存储结构了。
|
||||
|
||||
## SQL是如何在存储引擎中执行的?
|
||||
|
||||
数据真正存储的时候,无论在磁盘里,还是在内存中,都没法直接存储这种带有行列的二维表。数据库中的二维表,实际上是怎么存储的呢?这就是存储引擎负责解决的问题,存储引擎主要功能就是把逻辑的表行列,用合适的物理存储结构保存到文件中。不同的数据库,它们的物理存储结构是完全不一样的,这也是各种数据库之间巨大性能差距的根本原因。
|
||||
|
||||
我们还是以MySQL为例来说一下它的物理存储结构。MySQL非常牛的一点是,它在设计层面对存储引擎做了抽象,它的存储引擎是可以替换的。它默认的存储引擎是InnoDB,在InnoDB中,数据表的物理存储结构是以主键为关键字的B+树,每一行数据直接就保存在B+树的叶子节点上。比如,上面的订单表组织成B+树,是这个样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/e6/41bb301944e65e1585b238d26717e5e6.png" alt="">
|
||||
|
||||
这个树以订单表的主键orders.id为关键字组织,其中“62:[row data]”,表示的是订单号为62的一行订单数据。在InnoDB中,表的索引也是以B+树的方式来存储的,和存储数据的B+树的区别是,在索引树中,叶子节点保存的不是行数据,而是行的主键值。
|
||||
|
||||
如果通过索引来检索一条记录,需要先后查询索引树和数据树这两棵树:先在索引树中检索到行记录的主键值,然后再用主键值去数据树中去查找这一行数据。
|
||||
|
||||
简单了解了存储引擎的物理存储结构之后,我们回过头来继续看SQL是怎么在存储引擎中继续执行的。优化后的逻辑执行计划将会被转换成物理执行计划,物理执行计划是和数据的物理存储结构相关的。还是用InnoDB来举例,直接将逻辑执行计划转换为物理执行计划:
|
||||
|
||||
```
|
||||
InnodbProject(user_id=[$0], user_name=[$1], order_id=[$5])
|
||||
InnodbJoin(condition=[$0 == $6], joinType=[inner])
|
||||
InnodbTreeNodesProject(id=[key], name=[data[1]])
|
||||
InnodbFilter(condition=[key > 50])
|
||||
InnodbTreeScanAll(tree=[users])
|
||||
InnodbTreeNodesProject(id=[key], user_id=[data[1]])
|
||||
InnodbTreeScanAll(tree=[orders])
|
||||
|
||||
```
|
||||
|
||||
物理执行计划同样可以根据数据的物理存储结构、是否存在索引以及数据多少等各种因素进行优化。这一块儿的优化规则同样是非常复杂的,比如,我们可以把对用户树的全树扫描再按照主键过滤这两个步骤,优化为对树的范围查找。
|
||||
|
||||
```
|
||||
PhysicalProject(user_id=[$0], user_name=[$1], order_id=[$5])
|
||||
PhysicalJoin(condition=[$0 == $6], joinType=[inner])
|
||||
InnodbTreeNodesProject(id=[key], name=[data[1]])
|
||||
InnodbTreeRangeScan(tree=[users], range=[key > 50]) // 全树扫描再按照主键过滤,直接可以优化为对树的范围查找
|
||||
InnodbTreeNodesProject(id=[key], user_id=[data[1]])
|
||||
InnodbTreeScanAll(tree=[orders])
|
||||
|
||||
```
|
||||
|
||||
最终,按照优化后的物理执行计划,一步一步地去执行查找和计算,就可以得到SQL的查询结果了。
|
||||
|
||||
理解数据库执行SQL的过程,以及不同存储引擎中的数据和索引的物理存储结构,对于正确使用和优化SQL非常有帮助。
|
||||
|
||||
比如,我们知道了InnoDB的索引实现后,就很容易明白为什么主键不能太长,因为表的每个索引保存的都是主键的值,过长的主键会导致每一个索引都很大。再比如,我们了解了执行计划的优化过程后,就很容易理解,有的时候明明有索引却不能命中的原因是,数据库在对物理执行计划优化的时候,评估发现不走索引,直接全表扫描是更优的选择。
|
||||
|
||||
回头再来看一下这节课开头的那两条SQL,为什么一个不能命中索引,一个能命中?原因是InnoDB对物理执行计划进行优化的时候,能识别LIKE这种过滤条件,转换为对索引树的范围查找。而对第一条SQL这种写法,优化规则就没那么“智能”了。
|
||||
|
||||
它并没有识别出来,这个条件同样可以转换为对索引树的范围查找,而走了全表扫描。并不是说第一个SQL写的不好,而是数据库还不够智能。那现实如此,我们能做的就是尽量了解数据库的脾气秉性,按照它现有能力,尽量写出它能优化好的SQL。
|
||||
|
||||
## 小结
|
||||
|
||||
一条SQL在数据库中执行,首先SQL经过语法解析成AST,然后AST转换为逻辑执行计划,逻辑执行计划经过优化后,转换为物理执行计划,再经过物理执行计划优化后,按照优化后的物理执行计划执行完成数据的查询。几乎所有的数据库,都是由**执行器**和**存储引擎**两部分组成,执行器负责执行计算,存储引擎负责保存数据。
|
||||
|
||||
掌握了查询的执行过程和数据库内部的组成,你才能理解那些优化SQL的规则,这些都有助于你更好理解数据库行为,更高效地去使用数据库。
|
||||
|
||||
最后需要说明的一点是,今天这节课所讲的内容,不只是适用于我们用来举例的MySQL,几乎所有支持SQL的数据库,无论是传统的关系型数据库、还是NoSQL、NewSQL这些新兴的数据库,无论是单机数据库还是分布式数据库,比如HBase、Elasticsearch和SparkSQL等等这些数据库,它们的实现原理也都符合我们今天这节课所讲的内容。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你选一种你熟悉的**非关系型数据库**,最好是支持SQL的,当然,不支持SQL有自己的查询语言也可以。比如说HBase、Redis或者MongoDB等等都可以,尝试分析一下查询的执行过程,对比一下它的执行器和存储引擎与MySQL有什么不同。
|
||||
|
||||
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
86
极客时间专栏/后端存储实战课/高速增长篇/11 | MySQL如何应对高并发(一):使用缓存保护MySQL.md
Normal file
86
极客时间专栏/后端存储实战课/高速增长篇/11 | MySQL如何应对高并发(一):使用缓存保护MySQL.md
Normal file
@@ -0,0 +1,86 @@
|
||||
<audio id="audio" title="11 | MySQL如何应对高并发(一):使用缓存保护MySQL" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fc/d7/fcbe51f74b37a8bf88ca015c210dcad7.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
通过前面几节课的学习,相信你对MySQL这类关系型数据库的能力,已经有了定量的认知。
|
||||
|
||||
我们知道,大部分面向公众用户的互联网系统,它的并发请求数量是和在线用户数量正相关的,而MySQL能承担的并发读写的量是有上限的,当系统的在线用户超过几万到几十万这个量级的时候,单台MySQL就很难应付了。
|
||||
|
||||
绝大多数互联网系统,都使用MySQL加上Redis这对儿经典的组合来解决这个问题。Redis作为MySQL的前置缓存,可以替MySQL挡住绝大部分查询请求,很大程度上缓解了MySQL并发请求的压力。
|
||||
|
||||
Redis之所以能这么流行,非常重要的一个原因是,它的API非常简单,几乎没有太多的学习成本。但是,要想在生产系统中用好Redis和MySQL这对儿经典组合,并不是一件很简单的事儿。我在《[08 | 一个几乎每个系统必踩的坑儿:访问数据库超时](https://time.geekbang.org/column/article/211008)》举的社交电商数据库超时故障的案例,其中一个重要的原因就是,对缓存使用不当引发了缓存穿透,最终导致数据库被大量查询请求打死。
|
||||
|
||||
今天这节课,我们就来说一下,在电商的交易类系统中,如何正确地使用Redis这样的缓存系统,以及如何正确应对使用缓存过程中遇到的一些常见的问题。
|
||||
|
||||
## 更新缓存的最佳方式
|
||||
|
||||
要正确地使用好任何一个数据库,你都需要先了解它的能力和弱点,扬长避短。Redis是一个使用内存保存数据的高性能KV数据库,它的高性能主要来自于:
|
||||
|
||||
1. 简单的数据结构;
|
||||
1. 使用内存存储数据。
|
||||
|
||||
上节课我们讲到过,数据库可以分为执行器和存储引擎两部分,Redis的执行器这一层非常的薄,所以Redis只能支持有限的几个API,几乎没有聚合查询的能力,也不支持SQL。它的存储引擎也非常简单,直接在内存中用最简单的数据结构来保存数据,你从它的API中的数据类型基本就可以猜出存储引擎中数据结构。
|
||||
|
||||
比如,Redis的LIST在存储引擎的内存中的数据结构就是一个双向链表。内存是一种易失性存储,所以使用内存保存数据的Redis不能保证数据可靠存储。从设计上来说,Redis牺牲了大部分功能,牺牲了数据可靠性,换取了高性能。但也正是这些特性,使得Redis特别适合用来做MySQL的前置缓存。
|
||||
|
||||
虽然说,Redis支持将数据持久化到磁盘中,并且还支持主从复制,但你需要知道,**Redis仍然是一个不可靠的存储,它在设计上天然就不保证数据的可靠性**,所以一般我们都使用Redis做缓存,很少使用它作为唯一的数据存储。
|
||||
|
||||
即使只是把Redis作为缓存来使用,我们在设计Redis缓存的时候,也必须要考虑Redis的这种“数据不可靠性”,或者换句话说,我们的程序在使用Redis的时候,要能兼容Redis丢数据的情况,做到即使Redis发生了丢数据的情况,也不影响系统的数据准确性。
|
||||
|
||||
我们仍然用电商的订单系统来作为例子说明一下,如何正确地使用Redis做缓存。在缓存MySQL的一张表的时候,通常直接选用主键来作为Redis中的Key,比如缓存订单表,那就直接用订单表的主键订单号来作为Redis中的key。
|
||||
|
||||
如果说,Redis的实例不是给订单表专用的,还需要给订单的Key加一个统一的前缀,比如“orders:888888”。Value用来保存序列化后的整条订单记录,你可以选择可读性比较好的JSON作为序列化方式,也可以选择性能更好并且更节省内存的二进制序列化方式,都是可以的。
|
||||
|
||||
然后我们来说,缓存中的数据要怎么来更新的问题。我见过很多同学都是这么用缓存的:在查询订单数据的时候,先去缓存中查询,如果命中缓存那就直接返回订单数据。如果没有命中,那就去数据库中查询,得到查询结果之后把订单数据写入缓存,然后返回。在更新订单数据的时候,先去更新数据库中的订单表,如果更新成功,再去更新缓存中的数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/5e/c76155eaf8c6ac1e231d9bfb0e22ba5e.png" alt="">
|
||||
|
||||
这其实是一种经典的缓存更新策略: **Read/Write Through**。这样使用缓存的方式有没有问题?绝大多数情况下可能都没问题。但是,在并发的情况下,有一定的概率会出现“脏数据”问题,缓存中的数据可能会被错误地更新成了旧数据。
|
||||
|
||||
比如,对同一条订单记录,同时产生了一个读请求和一个写请求,这两个请求被分配到两个不同的线程并行执行,读线程尝试读缓存没命中,去数据库读到了订单数据,这时候可能另外一个读线程抢先更新了缓存,在处理写请求的线程中,先后更新了数据和缓存,然后,拿着订单旧数据的第一个读线程又把缓存更新成了旧数据。
|
||||
|
||||
这是一种情况,还有比如两个线程对同一个条订单数据并发写,也有可能造成缓存中的“脏数据”,具体流程类似于我在之前“[如何保证订单数据准确无误?](https://time.geekbang.org/column/article/204673)”这节课中讲到的ABA问题。你不要觉得发生这种情况的概率比较小,出现“脏数据”的概率是和系统的数据量以及并发数量正相关的,当系统的数据量足够大并且并发足够多的情况下,这种脏数据几乎是必然会出现的。
|
||||
|
||||
我在“[商品系统的存储该如何设计](https://time.geekbang.org/column/article/204688)”这节课中,在讲解如何缓存商品数据的时候,曾经简单提到过缓存策略。其中提到的Cache Aside模式可以很好地解决这个问题,在大多数情况下是使用缓存的最佳方式。
|
||||
|
||||
Cache Aside模式和上面的Read/Write Through模式非常像,它们处理读请求的逻辑是完全一样的,唯一的一个小差别就是,Cache Aside模式在更新数据的时候,并不去尝试更新缓存,而是去删除缓存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/31/0b9c9cb74f017c632136280a63015931.png" alt="">
|
||||
|
||||
订单服务收到更新数据请求之后,先更新数据库,如果更新成功了,再尝试去删除缓存中订单,如果缓存中存在这条订单就删除它,如果不存在就什么都不做,然后返回更新成功。这条更新后的订单数据将在下次被访问的时候加载到缓存中。使用Cache Aside模式来更新缓存,可以非常有效地避免并发读写导致的脏数据问题。
|
||||
|
||||
## 注意缓存穿透引起雪崩
|
||||
|
||||
如果我们的缓存命中率比较低,就会出现大量“缓存穿透”的情况。缓存穿透指的是,在读数据的时候,没有命中缓存,请求“穿透”了缓存,直接访问后端数据库的情况。
|
||||
|
||||
少量的缓存穿透是正常的,我们需要预防的是,短时间内大量的请求无法命中缓存,请求穿透到数据库,导致数据库繁忙,请求超时。大量的请求超时还会引发更多的重试请求,更多的重试请求让数据库更加繁忙,这样恶性循环导致系统雪崩。
|
||||
|
||||
当系统初始化的时候,比如说系统升级重启或者是缓存刚上线,这个时候缓存是空的,如果大量的请求直接打过来,很容易引发大量缓存穿透导致雪崩。为了避免这种情况,可以采用灰度发布的方式,先接入少量请求,再逐步增加系统的请求数量,直到全部请求都切换完成。
|
||||
|
||||
如果系统不能采用灰度发布的方式,那就需要在系统启动的时候对缓存进行预热。所谓的缓存预热就是在系统初始化阶段,接收外部请求之前,先把最经常访问的数据填充到缓存里面,这样大量请求打过来的时候,就不会出现大量的缓存穿透了。
|
||||
|
||||
还有一种常见的缓存穿透引起雪崩的情况是,当发生缓存穿透时,如果从数据库中读取数据的时间比较长,也容易引起数据库雪崩。
|
||||
|
||||
这种情况我在《[08 | 一个几乎每个系统必踩的坑儿:访问数据库超时](https://time.geekbang.org/column/article/211008)》这节课中也曾经提到过。比如说,我们缓存的数据是一个复杂的数据库联查结果,如果在数据库执行这个查询需要10秒钟,那当缓存中这条数据过期之后,最少10秒内,缓存中都不会有数据。
|
||||
|
||||
如果这10秒内有大量的请求都需要读取这个缓存数据,这些请求都会穿透缓存,打到数据库上,这样很容易导致数据库繁忙,当请求量比较大的时候就会引起雪崩。
|
||||
|
||||
所以,如果说构建缓存数据需要的查询时间太长,或者并发量特别大的时候,Cache Aside或者是Read/Write Through这两种缓存模式都可能出现大量缓存穿透。
|
||||
|
||||
对于这种情况,并没有一种方法能应对所有的场景,你需要针对业务场景来选择合适解决方案。比如说,可以牺牲缓存的时效性和利用率,缓存所有的数据,放弃Read Through策略所有的请求,只读缓存不读数据库,用后台线程来定时更新缓存数据。
|
||||
|
||||
## 小结
|
||||
|
||||
使用Redis作为MySQL的前置缓存,可以非常有效地提升系统的并发上限,降低请求响应时延。绝大多数情况下,使用Cache Aside模式来更新缓存都是最佳的选择,相比Read/Write Through模式更简单,还能大幅降低脏数据的可能性。
|
||||
|
||||
使用Redis的时候,还需要特别注意大量缓存穿透引起雪崩的问题,在系统初始化阶段,需要使用灰度发布或者其他方式来对缓存进行预热。如果说构建缓存数据需要的查询时间过长,或者并发量特别大,这两种情况下使用Cache Aside模式更新缓存,会出现大量缓存穿透,有可能会引发雪崩。
|
||||
|
||||
顺便说一句,我们今天这节课中讲到的这些缓存策略,都是非常经典的理论,早在互联网大规模应用之前,这些缓存策略就已经非常成熟了,在操作系统中,CPU Cache的缓存、磁盘文件的内存缓存,它们也都应用了我们今天讲到的这些策略。
|
||||
|
||||
所以无论技术发展的多快,计算机的很多基础的理论的知识都是相通的,你绞尽脑汁想出的解决工程问题的方法,很可能早都写在几十年前出版的书里。学习算法、数据结构、设计模式等等这些基础的知识,并不只是为了应付面试。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你想一下,具体什么情况下,使用Cache Aside模式更新缓存会产生脏数据?欢迎你在评论区留言,通过一个例子来说明情况。
|
||||
|
||||
感谢阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
76
极客时间专栏/后端存储实战课/高速增长篇/12 | MySQL如何应对高并发(二):读写分离.md
Normal file
76
极客时间专栏/后端存储实战课/高速增长篇/12 | MySQL如何应对高并发(二):读写分离.md
Normal file
@@ -0,0 +1,76 @@
|
||||
<audio id="audio" title="12 | MySQL如何应对高并发(二):读写分离" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/1c/bcacf2be00912cc693cd1ed3c1ed081c.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
上节课我和你讲了,使用Redis作为MySQL的前置缓存,可以帮助MySQL挡住绝大部分的查询请求。这种方法对于像电商中的商品系统、搜索系统这类与用户关联不大的系统,效果特别的好。因为在这些系统中,每个人看到的内容都是一样的,也就是说,对后端服务来说,每个人的查询请求和返回的数据都是一样的。这种情况下,Redis缓存的命中率非常高,近乎于全部的请求都可以命中缓存,相对的,几乎没有多少请求能穿透到MySQL。
|
||||
|
||||
但是,和用户相关的系统,使用缓存的效果就没那么好了,比如说,订单系统、账户系统、购物车系统等等。在这些系统里面,每个用户需要查询的信息都是和用户相关的,即使是同一个功能界面,那每个人看到的数据都是不一样的。
|
||||
|
||||
比如说,“我的订单”这个功能,用户在这里看到的都是自己的订单数据,我打开我的订单缓存的数据,是不能给你打开你的订单来使用的,因为我们两个人的订单是不一样的。这种情况下,缓存的命中率就没有那么高,还是有相当一部分查询请求因为命中不了缓存,打到MySQL上。
|
||||
|
||||
那随着系统用户数量越来越多,打到MySQL上的读写请求也越来越多,当单台MySQL支撑不了这么多的并发请求时,我们该怎么办?
|
||||
|
||||
## 读写分离是提升MySQL并发的首选方案
|
||||
|
||||
当单台MySQL无法满足要求的时候,只能用多个MySQL实例来承担大量的读写请求。MySQL和大部分常用的关系型数据库一样,都是典型的单机数据库,不支持分布式部署。用一个单机数据库的多个实例来组成一个集群,提供分布式数据库服务,是一个非常困难的事儿。
|
||||
|
||||
在部署集群的时候,需要做很多额外的工作,而且很难做到对应用透明,那你的应用程序也要为此做较大的架构调整。所以,除非系统规模真的大到只有这一条路可以走,不建议你对数据进行分片,自行构建MySQL集群,代价非常大。
|
||||
|
||||
一个简单而且非常有效的方案是,我们不对数据分片,而是使用多个具有相同数据的MySQL实例来分担大量的查询请求,这种方法通常称为“读写分离”。读写分离之所以能够解决问题,它实际上是基于一个对我们非常有利的客观情况,那就是,很多系统,特别是面对公众用户的互联网系统,对数据的读写比例是严重不均衡的。读写比一般都在几十左右,平均每发生几十次查询请求,才有一次更新请求。换句话来说,数据库需要应对的绝大部分请求都是只读查询请求。
|
||||
|
||||
一个分布式的存储系统,想要做分布式写是非常非常困难的,因为很难解决好数据一致性的问题。但实现分布式读就相对简单很多,我只需要增加一些只读的实例,只要能够把数据实时的同步到这些只读实例上,保证这这些只读实例上的数据都随时一样,这些只读的实例就可以分担大量的查询请求。
|
||||
|
||||
读写分离的另外一个好处是,它实施起来相对比较简单。把使用单机MySQL的系统升级为读写分离的多实例架构非常容易,一般不需要修改系统的业务逻辑,只需要简单修改DAO代码,把对数据库的读写请求分开,请求不同的MySQL实例就可以了。
|
||||
|
||||
通过读写分离这样一个简单的存储架构升级,就可以让数据库支持的并发数量增加几倍到十几倍。所以,当你的系统用户数越来越多,读写分离应该是你首先要考虑的扩容方案。
|
||||
|
||||
下图是一个典型的读写分离架构:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/db/40e195c130d45dcdf25a273cb8835ddb.jpg" alt="">
|
||||
|
||||
主库负责执行应用程序发来的所有数据更新请求,然后异步将数据变更实时同步到所有的从库中去,这样,主库和所有从库中的数据是完全一样的。多个从库共同分担应用的查询请求。
|
||||
|
||||
然后我们简单说一下,如何来实施MySQL的读写分离方案。你需要做两件事儿:
|
||||
|
||||
1. 部署一主多从多个MySQL实例,并让它们之间保持数据实时同步。
|
||||
1. 分离应用程序对数据库的读写请求,分别发送给从库和主库。
|
||||
|
||||
MySQL自带主从同步的功能,经过简单的配置就可以实现一个主库和几个从库之间的数据同步,部署和配置的方法,你看[MySQL的官方文档](https://dev.mysql.com/doc/refman/8.0/en/replication.html)照着做就可以。分离应用程序的读写请求方法有下面这三种:
|
||||
|
||||
1. 纯手工方式:修改应用程序的DAO层代码,定义读写两个数据源,指定每一个数据库请求的数据源。
|
||||
1. 组件方式:也可以使用像Sharding-JDBC这种集成在应用中的第三方组件来实现,这些组件集成在你的应用程序内,代理应用程序的所有数据库请求,自动把请求路由到对应数据库实例上。
|
||||
1. 代理方式:在应用程序和数据库实例之间部署一组数据库代理实例,比如说Atlas或者MaxScale。对应用程序来说,数据库代理把自己伪装成一个单节点的MySQL实例,应用程序的所有数据库请求被发送给代理,代理分离读写请求,然后转发给对应的数据库实例。
|
||||
|
||||
这三种方式,我最推荐的是第二种,使用读写分离组件。这种方式代码侵入非常少,并且兼顾了性能和稳定性。如果你的应用程序是一个逻辑非常简单的微服务,简单到只有几个SQL,或者是,你的应用程序使用的编程语言没有合适的读写分离组件,那你也可以考虑使用第一种纯手工的方式来实现读写分离。
|
||||
|
||||
一般情况下,不推荐使用第三种代理的方式,原因是,使用代理加长了你的系统运行时数据库请求的调用链路,有一定的性能损失,并且代理服务本身也可能出现故障和性能瓶颈等问题。但是,代理方式有一个好处是,它对应用程序是完全透明的。**所以,只有在不方便修改应用程序代码这一种情况下,你才需要采用代理方式。**
|
||||
|
||||
另外,如果你配置了多个从库,推荐你使用“HAProxy+Keepalived”这对儿经典的组合,来给所有的从节点做一个高可用负载均衡方案,既可以避免某个从节点宕机导致业务可用率降低,也方便你后续随时扩容从库的实例数量。因为HAProxy可以做L4层代理,也就是说它转发的是TCP请求,所以用“HAProxy+Keepalived”代理MySQL请求,在部署和配置上也没什么特殊的地方,正常配置和部署就可以了。
|
||||
|
||||
## 注意读写分离带来的数据不一致问题
|
||||
|
||||
读写分离的一个副作用是,可能会存在数据不一致的情况。原因是,数据库中的数据在主库完成更新后,是异步同步到每个从库上的,这个过程有一个微小的时间差,这个时间差叫**主从同步延迟**。正常情况下,主从延迟非常小,不超过1ms。但即使这个非常小的延迟,也会导致在某一个时刻,主库和从库上的数据是不一致的。应用程序需要能接受并克服这种主从不一致的情况,否则就会引发一些由于主从延迟导致的数据错误。
|
||||
|
||||
还是拿订单系统来举例,我们自然的设计思路是,用户从购物车里发起结算创建订单,进入订单页,打开支付页面进行支付,支付完成后,按道理应该再返回支付之前的订单页。但如果这个时候马上自动返回订单页,就很可能会出现订单状态还是显示“未支付”。因为,支付完成后,订单库的主库中,订单状态已经被更新了,而订单页查询的从库中,这条订单记录的状态有可能还没更新。怎么解决?
|
||||
|
||||
这种问题其实没什么好的技术手段来解决,所以你看大的电商,它支付完成后是不会自动跳回到订单页的,它增加了一个无关紧要的“支付完成”页面,其实这个页面没有任何有效的信息,就是告诉你支付成功,然后再放一些广告什么的。你如果想再看刚刚支付完成的订单,需要手动点一下,这样就很好地规避了主从同步延迟的问题。
|
||||
|
||||
上面这个例子还只是订单状态显示错误,刷新一下就好了。我们需要特别注意的,是那些数据更新后,立刻需要查询更新后的数据,然后再更新其他数据这种情况。比如说在购物车页面,如果用户修改了某个商品的数量,需要重新计算优惠和总价。更新了购物车的数据后,需要立即调用计价服务,这个时候如果计价服务去读购物车的从库,非常可能读到旧数据而导致计算的总价错误。
|
||||
|
||||
对于这个例子,你可以把“更新购物车、重新计算总价”这两个步骤合并成一个微服务,然后放在一个数据库事务中去,同一个事务中的查询操作也会被路由到主库,这样来规避主从不一致的问题。
|
||||
|
||||
对于这种主从延迟带来的数据不一致的问题,没有什么简单方便而且通用的技术方案可以解决,我们需要重新设计业务逻辑,尽量规避更新数据后立即去从库查询刚刚更新的数据。
|
||||
|
||||
## 小结
|
||||
|
||||
随着系统的用户增长,当单个MySQL实例快要扛不住大量并发的时候,读写分离是首选的数据库扩容方案。读写分离的方案不需要对系统做太大的改动,就可以让系统支撑的并发提升几倍到十几倍。
|
||||
|
||||
推荐你使用集成在应用内的读写分离组件方式来分离数据库读写请求,如果很难修改应用程序,也可以使用代理的方式来分离数据库读写请求。如果你的方案中部署了多个从库,推荐你用“HAProxy+Keepalived”来做这些从库的负载均衡和高可用,这个方案的好处是简单稳定而且足够灵活,不需要增加额外的服务器部署,便于维护并且不增加故障点。
|
||||
|
||||
主从同步延迟会导致主库和从库之间出现数据不一致的情况,我们的应用程序应该能兼容主从延迟,避免因为主从延迟而导致的数据错误。规避这个问题最关键的一点是,我们在设计系统的业务流程时,尽量不要在更新数据之后立即去查询更新后的数据。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你对照你现在负责开发或者维护的系统来分享一下,你的系统实施读写分离的具体方案是什么样的?比如,如何分离读写数据库请求?如何解决主从延迟带来的数据一致性问题?欢迎你在留言区与我讨论。
|
||||
|
||||
如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
83
极客时间专栏/后端存储实战课/高速增长篇/13 | MySQL主从数据库同步是如何实现的?.md
Normal file
83
极客时间专栏/后端存储实战课/高速增长篇/13 | MySQL主从数据库同步是如何实现的?.md
Normal file
@@ -0,0 +1,83 @@
|
||||
<audio id="audio" title="13 | MySQL主从数据库同步是如何实现的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/52/710c6dae132d8ebc9f8d38330dd02352.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
回顾我们之前讲MySQL相关的几节课程,你会发现主从同步有多重要。解决数据可靠性的问题需要用到主从同步;解决MySQL服务高可用要用到主从同步;应对高并发的时候,还是要用到主从同步。
|
||||
|
||||
我们在运维MySQL集群时,遇到的很多常见的问题,比如说,为什么从节点故障会影响到主节点?为什么主从切换之后丢数据了?为什么明明没有更新数据,客户端读到的数据还是变来变去的?这些都和主从同步的配置有密切的关系。
|
||||
|
||||
你不但要理解MySQL主从同步的原理,还要掌握一些相关配置的含义,才能正确地配置你的集群,知道集群在什么情况下会有什么样的行为,可能会出现什么样的问题,并且知道该如何解决。
|
||||
|
||||
今天这节课我们就来详细讲一下,MySQL的主从同步是怎么实现的,以及如何来正确地配置主从同步。
|
||||
|
||||
## 如何配置MySQL的主从同步?
|
||||
|
||||
当客户端提交一个事务到MySQL的集群,直到客户端收到集群返回成功响应,在这个过程中,MySQL集群需要执行很多操作:主库需要提交事务、更新存储引擎中的数据、把Binlog写到磁盘上、给客户端返回响应、把Binlog复制到所有从库上、每个从库需要把复制过来的Binlog写到暂存日志中、回放这个Binlog、更新存储引擎中的数据、给主库返回复制成功的响应。
|
||||
|
||||
这些操作的时序非常重要,这里面的“时序”,说的就是这些操作的先后顺序。同样的操作,因为时序不同,对应用程序来说,有很大的差异。比如说,如果先复制Binlog,等Binlog复制到从节点上之后,主节点再去提交事务,这种情况下,从节点的Binlog一直和主节点是同步的,任何情况下主节点宕机也不会丢数据。但如果把这个时序倒过来,先提交事务再复制Binlog,性能就会非常好,但是存在丢数据的风险。
|
||||
|
||||
MySQL提供了几个参数来配置这个时序,我们先看一下默认情况下的时序是什么样的。
|
||||
|
||||
默认情况下,MySQL采用异步复制的方式,执行事务操作的线程不会等复制Binlog的线程。具体的时序你可以看下面这个图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/3f/6359155a64c1a62cb5fe23f10946d23f.jpg" alt="">
|
||||
|
||||
MySQL主库在收到客户端提交事务的请求之后,会先写入Binlog,然后再提交事务,更新存储引擎中的数据,事务提交完成后,给客户端返回操作成功的响应。同时,从库会有一个专门的复制线程,从主库接收Binlog,然后把Binlog写到一个中继日志里面,再给主库返回复制成功的响应。
|
||||
|
||||
从库还有另外一个回放Binlog的线程,去读中继日志,然后回放Binlog更新存储引擎中的数据,这个过程和我们今天讨论的主从复制关系不大,所以我并没有在图中画出来。**提交事务和复制这两个流程在不同的线程中执行,互相不会等待,这是异步复制。**
|
||||
|
||||
掌握了异步复制的时序之后,我们就很容易理解之前几节课中讲到的一些问题的原因了。比如说,在异步复制的情况下,为什么主库宕机存在丢数据的风险?为什么读写分离存在读到脏数据的问题?产生这些问题,都是因为**异步复制它没有办法保证数据能第一时间复制到从库上。**
|
||||
|
||||
与异步复制相对的就是同步复制。同步复制的时序和异步复制基本是一样的,唯一的区别是,什么时候给客户端返回响应。异步复制时,主库提交事务之后,就会给客户端返回响应;而同步复制时,主库在提交事务的时候,会等待数据复制到所有从库之后,再给客户端返回响应。
|
||||
|
||||
同步复制这种方式在实际项目中,基本上没法用,原因有两个:一是性能很差,因为要复制到所有节点才返回响应;二是可用性也很差,主库和所有从库任何一个数据库出问题,都会影响业务。
|
||||
|
||||
为了解决这个问题,MySQL从5.7版本开始,增加一种半同步复制(Semisynchronous Replication)的方式。异步复制是,事务线程完全不等复制响应;同步复制是,事务线程要等待所有的复制响应;半同步复制介于二者之间,事务线程不用等着所有的复制成功响应,只要一部分复制响应回来之后,就可以给客户端返回了。
|
||||
|
||||
比如说,一主二从的集群,配置成半同步复制,只要数据成功复制到任意一个从库上,主库的事务线程就直接返回了。这种半同步复制的方式,它兼顾了异步复制和同步复制的优点。如果主库宕机,至少还有一个从库有最新的数据,不存在丢数据的风险。并且,半同步复制的性能也还凑合,也能提供高可用保证,从库宕机也不会影响主库提供服务。所以,半同步复制这种折中的复制方式,也是一种不错的选择。
|
||||
|
||||
接下来我跟你说一下,在实际应用过程中,选择半同步复制需要特别注意的几个问题。
|
||||
|
||||
配置半同步复制的时候,有一个重要的参数“rpl_semi_sync_master_wait_no_slave”,含义是:“至少等待数据复制到几个从节点再返回”。这个数量配置的越大,丢数据的风险越小,但是集群的性能和可用性就越差。最大可以配置成和从节点的数量一样,这样就变成了同步复制。
|
||||
|
||||
一般情况下,配成默认值1也就够了,这样性能损失最小,可用性也很高,只要还有一个从库活着,就不影响主库读写。丢数据的风险也不大,只有在恰好主库和那个有最新数据的从库一起坏掉的情况下,才有可能丢数据。
|
||||
|
||||
另外一个重要的参数是“rpl_semi_sync_master_wait_point”,这个参数控制主库执行事务的线程,是在提交事务之前(AFTER_SYNC)等待复制,还是在提交事务之后(AFTER_COMMIT)等待复制。默认是AFTER_SYNC,也就是先等待复制,再提交事务,这样完全不会丢数据。AFTER_COMMIT具有更好的性能,不会长时间锁表,但还是存在宕机丢数据的风险。
|
||||
|
||||
另外,虽然我们配置了同步或者半同步复制,并且要等待复制成功后再提交事务,还是有一种特别容易被忽略、可能存在丢数据风险的情况。
|
||||
|
||||
如果说,主库提交事务的线程等待复制的时间超时了,这种情况下事务仍然会被正常提交。并且,MySQL会自动降级为异步复制模式,直到有足够多(rpl_semi_sync_master_wait_no_slave)的从库追上主库,才能恢复成半同步复制。如果这个期间主库宕机,仍然存在丢数据的风险。
|
||||
|
||||
## 复制状态机:所有分布式存储都是这么复制数据的
|
||||
|
||||
在MySQL中,无论是复制还是备份恢复,依赖的都是全量备份和Binlog,全量备份相当于备份那一时刻的一个数据快照,Binlog则记录了每次数据更新的变化,也就是操作日志。我们这节课讲主从同步,也就是数据复制,虽然讲的都是MySQL,但是你要知道,这种基于“快照+操作日志”的方法,不是MySQL特有的。
|
||||
|
||||
比如说,Redis Cluster中,它的全量备份称为Snapshot,操作日志叫backlog,它的主从复制方式几乎和MySQL是一模一样的。
|
||||
|
||||
我再给你举个例子,之前我们讲过的Elasticsearch,它是一个内存数据库,读写都在内存中,那它是怎么保证数据可靠性的呢?对,它用的是translog,它备份和恢复数据的原理和实现方式也是完全一样的。这些什么什么log,都是不同的马甲儿而已,**几乎所有的存储系统和数据库,都是用这一套方法来解决备份恢复和数据复制问题的**。
|
||||
|
||||
既然这些存储系统他们实现数据复制的方法是完全一样的,那这几节课我们讲的MySQL主从复制时,讲到的那些问题、丢数据的风险,对于像Redis Cluster、ES或者其他分布式存储也都是一样存在的。那我们讲的,如何应对的方法、注意事项、最佳实践,这些也都是可以照搬的。
|
||||
|
||||
这一套方法其实是有理论基础的,叫做[复制状态机(Replication State Machine)](https://en.wikipedia.org/wiki/State_machine_replication),我能查到的最早的出处是1978年Lamport的一篇论文[《The Implementation of Reliable Distributed Multiprocess Systems》](http://lamport.azurewebsites.net/pubs/implementation.pdf)。
|
||||
|
||||
1978年啊,同学,那时候我们都还没出生呢!这么老的技术到今天仍然在被广泛地应用!无论应用技术发展的多快,实际上解决问题的方法,或者说是理论基础,一直是没什么变化的。所以,你在不断学习新的应用技术的同时,还需要多思考、总结和沉淀,这样会让你学习新技术的时候更快更轻松。
|
||||
|
||||
## 小结
|
||||
|
||||
最后,那为了便于你理解复制状态机,我们把这套方法再抽象总结一下。任何一个存储系统,无论它存储的是什么数据,用什么样的数据结构,都可以抽象成一个状态机。
|
||||
|
||||
存储系统中的数据称为状态(也就是MySQL中的数据),状态的全量备份称为快照(Snapshot),就像给数据拍个照片一样。我们按照顺序记录更新存储系统的每条操作命令,就是操作日志(Commit Log,也就是MySQL中的Binlog)。你可以对照下面这张图来理解上面这些抽象的概念。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/7a/83e34a8b9d4f81391e327172e5a2497a.jpg" alt="">
|
||||
|
||||
复制数据的时候,只要基于一个快照,按照顺序执行快照之后的所有操作日志,就可以得到一个完全一样的状态。在从节点持续地从主节点上复制操作日志并执行,就可以让从节点上的状态数据和主节点保持同步。
|
||||
|
||||
主从同步做数据复制时,一般可以采用几种复制策略。性能最好的方法是异步复制,主节点上先记录操作日志,再更新状态数据,然后异步把操作日志复制到所有从节点上,并在从节点执行操作日志,得到和主节点相同的状态数据。
|
||||
|
||||
异步复制的劣势是,可能存在主从延迟,如果主节点宕机,可能会丢数据。另外一种常用的策略是半同步复制,主节点等待操作日志最少成功复制到N个从节点上之后,再更新状态,这种方式在性能、高可用和数据可靠性几个方面都比较平衡,很多分布式存储系统默认采用的都是这种方式。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你想一下,复制状态机除了用于数据库的备份和复制以外,在计算机技术领域,还有哪些地方也用到了复制状态机?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
143
极客时间专栏/后端存储实战课/高速增长篇/14 | 订单数据越来越多,数据库越来越慢该怎么办?.md
Normal file
143
极客时间专栏/后端存储实战课/高速增长篇/14 | 订单数据越来越多,数据库越来越慢该怎么办?.md
Normal file
@@ -0,0 +1,143 @@
|
||||
<audio id="audio" title="14 | 订单数据越来越多,数据库越来越慢该怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/f9/22d9655db0e2ed0b5e4205ccf0a9e8f9.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
在前面几节课,我们一起学习了在并发持续高速增长的情况下,如何来逐步升级存储。今天这节课我们来聊一聊,如何应对数据的持续增长,特别是像订单数据这种会随着时间一直累积的数据。
|
||||
|
||||
为什么数据量越大数据库就越慢?你得理解这里面的根本原因。
|
||||
|
||||
我们知道,无论是“增删改查”哪个操作,其实都是查找问题,因为你都得先找到数据才能对数据做操作。那存储系统性能问题,其实就是查找快慢的问题。
|
||||
|
||||
无论是什么样的存储系统,一次查询所耗费的时间,都取决于两个因素:
|
||||
|
||||
1. 查找的时间复杂度;
|
||||
1. 数据总量。
|
||||
|
||||
这也是为什么大厂面试时总喜欢问“时间复杂度”相关问题的原因。查找的时间复杂度又取决于两个因素:
|
||||
|
||||
1. 查找算法;
|
||||
1. 存储数据的数据结构。
|
||||
|
||||
你看,这两个知识点也是面试问题中的常客吧?所以人家面试官并不是非要问你一些用不上的问题来为难你,这些知识点真的不是用不上,而是你不知道怎么用。
|
||||
|
||||
我们把话题拉回来。对于我们大多数做业务的系统,用的都是现成的数据库,数据的存储结构和查找算法都是由数据库来实现的,业务系统基本没法去改变它。比如说,我们讲过MySQL的InnoDB存储引擎,它的存储结构是B+树,查找算法大多就是树的查找,查找的时间复杂度就是O(log n),这些都是固定的。那我们唯一能改变的,就是数据总量了。
|
||||
|
||||
所以,**解决海量数据导致存储系统慢的问题,思想非常简单,就是一个“拆”字,把一大坨数据拆分成N个小坨,学名叫“分片(Shard)**”。拆开之后,每个分片里的数据就没那么多了,然后让查找尽量落在某一个分片上,这样来提升查找性能。
|
||||
|
||||
所有分布式存储系统解决海量数据查找问题都是遵循的这个思想,但是光有思想还不够,还需要落地,下面我们就来说如何拆分数据的问题。
|
||||
|
||||
## 存档历史订单数据提升查询性能
|
||||
|
||||
我们在开发业务系统的时候,很多数据都是具备时间属性的,并且随着系统运行,累计增长越来越多,数据量达到一定程度就会越来越慢,比如说电商中的订单数据,就是这种情况。按照我们刚刚说的思想,这个时候就需要拆分数据了。
|
||||
|
||||
我们的订单数据一般都是保存在MySQL中的订单表里面,说到拆分MySQL的表,大多数同学的第一反应都是“分库分表”,别着急,咱现在的数据量还没到非得分库分表那一步呢,下一节课我会和你讲分库分表。**当单表的订单数据太多,多到影响性能的时候,首选的方案是,归档历史订单。**
|
||||
|
||||
所谓归档,其实也是一种拆分数据的策略。简单地说,就是把大量的历史订单移到另外一张历史订单表中。为什么这么做呢?因为像订单这类具有时间属性的数据,都存在热尾效应。大多数情况下访问的都是最近的数据,但订单表里面大量的数据都是不怎么常用的老数据。
|
||||
|
||||
因为新数据只占数据总量中很少的一部分,所以把新老数据分开之后,新数据的数据量就会少很多,查询速度也就会快很多。老数据虽然和之前比起来没少多少,查询速度提升不明显,但是,因为老数据很少会被访问到,所以慢一点儿也问题不大。
|
||||
|
||||
这样拆分的另外一个好处是,**拆分订单时,需要改动的代码非常少**。大部分对订单表的操作都是在订单完成之前,这些业务逻辑都是完全不用修改的。即使像退货退款这类订单完成后的操作,也是有时限的,那这些业务逻辑也不需要修改,原来该怎么操作订单表还怎么操作。
|
||||
|
||||
基本上只有查询统计类的功能,会查到历史订单,这些需要稍微做一些调整,按照时间,选择去订单表还是历史订单表查询就可以了。很多电商大厂在它逐步发展壮大的过程中,都用这种订单拆分的方案撑了好多年。你可能还有印象,几年前你在京东、淘宝查自己的订单时,都有一个查“三个月前订单”的选项,其实就是查订单历史表。
|
||||
|
||||
归档历史订单,大致的流程是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/da/e16007b7e26c34a55d4bb4689b358dda.png" alt="">
|
||||
|
||||
1. 首先我们需要创建一个和订单表结构一模一样的历史订单表;
|
||||
1. 然后,把订单表中的历史订单数据分批查出来,插入到历史订单表中去。这个过程你怎么实现都可以,用存储过程、写个脚本或者写个导数据的小程序都行,用你最熟悉的方法就行。如果你的数据库已经做了主从分离,那最好是去从库查询订单,再写到主库的历史订单表中去,这样对主库的压力会小一点儿。
|
||||
1. 现在,订单表和历史订单表都有历史订单数据,先不要着急去删除订单表中的数据,你应该测试和上线支持历史订单表的新版本代码。因为两个表都有历史订单,所以现在这个数据库可以支持新旧两个版本的代码,如果新版本的代码有Bug,你还可以立刻回滚到旧版本,不至于影响线上业务。
|
||||
1. 等新版本代码上线并验证无误之后,就可以删除订单表中的历史订单数据了。
|
||||
1. 最后,还需要上线一个迁移数据的程序或者脚本,定期把过期的订单从订单表搬到历史订单表中去。
|
||||
|
||||
类似于订单商品表这类订单的相关的子表,也是需要按照同样的方式归档到各自的历史表中,由于它们都是用订单ID作为外键来关联到订单主表的,随着订单主表中的订单一起归档就可以了。
|
||||
|
||||
这个过程中,我们要注意的问题是,要做到对线上业务的影响尽量的小。迁移这么大量的数据,或多或少都会影响数据库的性能,你应该尽量放在闲时去迁移,**迁移之前一定做好备份**,这样如果不小心误操作了,也能用备份来恢复。
|
||||
|
||||
## 如何批量删除大量数据?
|
||||
|
||||
这里面还有一个很重要的细节问题:如何从订单表中删除已经迁走的历史订单数据?我们直接执行一个删除历史订单的SQL行不行?像这样删除三个月前的订单:
|
||||
|
||||
```
|
||||
delete from orders
|
||||
where timestamp < SUBDATE(CURDATE(),INTERVAL 3 month);
|
||||
|
||||
```
|
||||
|
||||
大概率你会遇到错误,提示删除失败,因为需要删除的数据量太大了,所以需要分批删除。比如说我们每批删除1000条记录,那分批删除的SQL可以这样写:
|
||||
|
||||
```
|
||||
delete from orders
|
||||
where timestamp < SUBDATE(CURDATE(),INTERVAL 3 month)
|
||||
order by id limit 1000;
|
||||
|
||||
```
|
||||
|
||||
执行删除语句的时候,最好在每次删除之间停顿一会儿,避免给数据库造成太大的压力。上面这个删除语句已经可以用了,反复执行这个SQL,直到全部历史订单都被删除,是可以完成删除任务的。
|
||||
|
||||
但是这个SQL还有优化空间,它每执行一次,都要先去timestamp对应的索引上找出符合条件的记录,然后再把这些记录按照订单ID排序,之后删除前1000条记录。
|
||||
|
||||
其实没有必要每次都按照timestamp比较订单,所以我们可以先通过一次查询,找到符合条件的历史订单中最大的那个订单ID,然后在删除语句中把删除的条件转换成按主键删除。
|
||||
|
||||
```
|
||||
select max(id) from orders
|
||||
where timestamp < SUBDATE(CURDATE(),INTERVAL 3 month);
|
||||
|
||||
|
||||
delete from orders
|
||||
where id <= ?
|
||||
order by id limit 1000;
|
||||
|
||||
```
|
||||
|
||||
这样每次删除的时候,由于条件变成了主键比较,我们知道在MySQL的InnoDB存储引擎中,表数据结构就是按照主键组织的一颗B+树,而B+树本身就是有序的,所以不仅查找非常快,也不需要再进行额外的排序操作了。当然这样做的前提条件是订单ID必须和订单时间正相关才行,大多数订单ID的生成规则都可以满足这个条件,所以问题不大。
|
||||
|
||||
然后我们再说一下,为什么在删除语句中非得加一个排序呢?因为按ID排序后,我们每批删除的记录,基本都是ID连续的一批记录,由于B+树的有序性,这些ID相近的记录,在磁盘的物理文件上,大致也是放在一起的,这样删除效率会比较高,也便于MySQL回收页。
|
||||
|
||||
大量的历史订单数据删除完成之后,如果你检查一下MySQL占用的磁盘空间,你会发现它占用的磁盘空间并没有变小,这是什么原因呢?这也是和InnoDB的物理存储结构有关系。
|
||||
|
||||
虽然逻辑上每个表是一颗B+树,但是物理上,每条记录都是存放在磁盘文件中的,这些记录通过一些位置指针来组织成一颗B+树。当MySQL删除一条记录的时候,只能是找到记录所在的文件中位置,然后把文件的这块区域标记为空闲,然后再修改B+树中相关的一些指针,完成删除。其实那条被删除的记录还是躺在那个文件的那个位置,所以并不会释放磁盘空间。
|
||||
|
||||
这么做也是没有办法的办法,因为文件就是一段连续的二进制字节,类似于数组,它不支持从文件中间删除一部分数据。如果非要这么删除,只能是把这个位置之后的所有数据往前挪,这样等于是要移动大量数据,非常非常慢。所以,删除的时候,只能是标记一下,并不真正删除,后续写入新数据的时候再重用这块儿空间。
|
||||
|
||||
理解了这个原理,你就很容易知道,不仅是MySQL,很多其他的数据库都会有类似的问题。这个问题也没什么特别好的办法解决,磁盘空间足够的话,就这样吧,至少数据删了,查询速度也快了,基本上是达到了目的。
|
||||
|
||||
如果说我们数据库的磁盘空间很紧张,非要把这部分磁盘空间释放出来,可以执行一次OPTIMIZE TABLE释放存储空间。对于InnoDB来说,执行OPTIMIZE TABLE实际上就是把这个表重建一遍,执行过程中会一直锁表,也就是说这个时候下单都会被卡住,这个是需要注意的。另外,这么优化有个前提条件,MySQL的配置必须是每个表独立一个表空间(innodb_file_per_table = ON),如果所有表都是放在一起的,执行OPTIMIZE TABLE也不会释放磁盘空间。
|
||||
|
||||
重建表的过程中,索引也会重建,这样表数据和索引数据都会更紧凑,不仅占用磁盘空间更小,查询效率也会有提升。那对于频繁插入删除大量数据的这种表,如果能接受锁表,定期执行OPTIMIZE TABLE是非常有必要的。
|
||||
|
||||
如果说,我们的系统可以接受暂时停服,最快的方法是这样的:直接新建一个临时订单表,然后把当前订单复制到临时订单表中,再把旧的订单表改名,最后把临时订单表的表名改成正式订单表。这样,相当于我们手工把订单表重建了一次,但是,不需要漫长的删除历史订单的过程了。我把执行过程的SQL放在下面供你参考:
|
||||
|
||||
```
|
||||
-- 新建一个临时订单表
|
||||
create table orders_temp like orders;
|
||||
|
||||
|
||||
-- 把当前订单复制到临时订单表中
|
||||
insert into orders_temp
|
||||
select * from orders
|
||||
where timestamp >= SUBDATE(CURDATE(),INTERVAL 3 month);
|
||||
|
||||
|
||||
-- 修改替换表名
|
||||
rename table orders to orders_to_be_droppd, orders_temp to orders;
|
||||
|
||||
|
||||
-- 删除旧表
|
||||
drop table orders_to_be_dropp
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
对于订单这类具有时间属性的数据,会随时间累积,数据量越来越多,为了提升查询性能需要对数据进行拆分,首选的拆分方法是把旧数据归档到历史表中去。这种拆分方法能起到很好的效果,更重要的是对系统的改动小,升级成本低。
|
||||
|
||||
在迁移历史数据过程中,如果可以停服,最快的方式是重建一张新的订单表,然后把三个月内的订单数据复制到新订单表中,再通过修改表名让新的订单表生效。如果只能在线迁移,那需要分批迭代删除历史订单数据,删除的时候注意控制删除节奏,避免给线上数据库造成太大压力。
|
||||
|
||||
最后,我要再一次提醒你,线上数据操作非常危险,在操作之前一定要做好数据备份。
|
||||
|
||||
## 思考题
|
||||
|
||||
在数据持续增长的过程中,今天介绍的这种“归档历史订单”的数据拆分方法,和直接进行分库分表相比,比如说按照订单创建时间,自动拆分成每个月一张表,两种方法各有什么优点和缺点?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
Reference in New Issue
Block a user