BizStationブログ

ビズステーション株式会社の公式ブログです。

Transactd 2.3 リリース!

Transactd 2.3 をリリースしました。
このリリースでは大きな機能追加はありませんが、アプリケーションの機能の実現や運用のために重要な細かな変更がされています。

Transactd 2.3 の新機能を紹介します。

readMore 検索

Transactdの検索クエリーは以下のいずれかの条件で終了します。

  • クエリーの条件から判断してこれ以上レコードがない。
  • 指定したレコード数に達した。
  • マッチしないレコード数が指定した数に達した。

2番目もしくは3番目の条件で終了したときは、まだそれに続くレコードを検索したい場合があります。従来は、最後に読み取ったレコードの値から再度クエリー条件を作成して検索を行う必要がありました。
readMoreを使うと、クエリーを再指定することなく同じ条件で続きから検索できます。たとえば、スマートフォンのアプリケーションなどで「もっと読む」といったリンクがありますが、これを簡単に実現することができます。
この「続き」は真に続きで、直前の検索で最後に読み取ったレコードの次のレコードから検索を行います。対して、SQLのLIMIT句とOFFSETを使用した場合では、もう一度最初から検索しOFFSET数を読み飛ばしてようやく目的の検索開始位置にたどり着きます。
readMoreは無駄な再読み取りがなく、OFFSETの使用に比べて高パフォーマンスです。

あわせて、クエリーが1~3番目のどの理由で終了したかを調べるstatReasonOfFind()メソッドも追加されました。

新しいグルーピング関数 firstとlast

recordsetのグルーピング関数に、firstlast関数が追加されました。これらの関数は、各グループの最初または最後のレコードのフィールド値をそのまま返します。
これを使用すると、非正規化されたテーブルでidなどに対する説明のフィールド(nameなど)の値をそのまま利用し、説明フィールドのためのJOINを不要にできます。
グルーピングでは、グループ化のキーフィールドと計算結果フィールドの列しかレコードセットに残りません。そこで、firstlast関数を使用して説明フィールドを残すことができます。

READ_COMMITEDでの共有ロック

トランザクション中のseekstepメソッドについて、2.2では、トランザクション分離レベルがREPEATABLE_READの場合、レコードごとに共有ロック(S)と排他ロック(X)の指定が可能でした。しかし、READ_COMMITEDでは排他ロック(X)のみで、共有ロック(S)の指定は行えませんでした。
2.3からはREAD_COMMITEDにおいてもその指定ができるようになりました。これで主要な2つのトランザクション分離レベルで、共有ロック(S)、排他ロック(X)、unlockRow()を使って、自由自在にロックをコントロールできます。
ロック競合は、書き込みパフォーマンスを悪化させる大きな要因です。細かなロック制御によってよりパフォーマンスの高いアプリケーションを作成できます。

コネクションの再接続

ネットワーク接続の再接続コマンドdatabase::reconnect()が追加されました。不意にサーバープロセスが再起動された場合でも、このメソッドによって再接続が可能になりました。再接続とともに、以下の処理も同時に行われます。

  • 開いていたテーブルの再オープン
  • カーソル位置の復元
  • レコードロックの復元

トランザクション中の切断でなければ、アプリケーションは何事も無かったかのように処理を継続できます。
ただし、現在のバージョンでは、ネットワーク接続の切断時に実行中だったトランザクションは復元されません。再度実行する必要があります。また、再接続先の変更も行えません。
将来的には、MySQLレプリケーションを使ってHA運用を行うために、接続先の自動変更、処理中トランザクションの自動再実行をサポートする予定です。

ネットワークタイムアウト

クライアントの接続タイムアウトと、送受信タイムアウトが別々に設定可能になりました。設定はクライアント側のtransactd.cnf(ini)にて行います。
デフォルトは以下の値(秒)です。

  connectTimeout = 20
  netTimeout = 180

この変更も、コネクションの再接続と併せてHAための準備になっています。Transactdは、サーバーの死活監視をクライアントライブラリが直接行う予定です。ネットワークのタイムアウト時間は、その判定においてとても重要な要素です。

まとめ

紹介した新機能に加えて、既知のバグについてはすべて修正し、より完成度を高めています。
前バージョンをご利用の方は、2.3にアップグレードすることを強くお奨めします。
詳しくはリリースノート(GitHub)をご覧ください。

MySQL/MariaDB UPDATEとDELETEの内部ロジック

前回のブログ「MySQL/MariaDBとTransactdのInnoDBロック制御詳細」で、「ロックなし読み取りと更新の混在に注意」と書きましたが、「更新前の値を条件で指定したUPDATEやDELETEは問題ないと思うが心配だ」という質問をいただきました。そこで今回は、UPDATEとDELETE文のInnoDBロックについて説明したいと思います。とは言っても、ロックの内容については既に説明済なので、更新処理の内部ロジックについて説明します。それが今回の質問の答えでもあります。

更新前の値をWHERE句で特定した更新

更新前の値をWHERE句で特定した更新の例としては、statusを1から2に変更して状態遷移させるような更新があります。たとえば以下のようなSQL文です。
UPDATE user SET status = 2 WHERE id = 1 and status = 1;
ポイントは、WHEREの条件に status = 1を指定している点です。レコードにバージョン番号フィールドを設けてそれを指定する場合も同様です。*1

UPDATE文内のWHERE id = 1 and status = 1の読み取りは、ロックされたものでしょうか?それとも、Consistent nonlocking readなのでしょうか?

UPDATEとDELETEの内部ロジック

UPDATEとDELETE文では、SELECT句によって更新する対象のレコード(SELECT句が無い場合はすべてのレコード)をフィルタリングします。
例えばすべてのレコードを対象にid >= 5 and id <= 10といった条件でフィルタリングをしたとき、MySQL内部のHandlerインタフェースの疑似コードは以下のようになります。(このサンプルはわかりやすくするため、先頭から最後まで検索しますが、実際はインデックスを使って必要なレコードを可能な限り絞って読み取ります。)

#define FIELD_INDEX_ID 0
#define KEY_ID         0

//テーブルオープン
TABLE_LIST tables;
tables.init_one_table("databaseName",strlen("databaseName"),
                      "tableName",strlen("tableName"), TL_READ);
open_table(thd, tables);
TABLE* table = tables.table;
Handler* file = table->file;
//インテンショロック IXの明示
table.reginfo.lock_type = TL_WRITE; // <-- IX
thd->lock = mysql_lock_tables(thd, &table, 1, 0);

//レコード処理
int keyNum = KEY_ID;
Field* field = *(table.field + FIELD_INDEX_ID);
file->ha_index_init(keyNum , true);
int stat = file->ha_index_first(); // <-- LOCK(X)
while (stat == STATUS_SUCCESS)
{
   if ((field->val_int() >= 5) && (field->val_int() <= 10))
   {   
      // UPDATE record[0]に新しいフィールド値をセットする
      file->ha_update_row(table->record[1],table->record[0]);
   }else
     file->unlock_row();
   file->ha_index_next();  //<-- LOCK(X)
}

//コミットとロック解放
trans_commit_stmt(thd);    
mysql_unlock_tables(thd, thd->lock);

//テーブルクローズ
close_thread_tables(thd);

