在 OceanBase 中,如何应对存储引擎的读放大问题?


首先为大家推荐这个 OceanBase 开源负责人老纪的公众号 “老纪的技术唠嗑局”,会持续更新和 #数据库、#AI#技术架构 相关的各种技术内容。欢迎感兴趣的朋友们关注!

前言

OceanBase 的 LSM-Tree 存储引擎天生具有高效的写入性能,而且既能够通过旁路导入高效处理定期的批量数据同步,又能够承载一些实时数据同步和历史库数据修改的场景。

但任何事物都有两面性,LSM-Tree 存储引擎虽然对写操作友好,但在数据读取时,必然绕不开多级数据归并带来的读放大问题。

最近一段时间,经常在社区论坛和微信群中出现:在高频插入、删除和更新的表中,读取性能不符合预期的问题。 这篇文章就简单为大家介绍一下应对方法。

背景:OceanBase 的存储引擎架构

OceanBase 的存储引擎基于 LSM-Tree 架构,将数据分为基线数据(放在基线 SSTable 中)和增量数据(放在 MemTable / 转储的 SSTable 中)两部分。其中基线数据是只读的,一旦生成就不再被修改;增量数据支持读写。

OceanBase 数据库的 DML 操作插入、更新、删除等操作,首先写入内存里的 MemTable,所以在写入性能上就相当于内存数据库的写入性能。等到 MemTable 达到一定大小时转储到磁盘成为增量的 SSTable(上图中的转储 SSTable 部分),转储到磁盘上的过程是批量的顺序写,相比 B+ 树架构离散的随机写,还会大大提高写盘的性能。

当增量的 SSTable 达到一定规模的时候,会触发增量数据和基线数据的合并,把增量数据和基线数据做一次整合,基线数据在合并完成之后就不会发生变化了,直到下一次合并。同时每天凌晨的业务低峰期,系统也会自动进行每日合并。

但是 LSM-Tree 的架构也存在一个问题,就是读放大(上图中的右侧箭头向上的部分)。在进行查询时,需要分别对 SSTable 和 MemTable 进行查询,并将查询结果进行一次归并,然后再将归并后的查询结果返回 SQL 层。OceanBase 为了减小读放大带来的影响,在内存实现了多级的缓存,例如 Block Cache 和 Row cache,来避免对基线数据频繁的进行随机读。

问题:Buffer 表效应

当用户在某张表上频繁的执行插入并且同时进行批量删除,或者有大量的并发更新操作时,可能会遇到一种现象:表中的数据行数并不大,但是查询和更新的性能出现明显下降。这种现象在 OceanBase 中称为 Queuing 表(业务上有时又称 Buffer 表)效应。

Buffer 表是 LSM-Tree 架构数据库都要面对的一类问题。LSM-Tree 架构下的删除操作,在合并之前都只是逻辑上标记删除而非物理删除,当增量数据中存在大量标记删除的数据时,物理行数量将远多于逻辑行数,从而会严重加剧存储引擎的读放大现象,并影响优化器对最优计划的选择。

分析:通过 explain extended_noaddr

分析方法很简单,用 EXPLAIN EXTENDED_NOADDR 命令对 SQL 进行详细的计划展示即可(通常用户在排查 SQL 性能问题时,会使用这种展示模式)。需要重点关注 physical_range_rows 和 logical_range_rows 的值。

上图中,physical_range_rows 和 logical_range_rows 分别表示 t1 表需要扫描的物理行数和逻辑行数。如果走了索引的话,含义为 t1 表在索引上需要扫描的物理行数和逻辑行数。

一般来说,physical_range_rows 和 logical_range_rows 这两个指标是相近的,看任意一个都可以。仅在这个特殊的 Buffer 表(频繁更新)场景下,physical_range_rows 可能会远大于 logical_range_rows,所以可以通过 physical_range_rows 和 logical_range_rows 的数值来判断是否出现 Buffer 表问题。

例如我向 t1 表插入 33 行数据,立即删除 29 行,那在增量数据里就会有 33 个物理行,其中 29 个物理行有 Delete 标记,查询真正的 4 个逻辑行时,就会出现大量无效扫描动作。

应对:Buffer 表自适应合并优化

为了更灵活地解决 Buffer 带来的性能下降问题,从 OceanBase 4.2.3 开始,为大家带来了 Buffer 表自适应合并优化特性

该特性允许用户根据业务场景为每张表设置不同的表级配置项 table_mode 以指定不同的快速冻结与自适应合并策略,以应对 Buffer 表引起的读放大现象,从而提高系统长期运行下的 QPS 等性能指标。

