This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View 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问题确保更新订单服务的幂等性。
通过这样两种幂等的实现方法,就可以保证,无论请求是不是重复,订单表中的数据都是正确的。当然,上面讲到的实现订单幂等的方法,你完全可以套用在其他需要实现幂等的服务中,只需要这个服务操作的数据保存在数据库中,并且有一张带有主键的数据表就可以了。
## 思考题
实现服务幂等的方法,远不止我们这节课上介绍的这两种,课后请你想一下,在你负责开发的业务系统中,能不能用这节课中讲到的方法来实现幂等?除了这两种方法以外,还有哪些实现服务幂等的方法?欢迎你在留言区与我交流互动。
感谢你的阅读,如果你觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。

View 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来访问又省事儿又节约带宽。而且几乎所有的对象存储云服务都自带CDNContent Delivery Network加速服务响应时间比直接请求业务的服务器更短。
国内的很多云厂商的对象存储对图片和视频都做了非常多的针对性优化。最有用的是缩放图片和视频转码你只要把图片和视频丢到对象存储中就可以随时获得任意尺寸大小的图片视频也会自动转码成各种格式和码率的版本适配各种App和场景。我只能说谁用谁知道真香
## 将商品介绍静态化
商品介绍在商详页中占得比重是最大的包含了大量的带格式文字、图片和视频。其中图片和视频自然要存放在对象存储里面商品介绍的文本一般都是随着商详页一起静态化保存在HTML文件中。
什么是静态化呢静态化是相对于动态页面来说的。一般我们部署到Tomcat中的Web系统返回的都是动态页面也就是在Web请求时动态生成的。比如说商详页一个Web请求过来带着SKUIDTomcat中的商详页模块再去访问各种数据库、调用后端服务动态把这个商详页拼出来返回给浏览器。
不过现在基本上没有系统会这么干了你想对于每个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块了你的系统是不是偷偷在坑我
这样给用户的体验非常不好。你不要以为这是一个小概率事件,当你的系统用户足够多的时候,每时每刻都有人在下单,这几乎是个必然出现的事件。
课后请你想一下,该怎么来解决这个问题?欢迎你在留言区与我交流互动。
感谢你的阅读,如果你觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。

