blob: f7c85dfb8e92b796330f896d938cf8b26a2e89d0 [file] [view]
# 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 数据结构
```cpp
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 类接口
```cpp
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 用户代码示例
```cpp
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 内部状态
```cpp
// 固定参数(构造时确定,之后不变)
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 | 字节数 |
|-----------|-------|
| BOOLEAN | 1 |
| INT32 / FLOAT | 4 |
| INT64 / DOUBLE | 8 |
| STRING | 32(保守估计,实际为变长)|
---
## 6. 两种模式行为对比
以实验配置(50 设备,50 FIELD int32M_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 | **是** |
两种模式在设备数稳定后行为一致。
---
## 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 中唯一设备数。