故障分析 | OceanBase 频繁更新数据后读性能下降的排查

作者:张乾

外星人2号,现兼任六位喵星人的资深铲屎官。

本文来源:原创投稿

* 爱可生开源社区出品,原创内容未经授权不得随意使用,转载请联系小编并注明来源。


背景

测试在做 OceanBase 纯读性能压测的时候,发现对数据做过更新操作后,读性能会有较为明显的下降。具体复现步骤如下。

复现方式

环境预备

部署 OB

使用 OBD 部署单节点 OB。


版本 IP
OceanBase 4.0.0.0 CE 10.186.16.122

参数均为默认值,其中内存以及转储合并等和本次实验相关的重要参数值具体如下:

参数名 含义 默认值
memstore_limit_percentage 设置租户使用 memstore 的内存占其总可用内存的百分比。 50
freeze_trigger_percentage 触发全局冻结的租户使用内存阈值。 20
major_compact_trigger 设置多少次小合并触发一次全局合并。 0
minor_compact_trigger 控制分层转储触发向下一层下压的阈值。当该层的 Mini SSTable 总数达到设定的阈值时,所有 SSTable 都会被下压到下一层,组成新的 Minor SSTable。 2


创建 sysbench 租户

create resource unit sysbench_unit max_cpu 26, memory_size '21g';<br>create resource pool sysbench_pool unit = 'sysbench_unit', unit_num = 1, zone_list=('zone1');<br>create tenant sysbench_tenant resource_pool_list=('sysbench_pool'), charset=utf8mb4, zone_list=('zone1'), primary_zone=RANDOM set variables ob_compatibility_mode='mysql', ob_tcp_invited_nodes='%';<br>

数据预备

创建 30 张 100 万行数据的表。

sysbench ./oltp_read_only.lua --mysql-host=10.186.16.122 --mysql-port=12881 --mysql-db=sysbenchdb --mysql-user="sysbench@sysbench_tenant"  --mysql-password=sysbench --tables=30 --table_size=1000000 --threads=256 --time=60 --report-interval=10 --db-driver=mysql --db-ps-mode=disable --skip-trx=on --mysql-ignore-errors=6002,6004,4012,2013,4016,1062 prepare<br>


环境调优

手动触发大合并

ALTER SYSTEM MAJOR FREEZE TENANT=ALL;<br><br># 查看合并进度<br>SELECT * FROM oceanbase.CDB_OB_ZONE_MAJOR_COMPACTIONG<br>


数据更新前的纯读 QPS

sysbench ./oltp_read_only.lua --mysql-host=10.186.16.122 --mysql-port=12881 --mysql-db=sysbenchdb --mysql-user="sysbench@sysbench_tenant"  --mysql-password=sysbench --tables=30 --table_size=1000000 --threads=256 --time=60 --report-interval=10 --db-driver=mysql --db-ps-mode=disable --skip-trx=on --mysql-ignore-errors=6002,6004,4012,2013,4016,1062 run<br>

read_only 的 QPS 表现如下:

第一次 第二次 第三次 第四次 第五次
344727.36 325128.58 353141.76 330873.54 340936.48


数据更新后的纯读 QPS

执行三次 write_only 脚本,其中包括了 update/delete/insert 操作,命令如下:

sysbench ./oltp_write_only.lua --mysql-host=10.186.16.122 --mysql-port=12881 --mysql-db=sysbenchdb --mysql-user="sysbench@sysbench_tenant"  --mysql-password=sysbench --tables=30 --table_size=1000000 --threads=256 --time=60 --report-interval=10 --db-driver=mysql --db-ps-mode=disable --skip-trx=on --mysql-ignore-errors=6002,6004,4012,2013,4016,1062 run<br>

再执行 read_only 的 QPS 表现如下:

第一次 第二次 第三次 第四次 第五次
170718.07 175209.29 173451.38 169685.38 166640.62

数据做一次大合并后纯读 QPS

手动触发大合并,执行命令:

ALTER SYSTEM MAJOR FREEZE TENANT=ALL;<br><br># 查看合并进度<br>SELECT * FROM oceanbase.CDB_OB_ZONE_MAJOR_COMPACTIONG<br>

再次执行 read_only ,QPS 表现如下,可以看到读的 QPS 恢复至初始水平。

