MemConstrainedWriter 设计文档

1. 背景与需求

1.1 问题描述

TsFile C++ 在资源受限环境(嵌入式设备、容器限额、边缘计算节点)中运行时,用户面临以下困境:

  • 不知道每次应写入多少行(max_rows
  • 不知道写入多少次后需要调用 flush()
  • 不知道何时内存将超出上限,需要 close() 当前文件并打开新文件

现有的 TsFileWriter / TsFileTableWriter 将这三个决策完全交给用户,用户需要深入理解 TsFile 内存模型才能正确配置。

1.2 内存组成分析

实验(见 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

1.3 内存约束下的最优策略

在给定内存上限 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 最大(单个文件容纳的数据量最多),是未知总量场景下的最优策略。


2. 设计目标

  1. 用户只需提供内存上限和 schema,不需要手动计算 F 和 K。
  2. 用户只需调用 write_table(),flush / close / reopen 全部自动处理。
  3. 当设备数未知时,通过动态追踪自适应调整,保证内存不超出上限。
  4. 接口风格与现有 TsFileTableWriter 保持一致,便于迁移。
  5. 零运行时内存监控开销:不依赖 ModStat,仅使用轻量的整数计算。

3. 关于内存监控方式的取舍

3.1 ModStat 方案(已排除)

ModStat 通过在每次 mem_alloc / mem_free 时执行一次 ATOMIC_FAA 来追踪各模块的内存占用。

排除原因:

  • 写入热路径(数据页分配、ByteStream 扩容)中 mem_alloc 调用频繁
  • ATOMIC_FAA 在弱内存模型的嵌入式 CPU 上代价不可忽略
  • ModStat 用于诊断目的,仅在 ENABLE_MEM_STAT 编译选项下开启
  • MemConstrainedWriter 不需要字节精度,公式估算(5-15% 误差)+ 安全裕度已足够

3.2 公式估算方案(采用)

利用公式 M_meta ≈ flush_count × D × b 进行轻量估算:

  • F 在构造时由公式一次性计算,之后固定不变
  • rotate 判断 在每次 flush() 后执行一次整数乘法,开销可忽略
  • D(设备数) 通过 Tablet::find_all_device_boundaries() 高效获取

find_all_device_boundaries() 按列进行逐元比较(非按行遍历),返回每个设备在 Tablet 中的行范围边界。boundaries.size() - 1 即为本次 Tablet 涉及的设备数。

3.3 安全裕度

由于公式存在 5-15% 的估算误差(主要来源:变长字符串开销、schema 结构的 list/map 节点),rotate 触发条件设置为:

if meta_estimate >= meta_budget × SAFETY_FACTOR:   // SAFETY_FACTOR = 0.85
    rotate_file()

提前 15% 触发 rotate,保证实际内存不超出上限。


4. 接口设计

4.1 数据结构

struct WritingStrategy {
    int64_t rows_per_tablet;      // 建议的 Tablet 行数 (F)
    int64_t flushes_per_file;     // 每个文件最多 flush 次数 (K),-1 = 不限
    int64_t estimated_peak_mem;   // 按此策略的预估峰值内存(bytes)
};

4.2 类接口

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
    );
};

4.3 文件命名规则

输出文件按 {output_dir}/{file_prefix}_{index:04d}.tsfile 命名:

/data/output/sensor_0000.tsfile
/data/output/sensor_0001.tsfile

4.4 用户代码示例

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 ...
}

5. 内部实现

5.1 模块结构

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()

5.2 内部状态

// 固定参数(构造时确定,之后不变)
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 值

5.3 write_table() 流程

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 作为硬上限保底, 防止极端情况下(如估算偏差较大)超出预算。

5.4 rotate_file() 流程

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()

5.5 动态设备追踪

// 每次 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)进一步确保估算偏低时不会超出上限。

5.6 compute_strategy() 计算逻辑

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字节数
BOOLEAN1
INT32 / FLOAT4
INT64 / DOUBLE8
STRING32(保守估计,实际为变长)

6. 两种模式行为对比

以实验配置(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_estimatemeta_budget×0.85是否 rotate
第 10 次10×264800 = 2.5MB4.9MB
第 19 次19×264800 = 4.8MB4.9MB

n_devices 未知(动态追踪,每次 Tablet 含 50 设备):

flush 轮次observed_DK 重算meta_estimate是否 rotate
第 1 次50(首次发现即稳定)220.26MB
第 19 次50224.8MB

两种模式在设备数稳定后行为一致。


7. 约束与局限

  1. STRING 列的内存估计误差:STRING 类型使用固定 32 字节估算,实际为变长。通过 SAFETY_FACTOR=0.85 和 M_INIT_BYTES 的保守取值缓解此误差。

  2. 每次 write_table 后立即 flush:当前设计在每次 write_table 后强制 flush。此行为与实验场景一致(每个 Tablet 写完即落盘),是内存受限场景的推荐模式。

  3. Schema 同构假设compute_strategy() 假设所有设备使用相同的 FIELD 列结构。异构 schema 场景下应取最大 FIELD 列数作为保守估计。

  4. 单线程写入:当前设计不考虑并发写入场景。


8. 待确认事项

  • [ ] 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 中唯一设备数。