Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

MySQLのINSERTを高速化するChange bufferingをソースコードから理解する

mysql_logo

背景

アカツキで提供しているサービスでは、ほぼ全てにおいてAWSのRDS(MySQL5.6, InnoDB)を使用しております。 ソーシャルゲームでは多くのWriteがかかりますが、そのコストが気になったので調べてみました。

調べてみたこと

一言にINSERTといっても、COMMIT時に即テーブルスペースに反映されるような単純な処理ではありません。 例えばINSERTしたテーブルにセカンダリインデックスがある場合、そこに加えられた変更を反映しなければなりません。 ところが、INSERT時バッファプール内に該当するインデックスのページが無い時はこれをディスクから読みだす必要があり、非常にコストが掛かります。 InnoDBにはこれを抑えるための"Change buffering"という機能があり、今回はその解説をしたいと思います。

Change bufferingとは

変更を加えたページがバッファプール内に無い場合、InnoDBはページを読み込むのではなくChange bufferと呼ばれる領域にその変更を記録します。 Change bufferは共有テーブルスペースに作成されます。なので、再起動等を行ったとしてもその変更が失われることはありません。

Configuring InnoDB Change Bufferingによると、Change bufferに書き込んだ変更は以下のタイミングでテーブルスペースに反映されると言われています。

  1. 該当するインデックスのページがバッファプールに読まれた時
  2. バックグラウンド実行によるマージ

これらに関して、詳細な動作を追ってみたいと思います。 Change bufferingはバックグラウンドで行われるため観測不可能ですので、その実装を追ってみました。

1. 該当するインデックスのページがバッファプールに読まれた時。

インデックスのページが読まれた時にマージ処理が行われるということなので、まずはInnoDBのページを読み出す処理を探してみましょう。

