博客 / 詳情

返回

故障分析 | OceanBase 頻繁更新數據後讀性能下降的排查

本文摘要

本文分析並復現了 OceanBase 頻繁更新數據後讀性能下降現象的原因,並給出了性能改善建議。

背景

測試在做 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';
create resource pool sysbench_pool unit = 'sysbench_unit', unit_num = 1, zone_list=('zone1');
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='%';

數據預備

創建 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

環境調優

手動觸發大合併

ALTER SYSTEM MAJOR FREEZE TENANT=ALL;

# 查看合併進度
SELECT * FROM oceanbase.CDB_OB_ZONE_MAJOR_COMPACTION\G

數據更新前的純讀 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

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

再執行 read_only 的 QPS 表現如下:

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

數據做一次大合併後純讀 QPS

手動觸發大合併,執行命令:

ALTER SYSTEM MAJOR FREEZE TENANT=ALL;

# 查看合併進度
SELECT * FROM oceanbase.CDB_OB_ZONE_MAJOR_COMPACTION\G

再次執行 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)
{
  int ret = OB_SUCCESS;
  // TRUE:For the multi-version row of the current rowkey, when there is no row to be read in this micro_block
  bool final_result = false;
  // TRUE:For reverse scanning, if this micro_block has the last row of the previous rowkey
  bool found_first_row = false;
  bool have_uncommited_row = false;
  const ObDatumRow *multi_version_row = NULL;
  ret_row = NULL;
 
  while (OB_SUCC(ret)) {
    final_result = false;
    found_first_row = false;
    // 定位到當前要讀取的位置
    if (OB_FAIL(locate_cursor_to_read(found_first_row))) {
      if (OB_UNLIKELY(OB_ITER_END != ret)) {
        LOG_WARN("failed to locate cursor to read", K(ret), K_(macro_id));
      }
    }
    LOG_DEBUG("locate cursor to read", K(ret), K(finish_scanning_cur_rowkey_),
              K(found_first_row), K(current_), K(reserved_pos_), K(last_), K_(macro_id));
 
    while (OB_SUCC(ret)) {
      multi_version_row = NULL;
      bool version_fit = false;
      // 讀取下一行
      if (read_row_direct_flag_) {
        if (OB_FAIL(inner_get_next_row_directly(multi_version_row, version_fit, final_result))) {
          if (OB_UNLIKELY(OB_ITER_END != ret)) {
            LOG_WARN("failed to inner get next row directly", K(ret), K_(macro_id));
          }
        }
      } else if (OB_FAIL(inner_inner_get_next_row(multi_version_row, version_fit, final_result, have_uncommited_row))) {
        if (OB_UNLIKELY(OB_ITER_END != ret)) {
          LOG_WARN("failed to inner get next row", K(ret), K_(macro_id));
        }
      }
      if (OB_SUCC(ret)) {
        // 如果讀取到的行版本不匹配,則不進行任何操作
        if (!version_fit) {
          // do nothing
        }
        // 如果匹配,則進行合併融合
        else if (OB_FAIL(do_compact(multi_version_row, row_, final_result))) {
          LOG_WARN("failed to do compact", K(ret));
        } else {
          // 記錄物理讀取次數
          if (OB_NOT_NULL(context_)) {
            ++context_->table_store_stat_.physical_read_cnt_;
          }
          if (have_uncommited_row) {
            row_.set_have_uncommited_row();
          }
        }
      }
      LOG_DEBUG("do compact", K(ret), K(current_), K(version_fit), K(final_result), K(finish_scanning_cur_rowkey_),
                "cur_row", is_row_empty(row_) ? "empty" : to_cstring(row_),
                "multi_version_row", to_cstring(multi_version_row), K_(macro_id));
      // 該行多版本如果在當前微塊已經全部讀取完畢,就將當前微塊的行緩存並跳出內層循環
      if ((OB_SUCC(ret) && final_result) || OB_ITER_END == ret) {
        ret = OB_SUCCESS;
        if (OB_FAIL(cache_cur_micro_row(found_first_row, final_result))) {
          LOG_WARN("failed to cache cur micro row", K(ret), K_(macro_id));
        }
        LOG_DEBUG("cache cur micro row", K(ret), K(finish_scanning_cur_rowkey_),
                  "cur_row", is_row_empty(row_) ? "empty" : to_cstring(row_),
                  "prev_row", is_row_empty(prev_micro_row_) ? "empty" : to_cstring(prev_micro_row_),
                  K_(macro_id));
        break;
      }
    }
    // 結束掃描,將最終結果放到ret_row,跳出外層循環。
    if (OB_SUCC(ret) && finish_scanning_cur_rowkey_) {
      if (!is_row_empty(prev_micro_row_)) {
        ret_row = &prev_micro_row_;
      } else if (!is_row_empty(row_)) {
        ret_row = &row_;
      }
      // If row is NULL, means no multi_version row of current rowkey in [base_version, snapshot_version) range
      if (NULL != ret_row) {
        (const_cast<ObDatumRow *>(ret_row))->mvcc_row_flag_.set_uncommitted_row(false);
        const_cast<ObDatumRow *>(ret_row)->trans_id_.reset();
        break;
      }
    }
  }
  if (OB_NOT_NULL(ret_row)) {
    if (!ret_row->is_valid()) {
      LOG_ERROR("row is invalid", KPC(ret_row));
    } else {
      LOG_DEBUG("row is valid", KPC(ret_row));
      if (OB_NOT_NULL(context_)) {
        ++context_->table_store_stat_.logical_read_cnt_;
      }
    }
  }
  return ret;
}

