天天看點

第16節 基于WRITESET的并行複制方式

注意:本文分為正文和附件兩部分,都是圖檔格式,如果正文有圖檔不清晰可以将附件的圖檔儲存到本地檢視。

基于COMMIT_ORDER的并行複制隻有在有壓力的情況下才可能會形成一組,壓力不大的情況下在從庫的并行度并不會高。但是基于WRITESET的并行複制目标就是在ORDER_COMMIT的基礎上再盡可能的降低last commit,這樣在從庫獲得更好的并行度(即便在主庫串行執行的事務在從庫也能并行應用)。它使用的方式就是通過掃描Writeset中的每一個元素(行資料的hash值)在一個叫做Writeset的曆史MAP(行資料的hash值和seq number的一個MAP)中進行比對,尋找是否有沖突的行,然後做相應的處理,後面我們會較長的描述這種行為。如果要使用這種方式我們需要在主庫設定如下兩個參數:

  • transaction_write_set_extraction=XXHASH64
  • binlog_transaction_dependency_tracking=WRITESET

它們是在5.7.22才引入的。

更多主從同步相關可以參考我的《深入了解MySQL主從原理 32節》專欄:

一、奇怪的last commit

我們先來看一個截圖,仔細觀察其中的last commit:

我們可以看到其中的last commit看起來是亂序的,這種情況在基于COMMIT_ORDER 的并行複制方式下是不可能出現的。實際上它就是我們前面說的基于WRITESET的并行複制再盡可能降低的last commit的結果。這種情況會在MTS從庫獲得更好的并行回放效果,第19節将會詳細解釋并行判定的标準。

二、Writeset是什麼

實際上Writeset是一個集合,使用的是C++ STL中的set容器,在類Rpl_transaction_write_set_ctx中包含了如下定義:

std::set<uint64> write_set_unique;           

集合中的每一個元素都是hash值,這個hash值和我們的transaction_write_set_extraction參數指定的算法有關,其來源就是行資料的主鍵和唯一鍵。每行資料包含了兩種格式:

  • 字段值為二進制格式
  • 字段值為字元串格式

每行資料的具體格式為:

在Innodb層修改一行資料之後會将這上面的格式的資料進行hash後寫入到Writeset中。可以參考函數add_pke,後面我也會以僞代碼的方式給出部分流程。

但是需要注意一個事務的所有的行資料的hash值都要寫入到一個Writeset。如果修改的行比較多那麼可能需要更多記憶體來存儲這些hash值。雖然8位元組比較小,但是如果一個事務修改的行很多,那麼還是需要消耗較多的記憶體資源的。

為了更直覺的觀察到這種資料格式,可以使用debug的方式擷取。下面我們來看一下。

三、Writeset的生成

我們使用如下表:

mysql> use test
Database changed
mysql> show create table jj10 \G
*************************** 1. row ***************************
       Table: jj10
