mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
mod
This commit is contained in:
124
极客时间专栏/后端存储实战课/创业篇/01 | 创建和更新订单时,如何保证数据准确无误?.md
Normal file
124
极客时间专栏/后端存储实战课/创业篇/01 | 创建和更新订单时,如何保证数据准确无误?.md
Normal file
@@ -0,0 +1,124 @@
|
||||
<audio id="audio" title="01 | 创建和更新订单时,如何保证数据准确无误?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/bc/971dc14291bb453544498865d1e1a9bc.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
订单系统是整个电商系统中最重要的一个子系统,订单数据也就是电商企业最重要的数据资产。今天这节课,我来和你说一下,在设计和实现一个订单系统的存储过程中,有哪些问题是要特别考虑的。
|
||||
|
||||
一个合格的订单系统,最基本的要求是什么?**数据不能错。**
|
||||
|
||||
一个购物流程,从下单开始、支付、发货,直到收货,这么长的一个流程中,每一个环节,都少不了更新订单数据,每一次更新操作又需要同时更新好几张表。这些操作可能被随机分布到很多台服务器上执行,服务器有可能故障,网络有可能出问题。
|
||||
|
||||
在这么复杂的情况下,保证订单数据一笔都不能错,是不是很难?实际上,只要掌握了方法,其实并不难。
|
||||
|
||||
- 首先,你的代码必须是正确没Bug的,如果说是因为代码Bug导致的数据错误,那谁也救不了你。
|
||||
- 然后,你要会正确地使用数据库的事务。比如,你在创建订单的时候,同时要在订单表和订单商品表中插入数据,那这些插入数据的INSERT必须在一个数据库事务中执行,数据库的事务可以确保:执行这些INSERT语句,要么一起都成功,要么一起都失败。
|
||||
|
||||
我相信这些“基本操作”对于你来说,应该不是问题。
|
||||
|
||||
但是,还有一些情况下会引起数据错误,我们一起来看一下。不过在此之前,我们要明白,对于一个订单系统而言,它的核心功能和数据结构是怎样的。
|
||||
|
||||
因为,任何一个电商,它的订单系统的功能都是独一无二的,基于它的业务,有非常多的功能,并且都很复杂。我们在讨论订单系统的存储问题时,必须得化繁为简,只聚焦那些最核心的、共通的业务和功能上,并且以这个为基础来讨论存储技术问题。
|
||||
|
||||
## 订单系统的核心功能和数据
|
||||
|
||||
我先和你简单梳理一下一个订单系统必备的功能,它包含但远远不限于:
|
||||
|
||||
1. 创建订单;
|
||||
1. 随着购物流程更新订单状态;
|
||||
1. 查询订单,包括用订单数据生成各种报表。
|
||||
|
||||
为了支撑这些必备功能,在数据库中,我们至少需要有这样几张表:
|
||||
|
||||
1. 订单主表:也叫订单表,保存订单的基本信息。
|
||||
1. 订单商品表:保存订单中的商品信息。
|
||||
1. 订单支付表:保存订单的支付和退款信息。
|
||||
1. 订单优惠表:保存订单使用的所有优惠信息。
|
||||
|
||||
这几个表之间的关系是这样的:订单主表和后面的几个子表都是一对多的关系,关联的外键就是订单主表的主键,也就是订单号。
|
||||
|
||||
绝大部分订单系统它的核心功能和数据结构都是这样的。
|
||||
|
||||
## 如何避免重复下单?
|
||||
|
||||
接下来我们来看一个场景。一个订单系统,提供创建订单的HTTP接口,用户在浏览器页面上点击“提交订单”按钮的时候,浏览器就会给订单系统发一个创建订单的请求,订单系统的后端服务,在收到请求之后,往数据库的订单表插入一条订单数据,创建订单成功。
|
||||
|
||||
假如说,用户点击“创建订单”的按钮时手一抖,点了两下,浏览器发了两个HTTP请求,结果是什么?创建了两条一模一样的订单。这样肯定不行,需要做防重。
|
||||
|
||||
有的同学会说,前端页面上应该防止用户重复提交表单,你说的没错。但是,网络错误会导致重传,很多RPC框架、网关都会有自动重试机制,所以对于订单服务来说,重复请求这个事儿,你是没办法完全避免的。
|
||||
|
||||
解决办法是,**让你的订单服务具备幂等性。**什么是幂等呢?一个幂等操作的特点是,其任意多次执行所产生的影响均与一次执行的影响相同。也就是说,一个幂等的方法,使用同样的参数,对它进行调用多次和调用一次,对系统产生的影响是一样的。所以,对于幂等的方法,不用担心重复执行会对系统造成任何改变。一个幂等的创建订单服务,无论创建订单的请求发送多少次,正确的结果是,数据库只有一条新创建的订单记录。
|
||||
|
||||
这里面有一个不太好解决的问题:对于订单服务来说,它怎么知道发过来的创建订单请求是不是重复请求呢?
|
||||
|
||||
在插入订单数据之前,先查询一下订单表里面有没有重复的订单,行不行?不太行,因为你很难用SQL的条件来定义“重复的订单”,订单用户一样、商品一样、价格一样,就认为是重复订单么?不一定,万一用户就是连续下了两个一模一样的订单呢?所以这个方法说起来容易,实际上很难实现。
|
||||
|
||||
很多电商解决这个问题的思路是这样的。在数据库的最佳实践中有一条就是,数据库的每个表都要有主键,绝大部分数据表都遵循这个最佳实践。一般来说,我们在往数据库插入一条记录的时候,都不提供主键,由数据库在插入的同时自动生成一个主键。这样重复的请求就会导致插入重复数据。
|
||||
|
||||
我们知道,表的主键自带唯一约束,如果我们在一条INSERT语句中提供了主键,并且这个主键的值在表中已经存在,那这条INSERT会执行失败,数据也不会被写入表中。**我们可以利用数据库的这种“主键唯一约束”特性,在插入数据的时候带上主键,来解决创建订单服务的幂等性问题。**
|
||||
|
||||
具体的做法是这样的,我们给订单系统增加一个“生成订单号”的服务,这个服务没有参数,返回值就是一个新的、全局唯一的订单号。在用户进入创建订单的页面时,前端页面先调用这个生成订单号服务得到一个订单号,在用户提交订单的时候,在创建订单的请求中带着这个订单号。
|
||||
|
||||
这个订单号也是我们订单表的主键,这样,无论是用户手抖,还是各种情况导致的重试,这些重复请求中带的都是同一个订单号。订单服务在订单表中插入数据的时候,执行的这些重复INSERT语句中的主键,也都是同一个订单号。数据库的唯一约束就可以保证,只有一次INSERT语句是执行成功的,这样就实现了创建订单服务幂等性。
|
||||
|
||||
为了便于你理解,我把上面这个幂等创建订单的流程,绘制成了时序图供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/17/667089ecbfdf18733c83c3d07783fa17.jpg" alt="">
|
||||
|
||||
还有一点需要注意的是,如果是因为重复订单导致插入订单表失败,订单服务不要把这个错误返回给前端页面。否则,就有可能出现这样的情况:用户点击创建订单按钮后,页面提示创建订单失败,而实际上订单却创建成功了。正确的做法是,遇到这种情况,订单服务直接返回订单创建成功就可以了。
|
||||
|
||||
## 如何解决ABA问题?
|
||||
|
||||
同样,订单系统各种更新订单的服务一样也要具备幂等性。
|
||||
|
||||
这些更新订单服务,比如说支付、发货等等这些步骤中的更新订单操作,最终落到订单库上,都是对订单主表的UPDATE操作。数据库的更新操作,本身就具备天然的幂等性,比如说,你把订单状态,从未支付更新成已支付,执行一次和重复执行多次,订单状态都是已支付,不用我们做任何额外的逻辑,这就是天然幂等。
|
||||
|
||||
那在实现这些更新订单服务时,还有什么问题需要特别注意的吗?还真有,在并发环境下,你需要注意ABA问题。
|
||||
|
||||
什么是ABA问题呢?我举个例子你就明白了。比如说,订单支付之后,小二要发货,发货完成后要填个快递单号。假设说,小二填了一个单号666,刚填完,发现填错了,赶紧再修改成888。对订单服务来说,这就是2个更新订单的请求。
|
||||
|
||||
正常情况下,订单中的快递单号会先更新成666,再更新成888,这是没问题的。那不正常情况呢?666请求到了,单号更新成666,然后888请求到了,单号又更新成888,但是666更新成功的响应丢了,调用方没收到成功响应,自动重试,再次发起666请求,单号又被更新成666了,这数据显然就错了。这就是非常有名的ABA问题。
|
||||
|
||||
具体的时序你可以参考下面这张时序图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/f5/6007b4d5a6e804e755e91c5f1d3cd2f5.jpg" alt="">
|
||||
|
||||
ABA问题怎么解决?这里给你提供一个比较通用的解决方法。给你的订单主表增加一列,列名可以叫version,也即是“版本号”的意思。每次查询订单的时候,版本号需要随着订单数据返回给页面。页面在更新数据的请求中,需要把这个版本号作为更新请求的参数,再带回给订单更新服务。
|
||||
|
||||
订单服务在更新数据的时候,需要比较订单当前数据的版本号,是否和消息中的版本号一致,如果不一致就拒绝更新数据。如果版本号一致,还需要再更新数据的同时,把版本号+1。“比较版本号、更新数据和版本号+1”,这个过程必须在同一个事务里面执行。
|
||||
|
||||
具体的SQL可以这样来写:
|
||||
|
||||
```
|
||||
UPDATE orders set tracking_number = 666, version = version + 1
|
||||
WHERE version = 8;
|
||||
|
||||
```
|
||||
|
||||
在这条SQL的WHERE条件中,version的值需要页面在更新的时候通过请求传进来。
|
||||
|
||||
通过这个版本号,就可以保证,从我打开这条订单记录开始,一直到我更新这条订单记录成功,这个期间没有其他人修改过这条订单数据。因为,如果有其他人修改过,数据库中的版本号就会改变,那我的更新操作就不会执行成功。我只能重新查询新版本的订单数据,然后再尝试更新。
|
||||
|
||||
有了这个版本号,再回头看一下我们上面那个ABA问题的例子,会出现什么结果?可能出现两种情况:
|
||||
|
||||
1. 第一种情况,把运单号更新为666的操作成功了,更新为888的请求带着旧版本号,那就会更新失败,页面提示用户更新888失败。
|
||||
1. 第二种情况,666更新成功后,888带着新的版本号,888更新成功。这时候即使重试的666请求再来,因为它和上一条666请求带着相同的版本号,上一条请求更新成功后,这个版本号已经变了,所以重试请求的更新必然失败。
|
||||
|
||||
无论哪种情况,数据库中的数据与页面上给用户的反馈都是一致的。这样就可以实现幂等更新并且避免了ABA问题。下图展示的是第一种情况,第二种情况也是差不多的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/a5/02497bcdaf0e37a7e6f92d180a4c38a5.jpg" alt="">
|
||||
|
||||
## 小结
|
||||
|
||||
我们把今天这节课的内容做一个总结。今天这节课,实际上就讲了一个事儿,也就是,实现订单操作的幂等的方法。
|
||||
|
||||
因为网络、服务器等等这些不确定的因素,重试请求是普遍存在并且不可避免的。具有幂等性的服务可以完美地克服重试导致的数据错误。
|
||||
|
||||
对于创建订单服务来说,可以通过预先生成订单号,然后利用数据库中订单号的唯一约束这个特性,避免重复写入订单,实现创建订单服务的幂等性。对于更新订单服务,可以通过一个版本号机制,每次更新数据前校验版本号,更新数据同时自增版本号,这样的方式,来解决ABA问题,确保更新订单服务的幂等性。
|
||||
|
||||
通过这样两种幂等的实现方法,就可以保证,无论请求是不是重复,订单表中的数据都是正确的。当然,上面讲到的实现订单幂等的方法,你完全可以套用在其他需要实现幂等的服务中,只需要这个服务操作的数据保存在数据库中,并且有一张带有主键的数据表就可以了。
|
||||
|
||||
## 思考题
|
||||
|
||||
实现服务幂等的方法,远不止我们这节课上介绍的这两种,课后请你想一下,在你负责开发的业务系统中,能不能用这节课中讲到的方法来实现幂等?除了这两种方法以外,还有哪些实现服务幂等的方法?欢迎你在留言区与我交流互动。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。
|
||||
119
极客时间专栏/后端存储实战课/创业篇/02 | 流量大、数据多的商品详情页系统该如何设计?.md
Normal file
119
极客时间专栏/后端存储实战课/创业篇/02 | 流量大、数据多的商品详情页系统该如何设计?.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<audio id="audio" title="02 | 流量大、数据多的商品详情页系统该如何设计?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/15/fe022a8f65e1e413acba849f4836ed15.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
今天这节课我们看一下,如何设计一个快速、可靠的存储架构支撑商品系统。
|
||||
|
||||
相对于上节课提到的订单系统,电商的商品系统主要功能就是增删改查商品信息,没有很复杂的业务逻辑,支撑的主要页面就是商品详情页(下文简称:商详)。不过,设计这个系统的存储,你仍然需要着重考虑两个方面的问题。
|
||||
|
||||
**第一,要考虑高并发的问题。**不管是什么电商系统,商详页一定是整个系统中DAU(日均访问次数)最高的页面之一。这个也不难理解,用户购物么,看商详了不一定买,买之前一定会看好多商详货比三家,所以商详的浏览次数要远比系统的其他页面高。如果说,在设计存储的时候,没有考虑到高并发的问题,大促的时候,支撑商详页的商品系统必然是第一个被流量冲垮的系统。
|
||||
|
||||
**第二,要考虑的是商品数据规模的问题。**商详页的数据规模,我总结了六个字,叫:**数量多,重量大**。
|
||||
|
||||
先说为什么数量多,国内一线的电商,SKU(直译为:库存单元,在电商行业,你可以直接理解为“商品”)的数量大约在几亿到几十亿这个量级。当然实际上并没有这么多种商品,这里面有很多原因,比如同一个商品它有不同版本型号,再比如,商家为了促销需要,可能会反复上下架同一个商品或者给同一个商品配不同的马甲,这都导致了SKU数量爆炸。
|
||||
|
||||
再说这个“重量大”,你可以打开一个电商商详页看一下,从上一直拉到底,你看看有多长?十屏以内的商详页那都叫短的,并且这里面不光有大量的文字,还有大量的图片和视频,甚至还有AR/VR的玩法在里面,所以说,每个商详页都是个“大胖子”。
|
||||
|
||||
支持商品系统的存储,要保存这么多的“大胖子”,还要支撑高并发,任务艰巨。
|
||||
|
||||
## 商品系统需要保存哪些数据?
|
||||
|
||||
先来看一下,一个商详页都有哪些信息需要保存。我把一个商详页里面的所有信息总结了一下,放在下面这张思维导图里面。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/91/94580cba1683eba8f098218e3c9e9791.jpg" alt="">
|
||||
|
||||
这里面,右边灰色的部分,来自于电商的其他系统,我们暂且不去管这些,左边彩色部分,都是商品系统需要存储的内容。
|
||||
|
||||
这么多内容怎么存?能不能像保存订单数据那样,设计一张商品表,把这些数据一股脑儿都放进去?一张表存不下就再加几张子表,这样行不行?你还真别说不行,现在这些电商大厂,在它们发展的早期就是这么干的。现在那么复杂的分布式存储架构,都是一点儿一点儿逐步演进过来的。
|
||||
|
||||
这么做的好处,就是糙快猛,简单可靠而且容易实现,但是,撑不了多少数据量,也撑不了多少并发。如果说,你要低成本快速构建一个小规模电商,这么做还真就是一个挺合理的选择。
|
||||
|
||||
当然,规模再大一点儿就不能这么干了。不能用数据库,那应该选择哪种存储系统来保存这么复杂的商品数据呢?任何一种存储都是没办法满足的,解决的思路是**分而治之,**我们可以把商品系统需要存储的数据按照特点,分成商品基本信息、商品参数、图片视频和商品介绍几个部分来分别存储。
|
||||
|
||||
## 商品基本信息该如何存储?
|
||||
|
||||
我们先来分析商品的基本信息,它包括商品的主副标题、价格、颜色等一些商品最基本、主要的属性。这些属性都是固定的,不太可能会因为需求或者不同的商品而变化,而且,这部分数据也不会太大。所以,还是建议你在数据库中建一张表来保存商品的基本信息。
|
||||
|
||||
然后,还需要在数据库前面,加一个缓存,帮助数据抵挡绝大部分的读请求。这个缓存,你可以使用Redis,也可以用Memcached,这两种存储系统都是基于内存的KV存储,都能解决问题。
|
||||
|
||||
接下来我和你简单看一下,如何来使用前置缓存来缓存商品数据。
|
||||
|
||||
处理商品信息的读请求时,先去缓存查找,如果找到就直接返回缓存中的数据。如果在缓存中没找到,再去查数据库,把从数据库中查到的商品信息返回给页面,顺便把数据在缓存里也放一份。
|
||||
|
||||
更新商品信息的时候,在更新数据库的同时,也要把缓存中的数据给删除掉。不然就有可能出现这种情况:数据库中的数据变了,而缓存中的数据没变,商详页上看到的还是旧数据。
|
||||
|
||||
这种缓存更新的策略,称为**Cache Aside**,是最简单实用的一种缓存更新策略,适用范围也最广泛。如果你要缓存数据,没有什么特殊的情况,首先就应该考虑使用这个策略。
|
||||
|
||||
除了Cache Aside以外,还有Read/Write Through、Write Behind等几种策略,分别适用于不同的情况,后面的课程中我会专门来讲。
|
||||
|
||||
设计商品基本信息表的时候,有一点需要提醒你的是,**一定要记得保留商品数据的每一个历史版本**。因为商品数据是随时变化的,但是订单中关联的商品数据,必须是下单那个时刻的商品数据,这一点很重要。你可以为每一个历史版本的商品数据保存一个快照,可以创建一个历史表保存到MySQL中,也可以保存到一些KV存储中。
|
||||
|
||||
## 使用MongoDB保存商品参数
|
||||
|
||||
我们再来分析商品参数,参数就是商品的特征。比如说,电脑的内存大小、手机的屏幕尺寸、酒的度数、口红的色号等等。和商品的基本属性一样,都是结构化的数据。但麻烦的是,不同类型的商品,它的参数是完全不一样的。
|
||||
|
||||
如果我们设计一个商品参数表,那这个表的字段就会太多了,并且每增加一个品类的商品,这个表就要加字段,这个方案行不通。
|
||||
|
||||
既然一个表不能解决问题,那就每个类别分别建一张表。比如说,建一个电脑参数表,里面的字段有CPU型号、内存大小、显卡型号、硬盘大小等等;再建一个酒类参数表,里面的字段有酒精度数、香型、产地等等。如果说,品类比较少,在100个以内,用几十张表分别保存不同品类的商品参数,这样做也是可以的。但是,有没有更好的方法呢?
|
||||
|
||||
大多数数据库,都要求数据表要有一个固定的结构。但有一种数据库,没有这个要求。特别适合保存像“商品参数”这种,属性不固定的数据,这个数据库就是MongoDB。
|
||||
|
||||
MongoDB是一个面向文档存储的NoSQL数据库,在MongoDB中,表、行、列对应的概念分别是:collection、document、field,其实都是一回事儿,为了便于你理解,在这里我们不咬文嚼字,还是用“表、行、列”来说明。
|
||||
|
||||
MongoDB最大的特点就是,它的“表结构”是不需要事先定义的,其实,在MongoDB中根本没有表结构。由于没有表结构,它支持你把任意数据都放在同一张表里,你甚至可以在一张表里保存商品数据、订单数据、物流信息等这些结构完全不同的数据。并且,还能支持按照数据的某个字段进行查询。
|
||||
|
||||
它是怎么做到的呢?MongoDB中的每一行数据,在存储层就是简单地被转化成BSON格式后存起来,这个BSON就是一种更紧凑的JSON。所以,即使在同一张表里面,它每一行数据的结构都可以是不一样的。当然,这样的灵活性也是有代价的,MongoDB不支持SQL,多表联查和复杂事务比较孱弱,不太适合存储一般的数据。
|
||||
|
||||
但是,对于商品参数信息,数据量大、数据结构不统一,这些MongoDB都可以很好的满足。我们也不需要事务和多表联查,MongoDB简直就是为了保存商品参数量身定制的一样。
|
||||
|
||||
## 使用对象存储保存图片和视频
|
||||
|
||||
图片和视频由于占用存储空间比较大,一般的存储方式都是,在数据库中只保存图片视频的ID或者URL,实际的图片视频以文件的方式单独存储。
|
||||
|
||||
现在图片和视频存储技术已经非常成熟了,首选的方式就是保存在对象存储(Object Storage)中。各大云厂商都提供对象存储服务,比如国内的七牛云、AWS的S3等等,也有开源的对象存储产品,比如MinIO,可以私有化部署。虽然每个产品的API都不一样,但功能大同小异。
|
||||
|
||||
对象存储可以简单理解为一个无限容量的大文件KV存储,它的存储单位是对象,其实就是文件,可以是一张图片,一个视频,也可以是其他任何文件。每个对象都有一个唯一的key,利用这个key就可以随时访问对应的对象。基本的功能就是写入、访问和删除对象。
|
||||
|
||||
云服务厂商的对象存储大多都提供了客户端API,可以在Web页面或者App中直接访问而不用通过后端服务来中转。这样,App和页面在上传图片视频的时候,直接保存到对象存储中,然后把对应key保存在商品系统中就可以了。
|
||||
|
||||
访问图片视频的时候,真正的图片和视频文件也不需要经过商品系统的后端服务,页面直接通过对象存储提供的URL来访问,又省事儿又节约带宽。而且,几乎所有的对象存储云服务都自带CDN(Content Delivery Network)加速服务,响应时间比直接请求业务的服务器更短。
|
||||
|
||||
国内的很多云厂商的对象存储对图片和视频,都做了非常多的针对性优化。最有用的是,缩放图片和视频转码,你只要把图片和视频丢到对象存储中,就可以随时获得任意尺寸大小的图片,视频也会自动转码成各种格式和码率的版本,适配各种App和场景。我只能说,谁用谁知道,真香!
|
||||
|
||||
## 将商品介绍静态化
|
||||
|
||||
商品介绍在商详页中占得比重是最大的,包含了大量的带格式文字、图片和视频。其中图片和视频自然要存放在对象存储里面,商品介绍的文本,一般都是随着商详页一起静态化,保存在HTML文件中。
|
||||
|
||||
什么是静态化呢?静态化是相对于动态页面来说的。一般我们部署到Tomcat中的Web系统,返回的都是动态页面,也就是在Web请求时,动态生成的。比如说商详页,一个Web请求过来,带着SKUID,Tomcat中的商详页模块,再去访问各种数据库、调用后端服务,动态把这个商详页拼出来,返回给浏览器。
|
||||
|
||||
不过,现在基本上没有系统会这么干了,你想,对于每个SKU的商详页,你每次动态生成的页面内容不是完全一样的么?生成这么多次,不仅浪费服务器资源,速度还慢,关键问题是,Tomcat能能抗的并发量和Nginx完全不是一个数量级的。
|
||||
|
||||
商详页的绝大部分内容都是商品介绍,它是不怎么变的。那不如就把这个页面事先生成好,保存成一个静态的HTML,访问商详页的时候,直接返回这个HTML。这就是静态化。
|
||||
|
||||
商详页静态化之后,不仅仅是可以节省服务器资源,还可以利用CDN加速,把商详页放到离用户最近的CDN服务器上,让商详页访问更快。
|
||||
|
||||
至于商品价格、促销信息等这些需要频繁变动的信息,不能静态化到页面中,可以在前端页面使用AJAX请求商品系统动态获取。这样就兼顾了静态化带来的优势,也能解决商品价格等信息需要实时更新的问题。
|
||||
|
||||
## 小结
|
||||
|
||||
最后,我们再来对今天的内容复个盘。商品系统的存储需要提供商品的基本信息、商品参数、图片和视频以及商品介绍等等这些数据。商品的基本信息和商品参数分别保存在MySQL和MongoDB中,用Redis作为前置缓存,图片和视频存放在对象存储中,商品介绍随着商详页一起静态化到商详静态页中。
|
||||
|
||||
我把商品系统的存储绘制成下面这张图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/ad/8dfdedfd574218f0cb3c19daa6fbb2ad.jpg" alt="">
|
||||
|
||||
一起来看一下图,这样一个商品系统的存储最终的效果是什么样的?图中实线表示每访问一次商详页,需要真正传输的数据,虚线表示当商详页数据发生变化的时候才需要进行一次数据传输。用户打开一个SKU的商详页时,首先去CDN获取商详页的HTML,然后访问商品系统获取价格等频繁变化的信息,这些信息从Redis缓存中获取。图片和视频信息,也是从对象存储的CDN中获取。
|
||||
|
||||
分析一下效果,数据量最大的图片、视频和商品介绍都是从离用户最近的CDN服务商获取的,速度快,节约带宽。真正打到商品系统的请求,就是价格这些需要动态获取的商品信息,一般做一次Redis查询就可以了,基本不会有流量打到MySQL中。
|
||||
|
||||
这样一个商品系统的存储的架构,把大部分请求都转移到了又便宜速度又快的CDN服务器上,可以用很少量的服务器和带宽资源,抗住大量的并发请求。
|
||||
|
||||
## 思考题
|
||||
|
||||
如果说,用户下单这个时刻,正好赶上商品调价,就有可能出现这样的情况:我明明在商详页看到的价格是10块钱,下单后,怎么变成15块了?你的系统是不是偷偷在坑我?
|
||||
|
||||
这样给用户的体验非常不好。你不要以为这是一个小概率事件,当你的系统用户足够多的时候,每时每刻都有人在下单,这几乎是个必然出现的事件。
|
||||
|
||||
课后请你想一下,该怎么来解决这个问题?欢迎你在留言区与我交流互动。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。
|
||||
152
极客时间专栏/后端存储实战课/创业篇/03 | 复杂而又重要的购物车系统,应该如何设计?.md
Normal file
152
极客时间专栏/后端存储实战课/创业篇/03 | 复杂而又重要的购物车系统,应该如何设计?.md
Normal file
@@ -0,0 +1,152 @@
|
||||
<audio id="audio" title="03 | 复杂而又重要的购物车系统,应该如何设计?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a6/9a/a6434898c2b7505a669c2b344479af9a.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
今天这节课我们来说一下购物车系统的存储该如何设计。
|
||||
|
||||
首先,我们来看购物车系统的主要功能是什么。就是在用户选购商品时,下单之前,暂存用户想要购买的商品。购物车对数据可靠性要求不高,性能也没有特别的要求,在整个电商系统中,看起来是相对比较容易设计和实现的一个子系统。
|
||||
|
||||
购物车系统的功能,主要的就三个:把商品加入购物车(后文称“加购”)、购物车列表页、发起结算下单,再加上一个在所有界面都要显示的购物车小图标。
|
||||
|
||||
支撑购物车的这几个功能,对应的存储模型应该怎么设计?很简单,只要一个“购物车”实体就够了。它的主要属性有什么?你打开京东的购物车页面,对着抄就设计出来了:SKUID(商品ID)、数量、加购时间和勾选状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/73/ac4dffc68c2aaf39a9f9d4003c50f773.png" alt="" title="备注:图片来源于网络,仅供本文介绍、评论及说明某问题,适当引用。">
|
||||
|
||||
这个“勾选状态”属性,就是在购物车界面中,每件商品前面的那个小对号,表示在结算下单时,是不是要包含这件商品。至于商品价格和总价、商品介绍等等这些信息,都可以实时从其他系统中获取,不需要购物车系统来保存。
|
||||
|
||||
购物车的功能虽然很简单,但是在设计购物车系统的存储时,仍然有一些特殊的问题需要考虑。
|
||||
|
||||
## 设计购物车存储时需要把握什么原则?
|
||||
|
||||
比如下面这几个问题:
|
||||
|
||||
1. 用户没登录,在浏览器中加购,关闭浏览器再打开,刚才加购的商品还在不在?
|
||||
1. 用户没登录,在浏览器中加购,然后登录,刚才加购的商品还在不在?
|
||||
1. 关闭浏览器再打开,上一步加购的商品在不在?
|
||||
1. 再打开手机,用相同的用户登录,第二步加购的商品还在不在呢?
|
||||
|
||||
上面这几个问题是不是有点儿绕?没关系,我们先简单解释一下这四个问题:
|
||||
|
||||
1. 如果用户没登录,加购的商品也会被保存在用户的电脑里,这样即使关闭浏览器再打开,购物车的商品仍然存在。
|
||||
1. 如果用户先加购,再登录,登录前加购的商品就会被自动合并到用户名下,所以登录后购物车中仍然有登录前加购的商品。
|
||||
1. 关闭浏览器再打开,这时又变为未登录状态,但是之前未登录时加购的商品已经被合并到刚刚登录的用户名下了,所以购物车是空的。
|
||||
1. 使用手机登录相同的用户,看到的就是该用户的购物车,这时无论你在手机App、电脑还是微信中登录,只要是相同的用户,看到是同一个购物车,所以第二步加购的商品是存在的。
|
||||
|
||||
所以,上面这四个问题的答案依次是:存在、存在、不存在、存在。
|
||||
|
||||
如果你没有设计或者开发过购物车系统,你可能并不会想到购物车还有这么多弯弯绕。但是,作为一个开发者,如果你不仔细把这些问题考虑清楚,用户在使用购物车的时候,就会感觉你的购物车系统不好用,不是加购的商品莫名其妙地丢了,就是购物车莫名其妙地多出来一些商品。
|
||||
|
||||
要解决上面这些问题,其实只要在存储设计时,把握这几个原则就可以了:
|
||||
|
||||
1. 如果未登录,需要临时暂存购物车的商品;
|
||||
1. 用户登录时,把暂存购物车的商品合并到用户购物车中,并且清除暂存购物车;
|
||||
1. 用户登陆后,购物车中的商品,需要在浏览器、手机APP和微信等等这些终端中都保持同步。
|
||||
|
||||
实际上,购物车系统需要保存两类购物车,**一类是未登录情况下的“暂存购物车”,一类是登录后的“用户购物车”**。
|
||||
|
||||
## 如何设计“暂存购物车”的存储?
|
||||
|
||||
我们先来看下暂存购物车的存储该怎么实现。暂存购物车应该存在客户端还是存在服务端?
|
||||
|
||||
如果保存在服务端,那每个暂存购物车都需要有一个全局唯一的标识,这个标识并不太容易设计,并且,存在服务端还要浪费服务端的资源。所以,肯定是保存在客户端好,既可以节约服务器的存储资源,也没有购物车标识的问题,因为每个客户端就保存它自己唯一一个购物车就可以了,不需要标识。
|
||||
|
||||
客户端的存储可以选择的不太多:Session、Cookie和LocalStorage,其中浏览器的LocalStorage和App的本地存储是类似的,我们都以LocalStorage来代表。
|
||||
|
||||
存在哪儿最合适?SESSION是不太合适的,原因是,SESSION的保留时间短,而且SESSION的数据实际上还是保存在服务端的。剩余的两种存储,Cookie和LocalStorage都可以用来保存购物车数据,选择哪种方式更好呢?各有优劣。
|
||||
|
||||
在我们这个场景中,使用Cookie和LocalStorage最关键的区别是,客户端和服务端的每次交互,都会自动带着Cookie数据往返,这样服务端可以读写客户端Cookie中的数据,而LocalStorage里的数据,只能由客户端来访问。
|
||||
|
||||
使用Cookie存储,实现起来比较简单,加减购物车、合并购物车的过程中,由于服务端可以读写Cookie,这样全部逻辑都可以在服务端实现,并且客户端和服务端请求的次数也相对少一些。
|
||||
|
||||
使用LocalStorage存储,实现相对就复杂一点儿,客户端和服务端都要实现一些业务逻辑,但LocalStorage的好处是,它的存储容量比Cookie的4KB上限要大得多,而且不用像Cookie那样,无论用不用,每次请求都要带着,可以节省带宽。
|
||||
|
||||
所以,选择Cookie或者是LocalStorage来存储暂存购物车都是没问题的,你可以根据它俩各自的优劣势来选择。比如你设计的是个小型电商,那用Cookie存储实现起来更简单。再比如,你的电商是面那种批发的行业用户,用户需要加购大量的商品,那Cookie可能容量不够用,选择LocalStorage就更合适。
|
||||
|
||||
不管选择哪种存储,暂存购物车保存的数据格式都是一样的,参照我们实体模型来设计就可以,我们可以直接用JSON表示:
|
||||
|
||||
```
|
||||
{
|
||||
"cart": [
|
||||
{
|
||||
"SKUID": 8888,
|
||||
"timestamp": 1578721136,
|
||||
"count": 1,
|
||||
"selected": true
|
||||
},
|
||||
{
|
||||
"SKUID": 6666,
|
||||
"timestamp": 1578721138,
|
||||
"count": 2,
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 如何设计“用户购物车”的存储?
|
||||
|
||||
接下来,我们再来看下用户购物车的存储该怎么实现。因为用户购物车必须要保证多端的数据同步,所以数据必须保存在服务端。常规的思路是,设计一张购物车表,把数据存在MySQL中。这个表的结构同样可以参照刚刚讲的实体模型来设计:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/cc/e8e7ae1638ec77c7bcc1ff949939b4cc.jpeg" alt="">
|
||||
|
||||
注意,需要在user_id上建一个索引,因为查询购物车表时,都是以user_id作为查询条件来查询的。
|
||||
|
||||
你也可以选择更快的Redis来保存购物车数据,以用户ID作为Key,用一个Redis的HASH作为Value来保存购物车中的商品。比如:
|
||||
|
||||
```
|
||||
{
|
||||
"KEY": 6666,
|
||||
"VALUE": [
|
||||
{
|
||||
"FIELD": 8888,
|
||||
"FIELD_VALUE": {
|
||||
"timestamp": 1578721136,
|
||||
"count": 1,
|
||||
"selected": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"FIELD": 6666,
|
||||
"FIELD_VALUE": {
|
||||
"timestamp": 1578721138,
|
||||
"count": 2,
|
||||
"selected": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里为了便于你理解,我们用JSON来表示Redis中HASH的数据结构,其中KEY中的值6666是一个用户ID,FIELD里存放的是商品ID,FIELD_VALUE是一个JSON字符串,保存加购时间、商品数量和勾选状态。
|
||||
|
||||
大家都知道,从读写性能上来说,Redis是比MySQL快非常多的,那是不是用Redis就一定比用MySQL更好呢?我们来比较一下使用MySQL和Redis两种存储的优劣势:
|
||||
|
||||
1. 显然使用Redis性能要比MySQL高出至少一个量级,响应时间更短,可以支撑更多的并发请求,“天下武功,唯快不破”,这一点Redis完胜。
|
||||
1. MySQL的数据可靠性是要好于Redis的,因为Redis是异步刷盘,如果出现服务器掉电等异常情况,Redis是有可能会丢数据的。但考虑到购物车里的数据,对可靠性要求也没那么苛刻,丢少量数据的后果也就是,个别用户的购物车少了几件商品,问题也不大。所以,在购物车这个场景下,Redis的数据可靠性不高这个缺点,并不是不能接受的。
|
||||
1. MySQL的另一个优势是,它支持丰富的查询方式和事务机制,这两个特性,对我们今天讨论的这几个购物车核心功能没什么用。但是,每一个电商系统都有它个性化的需求,如果需要以其他方式访问购物车的数据,比如说,统计一下今天加购的商品总数,这个时候,使用MySQL存储数据,就很容易实现,而使用Redis存储,查询起来就非常麻烦而且低效。
|
||||
|
||||
综合比较下来,考虑到需求总是不断变化,还是更推荐你使用MySQL来存储购物车数据。如果追求性能或者高并发,也可以选择使用Redis。
|
||||
|
||||
你可以感受到,我们设计存储架构的过程就是一个不断做选择题的过程。很多情况下,可供选择的方案不止一套,选择的时候需要考虑实现复杂度、性能、系统可用性、数据可靠性、可扩展性等等非常多的条件。需要强调的是,**这些条件每一个都不是绝对不可以牺牲的,不要让一些“所谓的常识”禁锢了你的思维。**
|
||||
|
||||
比如,一般我们都认为数据是绝对不可以丢的,也就是说不能牺牲数据可靠性。但是,像刚刚讲到的用户购物车的存储,使用Redis替代MySQL,就是牺牲了数据可靠性换取高性能。我们仔细分析后得出,很低概率的情况下丢失少量数据,是可以接受的。性能提升带来的收益远大于丢失少量数据而付出的代价,这个选择就是划算的。
|
||||
|
||||
如果说不考虑需求变化这个因素,牺牲一点点数据可靠性,换取大幅性能提升,选择Redis才是最优解。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们讲了购物车系统的存储该如何设计。
|
||||
|
||||
购物车系统的主要功能包括:加购、购物车列表页和结算下单。核心的实体就只有一个“购物车”实体,它至少要包括:SKUID、数量、加购时间和勾选状态这几个属性。
|
||||
|
||||
在给购物车设计存储时,为了确保购物车内的数据在多端保持一致,以及用户登录前后购物车内商品能无缝衔接,除了每个用户的“用户购物车”之外还要实现一个“暂存购物车”保存用户未登录时加购的商品,并在用户登录后自动合并“暂存购物车”和“用户购物车”。
|
||||
|
||||
暂存购物车存储在客户端浏览器或者App中,可以选择存放到Cookie或者LocalStorage中。用户购物车保存在服务端,可以选择使用Redis或者是MySQL存储,使用Redis存储会有更高的性能,可以支撑更多的并发请求,使用MySQL是更常规通用的方式,便于应对变化,系统的扩展性更好。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你思考一下,既然用户的购物车数据存放在MySQL或者是Redis中各有优劣势。那能不能把购物车数据存在MySQL中,并且用Redis来做缓存呢?这样不就可以兼顾两者的优势了么?这样做是不是可行?如果可行,如何来保证Redis中的数据和MySQL中的数据是一样的呢?
|
||||
|
||||
欢迎你在留言区与我讨论,如果你觉得今天学到的知识对你有帮助,也欢迎把它分享给你的朋友。
|
||||
350
极客时间专栏/后端存储实战课/创业篇/04 | 事务:账户余额总是对不上账,怎么办?.md
Normal file
350
极客时间专栏/后端存储实战课/创业篇/04 | 事务:账户余额总是对不上账,怎么办?.md
Normal file
@@ -0,0 +1,350 @@
|
||||
<audio id="audio" title="04 | 事务:账户余额总是对不上账,怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/cd/742c3366c9a78ee442097f80fa156dcd.mp3"></audio>
|
||||
|
||||
你好,我是李玥。今天这节课我们来说一下电商的账户系统。
|
||||
|
||||
账户系统负责记录和管理用户账户的余额,这个余额就是每个用户临时存在电商的钱,来源可能是用户充值或者退货退款等多种途径。
|
||||
|
||||
账户系统的用途也非常广泛,不仅仅是电商,各种互联网内容提供商、网络游戏服务商,电信运营商等等,都需要账户系统来管理用户账户的余额,或者是虚拟货币。包括银行的核心系统,也同样包含一个账户系统。
|
||||
|
||||
从业务需求角度来分析,一个最小化的账户系统,它的数据模型可以用下面这张表来表示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/fb/e435af4227850bc7f01b00f4959c10fb.jpg" alt="">
|
||||
|
||||
这个表包括用户ID、账户余额和更新时间三个字段。每次交易的时候,根据用户ID去更新这个账户的余额就可以了。
|
||||
|
||||
## 为什么总是对不上账?
|
||||
|
||||
每个账户系统都不是孤立存在的,至少要和财务、订单、交易这些系统有着密切的关联。理想情况下,账户系统内的数据应该是自洽的。所有用户的账户余额加起来,应该等于这个电商公司在银行专用账户的总余额。账户系统的数据也应该和其他系统的数据能对的上。比如说,每个用户的余额应该能和交易系统中充值记录,以及订单系统中的订单对的上。
|
||||
|
||||
不过,由于业务和系统的复杂性,现实情况却是,很少有账户系统能够做到一点不差的对上每一笔账。所以,稍微大型一点儿的系统,都会有一个专门的对账系统,来核对、矫正账户系统和其他系统之间的数据差异。
|
||||
|
||||
对不上账的原因非常多,比如业务变化、人为修改了数据、系统之间数据交换失败等等。那作为系统的设计者,我们只关注“如何避免由于技术原因导致的对不上账”就可以了,有哪些是因为技术原因导致的呢?比如说:网络请求错误,服务器宕机、系统Bug等。
|
||||
|
||||
“对不上账”是通俗的说法,它的本质问题是,**冗余数据的一致性问题**。
|
||||
|
||||
这里面的冗余数据并不是多余或者重复的数据,而是多份含有相同信息的数据。比如,我们完全可以通过用户的每一笔充值交易数据、消费的订单数据,来计算出这个用户当前的账户余额是多少。也就是说,账户余额数据和这些账户相关的交易记录,都含有“账户余额”这个信息,那它们之间就互为冗余数据。
|
||||
|
||||
在设计系统的存储时,原则上不应该存储冗余数据,一是浪费存储空间,二是让这些冗余数据保持一致是一件非常麻烦的事儿。但有些场景下存储冗余数据是必要的,比如用户账户的余额这个数据。
|
||||
|
||||
这个数据在交易过程中会被非常频繁地用到,总不能每次交易之前,先通过所有历史交易记录计算一下当前账户的余额,这样做速度太慢了,性能满足不了交易的需求。所以账户系统保存了每个用户的账户余额,这实际上是一种用**存储空间换计算时间**的设计。
|
||||
|
||||
如果说只是满足功能需求,账户系统只记录余额,每次交易的时候更新账户余额就够了。但是这样做有一个问题,如果账户余额被篡改,是没有办法追查的,所以在记录余额的同时,还需要记录每一笔交易记录,也就是账户的流水。流水的数据模型至少需要包含:流水ID、交易金额、交易时间戳以及交易双方的系统、账户、交易单号等信息。
|
||||
|
||||
虽然说,流水和余额也是互为冗余数据,但是记录流水,可以有效地修正由于系统Bug或者人为篡改导致的账户余额错误的问题,也便于账户系统与其他外部系统进行对账,所以账户系统记录流水是非常必要的。
|
||||
|
||||
在设计账户流水时,有几个重要的原则必须遵守,最好是用技术手段加以限制。
|
||||
|
||||
1. 流水记录只能新增,一旦记录成功不允许修改和删除。即使是由于正当原因需要取消一笔已经完成的交易,也不应该去删除交易流水。正确的做法是再记录一笔“取消交易”的流水。
|
||||
1. 流水号必须是递增的,我们需要用流水号来确定交易的先后顺序。
|
||||
|
||||
在对账的时候,一旦出现了流水和余额不一致,并且无法通过业务手段来确定到底是哪儿记错了的情况,一般的处理原则是以交易流水为准来修正余额数据,这样才能保证后续的交易能“对上账”。
|
||||
|
||||
那从技术上,如何保证账户系统中流水和余额数据一致呢?
|
||||
|
||||
## 使用数据库事务来保证数据一致性
|
||||
|
||||
在设计对外提供的服务接口时,不能提供单独更新余额或者流水的功能,只提供交易功能。我们需要在实现交易功能的时候,同时记录流水并修改余额,并且要尽可能保证,在任何情况下,记录流水和修改余额这两个操作,要么都成功,要么都失败。不能有任何一笔交易出现,记录了流水但余额没更新,或者更新了余额但是没记录流水。
|
||||
|
||||
这个事儿说起来挺简单,但实际上是非常难实现的。毕竟应用程序只能先后来执行两个操作,执行过程中,可能会发生网络错误、系统宕机等各种异常的情况,所以对于应用程序来说,很难保证这两个操作都成功或者都失败。
|
||||
|
||||
数据库提供了事务机制来解决这个问题,实际上事务这个特性最初就是被设计用来解决交易问题的,在英文中,事务和交易就是同一个单词:Transaction。
|
||||
|
||||
我们先看一下如何来使用MySQL的事务,实现一笔交易。比如说,在事务中执行一个充值100元的交易,先记录一条交易流水,流水号是888,然后把账户余额从100元更新到200元。对应的SQL是这样的:
|
||||
|
||||
```
|
||||
mysql> begin; -- 开始事务
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
mysql> insert into account_log ...; -- 写入交易流水
|
||||
Query OK, 1 rows affected (0.01 sec)
|
||||
|
||||
mysql> update account_balance ...; -- 更新账户余额
|
||||
Query OK, 1 rows affected (0.00 sec)
|
||||
|
||||
mysql> commit; # 提交事务
|
||||
Query OK, 0 rows affected (0.01 sec)
|
||||
|
||||
```
|
||||
|
||||
使用事务的时候,只需要在之前执行`begin`,标记开始一个事务,然后正常执行多条SQL语句,在事务里面的不仅可以执行更新数据的SQL,查询语句也是可以的,最后执行`commit`,提交事务就可以了。
|
||||
|
||||
我们来看一下,事务可以给我们提供什么样的保证?
|
||||
|
||||
首先,它可以保证,记录流水和更新余额这两个操作,要么都成功,要么都失败,即使是在数据库宕机、应用程序退出等等这些异常情况下,也不会出现,只更新了一个表而另一个表没更新的情况。这是事务的**原子性(Atomic)**。
|
||||
|
||||
事务还可以保证,数据库中的数据总是从一个一致性状态(888流水不存在,余额是100元)转换到另外一个一致性状态(888流水存在,余额是200元)。对于其他事务来说,不存在任何中间状态(888流水存在,但余额是100元)。
|
||||
|
||||
其他事务,在任何一个时刻,如果它读到的流水中没有888这条流水记录,它读出来的余额一定是100元,这是交易前的状态。如果它能读到888这条流水记录,它读出来的余额一定是200元,这是交易之后的状态。也就是说,事务保证我们读到的数据(交易和流水)总是一致的,这是事务的**一致性(Consistency)**。
|
||||
|
||||
实际上,这个事务的执行过程无论多快,它都是需要时间的,那修改流水表和余额表对应的数据,也会有先后。那一定存在一个时刻,流水更新了,但是余额还没更新,也就是说每个事务的中间状态是事实存在的。
|
||||
|
||||
数据库为了实现一致性,必须保证每个事务的执行过程中,中间状态对其他事务是不可见的。比如说我们在事务A中,写入了888这条流水,但是还没有提交事务,那在其他事务中,都不应该读到888这条流水记录。这是事务的**隔离性(Isolation)**。
|
||||
|
||||
最后,只要事务提交成功,数据一定会被持久化到磁盘中,后续即使发生数据库宕机,也不会改变事务的结果。这是事务的**持久性(Durability)**。
|
||||
|
||||
你会发现,我上面讲的就是事务的**ACID**四个基本特性。你需要注意的是,这四个特性之间是紧密关联在一起的,不用去纠结每一个特性的严格定义,更重要的是理解事务的行为,也就是我们的系统在使用事务的时候,各种情况下,事务对你的数据会产生什么影响,这是使用事务的关键。
|
||||
|
||||
## 理解事务的隔离级别
|
||||
|
||||
有了数据库的事务机制,只要确保每一笔交易都在事务中执行,我们的账户系统就很容易保证流水和余额数据的一致性。但是,ACID是一个非常严格的定义,或者说是理想的情况。如果要完全满足ACID,一个数据库的所有事务和SQL都只能串行执行,这个性能肯定是不能满足一般系统的要求的。
|
||||
|
||||
对账户系统和其他大多数交易系统来说,事务的原子性和持久性是必须要保证的,否则就失去了使用事务的意义,而一致性和隔离性其实可以做适当牺牲,来换取性能。所以,MySQL提供了四种隔离级别,具体来看一下这个表:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/3e/3c37eff420c7a9e41e6121ff491c8c3e.jpg" alt="">
|
||||
|
||||
几乎所有讲MySQL的事务隔离级别的文章里面,都有这个表,我们也不能免俗,因为这个表太经典了。很多同学看这个表的时候,面对这么多概念都有点儿晕,确实不太好理解。我来跟你说一下怎么来把这四种隔离级别搞清楚,重点在哪里。
|
||||
|
||||
这个表里面自上到下,一共有四种隔离级别:RU、RC、RR和SERIALIZABLE,这四种级别的隔离性越来越严格,性能也越来越差,在MySQL中默认的隔离级别是RR,可重复读。
|
||||
|
||||
先说两种不常用的,第一种RU级别,实际上就是完全不隔离。每个进行中事务的中间状态,对其他事务都是可见的,所以有可能会出现“脏读”。我们上一个小节充值的例子中,读到了888这条流水,但余额还是转账之前的100元,这种情况就是脏读。这种级别虽然性能好,但是存在脏读的可能,对应用程序来说比较难处理,所以基本不用。
|
||||
|
||||
第四种“序列化”级别,具备完美的“隔离性”和“一致性”,性能最差,也很少会用到。
|
||||
|
||||
常用的隔离级别其实就是RC和RR两种,其中MySQL默认的隔离级别是RR。这两种隔离级别都可以避免脏读,能够保证在其他事务中是不会读到未提交事务的数据,或者通俗地说,**只要你的事务没有提交,那这个事务对数据做出的更新,对其他会话是不可见的,它们读到的还是你这个事务更新之前的数据**。
|
||||
|
||||
RC和RR唯一的区别在于“是否可重复读”,这个概念也有点儿绕口,但其实也很简单。
|
||||
|
||||
**在一个事务执行过程中,它能不能读到其他已提交事务对数据的更新,如果能读到数据变化,就是“不可重复读”,否则就是“可重复读”**。
|
||||
|
||||
我们举个例子来说明,比如,我们把事务的隔离级别设为RC。会话A开启了一个事务,读到ID为0的账户,当前账户余额是100元。
|
||||
|
||||
```
|
||||
mysql> -- 会话 A
|
||||
mysql> -- 确认当前设置的隔离级别是RC
|
||||
mysql> SELECT @@global.transaction_isolation, @@transaction_isolation;
|
||||
+--------------------------------+-------------------------+
|
||||
| @@global.transaction_isolation | @@transaction_isolation |
|
||||
+--------------------------------+-------------------------+
|
||||
| READ-COMMITTED | READ-COMMITTED |
|
||||
+--------------------------------+-------------------------+
|
||||
1 row in set (0.00 sec)
|
||||
|
||||
mysql> begin;
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
mysql> select log_id, amount, timestamp from account_log order by log_id;
|
||||
+--------+--------+---------------------+
|
||||
| log_id | amount | timestamp |
|
||||
+--------+--------+---------------------+
|
||||
| 3 | 100 | 2020-02-07 09:40:37 |
|
||||
+--------+--------+---------------------+
|
||||
1 row in set (0.00 sec)
|
||||
|
||||
mysql> select * from account_balance; -- 账户余额是100元;
|
||||
+---------+---------+---------------------+--------+
|
||||
| user_id | balance | timestamp | log_id |
|
||||
+---------+---------+---------------------+--------+
|
||||
| 0 | 100 | 2020-02-07 09:47:39 | 3 |
|
||||
+---------+---------+---------------------+--------+
|
||||
1 row in set (0.00 sec)
|
||||
|
||||
```
|
||||
|
||||
这时候另外一个会话B,对这个账户完成了一笔转账交易,并且提交了事务。把账户余额更新成了200元。
|
||||
|
||||
```
|
||||
mysql> -- 会话 B
|
||||
mysql> begin;
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
mysql> select log_id, amount, timestamp from account_log order by log_id;
|
||||
+--------+--------+---------------------+
|
||||
| log_id | amount | timestamp |
|
||||
+--------+--------+---------------------+
|
||||
| 3 | 100 | 2020-02-07 09:40:37 |
|
||||
+--------+--------+---------------------+
|
||||
1 row in set (0.00 sec)
|
||||
|
||||
mysql> -- 写入流水
|
||||
mysql> insert into account_log values (NULL, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
|
||||
Query OK, 1 row affected (0.00 sec)
|
||||
|
||||
mysql> -- 更新余额
|
||||
mysql> update account_balance
|
||||
-> set balance = balance + 100, log_id = LAST_INSERT_ID(), timestamp = NOW()
|
||||
-> where user_id = 0 and log_id = 3;
|
||||
Query OK, 1 row affected (0.00 sec)
|
||||
Rows matched: 1 Changed: 1 Warnings: 0
|
||||
|
||||
mysql> -- 当前账户有2条流水记录
|
||||
mysql> select log_id, amount, timestamp from account_log order by log_id;
|
||||
+--------+--------+---------------------+
|
||||
| log_id | amount | timestamp |
|
||||
+--------+--------+---------------------+
|
||||
| 3 | 100 | 2020-02-07 09:40:37 |
|
||||
| 4 | 100 | 2020-02-07 10:06:15 |
|
||||
+--------+--------+---------------------+
|
||||
2 rows in set (0.00 sec)
|
||||
|
||||
mysql> -- 当前账户余额是200元;
|
||||
mysql> select * from account_balance;
|
||||
+---------+---------+---------------------+--------+
|
||||
| user_id | balance | timestamp | log_id |
|
||||
+---------+---------+---------------------+--------+
|
||||
| 0 | 200 | 2020-02-07 10:06:16 | 4 |
|
||||
+---------+---------+---------------------+--------+
|
||||
1 row in set (0.00 sec)
|
||||
mysql> commit;
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
|
||||
```
|
||||
|
||||
注意,这个时候会话A之前开启的事务是一直未关闭的。我们再来会话A中看一下账户的余额,你觉得应该是多少?
|
||||
|
||||
我们来看一下实际的结果。
|
||||
|
||||
```
|
||||
mysql> -- 会话 A
|
||||
mysql> -- 当前账户有2条流水记录
|
||||
mysql> select log_id, amount, timestamp from account_log order by log_id;
|
||||
+--------+--------+---------------------+
|
||||
| log_id | amount | timestamp |
|
||||
+--------+--------+---------------------+
|
||||
| 3 | 100 | 2020-02-07 09:40:37 |
|
||||
| 4 | 100 | 2020-02-07 10:06:15 |
|
||||
+--------+--------+---------------------+
|
||||
2 rows in set (0.00 sec)
|
||||
|
||||
mysql> -- 当前账户余额是200元;
|
||||
mysql> select * from account_balance;
|
||||
+---------+---------+---------------------+--------+
|
||||
| user_id | balance | timestamp | log_id |
|
||||
+---------+---------+---------------------+--------+
|
||||
| 0 | 200 | 2020-02-07 10:06:16 | 4 |
|
||||
+---------+---------+---------------------+--------+
|
||||
1 row in set (0.00 sec)
|
||||
mysql> commit;
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
```
|
||||
|
||||
可以看到,当我们把隔离级别设置为RC时,会话A第二次读到的账户余额是200元,也就是会话B更新后的数据。对于会话A来说,**在同一个事务内两次读取同一条数据,读到的结果可能会不一样,这就是“不可重复读”**。
|
||||
|
||||
如果把隔离级别设置为RR,会话A第二次读到的账户余额仍然是100元,交易流水也只有一条记录。**在RR隔离级别下,在一个事务进行过程中,对于同一条数据,每次读到的结果总是相同的,无论其他会话是否已经更新了这条数据**,**这就是“可重复读”。**
|
||||
|
||||
理解了RC和RR这两种隔离级别的区别,就足够应对绝大部分业务场景了。
|
||||
|
||||
最后,我来简单说一下“幻读”。在实际业务中,很少能遇到幻读,即使遇到,也基本不会影响到数据准确性,所以你简单了解一下即可。在RR隔离级别下,我们开启一个事务,之后直到这个事务结束,在这个事务内其他事务对数据的更新是不可见的,这个我们刚刚讲过。
|
||||
|
||||
比如我们在会话A中开启一个事务,准备插入一条ID为1000的流水记录。查询一下当前流水,不存在ID为1000的记录,可以安全地插入数据。
|
||||
|
||||
```
|
||||
mysql> -- 会话 A
|
||||
mysql> select log_id from account_log where log_id = 1000;
|
||||
Empty set (0.00 sec)
|
||||
|
||||
```
|
||||
|
||||
这时候,另外一个会话抢先插入了这条ID为1000的流水记录。
|
||||
|
||||
```
|
||||
mysql> -- 会话 B
|
||||
mysql> begin;
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
mysql> insert into account_log values
|
||||
-> (1000, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
|
||||
Query OK, 1 row affected (0.00 sec)
|
||||
|
||||
mysql> commit;
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
```
|
||||
|
||||
然后会话A再执行相同的插入语句时,就会报主键冲突错误,但是由于事务的隔离性,它执行查询的时候,却查不到这条ID为1000的流水,就像出现了“幻觉”一样,这就是幻读。
|
||||
|
||||
```
|
||||
mysql> -- 会话 A
|
||||
mysql> insert into account_log values
|
||||
-> (1000, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
|
||||
ERROR 1062 (23000): Duplicate entry '1000' for key 'account_log.PRIMARY'
|
||||
mysql> select log_id from account_log where log_id = 1000;
|
||||
Empty set (0.00 sec)
|
||||
|
||||
```
|
||||
|
||||
理解了这几种隔离级别,最后我们给出一种兼顾并发、性能和数据一致性的交易实现。这个实现在隔离级别为RC和RR时,都是安全的。
|
||||
|
||||
1. 我们给账户余额表增加一个log_id属性,记录最后一笔交易的流水号。
|
||||
1. 首先开启事务,查询并记录当前账户的余额和最后一笔交易的流水号。
|
||||
1. 然后写入流水记录。
|
||||
1. 再更新账户余额,需要在更新语句的WHERE条件中限定,只有流水号等于之前查询出的流水号时才更新。
|
||||
1. 然后检查更新余额的返回值,如果更新成功就提交事务,否则回滚事务。
|
||||
|
||||
需要特别注意的一点是,更新账户余额后,不能只检查更新语句是不是执行成功了,还需要检查返回值中变更的行数是不是等于1。因为即使流水号不相等,余额没有更新,这条更新语句的执行结果仍然是成功的,只是更新了0条记录。
|
||||
|
||||
下面是整个交易的SQL,供你参考:
|
||||
|
||||
```
|
||||
mysql> begin;
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
mysql> -- 查询当前账户的余额和最后一笔交易的流水号。
|
||||
mysql> select balance, log_id from account_balance where user_id = 0;
|
||||
+---------+--------+
|
||||
| balance | log_id |
|
||||
+---------+--------+
|
||||
| 100 | 3 |
|
||||
+---------+--------+
|
||||
1 row in set (0.00 sec)
|
||||
|
||||
mysql> -- 插入流水记录。
|
||||
mysql> insert into account_log values
|
||||
-> (NULL, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
|
||||
Query OK, 1 row affected (0.01 sec)
|
||||
|
||||
mysql> -- 更新余额,注意where条件中,限定了只有流水号等于之前查询出的流水号3时才更新。
|
||||
mysql> update account_balance
|
||||
-> set balance = balance + 100, log_id = LAST_INSERT_ID(), timestamp = NOW()
|
||||
-> where user_id = 0 and log_id = 3;
|
||||
Query OK, 1 row affected (0.00 sec)
|
||||
Rows matched: 1 Changed: 1 Warnings: 0
|
||||
|
||||
mysql> -- 这里需要检查更新结果,只有更新余额成功(Changed: 1)才提交事务,否则回滚事务。
|
||||
mysql> commit;
|
||||
Query OK, 0 rows affected (0.01 sec)
|
||||
|
||||
```
|
||||
|
||||
最后,我给出流水和余额两个表的DDL,你自己执行例子的时候可以使用。
|
||||
|
||||
```
|
||||
CREATE TABLE `account_log` (
|
||||
`log_id` int NOT NULL AUTO_INCREMENT COMMENT '流水号',
|
||||
`amount` int NOT NULL COMMENT '交易金额',
|
||||
`timestamp` datetime NOT NULL COMMENT '时间戳',
|
||||
`from_system` int NOT NULL COMMENT '转出系统编码',
|
||||
`from_system_transaction_number` int DEFAULT NULL COMMENT '转出系统的交易号',
|
||||
`from_account` int DEFAULT NULL COMMENT '转出账户',
|
||||
`to_system` int NOT NULL COMMENT '转入系统编码',
|
||||
`to_system_transaction_number` int DEFAULT NULL COMMENT '转入系统的交易号',
|
||||
`to_account` int DEFAULT NULL COMMENT '转入账户',
|
||||
`transaction_type` int NOT NULL COMMENT '交易类型编码',
|
||||
PRIMARY KEY (`log_id`)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE `account_balance` (
|
||||
`user_id` int NOT NULL COMMENT '用户ID',
|
||||
`balance` int NOT NULL COMMENT '余额',
|
||||
`timestamp` datetime NOT NULL COMMENT '时间戳',
|
||||
`log_id` int NOT NULL COMMENT '最后一笔交易的流水号',
|
||||
PRIMARY KEY (`user_id`)
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
账户系统用于记录每个用户的余额,为了保证数据的可追溯性,还需要记录账户流水。流水记录只能新增,任何情况下都不允许修改和删除,每次交易的时候需要把流水和余额放在同一个事务中一起更新。
|
||||
|
||||
事务具备原子性、一致性、隔离性和持久性四种基本特性,也就是ACID,它可以保证在一个事务中执行的数据更新,要么都成功,要么都失败。并且在事务执行过程中,中间状态的数据对其他事务是不可见的。
|
||||
|
||||
ACID是一种理想情况,特别是要完美地实现CI,会导致数据库性能严重下降,所以MySQL提供的四种可选的隔离级别,牺牲一定的隔离性和一致性,用于换取高性能。这四种隔离级别中,只有RC和RR这两种隔离级别是常用的,它们的唯一区别是在进行的事务中,其他事务对数据的更新是否可见。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后希望你能动手执行一下我们今天这节课中给出的例子,看一下多个事务并发更新同一个账户时,RC和RR两种不同的隔离级别,在行为上有什么不同?
|
||||
|
||||
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
120
极客时间专栏/后端存储实战课/创业篇/05 | 分布式事务:如何保证多个系统间的数据是一致的?.md
Normal file
120
极客时间专栏/后端存储实战课/创业篇/05 | 分布式事务:如何保证多个系统间的数据是一致的?.md
Normal file
@@ -0,0 +1,120 @@
|
||||
<audio id="audio" title="05 | 分布式事务:如何保证多个系统间的数据是一致的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/32/87/32cbbb746796d54b20e2c5249f5c1387.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
上节课,我和你一起通过账户系统学习了数据库事务,事务很好地解决了交易类系统的数据一致性问题。
|
||||
|
||||
事务的原子性和持久性可以确保在一个事务内,更新多条数据,要么都成功,要么都失败。在一个系统内部,我们可以使用数据库事务来保证数据一致性。那如果一笔交易,涉及到跨多个系统、多个数据库的时候,用单一的数据库事务就没办法解决了。
|
||||
|
||||
在之前大系统的时代,普遍的做法是,在设计时尽量避免这种跨系统跨数据库的交易。
|
||||
|
||||
但是,现在的技术趋势是云原生和微服务,微服务它的理念是什么?大系统被打散成多个小的微服务,每个微服务独立部署并且拥有自己的数据库,大数据库也被打散成多个小的数据库。跨越微服务和数据库的交易就成为一种越来越普遍的情况。我们的业务系统微服务化之后,不可避免地要面对跨系统的数据一致性问题。
|
||||
|
||||
如何来解决这种跨系统、跨数据库的数据一致性问题呢?你可能会脱口而出:分布式事务。但是,分布式事务可不像数据库事务那样,在开始和结尾分别加上begin和commit,剩下的问题数据库都可以帮我们搞定。在分布式环境下,没有这么简单的事儿,为什么?
|
||||
|
||||
因为在分布式环境中,一个交易将会被分布到不同的系统中,多个微服务进程内执行计算,在多个数据库中执行数据更新操作,这个场景比数据库事务支持的单进程单数据库场景复杂太多了。所以,并没有什么分布式事务服务或者组件能在分布式环境下,提供接近数据库事务的数据一致性保证。
|
||||
|
||||
今天这节课我们就来说一下,如何用分布式事务的方法,来解决微服务系统中,我们实际面临的分布式数据一致性问题。
|
||||
|
||||
## 到底什么是分布式事务?
|
||||
|
||||
在学习分布式事务这个概念之前,我先跟你说一下为什么一定要搞懂概念。我们这门课程是一门实战课,一般来说,我们更关注的是如何来解决实际问题,而不是理论和概念,所以你看,我们在讲解数据库事务的时候,讲的内容是如何用事务解决交易的问题,而没讲MySQL是如何实现ACID的。因为数据库已经把事务封装的非常好了,我们只需要掌握如何使用就可以很好地解决问题。
|
||||
|
||||
但分布式事务不是这样的,我刚刚说了,并没有一种分布式事务的服务或者组件,能帮我们很简单地就解决分布式系统下的数据一致性问题。我们在使用分布式事务时,更多的情况是,用分布式事务的理论来指导设计和开发,自行来解决数据一致性问题。也就是说,要解决分布式一致性问题,你必须掌握几种分布式事务的实现原理。
|
||||
|
||||
我们在讲解数据库事务时讲到了事务的ACID四个特性,我们知道即使是数据库事务,它考虑到性能的因素,大部分情况下不能也不需要百分之百地实现ACID,所以才有了事务四种隔离级别。
|
||||
|
||||
理论上,分布式事务也是事务,也需要遵从ACID四个特性,但实际情况是,在分布式系统中,因为必须兼顾性能和高可用,所以是不可能完全满足ACID的。我们常用的几种分布式事务的实现方法,都是“残血版”的事务,而且相比数据库事务,更加的“残血”。
|
||||
|
||||
分布式事务的解决方案有很多,比如:2PC、3PC、TCC、Saga和本地消息表等等。这些方法,它的强项和弱项都不一样,适用的场景也不一样,所以最好这些分布式事务你都能够掌握,这样才能在面临实际问题的时候选择合适的方法。这里面,2PC和本地消息表这两种分布式事务的解决方案,比较贴近于我们日常开发的业务系统。
|
||||
|
||||
## 2PC:订单与优惠券的数据一致性问题
|
||||
|
||||
2PC也叫二阶段提交,是一种常用的分布式事务实现方法。我们用订单和优惠券的例子来说明一下,如何用2PC来解决订单系统和促销系统的数据一致性问题。在我们购物下单时,如果使用了优惠券,订单系统和优惠券系统都要更新自己的数据,才能完成“在订单中使用优惠券”这个操作。
|
||||
|
||||
订单系统需要:
|
||||
|
||||
1. 在“订单优惠券表”中写入订单关联的优惠券数据;
|
||||
1. 在“订单表”中写入订单数据。
|
||||
|
||||
订单系统内两个操作的一致性问题可以直接使用数据库事务来解决。促销系统需要操作就比较简单,把刚刚使用的那张优惠券的状态更新成“已使用”就可以了。我们需要这两个系统的数据更新操作保持一致,要么都更新成功,要么都更新失败。
|
||||
|
||||
接下来我们来看2PC是怎么解决这个问题的。2PC引入了一个事务协调者的角色,来协调订单系统和促销系统,协调者对客户端提供一个完整的“使用优惠券下单”的服务,在这个服务的内部,协调者再分别调用订单和促销的相应服务。
|
||||
|
||||
所谓的二阶段指的是准备阶段和提交阶段。在准备阶段,协调者分别给订单系统和促销系统发送“准备”命令,订单和促销系统收到准备命令之后,开始执行准备操作。准备阶段都需要做哪些事儿呢?你可以理解为,除了提交数据库事务以外的所有工作,都要在准备阶段完成。比如说订单系统在准备阶段需要完成:
|
||||
|
||||
1. 在订单库开启一个数据库事务;
|
||||
1. 在“订单优惠券表”写入这条订单的优惠券记录;
|
||||
1. 在“订单表”中写入订单数据。
|
||||
|
||||
注意,到这里我们没有提交订单数据库的事务,最后给事务协调者返回“准备成功”。类似的,促销服务在准备阶段,需要在促销库开启一个数据库事务,更新优惠券状态,但是暂时不要提交这个数据库事务,给协调者返回“准备成功”。协调者在收到两个系统“准备成功”的响应之后,开始进入第二阶段。
|
||||
|
||||
等两个系统都准备好了之后,进入提交阶段。提交阶段就比较简单了,协调者再给这两个系统发送“提交”命令,每个系统提交自己的数据库事务,然后给协调者返回“提交成功”响应,协调者收到所有响应之后,给客户端返回成功响应,整个分布式事务就结束了。以下是这个过程的时序图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/f2/9106c7e4e8303eec84ca1172ab3214f2.jpg" alt="">
|
||||
|
||||
这是正常情况,接下来才是重点:异常情况下怎么办?
|
||||
|
||||
我们还是分两个阶段来说明。在准备阶段,如果任何一步出现错误或者是超时,协调者就会给两个系统发送“回滚事务”请求。每个系统在收到请求之后,回滚自己的数据库事务,分布式事务执行失败,两个系统的数据库事务都回滚了,相关的所有数据回滚到分布式事务执行之前的状态,就像这个分布式事务没有执行一样。以下是异常情况的时序图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/6a/27f6b617da547fd21edac826ae4bcb6a.jpg" alt="">
|
||||
|
||||
如果准备阶段成功,进入提交阶段,这个时候就“只有华山一条路”,整个分布式事务**只能成功,不能失败**。
|
||||
|
||||
如果发生网络传输失败的情况,需要反复重试,直到提交成功为止。如果这个阶段发生宕机,包括两个数据库宕机或者订单服务、促销服务所在的节点宕机,还是有可能出现订单库完成了提交,但促销库因为宕机自动回滚,导致数据不一致的情况。但是,因为提交的过程非常简单,执行很快,出现这种情况的概率非常小,所以,从实用的角度来说,2PC这种分布式事务的方法,实际的数据一致性还是非常好的。
|
||||
|
||||
在实现2PC的时候,没必要单独启动一个事务协调服务,这个协调服务的工作最好和订单服务或者优惠券服务放在同一个进程里面,这样做有两个好处:
|
||||
|
||||
- 参与分布式事务的进程更少,故障点也就更少,稳定性更好;
|
||||
- 减少了一些远程调用,性能也更好一些。
|
||||
|
||||
2PC是一种强一致的设计,它可以保证原子性和隔离性。只要2PC事务完成,订单库和促销库中的数据一定是一致的状态,也就是我们总说的,要么都成功,要么都失败。
|
||||
|
||||
所以2PC比较适合那些对数据一致性要求比较高的场景,比如我们这个订单优惠券的场景,如果一致性保证不好,有可能会被黑产利用,一张优惠券反复使用,那样我们的损失就大了。
|
||||
|
||||
2PC也有很明显的缺陷,整个事务的执行过程需要阻塞服务端的线程和数据库的会话,所以,2PC在并发场景下的性能不会很高。并且,协调者是一个单点,一旦过程中协调者宕机,就会导致订单库或者促销库的事务会话一直卡在等待提交阶段,直到事务超时自动回滚。
|
||||
|
||||
卡住的这段时间内,数据库有可能会锁住一些数据,服务中会卡住一个数据库连接和线程,这些都会造成系统性能严重下降,甚至整个服务被卡住。
|
||||
|
||||
**所以,只有在需要强一致、并且并发量不大的场景下,才考虑使用2PC。**
|
||||
|
||||
## 本地消息表:订单与购物车的数据一致性问题
|
||||
|
||||
2PC它的适用场景其实是很窄的,更多的情况下,只要保证数据最终一致就可以了。比如说,在购物流程中,用户在购物车界面选好商品后,点击“去结算”按钮进入订单页面创建一个新订单。这个过程我们的系统其实做了两件事儿。
|
||||
|
||||
- 第一,订单系统需要创建一个新订单,订单关联的商品就是购物车中选择的那些商品。
|
||||
- 第二,创建订单成功后,购物车系统需要把订单中的这些商品从购物车里删掉。
|
||||
|
||||
这也是一个分布式事务问题,创建订单和清空购物车这两个数据更新操作需要保证,要么都成功,要么都失败。但是,清空购物车这个操作,它对一致性要求就没有扣减优惠券那么高,订单创建成功后,晚几秒钟再清空购物车,完全是可以接受的。只要保证经过一个小的延迟时间后,最终订单数据和购物车数据保持一致就可以了。
|
||||
|
||||
本地消息表非常适合解决这种分布式最终一致性的问题。我们一起来看一下,如何使用本地消息表来解决订单与购物车的数据一致性问题。
|
||||
|
||||
本地消息表的实现思路是这样的,订单服务在收到下单请求后,正常使用订单库的事务去更新订单的数据,并且,在执行这个数据库事务过程中,在本地记录一条消息。这个消息就是一个日志,内容就是“清空购物车”这个操作。因为这个日志是记录在本地的,这里面没有分布式的问题,那这就是一个普通的单机事务,那我们就可以让订单库的事务,来保证记录本地消息和订单库的一致性。完成这一步之后,就可以给客户端返回成功响应了。
|
||||
|
||||
然后,我们再用一个异步的服务,去读取刚刚记录的清空购物车的本地消息,调用购物车系统的服务清空购物车。购物车清空之后,把本地消息的状态更新成已完成就可以了。异步清空购物车这个过程中,如果操作失败了,可以通过重试来解决。最终,可以保证订单系统和购物车系统它们的数据是一致的。
|
||||
|
||||
这里面,本地消息表,你可以选择存在订单库中,也可以用文件的形式,保存在订单服务所在服务器的本地磁盘中,两种方式都是可以的,相对来说,放在订单库中更简单一些。
|
||||
|
||||
消息队列RocketMQ提供一种事务消息的功能,其实就是本地消息表思想的一个实现。使用事务消息可以达到和本地消息表一样的最终一致性,相比我们自己来实现本地消息表,使用起来更加简单,你也可以考虑使用。(我在[《消息队列高手课》](https://time.geekbang.org/column/intro/212)的专栏中的“[如何利用事务消息实现分布式事务?](https://time.geekbang.org/column/article/111269)”这节课中有详细的讲解。)
|
||||
|
||||
如果看事务的ACID四个特性,本地消息表这种方法,它只能满足D(持久性),A(原子性)C(一致性)、I(隔离性)都比较差,但是,它的优点非常突出。
|
||||
|
||||
首先,实现简单,在单机事务的基础上稍加改造就可以实现分布式事务,另外,本地消息表的性能非常好,和单机事务的性能几乎没有差别。在这个基础上,还提供了大部分情况下都能接受的“数据最终一致性”的保证,所以,本地消息表是更加实用的分布式事务实现方法。
|
||||
|
||||
当然,即使能接受数据最终一致,本地消息表也不是什么场景都可以适用的。它有一个前提条件就是,异步执行的那部分操作,不能有依赖的资源。比如说,我们下单的时候,除了要清空购物车以外,还需要锁定库存。
|
||||
|
||||
库存系统锁定库存这个操作,虽然可以接受数据最终一致,但是,锁定库存这个操作是有一个前提的,这个前提是:库存中得有货。这种情况就不适合使用本地消息表,不然就会出现用户下单成功后,系统的异步任务去锁定库存的时候,因为缺货导致锁定失败。这样的情况就很难处理了。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我们讲解了,如何用分布式事务的几种方法来解决分布式系统中的数据一致性问题。对于订单和优惠券这种需要强一致的分布式事务场景,可以采用2PC的方法来解决问题。
|
||||
|
||||
2PC它的优点是强一致,但是性能和可用性上都有一些缺陷。本地消息表适用性更加广泛,虽然在数据一致性上有所牺牲,只能满足最终一致性,但是有更好的性能,实现简单,系统的稳定性也很好,是一种非常实用的分布式事务的解决方案。
|
||||
|
||||
无论是哪种分布式事务方法,其实都是把一个分布式事务,拆分成多个本地事务。**本地事务可以用数据库事务来解决,那分布式事务就专注于解决如何让这些本地事务保持一致的问题。**我们在遇到分布式一致性问题的时候,也要基于这个思想来考虑问题,再结合实际的情况选择分布式事务的方法。
|
||||
|
||||
## 思考题
|
||||
|
||||
2PC也有一些改进版本,比如3PC、TCC这些,它们大体的思想和2PC是差不多的,解决了2PC的一些问题,但是也会带来新的问题,实现起来也更复杂,限于篇幅我们没法每个都详细地去讲解。在理解了2PC的基础上,课后请你自行去学习一下3PC和TCC,然后对比一下,2PC、3PC和TCC分别适用于什么样的业务场景?
|
||||
|
||||
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
216
极客时间专栏/后端存储实战课/创业篇/06 | 如何用Elasticsearch构建商品搜索系统?.md
Normal file
216
极客时间专栏/后端存储实战课/创业篇/06 | 如何用Elasticsearch构建商品搜索系统?.md
Normal file
@@ -0,0 +1,216 @@
|
||||
<audio id="audio" title="06 | 如何用Elasticsearch构建商品搜索系统?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/da/afa8069dce1040cdc25a35d1b7c172da.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
搜索这个特性可以说是无处不在,现在很少有网站或者系统不提供搜索功能了,所以,即使你不是一个专业做搜索的程序员,也难免会遇到一些搜索相关的需求。搜索这个东西,表面上看功能很简单,就是一个搜索框,输入关键字,然后搜出来想要的内容就好了。
|
||||
|
||||
搜索背后的实现,可以非常简单,简单到什么程度呢?我们就用一个SQL,LIKE一下就能实现;也可以很复杂,复杂到什么程度呢?不说百度谷歌这种专业做搜索的公司,其他非专业做搜索的互联网大厂,搜索团队大多是千人规模,这里面不仅有程序员,还有算法工程师、业务专家等等。二者的区别也仅仅是,搜索速度的快慢以及搜出来的内容好坏而已。
|
||||
|
||||
今天这节课,我们就以电商中的商品搜索作为例子,来讲一下,如何用ES(Elasticsearch)来快速、低成本地构建一个体验还不错的搜索系统。
|
||||
|
||||
## 理解倒排索引机制
|
||||
|
||||
刚刚我们说了,既然我们的数据大多都是存在数据库里,用SQL的LIKE也能实现匹配,也能搜出结果,为什么还要专门做一套搜索系统呢?我先来和你分析一下,为什么数据库不适合做搜索。
|
||||
|
||||
搜索的核心需求是全文匹配,对于全文匹配,数据库的索引是根本派不上用场的,那只能全表扫描。全表扫描已经非常慢了,这还不算,还需要在每条记录上做全文匹配,也就是一个字一个字的比对,这个速度就更慢了。所以,使用数据来做搜索,性能上完全没法满足要求。
|
||||
|
||||
那ES是怎么来解决搜索问题的呢?我们来举个例子说明一下,假设我们有这样两个商品,一个是烟台红富士苹果,一个是苹果手机iPhone XS Max。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/0a/28a9b198c9b10a3b4d50a77d8fea6c0a.jpg" alt="">
|
||||
|
||||
这个表里面的DOCID就是唯一标识一条记录的ID,和数据库里面的主键是类似的。
|
||||
|
||||
为了能够支持快速地全文搜索,ES中对于文本采用了一种特殊的索引:倒排索引(Inverted Index)。那我们看一下在ES中,这两条商品数据倒排索引长什么样?请看下面这个表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/1c/6fcdd7e10c3e72b2abe635c8a5a6ff1c.jpg" alt="">
|
||||
|
||||
可以看到,这个倒排索引的表,它是以单词作为索引的Key,然后每个单词的倒排索引的值是一个列表,这个列表的元素就是含有这个单词的商品记录的DOCID。
|
||||
|
||||
这个倒排索引怎么构建的呢?当我们往ES写入商品记录的时候,ES会先对需要搜索的字段,也就是商品标题进行**分词**。分词就是把一段连续的文本按照语义拆分成多个单词。然后ES按照单词来给商品记录做索引,就形成了上面那个表一样的倒排索引。
|
||||
|
||||
当我们搜索关键字“苹果手机”的时候,ES会对关键字也进行分词,比如说,“苹果手机”被分为“苹果”和“手机”。然后,ES会在倒排索引中去搜索我们输入的每个关键字分词,搜索结果应该是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/c2/7191b2ba0e28d8b7db9871213664a6c2.jpg" alt="">
|
||||
|
||||
666和888这两条记录都能匹配上搜索的关键词,但是888这个商品比666这个商品匹配度更高,因为它两个单词都能匹配上,所以按照匹配度把结果做一个排序,最终返回的搜索结果就是:
|
||||
|
||||
>
|
||||
**苹果**Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G**手机**双卡双待
|
||||
|
||||
|
||||
>
|
||||
烟台红富士**苹果**5kg 一级铂金大果 单果230g以上 新鲜水果
|
||||
|
||||
|
||||
看起来搜索的效果还是不错的。
|
||||
|
||||
为什么倒排索引可以做到快速搜索?我和你一起来分析一下上面这个例子的查找性能。
|
||||
|
||||
这个搜索过程,其实就是对上面的倒排索引做了二次查找,一次找“苹果”,一次找“手机”。**注意,整个搜索过程中,我们没有做过任何文本的模糊匹配**。ES的存储引擎存储倒排索引时,肯定不是像我们上面表格中展示那样存成一个二维表,实际上它的物理存储结构和MySQL的InnoDB的索引是差不多的,都是一颗查找树。
|
||||
|
||||
对倒排索引做两次查找,也就是对树进行二次查找,它的时间复杂度,类似于MySQL中的二次命中索引的查找。显然,这个查找速度,比用MySQL全表扫描加上模糊匹配的方式,要快好几个数量级。
|
||||
|
||||
## 如何在ES中构建商品的索引?
|
||||
|
||||
理解了倒排索引的原理之后,我们一起用ES构建一个商品索引,简单实现一个商品搜索系统。虽然ES是为搜索而生的,但本质上,它仍然是一个存储系统。ES里面的一些概念,基本上都可以在关系数据库中找到对应的名词,为了便于你快速理解这些概念,我把这些概念的对应关系列出来,你可以对照理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/df/cdbfcc01166ad3a1fd2a12791d0079df.jpg" alt="">
|
||||
|
||||
在ES里面,数据的逻辑结构类似于MongoDB,每条数据称为一个**DOCUMENT**,简称DOC。DOC就是一个JSON对象,DOC中的每个JSON字段,在ES中称为**FIELD**,把一组具有相同字段的DOC存放在一起,存放它们的逻辑容器叫**INDEX**,这些DOC的JSON结构称为**MAPPING**。这里面最不好理解的就是这个INDEX,它实际上类似于MySQL中表的概念,而不是我们通常理解的用于查找数据的索引。
|
||||
|
||||
ES是一个用Java开发的服务端程序,除了Java以外就没有什么外部依赖了,安装部署都非常简单,具体你可以参照它的[官方文档](https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started.html)先把ES安装好。我们这个示例中,使用的ES版本是目前的最新版本7.6。
|
||||
|
||||
另外,为了能让ES支持中文分词,需要给ES安装一个中文的分词插件[IK Analysis for Elasticsearch](https://github.com/medcl/elasticsearch-analysis-ik),这个插件的作用就是告诉ES怎么对中文文本进行分词。
|
||||
|
||||
你可以直接执行下面的命令自动下载并安装:
|
||||
|
||||
```
|
||||
$elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.6.0/elasticsearch-analysis-ik-7.6.0.zip
|
||||
|
||||
```
|
||||
|
||||
安装完成后,需要重启ES,验证一下是否安装成功:
|
||||
|
||||
```
|
||||
curl -X POST "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d '{ "analyzer": "ik_smart", "text": "极客时间" }'
|
||||
{
|
||||
"tokens" : [
|
||||
{
|
||||
"token" : "极",
|
||||
"start_offset" : 0,
|
||||
"end_offset" : 1,
|
||||
"type" : "CN_CHAR",
|
||||
"position" : 0
|
||||
},
|
||||
{
|
||||
"token" : "客",
|
||||
"start_offset" : 1,
|
||||
"end_offset" : 2,
|
||||
"type" : "CN_CHAR",
|
||||
"position" : 1
|
||||
},
|
||||
{
|
||||
"token" : "时间",
|
||||
"start_offset" : 2,
|
||||
"end_offset" : 4,
|
||||
"type" : "CN_WORD",
|
||||
"position" : 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个分词器把“极客时间”分成了“极”、“客”和“时间”,没认出来“极客”这个词,还是有改进空间的。
|
||||
|
||||
为了能实现商品搜索,我们需要先把商品信息存放到ES中,首先我们先定义存放在ES中商品的数据结构,也就是MAPPING。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e6/99/e6cadb1ad8311de9772e673161f94999.jpg" alt="">
|
||||
|
||||
我们这个MAPPING只要两个字段就够了,sku_id就是商品ID,title保存商品的标题,当用户在搜索商品的时候,我们在ES中来匹配商品标题,返回符合条件商品的sku_id列表。ES默认提供了标准的RESTful接口,不需要客户端,直接使用HTTP协议就可以访问,这里我们使用[curl](https://curl.haxx.se/docs/manpage.html)通过命令行来操作ES。
|
||||
|
||||
接下来我们使用上面这个MAPPING创建INDEX,类似于MySQL中创建一个表。
|
||||
|
||||
```
|
||||
curl -X PUT "localhost:9200/sku" -H 'Content-Type: application/json' -d '{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"sku_id": {
|
||||
"type": "long"
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"analyzer": "ik_max_word",
|
||||
"search_analyzer": "ik_max_word"
|
||||
}
|
||||
}
|
||||
}
|
||||
}'
|
||||
{"acknowledged":true,"shards_acknowledged":true,"index":"sku"}
|
||||
|
||||
```
|
||||
|
||||
这里面,使用PUT方法创建一个INDEX,INDEX的名称是“sku”,直接写在请求的URL中。请求的BODY是一个JSON对象,内容就是我们上面定义的MAPPING,也就是数据结构。这里面需要注意一下,由于我们要在title这个字段上进行全文搜索,所以我们把数据类型定义为text,并指定使用我们刚刚安装的中文分词插件IK作为这个字段的分词器。
|
||||
|
||||
创建好INDEX之后,就可以往INDEX中写入商品数据,插入数据需要使用HTTP POST方法:
|
||||
|
||||
```
|
||||
curl -X POST "localhost:9200/sku/_doc/" -H 'Content-Type: application/json' -d '{
|
||||
"sku_id": 100002860826,
|
||||
"title": "烟台红富士苹果 5kg 一级铂金大果 单果230g以上 新鲜水果"
|
||||
}'
|
||||
{"_index":"sku","_type":"_doc","_id":"yxQVSHABiy2kuAJG8ilW","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}
|
||||
|
||||
curl -X POST "localhost:9200/sku/_doc/" -H 'Content-Type: application/json' -d '{
|
||||
"sku_id": 100000177760,
|
||||
"title": "苹果 Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G手机 双卡双待"
|
||||
}'
|
||||
{"_index":"sku","_type":"_doc","_id":"zBQWSHABiy2kuAJGgim1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":1,"_primary_term":1}
|
||||
|
||||
```
|
||||
|
||||
这里面我们插入了两条商品数据,一个烟台红富士,一个iPhone手机。然后就可以直接进行商品搜索了,搜索使用HTTP GET方法。
|
||||
|
||||
```
|
||||
curl -X GET 'localhost:9200/sku/_search?pretty' -H 'Content-Type: application/json' -d '{
|
||||
"query" : { "match" : { "title" : "苹果手机" }}
|
||||
}'
|
||||
{
|
||||
"took" : 23,
|
||||
"timed_out" : false,
|
||||
"_shards" : {
|
||||
"total" : 1,
|
||||
"successful" : 1,
|
||||
"skipped" : 0,
|
||||
"failed" : 0
|
||||
},
|
||||
"hits" : {
|
||||
"total" : {
|
||||
"value" : 2,
|
||||
"relation" : "eq"
|
||||
},
|
||||
"max_score" : 0.8594865,
|
||||
"hits" : [
|
||||
{
|
||||
"_index" : "sku",
|
||||
"_type" : "_doc",
|
||||
"_id" : "zBQWSHABiy2kuAJGgim1",
|
||||
"_score" : 0.8594865,
|
||||
"_source" : {
|
||||
"sku_id" : 100000177760,
|
||||
"title" : "苹果 Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G手机 双卡双待"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_index" : "sku",
|
||||
"_type" : "_doc",
|
||||
"_id" : "yxQVSHABiy2kuAJG8ilW",
|
||||
"_score" : 0.18577608,
|
||||
"_source" : {
|
||||
"sku_id" : 100002860826,
|
||||
"title" : "烟台红富士苹果 5kg 一级铂金大果 单果230g以上 新鲜水果"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们先看一下请求中的URL,其中的“sku”代表要在sku这个INDEX内进行查找,“_search”是一个关键字,表示要进行搜索,参数pretty表示格式化返回的JSON,这样方便阅读。再看一下请求BODY的JSON,query中的match表示要进行全文匹配,匹配的字段就是title,关键字是“苹果手机”。
|
||||
|
||||
可以看到,在返回结果中,匹配到了2条商品记录,和我们在前面讲解倒排索引时,预期返回的结果是一致的。
|
||||
|
||||
我们来回顾一下使用ES构建商品搜索服务的这个过程:首先安装ES并启动服务,然后创建一个INDEX,定义MAPPING,写入数据后,执行查询并返回查询结果,其实,这个过程和我们使用数据库时,先建表、插入数据然后查询的过程,就是一样的。所以,你就把ES当做一个支持全文搜索的数据库来使用就行了。
|
||||
|
||||
## 小结
|
||||
|
||||
ES本质上是一个支持全文搜索的分布式内存数据库,特别适合用于构建搜索系统。ES之所以能有非常好的全文搜索性能,最重要的原因就是采用了倒排索引。倒排索引是一种特别为搜索而设计的索引结构,倒排索引先对需要索引的字段进行分词,然后以分词为索引组成一个查找树,这样就把一个全文匹配的查找转换成了对树的查找,这是倒排索引能够快速进行搜索的根本原因。
|
||||
|
||||
但是,倒排索引相比于一般数据库采用的B树索引,它的写入和更新性能都比较差,因此倒排索引也只是适合全文搜索,不适合更新频繁的交易类数据。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们在电商的搜索框中搜索商品时,它都有一个搜索提示的功能,比如我输入“苹果”还没有点击搜索按钮的时候,搜索框下面会提示“苹果手机”、“苹果11、苹果电脑”这些建议的搜索关键字,请你课后看一下ES的文档,想一下,如何用ES快速地实现这个搜索提示功能?
|
||||
|
||||
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
176
极客时间专栏/后端存储实战课/创业篇/07|MySQL HA:如何将“删库跑路”的损失降到最低?.md
Normal file
176
极客时间专栏/后端存储实战课/创业篇/07|MySQL HA:如何将“删库跑路”的损失降到最低?.md
Normal file
@@ -0,0 +1,176 @@
|
||||
<audio id="audio" title="07|MySQL HA:如何将“删库跑路”的损失降到最低?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/29/6e3ba06b7964ef74b2bc0ebf01091c29.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
对于任何一个企业来说,数据安全的重要性是不言而喻的。我在开篇词中也曾经强调过,凡是涉及到数据的问题,都是损失惨重的大问题。
|
||||
|
||||
能够影响数据安全的事件,都是极小概率的事件,比如说:数据库宕机、磁盘损坏甚至机房着火,还有最近频繁出现在段子中“程序员不满老板删库跑路”的梗儿,但这些事儿一旦发生了,我们的业务就会损失惨重。
|
||||
|
||||
一般来说,存储系统导致的比较严重的损失主要有两种情况,一是数据丢失造成的直接财产损失,比如大量的坏账;二是由于存储系统损坏,造成整个业务系统停止服务而带来的损失。
|
||||
|
||||
所谓防患于未然,你从设计一个系统的第一天起,就需要考虑在出现各种问题的时候,如何来保证这个系统的数据安全性。今天我们来聊一聊,如何提前预防,将“删库跑路”等这类问题导致的损失尽量降到最低。
|
||||
|
||||
## 如何更安全地做数据备份和恢复?
|
||||
|
||||
保证数据安全,最简单而且有效的手段就是定期备份数据,这样出现任何问题导致的数据损失,都可以通过备份来恢复数据。但是,如何备份,才能最大程度地保证数据安全,并不是一个简单的事儿。
|
||||
|
||||
2018年还出现过某个著名的云服务商因为硬盘损坏,导致多个客户数据全部丢失的重大故障。这么大的云服务商,数据是不可能没有备份的,按说硬盘损坏,不会导致数据丢失的,但是因为各种各样的原因,最终的结果是数据的三个副本都被删除,数据丢失无法找回。
|
||||
|
||||
所以说,不是简单地定期把数据备份一下就可以高枕无忧了。接下来我们还是以大家最常用的MySQL为例来说一下,如何更安全地来做数据备份和恢复。
|
||||
|
||||
最简单的备份方式就是全量备份。备份的时候,把所有的数据复制一份,存放到文件中,恢复的时候再把文件中的数据复制回去,这样可以保证恢复之后数据库中的数据和备份时是完全一样的。在MySQL中,你可以使用[mysqldump](https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html)命令来执行全量备份。
|
||||
|
||||
比如我们要全量备份数据库test:
|
||||
|
||||
```
|
||||
$mysqldump -uroot -p test > test.sql
|
||||
|
||||
```
|
||||
|
||||
备份出来的文件就是一个SQL文件,就是创建数据库、表,写入数据等等这些SQL,如果要恢复数据,直接执行这个备份的SQL文件就可以了:
|
||||
|
||||
```
|
||||
$mysql -uroot test < test.sql
|
||||
|
||||
```
|
||||
|
||||
不过,全量备份的代价非常高,为什么这么说呢?
|
||||
|
||||
首先,备份文件包含数据库中的所有数据,占用的磁盘空间非常大;其次,每次备份操作都要拷贝大量数据,备份过程中会占用数据库服务器大量的CPU、磁盘IO资源,并且为了保证数据一致性,还有可能会锁表,这些都会导致备份期间,数据库本身的性能严重下降。所以,我们不能经常对数据库执行全量备份。
|
||||
|
||||
一般来说,每天执行一次全量备份已经是非常频繁了。那这就意味着,如果数据库中的数据丢了,那只能恢复到最近一次全量备份的那个时间点,这个时间点之后的数据还是丢了。也就是说,全量备份不能做到完全无损地恢复。
|
||||
|
||||
既然全量备份代价太高,不能频繁执行,那有没有代价低一点儿的备份方法,能让我们少丢甚至不丢数据呢?还真有,那就是**增量备份**。相比于全量备份,增量备份每次只备份相对于上一次备份变化的那部分数据,所以每次增量备份速度更快。
|
||||
|
||||
MySQL自带了Binlog,就是一种实时的增量备份。Binlog里面记录的就是MySQL数据的变更的操作日志,开启Binlog之后,我们对MySQL中的每次更新数据操作,都会被记录到Binlog中。
|
||||
|
||||
Binlog是可以回放的,回放Binlog,就相当于把之前对数据库所有数据更新操作按照顺序重新执行了一遍,回放完成之后数据自然就恢复了。这就是Binlog增量备份的基本原理。很多数据库都有类似于MySQL Binlog的日志,原理和Binlog是一样的,备份和恢复方法也是类似的。
|
||||
|
||||
下面通过一个例子看一下如何使用Binlog进行备份和恢复。首先使用“show variables like ‘%log_bin%’”命令确认一下是否开启了Binlog功能:
|
||||
|
||||
```
|
||||
mysql> show variables like '%log_bin%';
|
||||
+---------------------------------+-----------------------------------+
|
||||
| Variable_name | Value |
|
||||
+---------------------------------+-----------------------------------+
|
||||
| log_bin | ON |
|
||||
| log_bin_basename | /usr/local/var/mysql/binlog |
|
||||
+---------------------------------+-----------------------------------+
|
||||
mysql> show master status;
|
||||
+---------------+----------+--------------+------------------+-------------------+
|
||||
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
|
||||
+---------------+----------+--------------+------------------+-------------------+
|
||||
| binlog.000001 | 18745 | | | |
|
||||
+---------------+----------+--------------+------------------+-------------------+
|
||||
|
||||
```
|
||||
|
||||
可以看到当前这个数据库已经开启了Binlog,log_bin_basename表示Binlog文件在服务器磁盘上的具体位置。然后用“show master status”命令可查看当前Binlog的状态,显示正在写入的Binlog文件,及当前的位置。假设我们每天凌晨用mysqldump做一个全量备份,然后开启了Binlog,有了这些,我们就可以把数据恢复到全量备份之后的任何一个时刻。
|
||||
|
||||
下面我们做一个简单的备份恢复演示。我们先模拟一次“删库跑路”的场景,直接把账户余额表清空:
|
||||
|
||||
```
|
||||
mysql> truncate table account_balance;
|
||||
Query OK, 0 rows affected (0.02 sec)
|
||||
|
||||
|
||||
mysql> select * from account_balance;
|
||||
Empty set (0.00 sec)
|
||||
|
||||
```
|
||||
|
||||
然后我们来进行数据恢复,首先执行一次全量恢复,把数据库恢复到今天凌晨的状态。
|
||||
|
||||
```
|
||||
$mysql -uroot test < dump.sql
|
||||
|
||||
|
||||
mysql> select * from account_balance;
|
||||
+---------+---------+---------------------+--------+
|
||||
| user_id | balance | timestamp | log_id |
|
||||
+---------+---------+---------------------+--------+
|
||||
| 0 | 100 | 2020-02-13 20:24:33 | 3 |
|
||||
+---------+---------+---------------------+--------+
|
||||
|
||||
```
|
||||
|
||||
可以看到,表里面的数据已经恢复了,但还是比较旧的数据。然后我们再用Binlog把数据恢复到删库跑路之前的那个时刻:
|
||||
|
||||
```
|
||||
$mysqlbinlog --start-datetime "2020-02-20 00:00:00" --stop-datetime "2020-02-20 15:09:00" /usr/local/var/mysql/binlog.000001 | mysql -uroot
|
||||
|
||||
|
||||
mysql> select * from account_balance;
|
||||
+---------+---------+---------------------+--------+
|
||||
| user_id | balance | timestamp | log_id |
|
||||
+---------+---------+---------------------+--------+
|
||||
| 0 | 200 | 2020-02-20 15:08:12 | 0 |
|
||||
+---------+---------+---------------------+--------+
|
||||
|
||||
```
|
||||
|
||||
这时候,数据已经恢复到当天的15点了。
|
||||
|
||||
通过定期的全量备份,配合Binlog,我们就可以把数据恢复到任意一个时间点,再也不怕程序员删库跑路了。详细的命令你可以参考[MySQL的官方文档中“备份和恢复”这一章](https://dev.mysql.com/doc/refman/8.0/en/backup-and-recovery.html)。
|
||||
|
||||
在执行备份和恢复的时候,有几个要点你需要特别的注意。
|
||||
|
||||
**第一,也是最重要的,“不要把所有的鸡蛋放在同一个篮子中”,无论是全量备份还是Binlog,都不要和数据库存放在同一个服务器上****。**最好能做到不同机房,甚至不同城市,离得越远越好。这样即使出现机房着火、光缆被挖断甚至地震也不怕。
|
||||
|
||||
**第二,在回放Binlog的时候,指定的起始时间可以比全量备份的时间稍微提前一点儿,确保全量备份之后的所有操作都在恢复的Binlog范围内,这样可以保证恢复的数据的完整性。**
|
||||
|
||||
因为回放Binlog的操作是具备幂等性的(为了确保回放幂等,需要设置Binlog的格式为ROW格式),关于幂等性,我们在《[01 | 创建和更新订单时,如何保证数据准确无误?](https://time.geekbang.org/column/article/204673)》这节课中讲到过,多次操作和一次操作对系统的影响是一样的,所以重复回放的那部分Binlog并不会影响数据的准确性。
|
||||
|
||||
## 配置MySQL HA实现高可用
|
||||
|
||||
通过全量备份加上Binlog,我们可以将数据库恢复到任何一个时间点,这样至少不会丢数据了。如果说,数据库服务器宕机了,因为我们有备份数据,完全可以启动一个新的数据库服务器,把备份数据恢复到新的数据库上,这样新的数据库就可以替代宕机的数据库,继续提供服务。
|
||||
|
||||
但是,这个恢复数据的时间是很长的,如果数据量比较大的话,有可能需要恢复几个小时。这几个小时,我们的系统是一直不可用的,这样肯定不行。
|
||||
|
||||
这个问题怎么解决?很简单,你不要等着数据库宕机了,才开始做恢复,我们完全可以提前来做恢复这些事儿。
|
||||
|
||||
我们准备一台备用的数据库,把它的数据恢复成主库一样,然后实时地在主备数据库之间来同步Binlog,主库做了一次数据变更,生成一条Binlog,我们就把这一条Binlog复制到备用库并立即回放,这样就可以让备用库里面的数据和主库中的数据一直保持是一样的。一旦主库宕机,就可以立即切换到备用库上继续提供服务。这就是MySQL的高可用方案,也叫MySQL HA。
|
||||
|
||||
MySQL自身就提供了主从复制的功能,通过配置就可以让一主一备两台MySQL的数据库保持数据同步,具体的配置方法你可以参考[MySQ官方文档中“复制”这一章](https://dev.mysql.com/doc/refman/8.0/en/replication.html)。
|
||||
|
||||
接下来我们说这个方案的问题。当我们对主库执行一次更新操作的时候,主从两个数据库更新数据实际的时序是这样的:
|
||||
|
||||
1. 在主库的磁盘上写入Binlog;
|
||||
1. 主库更新存储引擎中的数据;
|
||||
1. 给客户端返回成功响应;
|
||||
1. 主库把Binlog复制到从库;
|
||||
1. 从库回放Binlog,更新存储引擎中的数据。
|
||||
|
||||
也就是说,从库的数据是有可能比主库上的数据旧一些的,这个主从之间复制数据的延迟,称为“主从延迟”。正常情况下,主从延迟基本都是毫秒级别,你可以认为主从就是实时保持同步的。麻烦的是不正常的情况,一旦主库或者从库繁忙的时候,有可能会出现明显的主从延迟。
|
||||
|
||||
而很多情况下,数据库都不是突然宕机的,而是先繁忙,性能下降,最终宕机。这种情况下,很有可能主从延迟很大,如果我们把业务直接切到从库上继续读写,主从延迟这部分数据就丢了,并且这个数据丢失是不可逆的。即使事后你找回了当时主库的Binlog也是没法做到自动恢复的,因为它和从库的数据是冲突的。
|
||||
|
||||
简单地说,如果主库宕机并且主从存在延迟的情况下,切换到从库继续读写,可以保证业务的可用性,但是主从延迟这部分数据就丢失了。
|
||||
|
||||
这个时候你就需要做一个选择题了,第一个选项是,保证不丢数据,牺牲可用性,暂时停止服务,想办法把主库的Binlog恢复到从库上之后再提供服务。第二个选项就是,冒着丢一些数据的风险,保证可用性,第一时间切换到从库继续提供服务。
|
||||
|
||||
那能不能既保证数据不丢,还能做到高可用呢?也是可以的,那你就要牺牲一些性能。MySQL也支持[同步复制](https://dev.mysql.com/doc/refman/8.0/en/replication-semisync.html),开启同步复制时,MySQL主库会等待数据成功复制到从库之后,再给客户端返回响应。
|
||||
|
||||
如果说,牺牲的这点儿性能我不在乎,这个方案是不是就完美了呢?也不是,新的问题又来了!你想一下,这种情况下从库宕机了怎么办?本来从库宕机对主库是完全没影响的,因为现在主库要等待从库写入成功再返回,从库宕机,主库就会一直等待从库,主库也卡死了。
|
||||
|
||||
这个问题也有解决办法,那就是再加一个从库,把主库配置成:成功复制到任意一个从库就返回,只要有一个从库还活着,就不会影响主库写入数据,这样就解决了从库宕机阻塞主库的问题。如果主库发生宕机,在两个从库中,至少有一个从库中的数据是和主库完全一样的,可以把这个库作为新的主库,继续提供服务。为此你需要付出的代价是,你要至少用三台数据库服务器,并且这三台服务器提供的服务性能,还不如一台服务器高。
|
||||
|
||||
我把上面这三种典型的HA方案总结成下面这个表格,便于你对比选择:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/ac/04ff6bce8f5b607950fc469a606433ac.jpg" alt="">
|
||||
|
||||
## 小结
|
||||
|
||||
今天这节课讲了两件事儿,一是如何备份和恢复数据库中的数据,确保数据安全;二是如何来实现数据库的高可用,避免宕机停服。
|
||||
|
||||
虽然这是两个不同的问题,但你要知道,解决这两个问题背后的实现原理是一样的。**高可用依赖的是数据复制,数据复制的本质就是从一个库备份数据,然后恢复到另外一个库中去。**
|
||||
|
||||
数据备份时,使用低频度的全量备份配合Binlog增量备份是一种常用而且非常实用的方法,使用这种备份方法,我们可以把数据库的数据精确地恢复到历史上任意一个时刻,不仅能解决数据损坏的问题,也不用怕误操作、删库跑路这些事儿了。特别要注意的是,让备份数据尽量地远离数据库。
|
||||
|
||||
我们今天讲到的几种MySQL典型的HA方案,在数据可靠性、数据库可用性、性能和成本几个方面,各有利弊,你需要根据业务情况,做一个最优的选择,并且为可能存在的风险做好准备。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后也请你在留言区分享一下,你现在负责系统的数据库是如何来实现高可用的,有什么风险和问题,学习了这节课之后,你会如何来改进这个高可用方案?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
93
极客时间专栏/后端存储实战课/海量数据篇/15 | MySQL存储海量数据的最后一招:分库分表.md
Normal file
93
极客时间专栏/后端存储实战课/海量数据篇/15 | MySQL存储海量数据的最后一招:分库分表.md
Normal file
@@ -0,0 +1,93 @@
|
||||
<audio id="audio" title="15 | MySQL存储海量数据的最后一招:分库分表" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/94/756366dc7668dc61c10b859f8965a994.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
从这节课开始,我们课程将进入最后一部分“海量数据篇”,这节课也是我们最后一节主要讲MySQL的课程。解决海量数据的问题,必须要用到分布式的存储集群,因为MySQL本质上是一个单机数据库,所以很多场景下不是太适合存TB级别以上的数据。
|
||||
|
||||
但是,绝大部分的电商大厂,它的在线交易这部分的业务,比如说,订单、支付相关的系统,还是舍弃不了MySQL,原因是,**只有MySQL这类关系型数据库,才能提供金融级的事务保证**。我们之前也讲过分布式事务,那些新的分布式数据库提供的所谓的分布式事务,多少都有点儿残血,目前还达不到这些交易类系统对数据一致性的要求。
|
||||
|
||||
那既然MySQL支持不了这么大的数据量,这么高的并发,还必须要用它,怎么解决这个问题呢?还是按照我上节课跟你说的思想,**分片**,也就是拆分数据。1TB的数据,一个库撑不住,我把它拆成100个库,每个库就只有10GB的数据了,这不就可以了么?这种拆分就是所谓的MySQL分库分表。
|
||||
|
||||
不过,思路是这样没错,分库分表实践起来是非常不容易的,有很多问题需要去思考和解决。
|
||||
|
||||
## 如何规划分库分表?
|
||||
|
||||
还是拿咱们的“老熟人”订单表来举例子。首先需要思考的问题是,分库还是分表?分库呢,就是把数据拆分到不同的MySQL库中去,分表就是把数据拆分到同一个库的多张表里面。
|
||||
|
||||
在考虑到底是分库还是分表之前,我们需要先明确一个原则,**那就是能不拆就不拆,能少拆不多拆**。原因也很简单,你把数据拆分得越散,开发和维护起来就越麻烦,系统出问题的概率就越大。
|
||||
|
||||
基于这个原则我们想一下,什么情况下适合分表,什么情况下不得不分库?
|
||||
|
||||
那我们分库分表的目的是为了解决两个问题:
|
||||
|
||||
第一,是数据量太大查询慢的问题。这里面我们讲的“查询”其实主要是事务中的查询和更新操作,因为只读的查询可以通过缓存和主从分离来解决,这个我们在之前的“MySQL如何应对高并发”的两节课中都讲过。那我们上节课也讲到过,**解决查询慢,只要减少每次查询的数据总量就可以了,也就是说,分表就可以解决问题**。
|
||||
|
||||
第二,是为了应对高并发的问题。应对高并发的思想我们之前也说过,一个数据库实例撑不住,就把并发请求分散到多个实例中去,所以,**解决高并发的问题是需要分库的**。
|
||||
|
||||
简单地说,**数据量大,就分表;并发高,就分库**。
|
||||
|
||||
一般情况下,我们的方案都需要同时做分库分表,这时候分多少个库,多少张表,分别用预估的并发量和数据量来计算就可以了。
|
||||
|
||||
另外,我个人不建议你在方案中考虑二次扩容的问题,也就是考虑未来的数据量,把这次分库分表设计的容量都填满了之后,数据如何再次分裂的问题。
|
||||
|
||||
现在技术和业务变化这么快,等真正到了那个时候,业务早就变了,可能新的技术也出来了,你之前设计的二次扩容方案大概率是用不上的,所以没必要为了这个而增加方案的复杂程度。还是那句话,**越简单的设计可靠性越高**。
|
||||
|
||||
## 如何选择Sharding Key?
|
||||
|
||||
分库分表还有一个重要的问题是,选择一个合适的列或者说是属性,作为分表的依据,这个属性一般称为Sharding Key。像我们上节课讲到的归档历史订单的方法,它的Sharding Key就是订单完成时间。每次查询的时候,查询条件中必须带上这个时间,我们的程序就知道,三个月以前的数据查订单历史表,三个月内的数据查订单表,这就是一个简单的按照时间范围来分片的算法。
|
||||
|
||||
选择合适Sharding Key和分片算法非常重要,直接影响了分库分表的效果。我们首先来说如何选择Sharding Key的问题。
|
||||
|
||||
**选择这个Sharding Key最重要的参考因素是,我们的业务是如何访问数据的**。
|
||||
|
||||
比如我们把订单ID作为Sharding Key来拆分订单表,那拆分之后,如果我们按照订单ID来查订单,就需要先根据订单ID和分片算法计算出,我要查的这个订单它在哪个分片上,也就是哪个库哪张表中,然后再去那个分片执行查询就可以了。
|
||||
|
||||
但是,当我打开“我的订单”这个页面的时候,它的查询条件是用户ID,这里没有订单ID,那就没法知道我们要查的订单在哪个分片上,就没法查了。当然你要强行查的话,那就只能把所有分片都查一遍,再合并查询结果,这个就很麻烦,而且性能很差,还不能分页。
|
||||
|
||||
那要是把用户ID作为Sharding Key呢?也会面临同样的问题,使用订单ID作为查询条件来查订单的时候,就没办法找到订单在哪个分片了。这个问题的解决办法是,在生成订单ID的时候,把用户ID的后几位作为订单ID的一部分,比如说,可以规定,18位订单号中,第10-14位是用户ID的后四位,这样按订单ID查询的时候,就可以根据订单ID中的用户ID找到分片。
|
||||
|
||||
那我们系统对订单的查询方式,肯定不只是按订单ID或者按用户ID这两种啊。比如说,商家希望看到的是自己店铺的订单,还有各种和订单相关的报表。对于这些查询需求,我们一旦对订单做了分库分表,就没法解决了。那怎么办呢?
|
||||
|
||||
一般的做法是,把订单数据同步到其他的存储系统中去,在其他的存储系统里面解决问题。比如说,我们可以再构建一个以店铺ID作为Sharding Key的只读订单库,专门供商家来使用。或者,把订单数据同步到HDFS中,然后用一些大数据技术来生成订单相关的报表。
|
||||
|
||||
所以你看,一旦做了分库分表,就会极大地限制数据库的查询能力,之前很简单的查询,分库分表之后,可能就没法实现了。所以我们在之前的课程中,先讲了各种各样的方法,来缓解数据多、并发高的问题,而一直没讲分库分表。**分库分表一定是,数据量和并发大到所有招数都不好使了,我们才拿出来的最后一招。**
|
||||
|
||||
## 如何选择分片算法?
|
||||
|
||||
在上节课我给你留的思考题中,我们提到过,能不能用订单完成时间作为Sharding Key呢?比如说,我分12个分片,每个月一个分片,这样对查询的兼容要好很多,毕竟查询条件中带上时间范围,让查询只落到某一个分片上,还是比较容易的,我在查询界面上强制用户必须指定时间范围就行了。
|
||||
|
||||
这种做法有个很大的问题,比如现在是3月份,那基本上所有的查询都集中在3月份这个分片上,其他11个分片都闲着,这样不仅浪费资源,很可能你3月那个分片根本抗不住几乎全部的并发请求。这个问题就是“热点问题”。
|
||||
|
||||
也就是说,我们希望并发请求和数据能均匀地分布到每一个分片上,尽量避免出现热点。这是选择分片算法时需要考虑的一个重要的因素。一般常用的分片算法就那么几种,刚刚讲到的按照时间范围分片的方法是其中的一种。
|
||||
|
||||
基于范围来分片容易产生热点问题,不适合作为订单的分片方法,但是这种分片方法的优点也很突出,那就是对查询非常友好,基本上只要加上一个时间范围的查询条件,原来该怎么查,分片之后还可以怎么查。范围分片特别适合那种数据量非常大,但并发访问量不大的ToB系统。比如说,电信运营商的监控系统,它可能要采集所有人手机的信号质量,然后做一些分析,这个数据量非常大,但是这个系统的使用者是运营商的工作人员,并发量很少。这种情况下就很适合范围分片。
|
||||
|
||||
一般来说,订单表都采用更均匀的哈希分片算法。比如说,我们要分24个分片,选定了Sharding Key是用户ID,那我们决定某个用户的订单应该落到那个分片上的算法是,拿用户ID除以24,得到的余数就是分片号。这是最简单的取模算法,一般就可以满足大部分要求了。当然也有一些更复杂的哈希算法,像一致性哈希之类的,特殊情况下也可以使用。
|
||||
|
||||
需要注意的一点是,哈希分片算法能够分得足够均匀的前提条件是,用户ID后几位数字必须是均匀分布的。比如说,你在生成用户ID的时候,自定义了一个用户ID的规则,最后一位0是男性,1是女性,这样的用户ID哈希出来可能就没那么均匀,可能会出现热点。
|
||||
|
||||
还有一种分片的方法:查表法。查表法其实就是没有分片算法,决定某个Sharding Key落在哪个分片上,全靠人为来分配,分配的结果记录在一张表里面。每次执行查询的时候,先去表里查一下要找的数据在哪个分片中。
|
||||
|
||||
查表法的好处就是灵活,怎么分都可以,你用上面两种分片算法都没法分均匀的情况下,就可以用查表法,人为地来把数据分均匀了。查表法还有一个特好的地方是,它的分片是可以随时改变的。比如我发现某个分片已经是热点了,那我可以把这个分片再拆成几个分片,或者把这个分片的数据移到其他分片中去,然后修改一下分片映射表,就可以在线完成数据拆分了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0f/9d/0faac5967ca1f9385d8f7eda8eedd09d.jpg" alt="">
|
||||
|
||||
但你需要注意的是,分片映射表本身的数据不能太多,否则这个表反而成为热点和性能瓶颈了。查表法相对其他两种分片算法来说,缺点是需要二次查询,实现起来更复杂,性能上也稍微慢一些。但是,分片映射表可以通过缓存来加速查询,实际性能并不会慢很多。
|
||||
|
||||
## 小结
|
||||
|
||||
对MySQL这样的单机数据库来说,分库分表是应对海量数据和高并发的最后一招,分库分表之后,将会对数据查询有非常大的限制。
|
||||
|
||||
分多少个库需要用并发量来预估,分多少表需要用数据量来预估。选择Sharding Key的时候,一定要能兼容业务最常用的查询条件,让查询尽量落在一个分片中,分片之后无法兼容的查询,可以把数据同步到其他存储中去,来解决这个问题。
|
||||
|
||||
我们常用三种分片算法,范围分片容易产生热点问题,但对查询更友好,适合适合并发量不大的场景;哈希分片比较容易把数据和查询均匀地分布到所有分片中;查表法更灵活,但性能稍差。
|
||||
|
||||
对于订单表进行分库分表,一般按照用户ID作为Sharding Key,采用哈希分片算法来均匀分布用户订单数据。为了能支持按订单号查询的需求,需要把用户ID的后几位放到订单号中去。
|
||||
|
||||
最后还需要强调一下,我们这节课讲的这些分片相关的知识,不仅仅适用于MySQL的分库分表,你在使用其他分布式数据库的时候,一样会遇到如何分片、如何选择Sharding Key和分片算法的问题,它们的原理都是一样的,所以我们讲的这些方法也都是通用的。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你想一下,把订单表拆分之后,那些和订单有外键关联的表,该怎么处理?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
114
极客时间专栏/后端存储实战课/海量数据篇/16 | 用Redis构建缓存集群的最佳实践有哪些?.md
Normal file
114
极客时间专栏/后端存储实战课/海量数据篇/16 | 用Redis构建缓存集群的最佳实践有哪些?.md
Normal file
@@ -0,0 +1,114 @@
|
||||
<audio id="audio" title="16 | 用Redis构建缓存集群的最佳实践有哪些?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/56/b68d0c726750b5cea58bb69314f47b56.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
之前连续几节课,我们都在以MySQL为例子,讲如何应对海量数据,如何应对高并发,如何实现高可用,我先带你简单复习一下。
|
||||
|
||||
- 数据量太大查询慢怎么办?存档历史数据或者分库分表,这是数据分片。
|
||||
- 并发太高扛不住怎么办?读写分离,这是增加实例数。
|
||||
- 数据库宕机怎么办?增加从节点,主节点宕机的时候用从节点顶上,这是主从复制。但是这里面要特别注意数据一致性的问题。
|
||||
|
||||
我在之前课程中,也多次提到过,**这些方法不仅仅是MySQL特有的,对于几乎所有的存储系统,都是适用的。**
|
||||
|
||||
今天这节课,我们来聊一聊,如何构建一个生产系统可用的Redis缓存集群。你将会看到,几种集群解决方案用到的思想,基本和我们上面讲的都是一样的。
|
||||
|
||||
## Redis Cluster如何解决数据量大、高可用和高并发问题?
|
||||
|
||||
Redis从3.0版本开始,提供了官方的集群支持,也就是Redis Cluser。**Redis Cluster相比于单个节点的Redis,能保存更多的数据,支持更多的并发,并且可以做到高可用,在单个节点故障的情况下,继续提供服务。**
|
||||
|
||||
为了能够保存更多的数据,和MySQL分库分表的方式类似,Redis Cluster也是通过分片的方式,把数据分布到集群的多个节点上。
|
||||
|
||||
Redis Cluster是如何来分片的呢?它引入了一个“槽(Slot)”的概念,这个槽就是哈希表中的哈希槽,槽是Redis分片的基本单位,每个槽里面包含一些Key。每个集群的槽数是固定的16384(16 * 1024)个,每个Key落在哪个槽中也是固定的,计算方法是:
|
||||
|
||||
```
|
||||
HASH_SLOT = CRC16(key) mod 16384
|
||||
|
||||
```
|
||||
|
||||
这个算法很简单,先计算Key的CRC值,然后把这个CRC之后的Key值直接除以16384,余数就是Key所在的槽。这个算法就是我们上节课讲过的哈希分片算法。
|
||||
|
||||
这些槽又是如何存放到具体的Redis节点上的呢?这个映射关系保存在集群的每个Redis节点上,集群初始化的时候,Redis会自动平均分配这16384个槽,也可以通过命令来调整。这个分槽的方法,也是我们上节课讲到过的分片算法:查表法。
|
||||
|
||||
客户端可以连接集群的任意一个节点来访问集群的数据,当客户端请求一个Key的时候,被请求的那个Redis实例先通过上面的公式,计算出这个Key在哪个槽中,然后再查询槽和节点的映射关系,找到数据所在的真正节点,如果这个节点正好是自己,那就直接执行命令返回结果。如果数据不在当前这个节点上,那就给客户端返回一个重定向的命令,告诉客户端,应该去连哪个节点上请求这个Key的数据。然后客户端会再连接正确的节点来访问。
|
||||
|
||||
解决分片问题之后,Redis Cluster就可以通过水平扩容来增加集群的存储容量,但是,每次往集群增加节点的时候,需要从集群的那些老节点中,搬运一些槽到新节点,你可以手动指定哪些槽迁移到新节点上,也可以利用官方提供的[redis-trib.rb](http://download.redis.io/redis-stable/src/redis-trib.rb)脚本来自动重新分配槽,自动迁移。
|
||||
|
||||
分片可以解决Redis保存海量数据的问题,并且客观上提升了Redis的并发能力和查询性能。但是并不能解决高可用的问题,每个节点都保存了整个集群数据的一个子集,任何一个节点宕机,都会导致这个宕机节点上的那部分数据无法访问。
|
||||
|
||||
那Redis Cluster是怎么解决高可用问题的?
|
||||
|
||||
参见上面我们讲到的方法:**增加从节点,做主从复制**。Redis Cluster支持给每个分片增加一个或多个从节点,每个从节点在连接到主节点上之后,会先给主节点发送一个SYNC命令,请求一次全量复制,也就是把主节点上全部的数据都复制到从节点上。全量复制完成之后,进入同步阶段,主节点会把刚刚全量复制期间收到的命令,以及后续收到的命令持续地转发给从节点。
|
||||
|
||||
因为Redis不支持事务,所以它的复制相比MySQL更简单,连Binlog都省了,直接就是转发客户端发来的更新数据命令来实现主从同步。如果某个分片的主节点宕机了,集群中的其他节点会在这个分片的从节点中选出一个新的节点作为主节点继续提供服务。新的主节点选举出来后,集群中的所有节点都会感知到,这样,如果客户端的请求Key落在故障分片上,就会被重定向到新的主节点上。
|
||||
|
||||
最后我们看一下,Redis Cluster是如何应对高并发的。
|
||||
|
||||
一般来说,Redis Cluster进行了分片之后,每个分片都会承接一部分并发的请求,加上Redis本身单节点的性能就非常高,所以大部分情况下不需要再像MySQL那样做读写分离来解决高并发的问题。默认情况下,集群的读写请求都是由主节点负责的,从节点只是起一个热备的作用。当然了,Redis Cluster也支持读写分离,在从节点上读取数据。
|
||||
|
||||
以上就是Redis Cluster的基本原理,你可以对照下图来加深理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/fd/c405e73a4fd797ca0cda82b383e46ffd.png" alt="">
|
||||
|
||||
你可以看到,Redis Cluster整体的架构完全就是照抄MySQL构建集群的那一套东西(当然,这些设计和方法也不是MySQL发明的),抄作业抄的就差把名字一起也抄上了。
|
||||
|
||||
具体如何搭建Redis Cluster以及相关的操作命令你可以看一下[Redis官方的这篇教程](https://redis.io/topics/cluster-tutorial)。
|
||||
|
||||
## 为什么Redis Cluster不适合超大规模集群?
|
||||
|
||||
Redis Cluster的优点是易于使用。分片、主从复制、弹性扩容这些功能都可以做到自动化,通过简单的部署就可以获得一个大容量、高可靠、高可用的Redis集群,并且对于应用来说,近乎于是透明的。
|
||||
|
||||
所以,**Redis Cluster是非常适合构建中小规模Redis集群**,这里的中小规模指的是,大概几个到几十个节点这样规模的Redis集群。
|
||||
|
||||
**但是Redis Cluster不太适合构建超大规模集群,主要原因是,它采用了去中心化的设计。**刚刚我们讲了,Redis的每个节点上,都保存了所有槽和节点的映射关系表,客户端可以访问任意一个节点,再通过重定向命令,找到数据所在的那个节点。那你有没有想过一个问题,这个映射关系表,它是如何更新的呢?比如说,集群加入了新节点,或者某个主节点宕机了,新的主节点被选举出来,这些情况下,都需要更新集群每一个节点上的映射关系表。
|
||||
|
||||
Redis Cluster采用了一种去中心化的[流言(Gossip)协议](https://en.wikipedia.org/wiki/Gossip_protocol)来传播集群配置的变化。一般涉及到协议都比较复杂,这里我们不去深究具体协议和实现算法,我大概给你讲一下这个协议原理。
|
||||
|
||||
所谓流言,就是八卦,比如说,我们上学的时候,班上谁和谁偷偷好上了,搞对象,那用不了一天,全班同学都知道了。咋知道的?张三看见了,告诉李四,李四和王小二特别好,又告诉了王小二,这样人传人,不久就传遍全班了。这个就是八卦协议的传播原理。
|
||||
|
||||
这个八卦协议它的好处是去中心化,传八卦不需要组织,吃瓜群众自发就传开了。这样部署和维护就更简单,也能避免中心节点的单点故障。八卦协议的缺点就是传播速度慢,并且是集群规模越大,传播的越慢。这个也很好理解,比如说,换成某两个特别出名的明星搞对象,即使是全国人民都很八卦,但要想让全国每一个人都知道这个消息,还是需要很长的时间。在集群规模太大的情况下,数据不同步的问题会被明显放大,还有一定的不确定性,如果出现问题很难排查。
|
||||
|
||||
## 如何用Redis构建超大规模集群?
|
||||
|
||||
Redis Cluster不太适合用于大规模集群,所以很多大厂,都选择自己去搭建Redis集群。这里面,每一家的解决方案都有自己的特色,但其实总体的架构都是大同小异的。
|
||||
|
||||
一种是基于代理的方式,在客户端和Redis节点之间,还需要增加一层代理服务。这个代理服务有三个作用。
|
||||
|
||||
第一个作用是,负责在客户端和Redis节点之间转发请求和响应。客户端只和代理服务打交道,代理收到客户端的请求之后,再转发到对应的Redis节点上,节点返回的响应再经由代理转发返回给客户端。
|
||||
|
||||
第二个作用是,负责监控集群中所有Redis节点状态,如果发现有问题节点,及时进行主从切换。
|
||||
|
||||
第三个作用就是维护集群的元数据,这个元数据主要就是集群所有节点的主从信息,以及槽和节点关系映射表。这个架构和我在《[12 | MySQL如何应对高并发(二):读写分离](https://time.geekbang.org/column/article/215330)》这节课中给你讲过的,用HAProxy+Keepalived来代理MySQL请求的架构是类似的,只是多了一个自动路由分片的功能而已。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/d6/03c2901db83c02cbfe90a8c9b78d04d6.jpg" alt="">
|
||||
|
||||
像开源的Redis集群方案[twemproxy](https://github.com/twitter/twemproxy)和[Codis](https://github.com/CodisLabs/codis),都是这种架构的。
|
||||
|
||||
这个架构最大的优点是对客户端透明,在客户端视角来看,整个集群和一个超大容量的单节点Redis是一样的。并且,由于分片算法是代理服务控制的,扩容也比较方便,新节点加入集群后,直接修改代理服务中的元数据就可以完成扩容。
|
||||
|
||||
不过,这个架构的缺点也很突出,增加了一层代理转发,每次数据访问的链路更长了,必然会带来一定的性能损失。而且,代理服务本身又是集群的一个单点,当然,我们可以把代理服务也做成一个集群来解决单点问题,那样集群就更复杂了。
|
||||
|
||||
另外一种方式是,不用这个代理服务,把代理服务的寻址功能前移到客户端中去。客户端在发起请求之前,先去查询元数据,就可以知道要访问的是哪个分片和哪个节点,然后直连对应的Redis节点访问数据。
|
||||
|
||||
当然,客户端不用每次都去查询元数据,因为这个元数据是不怎么变化的,客户端可以自己缓存元数据,这样访问性能基本上和单机版的Redis是一样的。如果某个分片的主节点宕机了,新的主节点被选举出来之后,更新元数据里面的信息。对集群的扩容操作也比较简单,除了迁移数据的工作必须要做以外,更新一下元数据就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/da/dcaced0a9ce9842ef688c9626accdcda.jpg" alt="">
|
||||
|
||||
虽然说,这个元数据服务仍然是一个单点,但是它的数据量不大,访问量也不大,相对就比较容易实现。我们可以用ZooKeeper、etcd甚至MySQL都能满足要求。这个方案应该是最适合超大规模Redis集群的方案了,在性能、弹性、高可用几方面表现都非常好,缺点是整个架构比较复杂,客户端不能通用,需要开发定制化的Redis客户端,只有规模足够大的企业才负担得起。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这节课我们讲了从小到大三种构建Redis集群的方式。
|
||||
|
||||
- 小规模的集群建议使用官方的Redis Cluster,在节点数量不多的情况下,各方面表现都不错。
|
||||
- 再大一些规模的集群,可以考虑使用twemproxy或者Codis这类的基于代理的集群架构,虽然是开源方案,但是已经被很多公司在生产环境中验证过。
|
||||
- 相比于代理方案,使用定制客户端的方案性能更好,很多大厂采用的都是类似的架构。
|
||||
|
||||
还有一个小问题需要注意的是,这几种集群方案对一些类似于“KEYS”这类的多KEY命令,都没法做到百分百支持。原因很简单,数据被分片了之后,这种多KEY的命令很可能需要跨多个分片查询。当你的系统从单个Redis库升级到集群时,可能需要考虑一下这方面的兼容性问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
很多存储系统之间都存在“互相抄作业”的嫌疑,其实这对于我们这些存储系统的使用者来说是好事儿,比如我们把MySQL都学透了,你再去看Redis,知道它抄了哪些作业,这部分我们就可以迅速掌握了,只要再研究一下不一样的那一小部分内容,我们就可以精通Redis了是不?
|
||||
|
||||
课后请你再去看一下HDFS,它在解决分片、复制和高可用这几方面,哪些是“抄作业”,哪些又是自己独创的。欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
186
极客时间专栏/后端存储实战课/海量数据篇/17 | 大厂都是怎么做MySQL to Redis同步的?.md
Normal file
186
极客时间专栏/后端存储实战课/海量数据篇/17 | 大厂都是怎么做MySQL to Redis同步的?.md
Normal file
@@ -0,0 +1,186 @@
|
||||
<audio id="audio" title="17 | 大厂都是怎么做MySQL to Redis同步的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ce/f8/ce70cc33d669d707ede0e8e862ee7ef8.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
之前我们在《[11 | MySQL如何应对高并发(一):使用缓存保护MySQL](https://time.geekbang.org/column/article/213230)》这一节课中,讲到了Read/Write Through和Cache Aside这几种更新缓存的策略,这几种策略都存在缓存穿透的可能,如果缓存没有命中,那就穿透缓存去访问数据库获取数据。
|
||||
|
||||
一般情况下,只要我们做好缓存预热,这个缓存的命中率很高,能穿透缓存打到数据库上的请求比例就非常低,这些缓存的策略都是没问题的。但是如果说,我们的Redis缓存服务的是一个超大规模的系统,那就又不一样了。
|
||||
|
||||
今天这节课,我们来说一下,在超大规模系统中缓存会面临什么样的问题,以及应该使用什么样的策略来更新缓存。
|
||||
|
||||
## 缓存穿透:超大规模系统的不能承受之痛
|
||||
|
||||
我们上节课讲到了如何构建Redis集群,由于集群可以水平扩容,那只要集群足够大,理论上支持海量并发也不是问题。但是,因为并发请求的数量这个基数太大了,即使有很小比率的请求穿透缓存,打到数据库上请求的绝对数量仍然不小。加上大促期间的流量峰值,还是存在缓存穿透引发雪崩的风险。
|
||||
|
||||
那这个问题怎么解决呢?其实方法你也想得到,不让请求穿透缓存不就行了?反正现在存储也便宜,只要你买得起足够多的服务器,Redis集群的容量就是无限的。不如把全量的数据都放在Redis集群里面,处理读请求的时候,干脆只读Redis,不去读数据库。这样就完全没有“缓存穿透”的风险了,实际上很多大厂它就是这么干的。
|
||||
|
||||
在Redis中缓存全量的数据,又引发了一个新的问题,那就是,如何来更新缓存中的数据呢?因为我们取消了缓存穿透的机制,这种情况下,从缓存读到数据可以直接返回,如果没读到数据,那就只能返回错误了!所以,当系统更新数据库的数据之后,必须及时去更新缓存。
|
||||
|
||||
说到这儿,又绕回到那个老问题上了:怎么保证Redis中的数据和数据库中的数据同步更新?我们之前讲过用分布式事务来解决数据一致性的问题,但是这些方法都不太适合用来更新缓存,**因为分布式事务,对数据更新服务有很强的侵入性**。我们拿下单服务来说,如果为了更新缓存增加一个分布式事务,无论我们用哪种分布式事务,或多或少都会影响下单服务的性能。还有一个问题是,如果Redis本身出现故障,写入数据失败,还会导致下单失败,等于是降低了下单服务性能和可用性,这样肯定不行。
|
||||
|
||||
**对于像订单服务这类核心的业务,一个可行的方法是,我们启动一个更新订单缓存的服务,接收订单变更的MQ消息,然后更新Redis中缓存的订单数据。**因为这类核心的业务数据,使用方非常多,本来就需要发消息,增加一个消费订阅基本没什么成本,订单服务本身也不需要做任何更改。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/8e/7cec502808318409dbc719c0b1cbbc8e.jpg" alt="">
|
||||
|
||||
唯一需要担心的一个问题是,如果丢消息了怎么办?因为现在消息是缓存数据的唯一来源,一旦出现丢消息,缓存里缺失的那条数据永远不会被补上。所以,必须保证整个消息链条的可靠性,不过好在现在的MQ集群,比如像Kafka或者RocketMQ,它都有高可用和高可靠的保证机制,只要你正确配置好,是可以满足数据可靠性要求的。
|
||||
|
||||
像订单服务这样,本来就有现成的数据变更消息可以订阅,这样更新缓存还是一个不错的选择,因为实现起来很简单,对系统的其他模块完全没有侵入。
|
||||
|
||||
## 使用Binlog实时更新Redis缓存
|
||||
|
||||
如果我们要缓存的数据,本来没有一份数据更新的MQ消息可以订阅怎么办?很多大厂都采用的,也是更通用的解决方案是这样的。
|
||||
|
||||
数据更新服务只负责处理业务逻辑,更新MySQL,完全不用管如何去更新缓存。负责更新缓存的服务,把自己伪装成一个MySQL的从节点,从MySQL接收Binlog,解析Binlog之后,可以得到实时的数据变更信息,然后根据这个变更信息去更新Redis缓存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/12/918380c0e43de2f4ef7ad5e8e9d5d212.jpg" alt="">
|
||||
|
||||
这种收Binlog更新缓存的方案,和刚刚我们讲到的,收MQ消息更新缓存的方案,其实它们的实现思路是一样的,都是异步订阅实时数据变更信息,去更新Redis。只不过,直接读取Binlog这种方式,它的通用性更强。不要求订单服务再发订单消息了,订单更新服务也不用费劲去解决“发消息失败怎么办?”这种数据一致性问题了。
|
||||
|
||||
而且,在整个缓存更新链路上,减少了一个收发MQ的环节,从MySQL更新到Redis更新的时延更短,出现故障的可能性也更低,所以很多大厂更青睐于这种方案。
|
||||
|
||||
这个方案唯一的缺点是,实现订单缓存更新服务有点儿复杂,毕竟不像收消息,拿到的直接就是订单数据,解析Binlog还是挺麻烦的。
|
||||
|
||||
有很多开源的项目就提供了订阅和解析MySQL Binlog的功能,下面我们以比较常用的开源项目[Canal](https://github.com/alibaba/canal)为例,来演示一下如何实时接收Binlog更新Redis缓存。
|
||||
|
||||
Canal模拟MySQL 主从复制的交互协议,把自己伪装成一个MySQL的从节点,向MySQL主节点发送dump请求,MySQL收到请求后,就会开始推送Binlog给Canal,Canal解析Binlog字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。下图是Canal的工作原理:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/e4/452211795717190e55c5b0ff2ab208e4.jpg" alt="">
|
||||
|
||||
在我们这个示例中,MySQL和Redis都运行在本地的默认端口上,MySQL的端口为3306,Redis的端口为6379。为了便于大家操作,我们还是以《[04 | 事务:账户余额总是对不上账,怎么办?](https://time.geekbang.org/column/article/206544)》这节课中的账户余额表account_balance作为演示数据。
|
||||
|
||||
首先,下载并解压Canal 最新的1.1.4版本到本地:
|
||||
|
||||
```
|
||||
wget https://github.com/alibaba/canal/releases/download/canal-1.1.4/canal.deployer-1.1.4.tar.gz
|
||||
tar zvfx canal.deployer-1.1.4.tar.gz
|
||||
|
||||
```
|
||||
|
||||
然后来配置MySQL,我们需要在MySQL的配置文件中开启Binlog,并设置Binlog的格式为ROW格式。
|
||||
|
||||
```
|
||||
[mysqld]
|
||||
log-bin=mysql-bin # 开启Binlog
|
||||
binlog-format=ROW # 设置Binlog格式为ROW
|
||||
server_id=1 # 配置一个ServerID
|
||||
|
||||
```
|
||||
|
||||
给Canal开一个专门的MySQL用户并授权,确保这个用户有复制Binlog的权限:
|
||||
|
||||
```
|
||||
CREATE USER canal IDENTIFIED BY 'canal';
|
||||
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
```
|
||||
|
||||
重启一下MySQL,确保所有的配置生效。重启后检查一下当前的Binlog文件和位置:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/8f/01293d0ccc372418f3e01c785e204b8f.png" alt="">
|
||||
|
||||
记录下File和Position两列的值,然后我们来配置Canal。编辑Canal的实例配置文件canal/conf/example/instance.properties,以便让Canal连接到我们的MySQL上。
|
||||
|
||||
```
|
||||
canal.instance.gtidon=false
|
||||
|
||||
|
||||
# position info
|
||||
canal.instance.master.address=127.0.0.1:3306
|
||||
canal.instance.master.journal.name=binlog.000009
|
||||
canal.instance.master.position=155
|
||||
canal.instance.master.timestamp=
|
||||
canal.instance.master.gtid=
|
||||
|
||||
|
||||
# username/password
|
||||
canal.instance.dbUsername=canal
|
||||
canal.instance.dbPassword=canal
|
||||
canal.instance.connectionCharset = UTF-8
|
||||
canal.instance.defaultDatabaseName=test
|
||||
# table regex
|
||||
canal.instance.filter.regex=.*\\..
|
||||
|
||||
```
|
||||
|
||||
这个配置文件需要配置MySQL的连接地址、库名、用户名和密码之外,还需要配置canal.instance.master.journal.name和canal.instance.master.position这两个属性,取值就是刚刚记录的File和Position两列。然后就可以启动Canal服务了:
|
||||
|
||||
```
|
||||
canal/bin/startup.sh
|
||||
|
||||
```
|
||||
|
||||
启动之后看一下日志文件canal/logs/example/example.log,如果里面没有报错,就说明启动成功并连接到我们的MySQL上了。
|
||||
|
||||
Canal服务启动后,会开启一个端口(11111)等待客户端连接,客户端连接上Canal服务之后,可以从Canal服务拉取数据,每拉取一批数据,正确写入Redis之后,给Canal服务返回处理成功的响应。如果发生客户端程序宕机或者处理失败等异常情况,Canal服务没收到处理成功的响应,下次客户端来拉取的还是同一批数据,这样就可以保证顺序并且不会丢数据。
|
||||
|
||||
接下来我们来开发账户余额缓存的更新程序,以下的代码都是用Java语言编写的:
|
||||
|
||||
```
|
||||
while (true) {
|
||||
Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
|
||||
long batchId = message.getId();
|
||||
try {
|
||||
int size = message.getEntries().size();
|
||||
if (batchId == -1 || size == 0) {
|
||||
Thread.sleep(1000);
|
||||
} else {
|
||||
processEntries(message.getEntries(), jedis);
|
||||
}
|
||||
|
||||
|
||||
connector.ack(batchId); // 提交确认
|
||||
} catch (Throwable t) {
|
||||
connector.rollback(batchId); // 处理失败, 回滚数据
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序逻辑也不复杂,程序启动并连接到Canal服务后,就不停地拉数据,如果没有数据就睡一会儿,有数据就调用processEntries方法处理更新缓存。每批数据更新成功后,就调用ack方法给Canal服务返回成功响应,如果失败抛异常就回滚。下面是processEntries方法的主要代码:
|
||||
|
||||
```
|
||||
for (CanalEntry.RowData rowData : rowChage.getRowDatasList()) {
|
||||
if (eventType == CanalEntry.EventType.DELETE) { // 删除
|
||||
jedis.del(row2Key("user_id", rowData.getBeforeColumnsList()));
|
||||
} else if (eventType == CanalEntry.EventType.INSERT) { // 插入
|
||||
jedis.set(row2Key("user_id", rowData.getAfterColumnsList()), row2Value(rowData.getAfterColumnsList()));
|
||||
} else { // 更新
|
||||
jedis.set(row2Key("user_id", rowData.getAfterColumnsList()), row2Value(rowData.getAfterColumnsList()));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面根据事件类型来分别处理,如果MySQL中的数据删除了,就删除Redis中对应的数据。如果是更新和插入操作,那就调用Redis的SET命令来写入数据。
|
||||
|
||||
把这个账户缓存更新服务启动后,我们来验证一下,我们在账户余额表插入一条记录:
|
||||
|
||||
```
|
||||
mysql> insert into account_balance values (888, 100, NOW(), 999);
|
||||
|
||||
```
|
||||
|
||||
然后来看一下Redis缓存:
|
||||
|
||||
```
|
||||
127.0.0.1:6379> get 888
|
||||
"{\"log_id\":\"999\",\"balance\":\"100\",\"user_id\":\"888\",\"timestamp\":\"2020-03-08 16:18:10\"}"
|
||||
|
||||
```
|
||||
|
||||
可以看到数据已经自动同步到Redis中去了。我把这个示例的完整代码放在了[GitHub](https://github.com/liyue2008/canal-to-redis-example)上供你参考。
|
||||
|
||||
## 小结
|
||||
|
||||
在处理超大规模并发的场景时,由于并发请求的数量非常大,即使少量的缓存穿透,也有可能打死数据库引发雪崩效应。对于这种情况,我们可以缓存全量数据来彻底避免缓存穿透问题。
|
||||
|
||||
对于缓存数据更新的方法,可以订阅数据更新的MQ消息来异步更新缓存,更通用的方法是,把缓存更新服务伪装成一个MySQL的从节点,订阅MySQL的Binlog,通过Binlog来更新Redis缓存。
|
||||
|
||||
需要特别注意的是,无论是用MQ还是Canal来异步更新缓存,对整个更新服务的数据可靠性和实时性要求都比较高,数据丢失或者更新慢了,都会造成Redis中的数据与MySQL中数据不同步。在把这套方案应用到生产环境中去的时候,需要考虑一旦出现不同步问题时的降级或补偿方案。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你思考一下,如果出现缓存不同步的情况,在你负责的业务场景下,该如何降级或者补偿?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
82
极客时间专栏/后端存储实战课/海量数据篇/18 | 分布式存储:你知道对象存储是如何保存图片文件的吗?.md
Normal file
82
极客时间专栏/后端存储实战课/海量数据篇/18 | 分布式存储:你知道对象存储是如何保存图片文件的吗?.md
Normal file
@@ -0,0 +1,82 @@
|
||||
<audio id="audio" title="18 | 分布式存储:你知道对象存储是如何保存图片文件的吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/b3/0fe9c3058098500a18c7624399b193b3.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
我们都知道,保存像图片、音视频这类大文件,最佳的选择就是对象存储。对象存储不仅有很好的大文件读写性能,还可以通过水平扩展实现近乎无限的容量,并且可以兼顾服务高可用、数据高可靠这些特性。
|
||||
|
||||
对象存储之所以能做到这么“全能”,最主要的原因是,**对象存储是原生的分布式存储系统**。这里我们讲的“原生分布式存储系统”,是相对于MySQL、Redis这类单机存储系统来说的。虽然这些非原生的存储系统,也具备一定的集群能力,但你也能感受到,用它们构建大规模分布式集群的时候,其实是非常不容易的。
|
||||
|
||||
随着云计算的普及,很多新生代的存储系统,都是原生的分布式系统,它们一开始设计的目标之一就是分布式存储集群,比如说[Elasticsearch](https://www.elastic.co/cn/)、[Ceph](http://about:blank)和国内很多大厂推出的新一代数据库,大多都可以做到:
|
||||
|
||||
- 近乎无限的存储容量;
|
||||
- 超高的读写性能;
|
||||
- 数据高可靠:节点磁盘损毁不会丢数据;
|
||||
- 实现服务高可用:节点宕机不会影响集群对外提供服务。
|
||||
|
||||
那这些原生分布式存储是如何实现这些特性的呢?
|
||||
|
||||
实际上不用我说,你也能猜得到,这里面同样存在严重的“互相抄作业”的情况。这个也可以理解,除了存储的数据结构不一样,提供的查询服务不一样以外,这些分布式存储系统,它们面临的很多问题都是一样的,那实现方法差不多也是可以理解。
|
||||
|
||||
对象存储它的查询服务和数据结构都非常简单,是最简单的原生分布式存储系统。这节课,我们就来一起来研究一下对象存储这种最简单的原生分布式存储,通过对象存储来认识一下分布式存储系统的一些共性。掌握了这些共性之后,你再去认识和学习其他的分布式存储系统,也会感觉特别容易。
|
||||
|
||||
## 对象存储数据是如何保存大文件的?
|
||||
|
||||
对象存储对外提供的服务,其实就是一个近乎无限容量的大文件KV存储,所以对象存储和分布式文件系统之间,没有那么明确的界限。对象存储的内部,肯定有很多的存储节点,用于保存这些大文件,这个就是数据节点的集群。
|
||||
|
||||
另外,我们为了管理这些数据节点和节点中的文件,还需要一个存储系统保存集群的节点信息、文件信息和它们的映射关系。这些为了管理集群而存储的数据,叫做元数据(Metadata)。
|
||||
|
||||
元数据对于一个存储集群来说是非常重要的,所以保存元数据的存储系统必须也是一个集群。但是元数据集群存储的数据量比较少,数据的变动不是很频繁,加之客户端或者网关都会缓存一部分元数据,所以元数据集群对并发要求也不高。一般使用类似[ZooKeeper](https://zookeeper.apache.org/)或者[etcd](https://github.com/etcd-io/etcd)这类分布式存储就可以满足要求。
|
||||
|
||||
另外,存储集群为了对外提供访问服务,还需要一个网关集群,对外接收外部请求,对内访问元数据和数据节点。网关集群中的每个节点不需要保存任何数据,都是无状态的节点。有些对象存储没有网关,取而代之的是客户端,它们的功能和作用都是一样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/0b/925a6309372b30f660c9b8bc198f860b.jpg" alt="">
|
||||
|
||||
那么,对象存储是如何来处理对象读写请求的呢?这里面处理读和写请求的流程是一样的,我们一起来说。网关收到对象读写请求后,首先拿着请求中的Key,去元数据集群查找这个Key在哪个数据节点上,然后再去访问对应的数据节点读写数据,最后把结果返回给客户端。
|
||||
|
||||
以上是一个比较粗略的大致流程,实际上这里面包含很多的细节,我们暂时没有展开讲。目的是让你在整体上对对象存储,以至于分布式存储系统,有一个清晰的认知。
|
||||
|
||||
上面这张图,虽然我画的是对象存储集群的结构,但是把图上的名词改一改,完全可以套用到绝大多数分布式文件系统和数据库上去,比如说HDFS。
|
||||
|
||||
## 对象是如何拆分和保存的?
|
||||
|
||||
接下来我们说一下对象存储到底是如何来保存大文件对象的。一般来说,对象存储中保存的文件都是图片、视频这类大文件。在对象存储中,每一个大文件都会被拆成多个大小相等的**块儿(Block)**,拆分的方法很简单,就是把文件从头到尾按照固定的块儿大小,切成一块儿一块儿,最后一块儿长度有可能不足一个块儿的大小,也按一块儿来处理。块儿的大小一般配置为几十KB到几个MB左右。
|
||||
|
||||
把大对象文件拆分成块儿的目的有两个:
|
||||
|
||||
1. 第一是为了提升读写性能,这些块儿可以分散到不同的数据节点上,这样就可以并行读写。
|
||||
1. 第二是把文件分成大小相等块儿,便于维护管理。
|
||||
|
||||
对象被拆成块儿之后,还是太过于碎片化了,如果直接管理这些块儿,会导致元数据的数据量会非常大,也没必要管理到这么细的粒度。所以一般都会再把块儿聚合一下,放到块儿的容器里面。这里的“容器”就是存放一组块儿的逻辑单元。容器这个名词,没有统一的叫法,比如在[ceph](https://ceph.io/)中称为Data Placement,你理解这个含义就行。容器内的块儿数大多是固定的,所以容器的大小也是固定的。
|
||||
|
||||
到这里,这个容器的概念,就比较类似于我们之前讲MySQL和Redis时提到的“分片”的概念了,都是复制、迁移数据的基本单位。每个容器都会有N个副本,这些副本的数据都是一样的。其中有一个主副本,其他是从副本,主副本负责数据读写,从副本去到主副本上去复制数据,保证主从数据一致。
|
||||
|
||||
这里面有一点儿和我们之前讲的不一样的是,对象存储一般都不记录类似MySQL的Binlog这样的日志。主从复制的时候,复制的不是日志,而是整块儿的数据。这么做有两个原因:
|
||||
|
||||
1. 第一个原因是基于性能的考虑。我们知道操作日志里面,实际上就包含着数据。在更新数据的时候,先记录操作日志,再更新存储引擎中的数据,相当于在磁盘上串行写了2次数据。对于像数据库这种,每次更新的数据都很少的存储系统,这个开销是可以接受的。但是对于对象存储来说,它每次写入的块儿很大,两次磁盘IO的开销就有些不太值得了。
|
||||
1. 第二个原因是它的存储结构简单,即使没有日志,只要按照顺序,整块儿的复制数据,仍然可以保证主从副本的数据一致性。
|
||||
|
||||
以上我们说的对象(也就是文件)、块儿和容器,都是逻辑层面的概念,数据落实到副本上,这些副本就是真正物理存在了。这些副本再被分配到数据节点上保存起来。这里的数据节点就是运行在服务器上的服务进程,负责在本地磁盘上保存副本的数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/0b/8d6616675ca90df023d1622aa1f2ef0b.jpg" alt="">
|
||||
|
||||
了解了对象是如何被拆分并存储在数据节点上之后,我们再来回顾一下数据访问的流程。当我们请求一个Key的时候,网关首先去元数据中查找这个Key的元数据。然后根据元数据中记录的对象长度,计算出对象有多少块儿。接下来的过程就可以分块儿并行处理了。对于每个块儿,还需要再去元数据中,找到它被放在哪个容器中。
|
||||
|
||||
我刚刚讲过,容器就是分片,怎么把块儿映射到容器中,这个方法就是我们在《[15 | MySQL存储海量数据的最后一招:分库分表](https://time.geekbang.org/column/article/217568)》这节课中讲到的几种分片算法。不同的系统选择实现的方式也不一样,有用哈希分片的,也有用查表法把对应关系保存在元数据中的。找到容器之后,再去元数据中查找容器的N个副本都分布在哪些数据节点上。然后,网关直接访问对应的数据节点读写数据就可以了。
|
||||
|
||||
## 小结
|
||||
|
||||
对象存储是最简单的分布式存储系统,主要由数据节点集群、元数据集群和网关集群(或者客户端)三部分构成。数据节点集群负责保存对象数据,元数据集群负责保存集群的元数据,网关集群和客户端对外提供简单的访问API,对内访问元数据和数据节点读写数据。
|
||||
|
||||
为了便于维护和管理,大的对象被拆分为若干固定大小的块儿,块儿又被封装到容器(也就分片)中,每个容器有一主N从多个副本,这些副本再被分散到集群的数据节点上保存。
|
||||
|
||||
对象存储虽然简单,但是它具备一个分布式存储系统的全部特征。所有分布式存储系统共通的一些特性,对象存储也都具备,比如说数据如何分片,如何通过多副本保证数据可靠性,如何在多个副本间复制数据,确保数据一致性等等。
|
||||
|
||||
希望你通过这节课的学习,不仅是学会对象存储,还要对比分析一下,对象存储和其他分布式存储系统,比如MySQL集群、HDFS、Elasticsearch等等这些,它们之间有什么共同的地方,差异在哪儿。想通了这些问题,你对分布式存储系统的认知,绝对会上升到一个全新的高度。然后你再去看一些之前不了解的存储系统,就非常简单了。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们刚刚说到过,对象存储并不是基于日志来进行主从复制的。假设我们的对象存储是一主二从三个副本,采用半同步方式复制数据,也就是主副本和任意一个从副本更新成功后,就给客户端返回成功响应。主副本所在节点宕机之后,这两个从副本中,至少有一个副本上的数据是和宕机的主副本上一样的,我们需要找到这个副本作为新的主副本,才能保证宕机不丢数据。
|
||||
|
||||
但是没有了日志,如果这两个从副本上的数据不一样,我们如何确定哪个上面的数据是和主副本一样新呢?欢迎你在留言区与我交流讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
71
极客时间专栏/后端存储实战课/海量数据篇/19 | 跨系统实时同步数据,分布式事务是唯一的解决方案吗?.md
Normal file
71
极客时间专栏/后端存储实战课/海量数据篇/19 | 跨系统实时同步数据,分布式事务是唯一的解决方案吗?.md
Normal file
@@ -0,0 +1,71 @@
|
||||
<audio id="audio" title="19 | 跨系统实时同步数据,分布式事务是唯一的解决方案吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/50/e76664d75360b92e4fecd62a9bb18c50.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
我们在《[15 | MySQL存储海量数据的最后一招:分库分表](https://time.geekbang.org/column/article/217568)》这节课中讲过,数据量太大的时候,单个存储节点存不下,那就只能把数据分片存储。
|
||||
|
||||
数据分片之后,我们对数据的查询就没那么自由了。比如订单表如果按照用户ID作为Sharding Key来分片,那就只能按照用户维度来查询。如果我是一个商家,我想查我店铺的订单,对不起,做不到了。(当然,强行查也不是不行,在所有分片上都查一遍,再把结果聚合起来,又慢又麻烦,实际意义不大。)
|
||||
|
||||
对于这样的需求,普遍的解决办法是用空间换时间,毕竟现在存储越来越便宜。再存一份订单数据到商家订单库,然后以店铺ID作为Sharding Key分片,专门供商家查询订单。
|
||||
|
||||
另外,之前我们在《[06 | 如何用Elasticsearch构建商品搜索系统](https://time.geekbang.org/column/article/208675)》这节课也讲到过,同样一份商品数据,如果我们是按照关键字搜索,放在ES里就比放在MySQL快了几个数量级。原因是,数据组织方式、物理存储结构和查询方式,对查询性能的影响是巨大的,而且海量数据还会指数级地放大这个性能差距。
|
||||
|
||||
所以,在大厂中,对于海量数据的处理原则,都是根据业务对数据查询的需求,反过来确定选择什么数据库、如何组织数据结构、如何分片数据,这样才能达到最优的查询性能。同样一份订单数据,除了在订单库保存一份用于在线交易以外,还会在各种数据库中,以各种各样的组织方式存储,用于满足不同业务系统的查询需求。像BAT这种大厂,它的核心业务数据,存个几十上百份是非常正常的。
|
||||
|
||||
那么问题来了,如何能够做到让这么多份数据实时地保持同步呢?
|
||||
|
||||
我们之前讲过分布式事务,可以解决数据一致性的问题。比如说,你可以用本地消息表,把一份数据实时同步给另外两、三个数据库,这样还可以接受,太多的话也是不行的,并且对在线交易业务还有侵入性,所以分布式事务是解决不了这个问题的。
|
||||
|
||||
今天这节课我们就来说一下,如何把订单数据实时、准确无误地同步到这么多异构的数据中去。
|
||||
|
||||
## 使用Binlog和MQ构建实时数据同步系统
|
||||
|
||||
早期大数据刚刚兴起的时候,大多数系统还做不到异构数据库实时同步,那个时候普遍的做法是,使用ETL工具定时同步数据,在T+1时刻去同步上一个周期的数据,然后再做后续的计算和分析。定时ETL对于一些需要实时查询数据的业务需求就无能为力了。所以,这种定时同步的方式,基本上都被实时同步的方式给取代了。
|
||||
|
||||
怎么来做这么大数据量、这么多个异构数据库的实时同步呢?你还记得我在《[17 | 大厂都是怎么做MySQL to Redis同步的](https://time.geekbang.org/column/article/217593)》这节课中讲到的方法吧?利用Canal把自己伪装成一个MySQL的从库,从MySQL实时接收Binlog然后写入Redis中。把这个方法稍微改进一下,就可以用来做异构数据库的同步了。
|
||||
|
||||
为了能够支撑下游众多的数据库,从Canal出来的Binlog数据肯定不能直接去写下游那么多数据库,一是写不过来,二是对于每个下游数据库,它可能还有一些数据转换和过滤的工作要做。所以需要增加一个MQ来解耦上下游。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/d8/dfa37d67d87fc7c8a8de50681f8134d8.jpg" alt="">
|
||||
|
||||
Canal从MySQL收到Binlog并解析成结构化数据之后,直接写入到MQ的一个订单Binlog主题中,然后每一个需要同步订单数据的业务方,都去订阅这个MQ中的订单Binlog主题,消费解析后的Binlog数据。在每个消费者自己的同步程序中,它既可以直接入库,也可以做一些数据转换、过滤或者计算之后再入库,这样就比较灵活了。
|
||||
|
||||
## 如何保证数据同步的实时性
|
||||
|
||||
这个方法看起来不难,但是非常容易出现性能问题。有些接收Binlog消息的下游业务,对数据的实时性要求比较高,不能容忍太高的同步时延。比如说,每个电商在大促的时候,都会有一个大屏幕,实时显示现在有多少笔交易,交易额是多少。这个东西都是给老板们看的,如果说大促的时候,你让老板们半小时之后才看到数字,那估计你就得走人了。
|
||||
|
||||
大促的时候,数据量大、并发高、数据库中的数据变动频繁,同步的Binlog流量也非常大。为了保证这个同步的实时性,整个数据同步链条上的任何一个环节,它的处理速度都必须得跟得上才行。我们一步一步分析可能会出现性能瓶颈的环节。
|
||||
|
||||
源头的订单库,如果它出现繁忙,对业务的影响就不只是大屏延迟了,那就影响到用户下单了,这个问题是数据库本身要解决的,这里我们不考虑。再顺着数据流向往下看,Canal和MQ这两个环节,由于没什么业务逻辑,性能都非常好。所以,**一般容易成为性能瓶颈的就是消费MQ的同步程序**,因为这些同步程序里面一般都会有一些业务逻辑,而且如果下游的数据库写性能跟不上,表象也是这个同步程序处理性能上不来,消息积压在MQ里面。
|
||||
|
||||
那我们能不能多加一些同步程序的实例数,或者增加线程数,通过增加并发来提升处理能力呢?这个地方的并发数,还真不是随便说扩容就可以就扩容的,我来跟你讲一下为什么。
|
||||
|
||||
我们知道,MySQL主从同步Binlog,是一个单线程的同步过程。为什么是单线程?原因很简单,在从库执行Binlog的时候,必须按顺序执行,才能保证数据和主库是一样的。**为了确保数据一致性,Binlog的顺序很重要,是绝对不能乱序的。** 严格来说,对于每一个MySQL实例,整个处理链条都必须是单线程串行执行,MQ的主题也必须设置为只有1个分区(队列),这样才能保证数据同步过程中的Binlog是严格有序的,写到目标数据库的数据才能是正确的。
|
||||
|
||||
那单线程处理速度上不去,消息越积压越多,这不无解了吗?其实办法还是有的,但是必须得和业务结合起来解决。
|
||||
|
||||
还是拿订单库来说啊,其实我们并不需要对订单库所有的更新操作都严格有序地执行,比如说A和B两个订单号不同的订单,这两个订单谁先更新谁后更新并不影响数据的一致性,因为这两个订单完全没有任何关系。但是同一个订单,如果更新的Binlog执行顺序错了,那同步出来的订单数据真的就错了。
|
||||
|
||||
也就是说,我们只要保证每个订单的更新操作日志的顺序别乱就可以了。这种一致性要求称为**因果一致性(Causal Consistency)**,有因果关系的数据之间必须要严格地保证顺序,没有因果关系的数据之间的顺序是无所谓的。
|
||||
|
||||
基于这个理论基础,我们就可以并行地来进行数据同步,具体的做法是这样的。
|
||||
|
||||
首先根据下游同步程序的消费能力,计算出需要多少并发;然后设置MQ中主题的分区(队列)数量和并发数一致。因为MQ是可以保证同一分区内,消息是不会乱序的,所以我们需要把具有因果关系的Binlog都放到相同的分区中去,就可以保证同步数据的因果一致性。对应到订单库就是,相同订单号的Binlog必须发到同一个分区上。
|
||||
|
||||
这是不是和之前讲过的数据库分片有点儿像呢?那分片算法就可以拿过来复用了,比如我们可以用最简单的哈希算法,Binlog中订单号除以MQ分区总数,余数就是这条Binlog消息发往的分区号。
|
||||
|
||||
Canal自带的分区策略就支持按照指定的Key,把Binlog哈希到下游的MQ中去,具体的配置可以看一下[Canal接入MQ的文档](https://github.com/alibaba/canal/wiki/Canal-Kafka-RocketMQ-QuickStart)。
|
||||
|
||||
## 小结
|
||||
|
||||
对于海量数据,必须要按照查询方式选择数据库类型和数据的组织方式,才能达到理想的查询性能。这就需要把同一份数据,按照不同的业务需求,以不同的组织方式存放到各种异构数据库中。因为数据的来源大多都是在线交易系统的MySQL数据库,所以我们可以利用MySQL的Binlog来实现异构数据库之间的实时数据同步。
|
||||
|
||||
为了能够支撑众多下游数据库实时同步的需求,可以通过MQ解耦上下游,Binlog先发送到MQ中,下游各业务方可以消费MQ中的消息再写入各自的数据库。
|
||||
|
||||
如果下游处理能力不能满足要求,可以增加MQ中的分区数量实现并发同步,但需要结合同步的业务数据特点,把具有因果关系的数据哈希到相同分区上,才能避免因为并发乱序而出现数据同步错误的问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
在我们这种数据同步架构下,如果说下游的某个同步程序或数据库出了问题,需要把Binlog回退到某个时间点然后重新同步,这个问题该怎么解决?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
86
极客时间专栏/后端存储实战课/海量数据篇/20 | 如何在不停机的情况下,安全地更换数据库?.md
Normal file
86
极客时间专栏/后端存储实战课/海量数据篇/20 | 如何在不停机的情况下,安全地更换数据库?.md
Normal file
@@ -0,0 +1,86 @@
|
||||
<audio id="audio" title="20 | 如何在不停机的情况下,安全地更换数据库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3e/8c/3e831695f95ca084d076ad803f05c68c.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
随着我们的系统规模逐渐增长,总会遇到需要更换数据库的问题。我们来说几种常见的情况。
|
||||
|
||||
- 对MySQL做了分库分表之后,需要从原来的单实例数据库迁移到新的数据库集群上。
|
||||
- 系统从传统部署方式向云上迁移的时候,也需要从自建的数据库迁移到云数据库上。
|
||||
- 一些在线分析类的系统,MySQL性能不够用的时候,就需要更换成一些专门的分析类数据库,比如说HBase。
|
||||
|
||||
更换数据库这个事儿,是一个非常大的技术挑战,因为我们需要保证整个迁移过程中,既不能长时间停服,也不能丢数据。
|
||||
|
||||
那么,今天这节课我们就来说一下,如何在不停机的情况下,安全地迁移数据更换数据库。
|
||||
|
||||
## 如何实现不停机更换数据库?
|
||||
|
||||
我们都知道墨菲定律:“如果事情有变坏的可能,不管这种可能性有多小,它总会发生。”放到这里呢,也就是说,我们在更换数据库的过程中,只要有一点儿可能会出问题的地方,哪怕是出现问题的概率非常小,它总会出问题。
|
||||
|
||||
实际上,无论是新版本的程序,还是新的数据库,即使我们做了严格的验证测试,做了高可用方案,刚刚上线的系统,它的稳定性总是没有那么好的,需要一个磨合的过程,才能逐步达到一个稳定的状态,这是一个客观规律。这个过程中一旦出现故障,如果不能及时恢复,造成的损失往往是我们承担不起的。
|
||||
|
||||
所以我们在设计迁移方案的时候,一定要做到,每一步都是可逆的。**要保证,每执行一个步骤后,一旦出现问题,能快速地回滚到上一个步骤**。这是很多同学在设计这种升级类技术方案的时候,容易忽略的问题。
|
||||
|
||||
接下来我们还是以订单库为例子,说一下这个迁移方案应该如何来设计。
|
||||
|
||||
首先要做的就是,把旧库的数据复制到新库中。因为旧库还在服务线上业务,所以不断会有订单数据写入旧库,我们不仅要往新库复制数据,还要保证新旧两个库的数据是实时同步的。所以,我们需要用一个同步程序来实现新旧两个数据库实时同步。
|
||||
|
||||
怎么来实现两个异构数据库之间的数据实时同步,这个方法我们上节课刚刚讲过,我们可以使用Binlog实时同步数据。如果源库不是MySQL的话,就麻烦一点儿,但也可以参考我们讲过的,复制状态机理论来实现。这一步不需要回滚,原因是,只增加了一个新库和一个同步程序,对系统的旧库和程序都没有任何改变。即使新上线的同步程序影响到了旧库,只要停掉同步程序就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/58/ba2a44c70d4766b281107f4134fe9d58.jpg" alt="">
|
||||
|
||||
然后,我们需要改造一下订单服务,业务逻辑部分不需要变,DAO层需要做如下改造:
|
||||
|
||||
1. 支持双写新旧两个库,并且预留热切换开关,能通过开关控制三种写状态:只写旧库、只写新库和同步双写。
|
||||
1. 支持读新旧两个库,同样预留热切换开关,控制读旧库还是新库。
|
||||
|
||||
然后上线新版的订单服务,这个时候订单服务仍然是只读写旧库,不读写新库。让这个新版的订单服务需要稳定运行至少一到二周的时间,期间除了验证新版订单服务的稳定性以外,还要验证新旧两个订单库中的数据是否是一致的。这个过程中,如果新版订单服务有问题,可以立即下线新版订单服务,回滚到旧版本的订单服务。
|
||||
|
||||
稳定一段时间之后,就可以开启订单服务的双写开关了。开启双写开关的同时,需要停掉同步程序。这里面有一个问题需要注意一下,就是**这个双写的业务逻辑,一定是先写旧库,再写新库,并且以写旧库的结果为准**。
|
||||
|
||||
旧库写成功,新库写失败,返回写成功,但这个时候要记录日志,后续我们会用到这个日志来验证新库是否还有问题。旧库写失败,直接返回失败,就不写新库了。这么做的原因是,不能让新库影响到现有业务的可用性和数据准确性。上面这个过程如果出现问题,可以关闭双写,回滚到只读写旧库的状态。
|
||||
|
||||
切换到双写之后,新库与旧库的数据可能会存在不一致的情况,原因有两个:一是停止同步程序和开启双写,这两个过程很难做到无缝衔接,二是双写的策略也不保证新旧库强一致,这时候我们需要上线一个对比和补偿的程序,这个程序对比旧库最近的数据变更,然后检查新库中的数据是否一致,如果不一致,还要进行补偿。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/ce/e0c3864866fe1ff3408e2589669b62ce.jpg" alt="">
|
||||
|
||||
开启双写后,还需要至少稳定运行至少几周的时间,并且期间我们要不断地检查,确保不能有旧库写成功,新库写失败的情况出现。对比程序也没有发现新旧两个库的数据有不一致的情况,这个时候,我们就可以认为,新旧两个库的数据是一直保持同步的。
|
||||
|
||||
接下来就可以用类似灰度发布的方式,把读请求一点儿一点儿地切到新库上。同样,期间如果出问题的话,可以再切回旧库。全部读请求都切换到新库上之后,这个时候其实读写请求就已经都切换到新库上了,实际的切换已经完成了,但还有后续的收尾步骤。
|
||||
|
||||
再稳定一段时间之后,就可以停掉对比程序,把订单服务的写状态改为只写新库。到这里,旧库就可以下线了。注意,整个迁移过程中,只有这个步骤是不可逆的。但是,这步的主要操作就是摘掉已经不再使用的旧库,对于在用的新库并没有什么改变,实际出问题的可能性已经非常小了。
|
||||
|
||||
到这里,我们就完成了在线更换数据库的全部流程。双写版本的订单服务也就完成了它的历史使命,可以在下一次升级订单服务版本的时候,下线双写功能。
|
||||
|
||||
## 如何实现对比和补偿程序?
|
||||
|
||||
在上面的整个切换过程中,如何实现这个对比和补偿程序,是整个这个切换设计方案中的一个难点。这个对比和补偿程序的难度在于,我们要对比的是两个都在随时变换的数据库中的数据。这种情况下,我们没有类似复制状态机这样理论上严谨实际操作还很简单的方法,来实现对比和补偿。但还是可以根据业务数据的实际情况,来针对性地实现对比和补偿,经过一段时间,把新旧两个数据库的差异,逐渐收敛到一致。
|
||||
|
||||
像订单这类时效性强的数据,是比较好对比和补偿的。因为订单一旦完成之后,就几乎不会再变了,那我们的对比和补偿程序,就可以依据订单完成时间,每次只对比这个时间窗口内完成的订单。补偿的逻辑也很简单,发现不一致的情况后,直接用旧库的订单数据覆盖新库的订单数据就可以了。
|
||||
|
||||
这样,切换双写期间,少量不一致的订单数据,等到订单完成之后,会被补偿程序修正。后续只要不是双写的时候,新库频繁写入失败,就可以保证两个库的数据完全一致。
|
||||
|
||||
比较麻烦的是更一般的情况,比如像商品信息这类数据,随时都有可能会变化。如果说数据上有更新时间,那我们的对比程序可以利用这个更新时间,每次在旧库取一个更新时间窗口内的数据,去新库上找相同主键的数据进行对比,发现数据不一致,还要对比一下更新时间。如果新库数据的更新时间晚于旧库数据,那可能是对比期间数据发生了变化,这种情况暂时不要补偿,放到下个时间窗口去继续对比。另外,时间窗口的结束时间,不要选取当前时间,而是要比当前时间早一点儿,比如1分钟前,避免去对比正在写入的数据。
|
||||
|
||||
如果数据连时间戳也没有,那只能去旧库读取Binlog,获取数据变化,然后去新库对比和补偿。
|
||||
|
||||
有一点需要说明的是,上面这些方法,如果严格推敲,都不是百分之百严谨的,都不能保证在任何情况下,经过对比和补偿后,新库的数据和旧库就是完全一样的。但是,在大多数情况下,这些实践方法还是可以有效地收敛新旧两个库的数据差异,你可以酌情采用。
|
||||
|
||||
## 小结
|
||||
|
||||
设计在线切换数据库的技术方案,首先要保证安全性,确保每一个步骤一旦失败,都可以快速回滚。此外,还要确保迁移过程中不丢数据,这主要是依靠实时同步程序和对比补偿程序来实现。
|
||||
|
||||
我把这个复杂的切换过程的要点,按照顺序总结成下面这个列表,供你参考:
|
||||
|
||||
1. 上线同步程序,从旧库中复制数据到新库中,并实时保持同步;
|
||||
1. 上线双写订单服务,只读写旧库;
|
||||
1. 开启双写,同时停止同步程序;
|
||||
1. 开启对比和补偿程序,确保新旧数据库数据完全一样;
|
||||
1. 逐步切量读请求到新库上;
|
||||
1. 下线对比补偿程序,关闭双写,读写都切换到新库上;
|
||||
1. 下线旧库和订单服务的双写功能。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们整个切换的方案中,只有一个步骤是不可逆的,就是由双写切换为单写新库这一步。如果说不计成本,如何修改我们的迁移方案,让这一步也能做到快速回滚?你可以思考一下这个问题,欢迎你在留言区与我交流讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
73
极客时间专栏/后端存储实战课/海量数据篇/21 | 类似“点击流”这样的海量数据应该如何存储?.md
Normal file
73
极客时间专栏/后端存储实战课/海量数据篇/21 | 类似“点击流”这样的海量数据应该如何存储?.md
Normal file
@@ -0,0 +1,73 @@
|
||||
<audio id="audio" title="21 | 类似“点击流”这样的海量数据应该如何存储?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/44/45df906626d9a53e9eafebf5e3880f44.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
对于大部分互联网公司来说,数据量最大的几类数据是:点击流数据、监控数据和日志数据。这里面“点击流”指的是在App、小程序和Web页面上的埋点数据,这些埋点数据记录用户的行为,比如你打开了哪个页面,点击了哪个按钮,在哪个商品上停留了多久等等这些。
|
||||
|
||||
当然你不用太担心自己的隐私问题,记录的这些行为数据不是为了监控用户,主要目的是为了从统计上分析群体用户的行为,从而改进产品和运营。比如,某件商品看的人很多,停留时间很长,最后下单购买的人却很少,那采销人员就要考虑是不是这件商品的定价太高了。
|
||||
|
||||
除了点击流数据以外,监控和日志数据都是大家常用的,我就不再多解释了。
|
||||
|
||||
这类数据都是真正“海量”的数据,相比于订单、商品这类业务的数据,数据量要多出2~3个数量级。每天产生的数据量就可能会超过TB(1 TB = 1024 GB)级别,经过一段时间累积下来,有些数据会达到PB(1 PB = 1024 TB)级别。
|
||||
|
||||
这种量级的数据,在大数据技术出现之前,是没法保存和处理的,只能是通过抽样的方法来凑合着做分析。Hadoop等大数据技术出现以后,才使得存储和计算海量数据成为可能。
|
||||
|
||||
今天这节课,我们来说说,应该选择什么样的存储系统,来保存像“点击流”这样的海量数据。
|
||||
|
||||
## 使用Kafka存储海量原始数据
|
||||
|
||||
早期对于这类海量原始数据,都倾向于**先计算再存储**。也就是,在接收原始数据的服务中,先进行一些数据过滤、聚合等初步的计算,将数据先收敛一下,再落存储。这样可以降低存储系统的写入压力,也能节省磁盘空间。
|
||||
|
||||
这几年,随着存储设备越来越便宜,并且,数据的价值被不断地重新挖掘,更多的大厂都倾向于**先存储再计算**,直接保存海量的原始数据,再对数据进行实时或者批量计算。这种方案,除了贵以外都是优点:
|
||||
|
||||
- 不需要二次分发就可以同时给多个流和批计算任务提供数据;
|
||||
- 如果计算任务出错,可以随时回滚重新计算;
|
||||
- 如果对数据有新的分析需求,上线后直接就可以用历史数据计算出结果,而不用去等新数据。
|
||||
|
||||
但是,这种方式对保存原始数据的存储系统要求就很高了:既要有足够大的容量,能水平扩容,还要读写都足够快,跟得上数据生产的写入速度,还要给下游计算提供低延迟的读服务。什么样的存储能满足这样的要求呢?这里我给出几种常用的解决方案。
|
||||
|
||||
第一种方案是,使用Kafka来存储。有的同学会问了,Kafka不是一个消息队列么,怎么成了存储系统了?那我告诉你,**现代的消息队列,本质上就是分布式的流数据存储系统。**
|
||||
|
||||
如果你感兴趣的话,你可以仔细去研究一下Kafka,它的数据是如何存储、分片、复制的?它是如何保证高可用,如何保证数据一致性的?那你会发现它和我们之前讲过的那些分布式存储系统,并没有什么太大的区别。唯一的区别就是,它的查询语言(生产和消费消息)和存储引擎的数据结构(Commit Log)比一般的存储系统要简单很多。但也正是因为这个原因,使得Kafka的读写性能远远好于其他的存储系统。Kafka官方给自己的定位也是“分布式流数据平台”,不只是一个MQ。
|
||||
|
||||
Kafka提供“无限”的消息堆积能力,具有超高的吞吐量,可以满足我们保存原始数据的大部分要求。写入点击流数据的时候,每个原始数据采集服务作为一个生产者,把数据发给Kafka就可以了。下游的计算任务,可以作为消费者订阅消息,也可以按照时间或者位点来读取数据。并且,Kafka作为事实标准,和大部分大数据生态圈的开源软件都有非常好的兼容性和集成度,像Flink、Spark等大多计算平台都提供了直接接入Kafka的组件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/8c/ba6bae1b4e59ba2000f0789886248d8c.jpg" alt="">
|
||||
|
||||
当然,Kafka也不是万能的,你可能注意到了,我刚刚讲Kafka提供“无限”的消息堆积能力,我在这个“无限”上打了个引号,这里面还是有一些限制需要注意的。Kafka也支持把数据分片,这个在Kafka中叫Partition,每个分片可以分布到不同的存储节点上。
|
||||
|
||||
写入数据的时候,可以均匀地写到这些分片上,理论上只要分片足够多,存储容量就可以是“无限”的。但是,单个分片总要落到某一个节点上,而单节点的存储容量毕竟是有限的,随着时间推移,单个分片总有写满的时候。
|
||||
|
||||
即使它支持扩容分片数量,也没办法像其他分布式存储系统那样,重新分配数据,把已有分片上的数据迁移一部分到新的分片上。所以扩容分片也解决不了已有分片写满的问题。而Kafka又不支持按照时间维度去分片,所以,**受制于单节点的存储容量,Kafka实际能存储的数据容量并不是无限的**。
|
||||
|
||||
## Kafka之外还有哪些解决方案?
|
||||
|
||||
所以,需要长时间(几个月-几年)保存的海量数据,就不适合用Kafka存储。这种情况下,只能退而求其次,使用第二种方案了。
|
||||
|
||||
第二种方案是,使用HDFS来存储。使用HDFS存储数据也很简单,就是把原始数据写成一个一个文本文件,保存到HDFS中。我们需要按照时间和业务属性来组织目录结构和文件名,以便于下游计算程序来读取,比如说:**“click/20200808/Beijing_0001.csv”**,代表2020年8月8日,从北京地区用户收集到的点击流数据,这个是当天的第一个文件。
|
||||
|
||||
对于保存海量的原始数据这个特定的场景来说,HDFS的吞吐量是远不如Kafka的。按照平均到每个节点上计算,Kafka的吞吐能力很容易达到每秒钟大几百兆,而HDFS只能达到百兆左右。这就意味着,要达到相同的吞吐能力,使用HDFS就要比使用Kafka,多用几倍的服务器数量。
|
||||
|
||||
但HDFS也有它的优势,第一个优势就是,它能提供真正无限的存储容量,如果存储空间不够了,水平扩容就可以解决。另外一个优势是,HDFS能提供比Kafka更强的数据查询能力。Kafka只能按照时间或者位点来提取数据,而HDFS配合Hive直接就可以支持用SQL对数据进行查询,虽然说查询的性能比较差,但查询能力要比Kafka强大太多了。
|
||||
|
||||
以上这两种方案因为都有各自的优势和不足,在实际生产中,都有不少的应用,你可以根据业务的情况来选择。那有没有兼顾这二者优势的方案呢?最好能做到,既有超高的吞吐能力,又能无限扩容,同时还能提供更好的查询能力,有这样的好事儿么?
|
||||
|
||||
我个人的判断是,目前还没有可用大规模于生产的,成熟的解决方案,但未来应该会有的。目前已经有一些的开源项目,都致力于解决这方面的问题,你可以关注一下。
|
||||
|
||||
一类是**分布式流数据存储**,比较活跃的项目有[Pravega](https://github.com/pravega/pravega)和Pulsar的存储引擎[Apache BookKeeper](https://github.com/apache/bookkeeper)。我所在的团队也在这个方向上持续探索中,也开源了我们的流数据存储项目[JournalKeeper](https://github.com/chubaostream/journalkeeper),也欢迎你关注和参与进来。这些分布式流数据存储系统,走的是类似Kafka这种流存储的路线,在高吞吐量的基础上,提供真正无限的扩容能力,更好的查询能力。
|
||||
|
||||
还有一类是**时序数据库(Time Series Databases)**,比较活跃的项目有[InfluxDB](https://github.com/influxdata/influxdb)和[OpenTSDB](https://github.com/OpenTSDB/opentsdb)等。这些时序数据库,不仅有非常好的读写性能,还提供很方便的查询和聚合数据的能力。但是,它们不是什么数据都可以存的,它们专注于类似监控数据这样,有时间特征并且数据内容都是数值的数据。如果你有存储海量监控数据的需求,可以关注一下这些项目。
|
||||
|
||||
## 小结
|
||||
|
||||
在互联网行业,点击流、监控和日志这几类数据,是海量数据中的海量数据。对于这类数据,一般的处理方式都是先存储再计算,计算结果保存到特定的数据库中,供业务系统查询。
|
||||
|
||||
所以,对于海量原始数据的存储系统,我们要求的是超高的写入和读取性能,和近乎无限的容量,对于数据的查询能力要求不高。生产上,可以选择Kafka或者是HDFS,Kafka的优点是读写性能更好,单节点能支持更高的吞吐量。而HDFS则能提供真正无限的存储容量,并且对查询更友好。
|
||||
|
||||
未来会有一些开源的流数据存储系统和时序数据库逐步成熟,并陆续应用到生产系统中去,你可以持续关注这些项目。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你想一下,为什么Kafka能做到几倍于HDFS的吞吐能力,技术上的根本原因是什么?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
70
极客时间专栏/后端存储实战课/海量数据篇/22 | 面对海量数据,如何才能查得更快?.md
Normal file
70
极客时间专栏/后端存储实战课/海量数据篇/22 | 面对海量数据,如何才能查得更快?.md
Normal file
@@ -0,0 +1,70 @@
|
||||
<audio id="audio" title="22 | 面对海量数据,如何才能查得更快?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/db/6da98294605e6f78fdfa9e7ced4ec8db.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
我们接着上节课的话题,来继续说海量数据。上节课我们讲了,如何来保存原始数据,那我们知道,原始数据的数据量太大了,能存下来就很不容易了,这个数据是没法直接来给业务系统查询和分析的。有两个原因,一是数据量太大了,二是也没有很好的数据结构和查询能力,来支持业务系统查询。
|
||||
|
||||
所以一般的做法是,用流计算或者是批计算,把原始数据再进行一次或者多次的过滤、汇聚和计算,把计算结果落到另外一个存储系统中去,由这个存储再给业务系统提供查询支持。这里的“流计算”,指的是Flink、Storm这类的实时计算,批计算是Map-Reduce或者Spark这类的非实时计算。
|
||||
|
||||
上节课我们说过,像点击流、监控和日志这些原始数据是“海量数据中的海量数据”,这些原始数据经过过滤汇总和计算之后,大多数情况下数据量会有量级的下降,比如说从TB级别的数据量,减少到GB级别。
|
||||
|
||||
有的业务,计算后的数据非常少,比如说一些按天粒度的汇总数据,或者排行榜类的数据,用什么存储都能满足要求。那有一些业务,没法通过事先计算的方式解决全部的问题。原始数据经过计算后产生的计算结果,数据量相比原始数据会减少一些,但仍然是海量数据。并且,我们还要在这个海量数据上,提供性能可以接受的查询服务。
|
||||
|
||||
今天这节课我们就来聊一聊,面对这样的海量数据,如何才能让查询更快一些。
|
||||
|
||||
## 常用的分析类系统应该如何选择存储?
|
||||
|
||||
查询海量数据的系统,大多都是离线分析类系统,你可以简单地理解为类似于做报表的系统,也就是那些主要功能是对数据做统计分析的系统。这类系统是重度依赖于存储的。选择什么样的存储系统、使用什么样的数据结构来存储数据,直接决定了数据查询、聚合和分析的性能。
|
||||
|
||||
分析类系统对存储的需求一般是这样的:
|
||||
|
||||
1. 一般用于分析的数据量都会比在线业务大出几个数量级,这需要存储系统能保存海量数据;
|
||||
1. 能在海量的数据上做快速的聚合、分析和查询。注意这里面所说的“快速”,前提是处理GB、TB甚至PB级别的海量数据,在这么大的数据量上做分析,几十秒甚至几分钟都算很快了,和在线业务要求的毫秒级速度是不一样的;
|
||||
1. 由于数据大多数情况下都是异步写入,对于写入性能和响应时延,一般要求不高;
|
||||
1. 分析类系统不直接支撑前端业务,所以也不要求高并发。
|
||||
|
||||
然后我们看有哪些可供选择的存储产品。如果你的系统的数据量在GB量级以下,MySQL仍然是可以考虑的,因为它的查询能力足以应付大部分分析系统的业务需求。并且可以和在线业务系统合用一个数据库,不用做ETL(数据抽取),省事儿并且实时性好。这里还是要提醒你,最好给分析系统配置单独的MySQL实例,避免影响线上业务。
|
||||
|
||||
如果数据量级已经超过MySQL极限,可以选择一些列式数据库,比如:HBase、Cassandra、ClickHouse,这些产品对海量数据,都有非常好的查询性能,在正确使用的前提下,10GB量级的数据查询基本上可以做到秒级返回。高性能的代价是功能上的缩水,这些数据库对数据的组织方式都有一些限制,查询方式上也没有MySQL那么灵活。大多都需要你非常了解这些产品的脾气秉性,按照预定的姿势使用,才能达到预期的性能。
|
||||
|
||||
另外一个值得考虑的选择是Elasticsearch(ES),ES本来是一个为了搜索而生的存储产品,但是也支持结构化数据的存储和查询。由于它的数据都存储在内存中,并且也支持类似于Map-Reduce方式的分布式并行查询,所以对海量结构化数据的查询性能也非常好。
|
||||
|
||||
最重要的是,ES对数据组织方式和查询方式的限制,没有其他列式数据库那么死板。也就是说,ES的查询能力和灵活性是要强于上述这些列式数据库的。在这个级别的几个选手中,我个人强烈建议你优先考虑ES。但是ES有一个缺点,就是你需要给它准备大内存的服务器,硬件成本有点儿高。
|
||||
|
||||
数据量级超过TB级的时候,对这么大量级的数据做统计分析,无论使用什么存储系统,都快不到哪儿去。这个时候的性能瓶颈已经是磁盘IO和网络带宽了。这种情况下,实时的查询和分析肯定做不了。解决的办法都是,定期把数据聚合和计算好,然后把结果保存起来,在需要时对结果再进行二次查询。这么大量级的数据,一般都选择保存在HDFS中,配合Map-Reduce、Spark、Hive等等这些大数据生态圈产品做数据聚合和计算。
|
||||
|
||||
## 转变你的思想:根据查询来选择存储系统
|
||||
|
||||
面对海量数据,仅仅是根据数据量级来选择存储系统,是远远不够的。
|
||||
|
||||
经常有朋友会问:“我的系统,每天都产生几个GB的数据量,现在基本已经慢得查不出来了,你说我换个什么数据库能解决问题呢?”那我的回答都是,对不起,换什么数据库也解决不了你的问题。为什么这么说呢?
|
||||
|
||||
因为在过去的几十年里面,存储技术和分布式技术,在基础理论方面并没有什么本质上突破。技术发展更多的是体现在应用层面上,比如说,集群管理简单,查询更加自动化,像Map-Reduce这些。不同的存储系统之间,并没有本质的差异。它们的区别只是,存储引擎的数据结构、存储集群的构建方式,以及提供的查询能力,这些方面的差异。这些差异,使得每一种存储,在它擅长的一些领域或者场景下,会有很好的性能表现。
|
||||
|
||||
比如说,最近很火的RocksDB、LevelDB,它们的存储结构LSM-Tree,其实就是日志和跳表的组合,单从数据结构的时间复杂度上来说,和“老家伙”MySQL采用的B+树,有本质的提升吗?没有吧,时间复杂度都是O(log n)。但是,LSM-Tree在某些情况下,它利用日志有更好的写性能表现。没有哪种存储能在所有情况下,都具有明显的性能优势,所以说,**存储系统没有银弹,<strong><strong>不要指望简单**</strong>地**<strong>更换一种数据库**</strong>,就可以解决数据量大,查询慢的问题。</strong>
|
||||
|
||||
但是,在特定的场景下,通过一些优化方法,把查询性能提升几十倍甚至几百倍,这个都是有可能的。这里面有个很重要的思想就是,**根据查询来选择存储系统和数据结构**。我们前面的课程《[06 | 如何用Elasticsearch构建商品搜索系统](https://time.geekbang.org/column/article/208675)》,就是把这个思想实践得很好的一个例子。ES采用的倒排索引的数据结构,并没有比MySQL的B+树更快或者说是更先进,但是面对“全文搜索”这个查询需求,选择使用ES的倒排索引,就比使用其他的存储系统和数据结构,性能上要高出几十倍。
|
||||
|
||||
再举个例子,大家都知道,京东的物流速度是非常快的。经常是,一件挺贵的衣服,下单之后,还没来得及后悔,已经送到了。京东的物流之所以能做到这么快,有一个很重要的原因是,它有一套智能的补货系统,根据历史的物流数据,对未来的趋势做出预测,来给全国每个仓库补货。这样京东就可以做到,你下单买的商品,很大概率在离你家几公里那个京东仓库里就有货,这样自然很快就送到了。这个系统的背后,它需要分析每天几亿条物流数据,每条物流数据又细分为几段到几十段,那每天的物流数据就是几十亿的量级。
|
||||
|
||||
这份物流数据,它的用途也非常多,比如说,智能补货系统要用;调度运力的系统也要用;评价每个站点儿、每个快递小哥的时效达成情况,还要用这个数据;物流规划人员同样要用这个数据进行分析,对物流网络做持续优化。
|
||||
|
||||
那用什么样的存储系统保存这些物流数据,才能满足这些查询需求呢?显然,任何一种存储系统,都满足不了这么多种查询需求。我们需要根据每一种需求,去专门选择合适的存储系统,定义适合的数据结构,各自解决各自的问题。而不是用一种数据结构,一个数据库去解决所有的问题。
|
||||
|
||||
对于智能补货和运力调度这两个系统,它的区域性很强,那我们可以把数据按照区域(省或者地市)做分片,再汇总一份全国的跨区物流数据,这样绝大部分查询都可以落在一个分片上,查询性能就会很好。
|
||||
|
||||
对于站点儿和人的时效达成情况,这种业务的查询方式以点查询为主,那可以考虑事先在计算的时候,按照站点儿和人把数据汇总好,存放到一些分布式KV存储中,基本上可以做到毫秒级查询性能。而对于物流规划的查询需求,查询方式是多变的,可以把数据放到Hive表中,按照时间进行分片。
|
||||
|
||||
我们之前也讲到过,按照时间分片是对查询最友好的分片方式。物流规划人员可以在上面执行一些分析类的查询任务,一个查询任务即使是花上几个小时,用来验证一个新的规划算法,也是可以接受的。
|
||||
|
||||
## 小结
|
||||
|
||||
海量数据的主要用途就是支撑离线分析类业务的查询,根据数据量规模不同,由小到大可以选择:关系型数据库,列式数据库和一些大数据存储系统。对于TB量级以下的数据,如果可以接受相对比较贵的硬件成本,ES是一个不错的选择。
|
||||
|
||||
对于海量数据来说,选择存储系统没有银弹,重要的是转变思想,根据业务对数据的查询方式,反推数据应该使用什么存储系统、如何分片,以及如何组织。即使是同样一份数据,也要根据不同的查询需求,组织成不同的数据结构,存放在适合的存储系统中,才能在每一种业务中都达到理想的查询性能。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的课后思考题是这样的,我们要做一个日志系统,收集全公司所有系统的全量程序日志,给开发和运维人员提供日志的查询和分析服务,你会选择用什么存储系统来存储这些日志?原因是什么?欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
105
极客时间专栏/后端存储实战课/海量数据篇/23 | MySQL经常遇到的高可用、分片问题,NewSQL是如何解决的?.md
Normal file
105
极客时间专栏/后端存储实战课/海量数据篇/23 | MySQL经常遇到的高可用、分片问题,NewSQL是如何解决的?.md
Normal file
@@ -0,0 +1,105 @@
|
||||
<audio id="audio" title="23 | MySQL经常遇到的高可用、分片问题,NewSQL是如何解决的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/57/e0/5767b04db553d7a03aaff673fe06cae0.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
在这个系列课程中,我们讲的都是如何解决生产系统中面临的一些存储系统相关的问题。在最后两节课里面,我们来说点儿新东西,看一下存储这个技术领域,可能会有哪些值得关注的新技术。当然,技术圈每天都有很多新的技术出现,也会经常发很多论文,出现很多的开源项目,这些大多数都不太靠谱儿。
|
||||
|
||||
今天我给你要说的这个New SQL,它是我个人认为非常靠谱,甚至在未来可能会取代MySQL这样的关系型数据库的一个技术。MySQL是几乎每一个后端开发人员必须要精通的数据库,既然New SQL非常有可能在将来替代MySQL,那我们就非常有必要提前去了解一下了。
|
||||
|
||||
## 什么是New SQL?
|
||||
|
||||
什么是New SQL?这个说来话长了,还要从存储技术发展的历史来解读。我们知道,早期只有像MySQL这样的关系数据库,这种关系型数据库因为支持SQL语言,后来被叫做SQL或者Old SQL。
|
||||
|
||||
然后,出现了Redis和很多KV存储系统,性能上各种吊打MySQL,而且因为存储结构简单,所以比较容易组成分布式集群,并且能够做到水平扩展、高可靠、高可用。因为这些KV存储不支持SQL,为了以示区分,被统称为No SQL。
|
||||
|
||||
No SQL本来希望能凭借高性能和集群的优势,替代掉Old SQL。但用户是用脚投票的,这么多年实践证明,你牺牲了SQL这种强大的查询能力和ACID事务支持,用户根本不买账,直到今天,Old SQL还是生产系统中最主流的数据库。
|
||||
|
||||
这个时候,大家都开始明白了,无论你其他方面做的比Old SQL好再多,SQL和ACID是刚需,这个命你革不掉的。你不支持SQL,就不会有多少人用。所以你看,近几年很多之前不支持SQL的数据库,都开始支持SQL了,甚至于像Spark、Flink这样的流计算平台,也都开始支持SQL。当然,虽然说支持SQL,但这里面各个产品的支持程度是参差不齐的,多多少少都有一些缩水。对于ACID的支持,基本上等同于就没有。
|
||||
|
||||
这个时候,New SQL它来了!简单地说,New SQL就是兼顾了Old SQL和No SQL的优点:
|
||||
|
||||
- 完整地支持SQL和ACID,提供和Old SQL隔离级别相当的事务能力;
|
||||
- 高性能、高可靠、高可用,支持水平扩容。
|
||||
|
||||
像Google的Cloud Spanner、国产的OceanBase以及开源的[CockroachDB](https://github.com/cockroachdb/cockroach)都属于New SQL数据库。Cockroach这个英文单词是蟑螂的意思,所以一般我们都把CockroachDB俗称为小强数据库。
|
||||
|
||||
这些New SQL凭什么就能做到Old SQL和No SQL做不到的这些特性呢?那我们就以开源的CockroachDB为例子,来看一下New SQL是不是真的这么厉害。
|
||||
|
||||
## CockroachDB是如何实现数据分片和弹性扩容的?
|
||||
|
||||
首先,我们一起先来简单看一下CockroachDB的架构,从架构层面分析一下,它是不是真的像宣传的那么厉害。我们先来看一下它的架构图(图片来自于[官方文档](https://github.com/cockroachdb/cockroach/blob/master/docs/design.md)):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/82/8c78db973e66bb62b23c8e85afe78082.jpg" alt="">
|
||||
|
||||
这是一个非常典型的分层架构,我们从上往下看。最上层是SQL层,SQL层支持和关系型数据库类似的逻辑数据结构,比如说库、表、行和列这些逻辑概念。SQL层向下调用的是一个抽象的接口层Structured Data API,实际实现这个API的是下面一层:Distributed, Monolithic KV Store,这就是一个分布式的KV存储系统。
|
||||
|
||||
我们先不深入进去看细节,从宏观层面上分析一下这个架构。你可以看到,这个架构仍然是我们之间讲过的,大部分数据库都采用的二层架构:执行器和存储引擎。它的SQL层就是执行器,下面的分布式KV存储集群就是它的存储引擎。
|
||||
|
||||
那我们知道,MySQL的存储引擎InnoDB,实际上是基于文件系统的B+树,像Hive和HBase,它们的存储引擎都是基于HDFS构建的。那CockroachDB这种,使用分布式KV存储来作为存储引擎的设计,理论上也是可行的,并没有什么特别难以逾越的技术壁垒。
|
||||
|
||||
而且,使用分布式KV存储作为存储引擎,实现高性能、高可靠、高可用,以及支持水平扩容这些特性,就不是什么难事儿了,其中很多分布式KV存储系统已经做到了,这里面使用的一些技术和方法,大多我们在之前的课程中也都讲到过。CockroachDB在实现它的存储引擎这一层,就是大量地借鉴,甚至是直接使用了已有的一些成熟技术。
|
||||
|
||||
它的分片算法采用的是范围分片,我们之前也讲到过,范围分片对查询是最友好的,可以很好地支持范围扫描这一类的操作,这样有利于它支撑上层的SQL查询。
|
||||
|
||||
它采用[Raft](https://raft.github.io/)一致性协议来实现每个分片的高可靠、高可用和强一致。这个Raft协议,它的一个理论基础,就是我们之前讲的复制状态机,并且在复制状态机的基础上,Raft实现了集群自我监控和自我选举来解决高可用的问题。Raft也是一个被广泛采用的、非常成熟的一致性协议,比如etcd也是基于Raft来实现的。
|
||||
|
||||
CockroachDB的元数据直接分布在所有的存储节点上,依靠流言协议来传播,这个流言协议,我们在《[16 | 用Redis构建缓存集群的最佳实践有哪些?](https://time.geekbang.org/column/article/217590)》这节课中也讲到过,在Redis Cluster中也是用流言协议来传播元数据变化的。
|
||||
|
||||
CockroachDB用上面这些成熟的技术解决了集群问题,在单机的存储引擎上,更是直接使用了RocksDB作为它的KV存储引擎。RocksDB也是值得大家关注的一个新的存储系统,下节课我们会专门讲RocksDB。
|
||||
|
||||
你可以看到,CockroachDB的存储引擎,也就是它的分布式KV存储集群,基本上没有什么大的创新,就是重用了已有的一些成熟的技术,这些技术在我们之前讲过的其他存储系统中,全部都见到过。我讲这些并没有贬低CockroachDB的意思,相反,站在巨人的肩膀上,才能看得更远,飞得更高,这是一种非常务实的做法。
|
||||
|
||||
## CockroachDB能提供金融级的事务隔离性么?
|
||||
|
||||
接下来我们说一下CockroachDB是怎么实现ACID的,它的ACID是不是类似于分布式事务的残血版?这是一个非常关键的问题,直接影响到它有没有可能在未来取代MySQL。
|
||||
|
||||
在说ACID之前,我们还是要简单说一下CockroachDB是怎么解析和执行SQL的。我们在《[10 | 走进黑盒:SQL是如何在数据库中执行的?](https://time.geekbang.org/column/article/213176)》这节课中讲过SQL是如何在MySQL中执行的,在CockroachDB中,这个执行的流程也是差不多的。同样是先解析SQL生成语法树,转换成逻辑执行计划,再转换为物理执行计划,优化后,执行物理执行计划返回查询结果,这样一个流程。
|
||||
|
||||
只是在CockroachDB中,物理执行计划就更加复杂了,因为它的物理执行计划面对的是一个分布式KV存储系统,在涉及到查找、聚合这类操作的时候,有可能需要涉及到多个分片(Range)。大致过程就是类似于Map-Reduce的逻辑,先查找元数据确定可能涉及到的分片,然后把物理执行计划转换成每个分片上的物理执行计划,在每个分片上去并行执行,最后,再对这些执行结果做汇总。
|
||||
|
||||
然后我们再来说CockroachDB的ACID。我们在《[04 | 事务:账户余额总是对不上账,怎么办?](https://time.geekbang.org/column/article/206544)》这节课中讲到过四种事务隔离级别,分别是RU、RC、RR和SERIALIZABLE,那CockroachDB能提供哪种隔离级别呢?答案是,以上四种都不是。
|
||||
|
||||
CockroachDB提供了另外两种隔离级别,分别是:**Snapshot Isolation (SI)** 和 **Serializable Snapshot Isolation (SSI)**,其中SSI是CockroachDB默认的隔离级别。
|
||||
|
||||
这两种隔离级别和之前提到的四种隔离级别是什么关系呢?我们通过下面这张表,和MySQL默认的隔离级别RR做一个对比。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/67/20e20d983ad7519e6eae11821a3f1567.jpg" alt="">
|
||||
|
||||
首先我们看SI这一行。我们之前讲到过,RR这种隔离级别,可以很好地解决脏读和不可重复读的问题,虽然可能会产生幻读,但实际上对绝大多数事务影响不大。SI不会发生脏读、不可重复读,也不会发生幻读的情况,这个隔离级别似乎比RR还要好。
|
||||
|
||||
但你要注意一下,我们这个表格比之前那个表格多了一列:写倾斜。可以看到,RR是不会出现写倾斜问题的,但是SI会有写倾斜问题。
|
||||
|
||||
什么是写倾斜?我们还是拿账户余额的例子来说明。比如说,我们的账户需要支持主副卡,主卡和副卡都分别有自己的余额,并且这个余额是可以透支的,只要满足主副卡的余额之和大于0就行了。如果我们需要给主卡支出100元,那SQL只要这样写就可以了:
|
||||
|
||||
```
|
||||
update account
|
||||
set balance = balance - 100 -- 在主卡中扣减100元
|
||||
where id = ? and
|
||||
(select balance from account where id = ?) -- 主卡余额
|
||||
+
|
||||
(select balance from account where id = ?) -- 附卡余额
|
||||
>= 100; -- 主副卡余额之和必须大于100元
|
||||
|
||||
```
|
||||
|
||||
在传统的RR隔离级别下,由于更新数据时会对记录加锁,即使更新主副卡的两个SQL分别在两个事务中并发执行,也不会出现把主副卡的余额之和扣减成负数的情况。
|
||||
|
||||
但是在SI级别下,由于它没有加锁,而是采用快照的方式来实现事务的隔离,这个时候,如果并发地去更新主副卡余额,是有可能出现把主副卡余额之和扣减为负数的情况的。这种情况称为**写倾斜**。这里顺便提一句,写倾斜是普遍的译法,我个人觉得“倾斜”这个词翻译得并不准确,实际上它表达的,就是因为没有检测读写冲突,也没有加锁,导致数据写错了。
|
||||
|
||||
SSI隔离级别在SI的基础上,加入了冲突检测的机制,通过检测读写冲突,然后回滚事务的方式来解决写倾斜的问题,当然这种方式付出的代价是降低性能,并且冲突严重的情况下,会频繁地出现事务回滚。
|
||||
|
||||
从理论上来说,CockroachDB支持的SI和SSI这两种事务隔离级别,能提供的事务隔离性,已经与传统的RC和RR隔离级别不相上下了,可以满足大多数在线交易类系统对ACID的要求。
|
||||
|
||||
## 小结
|
||||
|
||||
New SQL是新一代的分布式数据库,它具备原生分布式存储系统高性能、高可靠、高可用和弹性扩容的能力,同时还兼顾了传统关系型数据库的SQL支持。更厉害的是,它还提供了和传统关系型数据库不相上下的、真正的事务支持,具备了支撑在线交易类业务的能力。
|
||||
|
||||
CockroachDB是开源的New SQL数据库。它的存储引擎是一个分布式KV存储集群,执行器则大量借鉴了PostgreSQL的一些设计和实现,是一个集很多现有数据库和分布式存储系统技术于一身,这样的一个数据库产品。
|
||||
|
||||
从设计上来看,CockroachDB这类New SQL数据库,有非常大的潜质可以真正地取代MySQL这类传统的关系型数据库。但是我们也应该看到,目前这些New SQL数据库都还处于高速发展阶段,并没有被大规模地应用到生产系统中去。我也不建议你做小白鼠,在重要的系统上去使用它。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你去看一下[Raft](https://raft.github.io/)一致性协议,然后简单总结一下,CockroachDB是如何利用Raft协议实现Range高可用、高可靠和强一致的。欢迎你在留言区与我讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
73
极客时间专栏/后端存储实战课/海量数据篇/24 | RocksDB:不丢数据的高性能KV存储.md
Normal file
73
极客时间专栏/后端存储实战课/海量数据篇/24 | RocksDB:不丢数据的高性能KV存储.md
Normal file
@@ -0,0 +1,73 @@
|
||||
<audio id="audio" title="24 | RocksDB:不丢数据的高性能KV存储" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/21/5a5315e1a88d43a49d589c55f334a121.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
上节课我们在讲解CockroachDB的时候提到过,CockroachDB的存储引擎是一个分布式的KV存储集群,它用了一系列成熟的技术来解决集群问题,但是在集群的每个节点上,还需要一个单机的KV存储来保存数据,这个地方CockroachDB直接使用RocksDB作为它的KV存储引擎。
|
||||
|
||||
[RocksDB](https://github.com/facebook/rocksdb)是Facebook开源的一个高性能持久化KV存储。目前,你可能很少见到过哪个项目会直接使用RocksDB来保存数据,在未来,RocksDB大概率也不会像Redis那样被业务系统直接使用。那我们为什么要关注它呢?
|
||||
|
||||
因为越来越多的新生代数据库,都不约而同地选择RocksDB作为它们的存储引擎。在将来,很有可能出现什么样的情况呢?我们使用的很多不同的数据库,它们背后采用的存储引擎都是RocksDB。
|
||||
|
||||
我来给你举几个例子。我们上节课讲到的CockroachDB用到了RocksDB作为它的存储引擎。再说几个比较有名的,[MyRocks](http://myrocks.io/)这个开源项目,你看它这个名字就知道它是干什么的了。它在用RocksDB给MySQL做存储引擎,目的是取代现有的InnoDB存储引擎。并且,MySQL的亲兄弟MariaDB已经接纳了MyRocks,作为它的一个可选的存储引擎。还有大家都经常用的实时计算引擎[Flink](https://flink.apache.org/),用过的同学都知道,Flink的State就是一个KV的存储,它使用的也是RocksDB。还有包括MongoDB、Cassandra等等很多的数据库,都在开发基于RocksDB的存储引擎。
|
||||
|
||||
今天这节课,我们就一起来了解一下RocksDB这颗“未来之星”。
|
||||
|
||||
## 同样是KV存储,RocksDB有哪些不同?
|
||||
|
||||
说到KV存储,我们最熟悉的就是Redis了,接下来我们就来对比一下RocksDB和Redis这两个KV存储。
|
||||
|
||||
其实Redis和RocksDB之间没什么可比性,一个是缓存,一个是数据库存储引擎,放在一起比就像“关公战秦琼”一样。那我们把这两个KV放在一起对比,目的不是为了比谁强谁弱,而是为了让你快速了解RocksDB能力。
|
||||
|
||||
我们知道Redis是一个内存数据库,它之所以能做到非常好的性能,主要原因就是,它的数据都是保存在内存中的。从Redis官方给出的测试数据来看,它的随机读写性能大约在50万次/秒左右。而RocksDB相应的随机读写性能大约在20万次/秒左右,虽然性能还不如Redis,但是已经可以算是同一个量级的水平了。
|
||||
|
||||
这里面你需要注意到的一个重大差异是,Redis是一个内存数据库,并不是一个可靠的存储。数据写到内存中就算成功了,它并不保证安全地保存到磁盘上。而RocksDB它是一个持久化的KV存储,它需要保证每条数据都要安全地写到磁盘上,这也是很多数据库产品的基本要求。这么一比,我们就看出来RocksDB的优势了,我们知道,磁盘的读写性能和内存读写性能差着一两个数量级,读写磁盘的RocksDB,能和读写内存的Redis做到相近的性能,这就是RocksDB的价值所在了。
|
||||
|
||||
RocksDB为什么能在保证数据持久化的前提下,还能做到这么强的性能呢?我们之前反复讲到过,一个存储系统,它的读写性能主要取决于什么?取决于它的存储结构,也就是数据是如何组织的。
|
||||
|
||||
RocksDB采用了一个非常复杂的数据存储结构,并且这个存储结构采用了内存和磁盘混合存储方式,使用磁盘来保证数据的可靠存储,并且利用速度更快的内存来提升读写性能。或者说,RocksDB的存储结构本身就自带了内存缓存。
|
||||
|
||||
那我们知道,内存缓存可以很好地提升读性能,但是写入数据的时候,你是绕不过要写磁盘的。因为,要保证数据持久化,数据必须真正写到磁盘上才行。RocksDB为什么能做到这么高的写入性能?还是因为它特殊的数据结构。
|
||||
|
||||
大多数存储系统,为了能做到快速查找,都会采用树或者哈希表这样的存储结构,数据在写入的时候,必须写入到特定的位置上。比如说,我们在往B+树中写入一条数据,必须按照B+树的排序方式,写入到某个固定的节点下面。哈希表也是类似,必须要写入到特定的哈希槽中去。
|
||||
|
||||
这些数据结构会导致在写入数据的时候,不得不在磁盘上这里写一点儿,再去那里写一点儿,这样跳来跳去地写,也就是我们说的“随机写”。而RocksDB它的数据结构,可以让绝大多数写入磁盘的操作都是顺序写。那我们知道,无论是SSD还是HDD顺序写的性能都要远远好于随机写,这就是RocksDB能够做到高性能写入的根本原因。
|
||||
|
||||
那我们在《[21 | 类似“点击流”这样的海量数据应该如何存储?](https://time.geekbang.org/column/article/224162)》这节课中讲到过,Kafka也是采用顺序读写的方式,所以它的读写性能也是超级快。但是这种顺序写入的数据基本上是没法查询的,因为数据没有结构,想要查询的话,只能去遍历。RocksDB究竟使用了什么样的数据结构,在保证数据顺序写入的前提下还能兼顾很好的查询性能呢?这种数据结构就是**LSM-Tree**。
|
||||
|
||||
## LSM-Tree如何兼顾读写性能?
|
||||
|
||||
LSM-Tree的全称是:**The Log-Structured Merge-Tree**,是一种非常复杂的复合数据结构,它包含了WAL(Write Ahead Log)、跳表(SkipList)和一个分层的有序表(SSTable,Sorted String Table)。下面这张图就是LSM-Tree的结构图(图片来自于论文: [An Efficient Design and Implementation of LSM-Tree based Key-Value Store on Open-Channel SSD](http://ranger.uta.edu/~sjiang/pubs/papers/wang14-LSM-SDF.pdf))
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/6e/c0ba7aa330ea79a8a1dfe3a58547526e.jpg" alt="">
|
||||
|
||||
看起来非常复杂是吧?实际上它的结构比这个图更复杂。那我们尽量忽略没那么重要的细节,把它的核心原理讲清楚。首先需要注意的是,这个图上有一个横向的实线,是内存和磁盘的分界线,上面的部分是内存,下面的部分是磁盘。
|
||||
|
||||
我们先来看数据是如何写入的。当LSM-Tree收到一个写请求,比如说:PUT foo bar,把Key foo的值设置为bar。首先,这条操作命令会被写入到磁盘的WAL日志中(图中右侧的Log),这是一个顺序写磁盘的操作,性能很好。这个日志的唯一作用就是用于故障恢复,一旦系统宕机,可以从日志中把内存中还没有来得及写入磁盘的数据恢复出来。这个地方用的还是之前我们多次讲过的复制状态机理论。
|
||||
|
||||
写完日志之后,数据可靠性的问题就解决了。然后数据会被写入到内存中的MemTable中,这个MemTable就是一个按照Key组织的跳表(SkipList),跳表和平衡树有着类似的查找性能,但实现起来更简单一些。写MemTable是个内存操作,速度也非常快。数据写入到MemTable之后,就可以返回写入成功了。这里面有一点需要注意的是,**LSM-Tree在处理写入的过程中,直接就往MemTable里写,并不去查找这个Key是不是已经存在了**。
|
||||
|
||||
这个内存中MemTable不能无限地往里写,一是内存的容量毕竟有限,另外,MemTable太大了读写性能都会下降。所以,MemTable有一个固定的上限大小,一般是32M。MemTable写满之后,就被转换成Immutable MemTable,然后再创建一个空的MemTable继续写。这个Immutable MemTable,也就是只读的MemTable,它和MemTable的数据结构完全一样,唯一的区别就是不允许再写入了。
|
||||
|
||||
Immutable MemTable也不能在内存中无限地占地方,会有一个后台线程,不停地把Immutable MemTable复制到磁盘文件中,然后释放内存空间。每个Immutable MemTable对应一个磁盘文件,MemTable的数据结构跳表本身就是一个有序表,写入的文件也是一个按照Key排序的结构,这些文件就是SSTable。把MemTable写入SSTable这个写操作,因为它是把整块内存写入到整个文件中,这同样是一个顺序写操作。
|
||||
|
||||
到这里,虽然数据已经保存到磁盘上了,但还没结束,因为这些SSTable文件,虽然每个文件中的Key是有序的,但是文件之间是完全无序的,还是没法查找。这里SSTable采用了一个很巧妙的分层合并机制来解决乱序的问题。
|
||||
|
||||
SSTable被分为很多层,越往上层,文件越少,越往底层,文件越多。每一层的容量都有一个固定的上限,一般来说,下一层的容量是上一层的10倍。当某一层写满了,就会触发后台线程往下一层合并,数据合并到下一层之后,本层的SSTable文件就可以删除掉了。合并的过程也是排序的过程,除了Level 0(第0层,也就是MemTable直接dump出来的磁盘文件所在的那一层。)以外,每一层内的文件都是有序的,文件内的KV也是有序的,这样就比较便于查找了。
|
||||
|
||||
然后我们再来说LSM-Tree如何查找数据。查找的过程也是分层查找,先去内存中的MemTable和Immutable MemTable中找,然后再按照顺序依次在磁盘的每一层SSTable文件中去找,只要找到了就直接返回。这样的查找方式其实是很低效的,有可能需要多次查找内存和多个文件才能找到一个Key,但实际的效果也没那么差,因为这样一个分层的结构,它会天然形成一个非常有利于查找的情况:越是被经常读写的热数据,它在这个分层结构中就越靠上,对这样的Key查找就越快。
|
||||
|
||||
比如说,最经常读写的Key很大概率会在内存中,这样不用读写磁盘就完成了查找。即使内存中查不到,真正能穿透很多层SStable一直查到最底层的请求还是很少的。另外,在工程上还会对查找做很多的优化,比如说,在内存中缓存SSTable文件的Key,用布隆过滤器避免无谓的查找等来加速查找过程。这样综合优化下来,可以获得相对还不错的查找性能。
|
||||
|
||||
## 小结
|
||||
|
||||
RocksDB是一个高性能持久化的KV存储,被很多新生代的数据库作为存储引擎。RocksDB在保证不错的读性能的前提下,大幅地提升了写入性能,这主要得益于它的数据结构:LSM-Tree。
|
||||
|
||||
LSM-Tree通过混合内存和磁盘内的多种数据结构,将随机写转换为顺序写来提升写性能,通过异步向下合并分层SSTable文件的方式,让热数据的查找更高效,从而获得还不错的综合查找性能。
|
||||
|
||||
通过分析LSM-Tree的数据结构可以看出来,这种数据结构还是偏向于写入性能的优化,更适合在线交易类场景,因为在这类场景下,需要频繁写入数据。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们刚刚讲了LSM-Tree是如何读写数据的,但是并没有提到数据是如何删除的。课后请你去看一下RocksDB或者是LevelDB相关的文档,总结一下LSM-Tree删除数据的过程,也欢迎你在留言区分享你的总结。
|
||||
|
||||
感谢你的阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
86
极客时间专栏/后端存储实战课/结束语/结束语 | 把奋斗当习惯.md
Normal file
86
极客时间专栏/后端存储实战课/结束语/结束语 | 把奋斗当习惯.md
Normal file
@@ -0,0 +1,86 @@
|
||||
<audio id="audio" title="结束语 | 把奋斗当习惯" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/da/97e0062ec511fcff9a10ee3701088eda.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
今天这节课,是我们这系列课程的最后一讲,我想跟你聊聊我对个人技术成长的感悟。
|
||||
|
||||
程序员是一个特别依赖个人技术能力的职业,不同的程序员之间,技术能力的差别也非常大。一个大神程序员的产出,可以抵得上好几个普通程序员。一个技术差还自以为是的程序员,他的产出更是能抵得上几十个程序员,不过这个产出是负的。所谓一人写Bug,大家加班来找茬,相信很多人都有过这样的经历。
|
||||
|
||||
相应的,程序员的收入差距也非常大,从年入几万到几百万的都有。同样是应届生,从CRUD(增删改查)开始做起,几年之后有些人还在“CRUD”,只是更熟练了而已。而有的人技术成长得非常快,职位和收入也随之水涨船高。
|
||||
|
||||
为什么有些人的技术能快速成长?这里面的原因很复杂,每个人的天赋、工作经历、选择甚至于运气都是影响因素。但除此之外,还是会有一些方法和经验,可以为你的技术成长提速。
|
||||
|
||||
## 把奋斗当成习惯
|
||||
|
||||
技术的原始积累,是个人技术能力的基础。这个“积累”主要指的是,你要有足够多的技术经历,这里面包括你读过的书、写过的代码、做过的项目、解决过的Bug、用过的框架、踩过的坑儿以及遇到的各种线上问题,等等。
|
||||
|
||||
为什么说技术积累这么重要呢?没有技术积累我直接去学习技术原理,去刷题,通过这些手段去提升技术不行么?
|
||||
|
||||
那我给你举个例子,包括很多学习计算机专业的同学,刚毕业的时候,觉得大学里面学到那些专业课和实际工作脱节的厉害,还没有培训班讲的实战课有用。反而很多有多年技术经验的资深程序员,开始捡起大学那些课本去回炉重学。越是大厂技术面试,基础知识、算法、设计模式这些占比越重,这里面很多都是大学专业课学过的知识。
|
||||
|
||||
为什么会有这样的现象?因为如果没有足够的技术积累,你很难理解书本上的技术知识和原理它的用途在哪儿,所以会觉得没用。只有你遇到过这样的问题,有过困惑,再去看书上讲的知识,立刻就会有一种恍然大悟的感觉。所以说,原始的技术积累非常重要。
|
||||
|
||||
对于技术积累,没有捷径可以走,如果想做到快速积累,只有多写代码、多做项目这一条路。具体的做法很简单,也很难,就是去主动地去多做事情,不要去管这些事情是不是职责范围内的、有没有报酬、会不会有收获、对技术有没有提升。不计得失,任劳任怨。
|
||||
|
||||
这个做法说来简单,它真的就很简单,只要想去做,每个人都做得到。但是,它也很难。因为并不是每个人都打心里认同这个做法,**如果你没有内心的认同,又强迫自己这么做,是非常痛苦的,并且很难坚持下来**。所以,问题的关键是寻求内心的认同。
|
||||
|
||||
我刚毕业那几年,对这个观点就是非常不认同。我当时的想法是这样的:每个月才给我这么一点儿钱,凭啥我要主动去做这么多事儿?我对公司产生的价值,绝对对得起我的工资,再多干活,公司又不会给我钱。
|
||||
|
||||
我估计很多年轻的朋友也会有同样的想法。我们不能说这个想法就不对,实际上这里面涉及到人生观的问题。比如说,有的人清楚地知道自己想要什么,“我不追求什么技术,也不在乎职务收入,工作只是我谋生的手段,我更看重的是诗和远方”。
|
||||
|
||||
但我是一个“俗人”,希望不断提升自身的技术能力,获得自我认同,同时也获得更好的收入和体面的生活。如果你也和我一样不能免俗的话,我建议你在内心上尽快做一个转变。什么转变呢?**从“凭什么要我做?”,到“愿意主动去多做事儿”,再到“把奋斗当做习惯”。**
|
||||
|
||||
我的转变来自一顿酒局。记得当时也是一个前辈在一次一起喝酒的时候提点了我。当我借着喝点儿酒,和他抱怨当时的工作钱少事儿多没技术含量,他一句话点醒了我:“如果你是老板,当你有一个重要的任务,你是愿意交给那个只做分内事和你斤斤计较的人,还是交给那个不计得失兢兢业业的人呢?”
|
||||
|
||||
**其实你真正应该较劲的人,不是那个扣扣索索不舍得给你发钱的老板,而是那些和你一起竞争有限发展空间的同龄人。**主动去多做一些事儿,不仅能获得更多的成长和锻炼,更重要的是获得周围人对你的认同,这里面也包括你的领导和老板。这样你就会获得更重要的任务和更多的锻炼机会,才能相比同龄人更快速地成长,用更短的时间快速实现技术积累。
|
||||
|
||||
想通了这个道理,即使去做一些没有意义的脏活累活,心里也不会感觉那么痛苦了。
|
||||
|
||||
## 思考沉淀,让点成势
|
||||
|
||||
你的技术能力,会随着你的技术积累线性增长。当你的技术经验积累到一定程度的时候,你需要**停下来**,给自己几天时间,什么都不做,放空一下自己,利用这段时间去思考,问自己两个问题:
|
||||
|
||||
- 这段时间我都做了什么?
|
||||
- 技术上我都学到了什么?
|
||||
|
||||
然后,在脑海中把这两个问题的答案再梳理一下,这个时候你就有可能会发现,你之前积累的零散的知识,它们之间其实是有联系的,然后再通过总结,你就有可能会在某一个小的技术领域上,构建出一个知识体系。
|
||||
|
||||
原来看不清楚脉络的技术,有可能就看清楚了。反过来,理清了技术脉络,构建起知识体系之后,也会极大地加快你继续学习和积累的速度。
|
||||
|
||||
这么说有点儿抽象,我们还是通过一个例子来看。比如说学习一门新的编程语言,这个对很多同学来说都是一个挺大的挑战,但我现在是可以做到用一周的时间来学会一门全新的编程语言。
|
||||
|
||||
达到什么程度呢?精通和熟练是肯定谈不上的,但至少可以做到写出规范和合格的代码,去开发一个真正可以用于生产的系统,这个是没问题的。
|
||||
|
||||
我在上一门课程《[消息队列高手课](https://time.geekbang.org/column/intro/100032301)》中,有很多的示例代码,用了Java、Scala、Python和Golang四种比较流行的编程语言。我日常工作使用的是Java语言,Python偶尔会到。当时在编写这门课程的时候,Scala和Golang这两门语言都是现学现卖的。
|
||||
|
||||
我之所以能够做到快速学习,一个前提是,我之前在熟练地掌握了二、三门语言之后,经过了思考和总结,理清楚了编程语言的技术体系是什么样的,以及我已经掌握的这几门语言,它们之间有哪些共通的知识。
|
||||
|
||||
当我再学习一门全新的语言时,我首先会去看一下,这门语言和其他语言有什么不一样的特性,这些特性往往是,为了解决其他语言中那些不容易解决的问题而诞生的。比如,最近特别火的Rust语言,它之所以这么火的原因是,它采用了所有者模型来解决内存管理的老大难问题。如果你经历过用C++内存泄漏的痛苦,也体会过Java以及Golang动不动Stop the world的垃圾回收器,那你一下子就能理解Rust语言的这个特性有多可爱。
|
||||
|
||||
你可以看到,快速理解这些新特性的基础,还是要有足够的技术积累做支撑,如果你没有C++、Java和Go这些语言的使用经验,你的感受可能是:为什么会有所有者模型这么奇葩的设计?这个垃圾的编译器为什么总是编译不通过?
|
||||
|
||||
再说回来学习编程语言这件事,了解完一门新语言的特性之后,我会看一下它的基本语法、线程模型以及内存管理模型是什么样的,是不是和已有的语言是一样的机制;再看一下它的基础类库,包括常用的集合类、如何读写文件、如何处理输入输出这些;再有就是它的源代码如何组织,编译构建系统是什么样的,如何处理类库之间的引用依赖这些编译运行的问题;最后还要看一下这门语言的生态系统,比如最常用的Web框架、RPC框架是什么,一些常用的场景下,配合哪些中间件最合适等等。
|
||||
|
||||
当然这么多内容,不可能一下子都记住,但你会发现,这里面绝大部分内容都是和其他语言差不多的,我们只要记住这个语言独有的那些特性就好。
|
||||
|
||||
了解了以上这些内容,基本上我们就算是初步掌握一门语言了。不过我可能还没有那么熟练,写几行代码就得去看看文档和例子,写的还比较慢,但我们写出来的代码规范性和正确性是可以保证的。剩下的就交给时间,逐步去练熟直到精通。
|
||||
|
||||
所以,**停下来,去思考沉淀,让点成势,构建出自己的知识结构,是技术成长的捷径。**
|
||||
|
||||
## 洞见技术本质
|
||||
|
||||
如果说,我们能够不断地积累,思考,再积累,再思考,那不仅你的技术成长会非常快,反复地总结和思考,也会在无形中逐渐提升你的思考能力。
|
||||
|
||||
随着你的知识体系越来越完备,总结和思考的能力越来越强大,那你也就会越来越容易看清一项新技术的本质和原理,这又非常有助于你快速地学习一些新的技术。这个时候,你会有一种学习任何技术都很容易很轻松的感觉,恭喜你,你已经完成了技术的升华和蜕变。
|
||||
|
||||
但这个时候,我还要打你一棒子,那个很轻松、很容易的感觉其实是个错觉。为什么这么说呢?因为技术的原理或者说是本质,它本来就是很简单的,并不是我们有厉害。真正复杂和难的是工程实践中的细节。
|
||||
|
||||
比如说,汽车发动机的原理大家都知道,汽油燃烧热胀冷缩推动活塞做功。但是,这个地球上真正能造出可靠耐用的汽车发动机的公司并不多,原因就是光掌握原理是不够的,还要解决很多复杂的工程问题。
|
||||
|
||||
看清一项技术的原理之后,会利于我们快速学习这项技术,但要想达到精通并熟练的应用,还是要沉下心来,去深入学习、研究、使用和总结,这个功夫是少不了的。
|
||||
|
||||
好的,以上这些就是我对个人技术成长的一点点感悟。其实这些知识道理并不高深,只是能够做到,并且将这些道理变成自己的信条时,你才真正拥有了它。
|
||||
|
||||
到此,我们的《后端存储实战课》也就告一段落了。课程结束并非终点,我们还可以在留言区互动交流,也祝你享受成长,学有所成。
|
||||
|
||||
再见。
|
||||
13
极客时间专栏/后端存储实战课/结课测试/结课测试 | 后端存储,100分试卷等你来挑战.md
Normal file
13
极客时间专栏/后端存储实战课/结课测试/结课测试 | 后端存储,100分试卷等你来挑战.md
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
我们课程从上线更新至今,已经快2个月了,不知道这段时间你学习的怎么样?
|
||||
|
||||
为了帮助你检测自己的学习成果,我特别准备了一套结课测试题。这套测试题共有10道题目,包括7道单选题和3道多选题,满分100分。
|
||||
|
||||
题目中涉及到的知识点,我在这个系列课程中都讲过。每个题目出自哪一讲内容,我也会在答案解析中标注。
|
||||
|
||||
希望你能认真完成这次测试,如果你发现有些知识还没有掌握,可以回顾相应的内容,再去加深一下理解。也欢迎你在留言区与我互动交流。
|
||||
|
||||
好,点击下面的图片,开始测试吧!<br>
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=116&exam_id=250)
|
||||
85
极客时间专栏/后端存储实战课/课前必读/开篇词 | 从今天起,换种方式学存储.md
Normal file
85
极客时间专栏/后端存储实战课/课前必读/开篇词 | 从今天起,换种方式学存储.md
Normal file
@@ -0,0 +1,85 @@
|
||||
<audio id="audio" title="开篇词 | 从今天起,换种方式学存储" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/8b/bc25f54e2bbf0a70d45e0e67935f168b.mp3"></audio>
|
||||
|
||||
你好,我是李玥,《[消息队列高手课](https://time.geekbang.org/column/intro/212?utm_term=zeusI4EZD&utm_source=geektime&utm_medium=cunchu)》专栏的作者,目前在京东任职架构师。这是我在极客时间上的第二门课程,很高兴在这里遇见你。
|
||||
|
||||
在十多年的开发者职业生涯中,我的从业经历应该算是比较丰富的。在传统IT行业,做过非常多的企业级ToB的系统;转战互联网后,我又曾经带领创业团队体会过从0到1创业的艰辛,见证过互联网高速增长的高光时刻,也经历过京东数年大促的洗礼。
|
||||
|
||||
在工作过程中,我接触过很多系统。不同的系统,它的业务不一样,有做社交的,有做电商的,还有做内容的。系统的规模也不一样,有很小规模的系统,也有像BAT这样的巨无霸系统。在构建这些系统时,都会面临五花八门的问题。但总结下来我发现一个“神奇”的规律:
|
||||
|
||||
>
|
||||
**凡是那些特别难解决的、让你付出巨大代价的,或者是损失惨重的技术问题,几乎都可以归为存储系统的问题。**
|
||||
|
||||
|
||||
这个规律其实并不神奇,它是有原因的。
|
||||
|
||||
你可以想一下,我们开发的各种业务系统,几乎都是MIS系统,中文叫“管理信息系统”,有的大学还有这个专业。管理信息系统这个词的含义就是字面的意思:管理信息的系统。这里面的信息是什么?对,其实就是数据。不管你的系统业务是什么样的,最终都要落到对信息的管理,或者通俗点儿说,你系统的业务功能最终的结果就是数据。
|
||||
|
||||
只要这个数据是正确的,剩下的都是小问题。数据错了、丢了,甚至数据处理不及时,这些都是损失惨重的大问题。
|
||||
|
||||
所以用于承载数据的存储系统就显得非常重要,如果说,你能构建一个安全可靠、快速稳定的存储系统,基于这个基础之上构建的业务系统,显然就让人放心多了。
|
||||
|
||||
所以说,**存储是系统中最核心、最重要、最关键的组成部分,没有之一。**
|
||||
|
||||
## 你要关注存储的哪些特点?
|
||||
|
||||
我们常用的存储系统有很多,有单机的也有分布式的,有数据库、文件系统,还有一些介于二者之间的,种类非常多。无论是什么样的存储,比如MySQL、Redis、ElasticSearch等等,它们都有几个共同的特点。
|
||||
|
||||
**第一个特点是难用****。**怎么难用呢?对于应用程序来说,存储无非就是帮我们安全可靠地保存数据,在我需要的时候能快速读出来也就可以了。很遗憾,几乎没有存储系统能满足这么简单的要求。
|
||||
|
||||
有一个非常形象的比喻:我开着车去商场购物,到了停车场发现这个停车场不能存车,只能存零件。我必须自己把车拆了,然后把这些零件分门别类打上标签,存放到停车场货架上,走的时候自己再把零件取出来把车组装起来。
|
||||
|
||||
听起来很可笑是吧,你想想咱们用的这些存储系统,不就是这样吗?我们应用程序里管理的数据都是对象是吧?你告诉我哪个存储系统能存对象?没有吧?
|
||||
|
||||
拿MySQL举例,我要存取对象,必须把对象转换成MySQL表中的行,还得写SQL语句才能存取。是不是很难用?难用你还不得不用,并且还得把它给用好了,这里面有很多的方法、技巧和实践经验需要学习掌握。
|
||||
|
||||
**第二个特点是慢。**最近几年的技术圈,分布式存储这块儿非常繁荣,你可以看到过一段时间就有一个新的数据库诞生了,不管它们功能怎么样,无一例外,都说自己的有多快,性能多好,顺便把像MySQL这样的老家伙拉出来,做个性能对比测试,吊打一遍。
|
||||
|
||||
“一个人炫耀什么,说明内心缺少什么”,这个道理放到技术圈同样适用,不断有新的存储刷新性能记录,恰恰说明了存储系统性能不能让人满意。
|
||||
|
||||
一个经过良好优化过的业务系统,它的性能瓶颈一定是存储。从性能角度上来说,存储系统就是整个系统中最短的那块儿板子,存储系统有多慢,你的整个系统就多慢。如何优化存储性能,从而让整个系统运转如飞,这里面同样有很多的方法、技巧和经验需要掌握。
|
||||
|
||||
**第三个特点是杂。**存储这块儿不像其他的成熟的技术领域,基本上都是一两种方案包打天下,比如Java开发,基本上就被Spring统治了,再比如Web容器,静态用Nginx,动态Tomcat。但存储这块儿却不是这样的,就拿真正广泛应用在生产系统中的存储来说,你看有多少种?
|
||||
|
||||
MySQL、Redis、ElasticSearch、HBase、Hive、MongoDB、RocksDB、CockroachDB等等,这些存储还真就是谁都替代不了谁,每一种都有它擅长的地方,有它适用的场景,当然也有很突出的短板。如何根据业务系统的特点,选择合适的存储来构建我们的系统,这也是需要学习和掌握的。
|
||||
|
||||
## 学习存储的最佳姿势是什么样的?
|
||||
|
||||
既然存储有“难用、慢、杂”这几大特点,学习起来就更需要注意方法。如何来学最高效呢?我认为是实战,从问题入手。
|
||||
|
||||
存储涉及到很多理论知识和概念,比如各种数据结构、哈希、树以及它们的时间复杂度等等,这些内容往往都是偏数学范畴的一些知识,学起来不容易理解和记忆。并且,理论和实践之间往往存在着非常大的鸿沟,往往是“懂了一堆道理,却还是写不好代码”。
|
||||
|
||||
所以,我在极客时间上开了第二个专栏讲存储,我们只讲实践中大家都会遇到的问题,讲这些问题的解决方法,同时在这里面贯穿一些知识和原理。通过这样的学习方式,既可以快速地帮你解决实际问题,同时还能提升你的技术能力。
|
||||
|
||||
在接下来的课程中,我会带你一起,**从0到1,从小到大,以电商作为场景,讲解不同规模的存储系统应该如何构建**。
|
||||
|
||||
每一节课,我们一起解决一两个实战的问题,比如:为什么明明数据量和访问量不大,MySQL还是很慢?数据库宕机了怎么办?
|
||||
|
||||
为什么选择电商系统来讲呢?因为我熟啊!开个玩笑,当然不只是因为我做过几年电商系统。其实,很多培训学校、各种技术论坛都特别喜欢讲电商系统。因为电商这个系统,特别有代表性,特别适合作为案例来研究和学习。
|
||||
|
||||
**首先,电商系统覆盖面足够广泛****。**特别是是在互联网行业,你会发现几乎所有的互联网公司都在做两个事情:电商和社交。
|
||||
|
||||
**其次,****用电商系统作为案例,直接就能学以致用**。即使你面对的业务和电商关系不大,因为电商的系统足够复杂,你在其他业务中可能遇到的技术问题,大多数在电商系统中基本都会遇到,一样有借鉴的意义。另外,电商这个业务领域对所有人来说都很熟悉,拿它作为案例基本上不需要再讲解业务知识,我们可以快速地专注于技术问题本身。
|
||||
|
||||
即使是同样一个电商系统,不同的规模,它需要解决的问题也不一样。不少做技术的同学崇尚于海量数据和高并发,认为只有大厂那些高并发、海量数据的核心或者是底层存储系统,才是真正“有技术含量”的系统,能胜任这样系统的开发者,才是真正的技术大牛。这其实是一个技术认知误区。为什么这么说?
|
||||
|
||||
因为,并不是规模小的系统就简单,只有大规模的系统才有难度。
|
||||
|
||||
创业团队需要快速低成本把系统完整地实现出来,好快速验证它的商业模式;处于高速增长期的团队,它面临的问题是业务高速增长和不断变化,相应的,也要对系统不停地进行升级改造来适应变化,并且要在变化的过程中确保稳定;业务规模足够大的一些大厂,它需要解决的是如何应对高并发、海量数据这些问题。
|
||||
|
||||
所以说,不同规模的系统,在技术上没有高低贵贱之分,它们的建设目标不一样,面临的挑战不一样,需要解决的问题也不一样,对于存储系统的选择、架构设计也是不一样的。
|
||||
|
||||
所以,我们的课程设计就是按照系统的发展过程,分成了创业篇、高速增长篇和海量数据篇这三个部分。
|
||||
|
||||
- 在创业篇,我们重点解决从0到1的问题;比如:如何低成本高质量地快速构建一个小规模的订单存储系统。
|
||||
- 在高速增长篇,我们关注在高速变化的过程中,你的系统一定会遇到的一些共通问题,以及该如何应对这些问题。比如说,如何从单机的存储系统逐步演进为分布式存储系统;如何在线平滑的扩容我们的存储系统。
|
||||
- 在海量数据篇,我们重点解决高并发、海量数据情况下的存储系统该如何设计的问题。比如,海量的埋点数据该怎么存储;如何在各种数据库之间实时迁移和同步海量数据,等等。
|
||||
|
||||
通过学习这门课程,你将收获的不仅是案例中那些解决具体问题的方法,在电商系统架构、对存储系统的认知以及存储系统的设计能力这几个方面,都会有所提升。
|
||||
|
||||
更重要的是,通过案例来学习常用的数据库和存储系统的使用和实现,可以总结出存储系统最通用、本质的技术原理。掌握了存储系统的本质之后,不仅会让你在面试时更加从容,而且会让你对存储的理解上升一个层次,从“知道怎么用”,升级为“知道为什么这么用”,最终做到活学活用。
|
||||
|
||||
在极客时间的一段新旅程即将开始,在开始正式学习之前,我还想再说一些我的想法。技术的发展让使用技术变得越来越简单,但是作为有理想有情怀开发者,不能让技术把我们的变得越来越“简单”。我很开心又能同各位同学一起持续地丰富自己,也希望你能不负时光,认真对待这段学习之旅。
|
||||
|
||||
在学习过程中,你都可以在留言区提问,我看到会第一时间给你回复。另外,像《消息队列高手课》专栏一样,我也希望你可以在学习之初立一个Flag。有了目标指引,持之以恒地探索,成功必不会偏航。
|
||||
|
||||
好,开始学习吧!
|
||||
142
极客时间专栏/后端存储实战课/课前必读/课前加餐 | 电商系统是如何设计的?.md
Normal file
142
极客时间专栏/后端存储实战课/课前必读/课前加餐 | 电商系统是如何设计的?.md
Normal file
@@ -0,0 +1,142 @@
|
||||
<audio id="audio" title="课前加餐 | 电商系统是如何设计的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/1d/7077b3844c3a4b9518a26dd49428fa1d.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
在这个系列课程中,我们会讲电商这个行业在多年系统建设和运维过程中,总结出来的使用分布式存储系统的一些最佳实践。也会以电商系统作为例子来讲解存储相关的技术知识和问题。这都需要你对电商的业务逻辑、系统架构、核心业务流程有一个基本的认知。
|
||||
|
||||
虽然说,电商这个业务和你的生活息息相关,你可能对电商多少有一些了解,但是,即使是一个最小化的电商系统,它仍然非常复杂。所以,我们先花一节课的时间,一起以一个创业公司的CTO的视角,设计一个最小化的电商系统,在这个过程中帮你理清楚电商系统的架构。
|
||||
|
||||
这样,我们在学习后续课程的时候,就不再解释电商的业务和系统了,直接来讲具体的技术问题。
|
||||
|
||||
我们的故事从一个电话开始。某一天,一个曾经认识但并不太熟悉的老板突然来了一个电话:
|
||||
|
||||
“我有一个改变世界的想法,就差一个程序员了!你来吧!”
|
||||
|
||||
新公司很快就成立了,你成了新公司的CTO。关于要如何改变世界,目前唯一能确定的是,老板要做一个电商系统。具体做成什么样,还不清楚。你需要和老板讨论需求。
|
||||
|
||||
你:“咱们要做的业务模式是C2C、B2C还是B2B呢?”
|
||||
|
||||
老板:“什么B?什么C?我不懂你们那些技术的东西。”
|
||||
|
||||
你:“这么说吧,你要山寨,啊不对,做一个某宝网,还是某东网,还是某848网呢?”
|
||||
|
||||
老板:“不都是一样的么?有什么区别?你赶紧做一个出来我看看不就知道了?!”
|
||||
|
||||
有没有感觉似曾相识?作为研发谁没碰到过几个啥也不懂的需求方不是?那这种情况下,你怎么办呢?
|
||||
|
||||
**在需求还不太明确的情况下,比较可行的方式就是,先把那些不太会变化的核心系统搭建出来,尽量简单地实现出一个最小化的系统,然后再逐步迭代完善。**
|
||||
|
||||
## 电商系统的核心流程是什么样的?
|
||||
|
||||
接下来我带你一起来设计这个电商的核心系统。
|
||||
|
||||
遵照软件工程的一般规律,我们先从需求阶段开始。如何来做需求分析?理想情况下,应该由系统分析师或者是产品经理来承担这个任务。但现实很骨感,绝大多数情况下,你得到的所谓的“需求”,就是一两句话。需求分析的工作实际上就落在了开发者身上。
|
||||
|
||||
很多项目交付以后,还要改来改去,用户不满意,开发者很痛苦,其实就是缺失了需求分析这个步骤。所以,为了自己,每一个开发者都应该掌握一点需求分析的方法。
|
||||
|
||||
开发者怎么来做需求分析?这里面我们不讲那些做需求分析的方法和理论,只告诉你最重要、最关键的一个点。
|
||||
|
||||
不要一上来就设计功能,而是先要回答下面这两个问题:
|
||||
|
||||
1. 这个系统(或者是功能)是给哪些人用的?
|
||||
1. 这些人使用这个系统来解决什么问题?
|
||||
|
||||
这两个问题的答案,我把它们称为业务需求。在我们将要设计的这个电商系统中,它的业务需求是什么?电商的业务,每个人都熟悉,很容易回答这两个问题。
|
||||
|
||||
第一个问题,电商系统给哪些人用?
|
||||
|
||||
首先得有买东西的人,我们叫“用户”,还得有卖东西的人?我们叫“运营人员”。还有什么人会用这个系统?老板啊!你记住,你在设计任何一个系统的时候,千万不要把老板或者是领导给忘了,他们是给你钱的人,他们的意见非常重要!
|
||||
|
||||
然后我们一起回答第二个问题:用户、运营和老板,分别用电商系统来干什么?
|
||||
|
||||
这个问题也很容易回答,用户用系统来买东西,运营用系统来卖东西,老板需要在系统中看到他赚了多少钱。这两个问题的答案,或者说是业务需求,稍加细化后,可以用下面这个图来清晰的表述:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/bc/c75357b43ec89aebd88fbc2a3efcd1bc.jpg" alt="">
|
||||
|
||||
这个图在UML(统一建模语言)中称为用例图(Use Case),是需求分析的时候你需要画的第一张图。它回答的就是,业务需求中的那两个关键的问题,这个系统给谁用?他们用这个系统解决什么问题?
|
||||
|
||||
一般来说,业务需求和我们要设计的系统关系不大。为什么这么说呢?你可以看一下上面这个图里面的用例,放在传统的商业企业里面,比如一个小杂货铺、一个线下实体商场商店或者一个做电视购物的公司,是不是也是适用的?所以做业务需求的主要目的,是理清楚业务场景是什么样的。
|
||||
|
||||
然后我们来分析电商系统的流程。显然,一个电商系统最主要的业务流程,一定是购物这个流程。你应该很容易就能把这个流程分析出来,它的流程图是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bd/cf/bd778c67b3971e16d3cdbd9fae800dcf.jpg" alt="">
|
||||
|
||||
所有的电商几乎都是这样一个流程,我和你一起来看一下这个流程。
|
||||
|
||||
流程从用户选购商品开始,用户先从你的App中浏览商品;找到心仪的商品之后,把商品添加到购物车里面;都选好了之后,打开购物车,下一个订单;下单结算之后,就可以支付了;支付成功后,运营人员接下来会给每个已经支付的订单发货;邮寄商品给用户之后,用户确认收货,到这里一个完整的购物流程就结束了。
|
||||
|
||||
## 如何根据流程来划分功能模块?
|
||||
|
||||
接下来,我们把这个业务流程再细化,看一下电商系统如何来实现这个流程?我把细化之后的流程,绘制成了下面这个时序图(Sequence Diagram):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/ea/f5f3d89c31770591c5d59591a3390eea.jpg" alt="">
|
||||
|
||||
我们一起看下这个时序图中的每个步骤。
|
||||
|
||||
1. 用户开始浏览商品,需要有一个**商品模块**来支撑,给用户展示商品的介绍、价格等等这些信息。
|
||||
1. 用户把选好的商品加入购物车,这个步骤,也需要一个**购物车模块**来维护用户购物车中的商品。
|
||||
1. 用户下单肯定需要一个**订单模块**来创建这个新订单。订单创建好了之后,需要把订单中的商品从购物车中删除掉。
|
||||
1. 订单创建完成后,需要引导用户付款,也就是发起支付流程,这里需要有一个**支付模块**来实现支付功能,用户成功完成支付之后,需要把订单的状态变更为“已支付”。
|
||||
1. 之后运营人员就可以发货了,在系统中,发货这个步骤,需要扣减对应商品的库存数量,这个功能需要**库存模块**来实现,发货完成后,还需要把订单状态变更为“已发货”。
|
||||
1. 最后,用户收货之后,在系统中确认收货,系统把订单状态变更为“已收货”,流程结束。
|
||||
|
||||
这个流程涉及到的功能模块有:**商品、购物车、订单、支付和库存,**这几个模块就是一个电商系统中的核心功能模块。
|
||||
|
||||
当然,仅仅有这几个模块还是不够的,因为我们只分析了“购物”这个最主要的流程,并没有完全涵盖业务需求中的全部用例,比如:运营人员进货、老板查看报表这些用例还没有覆盖到。
|
||||
|
||||
相比购物这个流程,剩下的几个用例和流程都没那么复杂,用同样的方法就可以把其他功能模块分析出来。在这里我们就省略分析过程,直接给出我们电商系统的功能模块划分:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/54/0b37cfadd0e181cd9648efc9e9924354.jpg" alt="">
|
||||
|
||||
上面这个图,我使用的是UML中的包图(Package Diagram)来表示。整个系统按照功能,划分为十个模块,除了购物流程中涉及到的:商品、订单、购物车、支付、库存五个模块以外,还补充了促销、用户、账户、搜索推荐和报表这几个模块,这些都是构建一个电商系统必不可少的功能。我们一个一个来说每个模块需要实现的功能。
|
||||
|
||||
- 商品:维护和展示商品信息和价格。
|
||||
- 订单:维护订单信息和订单状态,计算订单金额。
|
||||
- 购物车:维护用户购物车中的商品。
|
||||
- 支付:负责与系统内外部的支付渠道对接,实现支付功能。
|
||||
- 库存:维护商品的库存数量和库存信息。
|
||||
- 促销:制定促销规则,计算促销优惠。
|
||||
- 用户:维护系统的用户信息,注意用户模块它是一个业务模块,一般不负责用户登录和认证,这是两个完全不同的功能。
|
||||
- 账户:负责维护用户的账户余额。
|
||||
- 搜索推荐:负责商城中,搜索商品和各种列表页和促销页的组织和展示,简单的说就是决定让用户优先看到哪些商品。
|
||||
- 报表:实现统计和分析功能,生成报表,给老板来做经营分析和决策使用。
|
||||
|
||||
这里面需要特别说一下促销模块,它是电商系统中,最复杂的一个模块。各种优惠券、满减、返现等等这些促销规则,每个都非常复杂,再加上这些规则叠加计算,常常是复杂到连制定促销规则的人都搞不清楚。
|
||||
|
||||
所以每个电商公司无一例外都爆出过,因为促销规则制定失误,而产生非常便宜的“羊毛单”,让精明的消费者薅了“羊毛”。不过五花八门的促销是提升销售最有效的手段,肯定不能因噎废食。
|
||||
|
||||
作为系统设计者,我们需要把促销的变化和复杂性封禁在促销模块内部,不能让一个促销模块把整个电商系统都搞得非常复杂,否则就很难去设计和实现。
|
||||
|
||||
可行的做法是,把促销模块与其他模块的接口设计的相对简单和固定,这样系统的其他模块就不会因为新的促销玩儿法而改变。
|
||||
|
||||
在创建订单时,订单模块把商品和价格信息传给促销模块,促销模块返回一个可以使用的促销列表,用户选择好促销和优惠,订单模块把商品、价格、促销优惠这些信息,再次传给促销模块,促销模块则返回促销价格。
|
||||
|
||||
最终生成的订单中,只记录订单使用了哪几种促销,以及最终的促销价就可以了。这样,不管促销这个模块的玩儿法怎么变化,订单和其他模块的业务逻辑不需要随之改变。
|
||||
|
||||
至此,我们就完成了一个电商系统的概要设计,你应该对电商系统也有了一个初步的了解。
|
||||
|
||||
## 小结
|
||||
|
||||
我们每节课的正文结束之后,都会安排一个小结,对课程的重点内容做一个总结。
|
||||
|
||||
今天我们再来回顾一下,一个电商系统的设计中,最核心的几个关键点。
|
||||
|
||||
首先,系统的角色是:用户、运营人员和老板。这三个角色对电商系统的需求是:用户使用系统来购物,运营人员负责销售商品,老板关注系统中经营数据。电商系统最核心的流程就是用户购物的流程,流程从用户浏览选购商品开始,加购、下单、支付、运营人员发货、用户确认收货,至此流程结束。
|
||||
|
||||
细化这个流程之后,我们可以分析出,支撑这个流程几个核心的功能模块:商品、订单、购物车、支付和库存。此外,还需要促销、用户、账户、搜索推荐和报表这些必备的功能模块支撑,才能构成一个完整的电商系统。
|
||||
|
||||
我还和你分享了作为一个开发者,你在做需求分析的时候,需要把握的一个要点:不要一上来就设计功能,而是要先理清业务需求,也就是这节课反复强调的两个问题:这个系统是给哪些人用的?他们分别用这个系统来解决什么问题?这样可以确保你做出来的系统,大体上不会偏离用户的预期。
|
||||
|
||||
最后,在讲解系统功能模块划分的时候,我们说了一个有效减少系统复杂度的设计经验。那就是,如果系统业务是复杂而多变的,尽量识别出这部分复杂业务的边界,将复杂封禁在一个模块的内部,避免这种复杂度扩散到整个系统中去。
|
||||
|
||||
这节课是我们专栏的课前加餐,目的是为了让你了解电商的组成模块。在后续的课程中,我将和你一起经历这个电商系统在从小到大的发展过程中遇到的存储技术问题,并一起解决它。
|
||||
|
||||
## 思考题
|
||||
|
||||
做完了概要设计,就可以来做技术选型了。作为公司的CTO,课后请你思考一下,这个电商系统的技术选型应该是什么样的?
|
||||
|
||||
- 使用什么编程语言和技术栈?
|
||||
- 需要哪些第三方的框架和云服务?
|
||||
- 我们最关心的存储系统该怎么选型?
|
||||
|
||||
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
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