上一篇文章 极致性能背后的黑科技?这个世上没有“银弹”!(一), 本质讲的就是一件事,"越多的并发处理,越快的速度"。那么怎么尽可能的提高并发呢?通过机器节点的横向扩展,单机cpu核数的充分利用,单个cpu SIMD 单指令处理更多的数据。
而本篇文章,将从数据量这一角度分析StarRocks是如何让查询速度更快,简而言之,就是"越少无效的数据参与计算,越快的速度"。我们将从存储引擎的读写之道说起,以及LSM树是为了解决什么问题,为啥要用列式存储?再到数据的更新与查询速度之间的trade off,以及如何减少无效数据的读取,计算等,揭示查询加速的秘密。
存储引擎的读写之道
我们先来简化下架构,一个存储引擎无非涉及到"读"和"写"两个操作,追求的目标是,读得越快,写得越快!事实上,如果是顺序读写,就是不涉及到数据的更新和删除,其实是比较简单的,因为磁盘顺序IO的效率很高,典型的代表像Kafka,Hdfs,它们都不涉及到对某条数据的更新和删除操作,这也是这两个框架基本上不存在性能方面的问题,因为架构简单,只涉及到顺序读,顺序写。
LSM树:解决随机写IO问题
而一旦涉及到更新操作,麻烦就来了,这时候就避免不了磁盘随机IO,目前主流的做法就是利用LSM树将随机写操作转化为顺序写操作,显著减少了磁盘随机I/O的开销,从而提升写性能。LSM树通过其日志结构和分层合并机制,专门优化了随机写操作,减少磁盘随机I/O。
- 写入流程:
-
1.数据先写入内存中的MemTable,这是一个快速的内存操作,不涉及磁盘I/O。
-
2.当MemTable达到一定大小后,数据被顺序写入磁盘上分层的sst文件,这是一个顺序I/O操作。
-
3.再通过compaction任务,周期性对sst文件进行合并。
-
4.所有写操作(包括更新和删除)都以追加方式记录到日志文件中,避免了原地更新导致的随机写。
LSM树将应用程序的随机写请求转化为内存操作+顺序磁盘写,减少了随机写I/O,进而提供写入性能。
列式存储:查询加速的基石
列式存储有哪些好处?以及为啥OLAP场景下适合用列式存储?
- 高效的数据压缩,列式存储将同一列的数据存储在一起,数据类型和值通常具有较高的相似性,便于压缩算法应用,从而显著减少存储空间占用。
- 列选择:查询时只需读取相关列的数据,减少I/O操作,尤其适合OLAP分析型查询。
- 向量化执行:列式存储支持向量化处理,CPU可以高效处理批量数据,提升查询速度。
- 聚合操作高效:对单列的聚合操作(SUM/AVG)更快,因为数据连续存储,缓存命中率高。
缺点则是频繁的更新操作会造成很大的开销,
读写权衡:更新策略的trade-off
数据更新引入了读写性能的权衡,常见的像Copy on write, Merge on read, Delete and insert,StarRocks通过采用Delete and insert如何提升读写性能,以及有效减少无效数据的scan。
**
**
1.Copy on write
在数据写入时对需要更新的数据copy一份,立即对数据进行重写更新,生成新的数据文件,确保查询时无需额外的合并操作。这种方式的核心优势是查询性能高,因为查询直接读取最新的数据文件,不需要做任何其他额外的操作,但写入性能较低,因为每次更新都需要重写受影响的数据文件。
比如,某次更新操作,修改pk=1对应的value=200, 同时插入一条新数据:pk=3, value=200。在这种模式下,由于在写入的同时需要copy一份数据并重写,写的时候开销比较大,读则效率很高。
**
**
2.Merge on read
Merge on read 主要用于处理大数据场景中的增量数据更新,通过将数据分为原始数据(通常是大批量、只读的数据)和增量数据(较小规模的更新、插入或删除操作),在查询时动态合并这两部分数据来提供最终结果。这种方式的核心优势是写入性能高,因为增量数据可以快速写入,而合并操作推迟到读取时进行。
比如,某次更新,修改pk=1对应的value=200, 同时插入一条新数据:pk=3, value=200。写的时候只记录增量更新的信息,不对原始数据文件做任何操作,相当于顺序写,而读的时候需要进行merge,得到最终结果,读开销比较大。
**
**
3.Delete and insert
通过将更新操作分解为逻辑删除(标记旧记录为已删除)和插入新记录来实现数据的修改,而不是直接修改现有记录。DeleteandInsert模式适用于需要简单实现数据更新、支持事务一致性且能高效处理批量操作的场景。
比如,某次更新,修改pk=1对应的value=200, 同时插入一条新数据:pk=3, value=200。只需要把原来pk=1的记录标记为删除,同时直接插入新的记录:pk=1, value=200。通过维护一个Delete数组之类的,写的时候对该数组进行更新,同时顺序写入新的数据,读的时候把被标记删除的记录过滤掉即可。
Delete and insert带来的好处,以下面这张图为例,执行一个sql,"select count(*), sum(revenue) from orders where state=2",如果是merge on read模式,谓词的过滤条件必须是把两个文件merge之后才能进行filter过滤,这时候scan扫描的数据量就多,而如果能把 "state=2" 这个谓词条件下推到scan层面,就能大大减少scan扫描的数据量,从而提高查询性能,Delete and Insert这种模式就能带来这种好处,而且还节省了merge操作开销,因为相同的key只需要读取最新一个,其他都是被标记删除的。
**
**
索引,进一步减少无效数据扫描
索引有助于快速定位到满足查询条件的数据。具体来说,如果表中基于一些列构建了索引,那么查询时如果能用上这些索引,就不需要扫描全表,只需要读取部分数据,就能快速定位到满足条件的数据的位置,从而提高查询效率。StarRocks提供了很多索引,有自动创建的索引,称为内置索引,包括前缀索引、Ordinal 索引、ZoneMap 索引。也有用户手动创建索引,包括 Bitmap 索引和 Bloom filter 索引等。
以前缀索引为例,看看前缀索引是如何减少scan的数据量。以下面这张orders表为例,其中order_id,order_date是排序键,starrocks会默认为排序键创建前缀索引;customer_id不是排序键,看下分别以order_id, customer_id作为查询条件的差别。
CREATE TABLE orders (
order_id BIGINT NOT NULL,
order_date DATE NOT NULL,
customer_id INT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL
)
ENGINE=OLAP
DUPLICATE KEY(order_id, order_date)
DISTRIBUTED BY HASH(order_id) BUCKETS 4
PROPERTIES (
"replication_num" = "1"
);
插入1200条数据,其中order_id是排序键,customer_id不是排序键
可以看到,排序让扫描的tablet减少,减少了更多无效的数据参与。
**
**
RuntimeFilter,优化运行时参与计算的数据量
- 多表 JOIN 时的数据扫描量过大: 在多表 JOIN 操作中,如果不对数据进行有效过滤,可能会扫描大量无关数据,导致高磁盘 I/O 和网络 I/O 开销,查询性能低下。Runtime Filter 通过动态生成过滤条件,减少需要扫描和处理计算的数据量。
- Shuffle 阶段的网络传输开销: 在分布式环境中,JOIN 操作可能涉及数据在节点间的 Shuffle(数据重分布)。如果数据量大,Shuffle 会带来显著的网络开销。Runtime Filter 通过在 JOIN 前过滤掉不必要的数据,降低 Shuffle 的数据量。
- ** 更多大数据干货,欢迎关注我的微信公众号—BigData共享**