Create Table: CREATE TABLE `jj10` (
  `id1` int(11) DEFAULT NULL,
  `id2` int(11) DEFAULT NULL,
  `id3` int(11) NOT NULL,
  PRIMARY KEY (`id3`),
  UNIQUE KEY `id1` (`id1`),
  KEY `id2` (`id2`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)           

我們寫入一行資料:

insert into jj10 values(36,36,36);           

這一行資料一共會生成4個元素分别為:

注意:這裡顯示的?是分隔符

1. 主鍵二進制格式
(gdb) p pke
$1 = "PRIMARY?test?4jj10?4\200\000\000$?4"

**注意:\200\000\000$ :為3個八進制位元組和ASCII字元 $,
其轉換為16進制就是“0X80 00 00 24 ”**           

分解為:

主鍵名稱 分隔符 庫名 庫名長度 表名 表名長度 主鍵字段1 長度
PRIMARY ? test 4 jj10 0x80 00 00 24
2. 主鍵字元串格式:
(gdb) p pke
$2 = "PRIMARY?test?4jj10?436?2"           
36 2
3. 唯一鍵二進制格式
(gdb) p pke
$3 = "id1?test?4jj10?4\200\000\000$?4"           

解析同上

4. 唯一鍵字元串格式:
(gdb) p pke
$4 = "id1?test?4jj10?436?2"           

最終這些資料會通過hash算法後寫入到Writeset中。

四、函數add_pke的大概流程

下面是一段僞代碼,用來描述這種生成過程:

如果表中存在索引:
   将資料庫名,表名資訊寫入臨時變量   
   循環掃描表中每個索引:
        如果不是唯一索引:
             退出本次循環繼續循環。
        循環兩種生成資料的方式(二進制格式和字元串格式):
             将索引名字寫入到pke中。
             将臨時變量資訊寫入到pke中。
             循環掃描索引中的每一個字段:
                将每一個字段的資訊寫入到pke中。
                如果字段掃描完成:
                   将pke生成hash值并且寫入到寫集合中。
    如果沒有找到主鍵或者唯一鍵記錄一個标記,後面通過這個标記來
    判定是否使用Writeset的并行複制方式           

五、Writeset設定對last commit的處理方式

前一節我們讨論了基于ORDER_COMMIT的并行複制是如何生成last_commit和seq number的。實際上基于WRITESET的并行複制方式隻是在ORDER_COMMIT的基礎上對last_commit做更進一步處理,并不影響原有的ORDER_COMMIT邏輯,是以如果要回退到ORDER_COMMIT邏輯非常友善。可以參考MYSQL_BIN_LOG::write_gtid函數。

根據binlog_transaction_dependency_tracking取值的不同會做進一步的處理,如下:

  • ORDER_COMMIT:調用m_commit_order.get_dependency函數。這是前面我們讨論的方式。
  • WRITESET:調用m_commit_order.get_dependency函數,然後調用m_writeset.get_dependency。可以看到m_writeset.get_dependency函數會對原有的last commit做處理。
  • WRITESET_SESSION:調用m_commit_order.get_dependency函數,然後調用m_writeset.get_dependency再調用m_writeset_session.get_dependency。m_writeset_session.get_dependency會對last commit再次做處理。

這段描述的代碼對應:

case DEPENDENCY_TRACKING_COMMIT_ORDER:
      m_commit_order.get_dependency(thd, sequence_number, commit_parent);
      break;
    case DEPENDENCY_TRACKING_WRITESET:
      m_commit_order.get_dependency(thd, sequence_number, commit_parent);
      m_writeset.get_dependency(thd, sequence_number, commit_parent);
      break;
    case DEPENDENCY_TRACKING_WRITESET_SESSION:
      m_commit_order.get_dependency(thd, sequence_number, commit_parent);
      m_writeset.get_dependency(thd, sequence_number, commit_parent);
      m_writeset_session.get_dependency(thd, sequence_number, commit_parent);
      break;           

六、Writeset的曆史MAP

我們到這裡已經讨論了Writeset是什麼,也已經說過如果要降低last commit的值我們需要通過對事務的Writeset和Writeset的曆史MAP進行比對,看是否沖突才能決定降低為什麼值。那麼必須在記憶體中儲存一份這樣的一個曆史MAP才行。在源碼中使用如下方式定義:

/*
    Track the last transaction sequence number that changed each row
    in the database, using row hashes from the writeset as the index.
  */
  typedef std::map<uint64,int64> Writeset_history; //map實作
  Writeset_history m_writeset_history;           

我們可以看到這是C++ STL中的map容器,它包含兩個元素:

  • Writeset的hash值
  • 最新一次本行資料修改事務的seq number

它是按照Writeset的hash值進行排序的。

其次記憶體中還維護一個叫做m_writeset_history_start的值,用于記錄Writeset的曆史MAP中最早事務的seq number。如果Writeset的曆史MAP滿了就會清理這個曆史MAP然後将本事務的seq number寫入m_writeset_history_start,作為最早的seq number。後面會看到對于事務last commit的值的修改總是從這個值開始然後進行比較判斷修改的,如果在Writeset的曆史MAP中沒有找到沖突那麼直接設定last commit為這個m_writeset_history_start值即可。下面是清理Writeset曆史MAP的代碼:

if (exceeds_capacity || !can_use_writesets)
//Writeset的曆史MAP已滿
  {
    m_writeset_history_start= sequence_number; 
//如果超過最大設定,清空writeset history。從目前seq number 重新記錄, 也就是最小的那個事務seq number
    m_writeset_history.clear();
//清空曆史MAP
  }           

七、Writeset的并行複制對last commit的處理流程

這裡介紹一下整個處理的過程,假設如下:

  • 目前通過基于ORDER_COMMIT的并行複制方式後,構造出來的是(last commit=125,seq number=130)。
  • 本事務修改了4條資料,我分别使用ROW1/ROW7/ROW6/ROW10代表。
  • 表隻包含主鍵沒有唯一鍵,并且我的圖中隻保留行資料的二進制格式的hash值,而沒有包含資料的字元串格式的hash值。

初始化情況如下圖(圖16-1,高清原圖包含在文末原圖中):

  1. 第一步 設定last commit為writeset_history_start的值也就是100。
  2. 第二步 ROW1.HASHVAL在Writeset曆史MAP中查找,找到沖突的行ROW1.HASHVAL将曆史MAP中這行資料的seq number更改為130。同時設定last commit為120。
  3. 第三步 ROW7.HASHVAL在Writeset曆史MAP中查找,找到沖突的行ROW7.HASHVAL将Writeset曆史MAP中這行資料的seq number更改為130。由于曆史MAP中對應的seq number為114,小于120不做更改。last commit依舊為120。
  4. 第四步 ROW6.HASHVAL在Writeset曆史MAP中查找,找到沖突的行ROW6.HASHVAL将Writeset曆史MAP中這行資料的seq number更改為130。由于曆史MAP中對應的seq number為105,小于120不做更改。last commit依舊為120。
  5. 第五步 ROW10.HASHVAL在Writeset曆史MAP中查找,沒有找到沖突的行,是以需要将這一行插入到Writeset曆史MAP中查找(需要判斷是否導緻曆史MAP占滿,如果占滿則不需要插入,後面随即要清理掉)。即要将ROW10.HASHVAL和seq number=130插入到Writeset曆史MAP中。

整個過程結束。last commit由以前的125降低為120,目的達到了。實際上我們可以看出Writeset曆史MAP就相當于儲存了一段時間以來修改行的快照,如果保證本次事務修改的資料在這段時間内沒有沖突,那麼顯然是可以在從庫并行執行的。last commit降低後如下圖(圖16-2,高清原圖包含在文末原圖中):

整個邏輯就在函數Writeset_trx_dependency_tracker::get_dependency中,下面是一些關鍵代碼,代碼稍多:

if (can_use_writesets) //如果能夠使用writeset 方式
  {
    /*
     Check if adding this transaction exceeds the capacity of the writeset
     history. If that happens, m_writeset_history will be cleared only after  而 add_pke
     using its information for current transaction.
    */
    exceeds_capacity=
      m_writeset_history.size() + writeset->size() > m_opt_max_history_size; 
//如果大于參數binlog_transaction_dependency_history_size設定清理标記
    /*
     Compute the greatest sequence_number among all conflicts and add the
     transaction's row hashes to the history.
    */
    int64 last_parent= m_writeset_history_start;
//臨時變量,首先設定為最小的一個seq number
    for (std::set<uint64>::iterator it= writeset->begin(); it != writeset->end(); ++it)
//循環每一個Writeset中的每一個元素 
    {
      Writeset_history::iterator hst= m_writeset_history.find(*it);
//是否在writeset history中 已經存在了。 map中的元素是 key是writeset 值是sequence number
      if (hst != m_writeset_history.end()) //如果存在
      {    
        if (hst->second > last_parent && hst->second < sequence_number) 
          last_parent= hst->second;
//如果已經大于了不需要設定
        hst->second= sequence_number; 
//更改這行記錄的sequence_number
      }
      else
      {
        if (!exceeds_capacity)
          m_writeset_history.insert(std::pair<uint64, int64>(*it, sequence_number));
//沒有沖突則插入。
      }
    }

......
    if (!write_set_ctx->get_has_missing_keys())
//如果沒有主鍵和唯一鍵那麼不更改last commit
    {
      /*
       The WRITESET commit_parent then becomes the minimum of largest parent
       found using the hashes of the row touched by the transaction and the
       commit parent calculated with COMMIT_ORDER.
      */;
      commit_parent= std::min(last_parent, commit_parent);
//這裡對last commit做更改了。降低他的last commit
    }
  }
    }
  }

  if (exceeds_capacity || !can_use_writesets)
  {
    m_writeset_history_start= sequence_number; 
//如果超過最大設定 清空writeset history。從目前sequence 重新記錄 也就是最小的那個事務seqnuce number
    m_writeset_history.clear();//清空真個MAP
  }
           

