淺析MySQL代價模型:告別盲目使用EXPLAIN,提前預知索引優化策略| 京東零售技術團隊

背景

在 MySQL 中,當我們爲表創建了一個或多個索引後,通常需要在索引定義完成後,根據具體的數據情況執行 EXPLAIN 命令,才能觀察到數據庫實際使用哪個索引、是否使用索引。這使得我們在添加新索引之前,無法提前預知數據庫是否能使用期望的索引。更爲糟糕的是,有時甚至在添加新的索引後,數據庫在某些查詢中會使用它,而在其他查詢中則不會使用,這種情況下,我們無法確定索引是否發揮了預期的作用,讓人感到非常苦惱。這種情況基本上意味着 MySQL 並沒有爲我們選擇最優的索引,而我們不得不在茫茫數據中摸索,試圖找到問題的癥結所在。我們可能會嘗試調整索引,甚至刪除索引,然後重新添加,希望 MySQL 能從中找到最優的索引選擇。然而,這樣的過程既耗時又費力,而且往往收效甚微。

如果在添加索引之前,我們能夠預知索引的使用情況,那麼對於表設計將大有裨益。我們可以在設計表結構時,更加明確地知道應該選擇哪些索引,如何優化索引,以提高查詢效率。我們不再需要依賴盲目嘗試和猜測,而是可以基於實際的數據和查詢情況,做出更加明智的決策。因此,對於 MySQL 用戶來說,能夠預知索引走勢的需求非常迫切。我們希望能有一種方法,能夠讓我們在添加索引之前,就清楚地瞭解 MySQL 將如何使用索引,以便我們能夠更好地優化表結構,提高查詢效率。這將極大地減輕我們的工作負擔,提高我們的工作效率,讓我們能夠更加專注於業務邏輯的處理,而不是在索引的海洋中掙扎。

爲了解決這個問題,我們可以深入研究 MySQL 的索引選擇機制。實際上,這個機制的核心就是代價模型,它通過一個公式來決定索引的選擇策略。相對於 MySQL 其他複雜的概念,代價模型實現起來要簡單得多。熟悉代價模型之後,我們可以預先了解 MySQL 在執行查詢時會如何選擇索引,從而更有效地進行索引優化。在接下來的文章中,我將結合近期進行索引優化的具體案例,來詳細解釋如何運用代價模型來優化索引。



MySQL代價模型淺析







MySQL數據庫主要由4層組成:

1.連接層:客戶端和連接服務,主要完成一些類似於連接處理、授權管理、以及相關的安全方案。

2.服務層:主要完成大多數的核心服務功能,如SQL接口,並完成緩存的查詢,SQL的分析和優化以及內部函數的執行。

3.引擎層:負責MySQL中數據的存儲和提取,服務器通過AP1與存儲引擎進行通信。

4.存儲層:將數據存儲文件系統上,並完成與存儲引擎的交互。



索引策略選擇在SQL優化器進行的

SQL 優化器會分析所有可能的執行計劃,選擇成本最低的執行,這種優化器稱之爲:CBO(Cost-based Optimizer,基於成本的優化器)。

Cost = Server Cost + Engine Cost = CPU Cost + IO Cost

其中,CPU Cost 表示計算的開銷,比如索引鍵值的比較、記錄值的比較、結果集的排序 ...... 這些操作都在 Server 層完成;

IO Cost 表示引擎層 IO 的開銷,MySQL 可以通過區分一張表的數據是否在內存中,分別計算讀取內存 IO 開銷以及讀取磁盤 IO 的開銷。



源碼簡讀

MySQL的數據源代碼採用了5.7.22版本,後續的代價計算公式將基於此版本進行參考。









opt_costconstants.cc【代價模型——計算所需代價計算係數】

/*
  在Server_cost_constants類中定義爲靜態常量變量的成本常量的值。如果服務器管理員沒有在server_cost表中添加新值,則將使用這些默認成本常數值。
  5.7版本開始可用從數據庫加載常量值,該版本前使用代碼中寫的常量值
*/

// 計算符合條件的⾏的代價,⾏數越多,此項代價越⼤
const double Server_cost_constants::ROW_EVALUATE_COST= 0.2;