针对 Buffer 表问题,OceanBase 提供了 5 种档位的 Table Mode 支持。不同的 Table Mode 对应不同的统计信息阈值与合并策略。存储层每次转储时都会根据转储的统计信息及 Table Mode 对应的阈值判断是否需要执行一次针对 Buffer 表场景的特殊合并以消除增量数据里的所有 Delete 标记,从而避免原有的大量无效扫描动作。

Table Mode 转储后触发合并阈值 转储后触发合并概率 转储后合并类型
Normal(默认值) 较高(为基准1.0 ) 极低 仅做 Medium Compaction
Queuing 高(0.9)
Moderate 中(0.8) 优先做 Medium Compaction, 若 Medium 冷却中,做 Meta
Super 低(0.6)
Extreme 极低(0.5) 较高 仅做 Meta Compaction

备注:

合并是 OceanBase 数据库将动静态数据做归并的行为,能有效消除增量数据

  • Medium Compaction 是一类触发链路较长,需要保证多副本上 Major 一致性的合并,合并较慢
  • Meta Compaction 是由各个 Observer 主动发起,生成只读 Meta SSTable 的合并,合并较快

简单来说,默认场景(Normal)下转储后发起合并的门槛比较高(例如认为转储中删除行超过 30 万才判断可能是 Buffer 表场景),因此转储后触发合并概率较低,触发的也是合并速度较慢的 Medium Compaction。

而随着 Table Mode 的逐步上调,转储后发起合并的门槛逐渐降低,触发合并的概率也逐渐提高,并且更倾向于做合并速度较快的 Meta Compaction,从而能及时通过合并消除增量数据避免 Buffer 表带来的大量无效扫描。

具体来说,不同参数下的策略可以分为如下三个模块:

触发快速冻结

对创建时间 > 存活时间阈值,且 memtable 有热点行(不在本次优化考虑内)或墓碑现象的触发快速冻结:

Table Mode 存活时间阈值 墓碑现象阈值
Normal(默认) 120s = 120s * 1.0 (更新行 + 删除行)> 25w = 25w * 1.0
Queuing 108s = 120s * 0.9 (更新行 + 删除行)> 22.5w = 25w * 0.9
Moderate 96s = 120s * 0.8 (更新行 + 删除行)> 20w = 25w * 0.8
Super 72s = 120s * 0.6 (更新行 + 删除行)> 15w = 25w * 0.6
Extreme 60s = 120s * 0.5 (更新行 + 删除行)> 12.5w = 25w * 0.5

备注:

存活时间阈值的 120s 为 4.2 的阈值。在 4.3 及以上版本,此阈值为 300s。

转储时收集统计信息并尝试触发分区合并

冻结后紧接着会做一次转储,若转储同时满足如下条件,将会进行 tablet 级别的信息汇报:

  • dml 数量 > 1000
  • insert + update + delete 行数 > 1000

当然, tablet 信息的汇报并不止于转储,一些慢查询场景也会做相应处理,只是不在本任务范围内。

当 memtable 超过预设内存阈值或快速冻结触发后都会执行一次转储。如果触发了快速冻结,意味着墓碑现象可能已经出现了,因此根据上述快速冻结策略的墓碑策略再判断一次,如果命中,直接发起一次合并。

分区合并调度时调度分区合并

分区合并的调度由一个独立的调度线程负责,基本逻辑是通过自适应合并的策略来判断某个 tablet 是否需要执行一次分区合并。相应地,也会根据不同的 table mode 调整自适应策略。

与 table mode 有关的自适应合并策略如下:

备注:

这里引用一下 OB 官网的 自适应合并 内容:

其中第三点:根据统计增量数据的总行数这个并不受 table mode 控制,增量行数的阈值是超过 10w 或者增量行达到极限 70%,对于非 Normal 表,大概率已经触发合并了。

导数场景

Table Mode 导数场景条件(需同时满足)
根据统计的 10 分钟内信息
热点 tablet 插入比例 >= 70%
且操作行数满足 (操作行数 = insert + delete + update)
Normal(默认) 查询次数 + 转储次数 > 5 = 5 * 1.0 操作行数 >= 1w = 1w * 1.0
Queuing 查询次数 + 转储次数 > 4.5 = 5 * 0.9 操作行数 >= 9k = 1w * 0.9
Moderate 查询次数 + 转储次数 > 4 = 5 * 0.8 操作行数 >= 8k= 1w * 0.8
Super 查询次数 + 转储次数 > 3 = 5 * 0.6 操作行数 >= 6k = 1w * 0.6
Extreme 查询次数 + 转储次数 > 2.5 = 5 * 0.5 操作行数 >= 5k = 1w * 0.5