分析

從火焰圖來看,QPS 降低,消耗集中在對多版本數據行的處理上,也就是一行數據的頻繁更新操作對應到存儲引擎裏是多條記錄,查詢的 SQL 在內部處理時,實際可能需要掃描的行數量可能遠大於本身的行數。

手法 2:分析 SQL 執行過程

通過 GV$OB_SQL_AUDIT 審計表,可以查看每次請求客户端來源、執行服務器信息、執行狀態信息、等待事件以及執行各階段耗時等。

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

對比性能下降前後相同 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
*************************** 1. row ***************************
               TRACE_ID: YB42AC110005-0005F9ADDCDF0240-0-0
            TENANT_NAME: sysbench_tenant
              USER_NAME: sysbench
                DB_NAME: sysbenchdb
              QUERY_SQL: SELECT c FROM sbtest20 WHERE id BETWEEN 498915 AND 499014 ORDER BY c
                PLAN_ID: 10776
            RETURN_ROWS: 100
            IS_HIT_PLAN: 1
           ELAPSED_TIME: 16037
           EXECUTE_TIME: 15764
MEMSTORE_READ_ROW_COUNT: 0
 SSSTORE_READ_ROW_COUNT: 100
    DATA_BLOCK_READ_CNT: 2
   DATA_BLOCK_CACHE_HIT: 2
   INDEX_BLOCK_READ_CNT: 2
  INDEX_BLOCK_CACHE_HIT: 1
1 row in set (0.255 sec)

執行更新操作後(低 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
*************************** 1. row ***************************
               TRACE_ID: YB42AC110005-0005F9ADE2E77EC0-0-0
            TENANT_NAME: sysbench_tenant
              USER_NAME: sysbench
                DB_NAME: sysbenchdb
              QUERY_SQL: SELECT c FROM sbtest7 WHERE id BETWEEN 501338 AND 501437 ORDER BY c
                PLAN_ID: 10848
            RETURN_ROWS: 100
            IS_HIT_PLAN: 1
           ELAPSED_TIME: 36960
           EXECUTE_TIME: 36828
MEMSTORE_READ_ROW_COUNT: 33
 SSSTORE_READ_ROW_COUNT: 200
    DATA_BLOCK_READ_CNT: 63
   DATA_BLOCK_CACHE_HIT: 63
   INDEX_BLOCK_READ_CNT: 6
  INDEX_BLOCK_CACHE_HIT: 4
1 row in set (0.351 sec)

分析

上面查詢結果顯示字段 IS_HIT_PLAN 的值為 1,説明 SQL 命中了執行計劃緩存,沒有走物理生成執行計劃的路徑。我們根據 PLAN_ID 進一步到 V$OB_PLAN_CACHE_PLAN_EXPLAIN 查看物理執行計劃(數據更新前後執行計劃相同,下面僅列出數據更新後的執行計劃)。

注:訪問 V$OB_PLAN_CACHE_PLAN_EXPLAIN,必須給定 tenant_idplan_id 的值,否則系統將返回空集。
MySQL [oceanbase]>  SELECT * FROM V$OB_PLAN_CACHE_PLAN_EXPLAIN WHERE tenant_id = 1002 AND plan_id=10848 \G
*************************** 1. row ***************************
   TENANT_ID: 1002
      SVR_IP: 172.17.0.5
    SVR_PORT: 2882
     PLAN_ID: 10848
  PLAN_DEPTH: 0
PLAN_LINE_ID: 0
    OPERATOR: PHY_SORT
        NAME: NULL
        ROWS: 100
        COST: 51
    PROPERTY: NULL
*************************** 2. row ***************************
   TENANT_ID: 1002
      SVR_IP: 172.17.0.5
    SVR_PORT: 2882
     PLAN_ID: 10848
  PLAN_DEPTH: 1
PLAN_LINE_ID: 1
    OPERATOR:  PHY_TABLE_SCAN
        NAME: sbtest20
        ROWS: 100
        COST: 6
    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)]
2 rows in set (0.001 sec)

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';

但是社區版 4.0.0.0 的發佈記錄中看到,不再支持 Queuing 表。後查詢社區有解釋:OB 在 4.x 版本(預計 4.1 完成)採用自適應的方式支持 Queuing 表的這種場景,不需要再人為指定,也就是 Release Note 中提到的不再支持 Queuing 表。

參考資料

  1. Queuing 表查詢緩慢問題
  2. 大批量數據處理後訪問慢問題處理
  3. OceanBase Queuing 表(buffer 表)處理最佳實踐
  4. ob4.0 確定不支持 Queuing 表了嗎?

本文關鍵字:#OceanBase# #火焰圖# #性能調優#

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.