// 鍵⽐較的代價,例如排序
const double Server_cost_constants::KEY_COMPARE_COST= 0.1;
  
/* 
   內存臨時表的創建代價
   通過基準測試,創建Memory臨時表的成本與向表中寫入10行的成本一樣高。
*/
const double Server_cost_constants::MEMORY_TEMPTABLE_CREATE_COST= 2.0;

// 內存臨時表的⾏代價
const double Server_cost_constants::MEMORY_TEMPTABLE_ROW_COST= 0.2;

/*
  內部myisam或innodb臨時表的創建代價
  創建MyISAM表的速度是創建Memory表的20倍。
*/
const double Server_cost_constants::DISK_TEMPTABLE_CREATE_COST= 40.0;

/*
  內部myisam或innodb臨時表的⾏代價
  當行數大於1000時,按順序生成MyISAM行比生成Memory行慢2倍。然而,沒有非常大的表的基準,因此保守地將此係數設置爲慢5倍(即成本爲1.0)。
*/
const double Server_cost_constants::DISK_TEMPTABLE_ROW_COST= 1.0;




/*
  在SE_cost_constants類中定義爲靜態常量變量的成本常量的值。如果服務器管理員沒有在engine_cost表中添加新值,則將使用這些默認成本常數值。
*/

// 從主內存緩衝池讀取塊的成本
const double SE_cost_constants::MEMORY_BLOCK_READ_COST= 1.0;

// 從IO設備(磁盤)讀取塊的成本
const double SE_cost_constants::IO_BLOCK_READ_COST= 1.0;



opt_costmodel.cc【代價模型——部分涉及方法】

double Cost_model_table::page_read_cost(double pages) const
{
  DBUG_ASSERT(m_initialized);
  DBUG_ASSERT(pages >= 0.0);

  // 估算聚集索引內存中頁面數佔其所有頁面數的比率
  const double in_mem= m_table->file->table_in_memory_estimate();

  const double pages_in_mem= pages * in_mem;
  const double pages_on_disk= pages - pages_in_mem;
  DBUG_ASSERT(pages_on_disk >= 0.0);

  const double cost= buffer_block_read_cost(pages_in_mem) +
    io_block_read_cost(pages_on_disk);

  return cost;
}

double Cost_model_table::page_read_cost_index(uint index, double pages) const
{
  DBUG_ASSERT(m_initialized);
  DBUG_ASSERT(pages >= 0.0);

  double in_mem= m_table->file->index_in_memory_estimate(index);

  const double pages_in_mem= pages * in_mem;
  const double pages_on_disk= pages - pages_in_mem;

  const double cost= buffer_block_read_cost(pages_in_mem) +
    io_block_read_cost(pages_on_disk);

  return cost;
}



handler.cc【代價模型——部分涉及方法】

// 聚集索引掃描IO代價計算公式
Cost_estimate handler::read_cost(uint index, double ranges, double rows)
{

  DBUG_ASSERT(ranges >= 0.0);
  DBUG_ASSERT(rows >= 0.0);

  const double io_cost= read_time(index, static_cast<uint>(ranges),
                                  static_cast<ha_rows>(rows)) *
                        table->cost_model()->page_read_cost(1.0);
  Cost_estimate cost;
  cost.add_io(io_cost);
  return cost;
}

// 表全量掃描代價相關計算(IO-cost)
Cost_estimate handler::table_scan_cost()
{
  const double io_cost= scan_time() * table->cost_model()->page_read_cost(1.0);
  Cost_estimate cost;
  cost.add_io(io_cost);
  return cost;
}

// 覆蓋索引掃描代價相關計算
Cost_estimate handler::index_scan_cost(uint index, double ranges, double rows)
{
  DBUG_ASSERT(ranges >= 0.0);
  DBUG_ASSERT(rows >= 0.0);

  const double io_cost= index_only_read_time(index, rows) *
    table->cost_model()->page_read_cost_index(index, 1.0);
  Cost_estimate cost;
  cost.add_io(io_cost);
  return cost;
}


/**
  估算在指定 keynr索引進行覆蓋掃描(不需要回表),掃描 records條記錄,需要讀取的索引頁面數

  @param keynr    Index number
  @param records  Estimated number of records to be retrieved
  @return
    Estimated cost of 'index only' scan
*/