墓碑场景

Table Mode 墓碑场景条件(需同时满足)
当转储次数 ≥ 2
且根据统计的 10 分钟内信息
操作行数 = insert + delete + update 更新删除行数比例
Normal(默认) 操作行数 > 1w = 1w * 1.0 更新删除比例 >= 30% = 30% * 1.0
Queuing 操作行数 > 9k = 1w * 0.9 更新删除比例 >= 27% = 30% * 0.9
Moderate 操作行数 > 8k= 1w * 0.8 更新删除比例 >= 24% = 30% * 0.8
Super 操作行数 > 6k = 1w * 0.6 更新删除比例 >= 18% = 30% * 0.6
Extreme 操作行数 > 5k = 1w * 0.5 更新删除比例 >= 15% = 30% * 0.5

与此同时,为了防止 10 分钟内的信息不够全面,统计了 tablet 自上次 medium/meta 时的删除行总数,并有满足如下条件时触发一次合并。

Table Mode 总删除行数
Normal(默认) 总删除行 > 30w
Queuing 总删除行 > 20w
Moderate 总删除行 > 10w
Super 总删除行 > 5w
Extreme 总删除行 > 1k

但是值得一提的是, 总删除行这个条件属于锦上添花类型,类似于 cache,很有可能未满足条件时 tablet 统计信息就被别的 tablet 刷掉了。同时,按照最宽松的 10 分钟内 1w 的条件,如果每个 10 分钟内信息都是 9999 行,要达到 30w 的阈值也得近 300 分钟,作用是更多是在 tablet 数量少的时候留下来兜底。

低效读场景

Table Mode 低效读场景条件(需同时满足)
根据统计的 10 分钟内信息
热点 tablet 慢查询比例(满足其一即可)
Normal(默认) 查询次数 + 转储次数 > 5 = 5 * 1.0 + 扫描物理行数 >= 1000 = 1000 * 1.0
+ 扫描微块数 >= 10 = 10 * 1.0
+ rowkey exists 数 >= 5 = 5 * 1.0
Queuing 查询次数 + 转储次数 > 4.5 = 5 * 0.9 + 扫描物理行数 >= 900 = 1000 * 0.9
+ 扫描微块数 >= 9 = 10 * 0.9
+ rowkey exists 数 >= 4.5 = 5 * 0.9
Moderate 查询次数 + 转储次数 > 4 = 5 * 0.8 + 扫描物理行数 >= 800 = 1000 * 0.8
+ 扫描微块数 >= 8 = 10 * 0.8
+ rowkey exists 数 >= 4 = 5 * 0.8
Super 查询次数 + 转储次数 > 3 = 5 * 0.6 + 扫描物理行数 >= 600 = 1000 * 0.6
+ 扫描微块数 >= 6 = 10 * 0.6
+ rowkey exists 数 >= 3 = 5 * 0.6
Extreme 查询次数 + 转储次数 > 2.5 = 5 * 0.5 + 扫描物理行数 >= 500 = 1000 * 0.5
+ 扫描微块数 >= 5 = 10 * 0.5
+ rowkey exists 数 >= 2.5 = 5 * 0.5

使用指南

创建表时指定 Table Mode

用户可以在创建表时就显式指定 Table Mode,若不指定则默认为 Normal 模式。

# 语法
create table t1 (c1 int) table_mode = 'normal/queuing/moderate/super/extreme';

# 创建一张 queuing 模式的表
create table t1 (k int, v double) table_mode = 'queuing';

# 创建一张 extreme 模式的表
create table t1 (k int, v double) table_mode = 'extreme';

修改表的 Table Mode

用户可以通过 DDL 语句显式修改 Table Mode

# 语法
alter table t1 set table_mode = 'normal/queuing/Moderate/Super/Extreme';

# 修改表的 table_mode 为 moderate
alter table t1 set table_mode = 'moderate';

例如,当用户观测到某张表的读放大现象严重,出现 Buffer 表现象时,可以视业务量规模与特点手动修改该表的 Table Mode 到更高档位,如从 normal 调整到 queuing,从 queuing 调整到 super 等。当 Table Mode 在存储层生效后,将会通过调度自适应合并的方式发起合并以解决 Buffer 表现象。