エラー処理などはだいぶ省いていますがおおよそこのような感じになります。
file->ha_index_first()file->ha_index_next()の部分がレコードを読み取っている部分です。
このコードのロックに関するポイントをまとめます。

  • 更新処理のインテンションロックは無条件に最初にIXが指定される。すなわち、その後に行われる対象レコードを探す読み取りはロックが取得される。
  • (InnoDBの場合)インデックス順に、順次ロック読み取りをしてそれが対象レコードなら、ha_update_row()によって更新が行われる。(削除ならha_delete_row())
  • ロックのタイプは、分離レベルに応じたものになる。
  • 更新対象外のレコードはアンロックが試みられる。(分離レベルによってInnoDBは無視する)

もう、お解かりだと思います。指定した条件の読み取りはロック付き読み取りで、更新時にも間違いなくその値です。更新前の値をWHERE句で特定した更新は、事前にロックもトランザクションの開始も不要です。

自動トランザクション複数行の更新を行った際に他のクライアントとの競合によってロックできなかった場合などは、この文全体が失敗します。部分的に成功してしまう心配も無用です。
ただ、自動トランザクションの場合、その直後に他のクライアントによって変更される可能性があります。続けて何かをする場合はトランザクション内で行いましょう。

Transactdの更新コード

上記の処理内容をTransactdのコードで表現してみました。手続きの流れは上記のHandlerとそっくりです。SQLでしかデータベースを扱ったことがない方も多いと思いますが、実はISAMデータベースのアクセスメソッドはどれもこのようなものです。

#define FIELD_INDEX_ID 0
#define KEY_ID         0

if (db->open(uri))
{
  table* tb = db->openTable(_T("user"));
  db->beginTrn();
  tb->setKeyNum(KEY_ID);
  tb->seekFirst();
  while(tb->stat() ==0)
  {
    if ((tb->getFVint(FIELD_INDEX_ID) >= 5) && 
     (tb->getFVint(FIELD_INDEX_ID) <= 10))
    {
      tb->setFV("status", 2);
      tb->update();
    }
    tb->seekNext();
  }
  db->endTrn();
  tb->release();
  db->close();
}

まとめ

「更新前の値をWHERE句で特定した更新」は条件の読み取りに排他ロックXが使われ、事前にトランザクションを開始しなくても安全に更新できます。ただ、続けて何かをする場合は、トランザクション内で行いましょう。

フィールドにバージョン番号をつけてそれを指定して更新する手法は「楽観的ロック」と呼ばれたりしますが、これもロックが無いわけではなく、InnoDBはレコードをロックしてから更新しています。SELECT ... FOR UPDATEとの違いを大雑把に言うと、1文で処理するか2文で処理するかの違いです。2文で処理するにはBEGINでトランザクションを開始する必要があるので少し手間がかかります。ですが、バージョンフィールドのような特別なフィールドは不要です。

*1:余談ですが、もし事前にトランザクション内で SELECT * from user WHERE id = 1 and status = 1 FOR UPDATE として読み取っていてidがユニークなら、UPDATE文に status = 1 が不要なのは以前の説明で理解していただけると思います。

MySQL/MariaDB 知っておきたい REPEATABLE-READ の不思議

昨日のブログ記事のアクセスが想像以上に多く、とても驚いています。

この中で、REPEATABLE-READのトランザクションで、「ロックなし読み取りした値を元に更新してはいけない」と書いたのですが、具体的なサンプルを入れていなかったので、ここで紹介したいと思います。

REPEATABLE-READにならないトランザクションを試す

更新 (update)よりも、Insertの方がより驚きがありますので、Insertで示します。

まずは前準備です。MySQLコマンドプロンプトを開いて、以下のSQLでデータベースとテーブルを用意します。

CREATE DATABASE test1;
USE test1;
CREATE TABLE `test1`.`test1` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `field2` int(11) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
INSERT INTO `test1`.`test1` (`field2`) VALUES (11);
SET SESSION TRANSACTION ISOLATION LEVEL repeatable read;
SELECT @@tx_isolation;

準備ができたら、トランザクションを開始BEGIN;して、SELECT * FROM test1;でINSERTの確認をします。

BEGIN; /* <--- Trx id = N */
SELECT * FROM test1; /* <-- Consistent nonlocking reads, */
+----+--------+      /*     reads version N */
| id | field2 |
+----+--------+
|  1 |     11 |
+----+--------+

BEGIN;でこのトランザクションのidが決まります。ここではNとします。SELECT * FROM test1;はロックなしリードなのでConsistent nonlocking readsです。ここで読み取ったバージョンはNです。


ここで、別のクライアントからINSERT INTO `test1`(`field2`) VALUES (12);を実行します。

/* execute following command FROM ANOTHER CONNECTION. */
USE test1;
INSERT INTO `test1`(`field2`) VALUES (12); /* <--- Trx id = N+1      */
                                           /* (auto transaction N+1) */

ここで実行したINSERTは別のトランザクション id=N+1 (自動トランザクション) で処理されます。


次に、元のクライアントに戻って、再びSELECT * FROM test1;を見てみます。

SELECT * FROM test1; /* <-- Consistent nonlocking reads, */
+----+--------+      /*     reads version N */
| id | field2 |
+----+--------+
|  1 |     11 |
+----+--------+
1 row in set (0.02 sec)

ちゃんとさっきと同じ結果が表示されます。REPEATABLE-READですね。

では次に、LOCK IN SHARE MODEを加えて、SELECT * FROM test1 LOCK IN SHARE MODE;を実行します。

SELECT * FROM test1 LOCK IN SHARE MODE; /* <-- Locking reads,       */
+----+--------+                         /*     reads latest version */
| id | field2 |
+----+--------+
|  1 |     11 |
|  2 |     12 |
+----+--------+

あれれ!!! 別のクライアントでINSERTしたものまで見えてしまっています。

ロックを要求すると、スナップショットはロックできないので、最新バージョンを読み取ってロックします。言い換えると、ロック要求は「最新をロックしてね」と言っているのと同義です。(SELECT * FROM test1 LOCK IN LATEST VERSION SHARE MODEってコマンドだったら判りやすいかも。ロックなしは、SELECT * FROM test1 READ OWN TRX VERSIONですね。)
更新処理(UPDATE,DELETE)は、MySQLが自動でロック付読み取りをします。なので「ロックなし読み取りした値を元に更新してはいけない」です。


面白いので、ここで、元のクライアントからINSERTしてみます。

INSERT INTO `test1`(`field2`) VALUES (13); /* <--- Trx id = N */

そしてやはりロックなしとロック付で読み取ってみます。

SELECT * FROM test1; /* <-- Consistent nonlocking reads, */
+----+--------+      /*     reads version N */
| id | field2 |
+----+--------+
|  1 |     11 |
|  3 |     13 |
+----+--------+
SELECT * FROM test1 LOCK IN SHARE MODE; /* <-- Locking reads, */
+----+--------+                         /*     reads latest version */
| id | field2 |
+----+--------+
|  1 |     11 |
|  2 |     12 |
|  3 |     13 |
+----+--------+

予想通りというか要求どおり、ロックなし読み取りはバージョンNのスナップショットを返し、ロック付は最新バージョンを返します。
(ロックなしはOWN TRX VERSIONを要求し、ロック付はLATEST VERSIONを要求)

COMMIT;
SELECT * FROM test1; /* <--- Consistent nonlocking reads,  */
+----+--------+      /*      reads latest version          */
| id | field2 |      /*      auto transaction trx id > N+1 */
+----+--------+
|  1 |     11 |
|  2 |     12 |
|  3 |     13 |
+----+--------+