double handler::index_only_read_time(uint keynr, double records)
{
  double read_time;
  uint keys_per_block= (stats.block_size/2/
                        (table_share->key_info[keynr].key_length + ref_length) +
                        1);
  read_time=((double) (records + keys_per_block-1) /
             (double) keys_per_block);
  return read_time;
}



sql_planner.cc【用於ref訪問類型索引費用計算】

        
        double tmp_fanout= 0.0;
        if (table->quick_keys.is_set(key) && !table_deps &&          //(C1)
            table->quick_key_parts[key] == cur_used_keyparts &&      //(C2)
            table->quick_n_ranges[key] == 1+MY_TEST(ref_or_null_part))  //(C3)
        {
          tmp_fanout= cur_fanout= (double) table->quick_rows[key];
        }
        else
        {
          // Check if we have statistic about the distribution
          if (keyinfo->has_records_per_key(cur_used_keyparts - 1))
          {
            cur_fanout= keyinfo->records_per_key(cur_used_keyparts - 1);
            
            if (!table_deps && table->quick_keys.is_set(key) &&     // (1)
                table->quick_key_parts[key] > cur_used_keyparts)    // (2)
                {
                  trace_access_idx.add("chosen", false)
                      .add_alnum("cause", "range_uses_more_keyparts");
                  is_dodgy= true;
                  continue;
                }

            tmp_fanout= cur_fanout;
          }
          else
          {
            
            rec_per_key_t rec_per_key;
            if (keyinfo->has_records_per_key(
                  keyinfo->user_defined_key_parts - 1))
              rec_per_key=
                keyinfo->records_per_key(keyinfo->user_defined_key_parts - 1);
            else
              rec_per_key=
                rec_per_key_t(tab->records()) / distinct_keys_est + 1;

            if (tab->records() == 0)
              tmp_fanout= 0.0;
            else if (rec_per_key / tab->records() >= 0.01)
              tmp_fanout= rec_per_key;
            else
            {
              const double a= tab->records() * 0.01;
              if (keyinfo->user_defined_key_parts > 1)
                tmp_fanout=
                  (cur_used_keyparts * (rec_per_key - a) +
                   a * keyinfo->user_defined_key_parts - rec_per_key) /
                  (keyinfo->user_defined_key_parts - 1);
              else
                tmp_fanout= a;
              set_if_bigger(tmp_fanout, 1.0);
            }
            cur_fanout= (ulong) tmp_fanout;
          }

          if (ref_or_null_part)
          {
            // We need to do two key searches to find key
            tmp_fanout*= 2.0;
            cur_fanout*= 2.0;
          }
         
          if (table->quick_keys.is_set(key) &&
              table->quick_key_parts[key] <= cur_used_keyparts &&
              const_part &
              ((key_part_map)1 << table->quick_key_parts[key]) &&
              table->quick_n_ranges[key] == 1 + MY_TEST(ref_or_null_part &
                                                     const_part) &&
              cur_fanout > (double) table->quick_rows[key])
          {
            tmp_fanout= cur_fanout= (double) table->quick_rows[key];
          }
        }


······

······ 

          // Limit the number of matched rows
          const double tmp_fanout=
            min(cur_fanout, (double) thd->variables.max_seeks_for_key);
          if (table->covering_keys.is_set(key)
              || (table->file->index_flags(key, 0, 0) & HA_CLUSTERED_INDEX))
          {
            // We can use only index tree
            const Cost_estimate index_read_cost=
              table->file->index_scan_cost(key, 1, tmp_fanout);
            cur_read_cost= prefix_rowcount * index_read_cost.total_cost();
          }
          else if (key == table->s->primary_key &&
                   table->file->primary_key_is_clustered())
          {
            const Cost_estimate table_read_cost=
              table->file->read_cost(key, 1, tmp_fanout);
            cur_read_cost= prefix_rowcount * table_read_cost.total_cost();
          }
          else
            cur_read_cost= prefix_rowcount *
              min(table->cost_model()->page_read_cost(tmp_fanout),
                  tab->worst_seeks);



handler.cc【用於range訪問類型索引費用計算】