第一次 第二次 第三次 第四次 第五次
325864.95 354866.82 331337.10 326113.78 340183.18

现象总结

对比数据更新前后的纯读 QPS,发现在做过批量更新操作后,读性能下降 17W 左右,做一次大合并后性能又可以提升回来。

排查过程

手法 1:火焰图

火焰图差异对比

收集数据更新前后进行压测时的火焰图,对比的不同点集中在下面标注的蓝框中。

放大到方法里进一步查看,发现低 QPS 火焰图顶部多了几个 '平台',指向同一个方法 oceanbase::blocksstable::ObMultiVersionMicroBlockRowScanner::inner_get_next_row


查看源码

火焰图中指向的方法,会进一步调用 ObMultiVersionMicroBlockRowScanner::inner_get_next_row_impl。后者的主要作用是借嵌套 while 循环进行多版本数据行的读取,并将符合条件的行合并融合(do_compact 中会调用 fuse_row),返回一个合并后的行(ret_row)作为最终结果,源码如下:

int ObMultiVersionMicroBlockRowScanner::inner_get_next_row_impl(const ObDatumRow *&ret_row)<br>{<br>  int ret = OB_SUCCESS;<br>  // TRUE:For the multi-version row of the current rowkey, when there is no row to be read in this micro_block<br>  bool final_result = false;<br>  // TRUE:For reverse scanning, if this micro_block has the last row of the previous rowkey<br>  bool found_first_row = false;<br>  bool have_uncommited_row = false;<br>  const ObDatumRow *multi_version_row = NULL;<br>  ret_row = NULL;<br> <br>  while (OB_SUCC(ret)) {<br>    final_result = false;<br>    found_first_row = false;<br>    // 定位到当前要读取的位置<br>    if (OB_FAIL(locate_cursor_to_read(found_first_row))) {<br>      if (OB_UNLIKELY(OB_ITER_END != ret)) {<br>        LOG_WARN("failed to locate cursor to read", K(ret), K_(macro_id));<br>      }<br>    }<br>    LOG_DEBUG("locate cursor to read", K(ret), K(finish_scanning_cur_rowkey_),<br>              K(found_first_row), K(current_), K(reserved_pos_), K(last_), K_(macro_id));<br> <br>    while (OB_SUCC(ret)) {<br>      multi_version_row = NULL;<br>      bool version_fit = false;<br>      // 读取下一行<br>      if (read_row_direct_flag_) {<br>        if (OB_FAIL(inner_get_next_row_directly(multi_version_row, version_fit, final_result))) {<br>          if (OB_UNLIKELY(OB_ITER_END != ret)) {<br>            LOG_WARN("failed to inner get next row directly", K(ret), K_(macro_id));<br>          }<br>        }<br>      } else if (OB_FAIL(inner_inner_get_next_row(multi_version_row, version_fit, final_result, have_uncommited_row))) {<br>        if (OB_UNLIKELY(OB_ITER_END != ret)) {<br>          LOG_WARN("failed to inner get next row", K(ret), K_(macro_id));<br>        }<br>      }<br>      if (OB_SUCC(ret)) {<br>        // 如果读取到的行版本不匹配,则不进行任何操作<br>        if (!version_fit) {<br>          // do nothing<br>        }<br>        // 如果匹配,则进行合并融合<br>        else if (OB_FAIL(do_compact(multi_version_row, row_, final_result))) {<br>          LOG_WARN("failed to do compact", K(ret));<br>        } else {<br>          // 记录物理读取次数<br>          if (OB_NOT_NULL(context_)) {<br>            ++context_->table_store_stat_.physical_read_cnt_;<br>          }<br>          if (have_uncommited_row) {<br>            row_.set_have_uncommited_row();<br>          }<br>        }<br>      }<br>      LOG_DEBUG("do compact", K(ret), K(current_), K(version_fit), K(final_result), K(finish_scanning_cur_rowkey_),<br>                "cur_row", is_row_empty(row_) ? "empty" : to_cstring(row_),<br>                "multi_version_row", to_cstring(multi_version_row), K_(macro_id));<br>      // 该行多版本如果在当前微块已经全部读取完毕,就将当前微块的行缓存并跳出内层循环<br>      if ((OB_SUCC(ret) && final_result) || OB_ITER_END == ret) {<br>        ret = OB_SUCCESS;<br>        if (OB_FAIL(cache_cur_micro_row(found_first_row, final_result))) {<br>          LOG_WARN("failed to cache cur micro row", K(ret), K_(macro_id));<br>        }<br>        LOG_DEBUG("cache cur micro row", K(ret), K(finish_scanning_cur_rowkey_),<br>                  "cur_row", is_row_empty(row_) ? "empty" : to_cstring(row_),<br>                  "prev_row", is_row_empty(prev_micro_row_) ? "empty" : to_cstring(prev_micro_row_),<br>                  K_(macro_id));<br>        break;<br>      }<br>    }<br>    // 结束扫描,将最终结果放到ret_row,跳出外层循环。<br>    if (OB_SUCC(ret) && finish_scanning_cur_rowkey_) {<br>      if (!is_row_empty(prev_micro_row_)) {<br>        ret_row = &prev_micro_row_;<br>      } else if (!is_row_empty(row_)) {<br>        ret_row = &row_;<br>      }<br>      // If row is NULL, means no multi_version row of current rowkey in [base_version, snapshot_version) range<br>      if (NULL != ret_row) {<br>        (const_cast<ObDatumRow *>(ret_row))->mvcc_row_flag_.set_uncommitted_row(false);<br>        const_cast<ObDatumRow *>(ret_row)->trans_id_.reset();<br>        break;<br>      }<br>    }<br>  }<br>  if (OB_NOT_NULL(ret_row)) {<br>    if (!ret_row->is_valid()) {<br>      LOG_ERROR("row is invalid", KPC(ret_row));<br>    } else {<br>      LOG_DEBUG("row is valid", KPC(ret_row));<br>      if (OB_NOT_NULL(context_)) {<br>        ++context_->table_store_stat_.logical_read_cnt_;<br>      }<br>    }<br>  }<br>  return ret;<br>}<br>


分析

从火焰图来看,QPS 降低,消耗集中在对多版本数据行的处理上,也就是一行数据的频繁更新操作对应到存储引擎里是多条记录,查询的 SQL 在内部处理时,实际可能需要扫描的行数量可能远大于本身的行数。

手法 2:分析 SQL 执行过程

通过 GV$OB_SQL_AUDIT 审计表,可以查看每次请求客户端来源、执行服务器信息、执行状态信息、等待事件以及执行各阶段耗时等。

GV$OB_SQL_AUDIT 用法参考:https://www.oceanbase.com/docs/common-oceanbase-database-cn-10000000001699453

对比性能下降前后相同 SQL 的执行信息

由于本文场景没有实际的慢 SQL,这里选择在 GV$OB_SQL_AUDIT 中,根据 SQL 执行耗时(elapsed_time)筛出 TOP10,取一条进行排查:SELECT c FROM sbtest% WHERE id BETWEEN ? AND ? ORDER BY c 。

执行更新操作前(也就是高 QPS 时):

MySQL [oceanbase]> select TRACE_ID,TENANT_NAME,USER_NAME,DB_NAME,QUERY_SQL,RETURN_ROWS,IS_HIT_PLAN,ELAPSED_TIME,EXECUTE_TIME,MEMSTORE_READ_ROW_COUNT,SSSTORE_READ_ROW_COUNT,DATA_BLOCK_READ_CNT,DATA_BLOCK_CACHE_HIT,INDEX_BLOCK_READ_CNT,INDEX_BLOCK_CACHE_HIT from GV$OB_SQL_AUDIT where TRACE_ID='YB42AC110005-0005F9ADDCDF0240-0-0' G<br>*************************** 1. row ***************************<br>               TRACE_ID: YB42AC110005-0005F9ADDCDF0240-0-0<br>            TENANT_NAME: sysbench_tenant<br>              USER_NAME: sysbench<br>                DB_NAME: sysbenchdb<br>              QUERY_SQL: SELECT c FROM sbtest20 WHERE id BETWEEN 498915 AND 499014 ORDER BY c<br>                PLAN_ID: 10776<br>            RETURN_ROWS: 100<br>            IS_HIT_PLAN: 1<br>           ELAPSED_TIME: 16037<br>           EXECUTE_TIME: 15764<br>MEMSTORE_READ_ROW_COUNT: 0<br> SSSTORE_READ_ROW_COUNT: 100<br>    DATA_BLOCK_READ_CNT: 2<br>   DATA_BLOCK_CACHE_HIT: 2<br>   INDEX_BLOCK_READ_CNT: 2<br>  INDEX_BLOCK_CACHE_HIT: 1<br>1 row in set (0.255 sec)<br>

执行更新操作后(低 QPS 值时):

MySQL [oceanbase]> select TRACE_ID,TENANT_NAME,USER_NAME,DB_NAME,QUERY_SQL,RETURN_ROWS,IS_HIT_PLAN,ELAPSED_TIME,EXECUTE_TIME,MEMSTORE_READ_ROW_COUNT,SSSTORE_READ_ROW_COUNT,DATA_BLOCK_READ_CNT,DATA_BLOCK_CACHE_HIT,INDEX_BLOCK_READ_CNT,INDEX_BLOCK_CACHE_HIT from GV$OB_SQL_AUDIT where TRACE_ID='YB42AC110005-0005F9ADE2E77EC0-0-0' G<br>*************************** 1. row ***************************<br>               TRACE_ID: YB42AC110005-0005F9ADE2E77EC0-0-0<br>            TENANT_NAME: sysbench_tenant<br>              USER_NAME: sysbench<br>                DB_NAME: sysbenchdb<br>              QUERY_SQL: SELECT c FROM sbtest7 WHERE id BETWEEN 501338 AND 501437 ORDER BY c<br>                PLAN_ID: 10848<br>            RETURN_ROWS: 100<br>            IS_HIT_PLAN: 1<br>           ELAPSED_TIME: 36960<br>           EXECUTE_TIME: 36828<br>MEMSTORE_READ_ROW_COUNT: 33<br> SSSTORE_READ_ROW_COUNT: 200<br>    DATA_BLOCK_READ_CNT: 63<br>   DATA_BLOCK_CACHE_HIT: 63<br>   INDEX_BLOCK_READ_CNT: 6<br>  INDEX_BLOCK_CACHE_HIT: 4<br>1 row in set (0.351 sec)<br>


分析

上面查询结果显示字段 IS_HIT_PLAN 的值为 1,说明 SQL 命中了执行计划缓存,没有走物理生成执行计划的路径。我们根据 PLAN_ID 进一步到 V$OB_PLAN_CACHE_PLAN_EXPLAIN 查看物理执行计划(数据更新前后执行计划相同,下面仅列出数据更新后的执行计划)。

注:访问 V$OB_PLAN_CACHE_PLAN_EXPLAIN,必须给定 tenant_id 和 plan_id 的值,否则系统将返回空集。

MySQL [oceanbase]>  SELECT * FROM V$OB_PLAN_CACHE_PLAN_EXPLAIN WHERE tenant_id = 1002 AND plan_id=10848 G<br>*************************** 1. row ***************************<br>   TENANT_ID: 1002<br>      SVR_IP: 172.17.0.5<br>    SVR_PORT: 2882<br>     PLAN_ID: 10848<br>  PLAN_DEPTH: 0<br>PLAN_LINE_ID: 0<br>    OPERATOR: PHY_SORT<br>        NAME: NULL<br>        ROWS: 100<br>        COST: 51<br>    PROPERTY: NULL<br>*************************** 2. row ***************************<br>   TENANT_ID: 1002<br>      SVR_IP: 172.17.0.5<br>    SVR_PORT: 2882<br>     PLAN_ID: 10848<br>  PLAN_DEPTH: 1<br>PLAN_LINE_ID: 1<br>    OPERATOR:  PHY_TABLE_SCAN<br>        NAME: sbtest20<br>        ROWS: 100<br>        COST: 6<br>    PROPERTY: table_rows:1000000, physical_range_rows:100, logical_range_rows:100, index_back_rows:0, output_rows:100, est_method:local_storage, avaiable_index_name[sbtest20], pruned_index_name[k_20], estimation info[table_id:500294, (table_type:12, version:-1--1--1, logical_rc:100, physical_rc:100)]<br>2 rows in set (0.001 sec)<br>

从 V$OB_PLAN_CACHE_PLAN_EXPLAIN 查询结果看,执行计划涉及两个算子:范围扫描算子 PHY_TABLE_SCAN 和排序算子 PHY_SORT。根据范围扫描算子 PHY_TABLE_SCAN 中的 PROPERTY 信息,可以看出该算子使用的是主键索引,不涉及回表,行数为 100。综上来看,该 SQL 的执行计划正确且已是最优,没有调整的空间。

再对比两次性能压测下 GV$OB_SQL_AUDIT 表,当性能下降后,MEMSTORE_READ_ROW_COUNT(MemStore 中读的行数)和 SSSTORE_READ_ROW_COUNT (SSSTORE 中读的行数)加起来读的总行数为 233,是实际返回行数的两倍多。符合上面观察到的火焰图上的问题,即实际读的行数大于本身的行数,该处消耗了系统更多的资源,导致性能下降。

结论

OceanBase 数据库的存储引擎基于 LSM-Tree 架构,以基线加增量的方式进行存储,当在一个表中进行大量的插入、删除、更新操作后,查询每一行数据的时候需要根据版本从新到旧遍历所有的 MemTable 以及 SSTable,将每个 Table 中对应主键的数据融合在一起返回,此时表现出来的就是查询性能明显下降,即读放大。

性能改善方式

对于已经运行在线上的 buffer 表问题,官方文档中给出的应急处理方案如下:

  1. 对于存在可用索引,但 OB 优化器计划生成为全表扫描的场景。需要进行执行计划 binding 来固定计划。
  2. 如果 SQL 查询的主要过滤字段无可用索引,此时推荐在线创建可用索引并绑定该计划。
  3. 如果业务场景暂时无法创建索引,或者执行的 SQL 多为范围扫描,此时可根据业务场景需要决定是否手动触发合并,将删除或更新的数据版本进行清理,降低全表扫描的数据量,提升速度。

另外,从 2.2.7 版本开始,OceanBase 引入了 buffer minor merge 设计,实现对 Queuing 表的特殊转储机制,彻底解决无效扫描问题,通过将表的模式设置为 queuing 来开启。对于设计阶段已经明确的 Queuing 表场景,推荐开启该特性作为长期解决方案。

ALTER TABLE table_name TABLE_MODE = 'queuing';<br>

但是社区版 4.0.0.0 的发布记录中看到,不再支持 Queuing 表。后查询社区有解释:OB 在 4.x 版本(预计 4.1 完成)采用自适应的方式支持 Queuing 表的这种场景,不需要再人为指定,也就是 Release Note 中提到的不再支持 Queuing 表。

参考资料

  1. 《Queuing 表查询缓慢问题》:https://www.oceanbase.com/docs/enterprise-oceanbase-database-cn-10000000000945692
  2. 《大批量数据处理后访问慢问题处理》:https://ask.oceanbase.com/t/topic/35602375
  3. 《OceanBase Queuing 表(buffer 表)处理最佳实践》:https://open.oceanbase.com/blog/2239494400
  4. 《ob4.0 确定不支持 Queuing 表了吗?》:https://ask.oceanbase.com/t/topic/35601606


             本文关键字 
             :#OceanBase# #火焰图# #性能调优# 

          <h5 data-tool="mdnice编辑器"></h5> 

文章推荐:


故障分析 | MySQL 升级到 8.0 变慢问题分析

技术分享 | OceanBase写入限速源码解读

新特性解读 | MySQL 8.0 新密码策略(上)

新特性解读 | MySQL 8.0 新密码策略(中)

新特性解读 | MySQL 8.0 新密码策略(下)

新特性解读 | MySQL 8.0 新密码策略(终篇)



关于 SQLE

爱可生开源社区的 SQLE 是一款面向数据库使用者和管理者,支持多场景审核,支持标准化上线流程,原生支持 MySQL 审核且数据库类型可扩展的 SQL 审核工具。

SQLE 获取

类型 地址
版本库 https://github.com/actiontech/sqle
文档 https://actiontech.github.io/sqle-docs-cn/
发布信息 https://github.com/actiontech/sqle/releases
数据审核插件开发文档 https://actiontech.github.io/sqle-docs-cn/3.modules/3.7_auditplugin/auditplugin_development.html

提交有效 pr,高质量 issue,将获赠面值 200-500 元(具体面额依据质量而定)京东卡以及爱可生开源社区精美周边!

更多关于 SQLE 的信息和交流,请加入官方QQ交流群:637150065

本文分享自微信公众号 - 爱可生开源社区(ActiontechOSS)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。