嵌入式设备、容器限额节点和边缘计算终端等资源受限环境对 TsFile 写入提出了严格的内存约束。在这些场景中,可用内存通常仅为数十 MB,而写入的时间序列数据量可能达到数亿行。用户面临以下困境:
flush() 将数据落盘;现有的 TsFileWriter / TsFileTableWriter 将上述三个决策完全交给用户,使用者需要深入理解 TsFile 内部的内存模型才能正确配置参数。
设计目标:
write_table(),flush / close / 文件轮转全部自动处理。通过对写入过程的逐模块内存追踪实验(基于 ModStat 编译期统计框架),确认 TsFile 写入过程中的内存由以下模块组成:
| 模块标识 | 说明 | 特征 |
|---|---|---|
CW_PAGES_DATA | ChunkWriter 已编码的数据页 | 主导项,占峰值 80-85%,flush 后清零 |
DEFAULT | 通用分配(ChunkWriter 对象、ByteStream 等) | 随活跃设备数分配,设备切换时部分释放 |
TABLET | 用户提交的 Tablet 数据缓冲 | 每个 batch 分配/释放,生命周期短 |
PAGE_WRITER_OUTPUT_STREAM | PageWriter 编码输出流 | 随活跃设备数分配,周期性释放 |
STATISTIC_OBJ | 统计对象(min/max/count 等) | 少量,随 flush 次数线性增长 |
ENCODER_OBJ / COMPRESSOR_OBJ | 编码器、压缩器对象 | 少量,随 flush 次数线性增长 |
TSFILE_WRITER_META | ChunkMeta、ChunkGroupMeta 等元数据 | 不可压缩,单调递增,仅 close 时释放 |
TS2DIFF_OBJ | TS_2DIFF 编码差分缓冲 | 少量 |
这些模块可归纳为三类:
$$M_{\text{total}} = M_{\text{init}} + M_{\text{data}} + M_{\text{meta}}$$
| 组成 | 来源 | 释放时机 | 能否控制 |
|---|---|---|---|
| $M_{\text{init}}$ | 固定开销(schema 注册、Writer 对象等) | 程序结束 | 不可控 |
| $M_{\text{data}}$ | ChunkWriter 数据页(CW_PAGES_DATA + DEFAULT + TABLET 等) | flush 后立即释放 | 控制批次大小 $F$ |
| $M_{\text{meta}}$ | ChunkGroup/Chunk 元数据(TSFILE_WRITER_META) | 仅 close 时释放 | 控制每文件 flush 次数 $K$ |
关键洞察:$M_{\text{meta}}$ 是核心约束来源。只要文件不 close,每次 flush 都会向内存中追加一批不可压缩的元数据。设备数越多、flush 次数越多,$M_{\text{meta}}$ 积压越严重。因此,文件轮转的根本原因不是磁盘空间,而是为了释放 $M_{\text{meta}}$。
对于 Table 模式,各量的计算方式如下:
每行数据的内存字节数:
$$s_{\text{tablet}} = 8 + \sum_{\text{all cols}} \text{sizeof}(\text{col_type})$$
$$s_{\text{data}} = \sum_{\text{FIELD cols}} (8 + \text{sizeof}(\text{field_col_type}))$$
$$s = s_{\text{tablet}} + s_{\text{data}}$$
其中 $s_{\text{tablet}}$ 是用户 Tablet 对象占用(包含时间戳 8 字节 + 所有列),$s_{\text{data}}$ 是 ChunkWriter 内部对 FIELD 列的编码缓冲(每列 8 字节 page header + 数据本身)。
每设备每次 flush 的元数据增量:
$$b = n_{\text{field_cols}} \times 104 + 96 \quad \text{(bytes/设备/flush)}$$
其中 104 bytes 来自每个 ChunkMeta 的序列化大小,96 bytes 来自 ChunkGroupMeta。
三类内存的量化公式:
| 组成 | 公式 | 说明 |
|---|---|---|
| $M_{\text{init}}$ | $\approx 900$ KB | 固定,实测值 |
| $M_{\text{data}}$ | $s \times F$ | $F$ = 当前 flush 区间内已写行数 |
| $M_{\text{meta}}$ | $K \times D \times b$ | $K$ = 已 flush 次数,$D$ = 设备数 |
类型字节数映射:
| TSDataType | 字节数 |
|---|---|
| BOOLEAN | 1 |
| INT32 / FLOAT | 4 |
| INT64 / DOUBLE | 8 |
| STRING | 32(保守估计,实际为变长) |
TsFile C++ 实现了基于编译选项 ENABLE_MEM_STAT 的内存追踪框架。其核心组件 ModStat 类通过 cache-line 对齐的原子计数器(ATOMIC_FAA)为每个分配模块(AllocModID)维护实时内存占用统计。所有内存分配通过 mem_alloc(size, mid) / mem_free(ptr) 接口完成,在分配的 8 字节头中记录模块 ID 和大小,从而实现零侵入的逐模块追踪。
该框架仅在编译时开启,不影响生产构建的性能。
实验程序(experiment/write_memory)配置如下:
mem_bench),2 个 TAG 列(STRING)+ 6 个 FIELD 列(INT64, DOUBLE, FLOAT, INT32, INT64, INT32),SNAPPY 压缩memory_threshold 参数)顺序写入模式下,每个 Tablet 仅包含单个设备的数据,10 个设备依次写完。
| Flush 事件 | 触发行数 | Flush 前 | Flush 后 | 释放量 |
|---|---|---|---|---|
| #1 | 32.1M | 41.5 MB | 8.1 MB | 33.4 MB |
| #2 | 70.0M | 42.3 MB | 8.1 MB | 34.2 MB |
| #3 | 104.1M | 48.1 MB | 8.2 MB | 39.8 MB |
| #4 | 133.5M | 38.7 MB | 8.0 MB | 30.7 MB |
| #5 | 171.0M | 38.8 MB | 7.4 MB | 31.4 MB |
| #6 (final) | 200.0M | 26.2 MB | 0.1 MB | 26.1 MB |
顺序模式的特征:在每 20M 行的设备切换边界处,DEFAULT(4.5 → 0.8 MB)和 TABLET(2.75 → 0.48 MB)出现短暂凹陷,形成锯齿状小波动。这是因为旧设备的 Tablet 对象和临时缓冲区被析构,新设备的尚未分配。CW_PAGES_DATA 则不受设备切换影响,持续线性增长直到触发 flush。
混合写入模式下,每个 Tablet 包含全部 10 个设备的数据(每设备 6553 行,按设备分组连续排列)。
| Flush 事件 | 触发行数 | Flush 前 | Flush 后 | 释放量 |
|---|---|---|---|---|
| #1 | 29.0M | 41.8 MB | 12.9 MB | 29.0 MB |
| #2 | 58.1M | 41.8 MB | 7.4 MB | 34.5 MB |
| #3 | 89.1M | 41.7 MB | 12.8 MB | 28.9 MB |
| #4 | 127.5M | 42.0 MB | 7.4 MB | 34.6 MB |
| #5 | 166.1M | 47.6 MB | 12.2 MB | 35.4 MB |
| #6 | 198.6M | 48.6 MB | 12.0 MB | 36.6 MB |
| #7 (final) | 200.0M | 6.4 MB | 0.1 MB | 6.2 MB |
两种模式的关键差异:
| 对比项 | 顺序写入 | 混合写入 |
|---|---|---|
| Flush 后残留内存 | ~8 MB | ~12 MB |
| 自动 flush 次数 | 5 次 | 6 次 |
| 设备切换锯齿 | 有(每 20M 行) | 无 |
PAGE_WRITER_OUTPUT_STREAM 峰值 | 0.46 MB(1 个设备活跃) | 4.59 MB(10 个设备同时活跃) |
| 更接近场景 | 批量导入历史数据 | 实时采集(多设备数据交替到达) |
混合模式下 flush 后残留更高(~12 MB vs ~8 MB),因为所有 10 个设备的 ChunkWriter 同时活跃,其 DEFAULT 分配和 PAGE_WRITER_OUTPUT_STREAM 缓冲在 flush 后仍保留(ChunkWriter 对象本身不销毁,只清空数据页)。
配置:50 设备,50 FIELD 列 int32,总行数 100,000:
| max_rows | 实测内存 | 公式估算 | 误差 |
|---|---|---|---|
| 10,000 | 11.7 MB | 11.05 MB | 5.5% |
| 8,000 | 10.8 MB | 10.3 MB | 4.6% |
| 6,000 | 10.6 MB | 9.63 MB | 9.1% |
| 5,000 | 11.2 MB | 9.75 MB | 12.9% |
公式误差 5-15%,主要来源:变长字符串开销、schema 结构节点、OS 内存分配粒度。该误差范围可通过安全裕度系数覆盖。
当写入总量 $R$(每设备总行数)和设备数 $D$ 均已知时,问题等价于经济订货量(EOQ)模型:$M_{\text{data}}$ 和 $M_{\text{meta}}$ 存在此消彼长的关系——增大批次大小 $F$ 可减少 flush 次数 $K$,降低 $M_{\text{meta}}$,但增大 $M_{\text{data}}$;反之亦然。
最优解使两者在峰值时刻相等:
$$F_{\text{opt}} = \sqrt{\frac{R \times D \times b}{s}}$$
$$K_{\text{opt}} = \frac{R}{F_{\text{opt}}}$$
$$M_{\min} = M_{\text{init}} + 2 \sqrt{R \times s \times D \times b}$$
当 $M_{\min} \leq M_{\text{limit}}$ 时,所有数据可写入单个文件,$F_{\text{opt}}$ 即为最优批次大小。
在流式写入场景中,总量未知,无法使用 EOQ 公式。此时采用预算对半分策略,使单个文件容纳的数据量 $F \times K$ 最大化:
$$M_{\text{avail}} = M_{\text{limit}} - M_{\text{init}}$$
$$F = \frac{M_{\text{avail}}}{2 \times s}, \quad K = \frac{M_{\text{avail}}}{2 \times D \times b}$$
该策略将可用内存均匀分配给 $M_{\text{data}}$ 和 $M_{\text{meta}}$,是数学上使 $F \times K$ 最大的分配方案。
在构造 Writer 时执行可行性校验:
$$M_{\min} = M_{\text{init}} + s + b$$
若 $M_{\min} > M_{\text{limit}}$,则当前 schema 在给定内存约束下不可行,应立即报错并说明最小可用配置。
输入:M_limit, schema, n_devices, total_rows_per_device
1. 从 schema 计算 s, b
2. 可行性检查:M_init + s + b ≤ M_limit,否则报错
3. M_avail = M_limit - M_init
4. 若 total_rows > 0 且 n_devices > 0(总量已知):
B = n_devices × b
F = √(total_rows × B / s)
K = total_rows / F
peak = M_init + s×F + K×B
若 peak ≤ M_limit:返回 {F, K, peak} // EOQ 最优
否则退化为对半分
5. 对半分策略:
D_eff = max(1, n_devices)
F = M_avail / (2 × s)
K = M_avail / (2 × D_eff × b)
返回 {F, max(1, K), M_init + s×F + K×D_eff×b}
$M_{\text{data}}$ 和 $M_{\text{meta}}$ 的释放时机不同,因此需要两级控制:
CW_PAGES_DATA 等缓冲。write_table(tablet):
writer->write_table(tablet) // M_data 增加
accumulated_rows += tablet.rows
if accumulated_rows >= F: // 达到数据预算
flush() // M_data 清零,M_meta 增加 D×b
flush_count++
accumulated_rows = 0
if flush_count × D × b >= rotate_thresh:
rotate_file() // M_meta 清零
两者结合后,$M_{\text{total}}$ 始终受控:
| 阶段 | 内存状态 |
|---|---|
| 写入中(未达 $F$) | $M_{\text{init}} + s \times \text{accumulated} + \text{flush_count} \times D \times b$ |
| flush 后 | $M_{\text{init}} + \text{flush_count} \times D \times b$ |
| rotate 后 | $M_{\text{init}}$ |
由于量化模型存在 5-15% 的估算误差,文件轮转的触发条件引入安全裕度系数:
$$\text{rotate_thresh} = \text{meta_budget} \times \text{SAFETY_FACTOR}$$
其中 $\text{SAFETY_FACTOR} = 0.85$,即提前 15% 触发轮转。
data_budget = s × F meta_budget = M_avail - data_budget rotate_thresh = meta_budget × 0.85
close() 调用时,$M_{\text{data}}$ 已为 0(最后一次 flush 已完成)。峰值为:
$$M_{\text{at_close}} = M_{\text{init}} + M_{\text{meta_final}}$$
由于在 $\text{flush_count} \times D \times b \geq \text{rotate_thresh}$ 时触发 rotate:
$$M_{\text{meta_final}} \leq \text{meta_budget} \times \text{SAFETY_FACTOR} \leq \text{meta_budget} \leq M_{\text{avail}}$$
$$\Rightarrow M_{\text{at_close}} \leq M_{\text{init}} + M_{\text{avail}} = M_{\text{limit}} \quad \checkmark$$
关闭时不会超出内存上限。
TsFileWriter 内部已有一个自适应的 $M_{\text{data}}$ 检查机制,通过 chunk_group_size_threshold_ 控制:
int TsFileWriter::check_memory_size_and_may_flush_chunks() { if (record_count_since_last_flush_ >= record_count_for_next_mem_check_) { int64_t mem_size = calculate_mem_size_for_all_group(); record_count_for_next_mem_check_ = record_count_since_last_flush_ * chunk_group_size_threshold_ / mem_size; if (mem_size > chunk_group_size_threshold_) { flush(); } } }
该机制通过动态调整检查间隔实现自适应——当内存增长速度快时检查更频繁,慢时检查更稀疏。TsFileTableWriter 在构造时将 memory_threshold 参数直接赋值给 chunk_group_size_threshold_:
TsFileTableWriter(WriteFile* writer_file, T* table_schema, uint64_t memory_threshold = 128 * 1024 * 1024) { // ... common::g_config_value_.chunk_group_size_threshold_ = memory_threshold; }
在 MemConstrainedWriter 的设计中,flush 由上层根据累积行数 $F$ 显式触发。底层的 chunk_group_size_threshold_ 设为 data_budget 值,作为双重保险——若单个超大 Tablet 导致 $M_{\text{data}}$ 短暂超出预算,底层机制仍可兜底触发 flush。
TsFile 已有两个可直接测量内存的接口,可替代公式估算:
$M_{\text{data}}$ 的直接测量:TsFileWriter::calculate_mem_size_for_all_group() 遍历所有 ChunkWriter,返回编码后的实际数据大小:
int64_t estimate_max_series_mem_size() { return chunk_data_.total_size() // 已完成页(编码后字节) + page_writer_.estimate_max_mem_size() // 当前页已编码 + encoder pending 上界 + page_header_overhead + statistic_sizeof; }
测量的是编码后的实际大小,高效编码时返回值远小于 $s \times F$,天然适应实际编码效率。
$M_{\text{meta}}$ 的直接测量:所有元数据均从 TsFileIOWriter::meta_allocator_(类型 PageArena)分配,只需给 PageArena 新增 get_total_used_bytes() 方法即可获得精确的 $M_{\text{meta}}$ 大小。
有了直接监控后,运行时控制简化为纯粹的阈值比较:
write_table(tablet):
writer->write_table(tablet)
if writer->get_data_size() >= data_budget:
writer->flush()
if writer->get_meta_size() >= meta_budget:
rotate_file()
原来需要计算的 $F$、$K$、$s$、$b$、$D$ 等参数大部分消失,“最优策略”退化为唯一一个决策:如何在 $M_{\text{avail}}$ 中分配 data_budget 和 meta_budget。
当用户未指定设备数(流式写入场景常见)时,系统进入动态追踪模式:
// 每次 write_table 后
d_in_tablet = tablet.find_all_device_boundaries().size() - 1
if d_in_tablet > observed_max_devices:
observed_max_devices = d_in_tablet
recompute_rotate_thresh() // D 增大 → K 减小 → thresh 减小
recompute_rotate_thresh():
D = max(1, observed_max_devices)
rotate_thresh = meta_budget / (D × b) × SAFETY_FACTOR
observed_max_devices 在单个文件内单调递增(rotate 后重置为 0),设备数越多阈值越小,天然保守。
struct WritingStrategy { int64_t rows_per_flush; // F:每次 flush 的行数 int64_t flushes_per_file; // K:每文件最多 flush 次数 int64_t estimated_peak_mem; // 按此策略的预估峰值内存(bytes) }; struct WriteStats { int64_t rows_written_total; // 累计写入行数(跨所有文件) int64_t files_completed; // 已完成(已 close)的文件数 int64_t current_file_flush_count; // 当前文件已 flush 次数 int64_t estimated_current_mem_bytes; // 当前估算内存 };
class MemConstrainedWriter { public: static constexpr int DEVICES_UNKNOWN = 0; MemConstrainedWriter( int64_t mem_limit_bytes, const std::string& output_dir, const std::string& file_prefix, const std::shared_ptr<TableSchema>& table_schema, int n_devices = DEVICES_UNKNOWN, int64_t total_rows_per_device = -1 ); ~MemConstrainedWriter(); int init_status() const; // 写入接口(内部自动 flush / close / reopen) int write_table(Tablet& tablet); int close(); // 查询建议的 Tablet 行数 int64_t rows_per_tablet() const; // 查询当前策略与运行时状态 const WritingStrategy& strategy() const; const WriteStats& stats() const; const std::vector<std::string>& created_files() const; // 纯计算接口:不创建文件,仅返回策略 static WritingStrategy compute_strategy( int64_t mem_limit_bytes, const std::shared_ptr<TableSchema>& table_schema, int n_devices, int64_t total_rows_per_device = -1 ); };
文件按 {output_dir}/{file_prefix}_{index:04d}.tsfile 命名,如 /data/output/sensor_0000.tsfile。
用户提交任意大小的 Tablet,系统内部累计行数,达到 $F$ 时自动 flush,达到 $K$ 次 flush 后自动轮转文件:
write_table(tablet):
if init_status != E_OK: return error
writer->write_table(tablet)
accumulated_rows += tablet.rows
// 动态设备追踪
if dynamic_mode:
d = count_devices_in_tablet(tablet)
if d > observed_max_devices:
observed_max_devices = d
recompute_rotate_thresh()
if accumulated_rows >= F:
do_flush()
do_flush():
writer->flush()
accumulated_rows = 0
flush_count++
meta_estimate = flush_count × D_eff × b
if meta_estimate >= rotate_thresh:
rotate_file()
rotate_file():
writer->close()
file_index++
writer = new TsFileWriter()
writer->open(next_file_path)
writer->register_table(schema)
flush_count = 0
if dynamic_mode: observed_max_devices = 0
基本用法:
auto schema = std::make_shared<TableSchema>("sensor_table", columns); MemConstrainedWriter writer( 10 * 1024 * 1024, // 10 MB 内存上限 "/data/output", "sensor", schema, 50, // 50 个设备 100000 // 每设备总行数(可选) ); if (writer.init_status() != E_OK) { return writer.init_status(); // 内存不足以写入此 schema } int F = writer.rows_per_tablet(); while (has_more_data) { Tablet tablet(..., F); fill(tablet); writer.write_table(tablet); // flush / rotate 全自动 } writer.close();
仅查询策略(不创建文件):
auto s = MemConstrainedWriter::compute_strategy( 10 * 1024 * 1024, schema, 50, 100000); // s.rows_per_flush = 每批次建议行数 // s.flushes_per_file = 每文件建议 flush 次数 // s.estimated_peak_mem = 预估峰值内存
chunk_group_size_threshold_ 作为双重保险兜底。chunk_group_size_threshold_ 是进程全局配置:多个 Writer 实例并发时会互相覆盖,当前设计仅支持单写入器场景。ENABLE_MEM_STAT=ON两种写入模式下,50 MB 阈值的实际控制效果:
| 指标 | 顺序写入 | 混合写入 |
|---|---|---|
| 设定阈值 | 50 MB | 50 MB |
| 实测峰值 | 48.1 MB | 48.6 MB |
| 峰值 / 阈值 | 96.2% | 97.2% |
| Flush 后最低 | 7.4 MB | 7.4 MB |
| 自动 flush 次数 | 5 | 6 |
内存峰值均未超过设定阈值,说明 chunk_group_size_threshold_ 的自适应检查机制有效。峰值略低于 50 MB 是因为检查粒度为 batch 级别(64K 行),在接近但未达阈值时即完成当前 batch 并触发 flush。
| 模式 | 吞吐量 | CPU 利用率 |
|---|---|---|
| 顺序写入 | 3.36 M rows/s | ~115% |
| 混合写入 | 3.30 M rows/s | ~115% |
两种模式吞吐量接近。CPU 利用率 >100% 表明部分编码/压缩工作利用了多核(SNAPPY 压缩开销)。
作为参考,同等规模下 Parquet(Arrow + SNAPPY)的写入吞吐约 6.3 M rows/s。TsFile 写入的主要瓶颈在于 Tablet::add_value() 的按列名 string map 查找开销,而非 I/O 或编码本身。
以 50 设备、50 FIELD 列 int32、$M_{\text{limit}} = 12$ MB 为例:
s = 1008 bytes/row b = 50 × 104 + 96 = 5296 bytes/设备/flush M_avail ≈ 11.5 MB 对半分策略: F = 11.5 MB / (2 × 1008) ≈ 5,694 行 K = 11.5 MB / (2 × 50 × 5296) ≈ 21 次 rotate_thresh = 5.75 MB × 0.85 / (50 × 5296) ≈ 第 18 次 flush
| 事件 | $M_{\text{meta}}$ 估算 | 操作 |
|---|---|---|
| flush 1 | 0.26 MB | 继续 |
| flush 10 | 2.65 MB | 继续 |
| flush 18 | 4.77 MB $\geq$ thresh | rotate:close 释放 4.77 MB 元数据,重开新文件 |
| flush 1(新文件) | 0.26 MB | 继续 |
文件轮转的本质:每 18 次 flush 必须 close 一次,释放积压的不可压缩元数据。
本章针对嵌入式环境下 TsFile 写入的内存约束问题,提出了一套完整的资源受限写入优化方案:
内存模型:将写入过程内存分解为 $M_{\text{init}}$(固定)、$M_{\text{data}}$(周期性,flush 释放)和 $M_{\text{meta}}$(单调递增,close 释放)三部分,并建立了量化公式。通过 ModStat 编译期追踪框架进行了实验验证,公式误差在 5-15% 范围内。
参数配置策略:针对总量已知和未知两种场景,分别给出了 EOQ 最优策略和对半分预算策略,实现了自动推导批次大小 $F$ 和文件轮转频率 $K$。
两级内存控制:通过 flush(释放 $M_{\text{data}}$)和文件轮转(释放 $M_{\text{meta}}$)的两级控制机制,配合安全裕度设计,保证写入全程内存不超过用户设定的上限。同时与 TsFile 已有的 chunk_group_size_threshold_ 自适应刷盘机制形成双重保险。
MemConstrainedWriter 接口:封装了上述所有机制,用户只需提供内存上限即可实现零决策的不间断写入,支持自动 flush、自动文件轮转和动态设备追踪。
实验验证:在 200M 行、50 MB 阈值的实验中,两种写入模式均将内存峰值控制在阈值以内,写入吞吐达到 3.3 M rows/s,验证了方案的有效性和实用性。