handler::multi_range_read_info_const(uint keyno, RANGE_SEQ_IF *seq,
                                     void *seq_init_param, uint n_ranges_arg,
                                     uint *bufsz, uint *flags, 
                                     Cost_estimate *cost)
{
  KEY_MULTI_RANGE range;
  range_seq_t seq_it;
  ha_rows rows, total_rows= 0;
  uint n_ranges=0;
  THD *thd= current_thd;
  
  /* Default MRR implementation doesn't need buffer */
  *bufsz= 0;

  DBUG_EXECUTE_IF("bug13822652_2", thd->killed= THD::KILL_QUERY;);

  seq_it= seq->init(seq_init_param, n_ranges, *flags);
  while (!seq->next(seq_it, &range))
  {
    if (unlikely(thd->killed != 0))
      return HA_POS_ERROR;
    
    n_ranges++;
    key_range *min_endp, *max_endp;
    if (range.range_flag & GEOM_FLAG)
    {
      min_endp= &range.start_key;
      max_endp= NULL;
    }
    else
    {
      min_endp= range.start_key.length? &range.start_key : NULL;
      max_endp= range.end_key.length? &range.end_key : NULL;
    }
    
    
    int keyparts_used= 0;
    if ((range.range_flag & UNIQUE_RANGE) &&                        // 1)
        !(range.range_flag & NULL_RANGE))
      rows= 1; /* there can be at most one row */
    else if ((range.range_flag & EQ_RANGE) &&                       // 2a)
             (range.range_flag & USE_INDEX_STATISTICS) &&           // 2b)
             (keyparts_used= my_count_bits(range.start_key.keypart_map)) &&
             table->
               key_info[keyno].has_records_per_key(keyparts_used-1) && // 2c)
             !(range.range_flag & NULL_RANGE))
    {
      rows= static_cast<ha_rows>(
        table->key_info[keyno].records_per_key(keyparts_used - 1));
    }
    else
    {
      DBUG_EXECUTE_IF("crash_records_in_range", DBUG_SUICIDE(););
      DBUG_ASSERT(min_endp || max_endp);
      if (HA_POS_ERROR == (rows= this->records_in_range(keyno, min_endp, 
                                                        max_endp)))
      {
        /* Can't scan one range => can't do MRR scan at all */
        total_rows= HA_POS_ERROR;
        break;
      }
    }
    total_rows += rows;
  }
  
  if (total_rows != HA_POS_ERROR)
  {
    const Cost_model_table *const cost_model= table->cost_model();

    /* The following calculation is the same as in multi_range_read_info(): */
    *flags|= HA_MRR_USE_DEFAULT_IMPL;
    *flags|= HA_MRR_SUPPORT_SORTED;

    DBUG_ASSERT(cost->is_zero());
    if (*flags & HA_MRR_INDEX_ONLY)
      *cost= index_scan_cost(keyno, static_cast<double>(n_ranges),
                             static_cast<double>(total_rows));
    else
      *cost= read_cost(keyno, static_cast<double>(n_ranges),
                       static_cast<double>(total_rows));
    cost->add_cpu(cost_model->row_evaluate_cost(
      static_cast<double>(total_rows)) + 0.01);
  }
  return total_rows;
}

驗證公式

創建驗證需要的表