2486 /********************************************************************//**
2487 This is the general function used to get access to a database page.
2488 @return pointer to the block or NULL */
2489 UNIV_INTERN
2490 buf_block_t*
2491 buf_page_get_gen(
...
2838     if (!recv_no_ibuf_operations) {
2839       if (access_time) {
2840 #ifdef UNIV_IBUF_COUNT_DEBUG
2841         ut_a(ibuf_count_get(space, offset) == 0);
2842 #endif /* UNIV_IBUF_COUNT_DEBUG */
2843       } else {
2844         ibuf_merge_or_delete_for_page(
2845           block, space, offset, zip_size, TRUE);
2846       }
2847     }

大きな関数ですので、一部割愛しています。 コメントにある通り、buf_page_get_gen関数はインデックスのページにアクセスするための一般的な関数です。 buf_page_get_genでは関数中でibuf_merge_or_delete_for_pageを呼び出しています。(ibufはChange bufferの前身であるinsert bufferの略) ibuf_merge_or_delete_for_pageの定義は

4540 /*********************************************************************//**
4541 When an index page is read from a disk to the buffer pool, this function
4542 applies any buffered operations to the page and deletes the entries from the
4543 insert buffer. If the page is not read, but created in the buffer pool, this
4544 function deletes its buffered entries from the insert buffer; there can
4545 exist entries for such a page if the page belonged to an index which
4546 subsequently was dropped. */
4547 UNIV_INTERN
4548 void
4549 ibuf_merge_or_delete_for_page(

とあり、ここでChange buffer内の変更を適用しています。 他にもページを読むための処理が有りますが、そこでも同じようにibuf_merge_or_delete_for_pageが呼び出されているようなので、 変更のあったページを読み込んだ時はChange buffer中の変更がマージされるようです。

2. バックグラウンド実行によるマージ

mysqlにはmaster threadと呼ばれるinnodbのバックグラウンド処理を行うスレッドがありますが、 この中でChange bufferのマージが行われています。

2055 /*********************************************************************//**
2056 Perform the tasks that the master thread is supposed to do when the
2057 server is active. There are two types of tasks. The first category is
2058 of such tasks which are performed at each inovcation of this function.
2059 We assume that this function is called roughly every second when the
2060 server is active. The second category is of such tasks which are
2061 performed at some interval e.g.: purge, dict_LRU cleanup etc. */
2062 static
2063 void
2064 srv_master_do_active_tasks(void)
2065 /*============================*/
...
2094   /* Do an ibuf merge */
2095   srv_main_thread_op_info = "doing insert buffer merge";
2096   counter_time = ut_time_us(NULL);
2097   ibuf_contract_in_background(0, FALSE);
2098   MONITOR_INC_TIME_IN_MICRO_SECS(
2099     MONITOR_SRV_IBUF_MERGE_MICROSECOND, counter_time);

2097行目のibuf_contract_in_backgroundを辿って行くと、ibuf_merge_spaceなどが見つかります。 さらに辿って行くと、ibuf_merge_space中からbuf_read_ibuf_merge_pages関数経由でibuf_merge_or_delete_for_page関数を呼び出しており、ここでChange bufferのマージを行っていることがわかります。 ibuf_contract_in_backgroundは主に、master threadにおけるバックグラウンド処理で呼び出されています。 master threadによるバックグラウンド処理にはサーバーがアクティブ状態のとき(srv_master_do_active_tasks)とサーバーがアイドル状態の時(srv_master_do_idle_tasks)の2種類があり、そのうち両方でChange bufferのマージが行われますが、少しずつ挙動が違います。まず、master threadによるアクティブ状態とアイドル状態の判定は以下のようになっています。

2342  if (srv_check_activity(old_activity_count)) {
2343    old_activity_count = srv_get_activity_count();
2344    srv_master_do_active_tasks();
2345  } else {
2346    srv_master_do_idle_tasks();
2347  }

srv_check_activity関数はold_activity_countと現在のactivity_countに差分があるかどうかを見ており、 前回srv_master_do_active_tasksを実行してから再度srv_master_do_active_tasksを実行する間にサーバーに対してアクションがあったかどうかでアクティブ/アイドル状態を切り替えています。 アクティブ状態とアイドル状態で実行されるibuf_contract_in_backgroundの違いは第二引数のbool値にあり、アクティブ状態では引数がFALSE

2094   /* Do an ibuf merge */
2095   srv_main_thread_op_info = "doing insert buffer merge";
2096   counter_time = ut_time_us(NULL);
2097   ibuf_contract_in_background(0, FALSE);
2098   MONITOR_INC_TIME_IN_MICRO_SECS(
2099     MONITOR_SRV_IBUF_MERGE_MICROSECOND, counter_time);

アイドル状態では引数がTRUE

2186   /* Do an ibuf merge */
2187   counter_time = ut_time_us(NULL);
2188   srv_main_thread_op_info = "doing insert buffer merge";
2189   ibuf_contract_in_background(0, TRUE);
2190   MONITOR_INC_TIME_IN_MICRO_SECS(
2191     MONITOR_SRV_IBUF_MERGE_MICROSECOND, counter_time);

となっており、他の実装に差はありません。 では、この第二引数は何を変化させているのでしょうか? ibuf_contract_in_background関数の実装を追ってみましょう。

2790 /*********************************************************************//**
2791 Contracts insert buffer trees by reading pages to the buffer pool.
2792 @return a lower limit for the combined size in bytes of entries which
2793 will be merged from ibuf trees to the pages read, 0 if ibuf is
2794 empty */
2795 UNIV_INTERN
2796 ulint
2797 ibuf_contract_in_background(
2798 /*========================*/
2799   table_id_t  table_id, /*!< in: if merge should be done only
2800           for a specific table, for all tables
2801           this should be 0 */
2802   ibool   full)   /*!< in: TRUE if the caller wants to
2803           do a full contract based on PCT_IO(100).
2804           If FALSE then the size of contract
2805           batch is determined based on the
2806           current size of the ibuf tree. */
2807 {
...
2819   if (full) {
2820     /* Caller has requested a full batch */
2821     n_pages = PCT_IO(100);
2822   } else {
2823     /* By default we do a batch of 5% of the io_capacity */
2824     n_pages = PCT_IO(5);
2825

少し長いですが、第二引数はPCT_IOマクロの引数の値を変化させています。 PCT_IOマクロの実装は以下のようになっています。

300 /* Returns the number of IO operations that is X percent of the
301 capacity. PCT_IO(5) -> returns the number of IO operations that
302 is 5% of the max where max is srv_io_capacity.  */
303 #define PCT_IO(p) ((ulong) (srv_io_capacity * ((double) (p) / 100.0)))

ここでは実装を追うのを割愛しますが、srv_io_capacityの中身はinnodb_io_capacity(※1)の値となります。 ここから分かることは、ibuf_contract_in_backgroundの第二引数がTRUEの時は100%の、FALSEの時は5%+αのinnodb_io_capacityを使用してChange bufferのマージを行うということになるということで、 アイドル時は設定しているinnodb_io_capacityを使いきってしまうため、この間サーバー負荷が上がると予想できます。

Change buffering機能について、特に気をつけたいこと。

バージョンアップの際にフォーマットが変わることがある。

少なくとも過去4回のアップデート中にChange Bufferの構造が変わっています。

  1. バージョン4.1.x未満
  2. バージョン4.1.x以上(innodb_file_per_tableが実装され、space idが必要になった)
  3. バージョン5.0.3以下(ROW_FORMATオプションが追加され、Change buffer中にこれに対応するためのフラグが追加された)
  4. バージョン5.5.0以上(Insert bufferingがdeleteやpurgeに対応したChange Bufferingになった)

今後も構造が変わることが予想されます。 そのため、アップデート前にシャットダウンする際は必ずinnodb_fast_shutdownを0に設定し、Change bufferのマージを完了させなければなりません。

Change Bufferのデフォルトの最大サイズはバッファプールの25%

/** Default value for maximum on-disk size of change buffer in terms
of percentage of the buffer pool. */
#define CHANGE_BUFFER_DEFAULT_SIZE  (25)

デフォルトの最大サイズはinnodb_buffer_pool_sizeの25%となっていますが、この25%という値は5.6.2からinnodb_change_buffer_max_sizeで変更可能になっています。 なぜこの値が変更可能になっているかというと、Change BufferのサイズがChange bufferの最大サイズの半分を超えた時、アクティブ/アイドル状態に関係なく強制的に100%のinnodb_io_capacityを使用してマージが実行されてしまうからです。また、Change bufferのサイズが最大サイズを超えた場合、Change buffering機能は動作せず、書き込むページをディスクへ読みに行くためIOが発生してしまいます。 この動作は突然サーバーのパフォーマンスが落ちることにつながるので、不安なときは監視したいですよね。 Change bufferの現在のサイズはSHOW ENGINE INNODB STATUSコマンド中のINSERT BUFFER AND ADAPTIVE HASH INDEXセグメントで得ることができます。 以下は、INSERT BUFFER AND ADAPTIVE HASH INDEXセグメントの例です。

-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
-------------------------------------
Ibuf: size 1, free list len 83, seg size 85, 3600 merges
merged operations:
 insert 17730, delete mark 167305, delete 105
 discarded operations:
  insert 0, delete mark 0, delete 0
  Hash table size 276671, node heap has 100 buffer(s)
  0.00 hash searches/s, 0.00 non-hash searches/s

sizeがChange bufferのサイズですが、この数字はページ数なので実サイズを得るためにはinnodb_page_size(デフォルトで16KB)を掛け算する必要があります。 例だと16KBがChange bufferのサイズとなります。ただし、異なるサイズの行がChange bufferに保存されるため、この値はおおよその値となります。 この値を監視したいときは、以下のようにすることができます。

expr `mysql -uroot -e 'show engine innodb status\G' | awk '/^Ibuf: size [0-9]+, free list len [0-9]+, seg size [0-9]+, [0-9]+ merges/ { print $3; }' | sed "s/,//"` \* 16384

実行結果はChange bufferのサイズ[byte]となります。

また、そもそもChange bufferのサイズが大きくならないように、バッファプール中にページが有ることも合わせて監視したいと思います。 Buffer pool hit rateを監視しましょう。 Buffer pool hit rateは同じくSHOW ENGINE INNODB STATUSコマンド中のBUFFER POOL AND MEMORYセクションにあります。 以下は、BUFFER POOL AND MEMORYセクションの例です。

----------------------
BUFFER POOL AND MEMORY
----------------------
Total memory allocated 13588365312; in additional pool allocated 0
Dictionary memory allocated 85572888
Buffer pool size   810368
Free buffers       8186
Database pages     760386
Old database pages 280528
Modified db pages  38524
Pending reads 1
Pending writes: LRU 0, flush list 20, single page 0
Pages made young 120864074, not young 3085053198
11.00 youngs/s, 33.99 non-youngs/s
Pages read 205587158, created 3544965, written 283537176
13.25 reads/s, 2.25 creates/s, 108.97 writes/s
Buffer pool hit rate 1000 / 1000, young-making rate 0 / 1000 not 2 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 760386, unzip_LRU len: 0
I/O sum[49680]:cur[456], unzip sum[0]:cur[0]

Buffer pool hit rateは通常、1000に近い値になります。この値が1000に近いほどバッファプール中にページが存在する確率が高くなり、readのためのIOを節約できます。 Buffer pool hit rateを監視したいときは、以下のようにすることが出来ます。

echo `mysql -uroot -e 'show engine innodb status\G' | awk '/^Buffer pool hit rate [0-9]+ /{print $5}'` | awk '{if(length($0) == 0) {print "0"} else{print}}'

結論

InnoDBにおいて「バッファプール上にデータが乗らなかった」時、バックグラウンドでこのような処理が行われています。 性能が落ちたり、定常的に高負荷になるのには必ず理由があります。 最大の性能が発揮できるよう、パラメータチューニングには気を配りたいですね。

(※1) innodb_io_capacityMySQLのバックグラウンド処理に使用するIOPSを設定する値です。