View 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表示
```
{
&quot;cart&quot;: [
{
&quot;SKUID&quot;: 8888,
&quot;timestamp&quot;: 1578721136,
&quot;count&quot;: 1,
&quot;selected&quot;: true
},
{
&quot;SKUID&quot;: 6666,
&quot;timestamp&quot;: 1578721138,
&quot;count&quot;: 2,
&quot;selected&quot;: 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来保存购物车中的商品。比如
```
{
&quot;KEY&quot;: 6666,
&quot;VALUE&quot;: [
{
&quot;FIELD&quot;: 8888,
&quot;FIELD_VALUE&quot;: {
&quot;timestamp&quot;: 1578721136,
&quot;count&quot;: 1,
&quot;selected&quot;: true
}
},
{
&quot;FIELD&quot;: 6666,
&quot;FIELD_VALUE&quot;: {
&quot;timestamp&quot;: 1578721138,
&quot;count&quot;: 2,
&quot;selected&quot;: false
}
}
]
}
```
这里为了便于你理解我们用JSON来表示Redis中HASH的数据结构其中KEY中的值6666是一个用户IDFIELD里存放的是商品IDFIELD_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中的数据是一样的呢
欢迎你在留言区与我讨论,如果你觉得今天学到的知识对你有帮助,也欢迎把它分享给你的朋友。

View 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&gt; begin; -- 开始事务
Query OK, 0 rows affected (0.00 sec)
mysql&gt; insert into account_log ...; -- 写入交易流水
Query OK, 1 rows affected (0.01 sec)
mysql&gt; update account_balance ...; -- 更新账户余额
Query OK, 1 rows affected (0.00 sec)
mysql&gt; 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&gt; -- 会话 A
mysql&gt; -- 确认当前设置的隔离级别是RC
mysql&gt; SELECT @@global.transaction_isolation, @@transaction_isolation;
+--------------------------------+-------------------------+
| @@global.transaction_isolation | @@transaction_isolation |
+--------------------------------+-------------------------+
| READ-COMMITTED | READ-COMMITTED |
+--------------------------------+-------------------------+
1 row in set (0.00 sec)
mysql&gt; begin;
Query OK, 0 rows affected (0.00 sec)
mysql&gt; 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&gt; 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&gt; -- 会话 B
mysql&gt; begin;
Query OK, 0 rows affected (0.00 sec)
mysql&gt; 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&gt; -- 写入流水
mysql&gt; insert into account_log values (NULL, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
Query OK, 1 row affected (0.00 sec)
mysql&gt; -- 更新余额
mysql&gt; update account_balance
-&gt; set balance = balance + 100, log_id = LAST_INSERT_ID(), timestamp = NOW()
-&gt; where user_id = 0 and log_id = 3;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql&gt; -- 当前账户有2条流水记录
mysql&gt; 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&gt; -- 当前账户余额是200元
mysql&gt; 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&gt; commit;
Query OK, 0 rows affected (0.00 sec)
```
注意这个时候会话A之前开启的事务是一直未关闭的。我们再来会话A中看一下账户的余额你觉得应该是多少
我们来看一下实际的结果。
```
mysql&gt; -- 会话 A
mysql&gt; -- 当前账户有2条流水记录
mysql&gt; 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&gt; -- 当前账户余额是200元
mysql&gt; 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&gt; 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&gt; -- 会话 A
mysql&gt; select log_id from account_log where log_id = 1000;
Empty set (0.00 sec)
```
这时候另外一个会话抢先插入了这条ID为1000的流水记录。
```
mysql&gt; -- 会话 B
mysql&gt; begin;
Query OK, 0 rows affected (0.00 sec)
mysql&gt; insert into account_log values
-&gt; (1000, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
Query OK, 1 row affected (0.00 sec)
mysql&gt; commit;
Query OK, 0 rows affected (0.00 sec)
```
然后会话A再执行相同的插入语句时就会报主键冲突错误但是由于事务的隔离性它执行查询的时候却查不到这条ID为1000的流水就像出现了“幻觉”一样这就是幻读。
```
mysql&gt; -- 会话 A
mysql&gt; insert into account_log values
-&gt; (1000, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
ERROR 1062 (23000): Duplicate entry '1000' for key 'account_log.PRIMARY'
mysql&gt; 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&gt; begin;
Query OK, 0 rows affected (0.00 sec)
mysql&gt; -- 查询当前账户的余额和最后一笔交易的流水号。
mysql&gt; select balance, log_id from account_balance where user_id = 0;
+---------+--------+
| balance | log_id |
+---------+--------+
| 100 | 3 |
+---------+--------+
1 row in set (0.00 sec)
mysql&gt; -- 插入流水记录。
mysql&gt; insert into account_log values
-&gt; (NULL, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
Query OK, 1 row affected (0.01 sec)
mysql&gt; -- 更新余额注意where条件中限定了只有流水号等于之前查询出的流水号3时才更新。
mysql&gt; update account_balance
-&gt; set balance = balance + 100, log_id = LAST_INSERT_ID(), timestamp = NOW()
-&gt; where user_id = 0 and log_id = 3;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql&gt; -- 这里需要检查更新结果只有更新余额成功Changed: 1才提交事务否则回滚事务。
mysql&gt; 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两种不同的隔离级别在行为上有什么不同
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。

View 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分别适用于什么样的业务场景
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。

View 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>
你好,我是李玥。
搜索这个特性可以说是无处不在,现在很少有网站或者系统不提供搜索功能了,所以,即使你不是一个专业做搜索的程序员,也难免会遇到一些搜索相关的需求。搜索这个东西,表面上看功能很简单,就是一个搜索框,输入关键字,然后搜出来想要的内容就好了。
搜索背后的实现可以非常简单简单到什么程度呢我们就用一个SQLLIKE一下就能实现也可以很复杂复杂到什么程度呢不说百度谷歌这种专业做搜索的公司其他非专业做搜索的互联网大厂搜索团队大多是千人规模这里面不仅有程序员还有算法工程师、业务专家等等。二者的区别也仅仅是搜索速度的快慢以及搜出来的内容好坏而已。
今天这节课我们就以电商中的商品搜索作为例子来讲一下如何用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 &quot;localhost:9200/_analyze?pretty&quot; -H 'Content-Type: application/json' -d '{ &quot;analyzer&quot;: &quot;ik_smart&quot;, &quot;text&quot;: &quot;极客时间&quot; }'
{
&quot;tokens&quot; : [
{
&quot;token&quot; : &quot;极&quot;,
&quot;start_offset&quot; : 0,
&quot;end_offset&quot; : 1,
&quot;type&quot; : &quot;CN_CHAR&quot;,
&quot;position&quot; : 0
},
{
&quot;token&quot; : &quot;客&quot;,
&quot;start_offset&quot; : 1,
&quot;end_offset&quot; : 2,
&quot;type&quot; : &quot;CN_CHAR&quot;,
&quot;position&quot; : 1
},
{
&quot;token&quot; : &quot;时间&quot;,
&quot;start_offset&quot; : 2,
&quot;end_offset&quot; : 4,
&quot;type&quot; : &quot;CN_WORD&quot;,
&quot;position&quot; : 2
}
]
}
```
可以看到,这个分词器把“极客时间”分成了“极”、“客”和“时间”,没认出来“极客”这个词,还是有改进空间的。
为了能实现商品搜索我们需要先把商品信息存放到ES中首先我们先定义存放在ES中商品的数据结构也就是MAPPING。
<img src="https://static001.geekbang.org/resource/image/e6/99/e6cadb1ad8311de9772e673161f94999.jpg" alt="">
我们这个MAPPING只要两个字段就够了sku_id就是商品IDtitle保存商品的标题当用户在搜索商品的时候我们在ES中来匹配商品标题返回符合条件商品的sku_id列表。ES默认提供了标准的RESTful接口不需要客户端直接使用HTTP协议就可以访问这里我们使用[curl](https://curl.haxx.se/docs/manpage.html)通过命令行来操作ES。
接下来我们使用上面这个MAPPING创建INDEX类似于MySQL中创建一个表。
```
curl -X PUT &quot;localhost:9200/sku&quot; -H 'Content-Type: application/json' -d '{
&quot;mappings&quot;: {
&quot;properties&quot;: {
&quot;sku_id&quot;: {
&quot;type&quot;: &quot;long&quot;
},
&quot;title&quot;: {
&quot;type&quot;: &quot;text&quot;,
&quot;analyzer&quot;: &quot;ik_max_word&quot;,
&quot;search_analyzer&quot;: &quot;ik_max_word&quot;
}
}
}
}'
{&quot;acknowledged&quot;:true,&quot;shards_acknowledged&quot;:true,&quot;index&quot;:&quot;sku&quot;}
```
这里面使用PUT方法创建一个INDEXINDEX的名称是“sku”直接写在请求的URL中。请求的BODY是一个JSON对象内容就是我们上面定义的MAPPING也就是数据结构。这里面需要注意一下由于我们要在title这个字段上进行全文搜索所以我们把数据类型定义为text并指定使用我们刚刚安装的中文分词插件IK作为这个字段的分词器。
创建好INDEX之后就可以往INDEX中写入商品数据插入数据需要使用HTTP POST方法
```
curl -X POST &quot;localhost:9200/sku/_doc/&quot; -H 'Content-Type: application/json' -d '{
&quot;sku_id&quot;: 100002860826,
&quot;title&quot;: &quot;烟台红富士苹果 5kg 一级铂金大果 单果230g以上 新鲜水果&quot;
}'
{&quot;_index&quot;:&quot;sku&quot;,&quot;_type&quot;:&quot;_doc&quot;,&quot;_id&quot;:&quot;yxQVSHABiy2kuAJG8ilW&quot;,&quot;_version&quot;:1,&quot;result&quot;:&quot;created&quot;,&quot;_shards&quot;:{&quot;total&quot;:2,&quot;successful&quot;:1,&quot;failed&quot;:0},&quot;_seq_no&quot;:0,&quot;_primary_term&quot;:1}
curl -X POST &quot;localhost:9200/sku/_doc/&quot; -H 'Content-Type: application/json' -d '{
&quot;sku_id&quot;: 100000177760,
&quot;title&quot;: &quot;苹果 Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G手机 双卡双待&quot;
}'
{&quot;_index&quot;:&quot;sku&quot;,&quot;_type&quot;:&quot;_doc&quot;,&quot;_id&quot;:&quot;zBQWSHABiy2kuAJGgim1&quot;,&quot;_version&quot;:1,&quot;result&quot;:&quot;created&quot;,&quot;_shards&quot;:{&quot;total&quot;:2,&quot;successful&quot;:1,&quot;failed&quot;:0},&quot;_seq_no&quot;:1,&quot;_primary_term&quot;:1}
```
这里面我们插入了两条商品数据一个烟台红富士一个iPhone手机。然后就可以直接进行商品搜索了搜索使用HTTP GET方法。
```
curl -X GET 'localhost:9200/sku/_search?pretty' -H 'Content-Type: application/json' -d '{
&quot;query&quot; : { &quot;match&quot; : { &quot;title&quot; : &quot;苹果手机&quot; }}
}'
{
&quot;took&quot; : 23,
&quot;timed_out&quot; : false,
&quot;_shards&quot; : {
&quot;total&quot; : 1,
&quot;successful&quot; : 1,
&quot;skipped&quot; : 0,
&quot;failed&quot; : 0
},
&quot;hits&quot; : {
&quot;total&quot; : {
&quot;value&quot; : 2,
&quot;relation&quot; : &quot;eq&quot;
},
&quot;max_score&quot; : 0.8594865,
&quot;hits&quot; : [
{
&quot;_index&quot; : &quot;sku&quot;,
&quot;_type&quot; : &quot;_doc&quot;,
&quot;_id&quot; : &quot;zBQWSHABiy2kuAJGgim1&quot;,
&quot;_score&quot; : 0.8594865,
&quot;_source&quot; : {
&quot;sku_id&quot; : 100000177760,
&quot;title&quot; : &quot;苹果 Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G手机 双卡双待&quot;
}
},
{
&quot;_index&quot; : &quot;sku&quot;,
&quot;_type&quot; : &quot;_doc&quot;,
&quot;_id&quot; : &quot;yxQVSHABiy2kuAJG8ilW&quot;,
&quot;_score&quot; : 0.18577608,
&quot;_source&quot; : {
&quot;sku_id&quot; : 100002860826,
&quot;title&quot; : &quot;烟台红富士苹果 5kg 一级铂金大果 单果230g以上 新鲜水果&quot;
}
}
]
}
}
```
我们先看一下请求中的URL其中的“sku”代表要在sku这个INDEX内进行查找“_search”是一个关键字表示要进行搜索参数pretty表示格式化返回的JSON这样方便阅读。再看一下请求BODY的JSONquery中的match表示要进行全文匹配匹配的字段就是title关键字是“苹果手机”。
可以看到在返回结果中匹配到了2条商品记录和我们在前面讲解倒排索引时预期返回的结果是一致的。
我们来回顾一下使用ES构建商品搜索服务的这个过程首先安装ES并启动服务然后创建一个INDEX定义MAPPING写入数据后执行查询并返回查询结果其实这个过程和我们使用数据库时先建表、插入数据然后查询的过程就是一样的。所以你就把ES当做一个支持全文搜索的数据库来使用就行了。
## 小结
ES本质上是一个支持全文搜索的分布式内存数据库特别适合用于构建搜索系统。ES之所以能有非常好的全文搜索性能最重要的原因就是采用了倒排索引。倒排索引是一种特别为搜索而设计的索引结构倒排索引先对需要索引的字段进行分词然后以分词为索引组成一个查找树这样就把一个全文匹配的查找转换成了对树的查找这是倒排索引能够快速进行搜索的根本原因。
但是倒排索引相比于一般数据库采用的B树索引它的写入和更新性能都比较差因此倒排索引也只是适合全文搜索不适合更新频繁的交易类数据。
## 思考题
我们在电商的搜索框中搜索商品时它都有一个搜索提示的功能比如我输入“苹果”还没有点击搜索按钮的时候搜索框下面会提示“苹果手机”、“苹果11、苹果电脑”这些建议的搜索关键字请你课后看一下ES的文档想一下如何用ES快速地实现这个搜索提示功能
欢迎你在留言区与我讨论,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,176 @@
<audio id="audio" title="07MySQL 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 &gt; test.sql
```
备份出来的文件就是一个SQL文件就是创建数据库、表写入数据等等这些SQL如果要恢复数据直接执行这个备份的SQL文件就可以了
```
$mysql -uroot test &lt; 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&gt; show variables like '%log_bin%';
+---------------------------------+-----------------------------------+
| Variable_name | Value |
+---------------------------------+-----------------------------------+
| log_bin | ON |
| log_bin_basename | /usr/local/var/mysql/binlog |
+---------------------------------+-----------------------------------+
mysql&gt; show master status;
+---------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+---------------+----------+--------------+------------------+-------------------+
| binlog.000001 | 18745 | | | |
+---------------+----------+--------------+------------------+-------------------+
```
可以看到当前这个数据库已经开启了Binloglog_bin_basename表示Binlog文件在服务器磁盘上的具体位置。然后用“show master status”命令可查看当前Binlog的状态显示正在写入的Binlog文件及当前的位置。假设我们每天凌晨用mysqldump做一个全量备份然后开启了Binlog有了这些我们就可以把数据恢复到全量备份之后的任何一个时刻。
下面我们做一个简单的备份恢复演示。我们先模拟一次“删库跑路”的场景,直接把账户余额表清空:
```
mysql&gt; truncate table account_balance;
Query OK, 0 rows affected (0.02 sec)
mysql&gt; select * from account_balance;
Empty set (0.00 sec)
```
然后我们来进行数据恢复,首先执行一次全量恢复,把数据库恢复到今天凌晨的状态。
```
$mysql -uroot test &lt; dump.sql
mysql&gt; select * from account_balance;
+---------+---------+---------------------+--------+
| user_id | balance | timestamp | log_id |
+---------+---------+---------------------+--------+
| 0 | 100 | 2020-02-13 20:24:33 | 3 |
+---------+---------+---------------------+--------+
```
可以看到表里面的数据已经恢复了但还是比较旧的数据。然后我们再用Binlog把数据恢复到删库跑路之前的那个时刻
```
$mysqlbinlog --start-datetime &quot;2020-02-20 00:00:00&quot; --stop-datetime &quot;2020-02-20 15:09:00&quot; /usr/local/var/mysql/binlog.000001 | mysql -uroot
mysql&gt; 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方案在数据可靠性、数据库可用性、性能和成本几个方面各有利弊你需要根据业务情况做一个最优的选择并且为可能存在的风险做好准备。
## 思考题
课后也请你在留言区分享一下,你现在负责系统的数据库是如何来实现高可用的,有什么风险和问题,学习了这节课之后,你会如何来改进这个高可用方案?欢迎你在留言区与我讨论。
感谢阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。