This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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欢迎你在留言区与我交流互动。
感谢你的阅读,如果你觉得今天的内容对工作有所帮助,也欢迎把它分享给你的朋友。

View 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';
```
欢迎你在留言区与我讨论,如果你觉得今天学到的知识对你有帮助,也欢迎把它分享给你的朋友。

View 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 &gt; 50
```
这个SQL语义是查询用户ID大于50的用户的所有订单这是很简单的一个联查需要查询users和orders两张表WHERE条件就是用户ID大于50。
数据库收到查询请求后需要先解析SQL语句把这一串文本解析成便于程序处理的结构化数据这就是一个通用的语法解析过程。跟编程语言的编译器编译时解析源代码的过程是完全一样的。如果是计算机专业的同学你上过的《编译原理》这门课其中很大的篇幅是在讲解这一块儿。没学过《编译原理》的同学也不用担心你暂时先不用搞清楚SQL文本是怎么转换成结构化数据的不妨碍你学习和理解这节课下面的内容。
转换后的结构化数据就是一棵树这个树的名字叫抽象语法树ASTAbstract 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 &gt; 50])
LogicalJoin(condition=[$0 == $6], joinType=[inner])
LogicalTableScan(table=[users])
LogicalTableScan(table=[orders])
```
和SQL、AST不同的是这个逻辑执行计划已经很像可以执行的程序代码了。你看上面这个执行计划很像我们编程语言的函数调用栈外层的方法调用内层的方法。所以要理解这个执行计划得从内往外看。
1. 最内层的2个LogicalTableScan的含义是把USERS和ORDERS这两个表的数据都读出来。
1. 然后拿这两个表所有数据做一个LogicalJoinJOIN的条件就是第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 &gt; 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 &gt; 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 &gt; 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有什么不同。
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。

View 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模式更新缓存会产生脏数据欢迎你在评论区留言通过一个例子来说明情况。
感谢阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。

View 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”来做这些从库的负载均衡和高可用这个方案的好处是简单稳定而且足够灵活不需要增加额外的服务器部署便于维护并且不增加故障点。
主从同步延迟会导致主库和从库之间出现数据不一致的情况,我们的应用程序应该能兼容主从延迟,避免因为主从延迟而导致的数据错误。规避这个问题最关键的一点是,我们在设计系统的业务流程时,尽量不要在更新数据之后立即去查询更新后的数据。
## 思考题
课后请你对照你现在负责开发或者维护的系统来分享一下,你的系统实施读写分离的具体方案是什么样的?比如,如何分离读写数据库请求?如何解决主从延迟带来的数据一致性问题?欢迎你在留言区与我讨论。
如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。

View 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个从节点上之后再更新状态这种方式在性能、高可用和数据可靠性几个方面都比较平衡很多分布式存储系统默认采用的都是这种方式。
## 思考题
课后请你想一下,复制状态机除了用于数据库的备份和复制以外,在计算机技术领域,还有哪些地方也用到了复制状态机?欢迎你在留言区与我讨论。
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。

View 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 &lt; SUBDATE(CURDATE(),INTERVAL 3 month);
```
大概率你会遇到错误提示删除失败因为需要删除的数据量太大了所以需要分批删除。比如说我们每批删除1000条记录那分批删除的SQL可以这样写
```
delete from orders
where timestamp &lt; SUBDATE(CURDATE(),INTERVAL 3 month)
order by id limit 1000;
```
执行删除语句的时候最好在每次删除之间停顿一会儿避免给数据库造成太大的压力。上面这个删除语句已经可以用了反复执行这个SQL直到全部历史订单都被删除是可以完成删除任务的。
但是这个SQL还有优化空间它每执行一次都要先去timestamp对应的索引上找出符合条件的记录然后再把这些记录按照订单ID排序之后删除前1000条记录。
其实没有必要每次都按照timestamp比较订单所以我们可以先通过一次查询找到符合条件的历史订单中最大的那个订单ID然后在删除语句中把删除的条件转换成按主键删除。
```
select max(id) from orders
where timestamp &lt; SUBDATE(CURDATE(),INTERVAL 3 month);
delete from orders
where id &lt;= ?
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 &gt;= SUBDATE(CURDATE(),INTERVAL 3 month);
-- 修改替换表名
rename table orders to orders_to_be_droppd, orders_temp to orders;
-- 删除旧表
drop table orders_to_be_dropp
```
## 小结
对于订单这类具有时间属性的数据,会随时间累积,数据量越来越多,为了提升查询性能需要对数据进行拆分,首选的拆分方法是把旧数据归档到历史表中去。这种拆分方法能起到很好的效果,更重要的是对系统的改动小,升级成本低。
在迁移历史数据过程中,如果可以停服,最快的方式是重建一张新的订单表,然后把三个月内的订单数据复制到新订单表中,再通过修改表名让新的订单表生效。如果只能在线迁移,那需要分批迭代删除历史订单数据,删除的时候注意控制删除节奏,避免给线上数据库造成太大压力。
最后,我要再一次提醒你,线上数据操作非常危险,在操作之前一定要做好数据备份。
## 思考题
在数据持续增长的过程中,今天介绍的这种“归档历史订单”的数据拆分方法,和直接进行分库分表相比,比如说按照订单创建时间,自动拆分成每个月一张表,两种方法各有什么优点和缺点?欢迎你在留言区与我讨论。
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。