分类 :3.存储引擎 | 篇章:02 MemTable

适用版本:TDengine v3.x(v3.3.x / v3.4.x) | 最后更新:2026-05-25
MemTable 是 VNode 中数据从写入到落盘之间的内存暂存区。它使用跳表(Skip List)按时间有序组织数据,既保证高效的并发写入,又支持快速的范围查询。本文解析 MemTable 的内部数据结构、生命周期管理和内存回收机制。
核心概念速查表
| 概念 | 说明 |
|---|---|
| MemTable | 当前活跃的内存写入缓冲区 |
| Immutable MemTable (IMem) | 已冻结、正在刷盘的旧 MemTable |
| Skip List | 跳表,MemTable 中数据的核心有序结构 |
| Buffer Pool | 内存池,MemTable 的内存来源(3 段轮转) |
| STbData | 单张子表在 MemTable 中的数据容器 |
| Commit 触发 | MemTable 写满时触发刷盘流程 |
详细解析
1. MemTable 的整体结构
MemTable 内部组织:
MemTable
│
├── Hash Table: uid → STbData
│ (快速定位某张子表的数据)
│
├── RB-Tree: 按 uid 排序的子表集合
│ (顺序遍历所有子表,用于 Commit)
│
└── 统计信息:
- nRows: 总行数
- minVer / maxVer: 版本范围
- minKey / maxKey: 时间戳范围
2. 子表数据容器(STbData)
每张子表在 MemTable 中有独立的数据容器:
STbData 结构:
STbData (uid = 子表 UID)
│
├── suid: 所属超级表 UID
├── uid: 子表 UID
│
└── Skip List(5 层跳表)
│
├── Level 4: ──○────────────────────○──────
├── Level 3: ──○──────○─────────────○──────
├── Level 2: ──○──○───○──○──────────○──────
├── Level 1: ──○──○───○──○──○───○───○──────
└── Level 0: ──○──○──○──○──○──○──○──○──○──
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
行 行 行 行 行 行 行 行 行
(按时间戳有序)
3. 跳表的工作原理
跳表是 MemTable 的核心数据结构,提供 O(log N) 的插入和查找性能:
跳表操作复杂度:
插入一行数据:O(log N)
按时间范围查询:O(log N + M) (N=总行数,M=结果行数)
查找特定时间戳:O(log N)
层数:5 层(固定)
晋升概率:每层约 1/4 的节点晋升到上层
内存布局:
节点 = 前向指针数组 + 数据行指针
数据行 = 时间戳 + 各列值(紧凑编码)
为什么选择跳表而不是 B+树?
| 对比 | 跳表 | B+树 |
|---|---|---|
| 并发写入 | 无锁/细粒度锁,高并发友好 | 需要树结构锁 |
| 实现复杂度 | 简单 | 复杂(分裂/合并) |
| 内存分配 | 节点独立分配 | 页内批量分配 |
| 范围查询 | O(log N + M) | O(log N + M),相同 |
| 适合场景 | 追加写入为主 | 随机读写均衡 |
4. 数据写入 MemTable 的流程
INSERT 写入 MemTable 流程:
写入请求(一批行数据)
│
▼
① 从 Buffer Pool 分配内存块
│
▼
② 按子表 UID 查找 Hash Table:
- 命中 → 获取现有 STbData
- 未命中 → 创建新 STbData,插入 Hash Table 和 RB-Tree
│
▼
③ 对每一行:
a. 编码行数据(时间戳 + 列值 → 紧凑二进制格式)
b. 插入跳表:
- 比较时间戳找到插入位置
- 随机决定层数(1~5 层)
- 链接前向指针
c. 如果时间戳已存在 → 覆盖更新(同表同时间戳只保留最新)
│
▼
④ 更新统计信息:
- nRows++
- 更新 minKey / maxKey
- 更新 minVer / maxVer
5. MemTable 生命周期
MemTable 状态机:
创建(Active)
│
│ ← 接收写入
│
│ 触发条件满足(Buffer 段写满)
│
▼
冻结(Immutable)
│
│ ← 不再接收写入,只供读取
│ ← Commit 线程异步刷盘
│
│ 刷盘完成
│
▼
回收(Released)
│
│ 内存归还 Buffer Pool
│
▼
销毁
6. Buffer Pool 与 MemTable 的关系
Buffer Pool 三段轮转与 MemTable:
BUFFER = 256 MB(用户配置)
┌─────────────────────────────────────────────┐
│ Buffer Pool │
│ │
│ 段 A (85MB) 段 B (85MB) 段 C (85MB) │
│ [Active Mem] [Imm Mem] [Free] │
│ │
│ 状态轮转: │
│ A: 当前写入 → 写满后冻结 │
│ B: 正在刷盘 → 刷盘完成后变为 Free │
│ C: 空闲 → A 写满后接替为 Active │
└─────────────────────────────────────────────┘
如果 C 还没来得及释放(B 刷盘慢):
→ 写入被阻塞(背压机制)
→ 日志警告:"Write is blocked due to buffer full"
7. Commit 触发条件
MemTable 刷盘由以下条件触发:
| 触发条件 | 说明 |
|---|---|
| Buffer 段写满 | 当前段使用量达到 BUFFER/3 |
| 手动 FLUSH | 用户执行 FLUSH DATABASE |
| VNode 关闭 | 正常关闭时刷盘所有未持久化数据 |
| WAL 大小超限 | WAL 累计超过上限时加速刷盘 |
8. 数据在 MemTable 中的编码
行数据的内存编码格式:
┌──────────────────────────────────────┐
│ 一行数据(Row) │
├──────────┬──────────┬────────────────┤
│ 行头 │ NULL位图 │ 列值数组 │
│ (版本号 │ (每列1bit │ (按定义顺序 │
│ +标志) │ 标记NULL) │ 紧凑排列) │
└──────────┴──────────┴────────────────┘
定长列:直接存储值(INT=4字节, FLOAT=4字节...)
变长列:长度前缀(2字节) + 实际数据
整行大小 = 行头 + ⌈列数/8⌉ + Σ(各列大小)
9. MemTable 与查询的交互
查询需要同时读取 MemTable 和磁盘文件,通过多路归并保证结果完整:
查询读取 MemTable:
TableScan 算子
│
├── 从 Active MemTable 读取
│ └── 跳表范围查询 [startTs, endTs]
│
├── 从 Immutable MemTable 读取(如果存在)
│ └── 同上
│
└── 从磁盘文件读取
└── .head → .data → 解压
│
▼
归并排序(按时间戳,取最新版本)
│
▼
输出有序结果
MVCC 语义:每行数据带有写入版本号(commitVer),查询使用 snapshot version 过滤------只看到查询开始前已提交的数据。
10. 乱序数据处理
时序数据可能乱序到达(网络延迟、设备补报等):
乱序数据写入处理:
MemTable 跳表中数据按时间有序排列
Case 1: 新数据时间戳 > 当前最大时间戳(正常顺序)
→ 追加到跳表末尾 → O(1) 快速路径
Case 2: 新数据时间戳 < 当前最大时间戳(乱序)
→ 二分查找插入位置 → O(log N)
→ 插入到跳表中间
Case 3: 新数据时间戳 = 已有时间戳(重复/更新)
→ 覆盖已有行(保留最新写入)
乱序对性能的影响:
- 写入性能:轻微降低(从 O(1) 变为 O(log N))
- 落盘合并:乱序数据可能需要写入旧的 FileSet
- STT 设计正是为了高效处理乱序落盘场景
代码示例
监控 MemTable 状态
sql
-- 查看 VNode 内存使用情况
SHOW VGROUPS;
-- 关注各 VNode 的内存占用
-- 手动触发刷盘
FLUSH DATABASE power;
-- 调整 Buffer 大小
ALTER DATABASE power BUFFER 512;
观察写入背压
bash
# 如果写入持续被阻塞,taosd 日志中会出现:
# "vgId:X write is blocked since buffer is full"
# 解决方案:
# 1. 增大 BUFFER 参数
# 2. 增加 VGROUPS 分散压力
# 3. 检查刷盘是否被阻塞(磁盘 I/O 瓶颈)
性能考量
BUFFER 大小对性能的影响
| BUFFER 值 | 写入效果 | 内存代价 |
|---|---|---|
| 小(64MB) | 频繁刷盘,写入可能被背压阻塞 | 低 |
| 中(256MB,默认) | 均衡 | 适中 |
| 大(1024MB) | 长时间缓冲,刷盘频率低 | 高 |
优化建议
| 场景 | 建议 |
|---|---|
| 高频写入被阻塞 | 增大 BUFFER 或增加 VGROUPS |
| 内存紧张 | 减小 BUFFER(但不低于 64MB) |
| 大量乱序数据 | 保持默认 BUFFER + 增大 STT_TRIGGER |
| 查询 MemTable 慢 | 检查单 VNode 子表数是否过多 |
FAQ
Q1: MemTable 满了会丢数据吗?
不会。MemTable 满了时写入会被阻塞(背压),等待刷盘完成释放空间后恢复。期间客户端会收到超时或重试信号。
Q2: 查询时 MemTable 正在刷盘会冲突吗?
不会。Immutable MemTable 是只读的,查询和刷盘可以并发进行。查询读取的是冻结时的快照,不受刷盘写入影响。
Q3: 重启后 MemTable 数据怎么恢复?
通过 WAL 重放。重启时读取 WAL 中 commitIndex 之后的日志,重新写入新的 MemTable,恢复到宕机前的状态。
Q4: 为什么每张子表需要独立的跳表?
因为时间戳在每张子表内是唯一主键。独立跳表保证同表内时间有序,且落盘时可以按表独立处理,避免不同表的数据相互干扰。
参考
系统构架篇
- 01-《TDengine 整体架构全景》
- 02-《集群拓扑深度解析》
- 03-《MNode 内部机制深度解析》
- 04-《RPC 通信层深度解析》
- 05-《VNode 生命周期》
- 06-《RAFT 共识协议》
- 07-《端到端的消息流》
数据模型
- 01-《数据库创建与参数详解》
- 02-《超级表/子表/普通表》
- 03-《支持数据类型深度解析》
- 04-《TDengine Tag 设计哲学与 Schema 变更机制》
- 05-《TDengine 虚拟表实现原理》