海量数据导致存储系统慢
拆,将一大坨数据拆分成 N 个小坨,学名「分片」。
归档历史数据
将大量的不常用的历史数据移到另外一张历史表中,大概流程:
批量删除大量数据
不能一次性直接删除,需要分批删除(并在每次删除之间停一会儿):
delete from orderswhere timestamp < SUBDATE(CURDATE(),INTERVAL 3 month)order by id limit 1000;
优化思路:先查出来符合条件的 id,然后根据 id 去删除:
select max(id) from orders
where timestamp < SUBDATE(CURDATE(),INTERVAL 3 month);
delete from orders
where id <= ?
order by id limit 1000;
删除数据后表空间没有被释放原因:虽然逻辑上每个表是一颗 B+ 树,但是物理上,每条记录都是存放在磁盘文件中的,这些记录通过一些位置指针来组织成一颗 B+ 树。当 MySQL 删除一条记录的时候,只能是找到记录所在的文件中位置,然后把文件的这块区域标记为空闲,然后再修改 B+ 树中相关的一些指针,完成删除。其实那条被删除的记录还是躺在那个文件的那个位置,所以并不会释放磁盘空间。
解决方案:可以执行一次 OPTIMIZE TABLE 释放存储空间。对于 InnoDB 来说,执行 OPTIMIZE TABLE 实际上就是把这个表重建一遍,执行过程中会一直锁表,也就是说这个时候下单都会被卡住,这个是需要注意的。前提条件是 MySQL 的配置必须是每个表独立一个表空间(innodb_file_per_table = ON),如果所有表都是放在一起的,执行 OPTIMIZE TABLE 也不会释放磁盘空间。
分库分表
原则
能不拆就不拆,能少拆就不多拆。因为数据拆得越散,开发和维护就越复杂。
解决的问题
一是解决数据量大了之后查询慢的问题,此处说的查询是事务中的查询和更新,只读的查询可以通过缓存或者主从分离来解决。解决查询慢,还是需要尽可能减少查询的数据总量,使用分表来解决。
二是为了应对高并发的问题,一个实例撑不住,我们可以将请求分散到不同的实例解决,使用分库来解决。
Sharding Key 选择
最重要的参考因素:业务如何访问数据
以我的订单页面为例:如果将订单的 ID 作为 Sharding Key 拆分,查询条件是用户 ID,没法知道查询的订单在哪个分片上,只能查找所有分片得出查询结果,性能差还麻烦。如果以用户 ID 为 Sharding Key,查询条件是订单 ID,就不知道订单在哪个分片上了。
上述的问题可以通过在生成订单 ID 的时候,把用户的 ID 位数作为订单 ID 的一部分,按照订单 ID 查询的时候,可以根据订单 ID 中的用户 ID 找到对应分片。但是真实业务中往往不只有用两个 ID 作为查询条件。
一般做法:将订单数据同步到其它存储系统中,在其它存储系统中解决。
构建一个以店铺 ID 作为 Sharding Key 的只读订单库,专门供商家使用;或者将数据导入到 HDFS,使用大数据技术生成订单的报表。
分片算法选择
前提:希望并发请求和数据能均衡到分不到每一个分片上
按照时间范围分片:容易产生热点问题,但是查询友好(加上时间范围的条件),适合数据量大并发访问量不大的 ToB 系统。
哈希分片算法:有最简单的取模算法(直接取余),或者一致性哈希,分得均匀的前提是作为 Sharding Key 的后几位数字是均匀分布的。
查表法:将分配结果记录到一个表里,执行查询时先查该表,然后去找对应存储的分片。整体上比较灵活,可以手动将数据分均匀,可以随时改变分片,也可以通过缓存加速查询。
Redis 集群
Redis 官方在 3.0 版本开始提供了 Redis Cluser 这种集群方式,类似 MySQL 分库分表,这种方式是通过分片的方式,来保存更多的数据。
分片的大概规则是通过 CRC16 算法算出来一个值,然后将此值除以 16384,余数就是 Key 所在的槽。
在集群初始化的时候,Redis 会自动平均分配 16384 个槽(也可以用命令调整),也会将「槽与 Redis 实例的映射关系」保存在每个 Redis 实例中。
客户端请求一个 key 的时候,被请求的 Redis 会先通过上面的公式,计算出这个 Key 在哪个槽中,然后再查询槽和节点的映射关系,找到数据所在的真正节点,如果这个节点正好是自己,那就直接执行命令返回结果。如果数据不在当前这个节点上,那就给客户端返回一个重定向的命令,告诉客户端,应该去连哪个节点上请求这个 Key 的数据。然后客户端会再连接正确的节点来访问。
如果需要增加节点水平扩容增大集群的存储容量,需要将老节点中搬运一些槽到新节点中,这一步可以通过手动指定,也可以用官方的 redis-trib.rb 脚本实现。
Redis Cluster 高可用是通过增加从节点,做主从复制实现的。
主从原理:读的时候主从都可以读,写的时候只写主库然后同步到从库
- 启动一台 slave 的时候,他会发送一个 psync 命令给 master ,如果是这个 slave 第一次连接到 master,他会触发一个全量复制。master 就会启动一个线程,生成 RDB 快照,还会把新的写请求都缓存在内存中,RDB 文件生成后,master 会将这个 RDB 发送给 slave 的,slave 拿到之后做的第一件事情就是写进本地的磁盘,然后加载进内存;同步期间的对主库写操作会记录到 replication buffer,待第二步完成后发送给从库。
- 一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。
- 网络断连后,通过增量复制同步。
注意:Redis Cluster 适合构建中小规模(几个到几十个节点) Redis 集群,不适合做超大规模集群,原因是因为其采用了 流言协议 来传播集群配置的变化,虽然部署和维护简单,但是传播会比较慢。
如果想构建超大规模集群可以使用 twemproxy 以及 codis,主要原理是在客户端和 Redis 节点之间增加了一层代理服务,可以起到转发客户端请求、监控节点状态、维护集群云数据的作用。缺点是增长了数据访问链路,带来一定的性能损失。还有一种方案是先查询元数据,查到要访问的分配和节点,然后直接找对应的 Redis 查询(元数据一般不咋变,变了的话,需要重新维护)。
附:搭建步骤
MySQL 与 Redis 同步
一是可以通过 MQ 订阅相关数据变更业务主题,然后专门提供一个更新缓存的服务,此服务消费消息,来更新缓存。
二是使用 binlog 实时更新 Redis 缓存。
后者不用考虑发送消息失败等数据一致性的问题,但是解析 binlog 相对麻烦,可以通过 Canal 来实现接收 binlog 来更新 Redis 缓存。
对象存储
对象存储是原生的分布式存储系统(相对于 MySQL 单机存储系统),有很好的大文件读写功能,还可以通过水平扩展实现几乎无限的容量,可以兼顾服务高可用、数据高可靠的特性。
大概原理
KV 存储,对象存储内部有存储节点,用于保存文件,这些节点就是数据节点的集群。
为了管理数据节点及其文件,需要一个存储系统保存节点信息、文件信息以及对应的映射关系,这个存储系统中的数据称为元数据。
为了对外提供访问服务,需要网关集群,对外接收外部请求,对内访问元数据和数据节点。
对象存储保存大文件的时候,会将大文件拆分成多个大小相等的块儿。
原因:
- 提升读写性能,块儿分配到不同的数据节点吗,可以并行读写
- 大小相等,便于维护
为了方便管理,还会有一个将块儿们聚合一下放到一个容器里(理解的是放的块儿的地址?)
跨系统实时同步数据
如果用本地消息表的方式(分布式事务),将一份数据实时同步给另外的数据库,会对业务有侵入性。
如果下游数据库多的话,只用 Canal 的话,可能写不过来,还有就是不同的下游可能会有不同的数据转换和过滤工作要做。可以通过加入 MQ 的方式来搞:Canal 从 MySQL 收到 binlog 并解析成结构化数据后,直接写到 MQ 的主题中,下游业务系统订阅此主题,消费解析到的 binlog 数据,然后入库。附:Canal 接入 MQ 文档。
对于瓶颈 3 来说,也不能随意增加并发来提升处理能力,原因是有的业务是有顺序要求的(如果更新 A B 两个订单,这两个订单的谁先更新谁后更新没关系,但是如果更新同一个订单,就得涉及到严格的顺序了),要做的是保证每个订单的更新操作日志不乱序就可以了。这种一致性称为因果一致性,有因果关系的数据之间必须要严格地保证顺序,没有因果关系的数据之间的顺序是无所谓的。
扩展:
MySQL 主从同步 Binlog,是一个单线程的同步过程。为什么是单线程?原因很简单,在从库执行 Binlog 的时候,必须按顺序执行,才能保证数据和主库是一样的。为了确保数据一致性,Binlog 的顺序很重要,是绝对不能乱序的。 严格来说,对于每一个 MySQL 实例,整个处理链条都必须是单线程串行执行,MQ 的主题也必须设置为只有 1 个分区(队列),这样才能保证数据同步过程中的 Binlog 是严格有序的,写到目标数据库的数据才能是正确的。
并行进行数据同步的具体做法:首先根据下游同步程序的消费能力,计算出需要多少并发;然后设置 MQ 中主题的分区(队列)数量和并发数一致。因为 MQ 是可以保证同一分区内,消息是不会乱序的,所以我们需要把具有因果关系的 binlog 都放到相同的分区中去,就可以保证同步数据的因果一致性。对应到订单库就是,相同订单号的 binlog 必须发到同一个分区上。这是不是和之前讲过的数据库分片有点儿像呢?那分片算法就可以拿过来复用了,比如我们可以用最简单的哈希算法,binlog 中订单号除以 MQ 分区总数,余数就是这条 binlog 消息发往的分区号。
不停机更换数据库
- 上线同步程序,从旧库中复制数据到新库中,并实时保持同步;
- 上线双写订单服务(改变 DAO 层,支持双写「只写旧、只写新、同步双写」,支持双读「读旧、读新」,可以通过预留接口实现切换),只读写旧库;
- 开启双写,同时停止同步程序(先写旧库、再写新库,以写旧库结果为准「如果旧库成功新库失败,返回成功,记录写新库失败原因;如果旧库失败新库成功,返回失败,不让新库影响现有业务可用性和数据准确性);
- 开启对比和补偿程序,确保新旧数据库数据完全一样(一是停止同步程序和开启双写,这两个过程很难做到无缝衔接,二是双写的策略也不保证新旧库强一致,这时候我们需要上线一个对比和补偿的程序,这个程序对比旧库最近的数据变更,然后检查新库中的数据是否一致,如果不一致,还要进行补偿);
- 逐步切量读请求到新库上;
- 下线对比补偿程序,关闭双写,读写都切换到新库上;
- 下线旧库和订单服务的双写功能。
如果双写切换为单写新库出现问题,可采用类似 3 一样的程序:从双写以旧库为准,过渡到双写以新库为准。然后把比对和补偿程序反过来,用新库的数据补偿旧库的数据。这样就可以做到,一旦出问题,再切回到旧库上了。
海量数据存储
数据量大的几类数据:点击流数据(在 App、小程序和 Web 页面上的埋点数据,记录了用户的行为:比如说打开的页面、使用了哪些按钮、留存时间等)、监控数据、日志数据。
之前存储这些数据倾向于「先计算后存储」,顾名思义,就是先做一些过滤、聚合等初步计算,然后再存储,降低存储系统压力和成本。
随着存储也来也便宜,数据价值被不断挖掘,现在更多倾向于「先存储后计算」,直接保存海量的数据,再对数据进行实时或者批量的计算,主要有以下优点:
- 不需要二次分发就可以同时给多个流和批计算任务提供数据;
- 如果计算任务出错,可以随时回滚重新计算;
- 如果对数据有新的分析需求,上线后直接就可以用历史数据计算出结果,而不用去等新数据。
对存储系统的要求:容量足够大、能够水平扩容、要跟上数据生产的写入以及给下游提供低延迟的读数据服务。常用的解决方案有 Kafka 以及HDFS。
Kafka
Kafka 官方给自己的定位也是“分布式流数据平台”,不只是一个 MQ。Kafka 提供“无限”的消息堆积能力,具有超高的吞吐量,可以满足我们保存原始数据的大部分要求。写入点击流数据的时候,每个原始数据采集服务作为一个生产者,把数据发给 Kafka 就可以了。下游的计算任务,可以作为消费者订阅消息,也可以按照时间或者位点来读取数据。并且,Kafka 作为事实标准,和大部分大数据生态圈的开源软件都有非常好的兼容性和集成度,像 Flink、Spark 等大多计算平台都提供了直接接入 Kafka 的组件。
存在的问题:容量并不是真正无限的,写入数据的时候会将数据均匀分散到分片上,但是分片对应的节点容量终究是有限的,分片总会写满,虽然支持扩容分片数量,但是 不能像其他分布式存储那样重新分配数据,所以扩容分片不能解决已有分片写满的问题,又不支持按照时间维度分片,所以受制于单节点的存储容量,Kafka 实际存储的容量并不是无限的。
HDFS
使用 HDFS 来存储。使用 HDFS 存储数据也很简单,就是把原始数据写成一个一个文本文件,保存到 HDFS 中。
对于保存海量的原始数据这个特定的场景来说,HDFS 的吞吐量是远不如 Kafka 的。按照平均到每个节点上计算,Kafka 的吞吐能力很容易达到每秒钟大几百兆,而 HDFS 只能达到百兆左右。这就意味着,要达到相同的吞吐能力,使用 HDFS 就要比使用 Kafka,多用几倍的服务器数量。
但 HDFS 也有它的优势,第一个优势就是,它能提供真正无限的存储容量,如果存储空间不够了,水平扩容就可以解决。另外一个优势是,HDFS 能提供比 Kafka 更强的数据查询能力。Kafka 只能按照时间或者位点来提取数据,而 HDFS 配合 Hive 直接就可以支持用 SQL 对数据进行查询,虽然说查询的性能比较差,但查询能力要比 Kafka 强大太多了。
扩展
分布式流数据存储:pravega、Pulsar 的存储引擎 Apache BookKeeper
时序数据库:influxdb、OpenTSDB
海量数据查询
将数据存起来之后,还得通过流计算(Flink、Storm 这类的实时计算)或者批计算(Map-Reduce 或者 Spark 这类的非实时计算)将原始数据进行过滤、汇聚等加工计算,将计算结果存到别的存储系统中使用以供业务系统提供查询支持。
如果你的系统的数据量在 GB 量级以下,MySQL 仍然是可以考虑的,因为它的查询能力足以应付大部分分析系统的业务需求。并且可以和在线业务系统合用一个数据库,不用做 ETL(数据抽取),省事儿并且实时性好。这里还是要提醒你,最好给分析系统配置单独的 MySQL 实例,避免影响线上业务。
如果数据量级已经超过 MySQL 极限,可以选择一些列式数据库,比如:HBase、Cassandra、ClickHouse,这些产品对海量数据,都有非常好的查询性能,在正确使用的前提下,10GB 量级的数据查询基本上可以做到秒级返回。高性能的代价是功能上的缩水,这些数据库对数据的组织方式都有一些限制,查询方式上也没有 MySQL 那么灵活。大多都需要你非常了解这些产品的脾气秉性,按照预定的姿势使用,才能达到预期的性能。
另外一个值得考虑的选择是 Elasticsearch(ES),ES 本来是一个为了搜索而生的存储产品,但是也支持结构化数据的存储和查询。由于它的数据都存储在内存中,并且也支持类似于 Map-Reduce 方式的分布式并行查询,所以对海量结构化数据的查询性能也非常好。
最重要的是,ES 对数据组织方式和查询方式的限制,没有其他列式数据库那么死板。也就是说,ES 的查询能力和灵活性是要强于上述这些列式数据库的。在这个级别的几个选手中,强烈建议你优先考虑 ES。但是 ES 有一个缺点,就是你需要给它准备大内存的服务器,硬件成本有点儿高。
New SQL
MySQL经常遇到的高可用、分片问题,NewSQL是如何解决的?
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 都属于 New SQL 数据库。
扩展:
架构:
架构仍然是大部分数据库都采用的二层架构:执行器和存储引擎。它的 SQL 层就是执行器,下面的分布式 KV 存储集群就是它的存储引擎。
采用 Raft 一致性协议来实现每个分片的高可靠、高可用和强一致。
设计文档:Design
隔离级别:CockroachDB 提供了两种隔离级别,是:Snapshot Isolation (SI) 和 Serializable Snapshot Isolation (SSI)
RocksDB
RocksDB:不丢数据的高性能KV存储
理解的是既是个数据库,也是个存储引擎。
读写原理:,使用了 LSM-Tree。全称是:The Log-Structured Merge-Tree,是一种非常复杂的复合数据结构,它包含了 WAL(Write Ahead Log)、跳表(SkipList)和一个分层的有序表(SSTable,Sorted String Table)
我们先来看数据是如何写入的。当 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,用布隆过滤器避免无谓的查找等来加速查找过程。这样综合优化下来,可以获得相对还不错的查找性能。
关联阅读
后端存储实战课——设计篇
后端存储实战课——高速增长篇