基于湖库一体架构,统一管理结构化、半结构化与非结构化等多模态数据,一个系统承载事务处理、实时分析与 AI 工作负载。
索引列频繁 UPDATE 导致索引 MemTable Key 膨胀的验证与分析
更新时间:2026-08-25 06:56
问题现象
当业务频繁 UPDATE 二级索引列时,索引表 MemTable 中会产生大量“死 key”(INSERT→DELETE),导致索引 Range Scan 读取行数远超实际有效行数,查询性能严重劣化。
问题原因
频繁 UPDATE 索引列时,每次 UPDATE 操作在索引表中会生成一个包含新值的 INSERT 记录,并生成一个删除旧值的 DELETE 记录。这些 DELETE 记录对应的旧索引键值在事务提交后成为“死 key”,它们会保留在索引的 MemTable 中,直到发生 Major Compaction 才会被物理回收。索引扫描(Range Scan)会遍历这些“死 key”,导致读取行数膨胀,性能下降。
关键信息
- 影响对象:包含频繁更新列的二级索引。
- 关键指标:执行计划中的
physical_range_rows远大于logical_range_rows;SQL Audit 中的MEMSTORE_READ_ROW_COUNT或SSSTORE_READ_ROW_COUNT异常增高。 - 核心机制:索引的 MemTable 中堆积了
INSERT→DELETE的 MVCC 记录。 - 解决路径:Major Compaction 可以回收“死 key”;转储(Minor Freeze)仅迁移数据,不回收。
问题的风险及影响
- 查询性能劣化:索引扫描需要遍历大量无效的“死 key”,增加了 I/O 和 CPU 开销,查询响应时间变长。
- 内存与存储压力:“死 key”占用 MemTable 内存,转储后占用 SSTable 存储空间。
- 执行计划失真:优化器基于
physical_range_rows等统计信息可能做出次优选择。 - 监控指标误导:
MEMSTORE_READ_ROW_COUNT等监控指标异常偏高,可能干扰性能问题定位。
适用版本
OceanBase 4.2.5.6 及受相同 MVCC 机制影响的其他版本。
解决方法
短期缓解
对于数据量较小的表,可以在查询中使用 /*+ FULL(table) */ Hint 强制走全表扫描,避免低效的索引扫描。
中期处理
定期执行 Major Compaction(合并)以回收“死 key”,释放存储空间并恢复索引扫描性能。
-- 在 sys 租户下执行
ALTER SYSTEM MAJOR FREEZE TENANT = 'tenant_name';
合并后,建议清理 Plan Cache 以使优化器基于新的数据结构生成执行计划。
长期优化
- 审视业务逻辑,避免对索引列进行高频更新。
- 考虑调整索引设计,将频繁更新的列从索引中移除。
- 如果业务允许,可将 UPDATE 模式改为 DELETE + INSERT。
规避方式
- 索引设计阶段:评估列更新频率,谨慎将高频更新列加入索引。
- 开发规范:在代码审查中关注对索引列的 UPDATE 操作频率。
- 运维监控:定期监控关键索引的
physical_range_rows与logical_range_rows比值,以及MEMSTORE_READ_ROW_COUNT指标,提前发现膨胀趋势。 - 合并策略:根据业务负载,规划并执行定期的 Major Compaction。
附录:测试验证详情
1. 测试环境
| 项目 | 值 |
|---|---|
| OceanBase 版本 | 4.2.5.6 |
| 租户 | mysql (tenant_id=1002) |
| 主表 tablet_id | 200012 |
| 索引表 tablet_id | 1152921504606846995 |
| 主表 table_id | 500037 |
| 索引表 table_id | 500038 |
2. 测试方案
2.1 表结构
CREATE TABLE `stream_event_table_partition_v1` (
`id` varchar(40) NOT NULL,
`application` varchar(40) NOT NULL,
`company_id` varchar(40) NOT NULL,
`created_at` datetime DEFAULT NULL,
`inqueue` bit(1) NOT NULL,
`partition_key` varchar(40) NOT NULL,
`timestamp` bigint(20) NOT NULL,
`topic` varchar(40) NOT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_application_timestamp` (`application`, `timestamp`)
) DEFAULT CHARSET = utf8mb4 ROW_FORMAT = COMPACT;
索引表 rowkey 结构为 (application, timestamp, id)。
2.2 测试数据与操作
- 插入 50 行数据,所有行的
application = 'i18n-executor'。 - 对其中 10 行数据执行 30 轮 UPDATE,每轮修改索引列
timestamp的值。
UPDATE stream_event_table_partition_v1
SET inqueue=0, `timestamp`=UNIX_TIMESTAMP(NOW())*1000
WHERE id IN ('evt-00001-a1b2c3d4e5f6', ... , 'evt-00045-c3d4e503a1b2');
2.3 验证 SQL
SELECT /*+ INDEX(eventtable0_ idx_application_timestamp) */
eventtable0_.*
FROM stream_event_table_partition_v1 eventtable0_
WHERE eventtable0_.application = 'i18n-executor'
ORDER BY eventtable0_.timestamp ASC
LIMIT 500;
3. 各阶段验证结果
3.1 UPDATE 压测后(数据在 MemTable 中)
- 索引表 MemTable 状态:
btree_item_count=350。其中:- 活 key(有效):50 个。
- 死 key(
INSERT→DELETE):300 个。
- 执行计划关键指标:
physical_range_rows: 350(索引 BTree 中的物理行数,含死 key)logical_range_rows: 50(逻辑可见行数)
- SQL Audit 关键指标:
MEMSTORE_READ_ROW_COUNT: 400(从 MemTable 读取的总行数)- 拆解:索引扫描 350 行 + 回表读主表 50 行 = 400 行。
3.2 转储后(Minor Freeze)
- 执行命令:
ALTER SYSTEM MINOR FREEZE TENANT = 'mysql'; - 效果:MemTable 数据迁移至 SSTable,但“死 key”未被回收。
- SQL Audit 关键指标:
MEMSTORE_READ_ROW_COUNT: 0SSSTORE_READ_ROW_COUNT: 400(读取行数未减少,仅存储位置变化)
3.3 合并后(Major Compaction)
- 执行命令:
ALTER SYSTEM MAJOR FREEZE TENANT = 'mysql'; - 效果:“死 key”被物理回收。
- 执行计划关键指标:
physical_range_rows: 50(恢复至与逻辑行数一致)
- SQL Audit 关键指标:
SSSTORE_READ_ROW_COUNT: 100(大幅下降)- 拆解:索引扫描 50 行 + 回表读主表 50 行 = 100 行。
4. 全阶段对比总结
| 阶段 | 索引 physical_range_rows |
MemStore Read | SSStore Read | 总读行数 |
|---|---|---|---|---|
| UPDATE 压测后(MemTable) | 350 | 400 | 0 | 400 |
| 转储后(Minor SSTable) | 950* | 0 | 400 | 400 |
| 合并后(Major SSTable) | 50 | 0 | 100 | 100 |
注:转储后 physical_range_rows 估算值升高与 SSTable 多版本存储方式有关。