コミットしてからロックなし読み取りをすると、この読み取りにも(自動)トランザクションのidが割り振られて、N+1よりも大きい値になります。読み取りにはそのバージョン(N+1+x)のスナップショットが使われ、それはほぼ最新バージョンです。

まとめ

InnoDBのMVCCとロックを理解すると、一見すると不思議に思えるこれらの動作も予想通りです。また、この動作(振る舞い)は仕方がないものかと思います。(判りづらいのは確かで、改善の余地はあるかとは思いますが。)

InnoDBのMVCCとロックの詳細は以下の記事で。

MySQL/MariaDBとTransactdのInnoDBロック制御詳細 その1

今回から数回にわたり、TransactdのオペレーションとInnoDBにおけるロックについて解説します。
ロックについてはあまり良くわからなくてもとりあえずそれなりに動くアプリケーションは作れてしまいます。ですが、マルチユーザー環境でミッションクリティカルなアプリケーションを書くには、ロックの理解が不可欠です。ロックをうまく使って、矛盾や間違いのない読み書きをしつつ同時実行性も高いアプリケーションにしましょう。
その1では、Transactdを実装する上でMySQLのソースやドキュメントから得た知見を基に、InnoDBのロックの種類と分離レベルに応じてそれをどのように使うかをまとめてみます。

Index

MySQLトランザクション関連用語

説明に入る前に、事前にMySQLトランザクション関連用語の理解が必要です。下記の用語についてあまりよくわからない場合は、大まかで良いので理解しておいてください。

MySQLのREPEATABLE-READ

MySQLトランザクション分離レベルはREPEATABLE READが標準です。通常 REPEATABLE READではファントムリードを回避できませんが、MySQLのREPEATABLE READはネクストキーロックという仕組みによってファントムリードが起きないようになっています。そのため、さらに分離レベルの高い SERIALIZABLE とほぼ同じものです。

トランザクション分離レベルは4つありますが、READ_UNCOMMITEDはほとんど使用されないので、ここでは省いて説明します。

InnoDBのロック

はじめにInnoDBの基本的なロックについて説明します。InnoDBはMVCCとこれらのロックを使って、トランザクション分離レベルを実装しています。

行ロック (row-level locking)

InnoDBは行ロックをサポートしています。行ロック(row lock)にはShared lock(共有ロックS)とExclusive lock(排他ロックX)の2種類があります。
トランザクションT1によって共有ロック(S)が取得されたレコードRは、別のトランザクションT2から共有ロック(S)はできますが、排他ロック(X)はできません。また、トランザクションT1によって排他ロック(X)が取得されたレコードRは、別のトランザクションT2から共有ロック(S)も排他ロック(X)もできません。

2つのトランザクションが同じレコードのロックを取得しようとしたときのロックの可否を表したのがInnoDBソースコードの以下の部分です。「TRUE」の組み合わせでは互いに競合せず双方とも取得できます。「FALSE」の組み合わせでは、後から取得しようとした方は、先に取得されたロックが解放されるまでロックを取得することができません。まだ説明していないIXやISというロックがありますが、ここではXとSの組み合わせのみ見てください。

//mysql-5.6.20/storage/innobase/lock/lock0lock.cc : 323

static const byte lock_compatibility_matrix[5][5] = {
 /**         IS     IX       S     X       AI */
 /* IS */ {  TRUE,  TRUE,  TRUE,  FALSE,  TRUE},
 /* IX */ {  TRUE,  TRUE,  FALSE, FALSE,  TRUE},
 /* S  */ {  TRUE,  FALSE, TRUE,  FALSE,  FALSE},
 /* X  */ {  FALSE, FALSE, FALSE, FALSE,  FALSE},
 /* AI */ {  TRUE,  TRUE,  FALSE, FALSE,  FALSE}
};

共有ロックと排他ロックそれぞれの使い道は以下の通りです。

共有ロック 読取
排他ロック 読取、更新、削除

GAPロック

GAPロックは、レコードとレコードの間の空間をロックします。これでInsertをブロックし、行ロックで更新をブロックすることで、ファントムリードを防止します。具体的には、後ろ側のレコードにGAPロックを行います。GAPロックは単体で使用する場合と、行ロックと組み合わせて使用する場合があります。行ロックと組み合わせたものをネクストキーロック(Next key lock)と呼んでいます。

GAPロック単体、ネクストキーロック、GAPロックを含まない通常の行ロックの3種類をinnodb_monitorで見ると、下記のように表示されます。*1

GAPロック単体

.`test` trx id 134515465 lock mode S locks gap before rec

ネクストキーロック

.`test` trx id 134515465 lock mode S locks

GAPロックを含まない通常の行ロック(row lock)

.`test` trx id 134515465 lock mode S locks rec but not gap

読み取ったレコードすべてにネクストキーロックを行い、最後に読み取ったレコードにGAPロックかネクストキーロック*2を行うことで、検索範囲内の更新と挿入ブロックを実現します。
たとえば、ユニークなインデックスフィールド id の1から15までレコードが挿入されたテーブルがあるとします。そこにREPEATABLE-READで select id from table where id <= 10 LOCK IN SHARE MODEとすると、まず、id 1から10まで10個のレコードは確実にネクストキーロックが行われます。11は、ユニークキーで <= としているので読まなくても検索終了を判断できそうなのですが、11もネクストキーロックされます。(11のロックについては、インデックスや検索条件の書き方によって変わります。MySQLでは、ほぼロックされると考えてください。)

GAPロック単体でロックされたレコードは、前の空間がロックされているだけでレコードそのものはロックされていません。以下の例では、同じレコード(0: 1:で示されたフィールドの内容が同じ)に対して異なる2つのトランザクションがGAPロック(X)と共有ロック(S)を取得しています。

//トランザクション
.`test` trx id 134515525 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000010; asc ;;
1: len 4; hex 80000003; asc ;;

//トランザクション2

.`test` trx id 134515526 lock mode S
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000010; asc ;;
1: len 4; hex 80000003; asc ;;

なお、検索がEND OF RECORDSまで到達した場合は、GAPロックを明示するレコードが存在しません。その場合は、最後のレコードのさらに後ろにある終端を示すレコード(supremum)がロックされます。

.`test` trx id 134515465 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

MySQLのhandlerインターフェースには、ネクストキーロックを明示的に指示する方法は存在しませんが、分離レベルにREPEATABLE READかSERIALIZABLEが指定されていると、行ロックに替えてネクストキーロックが使用されます。

余談ですが、SQLの悩みに「ロックされるレコードの範囲はどこになるのかがわかりにくい」といったものがあります。読み取り方法や範囲をオプティマイザが決めるためです。Transactdの場合はそのような悩みはありません。それらはコードを書く人が決めるからです。

インテンションロック (Intention Locks)

インテンションロックは、multiple granularity locking (複数粒度ロック)とMySQLのドキュメントに記されていますが、実際にこのようなロックがあるわけではありません。テーブルにこのロックを明示すると、以降の行操作に対して、行の共有ロック(S)または排他ロック(X)の取得が行われる仕組みのことです。
行ロックに合わせて、インテンションロックにも共有ロック(IS)と排他ロック(IX)の2種類があります。ともにテーブルに対してロックを明示します。

  • 共有ロック(IS)は、トランザクションT内でテーブルtの複数の行に対して共有ロック(S)の取得を試みる。
  • 排他ロック(IX)は、トランザクションT内でテーブルtの複数の行に対して排他ロック(X)の取得を試みる。