八、WRITESET_SESSION的方式

前面說過這種方式就是在WRITESET的基礎上繼續處理,實際上它的含義就是同一個session的事務不允許在從庫并行回放。代碼很簡單,如下:

int64 session_parent= thd->rpl_thd_ctx.dependency_tracker_ctx().
                        get_last_session_sequence_number();
//取本session的上一次事務的seq number
  if (session_parent != 0 && session_parent < sequence_number) 
//如果本session已經做過事務并且本次目前的seq number大于上一次的seq number
    commit_parent= std::max(commit_parent, session_parent);
//說明這個session做過多次事務不允許并發,修改為order_commit生成的last commit
  thd->rpl_thd_ctx.dependency_tracker_ctx().
    set_last_session_sequence_number(sequence_number);
//設定session_parent的值為本次seq number的值           

經過這個操作後,我們發現這種情況最後last commit恢複成了ORDER_COMMIT的方式。

九、關于binlog_transaction_dependency_history_size參數說明

本參數預設值為25000。代表的是我們說的Writeset曆史MAP中元素的個數。如前面分析的Writeset生成過程中修改一行資料可能會生成多個HASH值,是以這個值還不能完全等待于修改的行數,可以了解為如下:

  • binlog_transaction_dependency_history_size/2=修改的行數 * (1+唯一鍵個數)