CREATE TABLE `store_goods_center`
(
    `id`           bigint(20)  NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
    `sku_id`       bigint(20)  NOT NULL COMMENT '商品skuid',
    `station_no`   varchar(20) NOT NULL COMMENT '門店編號',
    `org_code`     bigint(20)  NOT NULL COMMENT '商家編號',
    `extend_field` text COMMENT '擴展字段',
    `version`      int(11)          DEFAULT '0' COMMENT '版本號',
    `create_time`  datetime         DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
    `create_pin`   varchar(50)      DEFAULT '' COMMENT '創建人',
    `update_time`  datetime         DEFAULT CURRENT_TIMESTAMP COMMENT '更新時間',
    `update_pin`   varchar(50)      DEFAULT '' COMMENT '更新人',
    `yn`           tinyint(4)       DEFAULT '0' COMMENT '刪除標示  0:正常  1:刪除',
    `ts`           timestamp   NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '時間戳',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uniq_storegoods` (`station_no`, `sku_id`) USING BTREE,
    KEY `idx_storegoods_org` (`org_code`, `sku_id`, `station_no`),
    KEY `idx_sku_id` (`sku_id`),
    KEY `idx_station_no_and_id` (`station_no`, `id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4 COMMENT ='門店商品關係表';

通過存儲過程初始化測試數據

DELIMITER //
CREATE PROCEDURE callback()
BEGIN
    DECLARE num INT;
    SET num = 1;
    WHILE
        num <= 100000 DO
        INSERT INTO store_goods_center(sku_id, station_no, org_code) VALUES (num + 10000000, floor(50+rand()*(100-50+1)), num);
        SET num = num + 1;
    END WHILE;
END;

執行存儲過程生成數據

CALL callback();



1.全表掃描計算代價公式



計算過程:

// 不同引擎計算方式有所區別
// innodb引擎實現handler.h
// 預估記錄數:ha_innobase::info_low
// 頁數量:ha_innobase::scan_time【數據總大小(字節) / 頁大小】

// 查詢全表數據大小(7880704) 
SHOW TABLE STATUS LIKE 'store_goods_center'; 
// 查詢數據庫頁大小(默認:16384) 
SHOW VARIABLES LIKE 'innodb_page_size';

// 全表掃描計算代價
// 頁數量
page = 數據總大小(字節) / 頁大小 = 7880704 / 16384 = 481;
// 預估範圍行數(總數據條數:10萬,預估數據條數:99827,有一定誤差)
records = 99827;


// 計算總代價
// 481 * 1 中的係數1 代表從主內存緩衝池讀取塊的成本(SE_cost_constants::IO_BLOCK_READ_COST= 1.0)
// 99827 * 0.2 中的係數0.2 代表計算符合條件的⾏的代價(ROW_EVALUATE_COST= 0.2)
cost = IO-cost + CPU-cost = (481 * 1) + (99827 * 0.2) = 481 + 19965.4 = 20446.4



驗證結果:

explain format = json
select * from store_goods_center;

"cost_info": {"query_cost": "20446.40"}



總結公式:

全表掃描代價 = 數據總大小 / 16384 + 預估範圍行數 * 0.2



2.覆蓋索引掃描計算代價公式



計算過程:

// 查詢全表數據大小(7880704) 
SHOW TABLE STATUS LIKE 'store_goods_center'; 
// 查詢數據庫頁大小(默認:16384) 
SHOW VARIABLES LIKE 'innodb_page_size';

// 預估範圍行數(總數據條數:1999,預估數據條數:1999,有一定誤差) 1999;
records = 1999

// keys_per_block計算
// block_size是文件的block大小,mysql默認爲16K;
// key_len是索引的鍵長度;
// ref_len是主鍵索引的長度;
keys_per_block = (stats.block_size / 2 / (table_share->key_info[keynr].key_length + ref_length) + 1);
// table_share->key_info[keynr].key_length 爲聯合索引,分別是station_no和sku_id
// station_no 爲varchar(20)且爲utf8mb4,長度 = 20 * 4 + 2 (可變長度需要加2) = 82
// sku_id bigint類型,長度爲8
// 主鍵索引爲bigint類型,長度爲8
keys_per_block = 16384 / 2 / (82 + 8 + 8) + 1 ≈ 84

// 計算總代價
read_time = ((double) (records + keys_per_block - 1) / (double) keys_per_block);
read_time = (1999 + 84 - 1) / 84 = 24.78;

// 計算總代價
// 24.78 * 1 中的係數1 代表從主內存緩衝池讀取塊的成本(SE_cost_constants::IO_BLOCK_READ_COST= 1.0)
// 1999 * 0.2 中的係數0.2 代表計算符合條件的⾏的代價(ROW_EVALUATE_COST= 0.2)
cost = IO-cost + CPU-cost = (24.78 * 1) + (1999 * 0.2) = 24.78 + 399.8 = 424.58

驗證結果:

explain format = json
select station_no from store_goods_center where station_no = '53';

"cost_info": {"query_cost": "424.58"}

總結公式:

keys_per_block = 8192 / 索引長度 + 1
覆蓋索引掃描代價 = (records + keys_per_block - 1) / keys_per_block + 預估範圍行數 * 0.2

公式簡化(去除影響較小的複雜計算)
覆蓋索引掃描代價 = (records * 涉及索引長度) / 8192 + 預估範圍行數 * 0.2

3.ref索引掃描計算代價公式



計算過程:

// cardinality = 49(基數,即有多少個不同key統計。)
SHOW TABLE STATUS LIKE 'store_goods_center'; 

// 頁數量 
page = 數據總大小(字節) / 頁大小 = 7880704 / 16384 = 481; 

// 計算代價最低索引(sql_planner.cc 中find_best_ref函數)
// IO COST最壞不會超過全表掃描IO消耗的3倍(或者總記錄數除以10) 
// 其中s->found_records表示表上的記錄數,s->read_time在innodb層表示page數
// s-> worst_seeks = min((double) s -> found_records / 10, (double) s -> read_time * 3);
// cur_read_cost= prefix_rowcount * min(table->cost_model() -> page_read_cost(tmp_fanout), tab -> worst_seeks);

// 預估範圍行數(總數據條數:10萬,預估數據條數:99827,有一定誤差)  
total_records = 99827; 
// 預估範圍行數(總數據條數:1999,預估數據條數:1999,有一定誤差) 1999;
records = 1999

// 計算總代價 
// 1999 * 0.2 中的係數0.2 代表計算符合條件的⾏的代價(ROW_EVALUATE_COST= 0.2)
// s-> worst_seeks = min((double) s -> found_records / 10, (double) s -> read_time * 3) -> min(99827 / 10, 481 * 3) = 481 * 3
// min(table->cost_model() -> page_read_cost(tmp_fanout), tab -> worst_seeks) -> min(page_read_cost(1999), 481 * 3) = 481 * 3
cost = IO-cost + CPU-cost = 481 * 3 + (1999 * 0.2) = 1443 + 399.8 = 1842.80

驗證結果:

explain format = json
select * from store_goods_center where station_no = '53';

"cost_info": {"query_cost": "1842.80"}

總結公式:

下面3個公式,取值最低的
1.(數據總大小 / 16384) * 3 + 預估範圍行數 * 0.2
2.總記錄數 / 10 + 預估範圍行數 * 0.2
3.掃描出記錄數 + 預估範圍行數 * 0.2



4.range索引掃描計算代價公式




// 預估範圍行數(總數據條數:1299,預估數據條數:1299,有一定誤差) 1299;
records = 1299

// 計算代價最低索引(handler.cc 中 multi_range_read_info_const 函數)
// 計算總代價 
// 1299 * 0.2 計算公式:cost_model->row_evaluate_cost(static_cast<double>(total_rows))
// + 0.01 計算公式:cost->add_cpu(cost_model->row_evaluate_cost(static_cast<double>(total_rows)) + 0.01);
// 1299 + 1 中的 +1 :單個掃描區間( id > 35018 )
// 1299 + 1 計算公式:*cost= read_cost(keyno, static_cast<double>(n_ranges), static_cast<double>(total_rows));
// (1299 * 0.2 + 0.01 + 1299) * 1 中的係數1 代表從主內存緩衝池讀取塊的成本(SE_cost_constants::IO_BLOCK_READ_COST= 1.0) 
// 1299 * 0.2 中的係數0.2 代表計算符合條件的⾏的代價(ROW_EVALUATE_COST= 0.2) 
cost = IO-cost + CPU-cost = ((1299 * 0.2 + 0.01 + 1299 + 1) * 1) + (1299 * 0.2) = 1559.81 + 259.8 = 1819.61



驗證結果:

explain format = json
select * from store_goods_center where station_no = '53' and id > 35018;

"cost_info": {"query_cost": "1819.61"}

總結公式:

range掃描代價 = 預估範圍行數 * 1.4 + 0.01 + 範圍數

公式簡化(去除影響較小的複雜計算) 
range掃描代價 = 預估範圍行數 * 1.4

索引衝突案例

門店商品系統中主要存儲門店與商品的關聯信息,併爲B端提供根據門店ID查詢關聯商品的功能。由於門店關聯的商品數據量較大,需要分頁查詢關聯商品數據。爲避免深分頁問題,我們選擇基於上次最新主鍵進行查詢(核心思想:通過主鍵索引,每次定位到ID所在位置,然後往後遍歷N個數據。這樣,無論數據量多少,查詢性能都能保持穩定。我們將所有數據根據主鍵ID進行排序,然後分批次取出,將當前批次的最大ID作爲下次查詢的篩選條件)。

select 字段1,字段2 ... from store_goods_center where station_no = ‘門店id’ and id > 上次查詢最大id order by id asc

爲了確保門店與商品組合的唯一性,我們在MySQL表中爲門店ID和商品ID添加了組合唯一索引【UNIQUE KEY uniq_storegoods (station_no, sku_id) USING BTREE】。由於該索引包含門店ID並且在聯合索引的第一個位置,查詢會使用該索引。但是,當分頁查詢命中該索引後,由於排序字段無法使用索引,產生了【Using filesort】,導致門店商品系統出現了一些慢查詢。爲了解決這個問題,我們對慢查詢進行了優化,優化思路是創建一個新的索引,使該SQL可以使用索引的排序來規避【Using filesort】的負面影響,新添加的索引爲【KEY idx_station_no_and_id (station_no, id)】。添加該索引後,效果立竿見影。

然而,我們發現仍然有慢查詢產生,並且這些慢查詢仍然使用uniq_storegoods索引,而不是idx_station_no_and_id索引。我們開始思考,爲什麼MySQL沒有爲我們的系統推薦使用最優的索引?是MySQL索引推薦有問題,還是我們創建索引有問題?如何做才能讓MySQL幫我們推薦我們認爲最優的索引?

當然,我們也可以使用FORCE INDEX強行讓MySQL走我們提前預設的索引,但是這種方式侷限太大,後期索引維護成本變得很高,甚至可能使用該SQL的其他業務性能變低。爲了突破整體優化的卡點狀態,我們需要了解一下MySQL索引推薦底層邏輯,即MySQL代價模型。瞭解相應規則後,現階段的問題將迎刃而解。







案例分析及優化

在回顧剛纔的問題時,我們發現問題源於原始索引產生了【Using filesort】,從而導致了慢查詢的出現。爲了解決這個問題,我們新增了一個索引,即【KEY idx_station_no_and_id (station_no, id)】,以替代原有的索引【UNIQUE KEY uniq_storegoods (station_no, sku_id)】。然而,儘管新增索引後大部分慢查詢得到了解決,但仍有部分慢查詢未能消除。進一步分析發現,這些慢查詢是由於SQL沒有使用我們期望的索引,而是使用了老索引,從而引發了【Using filesort】問題。在通過explain進行分析後,我們暫時還沒有找到合適的解決方案。



問題:儘管我們新增了索引,並且大部分SQL已經能夠使用新索引進行優化,但仍存在一些SQL沒有使用新索引。

// 通過代價模型進行分析

// 使用上面的測試數據進行分析
// 新增索引後都沒有走新索引
// 老索引,掃描行數:1999,代價計算值:1842.80,ref類型索引
// 新索引,掃描行數:1999,代價計算值:1850.46,range類型索引
select 字段1,字段2 ... from store_goods_center where station_no = ‘門店id’ and id > -1 order by id asc;

// 新增索引後走新索引
// 老索引,掃描行數:1999,代價計算值:1842.80,ref類型索引 
// 新索引,掃描行數:1299,代價計算值:1819.61,range類型索引
select 字段1,字段2 ... from store_goods_center where station_no = ‘門店id’ and id > 35018 order by id asc;

經過分析MySQL的代價模型,我們發現MySQL在選擇使用哪個索引時,主要取決於掃描出的數據條數。具體來說,掃描出的數據條數越少,MySQL就越傾向於選擇該索引(由於MySQL的索引數據訪問類型各異,計算公式也會有所不同。因此,在多個索引的掃描行數相近的情況下,所選索引可能與我們期望的索引有所不同)。順着這個思路排查,我們發現當id > -1時,無論是使用storeId + skuId還是storeId + id索引進行查詢,掃描出的數據條數是相同的。這是因爲這兩種查詢方式都是根據門店查詢商品數據,且id值肯定大於1。因此,對於MySQL來說,由於這兩種索引掃描出的數據條數相同,所以使用哪種索引效果相差不多。這就是爲什麼一部分查詢走新索引,而另一部分查詢走老索引的原因。然而,當查詢條件爲id > n時,storeId + id索引的優勢便得以顯現。因爲它能夠直接從索引中掃描並跳過id <= n的數據,而storeId + skuId索引卻無法直接跳過這部分數據,因此真正掃描的數據條數storeId + skuId要大於storeId + id。因此,在查詢條件爲id > n時,MySQL更傾向於使用新索引。(需要注意的是,示例給出的數據索引數據訪問類型不同,一個是range索引類型,一個是ref索引類型。由於算法不同,即使某個索引的檢索數據率略高於另一個索引,也可能導致系統將其推薦爲最優索引

問題已經分析清楚,主要原因是存在多個索引,且根據索引代價計算公式的代價相近,導致難以抉擇。因此,解決這個問題的方法不應該是同時定義兩個會讓MySQL"糾結"的索引選擇。相反,應該將兩個索引融合爲一個索引。具體的解決方案是根據門店查詢,將原來的主鍵id作爲上次查詢的最大id替換爲skuId。在算法切換完成後,刪除新的門店+主鍵id索引。然而,這種方式可能會引發另一個問題。由於底層排序算法發生了變化(由原來的主鍵id改爲skuId),可能導致無法直接從底層服務切換。此時,應考慮從下游使用此接口服務的應用進行切換。需要注意的是,如果下游系統是單機分頁迭代查詢門店數據,那麼下游系統可以直接進行切換。但如果這種分頁查詢動作同時交給多臺應用服務器執行,切換過程將變得相當複雜,他們的切換成本與底層切換成本相同。但是,這個系統的對外服務屬於這種情況,下游調用系統會有多臺應用服務器協作分頁迭代查詢數據,爲這次優化帶來很大影響。

最終,讓底層獨立完成切換方式最爲合適。在切換過程中,關鍵在於正確區分新老算法。老算法在迭代過程中不應切換至新算法。原系統對外服務提供的下次迭代用的id可用來進行區分。新算法在返回下次迭代用的id基礎上增加一個常量值,例如10億(加完後不能與原數據衝突,也可以將迭代id由整數轉換成負數以區分新老算法)。因此,如果是第一次訪問,直接使用新算法;如果不是第一次訪問,需要根據下次迭代用的id具體規則來判斷是否切換新老算法。



總結與後續規劃

使用Explan執行計劃存在無法提前預知索引選擇的侷限性。然而,只要熟悉MySQL底層代價模型的計算公式,我們就能預知索引的走向。藉助代價模型,我們不僅可以分析索引衝突的原因,還可以在發生衝突之前進行預警。甚至在添加索引之前,我們也可以根據代價模型公式來排查潛在問題。此外,根據數據業務密度,我們還可以預估當前索引的合理性,以及是否可能出現全表掃描等情況。因此,深入研究MySQL代價模型對於優化索引管理具有關鍵意義。

未來我們的系統應用將結合MySQL代價模型進行集成,實現自動分析數據庫和表的信息,以發現當前索引存在的問題,例如索引衝突或未使用索引導致的全表掃描。此外,該工具還可以針對尚未添加索引的表,根據數據情況提供合適的索引推薦。同時,該工具還能夠預測當數據達到某種密度時,可能出現全表掃描的問題,從而幫助提前做好優化準備。

爲了實現這些功能,我們將首先對MySQL代價模型進行深入研究,全面瞭解其計算公式和原理。這將有助於我們編寫相應的算法,自動分析數據庫和表的信息,找出潛在的索引問題。此外,我們還關注易用性和實用性,確保用戶能夠輕鬆地輸入相關數據庫和表的信息,並獲取有關優化建議。

該工具的開發將有助於提高數據庫性能,減少全表掃描的發生,降低系統資源消耗。同時,它還可以爲數據庫管理員和開發人員提供便利,使他們能夠更加專注於其他核心業務。通過結合MySQL代價模型,我們相信這個工具將在優化索引管理方面發揮重要作用,爲企業帶來更高的效益。



參考資料

https://github.com/mysql/mysql-server

作者:京東零售 王多友

來源:京東雲開發者社區

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章