TsFile C++ 在资源受限环境(嵌入式设备、容器限额、边缘计算节点)中运行时,用户面临以下困境:
max_rows)flush()close() 当前文件并打开新文件现有的 TsFileWriter / TsFileTableWriter 将这三个决策完全交给用户,用户需要深入理解 TsFile 内存模型才能正确配置。
实验(见 experiment/TsFile 内存控制.pdf)确认了写入过程的内存由三部分构成:
M_total = M_init + M_data(F) + M_meta(K)
| 组成 | 特征 | 公式 |
|---|---|---|
| M_init | 固定,程序启动后不变 | ≈ 900 KB |
| M_data | 周期性,flush 后释放 | s × F(字节) |
| M_meta | 单调递增,close 才写盘 | K × D × b(字节) |
其中:
F:每次 flush 写入的行数K:当前文件已 flush 的次数D:当前文件已出现的设备数(唯一 TAG 组合数)s = s_tablet + s_data:每行在内存中的综合字节数b:每设备每次 flush 新增的元数据字节数对于 Table 模式,各量的计算方式:
s_tablet = 8 + Σ sizeof(col_type) // 所有列(TAG + FIELD) s_data = Σ (8 + sizeof(field_col_type)) // 仅 FIELD 列进 ChunkWriter s = s_tablet + s_data b = n_field_cols × 104 + 96 // 每设备每次 flush 的 chunk_meta + chunk_group_meta
在给定内存上限 M_limit 和写入规模(设备数 D,总行数 R)的情况下:
总量已知时(EOQ 模型最优解):
F_opt = √(R × D × b / s) K_opt = R / F_opt M_min = M_init + 2 × √(R × s × D × b)
当 M_min ≤ M_limit 时,F_opt 即为最优批次大小,所有数据可写入单个文件。
总量未知时(流式,预算对半分):
M_avail = M_limit - M_init F = M_avail / (2 × s) // 数据预算 K = M_avail / (2 × D × b) // 元数据预算
此时 F × K 最大(单个文件容纳的数据量最多),是未知总量场景下的最优策略。
write_table(),flush / close / reopen 全部自动处理。TsFileTableWriter 保持一致,便于迁移。ModStat,仅使用轻量的整数计算。ModStat 通过在每次 mem_alloc / mem_free 时执行一次 ATOMIC_FAA 来追踪各模块的内存占用。
排除原因:
mem_alloc 调用频繁ATOMIC_FAA 在弱内存模型的嵌入式 CPU 上代价不可忽略ModStat 用于诊断目的,仅在 ENABLE_MEM_STAT 编译选项下开启MemConstrainedWriter 不需要字节精度,公式估算(5-15% 误差)+ 安全裕度已足够利用公式 M_meta ≈ flush_count × D × b 进行轻量估算:
flush() 后执行一次整数乘法,开销可忽略Tablet::find_all_device_boundaries() 高效获取find_all_device_boundaries() 按列进行逐元比较(非按行遍历),返回每个设备在 Tablet 中的行范围边界。boundaries.size() - 1 即为本次 Tablet 涉及的设备数。
由于公式存在 5-15% 的估算误差(主要来源:变长字符串开销、schema 结构的 list/map 节点),rotate 触发条件设置为:
if meta_estimate >= meta_budget × SAFETY_FACTOR: // SAFETY_FACTOR = 0.85
rotate_file()
提前 15% 触发 rotate,保证实际内存不超出上限。
struct WritingStrategy { int64_t rows_per_tablet; // 建议的 Tablet 行数 (F) int64_t flushes_per_file; // 每个文件最多 flush 次数 (K),-1 = 不限 int64_t estimated_peak_mem; // 按此策略的预估峰值内存(bytes) };
class MemConstrainedWriter { public: // n_devices = DEVICES_UNKNOWN(0) 时进入动态追踪模式 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, // 设备数,0 = 未知 int64_t total_rows_per_device = -1 // 每设备总行数,-1 = 未知 ); ~MemConstrainedWriter(); // 写入接口(内部自动 flush / close / reopen) int write_table(Tablet& tablet); // 完成写入后调用 int close(); // 查询建议的 Tablet 行数,用于创建 Tablet int64_t rows_per_tablet() const; // 查询当前生效的写入策略 const WritingStrategy& strategy() const; // 本次写入创建的所有文件路径 const std::vector<std::string>& created_files() const; // 纯计算接口:不创建 writer,只返回建议策略 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 /data/output/sensor_0001.tsfile
auto schema = std::make_shared<TableSchema>("sensor_table", columns); MemConstrainedWriter writer( 10 * 1024 * 1024, // 10 MB 内存上限 "/data/output", "sensor", schema, 50, // 50 个设备(已知时填写,未知时省略) 100000 // 每设备总行数(可选) ); int F = writer.rows_per_tablet(); while (has_more_data) { Tablet tablet(..., F); fill(tablet); writer.write_table(tablet); // flush / rotate 全自动 } writer.close(); for (const auto& path : writer.created_files()) { // /data/output/sensor_0000.tsfile ... }
src/writer/ ├── tsfile_writer.h/.cc (已有) ├── tsfile_table_writer.h/.cc (已有) ├── mem_constrained_writer.h (新增) └── mem_constrained_writer.cc (新增)
MemConstrainedWriter 内部持有一个 TsFileWriter*,在文件轮换时销毁并重建。直接包装 TsFileWriter 而非 TsFileTableWriter,以便在 rotate 时精确控制 open() 和 register_table()。
// 固定参数(构造时确定,之后不变) int64_t mem_limit_bytes_; int64_t s_; // 每行综合字节数 = s_tablet + s_data int64_t b_per_device_; // 每设备每 flush 的元数据增量(字节) int64_t meta_budget_; // 分给元数据的内存预算 = M_avail - s_ × F WritingStrategy strategy_; // rows_per_tablet 固定 // 运行时状态 TsFileWriter* writer_; std::shared_ptr<TableSchema> table_schema_; int64_t flush_count_; // 当前文件已 flush 次数 int file_index_; // 当前文件编号 std::vector<std::string> created_files_; // 动态设备追踪(n_devices == DEVICES_UNKNOWN 时使用) bool dynamic_mode_; int64_t observed_max_devices_; // 单次 Tablet 内见过的最多设备数 int64_t current_k_; // 动态计算的 K 值
write_table(tablet):
1. writer_->write_table(tablet)
2. writer_->flush()
3. flush_count_++
4. if dynamic_mode_:
d_in_tablet = tablet.find_all_device_boundaries().size() - 1
if d_in_tablet > observed_max_devices_:
observed_max_devices_ = d_in_tablet
recompute_k() // D 增大 → K 减小
5. effective_k = dynamic_mode_ ? current_k_ : strategy_.flushes_per_file
meta_estimate = flush_count_ × D_effective × b_per_device_
if effective_k > 0 && meta_estimate >= meta_budget_ × SAFETY_FACTOR:
rotate_file()
为何同时保留 flush_count 判断和 meta_estimate 判断:
meta_estimate是主要判断条件(基于实际累积量),effective_k作为硬上限保底, 防止极端情况下(如估算偏差较大)超出预算。
rotate_file(): 1. writer_->close() 2. delete writer_ 3. file_index_++ 4. writer_ = new TsFileWriter() 5. writer_->open(make_file_path(file_index_)) 6. writer_->register_table(table_schema_) 7. created_files_.push_back(current path) 8. flush_count_ = 0 9. if dynamic_mode_: observed_max_devices_ = 0; recompute_k()
// 每次 write_table 后(仅在 dynamic_mode_ 时)
d_in_tablet = tablet.find_all_device_boundaries().size() - 1
// observed_max_devices_ 跟踪单个 Tablet 内见过的最多设备数
// (rotate 后重置,重新从当前 Tablet 学习)
if d_in_tablet > observed_max_devices_:
observed_max_devices_ = d_in_tablet
recompute_k()
recompute_k():
D = observed_max_devices_
B = D × b_per_device_
current_k_ = max(1, meta_budget_ / B)
动态模式的保守性来源:observed_max_devices_ 只增不减(单文件内),设备数越多 K 越小。安全裕度(×0.85)进一步确保估算偏低时不会超出上限。
inputs: mem_limit, schema, n_devices, total_rows (-1 = 未知)
s_tablet = 8 + Σ sizeof(col_type) // 全部列(TAG + FIELD)
s_data = Σ (8 + sizeof(field_col_type)) // 仅 FIELD 列
s = s_tablet + s_data
n_field_cols = count(FIELD columns in schema)
b = n_field_cols × 104 + 96 // 每设备每 flush 的元数据
M_avail = mem_limit - M_INIT_BYTES // M_INIT_BYTES = 900 KB
if total_rows > 0 && n_devices > 0: // 总量已知:EOQ 最优
B = n_devices × b
F = √(total_rows × B / s)
K = total_rows / F
peak = M_INIT_BYTES + s×F + K×B
if peak ≤ mem_limit: return {F, K, peak}
// 超出上限,退化为对半分
// 总量未知,或 EOQ 超限:对半分预算
B = max(1, n_devices) × b // n_devices=0 时用 1 作为初始值
F = M_avail / (2 × s)
K = M_avail / (2 × B)
peak = M_INIT_BYTES + s×F + K×B
return {F, K, peak}
sizeof_datatype() 的映射:
| TSDataType | 字节数 |
|---|---|
| BOOLEAN | 1 |
| INT32 / FLOAT | 4 |
| INT64 / DOUBLE | 8 |
| STRING | 32(保守估计,实际为变长) |
以实验配置(50 设备,50 FIELD 列 int32,M_limit=12MB)为例:
s = 208 + 600 = 808 bytes/row b = 50×104 + 96 = 5296 bytes/设备/flush B = 50×5296 = 264,800 bytes/flush M_avail ≈ 11.5MB 策略(对半分):F ≈ 7,500 行,K ≈ 22 次
n_devices 已知(=50):
| flush 轮次 | meta_estimate | meta_budget×0.85 | 是否 rotate |
|---|---|---|---|
| 第 10 次 | 10×264800 = 2.5MB | 4.9MB | 否 |
| 第 19 次 | 19×264800 = 4.8MB | 4.9MB | 是 |
n_devices 未知(动态追踪,每次 Tablet 含 50 设备):
| flush 轮次 | observed_D | K 重算 | meta_estimate | 是否 rotate |
|---|---|---|---|---|
| 第 1 次 | 50(首次发现即稳定) | 22 | 0.26MB | 否 |
| 第 19 次 | 50 | 22 | 4.8MB | 是 |
两种模式在设备数稳定后行为一致。
STRING 列的内存估计误差:STRING 类型使用固定 32 字节估算,实际为变长。通过 SAFETY_FACTOR=0.85 和 M_INIT_BYTES 的保守取值缓解此误差。
每次 write_table 后立即 flush:当前设计在每次 write_table 后强制 flush。此行为与实验场景一致(每个 Tablet 写完即落盘),是内存受限场景的推荐模式。
Schema 同构假设:compute_strategy() 假设所有设备使用相同的 FIELD 列结构。异构 schema 场景下应取最大 FIELD 列数作为保守估计。
单线程写入:当前设计不考虑并发写入场景。
M_INIT_BYTES 取值:实验测得约 900 KB,是否随注册的时间序列数量显著变化。b = n_field_cols × 104 + 96 的精度:104 bytes/chunk_meta 和 96 bytes/chunk_group_meta 为近似值,需根据实际序列化大小校准。find_all_device_boundaries() 返回值语义确认:boundaries.size() - 1 等于本次 Tablet 中唯一设备数。