インテンションロックの使われ方を簡単に説明すると、これから更新や削除を行う際には排他ロック(IX)を設定し、更新や削除をされたくない読み取りを行う際には共有ロック(IS)を設定します。(SQL文で明示的にインテンションロックを操作する方法はありません。MySQLSQL文からテーブルロックタイプを決め、そのタイプからInnoDBインテンションロックタイプを決定します)

Multi-Versioning

InnoDBの各レコードには、最後に追加または更新・削除したトランザクションのIDが記録されます。更新・削除によって古くなったバージョン(旧バージョン)のレコードは、テーブルスペースのロールバックセグメントに保持されます。

  • スナップショットは、自分のトランザクションIDと同じか、より前のトランザクションIDで最後にコミットされたレコードの集合。
  • スナップショットの読み取りはロックは不要。また、ロックの取得や変更はできない。

前述の共有ロック(S)と排他ロック(X)のいずれでもないLOCK_NONE(ロックなし)の読み取りは、スナップショットが使われます。

Consistent Reads

トランザクションT1において、他のトランザクションによって変更された値に影響されることなく、T1内で原始性が保証された一貫性のある読み取りができることをConsistent reads(一貫性読み取り)と言います。
たとえば、売上伝票のヘッダーと明細はトランザクションによって一式でアトミックな書き込みが行われるとします。読み取りにおいて一貫性がないと、ヘッダーと明細で異なるバージョンを読んでしまうことが起こります。T1がヘッダーを読み取り、次に明細を読み取る前に、T2によって変更された伝票がコミットされた場合などです。Consistent readsはアトミックな特性と併せて、このような問題を解決するためにとても重要な機能です。
InnoDBのConsistent readsには、2つの方法があります。

  • Consistent Nonlocking Reads (ロックなし一貫性読み取り)
  • Consistent locking Reads (ロッキング一貫性読み取り)

Consistent Nonlocking Reads

Consistent Nonlocking Readsはマルチバージョン機能を使って、トランザクションを開始した時点のスナップショットにアクセスすることで実現します。他のトランザクションによってコミット済みの挿入・更新されたレコードがあっても、スナップショットにはそれはないので、Consistent readsを実現します。また、ロック不要なので Consistent Nonlocking Readsです。
InnoDBでConsistent Nonlocking Readsを行うには、ロックを要求しない読み取りを行うことで実現します。(SQLでは、SELECT ... FOR UPDATE か SELECT ... LOCK IN SHARE MODEを使わない読み取り)ただし、分離レベルがSERIALIZABLEの場合は、トランザクション内のすべての読み取り時に共有ロック(S)を要求してしまうので、Consistent Nonlocking Readsは行えません。
以下は、SERIALIZABLEがLOCK_Sを強要する部分のInnoDBのコードの抜粋です。(mysql-5.6.20)

// /mysql-5.6.20/storage/innobase/handler/ha_innodb.cc : 12191
if (trx->isolation_level == TRX_ISO_SERIALIZABLE
        && prebuilt->select_lock_type == LOCK_NONE
        && thd_test_options(
          thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)) {

      /* To get serializable execution, we let InnoDB
      conceptually add 'LOCK IN SHARE MODE' to all SELECTs
      which otherwise would have been consistent reads. An
      exception is consistent reads in the AUTOCOMMIT=1 mode:
      we know that they are read-only transactions, and they
      can be serialized also if performed as consistent
      reads. */

      prebuilt->select_lock_type = LOCK_S;
      prebuilt->stored_select_lock_type = LOCK_S;
    }

この、Consistent Nonlocking Readsには2つ注意点があります。

  • READ-COMMITEDの場合は、スナップショットのバージョンがトランザクション内で変わる。
  • スナップショットは更新できない。更新は最新バージョンに対して行うが、スナップショットとは値が異なることがある。

REPEATABLE-READのときは、トランザクションの最初から最後まで同じスナップショットのバージョンです。ところが、READ-COMMITEDのときは1つのSQL文の中でのみ同じものです。トランザクション中であっても、1文目と2文目では異なるスナップショット(のバージョン)が使われる可能性があります。(1文内だけのConsistent Nonlocking Reads)
スナップショットはコミット済の確定した内容なので、その行をロックして変更することはできません。トラザクション内で更新が必要な場合は、スナップショットではなく、最新バージョンをロックする必要があります。もし、最新のバージョンが、自分のトランザクションIDより新しいものであった場合、その内容はスナップショットとは異なるものになります。そのため、トランザクションに更新を含む場合、スナップショットを使ったConsistent Nonlocking Readsとの混合は、REPEATABLE-READを満たすことができません。
下図は、REPEATABLE-READのトランザクションにおいて、スナップショットの様子を図にしたものです。
f:id:bizstation:20141223115353p:plain
トランザクション2がConsistent Nonlocking Reads で読み取りを行ったあと、record 2を更新するには、トランザクション3(trx_id = 3)によって更新された最新のバージョンを読み取って排他ロック(X)を取得しなくてはなりません。最新のバージョンはConsistent Nonlocking Readsで読み取ったバージョンとは異なります。絶対に、ロックなしREADによって得られた値を元に更新してはいけません。

Consistent locking Reads

Consistent locking ReadsというワードはMySQLのドキュメントにはありませんが、Nonlockingと対比するために便宜的にこう呼んでいます。
Consistent locking Readsは、トランザクション内で読み取ったレコードをすべてネクストキーロックして、更新と挿入をブロックすることで、Consistent readを実現する方法です。(この方法は、更新を行いつつ一貫性のある読み取りができますが、ロックが多くなるためトランザクションの同時実行性能は大きく低下してしまいます。)
実はこのConsistent locking Readsが、SERIALIZABLE です。前述のとおり、SERIALIZABLE を指定するとロックを指定していない読み取りにおいても、ネクストキーロック(S)を自動で取得します。
それ以外の分離レベルでは、SELECT ... FOR UPDATE や SELECT ... LOCK IN SHARE MODE を使ってロックを指定しないと読み取りにロックをかけません。Consistent locking Readsにするには明示的にロックを行う必要があります。ちなみに、REPEATABLE-READですべての読み取りにロックを指定をすると、SERIALIZABLEと同じになります。
READ_COMMITEDでは、ロック指定読み取り時にネクストキーロックに替えて通常の行ロックが使用されます。READ_COMMITEDはファントムリードを防止できなくてもよいのでこれでよいのですが、Consistent locking Readsにはなりません。

ロック付読み取り時のロック対象

クエリーにマッチしないレコードのロック保持と開放について説明します。
クエリーの検索対象は、インデックスを使った特定のレコード範囲(または複数の範囲)、または全レコードのどちらかです。その際に、範囲内ではあるけれども条件にマッチしないレコードのロックはどうなるのでしょうか?
それは分離レベルによって以下のようになります。

SERIALIZABLE
REPEATABLE-READ
ロックを保持
(ファントムリードを防止するため)
READ_COMMITED ロックを解放

この保持するか否かの実装は、InnoDBのha_innobase::unlock_row()内にあります。MySQLもTransactdもアンマッチの時は、unlock_row()を呼び出しますが、InnoDBがREPEATABLE-READとSERIALIZABLEのときは無視してロックを解放しないようになっています。以下はそれを実装するInnoDBソースコード抜粋です。

