3. 存储模型对比:行存储 vs 列存储;LSM-Tree vs B+Tree;日志结构合并树详解
好,咱们进入正题。这一节我打算把存储模型这块掰开了揉碎了讲清楚。说实话,我在做量化系统之前,也踩过不少存储选型的坑。Tick 数据这东西,一天就能产生几个 TB,选错了存储模型,后面想改都来不及。
3.1 行存储 vs 列存储:两种思维方式的碰撞
先聊聊最基本的。行存储和列存储,说白了就是数据在磁盘上怎么摆的问题。
行存储,就是把一整行的数据挨着放。比如一条 Tick 数据包含时间、价格、成交量、买卖盘口……这些字段全部连续写在一起。你查一条完整记录时,一次 IO 就能全拿到,非常痛快。
列存储呢,是把所有行的同一个字段放在一起。时间列单独一个文件,价格列单独一个文件。你想想看,如果你只想算某只股票一天的平均价,列存储只需要读价格那一列,其他字段碰都不碰。
我个人的习惯是:OLTP 场景用行存,OLAP 场景用列存。但 Tick 数据比较特殊——它既需要高频写入,又需要按时间范围做聚合查询。这就尴尬了。
核心结论:Tick 级存储引擎,通常采用混合策略。写入时用行存保证吞吐,查询时用列存加速分析。
来看一个简单的对比表格:
| 维度 | 行存储 | 列存储 |
|---|---|---|
| 写入性能 | 优秀(一次写一行) | 较差(需要拆列写入) |
| 点查询(查某条记录) | 极快 | 慢(需要拼接多列) |
| 范围聚合(如 AVG、SUM) | 慢(读全行数据) | 极快(只读目标列) |
| 压缩率 | 低(数据类型混杂) | 高(同类型数据连续) |
| 典型代表 | MySQL InnoDB、PostgreSQL | ClickHouse、Parquet |
我在项目中遇到过一个问题:用纯行存存 Tick 数据,做回测时查 10 天的数据要扫整个表,慢得让人抓狂。后来改成列存,同样的查询快了 20 倍。但写入性能又下降了。嗯,这里要注意——没有银弹。
3.2 LSM-Tree vs B+Tree:两种索引哲学的较量
聊完存储布局,咱们再深入一层,看看索引结构。B+Tree 和 LSM-Tree,这是目前最主流的两种存储引擎内核。
B+Tree:传统关系型数据库的基石
B+Tree 大家应该不陌生。MySQL 的 InnoDB 就是用它。它的特点是:读快写慢。为什么?因为每次写入都要找到对应的叶子节点位置,然后做原地更新。如果数据量大了,随机 IO 就成了瓶颈。
我曾经调过一个 MySQL 实例,每秒写入 10 万条 Tick 数据,结果磁盘 IO 直接打满,延迟飙升到几百毫秒。后来一查,B+Tree 的页分裂太频繁了。
LSM-Tree:为写入而生的设计
LSM-Tree 全称是 Log-Structured Merge-Tree,日志结构合并树。它的思路完全反过来:写快读慢。
写入时,数据先写到内存里的 MemTable,满了就刷到磁盘变成 SSTable(Sorted String Table)。这个过程是顺序写,速度极快。但读取时,需要从多个 SSTable 里合并查找,所以读性能不如 B+Tree。
我的建议:如果你的场景是写多读少,比如 Tick 数据采集入库,LSM-Tree 是首选。如果读多写少,比如行情查询服务,B+Tree 更合适。
来看一个代码层面的对比,假设我们要插入一条 Tick 数据:
// B+Tree 写入流程(简化)
1. 查找 B+Tree,定位叶子节点位置
2. 如果叶子节点已满,触发页分裂
3. 写入数据到叶子节点
4. 更新索引(随机 IO)
// LSM-Tree 写入流程(简化)
1. 写入 WAL(预写日志,顺序 IO)
2. 写入 MemTable(内存,极快)
3. MemTable 满了,刷盘为 SSTable(顺序 IO)
4. 后台异步合并 SSTable(Compaction)
你看,LSM-Tree 的写入几乎全是顺序 IO,这在机械硬盘时代是巨大的优势。即使现在用 NVMe SSD,顺序写也比随机写快不少。
3.3 日志结构合并树详解:LSM-Tree 的核心机制
好,这部分我打算讲细一点。LSM-Tree 虽然名字听着唬人,但核心机制其实就三个:分层、合并、压缩。
3.3.1 分层结构
LSM-Tree 的数据是分层的。通常有 Level 0 到 Level N。Level 0 是最新写入的数据,Level N 是历史数据。每一层的数据量呈指数级增长。
举个例子:
- Level 0:最多 4 个 SSTable,每个 4MB
- Level 1:最多 10 个 SSTable,每个 32MB
- Level 2:最多 10 个 SSTable,每个 256MB
- ……以此类推
为什么要分层?说白了就是为了控制合并的代价。如果所有数据都混在一起,合并一次就要扫全量数据,那谁也扛不住。
3.3.2 合并策略(Compaction)
合并是 LSM-Tree 最核心的操作。当某一层的数据量超过阈值,就会触发合并,把数据推到下一层。
常见的合并策略有两种:
- Size-Tiered Compaction:等某一层有足够多的 SSTable 时,一次性合并。适合写入量大、查询少的场景。
- Leveled Compaction:逐层合并,每次只合并少量 SSTable。适合查询多的场景,因为每层的数据更有序。
我记得有一次,我用 Leveled Compaction 策略跑 Tick 数据入库,结果 Compaction 线程把 CPU 吃满了,写入延迟飙升。后来改成 Size-Tiered,虽然查询慢了点,但写入稳定多了。嗯,这就是取舍。
3.3.3 压缩与布隆过滤器
LSM-Tree 还有一个杀手锏:压缩。因为 SSTable 是顺序写入的,数据局部性好,压缩率通常很高。Tick 数据的时间戳、价格等字段,用差值编码或者字典编码,压缩率能达到 5:1 甚至 10:1。
另外,每个 SSTable 都会带一个布隆过滤器。查询时先过布隆过滤器,如果判断数据不在这个 SSTable 里,就直接跳过,不用实际读取。这能大幅提升查询性能。
避坑指南:我曾经因为布隆过滤器的假阳性率设置得太低(0.1%),导致查询时多读了很多不必要的 SSTable。后来调到 1%,内存占用少了 30%,查询性能反而提升了。别迷信过低的假阳性率,要根据实际数据量来调。
3.4 存储模型选型建议
说了这么多,最后给点实际的建议。对于 Tick 级数据存储引擎,我个人推荐这样选:
- 写入层:用 LSM-Tree 引擎(如 RocksDB、LevelDB),保证高吞吐写入。
- 查询层:用列存格式(如 Parquet、ORC),加速聚合分析。
- 缓存层:用 B+Tree 引擎(如 SQLite),缓存最近的热点数据。
说白了,没有哪个存储模型是万能的。Tick 数据的特殊性决定了我们必须组合使用。你想想看,一天几亿条数据,如果只用一个引擎,要么写不进去,要么查不出来。
好,这一节就到这里。下一节我会讲具体的存储引擎实现,包括 RocksDB 的调优经验和 ClickHouse 在 Tick 数据上的实战。咱们到时候见。
公众号:蓝海资料掘金营,微信deep3321