与此同时,更激进的 Table Mode 将会更频繁地发起合并,从而消耗更多的计算资源,如果此时业务体量或场景能够容忍一定程度的 Buffer 表效应,也可以手动调低数据表的 Table Mode。

Table Mode 生效说明

值得说明的是,尽管 Table Mode 在 Table Schema 中是实时生效的,但是在存储层后台对 tablet 信息的统计是是延迟更新的(2 分钟刷新一次),因此当修改表的 Table Mode 后,平均得经过 60s 才能生效。

另外,存储层后台对每个租户下转储统计信息的覆盖范围是有限的,由于资源有限,并不是所有 tablet 上的转储信息都会被统计到,只有转储时满足某些特定条件的 tablet(写入数据量较大,读放大严重等)才会被纳入统计,Table Mode 对转储/合并行为的影响也仅限于这些被纳入统计的 tablet 上。

简单来说,Queuing 表的调度依赖于缓冲池内定期从内部表更新的 Table Mode,我们认为满足 buffer 表行为的 tablet 进入缓冲池的概率是极大的,近似于不会被淘汰(除非 Queuing 表的 分区数量过于巨大)。

如果用户想主动让 table 加入缓冲池,就手动执行一次转储即可。

性能说明与表现形式

通过构造频繁插入/更新/删除的 workload 对比了不同 Table Mode 下表对查询的性能表现。

基本测试流程

首先创建 5 张表,Table Mode 分别为 NORMALQUEUINGMODERATESUPEREXTREME

CREATE TABLE table_name (k int primary key, v int) table_mode='table_mode'

之后重复执行如下流程 500 轮,每轮起 5 个线程对 5 张表分别进行采样,等所有线程都 return 后再开始下一轮

-- 1. 第 1 轮采样,记录时间
SELECT COUNT(*) FROM table_name;

-- 2. 随机插入 [1000, 2000, 3000, 4000, 5000, 10000, 20000] 行测试数据

-- 3. 第 2 轮采样,记录时间
SELECT COUNT(*) FROM table_name;

-- 4. 执行更新语句
UPDATE /*+ parallel(8)*/ table_name SET v = v + 1 WHERE k >= 0;

-- 5. 第 3 轮采样,记录时间
SELECT COUNT(*) FROM table_name;

-- 6. 执行删除语句
DELETE /*+ parallel(8)*/ FROM table_name WHERE k >= 0

-- 7. 第 4 轮采样,记录时间
SELECT COUNT(*) FROM table_name;

这样一轮可以得到 4 个采样数据点,最后每张表可以得到 2000 个采样点。

测试结果

Table Mode 平均查询时间(秒) 相比 Normal 提升
NORMAL 0.4750267331600189 0
QUEUING 0.4220401510000229 -11.5%
MODERATE 0.3732480742931366 -21.4%
SUPER 0.2679354891777039 -43.6%
EXTREME 0.23057958698272704 -51.5%

从图中可以比较直观地看出几点:

  • 前 375 次左右的采样实际上各个模式的表现是类似的,这是因为设置 table mode 对转储合并的影响需要一段时间内才能传递到存储层:转储线程 2 分钟一轮,且只有转储满足满足一定条件时,tablet 的统计信息才会被更新 tablet 从而影响快速冻结与合并。
  • 激进模式(Extreme)的表现是最好的,当查询时延逐渐增加时,可以及时触发快速冻结及后续合并从而降低 Buffer 表的读放大现象。从图中也可以看到,除了一开始的预热阶段,每次 Extreme 总是及时将波峰降下去,也即及时做了一次合并。

不同 Table Mode 设置下对性能的直观表现

从该图可以直观看出不同 Table Mode 对合并行为以及查询性能的影响。每一次查询时延波峰的下降都代表着一次合并的发起,越高 Table Mode 下的表将会更频繁地发起合并,在图上就体现为合并次数多。同时,由于及时清除增量数据中的 Delete 标记,平均查询时延也更低。

总结

从上述测试数据可以看出,越高的 Table Mode 能够发起更频繁的合并从而降低查询的时延。但是合并的调度与发起也都会消耗更多的计算资源,因此用户需要根据面对的业务场景、数据量大小以及对 Buffer 表效应的容忍程度选择合适的 Table Mode 以达成整体的平衡。

老纪的技术唠嗑局 不仅希望能持续给大家带来有价值的技术分享,也希望能和大家一起为开源社区贡献力量。如果你对 OceanBase 开源社区认可,点亮一颗小星星 吧!你的每一个Star,都是我们努力的动力~
https://github.com/oceanbase/oceanbase