对比对象:Apache Parquet C++ 库 + Apache Arrow C++ 库(parquet::arrow::FileReader + Arrow Compute)
用户调用
TsFileReader::query(table, columns, time_range, tag_filter)
│
▼
TableQueryExecutor::query()
├── IMetadataQuerier::getDevices(table, id_filter) ← Tag Filter 在此剪枝设备
│ TsFileMeta(内存索引)中查找匹配 id_filter 的设备列表
│
└── 对每个匹配设备 → DeviceQueryTask
│
▼
TsFileIOReader::alloc_ssi() ← 为每个 measurement 分配 SeriesScanIterator
└── 从 TsFileMeta 中定位 ChunkMeta(offset, size, 统计信息)
│
▼
ChunkReader::load_by_meta() ← 按需从磁盘加载 Chunk(含 PageHeader 列表)
│
└── 对每个 Page:
cur_page_statisify_filter() ← 使用 Page 统计信息(min/max)剪枝
skip_cur_page() ← 不满足则跳过,无 IO
decode_cur_page_data() ← 满足则:
├── Compressor::decompress() (解压缩)
└── Decoder::decode() (解码 → TsBlock)
│
▼
TsBlock(列式内存块,含 time 列 + value 列)
│
▼
TsBlockReader::next()(返回一个 TsBlock)
│
▼
TableResultSet(行迭代器包装)
│
▼
用户:result->next() → get_value<T>(col_index)
| 类 | 职责 |
|---|---|
TsFileReader | 对外 API 入口,持有 ReadFile + TsFileExecutor + TableQueryExecutor |
TableQueryExecutor | 查询编排;协调元数据查询 → 设备迭代 → TsBlock 读取 |
IMetadataQuerier / MetadataQuerier | 从 TsFileMeta 中查设备列表;在 Tag Filter 阶段完成设备级剪枝 |
TsFileIOReader | 文件底层 IO;加载文件 Footer、设备/测量点索引;分配 SSI |
ChunkReader | 核心读取逻辑;load_by_meta → 逐 Page 解压 → 解码 → TsBlock |
Compressor | 解压缩(GZIP / LZ4 / Snappy / Uncompressed) |
Decoder | 解码(Gorilla / Sprintz / RLE / Delta / Plain 等) |
TsBlock | 列式内存格式(VectorDesc 数组),ChunkReader 输出单元 |
TableResultSet | 包装 TsBlockReader,提供行迭代器 API(next() / get_value<T>()) |
Filter | 抽象过滤基类;子类:EqualToFilter, BetweenFilter, AndFilter, TagFilter, TimeFilter |
TsFile 物理布局: ┌────────────────────────────────┐ │ Magic + Version │ ├────────────────────────────────┤ │ Chunk (Device_0, s1) │ ← 一个设备的一列所有数据连续存储 │ PageHeader | PageData │ │ PageHeader | PageData │ │ ... │ ├────────────────────────────────┤ │ Chunk (Device_1, s1) │ │ ... │ ├────────────────────────────────┤ │ ... │ ├────────────────────────────────┤ │ File Footer(元数据索引) │ ← TsFileMeta,加载到内存的 PageArena │ DeviceMetadataIndex │ │ MeasurementMetadataIndex │ │ ChunkMeta(offset/size)│ └────────────────────────────────┘
| 场景 | TsFile 优势 |
|---|---|
| Tag Filter 查询 | 设备粒度剪枝,仅加载匹配设备的 Chunk;Parquet 无设备概念,需全列扫描后再 filter |
| 时间范围查询 | Page 级 min/max 统计,可跳过整页 IO;Parquet Row Group 统计粒度更粗 |
| 时序数据编码 | Gorilla(浮点)/ Sprintz / Delta(时间戳)专为时序优化;Parquet 通用编码在时序上压缩率低 |
| 写入语义一致性 | Tag 是一等公民,查询接口天然支持设备过滤 |
| 编号 | 瓶颈 | 说明 |
|---|---|---|
| P1 | 行式结果集 API | TableResultSet::next() 每次返回一行;Arrow 直接操作列内存,无行迭代开销 |
| P2 | 字符串列名查找 | get_value("s1") 每次查 map;Arrow 通过列索引直接访问 |
| P3 | TsBlock 未直接暴露给用户 | 内部已有列式 TsBlock,但最终被包装为行迭代器,额外开销 |
| P4 | 非向量化解码路径 | decode 函数逐值处理;Arrow 解码路径为 SIMD 优化的列式批处理 |
| P5 | 无 Arrow 格式输出接口 | Arrow compute/analytics 生态无法直接对接 TsFile 输出 |
| P6 | 单线程 Chunk 读取 | 同一个查询内各设备的 Chunk 串行读取;可并行 |
Parquet 读取路径:
parquet::arrow::OpenFile()
│
▼
FileReader::ReadTable(column_indices) ← 列投影(仅加载所需列)
├── Row Group 统计信息 (min/max) → 粗粒度剪枝
└── Column Chunk 解码 → Arrow ChunkedArray
│
▼
Arrow Table(所有列在内存中为 Arrow 格式 ChunkedArray)
│
▼
Arrow Compute:
CallFunction("equal", {id1_col, scalar}) → 布尔 mask
CallFunction("filter", {s1_col, mask}) → 过滤后 ChunkedArray
CallFunction("sum", {filtered}) → Scalar 结果
│
▼
全程向量化,SIMD 加速
Parquet+Arrow 劣势(TsFile 可以利用的点):
| 优先级 | 优化点 | 预期收益 |
|---|---|---|
| 高 | 暴露批量读取 API(TsBlock 级) | 消除 P1/P2,Full Scan 大幅提速 |
| 高 | 列索引替代列名字符串查找 | 消除 P2,每行节省 map 查找 |
| 中 | Tag Filter + 列投影组合 | 同时剪枝设备 + 只解码所需列 |
| 优化点 | 说明 |
|---|---|
| SIMD 友好解码路径 | 对 Plain/RLE 编码使用 SIMD 批量解码,对齐 Arrow 的向量化优势 |
| Arrow IPC 格式输出接口 | 将 TsBlock 直接转换为 Arrow RecordBatch,打通 Arrow compute 生态 |
| Predicate pushdown to Page level | 将 field filter(如 s1 > 100)推入 Page 解码阶段,减少解码量 |
| 优化点 | 说明 |
|---|---|
| 多线程并行 Chunk 读取 | 不同设备 Chunk 并行加载 + 解码,充分利用多核 |
| IO prefetch | 异步预取下一个 Chunk,隐藏 IO 延迟 |
| 列式存储布局优化 | 对高频访问 field 列,考虑调整 Chunk 边界以提高 IO 局部性 |
Table: bench_table Columns: time (TIMESTAMP/INT64) — 单调递增,隐式列(TsFile)/ 显式列(Parquet) id1 (TAG, STRING) — 设备标识,值: "device_0" ~ "device_9" id2 (TAG, STRING) — 固定值 "tag_b" s1 (FIELD, INT64) — 值 = 时间戳,用于 checksum s2 (FIELD, DOUBLE) — 值 = ts * 1.1 s3 (FIELD, FLOAT) — 值 = ts % 10000 s4 (FIELD, INT32) — 值 = ts % 100000 压缩:SNAPPY(两侧对齐;TsFile TAG 列为 UNCOMPRESSED,FIELD 列为 SNAPPY) 规模: 设备数 (kNumDevices): 10 每设备行数: row_count / 10 (默认 100,000) 总行数 (row_count): 1,000,000 时间戳分布: device_d 拥有 [d*100K, (d+1)*100K)
| 场景 | 描述 | 结果行数 | 理论优势方 |
|---|---|---|---|
| Tag Filter | id1="device_0",计算 sum(s1) | 100K(1/10) | TsFile(设备级剪枝) |
| Time Filter | time ∈ [0, 500K),计算 sum(s1) | 500K(1/2) | TsFile(Page 级时间剪枝) |
两侧产生相同 checksum(sum_s1),以此验证结果正确性。计算方式不要求对等(TsFile 行迭代 vs Parquet 向量化,这正是要优化的差距)。
# 构建 TsFile 主库 cmake -B build -S . -DCMAKE_BUILD_TYPE=Release cmake --build build --target tsfile -j # 构建 read_perf_compare(需要系统安装 apache-arrow) cmake -B examples/build -S examples \ -DENABLE_READ_PERF_COMPARE=ON \ -DCMAKE_PREFIX_PATH="$(brew --prefix apache-arrow)/lib/cmake" \ -DCMAKE_BUILD_TYPE=Release cmake --build examples/build --target read_perf_compare -j # 运行(默认 1,000,000 行) ./examples/build/read_perf_compare 1000000
-O3,-DCMAKE_BUILD_TYPE=Release| 场景 | 结果行数 | TsFile 耗时(s) | TsFile 吞吐(rows/s) | Parquet+Arrow 耗时(s) | Parquet+Arrow 吞吐(rows/s) | 差距(倍) |
|---|---|---|---|---|---|---|
TAG_FILTER (id1="device_0") | 100K | 0.147 | 681,356 | 0.057 | 1,755,389 | 2.6× |
TIME_FILTER (time ∈ [0, 500K)) | 500K | 0.731 | 683,886 | 0.050 | 9,933,783 | 14.5× |
checksum 两侧一致(TAG: 4,999,950,000 / TIME: 124,999,750,000)
运行方式:
cmake --build examples/build -j && ./examples/build/examples
ResultSet 迭代层next() 行迭代,无法利用已有的列式 TsBlock 内存格式;暴露批量/列式读取接口是首要优化方向TS2DIFFDecoder::has_remaining() 缺少 header_peeked_ 检查
src/encoding/ts2diff_decoder.h:226peek_next_block_range_int64() 读取最后一个 TS2DIFF 块的 header(24 字节)后,ByteStream 已空。随后 has_remaining() 返回 false(未检查 header_peeked_ 标志),导致 read_batch_int64() 返回 time_count=0,丢失最后一个 partial block(67 个值)prev_time_page_not_finish()=false / prev_value_page_not_finish()=true 不匹配 → get_next_page() 跳过后续所有页 → 每设备只读 1 页(9933 行),而非 10 页(100000 行)has_remaining() 中增加 header_peeked_ 条件:return header_peeked_ || bits_left_ != 0 || (current_index_ <= write_index_ && write_index_ != -1 && current_index_ != 0);
read_batch_int64):一次解码 129 个时间戳(对应一个 TS2DIFF 块),避免逐值虚函数调用peek_next_block_range_int64):读取 TS2DIFF 块 header 获取 [first_value, first_value + write_index * delta_min] 时间范围,整块不满足时直接跳过解码read_batch_int32/int64/float/double):与时间批量解码配对,一次读取一批值satisfy_batch_time):对一批时间戳做批量 filter 判断,返回 pass 掩码测试数据:10 设备 × 100K 行/设备 = 1M 总行,4 列 FIELD(s1=INT64, s2=DOUBLE, s3=FLOAT, s4=INT32)
| 场景 | 结果行数 | TsFile (row) | TsFile (batch) | Parquet+Arrow | TsFile batch vs Parquet |
|---|---|---|---|---|---|
TAG_FILTER (id1="device_0") | 100K | 0.0222s / 4.5M rows/s | 0.0105s / 9.5M rows/s | 0.0745s / 1.3M rows/s | 7.1× 快 |
TIME_FILTER (time ∈ [0, 333333)) | 333K | 0.0741s / 4.5M rows/s | 0.0364s / 9.2M rows/s | 0.0513s / 6.5M rows/s | 1.4× 快 |
checksum 两侧一致(TAG: 4,999,950,000 / TIME: 55,555,277,778)
| 场景 | Baseline TsFile (row) | 优化后 TsFile (row) | 优化后 TsFile (batch) | 行模式提速 | 批量模式提速 |
|---|---|---|---|---|---|
| TAG_FILTER | 0.147s / 681K rows/s | 0.022s / 4.5M rows/s | 0.0105s / 9.5M rows/s | 6.6× | 14× |
| TIME_FILTER | 0.731s / 684K rows/s | 0.074s / 4.5M rows/s | 0.036s / 9.2M rows/s | 9.9× | 20× |
注:Baseline 受 has_remaining bug 影响,每设备只读约 1/10 的数据。修复 bug 后行模式吞吐约 4.5M rows/s,批量模式约 9.5M rows/s。
自身时间(self time)Top 10:
| 采样数 | 占比 | 函数 | 说明 |
|---|---|---|---|
| 49 | 8.6% | ByteStream::read_buf | Gorilla/TS2DIFF 底层字节读取 |
| 29 | 5.1% | _platform_memmove | 解压缩/向量拷贝中的内存搬运 |
| 22 | 3.9% | TableResultSet::next | 行迭代器开销(仅 row 模式) |
| 21 | 3.7% | GorillaDecoder::read_long | Gorilla 解码核心(逐值读 bit) |
| 19 | 3.3% | GorillaDecoder::flush_byte_if_empty | Gorilla 字节刷新 |
| 17 | 3.0% | Field::set_value | 行模式每行填充 Field 对象 |
| 11 | 1.9% | FixedLengthVector::append | TsBlock 列追加 |
| 10 | 1.8% | double_DECODE_TV_BATCH | 批量解码自身 |
| 9 | 1.6% | TS2DIFFDecoder::read_batch_int64 | 时间戳批量解码 |
| 9 | 1.6% | FixedLengthVector::read | TsBlock 列读取 |
关键瓶颈分析:
GorillaDecoder::read_long + flush_byte_if_empty + ByteStream::read_buf 合计约 140 采样。Gorilla 仍为逐值解码(bit-by-bit),是当前最大解码瓶颈。TS2DIFF 已有批量路径(仅 9 采样),Gorilla 尚无。TableResultSet::next(22)+ Field::set_value(17)+ CaseInsensitiveHash/Equal(~80 采样来自字符串列名 map 查找)。batch 模式完全跳过此层。下一步优化方向(按收益排序):
| 优先级 | 优化 | 预期收益 |
|---|---|---|
| 高 | Gorilla 批量解码(read_batch_float/double) | 减少 Gorilla 25% 开销,预期 batch 路径再提速 30-50% |
| 中 | 行模式列名查找缓存(column index) | row 模式提速 ~20%,但 batch 模式不受影响 |
| 中 | TS2DIFF SIMD 解包(128-delta block) | 进一步优化时间戳解码,但当前仅 1.6%,收益有限 |
| 低 | Arrow IPC 输出接口 | 打通 Arrow compute 生态 |