从0到1,从小到大,以电商作为场景,讲解不同规模的存储系统应该如何构建。
在创业篇,我们重点解决从0到1的问题;比如:如何低成本高质量地快速构建一个小规模的订单存储系统。
在高速增长篇,我们关注在高速变化的过程中,你的系统一定会遇到的一些共通问题,以及该如何应对这些问题。比如说,如何从单机的存储系统逐步演进为分布式存储系统;如何在线平滑的扩容我们的存储系统。
在海量数据篇,我们重点解决高并发、海量数据情况下的存储系统该如何设计的问题。比如,海量的埋点数据该怎么存储;如何在各种数据库之前实时迁移和同步海量数据,等等
这个流程涉及到的功能模块有:商品、购物车、订单、支付和库存,这几个模块就是一个电商系统中的核心功能模块。
一个购物流程,从下单开始、支付、发货,直到收货,这么长的一个流程中,每一个环节,都少不了更新订单数据,每一次更新操作又需要同时更新好几张表。这些操作可能被随机分布到很多台服务器上执行,服务器有可能故障,网络有可能出问题。
正确地使用数据库的事务
它包含但远远不限于
我们至少需要有这样几张表
这几个表之间的关系是这样的:订单主表和后面的几个子表都是一对多的关系,关联的外键就是订单主表的主键,也就是订单号。
一个幂等操作的特点是,其任意多次执行所产生的影响均与一次执行的影响相同。
表的主键自带唯一约束,如果我们在一条INSERT语句中提供了主键,并且这个主键的值在表中已经存在,那这条INSERT会执行失败,数据也不会被写入表中
利用数据库的这种“主键唯一约束”特性,在插入数据的时候带上主键,来解决创建订单服务的幂等性问题。
具体的做法是这样的,我们给订单系统增加一个“生成订单号”的服务,这个服务没有参数,返回值就是一个新的、全局唯一的订单号。在用户进入创建订单的页面时,前端页面先调用这个生成订单号服务得到一个订单号,在用户提交订单的时候,在创建订单的请求中带着这个订单号。
如果是因为重复订单导致插入订单表失败,订单服务不要把这个错误返回给前端页面,遇到这种情况,订单服务直接返回订单创建成功就可以了。
对于创建订单服务来说,可以通过预先生成订单号,然后利用数据库中订单号的唯一约束这个特性,避免重复写入订单,实现创建订单服务的幂等性。
给你的订单主表增加一列,列名可以叫version,也即是“版本号”的意思。每次查询订单的时候,版本号需要随着订单数据返回给页面。页面在更新数据的请求中,需要把这个版本号作为更新请求的参数,再带回给订单更新服务。
订单服务在更新数据的时候,需要比较订单当前数据的版本号,是否和消息中的版本号一致,如果不一致就拒绝更新数据。如果版本号一致,还需要再更新数据的同时,把版本号+1。“比较版本号、更新数据和版本号+1”,这个过程必须在同一个事务里面执行。
具体的SQL可以这样来写:
UPDATE orders set tracking_number = 666, version = version + 1 WHERE version = 8;
在这条SQL的WHERE条件中,version的值需要页面在更新的时候通过请求传进来。
对于更新订单服务,可以通过一个版本号机制,每次更新数据前校验版本号,更新数据同时自增版本号,这样的方式,来解决ABA问题,确保更新订单服务的幂等性。
如何设计一个快速、可靠的存储架构支撑商品系统
商品详情页需要保存以下信息,右边灰色的部分,来自于电商的其他系统,我们暂且不去管这些,左边彩色部分,都是商品系统需要存储的内容。
商品基本信息(商品信息)、商品参数、图片视频和商品介绍。
把商品系统需要存储的数据按照特点,分成商品基本信息、商品参数、图片视频和商品介绍
商品的基本信息,它包括商品的主副标题、价格、颜色等一些商品最基本、主要的属性。这些属性都是固定的,不太可能会因为需求或者不同的商品而变化,而且,这部分数据也不会太大。所以,还是建议你在数据库中建一张表来保存商品的基本信息。
Cache Aside:在更新数据库的同时,也要把缓存中的数据给删除掉。
这种缓存更新的策略,称为Cache Aside,是最简单实用的一种缓存更新策略,适用范围也最广泛。如果你要缓存数据,没有什么特殊的情况,首先就应该考虑使用这个策略。除了Cache Aside以外,还有Read/Write Through、Write Behind等几种策略
适合保存像“商品参数”这种,属性不固定的数据,这个数据库就是Mongo DB。
Mongo DB是一个面向文档存储的NoSQL数据库,在Mongo DB中,表、行、列对应的概念分别是:collection、document、field,其实都是一回事儿,为了便于你理解,在这里我们不咬文嚼字,还是用“表、行、列”来说明。
Mongo DB最大的特点就是,它的“表结构”是不需要事先定义的,其实,在Mongo DB中根本没有表结构。由于没有表结构,它支持你把任意数据都放在同一张表里,你甚至可以在一张表里保存商品数据、订单数据、物流信息等这些结构完全不同的数据。并且,还能支持按照数据的某个字段进行查询。
Mongo DB中的每一行数据,在存储层就是简单地被转化成BSON格式后存起来,这个BSON就是一种更紧凑的JSON。所以,即使在同一张表里面,它每一行数据的结构都可以是不一样的。当然,这样的灵活性也是有代价的,Mongo DB不支持SQL,多表联查和复杂事务比较孱弱,不太适合存储一般的数据。
首选的方式就是保存在对象存储(Object Storage)中。各大云厂商都提供对象存储服务,比如国内的七牛云、AWS的S3等等,也有开源的对象存储产品,比如MinIO,可以私有化部署。虽然每个产品的API都不一样,但功能大同小异。
对象存储可以简单理解为一个无限容量的大文件KV存储,它的存储单位是对象,其实就是文件,可以是一张图片,一个视频,也可以是其他任何文件。每个对象都有一个唯一的key,利用这个key就可以随时访问对应的对象。基本的功能就是写入、访问和删除对象。
云服务厂商的对象存储大多都提供了客户端API,可以在Web页面或者App中直接访问而不用通过后端服务来中转。这样,App和页面在上传图片视频的时候,直接保存到对象存储中,然后把对应key保存在商品系统中就可以了。
访问图片视频的时候,真正的图片和视频文件也不需要经过商品系统的后端服务,页面直接通过对象存储提供的URL来访问,又省事儿又节约带宽。而且,几乎所有的对象存储云服务都自带CDN(Content Delivery Network)加速服务,响应时间比直接请求业务的服务器更短
商品介绍在商详页中占得比重是最大的,包含了大量的带格式文字、图片和视频。其中图片和视频自然要存放在对象存储里面,商品介绍的文本,一般都是随着商详页一起静态化,保存在HTML文件中。
商详页的绝大部分内容都是商品介绍,它是不怎么变的。那不如就把这个页面事先生成好,保存成一个静态的HTML,访问商详页的时候,直接返回这个HTML。这就是静态化。
商详页静态化之后,不仅仅是可以节省服务器资源,还可以利用CDN加速,把商详页放到离用户最近的CDN服务器上,让商详页访问更快。
至于商品价格、促销信息等这些需要频繁变动的信息,不能静态化到页面中,可以在前端页面使用AJAX请求商品系统动态获取。
图中实线表示每访问一次商详页,需要真正传输的数据,虚线表示当商详页数据发生变化的时候才需要进行一次数据传输。用户打开一个SKU的商详页时,首先去CDN获取商详页的HTML,然后访问商品系统获取价格等频繁变化的信息,这些信息从Redis缓存中获取。图片和视频信息,也是从对象存储的CDN中获取。
数据量最大的图片、视频和商品介绍都是从离用户最近的CDN服务商获取的,速度快,节约带宽。真正打到商品系统的请求,就是价格这些需要动态获取的商品信息,一般做一次Redis查询就可以了,基本不会有流量打到MySQL中。
这样一个商品系统的存储的架构,把大部分请求都转移到了又便宜速度又快的CDN服务器上,可以用很少量的服务器和带宽资源,抗住大量的并发请求。
实际上,购物车系统需要保存两类购物车,一类是未登录情况下的“暂存购物车”,一类是登录后的“用户购物车”。
保存在客户端好,既可以节约服务器的存储资源,也没有购物车标识的问题,因为每个客户端就保存它自己唯一一个购物车就可以了,不需要标识。
客户端的存储可以选择的不太多:Session、Cookie和Local Storage,其中浏览器的Local Storage和App的本地存储是类似的,我们都以Local Storage来代表。
存在哪儿最合适?SESSION是不太合适的,原因是,SESSION的保留时间短,而且SESSION的数据实际上还是保存在服务端的。剩余的两种存储,Cookie和Local Storage都可以用来保存购物车数据,选择哪种方式更好呢?各有优劣。
使用Cookie和Local Storage最关键的区别是,客户端和服务端的每次交互,都会自动带着Cookie数据往返,这样服务端可以读写客户端Cookie中的数据,而Local Storage里的数据,只能由客户端来访问。
使用Cookie存储,实现起来比较简单,加减购物车、合并购物车的过程中,由于服务端可以读写Cookie,这样全部逻辑都可以在服务端实现,并且客户端和服务端请求的次数也相对少一些。
使用Local Storage存储,实现相对就复杂一点儿,客户端和服务端都要实现一些业务逻辑,但Local Storage的好处是,它的存储容量比Cookie的4KB上限要大得多,而且不用像Cookie那样,无论用不用,每次请求都要带着,可以节省带宽。
比如你设计的是个小型电商,那用Cookie存储实现起来更简单。再比如,你的电商是面那种批发的行业用户,用户需要加购大量的商品,那Cookie可能容量不够用,选择Local Storage就更合适。
使用MySQL和Redis两种存储的优劣势:
更推荐你使用MySQL来存储购物车数据。如果追求性能或者高并发,也可以选择使用Redis。
对不上账的原因非常多,比如业务变化、人为修改了数据、系统之间数据交换失败等等。那作为系统的设计者,我们只关注“如何避免由于技术原因导致的对不上账”就可以了,有哪些是因为技术原因导致的呢?比如说:网络请求错误,服务器宕机、系统Bug等
在设计账户流水时,有几个重要的原则必须遵守,最好是用技术手段加以限制。
在对账的时候,一旦出现了流水和余额不一致,并且无法通过业务手段来确定到底是哪儿记错了的情况,一般的处理原则是以交易流水为准来修正余额数据,这样才能保证后续的交易能“对上账”。
ACID
首先,它可以保证,记录流水和更新余额这两个操作,要么都成功,要么都失败,即使是在数据库宕机、应用程序退出等等这些异常情况下,也不会出现,只更新了一个表而另一个表没更新的情况。这是事务的原子性(Atomic)
事务保证我们读到的数据(交易和流水)总是一致的,这是事务的一致性(Consistency)。
数据库为了实现一致性,必须保证每个事务的执行过程中,中间状态对其他事务是不可见的。比如说我们在事务A中,写入了888这条流水,但是还没有提交事务,那在其他事务中,都不应该读到888这条流水记录。这是事务的隔离性(Isolation)。
只要事务提交成功,数据一定会被持久化到磁盘中,后续即使发生数据库宕机,也不会改变事务的结果。这是事务的持久性(Durability)。
SQL标准的事务隔离级别包括:读未提交(readuncommitted)、读提交(readcommitted)、可重复读(repeatableread)和串行化(serializable)。
在MySQL中默认的隔离级别是RR,可重复读。
跨系统、跨数据库的数据一致性问题,保证多个系统间的数据是一致的
如何用分布式事务的方法,来解决微服务系统中,我们实际面临的分布式数据一致性问题。
在分布式系统中,因为必须兼顾性能和高可用,所以是不可能完全满足ACID的。
分布式事务的解决方案有很多,比如:2PC、3PC、TCC、Saga和本地消息表等等。这些方法,它的强项和弱项都不一样,适用的场景也不一样,所以最好这些分布式事务你都能够掌握,这样才能在面临实际问题的时候选择合适的方法。
这里面,2PC和本地消息表这两种分布式事务的解决方案,比较贴近于我们日常开发的业务系统。
只有在需要强一致、并且并发量不大的场景下,才考虑使用2PC。
协调者:准备,提交
2PC也叫二阶段提交,是一种常用的分布式事务实现方法。
所谓的二阶段指的是准备阶段和提交阶段。在准备阶段,协调者分别给订单系统和促销系统发送“准备”命令,订单和促销系统收到准备命令之后,开始执行准备操作。
比如说订单系统在准备阶段需要完成:
注意,到这里我们没有提交订单数据库的事务,最后给事务协调者返回“准备成功”。类似的,促销服务在准备阶段,需要在促销库开启一个数据库事务,更新优惠券状态,但是暂时不要提交这个数据库事务,给协调者返回“准备成功”。协调者在收到两个系统“准备成功”的响应之后,开始进入第二阶段。
等两个系统都准备好了之后,进入提交阶段。
提交阶段就比较简单了,协调者再给这两个系统发送“提交”命令,每个系统提交自己的数据库事务,然后给协调者返回“提交成功”响应,协调者收到所有响应之后,给客户端返回成功响应,整个分布式事务就结束了。
这个过程的时序图:
异常情况下怎么办
我们还是分两个阶段来说明。在准备阶段,如果任何一步出现错误或者是超时,协调者就会给两个系统发送“回滚事务”请求。每个系统在收到请求之后,回滚自己的数据库事务,分布式事务执行失败,两个系统的数据库事务都回滚了,相关的所有数据回滚到分布式事务执行之前的状态,就像这个分布式事务没有执行一样。
如果准备阶段成功,进入提交阶段,这个时候就“只有华山一条路”,整个分布式事务只能成功,不能失败。
在实现2PC的时候,没必要单独启动一个事务协调服务,这个协调服务的工作最好和订单服务或者优惠券服务放在同一个进程里面,这样做有两个好处:
2PC是一种强一致的设计,它可以保证原子性和隔离性。只要2PC事务完成,订单库和促销库中的数据一定是一致的状态,也就是我们总说的,要么都成功,要么都失败。
所以2PC比较适合那些对数据一致性要求比较高的场景,比如我们这个订单优惠券的场景,如果一致性保证不好,有可能会被黑产利用,一张优惠券反复使用,那样我们的损失就大了。
2PC也有很明显的缺陷,整个事务的执行过程需要阻塞服务端的线程和数据库的会话,所以,2PC在并发场景下的性能不会很高。并且,协调者是一个单点,一旦过程中协调者宕机,就会导致订单库或者促销库的事务会话一直卡在等待提交阶段,直到事务超时自动回滚。
所以,只有在需要强一致、并且并发量不大的场景下,才考虑使用2PC。
分布式最终一致性
执行本地数据库事务时,本地记录一条消息,其他异步服务读取刚才的本地消息。
更多的情况下,只要保证数据最终一致就可以了。比如说,在购物流程中,用户在购物车界面选好商品后,点击“去结算”按钮进入订单页面创建一个新订单。这个过程我们的系统其实做了两件事儿。
这也是一个分布式事务问题,创建订单和清空购物车这两个数据更新操作需要保证,要么都成功,要么都失败。
但是,清空购物车这个操作,它对一致性要求就没有扣减优惠券那么高,订单创建成功后,晚几秒钟再清空购物车,完全是可以接受的。只要保证经过一个小的延迟时间后,最终订单数据和购物车数据保持一致就可以了。
本地消息表非常适合解决这种分布式最终一致性的问题。
订单服务在收到下单请求后,正常使用订单库的事务去更新订单的数据,并且,在执行这个数据库事务过程中,在本地记录一条消息。这个消息就是一个日志,内容就是“清空购物车”这个操作。因为这个日志是记录在本地的,这里面没有分布式的问题,那这就是一个普通的单机事务,那我们就可以让订单库的事务,来保证记录本地消息和订单库的一致性。完成这一步之后,就可以给客户端返回成功响应了。
然后,我们再用一个异步的服务,去读取刚刚记录的清空购物车的本地消息,调用购物车系统的服务清空购物车。购物车清空之后,把本地消息的状态更新成已完成就可以了。异步清空购物车这个过程中,如果操作失败了,可以通过重试来解决。最终,可以保证订单系统和购物车系统它们的数据是一致的。
本地消息表,你可以选择存在订单库中,也可以用文件的形式,保存在订单服务所在服务器的本地磁盘中,两种方式都是可以的,相对来说,放在订单库中更简单一些。
消息队列RocketMQ提供一种事务消息的功能,其实就是本地消息表思想的一个实现。使用事务消息可以达到和本地消息表一样的最终一致性,相比我们自己来实现本地消息表,使用起来更加简单,你也可以考虑使用。
ES本质上是一个支持全文搜索的分布式内存数据库,特别适合用于构建搜索系统。ES之所以能有非常好的全文搜索性能,最重要的原因就是采用了倒排索引。
倒排索引是一种特别为搜索而设计的索引结构,
倒排索引先对需要索引的字段进行分词,然后以分词为索引组成一个查找树,
这样就把一个全文匹配的查找转换成了对树的查找,这是倒排索引能够快速进行搜索的根本原因。
倒排索引相比于一般数据库采用的B树索引,它的写入和更新性能都比较差,因此倒排索引也只是适合全文搜索,不适合更新频繁的交易类数据。
这个表里面的DOCID就是唯一标识一条记录的ID,和数据库里面的主键是类似的。
为了能够支持快速地全文搜索,ES中对于文本采用了一种特殊的索引:倒排索引(InvertedIndex)。
这个倒排索引的表,它是以单词作为索引的Key,然后每个单词的倒排索引的值是一个列表,这个列表的元素就是含有这个单词的商品记录的DOCID。
当我们往ES写入商品记录的时候,ES会先对需要搜索的字段,也就是商品标题进行分词。分词就是把一段连续的文本按照语义拆分成多个单词。然后ES按照单词来给商品记录做索引,就形成了上面那个表一样的倒排索引。
是对上面的倒排索引做了二次查找,整个搜索过程中,我们没有做过任何文本的模糊匹配。
ES的存储引擎存储倒排索引时,肯定不是像我们上面表格中展示那样存成一个二维表,实际上它的物理存储结构和MySQL的InnoDB的索引是差不多的,都是一颗查找树。
INDEX:表
DOCUMENT:行
FIELD:列
MAPPING:表结构
在ES里面,数据的逻辑结构类似于Mongo DB,每条数据称为一个DOCUMENT,简称DOC。DOC就是一个JSON对象,DOC中的每个JSON字段,在ES中称为FIELD,把一组具有相同字段的DOC存放在一起,存放它们的逻辑容器叫INDEX,这些DOC的JSON结构称为MAPPING。这里面最不好理解的就是这个INDEX,它实际上类似于MySQL中表的概念,而不是我们通常理解的用于查找数据的索引。
安装部署都非常简单,具体你可以参照它的官方文档官方文档先把ES安装好
让ES支持中文分词,需要给ES安装一个中文的分词插件IKAnalysisforElasticsearch
为了能实现商品搜索,我们需要先把商品信息存放到ES中,首先我们先定义存放在ES中商品的数据结构,也就是MAPPING。
我们这个MAPPING只要两个字段就够了,sku_id就是商品ID,title保存商品的标题,当用户在搜索商品的时候,我们在ES中来匹配商品标题,返回符合条件商品的sku_id列表。ES默认提供了标准的RESTful接口,不需要客户端,直接使用HTTP协议就可以访问,这里我们使用curl通过命令行来操作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以上 新鲜水果"
}
}
]
}
}
其中的“sku”代表要在sku这个INDEX内进行查找,“_search”是一个关键字,表示要进行搜索,参数pretty表示格式化返回的JSON,这样方便阅读。再看一下请求BODY的JSON,query中的match表示要进行全文匹配,匹配的字段就是title,关键字是“苹果手机”。
存储系统导致的比较严重的损失主要有两种情况,一是数据丢失造成的直接财产损失,比如大量的坏账;二是由于存储系统损坏,造成整个业务系统停止服务而带来的损失。
最简单的备份方式就是全量备份。
备份的时候,把所有的数据复制一份,存放到文件中,恢复的时候再把文件中的数据复制回去,这样可以保证恢复之后数据库中的数据和备份时是完全一样的。
在MySQL中,你可以使用mysql dump命令来执行全量备份。
相比于全量备份,增量备份每次只备份相对于上一次备份变化的那部分数据,所以每次增量备份速度更快。
MySQL自带了Binlog,就是一种实时的增量备份。Binlog里面记录的就是MySQL数据的变更的操作日志,开启Binlog之后,我们对MySQL中的每次更新数据操作,都会被记录到Binlog中。
Binlog是可以回放的,回放Binlog,就相当于把之前对数据库所有数据更新操作按照顺序重新执行了一遍,回放完成之后数据自然就恢复了。这就是Binlog增量备份的基本原理。很多数据库都有类似于MySQLBinlog的日志,原理和Binlog是一样的,备份和恢复方法也是类似的。
通过定期的全量备份redolog,配合Binlog,我们就可以把数据恢复到任意一个时间点详细的命令你可以参考MySQL的官方文档中“备份和恢复”这一章。
在执行备份和恢复的时候,有几个要点你需要特别的注意:
因为回放Binlog的操作是具备幂等性的(为了确保回放幂等,需要设置Binlog的格式为ROW格式)
这个恢复数据的时间是很长的,如果数据量比较大的话,有可能需要恢复几个小时。这几个小时,我们的系统是一直不可用的,这样肯定不行。
因此要提前做恢复
准备一台备用的数据库,把它的数据恢复成主库一样,然后实时地在主备数据库之间来同步Binlog,主库做了一次数据变更,生成一条Binlog,我们就把这一条Binlog复制到备用库并立即回放,这样就可以让备用库里面的数据和主库中的数据一直保持是一样的。一旦主库宕机,就可以立即切换到备用库上继续提供服务。这就是MySQL的高可用方案,也叫MySQL HA。
MySQL自身就提供了主从复制的功能,通过配置就可以让一主一备两台MySQL的数据库保持数据同步,具体的配置方法参考MySQL官方文档中“复制”
接下来我们说这个方案的问题。当我们对主库执行一次更新操作的时候,主从两个数据库更新数据实际的时序是这样的:
也就是说,从库的数据是有可能比主库上的数据旧一些的,这个主从之间复制数据的延迟,称为“主从延迟”。
正常情况下,主从延迟基本都是毫秒级别,你可以认为主从就是实时保持同步的。麻烦的是不正常的情况,一旦主库或者从库繁忙的时候,有可能会出现明显的主从延迟。
如果主库宕机并且主从存在延迟的情况下,切换到从库继续读写,可以保证业务的可用性,但是主从延迟这部分数据就丢失了。
既保证数据不丢,还能做到高可用呢?也是可以的,那你就要牺牲一些性能。MySQL也支持同步复制同步复制,开启同步复制时,MySQL主库会等待数据成功复制到从库之后,再给客户端返回响应。
这种情况下从库宕机了怎么办?本来从库宕机对主库是完全没影响的,因为现在主库要等待从库写入成功再返回,从库宕机,主库就会一直等待从库,主库也卡死了。
这个问题也有解决办法,那就是再加一个从库,把主库配置成:成功复制到任意一个从库就返回,只要有一个从库还活着,就不会影响主库写入数据,这样就解决了从库宕机阻塞主库的问题。如果主库发生宕机,在两个从库中,至少有一个从库中的数据是和主库完全一样的,可以把这个库作为新的主库,继续提供服务。为此你需要付出的代价是,你要至少用三台数据库服务器,并且这三台服务器提供的服务性能,还不如一台服务器高
MySQL这种CPU利用率高的现象,绝大多数情况都是由慢SQL导致的,所以我们优先排查慢SQL。MySQL和各大云厂商提供的RDS都能提供慢SQL日志,分析慢SQL日志,是查找类似问题原因最有效的方法。
慢SQL的日志中,会有这样一些信息:SQL、执行次数、执行时长。
通过分析慢SQL找问题,并没有什么标准的方法,主要还是依靠经验。
首先,你需要知道的一点是,当数据库非常忙的时候,它执行任何一个SQL都很慢。所以,并不是说,慢SQL日志中记录的这些慢SQL都是有问题的SQL。
大部分情况下,导致问题的SQL只是其中的一条或者几条。不能简单地依据执行次数和执行时长进行判断,但是,单次执行时间特别长的SQL,仍然是应该重点排查的对象。
通过分析这个系统的慢SQL日志,首先找到了一个特别慢的SQL。
排行榜,这种排行榜的查询,一定要做缓存
先问自己几个问题:
在使用缓存的时候,还需要特别注意的就是缓存命中率,要尽量避免请求命中不了缓存,穿透到数据库上。
优秀的系统架构,可以在一定程度上,减轻故障对系统的影响。针对这次事故,我给这个系统在架构层面,提了两个改进的建议。
思想是:系统的关键部分要有自我保护机制,避免外部的错误影响到系统的关键部分。
这个脚本每分钟执行一次,检测上一分钟内,有没有执行时间超过一分钟(这个阈值可以根据实际情况调整)的慢SQL,如果发现,直接杀掉这个会话。
这样可以有效地避免一个慢SQL拖垮整个数据库的悲剧。
即使出现慢SQL,数据库也可以在至多1分钟内自动恢复,避免数据库长时间不可用。代价是,可能会有些功能,之前运行是正常的,这个脚本上线后,就会出现问题。
但是,这个代价还是值得付出的,并且,可以反过来督促开发人员更加小心,避免写出慢SQL。
思想是:当关键系统出现故障的时候,要有临时的降级方案,尽量减少故障带来的影响。
只要包含商品搜索栏、大的品类和其他顶级功能模块入口的链接就可以了。
在Nginx上做一个策略,如果请求首页数据超时的时候,直接返回这个静态的首页作为替代。
这样后续即使首页再出现任何的故障,也可以暂时降级,用静态首页替代。至少不会影响到用户使用其他功能。
比如说对数据做适当的隔离,改进缓存置换策略,做数据库主从分离,把非业务请求的数据库查询迁移到单独的从库上等等,只是这些改进都需要对系统做比较大的改动升级,需要从长计议,在系统后续的迭代过程中逐步地去实施。
如果数据库中的数据量就是很多,而且查询业务逻辑就需要遍历大量数据怎么办?
索引可以显著减少查询遍历数据的数量,所以提升SQL查询性能最有效的方式就是,让查询尽可能多的命中索引,但索引也是一把双刃剑,它在提升查询性能的同时,也会降低数据更新的性能。
在MySQL中使用执行计划也非常简单,只要在你的SQL语句前面加上EXPLAIN关键字,然后执行这个查询语句就可以了。
我带你一起来分析一下这两个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在执行的时候无法命中索引呢?
SELECT * FROM user WHERE left(department_code, 5) = ‘00028’;
SELECT * FROM user WHERE department_code LIKE ‘00028%’;
原因是,这个SQL的WHERE条件中对department_code这个列做了一个left截取的计算,对于表中的每一条数据,都得先做截取计算,然后判断截取后的值,所以不得不做全表扫描。你在写SQL的时候,尽量不要在WEHER条件中,对列做任何计算。
数据库的服务端,可以划分为执行器(ExecutionEngine)和存储引擎(StorageEngine)两部分。执行器负责解析SQL执行查询,存储引擎负责保存数据。
几乎所有的数据库,都是由执行器和存储引擎两部分组成,执行器负责执行计算,存储引擎负责保存数据。
数据库收到查询请求后,需要先解析SQL语句,把这一串文本解析成便于程序处理的结构化数据,这就是一个通用的语法解析过程。转换后的结构化数据,就是一棵树,这个树的名字叫抽象语法树(AST,AbstractSyntaxTree)
执行器解析这个AST之后,会生成一个逻辑执行计划。
如何对执行计划进行优化,不同的数据库有不同的优化方法,这一块儿也是不同数据库性能有差距的主要原因之一。优化的总体思路是,在执行计划中,尽早地减少必须处理的数据量。也就是说,尽量在执行计划的最内层减少需要处理的数据量。
在InnoDB中,数据表的物理存储结构是以主键为关键字的B+树,每一行数据直接就保存在B+树的叶子节点上。
在InnoDB中,表的索引也是以B+树的方式来存储的,和存储数据的B+树的区别是,在索引树中,叶子节点保存的不是行数据,而是行的主键值。
如果通过索引来检索一条记录,需要先后查询索引树和数据树这两棵树:先在索引树中检索到行记录的主键值,然后再用主键值去数据树中去查找这一行数据。
为什么主键不能太长,因为表的每个索引保存的都是主键的值,过长的主键会导致每一个索引都很大。再
有的时候明明有索引却不能命中的原因是,数据库在对物理执行计划优化的时候,评估发现不走索引,直接全表扫描是更优的选择。
Redis作为MySQL的前置缓存,可以替MySQL挡住绝大部分查询请求,很大程度上缓解了MySQL并发请求的压力。
Redis是一个使用内存保存数据的高性能KV数据库,它的高性能主要来自于:
数据库可以分为执行器和存储引擎两部分,Redis的执行器这一层非常的薄,所以Redis只能支持有限的几个API,几乎没有聚合查询的能力,也不支持SQL。它的存储引擎也非常简单,直接在内存中用最简单的数据结构来保存数据,你从它的API中的数据类型基本就可以猜出存储引擎中数据结构。
Redis的LIST在存储引擎的内存中的数据结构就是一个双向链表。
内存是一种易失性存储,所以使用内存保存数据的Redis不能保证数据可靠存储。从设计上来说,Redis牺牲了大部分功能,牺牲了数据可靠性,换取了高性能。但也正是这些特性,使得Redis特别适合用来做MySQL的前置缓存。
Redis支持将数据持久化到磁盘中,并且还支持主从复制,但你需要知道,Redis仍然是一个不可靠的存储,它在设计上天然就不保证数据的可靠性,所以一般我们都使用Redis做缓存,很少使用它作为唯一的数据存储。
如何正确地使用Redis做缓存。在缓存MySQL的一张表的时候,通常直接选用主键来作为Redis中的Key,比如缓存订单表,那就直接用订单表的主键订单号来作为Redis中的key。
Redis的实例不是给订单表专用的,还需要给订单的Key加一个统一的前缀,比如“orders:888888”。Value用来保存序列化后的整条订单记录,你可以选择可读性比较好的JSON作为序列化方式,也可以选择性能更好并且更节省内存的二进制序列化方式,都是可以的。
在查询订单数据的时候,先去缓存中查询,如果命中缓存那就直接返回订单数据。如果没有命中,那就去数据库中查询,得到查询结果之后把订单数据写入缓存,然后返回。
在更新订单数据的时候,先去更新数据库中的订单表,如果更新成功,再去更新缓存中的数据。Read/WriteThrough。
**Cache Aside模式在更新数据的时候,并不去尝试更新缓存,而是去删除缓存。**订单服务收到更新数据请求之后,先更新数据库,如果更新成功了,再尝试去删除缓存中订单,如果缓存中存在这条订单就删除它,如果不存在就什么都不做,然后返回更新成功。
如果我们的缓存命中率比较低,就会出现大量“缓存穿透”的情况。缓存穿透指的是,在读数据的时候,没有命中缓存,请求“穿透”了缓存,直接访问后端数据库的情况。
少量的缓存穿透是正常的,我们需要预防的是,短时间内大量的请求无法命中缓存,请求穿透到数据库,导致数据库繁忙,请求超时。大量的请求超时还会引发更多的重试请求,更多的重试请求让数据库更加繁忙,这样恶性循环导致系统雪崩
系统刚上线:
从数据库中读取数据的时间比较长:
构建缓存数据需要的查询时间太长,或者并发量特别大的时候,Cache Aside或者是Read/WriteThrough这两种缓存模式都可能出现大量缓存穿透。
对于这种情况,针对业务场景来选择合适解决方案。比如说,可以牺牲缓存的时效性和利用率,缓存所有的数据,放弃ReadThrough策略所有的请求,只读缓存不读数据库,用后台线程来定时更新缓存数据。
缓存的命中率就没有那么高,还是有相当一部分查询请求因为命中不了缓存,打到MySQL上。
那随着系统用户数量越来越多,打到MySQL上的读写请求也越来越多,当单台MySQL支撑不了这么多的并发请求时,我们该怎么办?
当单台MySQL无法满足要求的时候,只能用多个MySQL实例来承担大量的读写请求。MySQL和大部分常用的关系型数据库一样,都是典型的单机数据库,不支持分布式部署。
一个简单而且非常有效的方案是,我们不对数据分片,而是使用多个具有相同数据的MySQL实例来分担大量的查询请求,这种方法通常称为“读写分离”。
实现分布式读就相对简单很多,我只需要增加一些只读的实例,只要能够把数据实时的同步到这些只读实例上,保证这这些只读实例上的数据都随时一样,这些只读的实例就可以分担大量的查询请求。
通过读写分离这样一个简单的存储架构升级,就可以让数据库支持的并发数量增加几倍到十几倍。所以,当你的系统用户数越来越多,读写分离应该是你首先要考虑的扩容方案。
下图是一个典型的读写分离架构:
主库负责执行应用程序发来的所有数据更新请求,然后异步将数据变更实时同步到所有的从库中去,这样,主库和所有从库中的数据是完全一样的。多个从库共同分担应用的查询请求。
如何来实施MySQL的读写分离方案。你需要做两件事儿:
MySQL自带主从同步的功能,经过简单的配置就可以实现一个主库和几个从库之间的数据同步,部署和配置的方法,你看MySQL的官方文档照着做就可以。
分离应用程序的读写请求方法有下面这三种:
最推荐的是第二种,使用读写分离组件。这种方式代码侵入非常少,并且兼顾了性能和稳定性。
一般情况下,不推荐使用第三种代理的方式,原因是,使用代理加长了你的系统运行时数据库请求的调用链路,有一定的性能损失,并且代理服务本身也可能出现故障和性能瓶颈等问题。但是,代理方式有一个好处是,它对应用程序是完全透明的。所以,只有在不方便修改应用程序代码这一种情况下,你才需要采用代理方式。
如果你配置了多个从库,推荐你使用**“HAProxy+Keepalived”这对儿经典的组合,来给所有的从节点做一个高可用负载均衡方案**,既可以避免某个从节点宕机导致业务可用率降低,也方便你后续随时扩容从库的实例数量。因为HAProxy可以做L4层代理,也就是说它转发的是TCP请求,所以用“HAProxy+Keepalived”代理MySQL请求,在部署和配置上也没什么特殊的地方,正常配置和部署就可以了。
主从同步延迟
你看大的电商,它支付完成后是不会自动跳回到订单页的,它增加了一个无关紧要的“支付完成”页面,其实这个页面没有任何有效的信息,就是告诉你支付成功,然后再放一些广告什么的。你如果想再看刚刚支付完成的订单,需要手动点一下,这样就很好地规避了主从同步延迟的问题。
需要特别注意的,是那些数据更新后,立刻需要查询更新后的数据,然后再更新其他数据这种情况。
你可以把“更新购物车、重新计算总价”这两个步骤合并成一个微服务,然后放在一个数据库事务中去,同一个事务中的查询操作也会被路由到主库,这样来规避主从不一致的问题。
对于这种主从延迟带来的数据不一致的问题,没有什么简单方便而且通用的技术方案可以解决,我们需要重新设计业务逻辑,尽量规避更新数据后立即去从库查询刚刚更新的数据
当客户端提交一个事务到MySQL的集群,直到客户端收到集群返回成功响应,在这个过程中,MySQL集群需要执行很多操作:主库需要提交事务、更新存储引擎中的数据、把Binlog写到磁盘上、给客户端返回响应、把Binlog复制到所有从库上、每个从库需要把复制过来的Binlog写到暂存日志中、回放这个Binlog、更新存储引擎中的数据、给主库返回复制成功的响应。
这些操作的时序非常重要,这里面的“时序”,说的就是这些操作的先后顺序。
如果先复制Binlog,等Binlog复制到从节点上之后,主节点再去提交事务,这种情况下,从节点的Binlog一直和主节点是同步的,任何情况下主节点宕机也不会丢数据。但如果把这个时序倒过来,先提交事务再复制Binlog,性能就会非常好,但是存在丢数据的风险。
MySQL提供了几个参数来配置这个时序,我们先看一下默认情况下的时序是什么样的。
默认情况下,MySQL采用异步复制的方式,执行事务操作的线程不会等复制Binlog的线程。具体的时序你可以看下面这个图
MySQL主库在收到客户端提交事务的请求之后,会先写入Binlog,然后再提交事务,更新存储引擎中的数据,事务提交完成后,给客户端返回操作成功的响应。同时,从库会有一个专门的复制线程,从主库接收Binlog,然后把Binlog写到一个中继日志里面,再给主库返回复制成功的响应。
从库还有另外一个回放Binlog的线程,去读中继日志,然后回放Binlog更新存储引擎中的数据,这个过程和我们今天讨论的主从复制关系不大,所以我并没有在图中画出来。提交事务和复制这两个流程在不同的线程中执行,互相不会等待,这是异步复制。
在异步复制的情况下,为什么主库宕机存在丢数据的风险?为什么读写分离存在读到脏数据的问题?产生这些问题,都是因为异步复制它没有办法保证数据能第一时间复制到从库上。
与异步复制相对的就是同步复制。同步复制的时序和异步复制基本是一样的,唯一的区别是,什么时候给客户端返回响应。异步复制时,主库提交事务之后,就会给客户端返回响应;而同步复制时,主库在提交事务的时候,会等待数据复制到所有从库之后,再给客户端返回响应。
同步复制这种方式在实际项目中,基本上没法用,原因有两个:一是性能很差,因为要复制到所有节点才返回响应;二是可用性也很差,主库和所有从库任何一个数据库出问题,都会影响业务。
为了解决同步复制导致的问题,MySQL从5.7版本开始,增加一种半同步复制(SemisynchronousReplication)的方式。
异步复制是,事务线程完全不等复制响应;
同步复制是,事务线程要等待所有的复制响应;
半同步复制介于二者之间,事务线程不用等着所有的复制成功响应,只要一部分复制响应回来之后,就可以给客户端返回了。
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特有的。
之前我们讲过的Elasticsearch,它是一个内存数据库,读写都在内存中,那它是怎么保证数据可靠性的呢?对,它用的是translog,它备份和恢复数据的原理和实现方式也是完全一样的。这些什么什么log,都是不同的马甲儿而已。
几乎所有的存储系统和数据库,都是用这一套方法来解决备份恢复和数据复制问题的。
这一套方法其实是有理论基础的,叫做复制状态机(Replication State Machine)。
无论它存储的是什么数据,用什么样的数据结构,都可以抽象成一个状态机。
存储系统中的数据称为状态(也就是MySQL中的数据),状态的全量备份称为快照(Snapshot),就像给数据拍个照片一样。我们按照顺序记录更新存储系统的每条操作命令,就是操作日志(Commit Log,也就是MySQL中的Binlog)。你可以对照下面这张图来理解上面这些抽象的概念
复制数据的时候,只要基于一个快照,按照顺序执行快照之后的所有操作日志,就可以得到一个完全一样的状态。在从节点持续地从主节点上复制操作日志并执行,就可以让从节点上的状态数据和主节点保持同步。
主从同步做数据复制时,一般可以采用几种复制策略。性能最好的方法是异步复制,主节点上先记录操作日志,再更新状态数据,然后异步把操作日志复制到所有从节点上,并在从节点执行操作日志,得到和主节点相同的状态数据。
异步复制的劣势是,可能存在主从延迟,如果主节点宕机,可能会丢数据。另外一种常用的策略是半同步复制,主节点等待操作日志最少成功复制到N个从节点上之后,再更新状态,这种方式在性能、高可用和数据可靠性几个方面都比较平衡,很多分布式存储系统默认采用的都是这种方式。
无论是什么样的存储系统,一次查询所耗费的时间,都取决于两个因素:
查找的时间复杂度又取决于两个因素:
MySQL的InnoDB存储引擎,它的存储结构是B+树,查找算法大多就是树的查找,查找的时间复杂度就是O(logn),这些都是固定的。那我们唯一能改变的,就是数据总量了。
时间类型的:归档
这类具有时间属性的数据,会随时间累积,数据量越来越多,为了提升查询性能需要对数据进行拆分,首选的拆分方法是把旧数据归档到历史表中去。
当单表的订单数据太多,多到影响性能的时候,首选的方案是,归档历史订单。
所谓归档,其实也是一种拆分数据的策略。简单地说,就是把大量的历史订单移到另外一张历史订单表中。为什么这么做呢?因为像订单这类具有时间属性的数据,都存在热尾效应。大多数情况下访问的都是最近的数据,但订单表里面大量的数据都是不怎么常用的老数据。
因为新数据只占数据总量中很少的一部分,所以把新老数据分开之后,新数据的数据量就会少很多,查询速度也就会快很多。老数据虽然和之前比起来没少多少,查询速度提升不明显,但是,因为老数据访很少会被访问到,所以慢一点儿也问题不大。
拆分订单时,需要改动的代码非常少。
基本上只有查询统计类的功能,会查到历史订单,这些需要稍微做一些调整,按照时间,选择去订单表还是历史订单表查询就可以了。很多电商大厂在它逐步发展壮大的过程中,都用这种订单拆分的方案撑了好多年。你可能还有印象,几年前你在京东、淘宝查自己的订单时,都有一个查“三个月前订单”的选项,其实就是查订单历史表。
归档历史订单,大致的流程是这样的:
类似于订单商品表这类订单的相关的子表,也是需要按照同样的方式归档到各自的历史表中,由于它们都是用订单ID作为外键来关联到订单主表的,随着订单主表中的订单一起归档就可以了。
这个过程中,我们要注意的问题是,要做到对线上业务的影响尽量的小。迁移这么大量的数据,或多或少都会影响数据库的性能,你应该尽量放在闲时去迁移,迁移之前一定做好备份,这样如果不小心误操作了,也能用备份来恢复。
例子:
如何从订单表中删除已经迁走的历史订单数据?
其实每次都排序是没必要的,所以我们可以先通过一次查询,找到符合条件的历史订单中最大的那个订单ID,然后在删除语句中把删除的条件转换成按主键删除。
这样每次删除的时候,由于条件变成了主键比较,我们知道在MySQL的InnoDB存储引擎中,表数据结构就是按照主键组织的一颗B+树,而B+树本身就是有序的,所以不仅查找非常快,也不需要再进行额外的排序操作了。当然这样做的前提条件是订单ID必须和订单时间正相关才行,大多数订单ID的生成规则都可以满足这个条件,所以问题不大。
为什么在删除语句中非得加一个排序呢?因为按ID排序后,我们每批删除的记录,基本都是ID连续的一批记录,由于B+树的有序性,这些ID相近的记录,在磁盘的物理文件上,大致也是放在一起的,这样删除效率会比较高,也便于MySQL回收页。
虽然逻辑上每个表是一颗B+树,但是物理上,每条记录都是存放在磁盘文件中的,这些记录通过一些位置指针来组织成一颗B+树。
当MySQL删除一条记录的时候,只能是找到记录所在的文件中位置,然后把文件的这块区域标记为空闲,然后再修改B+树中相关的一些指针,完成删除。其实那条被删除的记录还是躺在那个文件的那个位置,所以并不会释放磁盘空间。
如何释放?——重建表OPTIMIZETABLE会导致锁表
如果说我们数据库的磁盘空间很紧张,非要把这部分磁盘空间释放出来,可以执行一次OPTIMIZETABLE释放存储空间。对于InnoDB来说,执行OPTIMIZETABLE实际上就是把这个表重建一遍,执行过程中会一直锁表,也就是说这个时候下单都会被卡住,这个是需要注意的。另外,这么优化有个前提条件,MySQL的配置必须是每个表独立一个表空间(innodb_file_per_table=ON),如果所有表都是放在一起的,执行OPTIMIZETABLE也不会释放磁盘空间。
重建表的过程中,索引也会重建,这样表数据和索引数据都会更紧凑,不仅占用磁盘空间更小,查询效率也会有提升。那对于频繁插入删除大量数据的这种表,如果能接受锁表,定期执行OPTIMIZETABLE是非常有必要的。
如果说,我们的系统可以接受暂时停服,最快的方法是这样的:
insertinto[orders_temp临时表]
select*fromorders
wheretimestamp>=SUBDATE(CURDATE(),INTERVAL3month)
这样,相当于我们手工把订单表重建了一次,但是,不需要漫长的删除历史订单的过程了。我把执行过程的SQL放在下面供你参考:
在迁移历史数据过程中,如果可以停服,最快的方式是重建一张新的订单表,然后把三个月内的订单数据复制到新订单表中,再通过修改表名让新的订单表生效。如果只能在线迁移,那需要分批迭代删除历史订单数据,删除的时候注意控制删除节奏,避免给线上数据库造成太大压力。
最后,我要再一次提醒你,线上数据操作非常危险,在操作之前一定要做好数据备份。
解决海量数据导致存储系统慢的问题,思想非常简单,就是一个“拆”字,把一大坨数据拆分成N个小坨,学名叫“分片(Shard)”。拆开之后,每个分片里的数据就没那么多了,然后让查找尽量落在某一个分片上,这样来提升查找性能。
首先需要思考的问题是,分库还是分表?分库呢,就是把数据拆分到不同的MySQL库中去,分表就是把数据拆分到同一个库的多张表里面。
那就是能不拆就不拆,能少拆不多拆。
什么情况下适合分表,什么情况下不得不分库?
第一,是数据量太大查询慢的问题。这里面我们讲的“查询”其实主要是事务中的查询和更新操作,因为只读的查询可以通过缓存和主从分离来解决,这个我们在之前的“MySQL如何应对高并发”的两节课中都讲过。那我们上节课也讲到过,解决查询慢,只要减少每次查询的数据总量就可以了,也就是说,分表就可以解决问题。
第二,是为了应对高并发的问题。应对高并发的思想我们之前也说过,一个数据库实例撑不住,就把并发请求分散到多个实例中去,所以,解决高并发的问题是需要分库的。
数据量大,就分表;并发高,就分库。
分库分表还有一个重要的问题是,**选择一个合适的列或者说是属性,作为分表的依据,这个属性一般称为ShardingKey。**像我们上节课讲到的归档历史订单的方法,它的ShardingKey就是订单完成时间。每次查询的时候,查询条件中必须带上这个时间,我们的程序就知道,三个月以前的数据查订单历史表,三个月内的数据查订单表,这就是一个简单的按照时间范围来分片的算法。
选择合适ShardingKey和分片算法非常重要,直接影响了分库分表的效果。我们首先来说如何选择ShardingKey的问题。
选择这个ShardingKey最重要的参考因素是,我们的业务是如何访问数据的。
比如我们把订单ID作为ShardingKey来拆分订单表,那拆分之后,如果我们按照订单ID来查订单,就需要先根据订单ID和分片算法计算出,我要查的这个订单它在哪个分片上,也就是哪个库哪张表中,然后再去那个分片执行查询就可以了。
但是,当我打开“我的订单”这个页面的时候,它的查询条件是用户ID,这里没有订单ID,那就没法知道我们要查的订单在哪个分片上,就没法查了。当然你要强行查的话,那就只能把所有分片都查一遍,再合并查询结果,这个就很麻烦,而且性能很差,还不能分页。
那要是把用户ID作为ShardingKey呢?也会面临同样的问题,使用订单ID作为查询条件来查订单的时候,就没办法找到订单在哪个分片了。
这个问题的解决办法是,在生成订单ID的时候,把用户ID的后几位作为订单ID的一部分,比如说,可以规定,18位订单号中,第10-14位是用户ID的后四位,这样按订单ID查询的时候,就可以根据订单ID中的用户ID找到分片
那我们系统对订单的查询方式,肯定不只是按订单ID或者按用户ID这两种啊。比如说,商家希望看到的是自己店铺的订单,还有各种和订单相关的报表。对于这些查询需求,我们一旦对订单做了分库分表,就没法解决了。那怎么办呢?
一般的做法是,把订单数据同步到其他的存储系统中去,在其他的存储系统里面解决问题。比如说,我们可以再构建一个以店铺ID作为ShardingKey的只读订单库,专门供商家来使用。或者,把订单数据同步到HDFS中,然后用一些大数据技术来生成订单相关的报表。
分库分表一定是,数据量和并发大到所有招数都不好使了,我们才拿出来的最后一招。
“热点问题”
并发请求和数据能均匀地分布到每一个分片上,尽量避免出现热点。这是选择分片算法时需要考虑的一个重要的因素。一般常用的分片算法就那么几种,刚刚讲到的按照时间范围分片的方法是其中的一种。
基于范围来分片容易产生热点问题,不适合作为订单的分片方法,但是这种分片方法的优点也很突出,那就是对查询非常友好,基本上只要加上一个时间范围的查询条件,原来该怎么查,分片之后还可以怎么查。范围分片特别适合那种数据量非常大,但并发访问量不大的ToB系统。比如说,电信运营商的监控系统,它可能要采集所有人手机的信号质量,然后做一些分析,这个数据量非常大,但是这个系统的使用者是运营商的工作人员,并发量很少。这种情况下就很适合范围分片。
一般来说,订单表都采用更均匀的哈希分片算法。
比如说,我们要分24个分片,选定了ShardingKey是用户ID,那我们决定某个用户的订单应该落到那个分片上的算法是,拿用户ID除以24,得到的余数就是分片号。这是最简单的取模算法,一般就可以满足大部分要求了。当然也有一些更复杂的哈希算法,像一致性哈希之类的,特殊情况下也可以使用。
需要注意的一点是,哈希分片算法能够分得足够均匀的前提条件是,用户ID后几位数字必须是均匀分布的。
还有一种分片的方法:查表法。查表法其实就是没有分片算法,决定某个ShardingKey落在哪个分片上,全靠人为来分配,分配的结果记录在一张表里面。每次执行查询的时候,先去表里查一下要找的数据在哪个分片中。
查表法的好处就是灵活,怎么分都可以,你用上面两种分片算法都没法分均匀的情况下,就可以用查表法,人为地来把数据分均匀了。查表法还有一个特好的地方是,它的分片是可以随时改变的。比如我发现某个分片已经是热点了,那我可以把这个分片再拆成几个分片,或者把这个分片的数据移到其他分片中去,然后修改一下分片映射表,就可以在线完成数据拆分了
但你需要注意的是,分片映射表本身的数据不能太多,否则这个表反而成为热点和性能瓶颈了。查表法相对其他两种分片算法来说,缺点是需要二次查询,实现起来更复杂,性能上也稍微慢一些。但是,分片映射表可以通过缓存来加速查询,实际性能并不会慢很多。
之前连续几节课,我们都在以MySQL为例子,讲如何应对海量数据,如何应对高并发,如何实现高可用,我先带你简单复习一下。
这些方法不仅仅是MySQL特有的,对于几乎所有的存储系统,都是适用的。
如何构建一个生产系统可用的Redis缓存集群。你将会看到,几种集群解决方案,它们用到的思想,基本和我们上面讲的都是一样的。
Redis从3.0版本开始,提供了官方的集群支持,也就是RedisCluser。
Redis Cluster相比于单个节点的Redis,能保存更多的数据,支持更多的并发,并且可以做到高可用,在单个节点故障的情况下,继续提供服务。
为了能够保存更多的数据,和MySQL分库分表的方式类似,Redis Cluster也是通过分片的方式,把数据分布到集群的多个节点上。
这几种集群方案对一些类似于“KEYS”这类的多KEY命令,都没法做到百分百支持。原因很简单,数据被分片了之后,这种多KEY的命令很可能需要跨多个分片查询。当你的系统从单个Redis库升级到集群时,可能需要考虑一下这方面的兼容性问题。
Redis Cluster是如何来分片的呢?
它引入了一个“槽(Slot)”的概念,这个槽就是哈希表中的哈希槽,槽是Redis分片的基本单位,每个槽里面包含一些Key。每个集群的槽数是固定的16384(16*1024)个,每个Key落在哪个槽中也是固定的,计算方法是:
HASH_SLOT=CRC16(key)mod16384
这个算法很简单,先计算Key的CRC值,然后把这个CRC之后的Key值直接除以16384,余数就是Key所在的槽。这个算法就是我们上节课讲过的哈希分片算法
这个映射关系保存在集群的每个Redis节点上,集群初始化的时候,Redis会自动平均分配这16384个槽,也可以通过命令来调整。这个分槽的方法,也是我们上节课讲到过的分片算法:查表法。
客户端可以连接集群的任意一个节点来访问集群的数据,当客户端请求一个Key的时候,被请求的那个Redis实例先通过上面的公式,计算出这个Key在哪个槽中,然后再查询槽和节点的映射关系,找到数据所在的真正节点,如果这个节点正好是自己,那就直接执行命令返回结果。如果数据不在当前这个节点上,那就给客户端返回一个重定向的命令,告诉客户端,应该去连哪个节点上请求这个Key的数据。然后客户端会再连接正确的节点来访问。
解决分片问题之后,Redis Cluster就可以通过水平扩容来增加集群的存储容量,但是,每次往集群增加节点的时候,需要从集群的那些老节点中,搬运一些槽到新节点,你可以手动指定哪些槽迁移到新节点上,也可以利用官方提供的redis-trib.rb脚本来自动重新分配槽,自动迁移。
分片可以解决Redis保存海量数据的问题,并且客观上提升了Redis的并发能力和查询性能。但是并不能解决高可用的问题,每个节点都保存了整个集群数据的一个子集,任何一个节点宕机,都会导致这个宕机节点上的那部分数据无法访问。
增加从节点,做主从复制
Redis Cluster支持给每个分片增加一个或多个从节点,每个从节点在连接到主节点上之后,会先给主节点发送一个SYNC命令,请求一次全量复制,也就是把主节点上全部的数据都复制到从节点上。
全量复制完成之后,进入同步阶段,主节点会把刚刚全量复制期间收到的命令,以及后续收到的命令持续地转发给从节点。
因为Redis不支持事务,所以它的复制相比MySQL更简单,连Binlog都省了,直接就是转发客户端发来的更新数据命令来实现主从同步。
如果某个分片的主节点宕机了,集群中的其他节点会在这个分片的从节点中选出一个新的节点作为主节点继续提供服务。新的主节点选举出来后,集群中的所有节点都会感知到,这样,如果客户端的请求Key落在故障分片上,就会被重定向到新的主节点上。
一般来说,Redis Cluster进行了分片之后,每个分片都会承接一部分并发的请求,加上Redis本身单节点的性能就非常高,所以大部分情况下不需要再像MySQL那样做读写分离来解决高并发的问题。默认情况下,集群的读写请求都是由主节点负责的,从节点只是起一个热备的作用。当然了,Redis Cluster也支持读写分离,在从节点上读取数据。
以上就是Redis Cluster的基本原理,你可以对照下图来加深理解。
Redis Cluster整体的架构完全就是照抄MySQL构建集群的那一套东西
具体如何搭建Redis Cluster以及相关的操作命令你可以看一下Redis官方的这篇教程。
为什么Redis Cluster不适合超大规模集群?
Redis Cluster的优点是易于使用。分片、主从复制、弹性扩容这些功能都可以做到自动化,通过简单的部署就可以获得一个大容量、高可靠、高可用的Redis集群,并且对于应用来说,近乎于是透明的。
所以,Redis Cluster是非常适合构建中小规模Redis集群,这里的中小规模指的是,大概几个到几十个节点这样规模的Redis集群。
但是Redis Cluster不太适合构建超大规模集群,主要原因是,它采用了去中心化的设计。刚刚我们讲了,**Redis的每个节点上,都保存了所有槽和节点的映射关系表,客户端可以访问任意一个节点,再通过重定向命令,找到数据所在的那个节点。**那你有没有想过一个问题,这个映射关系表,它是如何更新的呢?比如说,集群加入了新节点,或者某个主节点宕机了,新的主节点被选举出来,这些情况下,都需要更新集群每一个节点上的映射关系表。
Redis Cluster采用了一种去中心化的流言(Gossip)协议来传播集群配置的变化。一般涉及到协议都比较复杂,这里我们不去深究具体协议和实现算法,我大概给你讲一下这个协议原理。
这个八卦协议它的好处是去中心化,传八卦不需要组织,吃瓜群众自发就传开了。这样部署和维护就更简单,也能避免中心节点的单点故障。八卦协议的缺点就是传播速度慢,并且是集群规模越大,传播的越慢。这个也很好理解,比如说,换成某两个特别出名的明星搞对象,即使是全国人民都很八卦,但要想让全国每一个人都知道这个消息,还是需要很长的时间。在集群规模太大的情况下,数据不同步的问题会被明显放大,还有一定的不确定性,如果出现问题很难排查。
Redis Cluster不太适合用于大规模集群,所以很多大厂,都选择自己去搭建Redis集群。这里面,每一家的解决方案都有自己的特色,但其实总体的架构都是大同小异的。
一种是基于代理的方式,在客户端和Redis节点之间,还需要增加一层代理服务。这个代理服务有三个作用。
像开源的Redis集群方案twemproxy和Codis,都是这种架构的。
这个架构最大的优点是对客户端透明,在客户端视角来看,整个集群和一个超大容量的单节点Redis是一样的。并且,由于分片算法是代理服务控制的,扩容也比较方便,新节点加入集群后,直接修改代理服务中的元数据就可以完成扩容。
缺点也很突出,增加了一层代理转发,每次数据访问的链路更长了,必然会带来一定的性能损失。而且,代理服务本身又是集群的一个单点,当然,我们可以把代理服务也做成一个集群来解决单点问题,那样集群就更复杂了。
另外一种方式是,不用这个代理服务,把代理服务的寻址功能前移到客户端中去。客户端在发起请求之前,先去查询元数据,就可以知道要访问的是哪个分片和哪个节点,然后直连对应的Redis节点访问数据。
客户端不用每次都去查询元数据,因为这个元数据是不怎么变化的,客户端可以自己缓存元数据,这样访问性能基本上和单机版的Redis是一样的。如果某个分片的主节点宕机了,新的主节点被选举出来之后,更新元数据里面的信息。对集群的扩容操作也比较简单,除了迁移数据的工作必须要做以外,更新一下元数据就可以了。
这个元数据服务仍然是一个单点,但是它的数据量不大,访问量也不大,相对就比较容易实现。
我们可以用ZooKeeper、etcd甚至MySQL都能满足要求。这个方案应该是最适合超大规模Redis集群的方案了,在性能、弹性、高可用几方面表现都非常好,缺点是整个架构比较复杂,客户端不能通用,需要开发定制化的Redis客户端,只有规模足够大的企业才负担得起。
Read/WriteThrough和Cache Aside这几种更新缓存的策略,这几种策略都存在缓存穿透的可能,如果缓存没有命中,那就穿透缓存去访问数据库获取数据。
一般情况下,只要我们做好缓存预热,这个缓存的命中率很高,能穿透缓存打到数据库上的请求比例就非常低,这些缓存的策略都是没问题的。但是如果说,我们的Redis缓存服务的是一个超大规模的系统,那就又不一样了。
超大规模系统中缓存会面临什么样的问题,以及应该使用什么样的缓存更新策略。
在处理超大规模并发的场景时,由于并发请求的数量非常大,即使少量的缓存穿透,也有可能打死数据库引发雪崩效应。对于这种情况,我们可以缓存全量数据来彻底避免缓存穿透问题。
对于缓存数据更新的方法,可以订阅数据更新的MQ消息来异步更新缓存,
更通用的方法是,把缓存更新服务伪装成一个MySQL的从节点,订阅MySQL的Binlog,通过Binlog来更新Redis缓存。
需要特别注意的是,无论是用MQ还是Canal来异步更新缓存,对整个更新服务的数据可靠性和实时性要求都比较高,数据丢失或者更新慢了,都会造成Redis中的数据与MySQL中数据不同步。在把这套方案应用到生产环境中去的时候,需要考虑一旦出现不同步问题时的降级或补偿方案。
我们上节课讲到了如何构建Redis集群,由于集群可以水平扩容,那只要集群足够大,理论上支持海量并发也不是问题。
但是,因为并发请求的数量这个基数太大了,即使有很小比率的请求穿透缓存,打到数据库上请求的绝对数量仍然不小。
加上大促期间的流量峰值,还是存在缓存穿透引发雪崩的风险。
解决办法:不让请求穿透缓存不就行了?反正现在存储也便宜,只要你买得起足够多的服务器,Redis集群的容量就是无限的。不如把全量的数据都放在Redis集群里面,处理读请求的时候,干脆只读Redis,不去读数据库。这样就完全没有“缓存穿透”的风险了,实际上很多大厂它就是这么干的。
怎么保证Redis中的数据和数据库中的数据同步更新?我们之前讲过用分布式事务来解决数据一致性的问题,但是这些方法都不太适合用来更新缓存,因为分布式事务,对数据更新服务有很强的侵入性。我们拿下单服务来说,如果为了更新缓存增加一个分布式事务,无论我们用哪种分布式事务,或多或少都会影响下单服务的性能。还有一个问题是,如果Redis本身出现故障,写入数据失败,还会导致下单失败,等于是降低了下单服务性能和可用性,这样肯定不行。
对于像订单服务这类核心的业务,一个可行的方法是,我们启动一个更新订单缓存的服务,接收订单变更的MQ消息,然后更新Redis中缓存的订单数据。因为这类核心的业务数据,使用方非常多,本来就需要发消息,增加一个消费订阅基本没什么成本,订单服务本身也不需要做任何更改。
唯一需要担心的一个问题是,如果丢消息了怎么办?因为现在消息是缓存数据的唯一来源,一旦出现丢消息,缓存里缺失的那条数据永远不会被补上。所以,必须保证整个消息链条的可靠性,不过好在现在的MQ集群,比如像Kafka或者RocketMQ,它都有高可用和高可靠的保证机制,只要你正确配置好,是可以满足数据可靠性要求的。
像订单服务这样,本来就有现成的数据变更消息可以订阅,这样更新缓存还是一个不错的选择,因为实现起来很简单,对系统的其他模块完全没有侵入。
如果我们要缓存的数据,本来没有一份数据更新的MQ消息可以订阅怎么办?
数据更新服务只负责处理业务逻辑,更新MySQL,完全不用管如何去更新缓存。负责更新缓存的服务,把自己伪装成一个MySQL的从节点,从MySQL接收Binlog,解析Binlog之后,可以得到实时的数据变更信息,然后根据这个变更信息去更新Redis缓存。
把缓存当成MySQL的从节点,根据binlog更新。
这种收Binlog更新缓存的方案,和刚刚我们讲到的,收MQ消息更新缓存的方案,其实它们的实现思路是一样的,都是异步订阅实时数据变更信息,去更新Redis。只不过,**直接读取Binlog这种方式,它的通用性更强。**不要求订单服务再发订单消息了,订单更新服务也不用费劲去解决“发消息失败怎么办?”这种数据一致性问题了。
在整个缓存更新链路上,减少了一个收发MQ的环节,从MySQL更新到Redis更新的时延更短,出现故障的可能性也更低,所以很多大厂更青睐于这种方案。
这个方案唯一的缺点是,实现订单缓存更新服务有点儿复杂,毕竟不像收消息,拿到的直接就是订单数据,解析Binlog还是挺麻烦的。
有很多开源的项目就提供了订阅和解析MySQLBinlog的功能,下面我们以比较常用的开源项目Canal为例,来演示一下如何实时接收Binlog更新Redis缓存。
Canal模拟MySQL主从复制的交互协议,把自己伪装成一个MySQL的从节点,向MySQL主节点发送dump请求,MySQL收到请求后,就会开始推送Binlog给Canal,Canal解析Binlog字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
下图是Canal的工作原理
MySQL和Redis都运行在本地的默认端口上,MySQL的端口为3306,Redis的端口为6379。
首先,下载并解压Canal最新的1.1.4版本到本地:
然后来配置MySQL,我们需要在MySQL的配置文件中开启Binlog,并设置Binlog的格式为ROW格式。
给Canal开一个专门的MySQL用户并授权,确保这个用户有复制Binlog的权限:
重启一下MySQL,确保所有的配置生效。重启后检查一下当前的Binlog文件和位置:
记录下File和Position两列的值,然后我们来配置Canal。编辑Canal的实例配置文件canal/conf/example/instance.properties,以便让Canal连接到我们的MySQL上。
这个配置文件需要配置MySQL的连接地址、库名、用户名和密码之外,还需要配置canal.instance.master.journal.name和canal.instance.master.position这两个属性,取值就是刚刚记录的File和Position两列。然后就可以启动Canal服务了:
启动之后看一下日志文件canal/logs/example/example.log,如果里面没有报错,就说明启动成功并连接到我们的MySQL上了。
Canal服务启动后,会开启一个端口(11111)等待客户端连接,客户端连接上Canal服务之后,可以从Canal服务拉取数据,每拉取一批数据,正确写入Redis之后,给Canal服务返回处理成功的响应。如果发生客户端程序宕机或者处理失败等异常情况,Canal服务没收到处理成功的响应,下次客户端来拉取的还是同一批数据,这样就可以保证顺序并且不会丢数据。
接下来我们来开发账户余额缓存的更新程序,以下的代码都是用Java语言编写的:
这个程序逻辑也不复杂,程序启动并连接到Canal服务后,就不停地拉数据,如果没有数据就睡一会儿,有数据就调用processEntries方法处理更新缓存。每批数据更新成功后,就调用ack方法给Canal服务返回成功响应,如果失败抛异常就回滚。下面是processEntries方法的主要代码:
这里面根据事件类型来分别处理,如果MySQL中的数据删除了,就删除Redis中对应的数据。如果是更新和插入操作,那就调用Redis的SET命令来写入数据。
把这个账户缓存更新服务启动后,我们来验证一下,我们在账户余额表插入一条记录:
然后来看一下Redis缓存:
可以看到数据已经自动同步到Redis中去了。我把这个示例的完整代码放在了GitHub上供你参考。
对象存储是最简单的分布式存储系统,主要由数据节点集群、元数据集群和网关集群(或者客户端)三部分构成。
数据节点集群负责保存对象数据,元数据集群负责保存集群的元数据,网关集群和客户端对外提供简单的访问API,对内访问元数据和数据节点读写数据。
为了便于维护和管理,大的对象被拆分为若干固定大小的块儿,块儿又被封装到容器(也就分片)中,每个容器有一主N从多个副本,这些副本再被分散到集群的数据节点上保存。
对象存储虽然简单,但是它具备一个分布式存储系统的全部特征。所有分布式存储系统共通的一些特性,对象存储也都具备,比如说数据如何分片,如何通过多副本保证数据可靠性,如何在多个副本间复制数据,确保数据一致性等等。
希望你通过这节课的学习,不仅是学会对象存储,还要对比分析一下,对象存储和其他分布式存储系统,比如MySQL集群、HDFS、Elasticsearch等等这些,它们之间有什么共同的地方,差异在哪儿。想通了这些问题,你对分布式存储系统的认知,绝对会上升到一个全新的高度。然后你再去看一些之前不了解的存储系统,就非常简单了。
对象存储对外提供的服务,其实就是一个近乎无限容量的大文件KV存储,所以对象存储和分布式文件系统之间,没有那么明确的界限。对象存储的内部,肯定有很多的存储节点,用于保存这些大文件,这个就是数据节点的集群。
我们为了管理这些数据节点和节点中的文件,还需要一个存储系统保存集群的节点信息、文件信息和它们的映射关系。这些为了管理集群而存储的数据,叫做元数据(Metadata)。
元数据对于一个存储集群来说是非常重要的,所以保存元数据的存储系统必须也是一个集群。但是元数据集群存储的数据量比较少,数据的变动不是很频繁,加之客户端或者网关都会缓存一部分元数据,所以元数据集群对并发要求也不高。一般使用类似ZooKeeper或者etcd这类分布式存储就可以满足要求。
存储集群为了对外提供访问服务,还需要一个网关集群,对外接收外部请求,对内访问元数据和数据节点。网关集群中的每个节点不需要保存任何数据,都是无状态的节点。有些对象存储没有网关,取而代之的是客户端,它们的功能和作用都是一样的。
对象存储是如何来处理对象读写请求的呢?这里面处理读和写请求的流程是一样的,我们一起来说。网关收到对象读写请求后,首先拿着请求中的Key,去元数据集群查找这个Key在哪个数据节点上,然后再去访问对应的数据节点读写数据,最后把结果返回给客户端。
虽然我画的是对象存储集群的结构,但是把图上的名词改一改,完全可以套用到绝大多数分布式文件系统和数据库上去,比如说HDFS。
接下来我们说一下对象存储到底是如何来保存大文件对象的。一般来说,对象存储中保存的文件都是图片、视频这类大文件。
在对象存储中,每一个大文件都会被拆成多个大小相等的块儿(Block),拆分的方法很简单,就是把文件从头到尾按照固定的块儿大小,切成一块儿一块儿,最后一块儿长度有可能不足一个块儿的大小,也按一块儿来处理。块儿的大小一般配置为几十KB到几个MB左右。
把大对象文件拆分成块儿的目的有两个:
一般都会再把块儿聚合一下,放到块儿的容器里面。这里的“容器”就是存放一组块儿的逻辑单元。容器这个名词,没有统一的叫法,比如在ceph中称为DataPlacement,你理解这个含义就行。容器内的块儿数大多是固定的,所以容器的大小也是固定的。
这个容器的概念,就比较类似于我们之前讲MySQL和Redis时提到的“分片”的概念了,都是复制、迁移数据的基本单位。每个容器都会有N个副本,这些副本的数据都是一样的。其中有一个主副本,其他是从副本,主副本负责数据读写,从副本去到主副本上去复制数据,保证主从数据一致。
这里面有一点儿和我们之前讲的不一样的是,对象存储一般都不记录类似MySQL的Binlog这样的日志。主从复制的时候,复制的不是日志,而是整块儿的数据。这么做有两个原因:
以上我们说的对象(也就是文件)、块儿和容器,都是逻辑层面的概念,数据落实到副本上,这些副本就是真正物理存在了。这些副本再被分配到数据节点上保存起来。这里的数据节点就是运行在服务器上的服务进程,负责在本地磁盘上保存副本的数据。
了解了对象是如何被拆分并存储在数据节点上之后,我们再来回顾一下数据访问的流程。当我们请求一个Key的时候,网关首先去元数据中查找这个Key的元数据。然后根据元数据中记录的对象长度,计算出对象有多少块儿。接下来的过程就可以分块儿并行处理了。对于每个块儿,还需要再去元数据中,找到它被放在哪个容器中
容器就是分片,怎么把块儿映射到容器中,这个方法就是我们在《15|MySQL存储海量数据的最后一招:分库分表》这节课中讲到的几种分片算法。不同的系统选择实现的方式也不一样,有用哈希分片的,也有用查表法把对应关系保存在元数据中的。找到容器之后,再去元数据中查找容器的N个副本都分布在哪些数据节点上。然后,网关直接访问对应的数据节点读写数据就可以了。
如何能够做到让这么多份数据实时地保持同步呢?
我们之前讲过分布式事务,可以解决数据一致性的问题。比如说,你可以用本地消息表,把一份数据实时同步给另外两、三个数据库,这样还可以接受,太多的话也是不行的,并且对在线交易业务还有侵入性,所以分布式事务是解决不了这个问题的。
利用Canal把自己伪装成一个MySQL的从库,从MySQL实时接收Binlog然后写入Redis中。把这个方法稍微改进一下,就可以用来做异构数据库的同步了。+MQ
为了能够支撑下游众多的数据库,从Canal出来的Binlog数据肯定不能直接去写下游那么多数据库,一是写不过来,二是对于每个下游数据库,它可能还有一些数据转换和过滤的工作要做。所以需要增加一个MQ来解耦上下游。
Canal从MySQL收到Binlog并解析成结构化数据之后,**直接写入到MQ的一个订单Binlog主题中,然后每一个需要同步订单数据的业务方,都去订阅这个MQ中的订单Binlog主题,消费解析后的Binlog数据。**在每个消费者自己的同步程序中,它既可以直接入库,也可以做一些数据转换、过滤或者计算之后再入库,这样就比较灵活了。
一般容易成为性能瓶颈的就是消费MQ的同步程序,因为这些同步程序里面一般都会有一些业务逻辑,而且如果下游的数据库写性能跟不上,表象也是这个同步程序处理性能上不来,消息积压在MQ里面。
实时性差。
单线程处理速度上不去,消息越积压越多,这不无解了吗?其实办法还是有的,但是必须得和业务结合起来解决。
只要保证每个订单的更新操作日志的顺序别乱就可以了。这种一致性要求称为因果一致性(CausalConsistency),有因果关系的数据之间必须要严格地保证顺序,没有因果关系的数据之间的顺序是无所谓的。
并行地来进行数据同步,具体的做法是这样的。
对应到订单库就是,相同订单号的Binlog必须发到同一个分区上。这是不是和之前讲过的数据库分片有点儿像呢?那分片算法就可以拿过来复用了,比如我们可以用最简单的哈希算法,Binlog中订单号除以MQ分区总数,余数就是这条Binlog消息发往的分区号。
Canal自带的分区策略就支持按照指定的Key,把Binlog哈希到下游的MQ中去,具体的配置可以看一下Canal接入MQ的文档。
随着我们的系统规模逐渐增长,总会遇到需要更换数据库的问题。我们来说几种常见的情况。
对MySQL做了分库分表之后,需要从原来的单实例数据库迁移到新的数据库集群上。
系统从传统部署方式向云上迁移的时候,也需要从自建的数据库迁移到云数据库上。
一些在线分析类的系统,MySQL性能不够用的时候,就需要更换成一些专门的分析类数据库,比如说HBase。
设计在线切换数据库的技术方案,首先要保证安全性,确保每一个步骤一旦失败,都可以快速回滚。此外,还要确保迁移过程中不丢数据,这主要是依靠实时同步程序和对比补偿程序来实现。
我把这个复杂的切换过程的要点,按照顺序总结成下面这个列表,供你参考:
在设计迁移方案的时候,一定要做到,每一步都是可逆的。要保证,每执行一个步骤后,一旦出现问题,能快速地回滚到上一个步骤。
接下来我们还是以订单库为例子,说一下这个迁移方案应该如何来设计。
首先要做的就是,把旧库的数据复制到新库中。因为旧库还在服务线上业务,所以不断会有订单数据写入旧库,我们不仅要往新库复制数据,还要保证新旧两个库的数据是实时同步的。所以,我们需要用一个同步程序来实现新旧两个数据库实时同步。
怎么来实现两个异构数据库之间的数据实时同步,这个方法我们上节课刚刚讲过,我们可以使用Binlog实时同步数据。如果源库不是MySQL的话,就麻烦一点儿,但也可以参考我们讲过的,复制状态机理论来实现。这一步不需要回滚,原因是,只增加了一个新库和一个同步程序,对系统的旧库和程序都没有任何改变。即使新上线的同步程序影响到了旧库,只要停掉同步程序就可以了
然后,我们需要改造一下订单服务,业务逻辑部分不需要变,DAO层需要做如下改造:
然后上线新版的订单服务,这个时候订单服务仍然是只读写旧库,不读写新库。让这个新版的订单服务需要稳定运行至少一到二周的时间,期间除了验证新版订单服务的稳定性以外,还要验证新旧两个订单库中的数据是否是一致的。这个过程中,如果新版订单服务有问题,可以立即下线新版订单服务,回滚到旧版本的订单服务。
稳定一段时间之后,就可以开启订单服务的双写开关了。开启双写开关的同时,需要停掉同步程序。这里面有一个问题需要注意一下,就是这个双写的业务逻辑,一定是先写旧库,再写新库,并且以写旧库的结果为准。
切换到双写之后,新库与旧库的数据可能会存在不一致的情况,原因有两个:一是停止同步程序和开启双写,这两个过程很难做到无缝衔接,二是双写的策略也不保证新旧库强一致,这时候我们需要上线一个对比和补偿的程序,这个程序对比旧库最近的数据变更,然后检查新库中的数据是否一致,如果不一致,还要进行补偿。
开启双写后,还需要至少稳定运行至少几周的时间,并且期间我们要不断地检查,确保不能有旧库写成功,新库写失败的情况出现。对比程序也没有发现新旧两个库的数据有不一致的情况,这个时候,我们就可以认为,新旧两个库的数据是一直保持同步的。
接下来就可以用类似灰度发布的方式,把读请求一点儿一点儿地切到新库上。同样,期间如果出问题的话,可以再切回旧库。全部读请求都切换到新库上之后,这个时候其实读写请求就已经都切换到新库上了,实际的切换已经完成了,但还有后续的收尾步骤。
再稳定一段时间之后,就可以停掉对比程序,把订单服务的写状态改为只写新库。到这里,旧库就可以下线了。注意,整个迁移过程中,只有这个步骤是不可逆的。但是,这步的主要操作就是摘掉已经不再使用的旧库,对于在用的新库并没有什么改变,实际出问题的可能性已经非常小了。
到这里,我们就完成了在线更换数据库的全部流程。双写版本的订单服务也就完成了它的历史使命,可以在下一次升级订单服务版本的时候,下线双写功能。
像订单这类时效性强的数据,是比较好对比和补偿的。因为订单一旦完成之后,就几乎不会再变了,那我们的对比和补偿程序,就可以依据订单完成时间,每次只对比这个时间窗口内完成的订单。补偿的逻辑也很简单,发现不一致的情况后,直接用旧库的订单数据覆盖新库的订单数据就可以了。
切换双写期间,少量不一致的订单数据,等到订单完成之后,会被补偿程序修正。后续只要不是双写的时候,新库频繁写入失败,就可以保证两个库的数据完全一致。
更一般的情况,比如像商品信息这类数据,随时都有可能会变化。如果说数据上有更新时间,那我们的对比程序可以利用这个更新时间,每次在旧库取一个更新时间窗口内的数据,去新库上找相同主键的数据进行对比,发现数据不一致,还要对比一下更新时间。**如果新库数据的更新时间晚于旧库数据,那可能是对比期间数据发生了变化,这种情况暂时不要补偿,放到下个时间窗口去继续对比。**另外,时间窗口的结束时间,不要选取当前时间,而是要比当前时间早一点儿,比如1分钟前,避免去对比正在写入的数据。
如果数据连时间戳也没有,那只能去旧库读取Binlog,获取数据变化,然后去新库对比和补偿。
先存储再计算,计算结果保存到特定的数据库中,供业务系统查询。
所以,对于海量原始数据的存储系统,我们要求的是超高的写入和读取性能,和近乎无限的容量,对于数据的查询能力要求不高。
生产上,可以选择Kafka或者是HDFS,Kafka的优点是读写性能更好,单节点能支持更高的吞吐量。而HDFS则能提供真正无限的存储容量,并且对查询更友好。
先存储再计算,除了贵以外都是优点:
但是,这种方式对保存原始数据的存储系统要求就很高了:既要有足够大的容量,能水平扩容,还要读写都足够快,跟得上数据生产的写入速度,还要给下游计算提供低延迟的读服务。什么样的存储能满足这样的要求呢?这里我给出几种常用的解决方案。
Kafka提供“无限”的消息堆积能力,具有超高的吞吐量,可以满足我们保存原始数据的大部分要求。写入点击流数据的时候,每个原始数据采集服务作为一个生产者,把数据发给Kafka就可以了。下游的计算任务,可以作为消费者订阅消息,也可以按照时间或者位点来读取数据。并且,Kafka作为事实标准,和大部分大数据生态圈的开源软件都有非常好的兼容性和集成度,像Flink、Spark等大多计算平台都提供了直接接入Kafka的组件。
Kafka也支持把数据分片,这个在Kafka中叫Partition,每个分片可以分布到不同的存储节点上。
写入数据的时候,可以均匀地写到这些分片上,理论上只要分片足够多,存储容量就可以是“无限”的。但是,单个分片总要落到某一个节点上,而单节点的存储容量毕竟是有限的,随着时间推移,单个分片总有写满的时候。
即使它支持扩容分片数量,也没办法像其他分布式存储系统那样,重新分配数据,把已有分片上的数据迁移一部分到新的分片上。所以扩容分片也解决不了已有分片写满的问题。而Kafka又不支持按照时间维度去分片,所以,受制于单节点的存储容量,Kafka实际能存储的数据容量并不是无限的。
需要长时间(几个月-几年)保存的海量数据,就不适合用Kafka存储。这种情况下,只能退而求其次,使用第二种方案了。
第二种方案是,使用HDFS来存储。使用HDFS存储数据也很简单,就是把原始数据写成一个一个文本文件,保存到HDFS中。我们需要按照时间和业务属性来组织目录结构和文件名,以便于下游计算程序来读取,比如说:“click/20200808/Beijing_0001.csv”,代表2020年8月8日,从北京地区用户收集到的点击流数据,这个是当天的第一个文件。
HDFS也有它的优势,第一个优势就是,它能提供真正无限的存储容量,如果存储空间不够了,水平扩容就可以解决。另外一个优势是,HDFS能提供比Kafka更强的数据查询能力。Kafka只能按照时间或者位点来提取数据,而HDFS配合Hive直接就可以支持用SQL对数据进行查询,虽然说查询的性能比较差,但查询能力要比Kafka强大太多了。
目前还没有可用大规模于生产的,成熟的解决方案,但未来应该会有的。目前已经有一些的开源项目,都致力于解决这方面的问题。
一类是分布式流数据存储,比较活跃的项目有Pravega和Pulsar的存储引擎ApacheBookKeeper。我所在的团队也在这个方向上持续探索中,也开源了我们的流数据存储项目JournalKeeper,也欢迎你关注和参与进来。这些分布式流数据存储系统,走的是类似Kafka这种流存储的路线,在高吞吐量的基础上,提供真正无限的扩容能力,更好的查询能力。
还有一类是时序数据库(TimeSeriesDatabases),比较活跃的项目有InfluxDB和OpenTSDB等。这些时序数据库,不仅有非常好的读写性能,还提供很方便的查询和聚合数据的能力。但是,它们不是什么数据都可以存的,它们专注于类似监控数据这样,有时间特征并且数据内容都是数值的数据。如果你有存储海量监控数据的需求,可以关注一下这些项目。
所以一般的做法是,用流计算或者是批计算,把原始数据再进行一次或者多次的过滤、汇聚和计算,把计算结果落到另外一个存储系统中去,由这个存储再给业务系统提供查询支持。这里的“流计算”,指的是Flink、Storm这类的实时计算,批计算是Map-Reduce或者Spark这类的非实时计算。
像点击流、监控和日志这些原始数据是“海量数据中的海量数据”,这些原始数据经过过滤汇总和计算之后,大多数情况下数据量会有量级的下降,比如说从TB级别的数据量,减少到GB级别。
有的业务,计算后的数据非常少,比如说一些按天粒度的汇总数据,或者排行榜类的数据,用什么存储都能满足要求。那有一些业务,没法通过事先计算的方式解决全部的问题。原始数据经过计算后产生的计算结果,数据量相比原始数据会减少一些,但仍然是海量数据。并且,我们还要在这个海量数据上,提供性能可以接受的查询服务。
面对这样的海量数据,如何才能让查询更快一些。
海量数据的主要用途就是支撑离线分析类业务的查询,根据数据量规模不同,由小到大可以选择:关系型数据库,列式数据库和一些大数据存储系统。对于TB量级以下的数据,如果可以接受相对比较贵的硬件成本,ES是一个不错的选择。
对于海量数据来说,选择存储系统没有银弹,重要的是转变思想,根据业务对数据的查询方式,反推数据应该使用什么存储系统、如何分片,以及如何组织。即使是同样一份数据,也要根据不同的查询需求,组织成不同的数据结构,存放在适合的存储系统中,才能在每一种业务中都达到理想的查询性能。
查询海量数据的系统,大多都是离线分析类系统,你可以简单地理解为类似于做报表的系统,也就是那些主要功能是对数据做统计分析的系统。这类系统是重度依赖于存储的。选择什么样的存储系统、使用什么样的数据结构来存储数据,直接决定了数据查询、聚合和分析的性能。
分析类系统对存储的需求一般是这样的:
然后我们看有哪些可供选择的存储产品。如果你的系统的数据量在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等等这些大数据生态圈产品做数据聚合和计算。
在特定的场景下,通过一些优化方法,把查询性能提升几十倍甚至几百倍,这个都是有可能的。这里面有个很重要的思想就是,根据查询来选择存储系统和数据结构
ES采用的倒排索引的数据结构,并没有比MySQL的B+树更快或者说是更先进,但是面对“全文搜索”这个查询需求,选择使用ES的倒排索引,就比使用其他的存储系统和数据结构,性能上要高出几十倍。
对于智能补货和运力调度这两个系统,它的区域性很强,那我们可以把数据按照区域(省或者地市)做分片,再汇总一份全国的跨区物流数据,这样绝大部分查询都可以落在一个分片上,查询性能就会很好
对于站点儿和人的时效达成情况,这种业务的查询方式以点查询为主,那可以考虑事先在计算的时候,按照站点儿和人把数据汇总好,存放到一些分布式KV存储中,基本上可以做到毫秒级查询性能。而对于物流规划的查询需求,查询方式是多变的,可以把数据放到Hive表中,按照时间进行分片。
按照时间分片是对查询最友好的分片方式。物流规划人员可以在上面执行一些分析类的查询任务,一个查询任务即使是花上几个小时,用来验证一个新的规划算法,也是可以接受的。
这种关系型数据库因为支持SQL语言,后来被叫做SQL或者OldSQL。
出现了Redis和很多KV存储系统,性能上各种吊打MySQL,而且因为存储结构简单,所以比较容易组成分布式集群,并且能够做到水平扩展、高可靠、高可用。因为这些KV存储不支持SQL,为了以示区分,被统称为NoSQL。
NewSQL就是兼顾了OldSQL和NoSQL的优点:
完整地支持SQL和ACID,提供和OldSQL隔离级别相当的事务能力;
高性能、高可靠、高可用,支持水平扩容
像Google的Cloud Spanner、国产的Ocean Base以及开源的Cockroach DB都属于NewSQL数据库。Cockroach这个英文单词是蟑螂的意思,所以一般我们都把Cockroach DB俗称为小强数据库。
首先,我们一起先来简单看一下Cockroach DB的架构,从架构层面分析一下,它是不是真的像宣传的那么厉害。我们先来看一下它的架构图(图片来自于官方文档):
这是一个非常典型的分层架构,我们从上往下看。最上层是SQL层,SQL层支持和关系型数据库类似的逻辑数据结构,比如说库、表、行和列这些逻辑概念。SQL层向下调用的是一个抽象的接口层Structured Data API,实际实现这个API的是下面一层:Distributed,Monolithic KV Store,这就是一个分布式的KV存储系统。
我们先不深入进去看细节,从宏观层面上分析一下这个架构。你可以看到,这个架构仍然是我们之间讲过的,大部分数据库都采用的二层架构:执行器和存储引擎。它的SQL层就是执行器,下面的分布式KV存储集群就是它的存储引擎。
那我们知道,MySQL的存储引擎InnoDB,实际上是基于文件系统的B+树,像Hive和HBase,它们的存储引擎都是基于HDFS构建的。那Cockroach DB这种,使用分布式KV存储来作为存储引擎的设计,理论上也是可行的,并没有什么特别难以逾越的技术壁垒。
它的分片算法采用的是范围分片,我们之前也讲到过,范围分片对查询是最友好的,可以很好地支持范围扫描这一类的操作,这样有利于它支撑上层的SQL查询。
它采用Raft一致性协议来实现每个分片的高可靠、高可用和强一致。这个Raft协议,它的一个理论基础,就是我们之前讲的复制状态机,并且在复制状态机的基础上,Raft实现了集群自我监控和自我选举来解决高可用的问题。Raft也是一个被广泛采用的、非常成熟的一致性协议,比如etcd也是基于Raft来实现的。
Cockroach DB的元数据直接分布在所有的存储节点上,依靠流言协议来传播,这个流言协议,在Redis Cluster中也是用流言协议来传播元数据变化的。
Cockroach DB用上面这些成熟的技术解决了集群问题,在单机的存储引擎上,更是直接使用了Rocks DB作为它的KV存储引擎。Rocks DB也是值得大家关注的一个新的存储系统,下节课我们会专门讲Rocks DB。
Cockroach DB是怎么解析和执行SQL的。同样是先解析SQL生成语法树,转换成逻辑执行计划,再转换为物理执行计划,优化后,执行物理执行计划返回查询结果,这样一个流程。
只是在Cockroach DB中,物理执行计划就更加复杂了,因为它的物理执行计划面对的是一个分布式KV存储系统,在涉及到查找、聚合这类操作的时候,有可能需要涉及到多个分片(Range)。大致过程就是类似于Map-Reduce的逻辑,先查找元数据确定可能涉及到的分片,然后把物理执行计划转换成每个分片上的物理执行计划,在每个分片上去并行执行,最后,再对这些执行结果做汇总。
然后我们再来说Cockroach DB的ACID。
Cockroach DB提供了另外两种隔离级别,分别是:Snapshot Isolation(SI)和Serializable Snapshot Isolation(SSI),其中SSI是Cockroach DB默认的隔离级别。
首先我们看SI这一行。我们之前讲到过,RR这种隔离级别,可以很好地解决脏读和不可重复读的问题,虽然可能会产生幻读,但实际上对绝大多数事务影响不大。
SI不会发生脏读、不可重复读,也不会发生幻读的情况,这个隔离级别似乎比RR还要好。
什么是写倾斜。但是在SI级别下,由于它没有加锁,而是采用快照的方式来实现事务的隔离,这个时候,如果并发地去更新主副卡余额,是有可能出现把主副卡余额之和扣减为负数的情况的。这种情况称为写倾斜。实际上它表达的,就是因为没有检测读写冲突,也没有加锁,导致数据写错了。
SSI隔离级别在SI的基础上,加入了冲突检测的机制,通过检测读写冲突,然后回滚事务的方式来解决写倾斜的问题,当然这种方式付出的代价是降低性能,并且冲突严重的情况下,会频繁地出现事务回滚。
Cockroach DB的存储引擎是一个分布式的KV存储集群,它用了一系列成熟的技术来解决集群问题,但是在集群的每个节点上,还需要一个单机的KV存储来保存数据,这个地方Cockroach DB直接使用Rocks DB作为它的KV存储引擎。
Rocks DB是一个高性能持久化的KV存储,被很多新生代的数据库作为存储引擎。Rocks DB在保证不错的读性能的前提下,大幅地提升了写入性能,这主要得益于它的数据结构:LSM-Tree。
LSM-Tree通过混合内存和磁盘内的多种数据结构,将随机写转换为顺序写来提升写性能,通过异步向下合并分层SSTable文件的方式,让热数据的查找更高效,从而获得还不错的综合查找性能。
通过分析LSM-Tree的数据结构可以看出来,这种数据结构还是偏向于写入性能的优化,更适合在线交易类场景,因为在这类场景下,需要频繁写入数据。
说到KV存储,我们最熟悉的就是Redis了,接下来我们就来对比一下Rocks DB和Redis这两个KV存储。
其实Redis和Rocks DB之间没什么可比性,一个是缓存,一个是数据库存储引擎,放在一起比就像“关公战秦琼”一样。那我们把这两个KV放在一起对比,目的不是为了比谁强谁弱,而是为了让你快速了解Rocks DB能力。
我们知道Redis是一个内存数据库,它之所以能做到非常好的性能,主要原因就是,它的数据都是保存在内存中的。从Redis官方给出的测试数据来看,它的随机读写性能大约在50万次/秒左右。而Rocks DB相应的随机读写性能大约在20万次/秒左右,虽然性能还不如Redis,但是已经可以算是同一个量级的水平了。
重大差异是,Redis是一个内存数据库,并不是一个可靠的存储。数据写到内存中就算成功了,它并不保证安全地保存到磁盘上。而Rocks DB它是一个持久化的KV存储,它需要保证每条数据都要安全地写到磁盘上,这也是很多数据库产品的基本要求。这么一比,我们就看出来Rocks DB的优势了,我们知道,磁盘的读写性能和内存读写性能差着一两个数量级,读写磁盘的Rocks DB,能和读写内存的Redis做到相近的性能,这就是Rocks DB的价值所在了。
Rocks DB为什么能在保证数据持久化的前提下,还能做到这么强的性能呢?我们之前反复讲到过,一个存储系统,它的读写性能主要取决于什么?取决于它的存储结构,也就是数据是如何组织的。
Rocks DB采用了一个非常复杂的数据存储结构,并且这个存储结构采用了内存和磁盘混合存储方式,使用磁盘来保证数据的可靠存储,并且利用速度更快的内存来提升读写性能。或者说,Rocks DB的存储结构本身就自带了内存缓存。
Kafka也是采用顺序读写的方式,所以它的读写性能也是超级快。但是这种顺序写入的数据基本上是没法查询的,因为数据没有结构,想要查询的话,只能去遍历。Rocks DB究竟使用了什么样的数据结构,在保证数据顺序写入的前提下还能兼顾很好的查询性能呢?这种数据结构就是LSM-Tree。
LSM-Tree的全称是:TheLog-StructuredMerge-Tree,是一种非常复杂的复合数据结构,它包含了WAL(WriteAheadLog)、跳表(SkipList)和一个分层的有序表(SSTable,SortedStringTable)。下面这张图就是LSM-Tree的结构图(图片来自于论文:AnEfficientDesignandImplementationofLSM-TreebasedKey-ValueStoreonOpen-ChannelSSD)
首先需要注意的是,这个图上有一个横向的实线,是内存和磁盘的分界线,上面的部分是内存,下面的部分是磁盘。
我们先来看数据是如何写入的。当LSM-Tree收到一个写请求,比如说:PUTfoobar,把Keyfoo的值设置为bar。首先,这条操作命令会被写入到磁盘的WAL日志中(图中右侧的Log),这是一个顺序写磁盘的操作,性能很好。这个日志的唯一作用就是用于故障恢复,一旦系统宕机,可以从日志中把内存中还没有来得及写入磁盘的数据恢复出来。这个地方用的还是之前我们多次讲过的复制状态机理论。
写完日志之后,数据可靠性的问题就解决了。然后数据会被写入到内存中的MemTable中,这个MemTable就是一个按照Key组织的跳表(SkipList),跳表和平衡树有着类似的查找性能,但实现起来更简单一些。写MemTable是个内存操作,速度也非常快。数据写入到MemTable之后,就可以返回写入成功了。这里面有一点需要注意的是,LSMTree在处理写入的过程中,直接就往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文件就可以删除掉了。合并的过程也是排序的过程,除了Level0(第0层,也就是MemTable直接dump出来的磁盘文件所在的那一层。)以外,每一层内的文件都是有序的,文件内的KV也是有序的,这样就比较便于查找了。
然后我们再来说LSM-Tree如何查找数据。查找的过程也是分层查找,先去内存中的MemTable和Immutable MemTable中找,然后再按照顺序依次在磁盘的每一层SSTable文件中去找,只要找到了就直接返回。这样的查找方式其实是很低效的,有可能需要多次查找内存和多个文件才能找到一个Key,但实际的效果也没那么差,因为这样一个分层的结构,它会天然形成一个非常有利于查找的情况:越是被经常读写的热数据,它在这个分层结构中就越靠上,对这样的Key查找就越快。
最经常读写的Key很大概率会在内存中,这样不用读写磁盘就完成了查找。即使内存中查不到,真正能穿透很多层SStable一直查到最底层的请求还是很少的。另外,在工程上还会对查找做很多的优化,比如说,在内存中缓存SSTable文件的Key,用布隆过滤器避免无谓的查找等来加速查找过程。这样综合优化下来,可以获得相对还不错的查找性能。