ゲーム開発におけるRDBMSの役割
RDBMS(Relational DataBase Management System)は業務システム(以下、業務系)で多くの実績を作ってきました。一方でゲーム分野(以下、ゲーム系)では、RDBMSはほとんど使われていませんでした。ネットゲームではなく、スタンドアローンのゲームが中心だったので、プレーンなテキストやバイナリ形式でデータを保存していたからです(スタンドアローン環境向けのSQLiteもまだ普及していませんでした) 。しかし、ネットゲームの流行に伴い、現在ではゲーム系でもLAMP環境注1 で開発されることが多くなっています。ゲーム系の開発でRDBMSが使われ始めたころは、O/Rマッパー(Object Relational Mapper。以下、ORM)が広く使われていましたから、最初からORMを利用して手続き型(オブジェクト指向)言語の感覚でRDBMSをストレージのように使うことができました。そのためRDBMSを深い理解をしないまま使っているエンジニアも多いのではないでしょうか。
話が変わりますが、以前『Software Design』誌で連載していた「RDB性能トラブルバスターズ奮闘記」を再構成した『SQLの苦手を克服する本』 という書籍が2019年8月に発刊されます。この連載や書籍では業務系でのトラブルを中心に解説しましたので、ゲーム系で重要な事柄について言及していないこともあります。そのため本稿ではゲーム系のエンジニアがRDBMSについて勘違いしていることが多いポイントについて、MySQLを中心に解説します注2 。Software Designの連載でもそうでしたが、本稿は概念的に理解すること、それが目的ですので、詳しいコマンドの解説は省いています。また、業務系やWebサービスを作られているエンジニアも対象にしています。
本稿のサンプルはMySQL 8.0.15を利用しました。サンプルソースのデータは、MySQLのサンプルデータベース注3 world databaseを使います(件数などはデータを取得した時期によって前後する可能性があるのでご了承ください) 。
I/Oの単位
SQLは抽象度が非常に高いため、RDBMSの低レイヤの部分は隠蔽されていて、その深い部分のしくみを知らなくても使えます。しかし、ゲーム系のエンジニアは低レイヤの仕事をこなすことが多いので、本稿ではそうした部分から解説します。
抽象度が高いSQLをさらにORMでラップする場合を想定してみましょう。このとき、コード上はレコード単位で処理されているように感じるかもしれません。しかし、実際には、RDBMSはページまたはブロックと呼ばれる決まった大きさ(以下、ページ)の単位ごとにデータを読み書きします。RDBMSのページのイメージは図1 のようになっています。
図1 RDBMSのページのイメージ
この図を見ればわかると思いますが、RDBMSのI/Oの最小単位はレコード単位ではなくページ単位なのです。なお、MySQLの場合、デフォルトのページサイズは16KBとなっているうえ、ほとんどのシステムでデフォルトのページサイズのままMySQLが使われているようです。
もちろん、Oracle Database(以降、Oracle)などテーブルスペース(表領域)ごとにページサイズを変更できるRDBMSはあります。しかし、MySQLの場合、インスタンスを作りなおさなければページサイズも変更できません。そのため、Amazon Relational Database Service(以下、RDS)のようなインスタンスを共有するサービスでMySQLを利用する場合、ページサイズを変更することはできません。
いずれにしても、主記憶装置がHDDである場合、このような「可変長のレコードをいかに効率よく扱うか」という課題を解決するために作られたのが、ページ単位のI/Oという処理構造なのです。
本稿とは直接関係のない話ですが、HDDの存在を意識する必要のないインメモリDB注4 ではレコード単位でI/Oができます。このインメモリDBのI/Oは、キャッシュヒット率が99%もあるようなRDBMSのI/Oよりも10倍以上速くなるということが多いです。その理由はメモリのI/Oが高速だからということではありません。RDBMSのキャッシュヒット率が99%であるなら、HDDのI/O速度は残りの1%だけなので、速度差にはほとんど関係ないはずです。本当の理由は、インメモリDBのI/O単位はレコード単位であるため、ページ単位でI/Oが起こるRDBMSに比べ、無駄なI/Oが軽減されるということにあります。
MyISAMについて
MySQLでは、テーブルごとにストレージエンジンを切り替えることができます。デフォルトはInnoDBですが、MyISAMというISAM形式のストレージエンジンも利用されることが多いです。この、ISAM(Indexed Sequential Access Method)という形式は、COBOL時代から使われている古典的なデータ保存形式です。ISAM形式では、ページ単位でデータが保存されるInnoDBと違って、固定長のシーケンシャルファイルとしてデータが保存されます。MyISAMでは可変長のレコードも扱えるように拡張はされていますが、レコード長を超えたデータは、別のエリアに保存するという方式になっています。つまり、レコードのフラグメンテーションが起きるのです。
また、MyISAMの更新処理には、
トランザクション処理がない
テーブルロックがかかってしまう
などといった制約があるため、ゲーム系のような、マルチユーザ環境で激しく更新処理が行われるシステムへの利用は困難です。しかし、更新が行われないマスタデータに利用すると高速化できる可能性があります。MyISAMを利用するときは、構造をよく理解して利用してください。
プライマリーキー(クラスタードインデックス)
MySQLでは、プライマリーキーはデフォルトで図2 のようなクラスタードインデックスという形式で格納されます。
図2 クラスタードインデックス
クラスタードインデックスにおいては、最上層の(1)はルートと呼ばれます。中間の(2)はブランチと呼ばれます。図2では1段しかありませんが、データが増えれば複数段になります。また、( 3)の最下層はリーフと呼ばれます。クラスタードインデックスではこの(3)の部分が実データになっています。
リスト1 のSQLのように、WHERE句にプライマリーキーを指定してデータを1件だけ読み取るときを考えてみましょう。
リスト1 プライマリーキーを指定
SELECT * FROM city WHERE ID = 1532;
仮にレコード長が30Byteしかない場合であっても、クラスタードインデックスでは少なくとも(1) 、( 2) 、( 3)の3ページ(16KB×3=48KB)ぶん読み込む必要があります。
クラスタードインデックスは実データがキーの順番に並んで保存されます。それを利用したお勧めのテーブル設計があります(ただし、コーディング量は増えます) 。
それは、「 プライマリーキーに複合キーを指定する」というものです。あくまで筆者は「プライマリーキーに複合キー注5 やナチュラルキー注6 を使わないほうが良い」という立場ですが、ゲーム系でMySQLを利用するなら、あえて複合キーにするほうがI/O処理が効率的になる可能性が高いです。たとえば、ユーザがキャラクターを複数所持していることを想定するとリスト2 のようなテーブルとプライマリーキーになります。
リスト2 プライマリーキーに複合キーを指定するテーブル設計の例
CREATE TABLE user_characters(
user_id int NOT NULL DEFAULT 0 COMMENT 'ユーザID'
, user_character_id int NOT NULL DEFAULT 0 COMMENT 'ユーザキャラクターID'
, character_id int NOT NULL DEFAULT 1 COMMENT 'キャラクターID'
-- …(中略)…
, PRIMARY KEY CLUSTERED(user_id, user_character_id )
) COMMENT = 'ユーザ所有キャラクター';
こちらの例では、ユーザIDをプライマリーキーの最初に配置することで、ユーザID順に並んで保存されます。そのため、user_charactersのユーザの情報は、同じページに配置される可能性が高くなり(図3上 ) 、I/Oが軽減されるというわけです。
図3 プライマリーキーを複合キーにした場合とサロゲートキーにした場合の違い
逆に、user_charactersテーブルにAUTO_INCREMENTを指定したサロゲートキーを利用したり、キャラクターIDをユーザIDより先にプライマリーキーとして配置したりするときを考えてみましょう。こういった場合、特定のユーザが10個のキャラクターを所持していると、user_charactersのデータは10ページに分散されて保存される可能性が高くなります(図3下 ) 。すなわち、ユーザIDをプライマリーキーの先頭に配置したときと比べると、所持キャラクターを取得する処理で数倍以上の負荷がかかることになります。
I/O性能を考えるのであれば、同時に使うことが多いデータはできる限り同じページに保存されるように工夫しましょう。
B-Treeインデックス
MySQLにおいて、特定のカラム値のある行をすばやく見つけるためのしくみとして、B-Treeというインデックスがあります。この構造は図4 のようになっています。
図4 B-Treeインデックス
図4 の3段階のうち、一番上の階層はルート、中間階層はブランチ(データが増えれば複数段になります) 、最下階層はリーフと呼ばれます。B-Treeインデックスも、ルートからリーフに至るまでページ単位で保存されています。
通常のインデックスでは、どのページに実データがあるかという、キーとアドレスの組み合わせが保存されています。ここで、B-Treeインデックスにおけるデータを探索する処理について見てみましょう。リスト3 のSQLは、図4 の(1)から順に読み取っていき、「 CountryCode = 'JPN'」に該当する実データを探す(Non-Unique Key Lookup)という処理になります。
リスト3 CountryCode = 'JPN'という実データを探すSQL文
SELECT * FROM city WHERE CountryCode = 'JPN';
これは、Javaのようなソースコードで表現すると、リスト4 のような処理になります。
リスト4 CountryCode = 'JPN'という実データを探す手続き型のソースコード
// cityテーブルのIndexの中のCountryCodeという名前のインデックスの意味
rowKeys = city.Indices.CountryCode.getNonUniqueKeys("JPN");
for(RowID rowID:rowKeys){
retRows.add(city.getRow(rowID));
}
return retRows;
図4 はあくまでもイメージではありますが、少なくとも6ページ(16KB×6=96KB)読んでいることになります。インデックスを使うと、リスト3 のような処理では全データを読み取るよりI/O処理は少なくなります。しかし、検索条件によっては全件を読むほうが、インデックスを使うよりI/O処理が少なくなるということも起こり得ます。これについては第3回で具体的に解説します。
DBサーバの更新処理
ネットゲームのようなシステムでは高頻度な更新処理が起こり得ます。そのため、この解説をとおして、RDBMSの更新処理についても理解を深めておきましょう。
RDBMSの更新処理は図5 の流れで行われます。
図5 RDBMSの更新処理の流れ
RDBMSにおいては、メモリ上のデータとログファイルへの書き込みが完了すれば更新処理のコミット完了となります。これまでの解説のとおり、HDD上のデータはページ単位で保存されていて、ランダムに分散しています。ログファイルへの書き込みはシーケンシャルライトですので、比較的高速に終わります。しかし、HDDのランダムライトは非常に遅いため遅延書き込みする方式になっています。つまり、HDD上の実データは遅延書き込みされるため、運用中は一貫したデータがHDD上には存在しないということになります。そこで、実際の運用上はHDDのデータとメモリ上のデータを合わせて、最新の一貫したデータとしています。
ログファイルについて
ログファイルは障害発生時やレプリケーションを行う際に利用されます。
たとえば、運用中に実行プロセスが止まってしまったときを考えてみましょう。メモリ上にあってHDDに書き込みされていないページ(以降、ダーティページ)は当然消えてしまいます。しかし、RDBMSでは、図6 のように再起動時に自動的にログファイルからダーティページの書き込み処理が走るようになっています。
図6 ダーティページの書き込み処理
これらの機能により、HDDに遅延書き込みを行っていても、RDBMSにコミットしたデータは保証されているわけです(これに関しては、第2回で詳しく解説します) 。
障害対応
ゲーム系エンジニア向けの解説を執筆するために、いくつかのスマホゲームをインストールし、プレイしてみました。さらに、多くのユーザレビューを読んでみたのですが、その中に、「 障害でデータがロールバックされてしまった。私の引いたSSR(Super Special Rare)カードを返せ!」というものがいくつか見られました。データベースについて、ゲーム会社の方にお話をうかがうと、AWS(Amazon Web Services)などを利用することが多く、簡単にスナップショットを取ることができるので、数時間〜1日に1回スナップショットを取ってバックアップとしている、とのことでした。つまり、もしバックアップから戻す必要があるような障害が起きれば、数時間〜1日分はデータが失われてしまうということになります。これはユーザレビューにあったクレームと状況が一致しますから、そういう事件がいろんなゲームタイトルで起きていることが想像されます。
しかし、「 バックアップファイルとそれ以降のログファイルがすべて存在すれば」 、データは最後にコミットされた時点まで戻すことができます。
たとえば、筆者は金融会社のデータベースを担当していたこともありますが、その中には、磁気テープに書き込んでいるため、年に1回程度しかバックアップを取れないというシステムもありました。それでも金融というクリティカルなシステムが運用されていたのは、別のファイルサーバに保存しているログファイルと磁気テープに保存しているバックアップファイルでログファイルを管理していたからです。そういったシステムでは、図7 のように、年に1回のバックアップファイルと、1年分のログファイルの2つを適用することによって障害の直前にコミットした状態まで戻すことを保証していました。
図7 金融システムのデータベース運用
もちろん、大量のログファイルから最新データへ戻すのは時間がかかりますから、バックアップの頻度が高いに越したことはありません。しかし、どんなにバックアップの頻度が高くても、バックアップファイルとログファイルの管理は必要になります。また、復旧作業はめったにやらない作業でありながら、実際に行うときには非常に緊急性が高い作業になります。ですから、事前に手順の確認作業を行い、担当者が代わるたびに手順の確認と予行演習を行うようにしましょう。