// /mysql-5.6.20/storage/innobase/handler/ha_innodb.cc : 7222
  switch (prebuilt->row_read_type) {
  case ROW_READ_WITH_LOCKS:
    if (!srv_locks_unsafe_for_binlog
        && prebuilt->trx->isolation_level
        > TRX_ISO_READ_COMMITTED) {
      break;
    }
    /* fall through */
  case ROW_READ_TRY_SEMI_CONSISTENT:
    row_unlock_for_mysql(prebuilt, FALSE);
    break;
  case ROW_READ_DID_SEMI_CONSISTENT:
    prebuilt->row_read_type = ROW_READ_TRY_SEMI_CONSISTENT;
    break;
  }

row_read_typeがROW_READ_WITH_LOCKSであるときは、直前の読み取りでロックをしていることを示します。isolation_levelがTRX_ISO_READ_COMMITTEDより大きい場合はbreakして次のアンロック関数 row_unlock_for_mysql()が呼び出されません。

MySQLのロック制御

ここまではInnoDBのロック機能について説明しました。それとは別にMySQLにはテーブルロック機能があります。また、MySQLがどのように、InnoDBのロックを制御するか説明します。

LOCK TABLES statement

LOCK TABLES statementは、InnoDBのロックとは無関係です。LOCK TABLESは、MySQLの管理するテーブルロックマネージャの機能を利用して実現するものです。MySQLのテーブル操作はhandlerインターフェースを通じて行いますが、handlerインターフェースでテーブルにアクセスするには事前に必ずテーブルのロックを取得しなければなりません。
LOCK TABLES ... READは、ロックマネージャから書き込みをブロックするTL_READロック(自身も他者もREAD可 WRITE不可)を取得します。以降このテーブルはREADロックの取得のみ許可されます。
LOCK TABLES ... WRITEはTL_WRITE排他ロック(自身はREAD WRITE可、他者はREAD WRITE不可)を取得して、以降このテーブルのすべてのロック取得をブロックします。すなわち、読み取りも書き込みもできなくなります。
取得したロックは、UNLOCK TABLESで開放されます。

このLOCK TABLESは、行レベルのロックではくテーブル単位のロックなので、書き込みのある複数トランザクションの同時実行はできません。分離レベルの概念もなくトランザクションは占有です。あえて分離レベル風に名前をつけると EXCLUSIVE です。

トランザクションはLOCK TABLES...で自動で開始されています。AUTOCOMMITが有効だと文の終了でCOMMITされ、後でROLLBACKできなくなってしまいます。複数のSQL文を発行するときはLOCK TABLESの前に、SET AUTOCOMMIT=0 で無効にしておきましょう。UNLOCK TABLESの前にCOMMITまたはROLLBACKすることで、アトミックな処理ができます。

Transactdでテーブルをロックする

少しだけTransactdでの処理を説明します。
Transactdでは、テーブルをオープンするときのmodeパラメータに、TD_OPEN_EXCLUSIVE か TD_OPEN_READONLY_EXCLUSIVEを指定することで、WRITEまたはREADのテーブルロックを行うことができます。

MySQLはLOCK TABLES呼び出し時にすべてのテーブルの列挙が必要で、後からテーブルを追加したりできませんが、Transactdは後からいくつでも追加できます。また、通常モードとEXCLUSIVEモードを混在させてオープンすることもできます。さらに、それらのテーブルが混在したトランザクションもそのまま行えます。SQL文にはアトミックな処理に独特の癖がありますが、Transactdにはそのような特別なことは何もありません。

// テーブル1を排他ロックでオープン。 LOCK TABLES ... WRITEと同じ
table* tb1 = db->openTable(name1, TD_OPEN_EXCLUSIVE); 
// テーブル2をREADロックでオープン。 LOCK TABLES ... READと同じ
table* tb2 = db->openTable(name2, TD_OPEN_READONLY_EXCLUSIVE); 
//ロックモードが異なるテーブルが混在したトランザクションもOK!
db->beginTrn();
...
tb2->seek();
...
tb1->update();
...
db->endTrn();

分離レベルとロック制御

分離レベルに応じてInnoDBがどのように読み取り時にロックをするかを表にまとめます。
読み取りには、ロックなしと有り(SELECT...FOR UPDATE(X)または SELECT...LOCK IN SHARE MODE(S)を使った読み取り)がありますので、それぞれをトランザクション中に使うとどのような読み取り処理をするかが整理できます。併せてロック有りの際にアンマッチレコードのロック開放を行うかどうかも加えておきました。

なお、ここでのMySQLトランザクションは、 START TRANSACTIONで開始しCOMMITで終了するトランザクションと考えてください。(1文での自動トランザクションも基本的には同じですが、更新を含まない場合、ロックをしないなどの最適化がMySQL内部で自動的に行われます。)

MySQLの分離レベルと読み取りロックタイプ表
分離レベル ロックなしREAD ロック有READ*3 アンマッチレコードのロック解放
SERIALIZABLE できない Next key lock しない
REPEATABLE-READ Consistent Nonlocking Reads
トランザクション内で同一バージョンのスナップショット
Next key lock しない
READ-COMMITED Consistent Nonlocking Reads
1つのSQL文内で同一バージョンのスナップショット
row lock する

なお、更新や削除を行う場合は、対象レコードの読み取り時に自動で排他ロック(X)の取得がトライされ、取得できれば実行されます。

まとめ

ここまで見てきたInnoDBの仕様やロックの特性から、MySQLSQL文でミッションクリティカルなアプリケーションを書く場合に大切なことをまとめます。

  • 分離レベルのデフォルトは、REPEATABLE-READにする。(以下の前提条件)
  • アトミックな操作は、トランザクションで(START TRANSACTION ... COMMIT)で行う。
  • 複数のSQL文による読み取りで一貫性が必要なら、更新が無くともトランザクション内で(START TRANSACTION ... COMMIT)で読み取る。
  • 行ロックはトランザクション内であっても、SELECT...FOR UPDATE(X)または SELECT...LOCK IN SHARE MODE(S)を使って明示的しなければ行われない。ただし、分離レベルをSERIALIZABLEにすれば、明示しない読み取りも共有ロック(S)が取得できる。
  • (REPEATABLE-READの)トランザクション内において、ロックなし読み取りと更新処理の混在に注意する。この組み合わせは、REPEATABLE-READではなく、異なった値を読むことがある。受け入れられない場合は、すべての読み取りをロック付読み取りにするか、SERIALIZABLEにする。
  • ファントムリードが問題にならないトランザクションであれば、同時実行性を向上するためにREAD-COMMITEDを使う。
  • テーブルを占有したいときは、SET AUTOCOMMIT=0の後で、LOCK TABLES ... を呼び、処理終了後にCOMMITまたはROLLBACKしてから、UNLOCK TABLESを呼び出す。テーブルロックはテーブルを占有してしまうので、マルチユーザー環境では特別な状況以外使用してはいけない。

いかがだったでしょうか?
これらを理解し実践することがミッションクリティカルなアプリケーションの第一歩になると思います。何か参考になればいいなと思います。

次回、その2ではTransactdのInnoDBロック制御について詳しく書きたいと思います。

*1:参考コード /mysql-5.6.20/storage/innobase/lock/lock0lock.cc : 5011 lock_rec_print()

