r很多大数据组件在快速原型时期都是Java实现,后来因为GC不可控、内存或者向量化等等各种各样的问题换到了C++,比如zookeeper->nuraft(https://www.yuque.com/treblez/qksu6c/hu1fuu71hgwanq8o?singleDoc# 《olap/clickhouse keeper 一致性协调服务》),kafka->redpanda(https://www.yuque.com/treblez/qksu6c/ugig8y358fyyg5lp?singleDoc# 《Clickhouse blob阅读笔记(一)》)之类的。
但是nuraft和redpanda估计大部分人都没听说过,因为绝大多数情况下,zk和kafka这种也就足够了。数据量越大,Java和C++的性能差别也就越小。
Elasticsearch这里也是一样,它也有C++的替代品manticoresearch,可以看看官方的测试结果(https://github.com/manticoresoftware/manticoresearch),提升还是挺大的。
同样,大多数情况下选型还得是Elasticsearch,因为这东西生态好,省去了大量踩坑的成本,最关键的是性能也足够了,百万数据几十毫秒延迟,换C++意义也不大。所以我们今天不看manticoresearch,还是学一下Elasticsearch。
ES接近三百万行代码,那肯定不能直接看,本文主要还是结合《Elasticsearch源码解析与优化实战》梳理下它在分布式、数据结构、搜索方面用到的一些核心的东西。ES其实是对lucene的分布式改造,在上面加了一些共识、主备、选举、高可用之类的东西。
顺带说一下,它和CK的场景是不同的,ES主要面对非结构化数据查询,CK面对的是OLAP场景。
如何解决深度分页问题?
- scoll 使用的是快照,没法保证实时
- from+size 最差的实现,内存占用和时间都很差
- search_after 通过记录自增id来查询,减少了内存占用
lucene如何实现倒排索引的?
索引
es由_index、_type和_id唯一标识一个文档。
_index(索引)指向一个或多个物理分片的逻辑命名空间,_type类型用于区分同一个集合中的不同细分,在不同的细分中,数据的整体模式是相同或相似的,不适合完全不同类型的数据。多个_type可以在相同的索引中存在,只要它们的字段不冲突即可(对于整个索引,映射在本质上被“扁平化”成一个单一的、全局的模式)。_id文档标记符由系统自动生成或使用者提供。
ES的结构是怎样的?index、node、shard、segment、field、term的关系?
一个ES Index(索引)包含很多shard(分片),一个shard是一个Lucene索引,Lucene索引由很多分段组成,每一个分段都是一个倒排索引。Lucene索引可以独立执行建立索引和搜索的任务。ES每次“refresh”都会生成一个新的分段,其中包含若干文档的数据。在每个分段内部,文档的不同字段被单独建立索引。每个字段的值由若干词(Term)组成,Term是原文本内容经过分词器处理和语言处理后的最终结果(例如,去除标点符号和转换为词根)。
ES通过文件页缓存的flush机制(可以看https://www.yuque.com/treblez/qksu6c/yxl59pkvczqot9us?singleDoc# 《ptmalloc:从内存虚拟化说起》)实现了近实时查询。每秒产生一个新分段,新段先写入文件系统缓存,但稍后再执行flush刷盘操作,写操作很快会执行完,一旦写成功,就可以像其他文件一样被打开和读取了。
近实时查询其实就是最终一致,最终一致要保证没有易失性,ES通过事务日志做到了这一点。
段合并
ES段合并
在ES中,每秒清空一次写缓冲,将这些数据写入文件,这个过程称为refresh,每次refresh会创建一个新的Lucene 段。但是分段数量太多会带来较大的麻烦,**每个段都会消耗文件句柄、内存。每个搜索请求都需要轮流检查每个段,查询完再对结果进行合并;所以段越多,搜索也就越慢。**因此需要通过一定的策略将这些较小的段合并为大的段,常用的方案是选择大小相似的分段进行合并。在合并过程中,标记为删除的数据不会写入新分段,当合并过程结束,旧的分段数据被删除,标记删除的数据才从磁盘删除。
HBase、Cassandra等系统都有类似的分段机制,写过程中先在内存缓冲一批数据,不时地将这些数据写入文件作为一个分段,分段具有不变性,再通过一些策略合并分段。分段合并过程中,新段的产生需要一定的磁盘空间,我们要保证系统有足够的剩余可用空间。Cassandra系统在段合并过程中的一个问题就是,**当持续地向一个表中写入数据,如果段文件大小没有上限,当巨大的段达到磁盘空间的一半时,剩余空间不足以进行新的段合并过程。**如果段文件设置一定上限不再合并,则对表中部分数据无法实现真正的物理删除。ES存在同样的问题。
LSM-Tree段合并
我们都知道,LSM-Tree脱胎于BigTable,LevelDB,RocksDB,HBase,Cassandra等都是基于LSM结构。这里既然提到了HBase、Cassandra,那就不得不看下LSM-Tree的段合并了。
我们回顾下LSM-Tree的读写过程:
- 写入时,数据会被添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树被称为内存表(memtable)。 当内存表大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。这可以 高效地完成,因为树已经维护了按键排序的键值对。新的SSTable文件成为数据库的最新 部分。当SSTable被写入磁盘时,写入可以继续到一个新的内存表实例。
- 读取时首先尝试在内存表中找到关键字,然后在最近的磁盘段中,然后在 下一个较旧的段中找到该关键字。
(下面来自https://zhuanlan.zhihu.com/p/462850000)
sst 是不可修改的,数据的更新和删除都是以写入新记录的形式呈现。数据在文件中是按 key 有序组织的,利于高效地查询和后续合并。(可以参考数据密集型应用系统设计第三章)
随着数据的不断写入和更新,sst 的数量会不断增加,进而会出现两个问题:
- sst 中可能存在修改前的老数据和已经删除的数据,这些无用数据会占用存储空间,造成资源浪费;
- 由于 sst 越来越多,数据分散在多个文件,读取时,可能会访问多个文件,导致读性能下降。对于上述问题,需要一些机制去解决,这种机制就称为 compaction,compaction 的目的是将多个 sst 合并成一个,在合并的同时将无用的数据清理掉,合并成的新文件也是按 key 排序的。可以看到,通过 compaction,很好地解决上述问题。
LSM-Tree的常用合并算法有什么?
memtable sstable
**Size-Tiered Compaction Strategy (STCS) **
memtable 逐步刷入到磁盘 sst,刚开始 sst 都是小文件,随着小文件越来越多,当数据量达到一定阈值时,STCS 策略会将这些小文件 compaction 成一个中等大小的新文件。同样的道理,当中等文件数量达到一定阈值,这些文件将被 compaction 成大文件,这种方式不断递归,会持续生成越来越大的文件。总的来说,STCS 就是将 sst 按大小分类,相似大小的 sst 分在同一类,然后将多个同类的 sst 合并到下一个类别。通过这种方式,可以有效减少 sst 的数量。
由于 STCS 策略比较简单,同一份数据在 compaction 期间拷贝的次数相对较少,即写入放大相对小,很多基于 LSM-Tree 的系统将其作为默认的 compaction 策略,如 Lucene、Cassandra、Scylla 等。STCS 逻辑简单、写入放大低,但是它也有很大的缺陷 – 空间放大。其实也存在较大的读放大
为什么会产生空间放大呢?compaction 的过程中,参与 compaction 的 sst 不能立马删除,直到新生成的 sst 写入完毕,这里其实还有一个原因,如果老的 sst 有读操作,由于文件还被引用,也是不能立即删除的。因此,在 compaction 的过程中,磁盘上新老文件共存,产生临时空间放大。即使这种空间放大是临时的,但是对于系统来说,不得不使用比实际数据量更大的磁盘空间,以保证 compaction 正常执行,这产生的代价很昂贵。
这也就是上面一节提到的那个问题。
**Leveled Compaction Strategy (LCS) **
- sst 的大小可控,默认每个 sst 的大小一致(STCS 在经过多次合并后,层级越深,产生的 sst 文件就越大,最终会形成超大文件)
- LCS 在合并时,会保证除 Level 0(L0)之外的其他 Level 有序且无覆盖
- 除 L0 外,每层文件的总大小呈指数增长,假如 L1 最多 10 个,则 L2 为 100 个,L3 为 1000 个…
首先是内存中的 memtable 刷到 L0,当 L0 中的文件数达到一定阈值后,会将 L0 的所有文件及与 L1 有覆盖的文件做合并,然后生成新文件(如果文件大小超过阈值,会切成多个)到 L1,L1 中的文件时全局有序的,不会出现重叠的情况;
当 L1 的文件数量达到阈值时,会选取 L1 中的一个 sst 与 L2 中的多个文件做合并,假设 L1 有 10 个文件,那么一个文件便占 L1 数据量的 1/10,假设每层包含的 key 范围相同,那么 L1 中的一个文件理论上会覆盖 L2 层的 10 个文件,因此会选取 L1 中的一个文件与 L2 中的 10 个文件一起 compaction,将生成的新文件放到 L2;
LCS 不会有超大文件,而且在层与层之间合并时,大体上只选取 11 个 sst 进行