パフォーマンス比較 Cassandra、Mongodb、SQLite、H2、MySQL、Postgres
下記のようなシステムでパフォーマンスが良さげな SQLite を使用予定ですが、もっと速いものが無いか確認のため他のデータベースのパフォーマンスを計測してみました。SQL 利用前提ですが、NoSQL が圧倒的な性能を出す場合は検討する必要があるので KVS も確認しました。
- データ件数は 1 億件程度、JDBC SQL 利用可能
- INSERT、UPDATE はバッチ
- SELECT は主キーアクセス性能を重視
- 将来スケールアウトのための分散はありえるが、スタンドアロンで遅いのはだめ
データベースのパフォーマンス比較
計測したデータベース
データベース名 | タイプ | 形態 | 評判 | 計測についての備考 |
---|---|---|---|---|
SQLite | RDB | 組み込み ※2 | おもちゃ、Android標準 | JDBC操作 ※1 |
H2 | RDB | 組み込み ※2 | 組み込み最速 | JDBC操作 ※1 |
Derby | RDB | 組み込み ※2 | Java標準で付属 | JDBC操作 ※1 |
EHCache | KVS | 組み込み ※2 | OSCacheと2大巨頭 | 1万件ごとにディスク書き込み設定 |
Redis(Jedis) | KVS | サーバー ※4 | 爆速 | Jedis API 操作 |
Mongodb | KVS | サーバー ※4 | 高パフォーマンス | Mongodb 標準 API 操作 ※3 |
Cassandra | KVS | サーバー | 大手導入事例多数、廃止も多数 | JDBC操作 (AutoCommit のみ可能) |
MySQL(MyISAM) | RDB | サーバー | サーバ型非トランザクションRDB最速 | JDBC操作 ※1 |
MySQL(InnoDB) | RDB | サーバー | サーバ型トランザクションRDB最速 | JDBC操作 ※1 |
PostgreSQL | RDB | サーバー | MySQLと異なりGPLじゃない | JDBC操作 ※1 |
※1 JDBC 操作に関して INSERT は 1 万件ごとにコミット、SELECT は主キー指定で 1 件ずつ全件取得
※2 組み込みモード (ローカルファイル永続化) のみ計測、インメモリモードやサーバーモードは未計測
※3 全 INSERT 後の ensureIndex によるインデックス作成時間が計測結果に含まれる
※4 遅延書き込み (非同期書き込み) *1
計測結果
環境 CPU Core2 Duo 2GH 2GHz*2、メモリ 4GB、Windows XP 32bit*3、HDD (TOSHIBA MK8052GSX)*4、しょぼめのノートパソコン
データ 主キー:数値、値:文字列、レコード長:約 200 byte
スレッド数 1
線形的増加と指数関数的増加、臨界点
データベースの処理数に対する処理時間は、上記結果の MySQL に見られるような線形的増加 (リニア、直線的) に増加するパターンと、Cassandra のように指数関数的 (雪ダルマ式) に増大するパターンがあります。また、線形的増加から指数関数的増加に移行する臨界点や動作不能になる臨界点がある場合が多いです。1 万件での処理時間はこうだから 1 億件の予想処理時間はその 1 万倍というような情報をたまに見かけますが、件数が増大するほど線形的増加ではないほうが多いと思います。
SQLite が予想以上に他を圧倒し高速
SQLite が INSERT、SELECT とも予想以上に他を圧倒し高速でした。計測結果に示すとおり 1 億件程度のデータ (データファイルサイズは 1.8 GB でした) なら大丈夫そうです。H2 のサイトに H2 が最速とするパフォーマンス比較 (H2) がありますが SQLite はトランザクションがテストされていないという理由で比較されていません。ちなみに実際には SQLite はトランザクションをサポートしていて、テストに関しても SQLite 本体コード 6万7000行 に対しテストコードは 4567万8000行 (publickey) だそうです。古い情報ですが、SQLiteを使うべき10の理由と5つのデメリット (CAP-LAB テクニカル) が色々参考になります。
SSD で試してみたところ、1 億件 INSERT 647秒、1 億回 SELECT 383秒 でした。さすがに速い。SSD 環境は CPU i5 2.4GH、メモリ 8GB、Windows 7 64bit*5、SSD (TOSHIBA THNSNC128GMMJ)*6、これもノートパソコンです。
件数が増えると EHCache が想定以上に遅い
EHCache は 1 万件取得で最速ですが、件数が増えると組み込み RDB の SQLite や H2 より遅いのは良い?にしても、クライアント・サーバー型 KVS の Mongodb や Redis より総合的に少し遅いのは予想外でした。overflowToDisk の設定にもよると思いますが、知らずに H2 や Mongodb のキャッシュとして EHCache を使ってまいそうです。すべてディスクに永続化するように設定していたのですが、100万件テストでは登録したデータを取得しようとしたときに欠落している場合があるため計測しませんでした。ところでロゴを見て気づいたのですが EHCache が回文になっているのを今初めて知りました。
Mongodb がクライアント・サーバー型としては登録性能に優れている
最速の SQLite と比較すると登録は 6 倍かかり、取得は 20 倍かかりますが、クライアント・サーバー型としては最も登録性能に優れています。ただし、分散環境ではデータがときどき消える、マルチコアでスケールしない (InfoQ)、などに注意する必要があります。もちろん、これらの問題は将来解消されるかもしれません。
NoSQL、KVS が終焉
NoSQL が終焉したと言われて久しいですが、Google のインフラ基盤や Facebook の HBase (Hadoop) で HBase (Hadoop データベース)*7 がバリバリ使われていて、Mixi では永続化機能付き memcached とも言える TokyoTyrant が使用され、DeNA では MySQL に HandlerSocket を組み込み NoSQL を実現しています。必要な場面があるので今後も NoSQL や KVS が無くなることはなく、逆に RDB も衰退することはありません。Twitter は今でも MySQL + memcached ですし、GAE では MySQL も用意されています。また、NoSQL に対して SQL が遅いというアンチテーゼとして、Postgres の設計者による分散インメモリ RDB の VoltDB が登場しています。
業務システムから見ると SQL はそのままでスケールアウトしたいという要望に単純な NoSQL や KVS は合致しません。元々 RDB がスケールアウトによりリニアにスケールしない問題や耐障害性を解決するために Google の BigTable などにインスパイアされ登場したのが分散 KVS ですが、現在は RDB に NoSQL や KVS の要素が取り込まれています。例えば、MySQL 5.6 では memcached や HandlerSoclet などの概念が取り込まれ SQL なしで高速アクセス可能になっています。また MySQL Cluster や Oracle Exadata は分散 KVS と同じようにリニアにスケールアウト可能になっており、Postgres も 32 コア CPU 対応などスケールアップ可能になっています。
追加 2012/10/19
計測結果についての補足
計測結果はあくまでも私が必要な環境と条件の計測結果であり、id:matsumoto_r さんがおっしゃられているとおりデータベースそのものの優劣を示しているものではありません。並行アクセスしたらとか、設定変えたらとか、たくさんご意見いただきましたが、もちろん、負荷条件を変えたりそれぞれ環境に合わせてチューニングすれば結果は変わると思います。今回の想定システムでは並行アクセスはほぼ発生せず意味がないのでマルチスレッドでの計測はせず、シングルスレッドでのデータ永続化の基本性能を確認するために実施しました。並行処理が得意(前提)とされるデータベースをシングル構成で計測に追加したのは差を確認するためです。並行性能の情報はたくさん公開されていると思いますが、ご自分の環境に合う方法で計測していただければと思います。
なお、ソフトウェアやドライバはすべて最新安定版、設定はデフォルトです。ただし、全データのディスク同期が前提の計測であるため、EHCache はインメモリではなくディスク永続化モードです。ちなみに SQLite はもう少し触ってみたところ Beta 版にすると 20% 高速化、非同期モードにするとさらに 10% 高速化しました。
計測になぜ Oracle が含まれてないの?
私は臆病者なので良い結果を出さないと Oracle に怒られる気がするからです。OTN ライセンスに下記の条項が含まれています。
オラクルの事前承諾なく、プログラムのベンチマークテストの結果を開示すること。
OTN開発者ライセンス
蛇足ですが、DB2 は設定を完璧にして最新パッチをあてて最高性能を出さないと怒られるかもしれません。
(A) ベンチマーク・テストで使用した方法 (例えば、ハードウェアおよびソフトウェアのセットアップ、導入手順および構成ファイル) を公開し、
SLA - L-JWOG-6K4JSQ
(B) 「プログラム」に対する IBM または IBM 製品を提供する第三者 (以下「第三者」といいます。) から提供される最新の適用可能な更新、パッチ、修正が適用された所定の稼働環境で 「プログラム」を実行してベンチマーク・テストを行い
(C) 「プログラム」の資料ならびに IBM がサポートする「プログラム」用の Web サイトで提供されているすべてのパフォーマンス・チューニングおよび最良の方法に従うこと
計測ソース*8
今回の計測対象のメインとなる JDBC のソースはこちらになります。INSERT のあとの SELECT なのでキャッシュ云々の話もありますが、それも含めて各データソースに対して同じ操作をしています。SQLite のみ Class.forName しているのはドライバがサービスプロバイダーフレームワークに対応していないためです。(bitbucket リポジトリの最新ソースでは 2012/09 対応)
package test; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import org.junit.After; import org.junit.Test; public class JdbcTest { @Test public void sqlite() throws Exception { Class.forName("org.sqlite.JDBC"); con = DriverManager.getConnection("jdbc:sqlite:test.sqlite3"); Statement st = con.createStatement(); executeUpdate(st, "drop table if exists person"); executeUpdate(st, "create table person (id integer primary key, name string)"); executeQuery(); } @Test public void h2() throws Exception { con = DriverManager.getConnection("jdbc:h2:testh2", "sa", ""); Statement st = con.createStatement(); executeUpdate(st, "drop table if exists person"); executeUpdate(st, "create table person (id integer primary key, name varchar)"); executeQuery(); } @Test public void derby() throws Exception { con = DriverManager.getConnection("jdbc:derby:derby;create=true"); Statement st = con.createStatement(); executeUpdate(st, "drop table person"); executeUpdate(st, "create table person (id int primary key, name varchar(200))"); executeQuery(); } @Test public void mysql_myisam() throws Exception { mysql("MyISAM"); } @Test public void mysql_innodb() throws Exception { mysql("InnoDB"); } private void mysql(String engine) throws Exception { con = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", ""); Statement st = con.createStatement(); executeUpdate(st, "drop table if exists person"); executeUpdate(st, "create table person (id integer primary key, name varchar(200)) engine = " + engine); executeQuery(con.getMetaData().getDatabaseProductName() + "(" + engine + ")"); } @Test public void postgres() throws Exception { con = DriverManager.getConnection("jdbc:postgresql:postgres", "postgres", "postgres"); Statement st = con.createStatement(); executeUpdate(st, "drop table if exists person"); executeUpdate(st, "create table person (id integer primary key, name varchar)"); executeQuery(); } @Test public void cassandra() throws Exception { con = DriverManager.getConnection("jdbc:cassandra://localhost:9160/test"); Statement st = con.createStatement(); executeUpdate(st, "drop table person"); executeUpdate(st, "create table person (id int primary key, name text)"); executeQuery(); } // 共通メンバー ------------------------------------------- private Connection con; private static final int COUNT = 10000 * 10; private static final String DATA = "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"; @After public void after() throws Exception { if (con != null) { con.close(); } } private void executeUpdate(Statement st, String sql) { try { st.executeUpdate(sql); } catch (Exception e) { System.out.println(e.toString()); } } private void executeQuery() throws Exception { executeQuery(con.getMetaData().getDatabaseProductName()); } private void executeQuery(String databaseName) throws Exception { boolean isCassandra = databaseName.contains("Cassandra"); boolean isAutoCommit = isCassandra; System.out.printf("%-14s", databaseName); if (!isAutoCommit) { con.setAutoCommit(false); } long insertStart = System.currentTimeMillis(); PreparedStatement insertPs = con.prepareStatement("insert into person (id, name) values(?, '" + DATA + "')"); for (int i = 0; i < COUNT; i++) { insertPs.setInt(1, i); insertPs.executeUpdate(); if (!isAutoCommit && i % 10000 == 0) { con.commit(); } } if (!isAutoCommit) { con.commit(); } double insertSec = (double) (System.currentTimeMillis() - insertStart) / 1000; long selectStart = System.currentTimeMillis(); PreparedStatement selectPs = con.prepareStatement("select * from person where id = ?"); for (int i = 0; i < COUNT; i++) { selectPs.setInt(1, i); selectPs.executeQuery().next(); } double selectSec = (double) (System.currentTimeMillis() - selectStart) / 1000; String countSql = "select count(1) from person"; if (isCassandra) { countSql += " limit 100000000"; } ResultSet rs = con.createStatement().executeQuery(countSql); rs.next(); logProcessTime(rs.getInt(1), insertSec, selectSec); } private void logProcessTime(long count, double insertSec, double selectSec) { System.out.printf("%4d万件 ", count / 10000); System.out.printf("%7.2f秒 ", insertSec); System.out.printf("%7.2f秒", selectSec); System.out.println(); } }