*2:どちらになるかは検索条件によって変わります

*3:SELECT...FOR UPDATE(X)または SELECT...LOCK IN SHARE MODE(S)を使った読み取り

Transactd 2.1はさらに高速で低負荷に

まもなくリリースされるTransactd 2.1は、2.0に比べてクエリーの速度が大幅に高速になります。

SQLより高速なクエリー

2.1ではJoinなどに関する処理を徹底的に見直し、SQLを凌ぐクエリーレスポンスを実現しました。

TransactdのJoinを含むクエリーは、テーブルごとにクライアントからリクエストを送信するため、サーバー側ですべてを処理して結果を返すSQLに比べて通信回数が多くなり、レスポンスにおいては不利です。特に結果レコード数が少ない場合は、処理時間に比べて通信時間の占める割合が大きく、高速に処理しても差が出にくくなります。にもかかわらず、Transactd2.1はSQLより高速にJoin処理ができます。

下図は、LinuxUbuntu 14.04.1)上のPHP5.6クライアントでのレスポンスを、SQLMySQL 5.6.21, PDO)と比較したグラフです。Readは単純な1レコードの読み取り、Join_xRowはx行の結果を返すクエリーです。すべて、5クライアントで数千回の連続実行をして、1回あたりの平均を取っています。(ネットワークは1Gbps NIC

図1 (縦軸は秒)
f:id:bizstation:20141128132100p:plain

//クエリーのSQL文 
select `user`.`id`,`user`.`名前`,`user`.`group`,`groups`.`name` as `group_name`
from `user` left join `groups` on `user`.`group`  = `groups`.`code`
where `user`.`id` > ? and `user`.`id` <= ?
order by `group_name`;
//Transactdのコード
$q->select('id', '名前', 'group')->where('id', '<', '?');
$rs = $activeTable1->keyValue($id)->read($q);
$activeTable2->alias('name', 'group_name');
$q->reset()->select('group_name');
$activeTable2->join($rs, $q, 'group');
$rs->orderBy('group_name');

高速化のポイント

プリペアードクエリー

プリペアードクエリー機能が新しく追加されました。これによりクエリーの実行速度の向上、CPU負荷、ネットワーク消費の低減をしました。

TransactdのアクセスはAPIベースです。SQLのような言語構文解析はありません。しかし、クライアント側ではシリアライズ、サーバー側ではデシリアライズや比較順の決定などの負荷があります。
プリペアードクエリーは、(サーバー、クライアントともに)クエリー処理時に行われる準備処理を事前にキャッシュします。クエリー実行時は、プレースホルダーの値のみやり取りして処理を行います。

下図は、プリペアードクエリーを使用したときとそうでない時のレスポンス比較の図です。約12%レスポンス速度が向上しています。

15 Client同時実行 Joinを含む1行を返すクエリー実行速度の平均値(縦軸は秒)
f:id:bizstation:20141128165121p:plain

recordsetクラスの実装見直し

図1のJoinを含むテストでは、Transactd、SQLともに結果セットを[行][列]の連想配列の形式で値を読み出せるようにするところまで実行しています。サーバーから受け取ったデータを如何に効率よく連想配列のように取り出せるかについて、メモリのアロケート回数、コピー回数の低減を徹底的に進め、実行速度の向上とメモリの利用効率を高めました。

SWIGバインディングコードの最適化

PHPRubyの言語バインディングSWIGを使用して生成しています。このSWIGによって生成されるバインディングコードを見直し、PHPRubyでの処理速度の向上と省メモリーを実現しました。
SWIGは関数のオーバーロードやデフォルト引数があると非常に冗長なコードを生成してしまいます。また、メソッドチェーンのためのthisを返す場合も新しいリーソースを確保してしまいます。2.1はこれらを取り除き最適なコードにすることで、バインディングのオーバーヘッドを大幅に小さくしています。

ガベージコレクタの影響を受けない結果セットメモリ管理

PHPRubyActiveXなどの言語バインディングでも、結果セット(recordset)のメモリ確保と管理はC++のTransactdクライアント内部で行われます。「recordsetクラスの実装見直し」と「SWIGバインディングコードの最適化」によって、ガベージコレクタによる「引っかかるような」もたつきがほとんどなくなりました。常にスムーズに処理が行われます。

下の動画はPHPにて3つのテーブルをJoinし、500行の結果を返すクエリーを2000回連続して実行したときの、PDOとTransactdの比較動画です。20回ごとに1つのプログレスが進んでいきます。上がPDO、下がTransactdです。ともに同じ結果を返すクエリーです。

Transactdはスムーズに処理が進んでいく様子をご覧ください。


Transactd 2.1 vs PDO(MySQL) - YouTube

このクエリーでは、MySQL+PDOが32.4秒、Transactdが9.4秒で3倍以上の速度差が出ています。

SQLの数分の1のサーバーCPU負荷

一連のbenchmark実行時のサーバーCPU負荷を計測しました。 図1のJoin 100レコードのReadテスト時のCPU負荷の変化を示します。
(図1では5クライアントの時のデータを載せていますが、実際の計測は1~50クライアントまで連続的に計測しています。)

図を見るとTransactdはCPU負荷が小さいことがわかります。(面積で約 1/2)さらにこのとき約1.6倍のスループットが出ていましたので、実質MySQLの約1/3のCPU負荷になっています。
データの転送量の少なさ、SQL文の解析、Joinの結合処理、OrderByのソート処理がないことが要因かと思います。サーバーは、SQLに比べ約3倍のスケーリングを実現できます。
f:id:bizstation:20141128181255p:plain

まとめ

2.0のリリース以降、クエリーパフォーマンスの改善を図ってきました。この改善でサーバー、クライアントとも処理時間は限りなく少なくなりました。

ここで提示したデータはありのままのデータです。出来る限り実際の使用状況に近くするため、1GbpsのNICでケーブルで接続して計測しています。ローカルや同一ホスト内の仮想サーバー間通信ではありません。

今回示したデータは、SQLとの差が少し少な目に見えるかも知れません。これは、処理時間において、1Gbps NICによるTCP/IP通信のレイテンシの占める割合が大きく、この部分はSQLもTransactdもほぼ同様にかかるためです。他のNoSQL(Radisやmemcachedなど)でも全く同様です。

参考までに、ローカルでのサーバーとクライアントの通信に、共有メモリによるプロセス間通信とTCP/IPを使った場合の差を示します。共有メモリによる方法は通信時間は限りなくゼロに近いものです。点線で囲まれた部分が通信時間に相当します。通信時間の比率の高さがよくわかると思います。

処理時間に占める通信時間(縦軸はミリ秒)
f:id:bizstation:20141128205552p:plain

サーバー間のネットワークもいよいよ10Gbpsの時代になりつつあります。サーバーとクライアントを10Gbps NICで接続すればSQLとの差はさらに大きなものになると思います。

先日、ブログのコメントに、更新処理における排他制御に関する質問をいただきましたので、詳しく回答させていただきました。その他、使い方など何か不明なことがありましたら是非コメントください。お待ちしています。
(いただいた質問は下記記事にあります。)

Transactd 2.0 その3 データベーススケーリング - BizStationブログ

MySQLからNoSQLへの移行に「ちょっと待った」

Transactdの開発過程でMySQLのパフォーマンス問題を調べてみたところ、ほとんどは「クエリーが遅い」といった問題のようでした。また、NoSQLデータベースへの移行理由としても「MySQLが遅いから」といった意見が結構あります。しかし、そのような理由でMySQLからNoSQLに移行しようとしているのなら、「ちょっと待った」です。(他の理由なら別ですが。)

NoSQLに移行すれば何もかもがバラ色というわけではありません。本当に移行する必要があるか、これを読んで考えてみてください。

TransactdはNoSQLですが、MySQLでもあります。同じMySQLでNoSQLを実現できるTransactdはこの話の中ではMySQLに入れて考えてください。

遅い理由

一口に遅いといっても、いくつかの意味があります。

応答速度が遅い

応答速度を見るには、マスターデータにselect * where id = x from xxxといった1レコードを取得するクエリを投げて、どれくらいのスピードで返るか比較するのが適当でしょう。サーバーだけでなく、ネットワーク、クライアントライブラリも含めた計測で評価する必要があります。

データがメモリ上にあることを前提とすると、応答速度を決定する主な要因は、ネットワークのレイテンシです。ネットワークのレイテンシについては、TCPであればSQLでもNoSQLでも同じだけかかります。

SQLだと、このような簡単なクエリでも、構文解析を行うためNoSQLより遅くなります。しかし、Transactdには構文解析はありません。同じネットワークであれば他のNoSQLとほとんど同じ速度です。

これを確かめるために、実際にmemcached 1.4.5と比較してみました。1つ約100バイトのレコードを2万件用意して、1レコードずつ20,000回の読み取りにかかる時間を計測しました。同じサーバーマシン、同じクライアントマシンで、クライアントはすべてPHP5.5で比較しています。

[MYSQL(PDO)]
7.3037860393524 sec
2738.3058447004 records/sec

[memcached]
5.2722289562225 sec
3793.4619619269 records/sec

[Transactd]
5.6204497814178 sec
3558.4340716153 records/sec
[TEST Environment Spec]
Server    : HP Proliant DL165G7 AMD Opteron(TM) Processor 6212 / 10GB RAM Intel 1Gbps NIC
Client    : HP Elite book 9470 Core i5 8GB RAM Intel 1Gbps NIC
MySQL     : 5.6.14 64Bit
Transactd : 2.0.0 64Bit
memcached : 1.4.5 64Bit

memcachedが少し速い結果になりましたが、Transactdと大きな差はありません。innodbは十分に高速ですね。Transactdを使えば、他のNoSQLに変更しても応答速度は大きく変わりません。
(「ホントにmemcachedと変わんないの?」と思う方は多いでしょう。TranasctdをネットワークレイテンシのないWindowsローカルで動作させると上の条件で1秒くらいになります。ネットワークの往復2万回で4秒ほどかかっている計算です。これはmemcachedも同じですからほとんど差がないのです。)

クエリが遅い

クエリが遅いのは、サーバー側で大量のレコードを読み取ってしまったり、大量のデータを保持しソートするなどしているからです。適切なindexを使って限られた範囲を読み取れば、(クライアント数の増大など以外では)予想外のパフォーマンスになってしまうことはありません。もう一度クエリを見直してみましょう。

大切なのは、どのindexを使ってどこからどの範囲を読み取るのか、その範囲にはおよそどの程度のレコードがあるのかを、将来も含めて想定することです。
SQLだけで操作してきた場合は、これらを想定するのは難しいように思えるかもしれません。しかし、これが出来ないと、他のNoSQLを使用したプログラミングはもっと難しくなります。

NoSQLではkey/valueでデータを格納していきます。シンプルでいいのですが、たとえばvalueでの絞り込みや検索を行う際に、すべてのデータを取得して探したのでは、とても遅くなってしまいます。これを回避するにはIndexテーブルなどを作成して、事前にどこにデータがあるかkeyを保存する処理などが必要になります。

実はこの「Indexテーブルなどの作成」は、先ほど強調した大切な事項とほぼ同じことを言っています。遅いクエリを書いてしまうのは、indexを使ったアクセスと読み取り範囲を想定できていないからであり、たとえNoSQLでもそこから逃れることはできないのです。

よりよいクエリを書くための提案があります。TransactdのQueryBuilderを使ってクエリを作成してみてください。QueryBuilderでは、どのインデックスを使用してどの値から読み取るのかを最初に決めなくてはなりません。同時にconditionsで終わりの条件も指定します。もし、どちらも指定しなければ、そのテーブルの全レコードを検索します。

「縛りがきついな」と思われた方、オプティマイザの夢物語はそこまでにしましょう。MySQLオプティマイザが決める実行計画も、何ひとつ変わらない全く同じ縛りの中で決定されています。

その他

その他にパフォーマンスで大切なことは、データがキャッシュに載っているかです。ディスクへの物理アクセスはとても低速です。サーバーに十分なメモリを搭載し、innodb_buffer_pool_sizeに大きなメモリを割り当てましょう。

NICは1Gbps以上のものを使用し、ドライバーのRSS(recieve side scaling)を有効にしてCPU負荷を分散させます。MySQLはCPU負荷が高いのでなるべく高速でコアの多いものを使います。

この辺はMySQLに限ったことではありません。NoSQLでもほとんど同じです。

まとめ

いかがでしたでしょうか?NoSQLに移行したからといって「遅い」が簡単には改善しないことがお分りいただけたと思います。Transactdであれば、MySQLのまま部分的に置き換えて高速化することもできます。

また、クエリの見直しには是非QueryBuilderを使ってみてください。作りながら実行できるので、すぐにindexを使ったアクセスの仕方と大切さを実感できます。理解が進めばパフォーマンスの良いSQLが自然に書けるようになるかと思います。教育や学習ツールにもなりますね!

最後に、関連記事を貼っておきます。

Transactd 2.0 その2 QueryBuilder で簡単NoSQLクエリー - BizStationブログ


Transactd 2.0 その3 データベーススケーリング - BizStationブログ

Transactd 2.0 その3 データベーススケーリング

ほとんどのNoSQLは、容易なスケーリングと、アクセス頻度の高い処理の高速化を目的として使われます。今回はTransactd 2.0について、スケーリングがどのように実現されるか書きたいと思います。

1. なぜNoSQLか?(SQLの欠点)

テーブル間の依存

スケールアウトは、データを分割し別のサーバーに移動することで負荷を分散させます。SQLの場合、特にJOINは、サーバー内でデータを連結するため、あるテーブルだけ外部に移動するといったことができません。この欠点を回避するために、SQLにも関わらずJOINを禁止するといった使い方をするところもあるようです。

CPU負荷が高い

SQLは文字列として送信され、サーバーで解析され実行されます。そのため、構文解析や、一時的なデータ保持、ソート、演算など多くのCPUを消費します。

2. SQLでのボトルネックは何か?

サーバーボトルネック

データベースへのアクセスが増え、負荷が増大していった場合に、具体的に何がボトルネックになるのでしょうか?仮にすべてのデータがキャッシュに載っているとしたら、ボトルネックになるのは、ネットワークでしょうか?それともCPUでしょうか?

まず、一般的にDBサーバーが送受信するパケットは比較的小さなものが多く、巨大な結果受け取るような処理をしない限り、現在の標準的な1GBのネットワーク帯域を使い果たすことはほとんどありません。これは、NICとドライバーによって処理できるPPS(packet/sec)の上限の方がボトルネックになるからです。PPSは理論上、帯域をパケットサイズで割った数ですが、実際には、特に小さなパケットで理論値よりだいぶ少なくなります。(正確なデータではありませんが、1GBpsのNICでも256Byte以下のパケットでは頑張っても往復で30万pps位な感じがあります。理論上は256Byteで96万ppsです。 (1Gbps x 2 /(256 x 8))

実際に当社で負荷テストを行った結果ですが、複数台のクライアントから、スレッドを増やしながら連続したリクエストを送信しつづけると、リクエスト数とともにサーバーのCPU使用率が増え、やがてほぼ100%に達します。このテストはSQLの中でもCPU負荷の小さな1レコードのreadです。
下図はその時の結果のグラフです。
f:id:bizstation:20141001205728p:plain
横軸はリクエストを送り続けるクライアント数で、縦軸は1秒間に処理できたリクエスト数です。
ともに、CPU使用率がほぼ100%に達し、リクエストを送るクライアントを増やしても、1秒あたりの処理数がほとんどが増加しなくなりました。

TransactdとMySQLの比較では絶対的な処理能力の差が1.4倍程になっています。このテストはSQLとTransactdでのCPU負荷の差が最も小さい単純なreadでの比較です。JoinやOrderByといった処理が加わるとさらに開きは大きくなっていきます。

少し横道にそれますが、グラフでリクエストに応じて処理数が直線的に増えている間は、処理の遅延はありません。徐々に遅延が始まり、傾きが急に水平に近くなったところから極端に遅くなり始めます。(リクエスト増に対して一定数の応答しか返せないので順番待ちになるから。)

クライアントボトルネック

例えば、ネットワーク越しの1台のクライアントから、1レコードのreadを連続して行って、MySQLサーバーの処理限界に到達させることができるでしょうか?それともクライアント側の何かがボトルネックとなって頭打ちになるのでしょうか? (クライアントとMySQLサーバーは1本のネットワークケーブルでつながれています。)

当社の実験では、クライアント側で非常に多くのスレッドを立ててアクセスしましたが、クライアントもサーバーもCPUを使い果たすことはないにも関わらず、処理数はある程度で止まってしまいました。クライアントではCPU全体を使い果たしていませんが、最初のコアのみ100%近くになっています。サーバー側ではRSS(Receive Side Scaling)によって受信とその応答送信が複数のコアに分散されますが、接続を開始する側では分散されず、送受信が1コアに集中してボトルネックになっているようです。

しかし、実際の運用では連続してDBサーバーにアクセスし続けることはなく、その他の処理も行いますので、これが問題になることはあまりないように思います。

SQLでのボトルネック複数のクライアントアクセス構成でサーバー側CPUで起きる事が多いと言えるかと思います。

Transactdで垂直スケーリング

垂直スケーリングは、1台のサーバーでの処理能力を高める「スケールアップ」です。

Transactdでのサーバー処理は、SQLのようなCPUを必要とする構文解析はありません。また、データアクセス以外のJoinやOrderByといった処理はクライアント側で行われるため、CPU負荷がクライアントに移動します。これらによって、サーバーのCPU負荷を大幅に削減できます。
SQLからTransactdに置き換えることで、確実により多くの処理を行えるようになります。

Transactdで水平スケーリング

水平スケーリングは、データを別サーバーへ移動させる「スケールアウト」です。

スケールアウトは、CPU負荷の削減とデータサイズの削減によって、よりキャッシュ溢れを防止できます。移動させるデータの分割方法は色々あります。ここでは、一番単純なテーブル単位のスケールアウトとidによる水平分割の方法を説明します。

TransactdのJoinはクライアント側で行うため、テーブルのロケーションはどこでも同じようにできます。これを容易にするためIDatabaseManagerインターフェースが導入されています。

テーブルの移動とコード変更

IDatabaseManagerは内部に複数のデータベースを保持し、useで事前にデータベースのURIを指定します。使い終わったら unUseを呼び出して返却します。
また同時に、IDatabaseManagerインターフェース自体がDatabaseオブジェクトと同様のインタフェースを持つことで、既存のAPIに対して1つのデータベースであるかのように振る舞います。JoinはActiveTableを使って行いますが、その使い方を示します。

    $cp1 = new bzs\connectParams('tdap://localhost/querytest?dbfile=test.bdf');
    $db->c_use($cp1);
    $at1 = new bzs\activeTable($db, 'user');
    ...
    $db->unUse();

上記のtdap://localhost/querytest?dbfile=test.bdfがサーバーを指定しているURIです。ここを別のものに書き換えるだけでテーブルを移動しても全く同じように動作します。(PHPではuse予約語のためc_useメソッドになっています。)

分散クエリーを予定しているのであれば、下記のようにURIを返す関数を1つ作れば集中管理できます。

function getDatabaseUriParam($tableName)
{
    $dbhost = '192.168.0.15';
    if ($tableName === 'user') 
         $dbhost = '192.168.0.16';
    return new bzs\connectParams('tdap://' + $dbhost + '/querytest?dbfile=test.bdf');
}

...
$tableName = 'user';
$db->c_use(getDatabaseUriParam($tableName));//<-- 関数によりURIを取得
$at1 = new bzs\activeTable($db, $tableName);
...
$db->unUse();
idによるテーブルの水平分割

テーブルの水平分割も基本はテーブルの移動と同様ですが、どのようなルールで分割するか基準を決めURIを返す関数にその情報も与えます。例えばidの範囲が1~100万のデータがサーバーA、それ以上がBにあるとすると、先ほどのgetDatabaseUriParam関数を少し変更し、呼び出しにidの値を加え以下のようにできます。

function getDatabaseUriParam($tableName, $id)
{
    $dbhost = '192.168.0.15';
    if ($tableName === 'user') 
    {
         if ($id <= 1000000)
             $dbhost = '192.168.0.16';
         else
             $dbhost = '192.168.0.17';
    }
    return 'tdap://' + $dbhost + '/querytest?dbfile=test.bdf';
}
...
$tableName = 'user';
$db->c_use(getDatabaseUriParam($tableName, $id));//<-- 関数によりURIを取得
$at1 = new bzs\activeTable($db, $tableName);
...
$db->unUse();

これはとても簡単な例です。Joinを伴った場合は少し複雑になりますので、また別の機会にしたいと思います。

分散トランザクション

前述の useは同時に2つ以上を指定することができます。この状態でトランザクションを開始すると、利用中のすべてのデータベースに対してトランザクション開始の呼び出しが行われます。2つ以上のデータベースにまたがる更新処理でXAトランザクションのような処理が可能です。
(現在のバージョンでは完全なXAではありません。コミット処理で、先頭以外のデータベースで失敗した場合、最初にコミットできたトランザクションロールバックされません。)
コード上では、データベースが1つでも2つでも全く変わりありません。

$db->beginTrn();//<-- 使用中のすべてのデータベースで開始
...
$db->endTrn();//<-- 使用中のすべてのトランザクションをコミット
$db->unUse();//<-- すべてのデータベースを解放

まとめ

スケーリングは、NoSQLの大切な目的の1つです。TransactdはSQLと同じ処理ができるだけでなく、サーバー1台ごとの処理能力を高め、またほんの少しの変更や準備で容易にスケールアウトができることがお分りいただけたかと思います。使い慣れたMySQLで、高速でスケールしやすいTransactdを是非皆さんも試してみてください。