我們通過前面的分析可以發現如果這個值越大那麼在Writeset曆史MAP中能容下的元素也就越多,生成的last commit就可能更加精确(更加小),從庫并發的效率也就可能越高。但是我們需要注意設定越大相應的記憶體需求也就越高了。

十、沒有主鍵的情況

實際上在函數add_pke中就會判斷是否有主鍵或者唯一鍵,如果存在唯一鍵也是可以。Writeset中存儲了唯一鍵的行資料hash值。參考函數add_pke,下面是判斷:

if (!((table->key_info[key_number].flags & (HA_NOSAME )) == HA_NOSAME)) 
//跳過非唯一的KEY
        continue;           

如果沒有主鍵或者唯一鍵那麼下面語句将被觸發:

if (writeset_hashes_added == 0)
    ws_ctx->set_has_missing_keys();           

然後我們在生成last commit會判斷這個設定如下:

if (!write_set_ctx->get_has_missing_keys())
//如果沒有主鍵和唯一鍵那麼不更改last commit
    {
      /*
       The WRITESET commit_parent then becomes the minimum of largest parent
       found using the hashes of the row touched by the transaction and the
       commit parent calculated with COMMIT_ORDER.
      */;
      commit_parent= std::min(last_parent, commit_parent);//這裡對last commit做更改了。降低他的last commit
    }
  }           

是以沒有主鍵可以使用唯一鍵,如果都沒有的話WRITESET設定就不會生效回退到老的ORDER_COMMIT方式。

十一、為什麼同一個session執行的事務也能生成同樣的last commit

有了前面的基礎,我們就很容易解釋這種現象了。其主要原因就是Writeset的曆史MAP的存在,隻要這些事務修改的行沒有沖突,也就是主鍵/唯一鍵不相同,那麼在基于WRITESET的并行複制方式中就可以存在這種現象,但是如果binlog_transaction_dependency_tracking設定為WRITESET_SESSION則不會出現這種現象。

寫在最後

好了到這裡我們明白了基于WRITESET的并行複制方式的優點,但是它也有明顯的缺點如下:

  • Writeset中每個hash值都需要和Writeset的曆史MAP進行比較。
  • Writeset需要額外的記憶體空間。
  • Writeset的曆史MAP需要額外的記憶體空間。

如果從庫沒有延遲,則不需要考慮這種方式,即便有延遲我們也應該先考慮其他方案。第28節我們将會描述有哪些導緻延遲的可能。

第16節結束