ほとんどのNoSQLは、容易なスケーリングと、アクセス頻度の高い処理の高速化を目的として使われます。今回はTransactd 2.0について、スケーリングがどのように実現されるか書きたいと思います。
1. なぜNoSQLか?(SQLの欠点)
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です。
下図はその時の結果のグラフです。
横軸はリクエストを送り続けるクライアント数で、縦軸は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サーバーにアクセスし続けることはなく、その他の処理も行いますので、これが問題になることはあまりないように思います。
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();//<-- すべてのデータベースを解放