こんにちは、アプリケーション基盤チームの青木(@a_o_k_i_n_g)です。先日親知らずを抜歯した時、つらすぎたので MySQL の JOIN のことを考えて心の平静を保っていました。
サイボウズの製品のひとつである kintone はニーズに応じて自由に業務アプリのようなものを手軽に作ることができ、データの検索条件やソート条件も細かくカスタマイズ可能で、様々なレベルでのアクセス権も設定可能という非常に便利なツールです。
しかしその機能を支える裏側では複雑なクエリが発行され、MySQL に多大な負荷をかけています。サイボウズのクラウドには数十テラバイトに登る MySQL データがあり、数千万件オーダーのテーブルを複数 JOIN するクエリが毎秒のように実行されるという、エンジニア魂が滾る環境です。
現在サイボウズでは性能改善に力を入れており、僕もその業務に従事しています。例えば2018年7月の更新では kintone の Innodb_rows_read
を半減させており、その成果は記事の最後で紹介しています。
サイボウズの性能改善業で得た MySQL に関する知識知見をこの記事にまとめました。MySQL の性能に苦しむ皆様のお役に立てれば幸いです。
調査方法編
スロークエリログを読もう
まず第一にスロークエリログを読みましょう。
とは言っても上から順にただ読むのではなく、統計処理をして総合的に見てどのクエリがどのくらい遅いのかを可視化すると良いです。上から遅い順に改善していきましょう。
統計処理をする際は percona-toolkit の pt-query-digest が役に立つでしょう。
サイボウズでは、スロークエリログのパラメーターを潰して安全に扱える形式のログも用意しており、気軽にクエリや統計情報などを取得できるようになっています。また、読みやすさやいくつかの利便性の観点から統計情報を出すツールは自作しています。
スロークエリログにはクエリそのもの以外にもいくつかの項目があるので説明します。
- Query_time: クエリ実行時間
- Lock_time: ロックした時間
- Rows_sent: ヒットしたレコード数
- Rows_examined: スキャンしたレコード数
スロークエリに出ているログはクエリそのものや Query_time
に目を奪われがちですが、Rows_examined にも注目しましょう。当然ながらこの値が多ければ多いほどスキャンに時間がかかるので、クエリの改善でスキャン数を減らしましょう。Query_time
については、その瞬間に実行されている他のクエリなどに影響されるので水ものです。
なにはともあれ EXPLAIN
遅いクエリが判明したら EXPLAIN
しましょう。
EXPLAIN
結果の読み方は nippondanji こと Mikiya Okuno 氏の記事が最高です。
漢(オトコ)のコンピュータ道: MySQLのEXPLAINを徹底解説!!
上記記事を読めばもうそれだけでほぼ十分なのですが、いくつか注意点を記します。
まずひとつ目。EXPLAIN
の結果はあくまで 実行計画であって、実際に実行した結果ではありません。 そのため、EXPLAIN
上では高速そうでも実行すると遅かったり、あるいはその逆もあり得ます。
ふたつ目。MySQL のオプティマイザはあまり賢くありません。 そのため、適切なインデクスが使われていなかったり、JOIN するテーブルの順序が最適ではないということがしばしば起こります。そのような事が判明したら FORCE INDEX
したり STRAIGHT_JOIN
したりしましょう。
みっつ目。Extra 欄に Using temporary
が出たからといって遅いとは限りません。これは当該クエリが一時テーブルを使うことを意味していますが、一時テーブルを使うといってもオンメモリで済む場合もあるので、常に遅いとは言えません。オンメモリで済むかどうかは tmp_table_size
と max_heap_table_size
のしきい値で判断されるので、適宜調整しましょう。
最後に、Using filesort
について同じく Mikiya Okuno 氏の素晴らしい記事があるので熟読しましょう。
漢(オトコ)のコンピュータ道: Using filesort
MySQL の状態を知ろう
MySQL が持つ様々な状態は SHOW GLOBAL STATUS
で一覧することができます。とはいえ数百項目あり全てを見ることは大変ので、いくつかよく使う項目をリストアップします。
Variable_name | 意味 |
---|---|
Created_tmp_disk_tables | ストレージ上に作られた一時テーブルの数 |
Created_tmp_tables | メモリ上に作られた一時テーブルの数 |
Slow_queries | スロークエリの数 |
Bytes_sent | クライアントに送信したデータ量(バイト) |
Bytes_received | クライアントから受け取ったデータ量(バイト) |
Com_select | SELECT ステートメントが実行された回数。他の Com_xxx 系も同様で、例えば Com_insert は INSERT ステートメントの実行回数。 |
Handler_write | レコード挿入のリクエスト数 |
Innodb_data_read | 読み取られたデータ量(バイト) |
Innodb_data_written | 書き込まれたデータ量(バイト) |
Innodb_row_lock_time | 行ロック取得に要した合計時間(ミリ秒) |
全項目の仕様はこちら。
MySQL :: MySQL 5.7 Reference Manual :: 5.1.9 Server Status Variables
5.6 版ですが日本語版もあります。ざっと目を通しておくと良いでしょう。
これらの値を見たい時、サイボウズでは SHOW GLOBAL STATUS
クエリを発行せずとも Datadog 上で閲覧できるようになっています。過去の推移も閲覧できるので、同様のツールを導入しておくと非常に便利です。
こちらはとある環境の MySQL の Com_select
の推移を表示した例です。
MySQL の変数を知ろう
MySQL は SHOW GLOBAL VARIABLES
クエリで MySQL の変数一覧を知ることができます。
例えばソートに関する設定を見たい時、下記のようなクエリで閲覧可能です(※各値はローカル環境での適当なものです)。
mysql> SHOW GLOBAL VARIABLES LIKE '%sort%'; +--------------------------------+-----------+ | Variable_name | Value | +--------------------------------+-----------+ | innodb_disable_sort_file_cache | OFF | | innodb_ft_sort_pll_degree | 2 | | innodb_sort_buffer_size | 1048576 | | max_length_for_sort_data | 1024 | | max_sort_length | 1024 | | myisam_max_sort_file_size | 134217728 | | myisam_sort_buffer_size | 4194304 | | sort_buffer_size | 4194304 | +--------------------------------+-----------+ 8 rows in set (0.00 sec)
もちろん MySQL クライアント上から変数の書き換えは可能ですが、MySQL をシャットダウンするとその設定値は失われます。永続的に変更したい場合は my.cnf
を修正しましょう。
よくある使い方としては、一時的にクエリログを出したい時に general_log
変数を変更することが多いです。
SET GLOBAL general_log = 'ON'
その他多種多様にあるパラメーターも同様の方法で変更可能です。
一般的なチューニングの改善度合いとして、クエリの改善そのものは 100 倍や 1000 倍くらい高速化するケースがありますが、パラメーターの修正は数パーセント程度の改善にとどまる場合が多いです。
クエリのプロファイルを取ろう
MySQL には性能に関する情報を保持する performance_schema
というデータベースがあります。このデータベースを利用すると様々な情報を取得できるのですが、ここではクエリのプロファイルを取る方法を紹介します。
performance_schema
が有効になってない場合、my.cnf
に下記設定を取り込んで再起動しましょう。
[mysqld] performance_schema=on
performance_schema
が有効になったら、プロファイルの事前準備としてこれらのクエリを発行します。
UPDATE performance_schema.setup_instruments SET ENABLED = 'YES', TIMED = 'YES' WHERE NAME LIKE '%statement/%'; UPDATE performance_schema.setup_instruments SET ENABLED = 'YES', TIMED = 'YES' WHERE NAME LIKE '%stage/%'; UPDATE performance_schema.setup_consumers SET ENABLED = 'YES' WHERE NAME LIKE '%events_statements_%'; UPDATE performance_schema.setup_consumers SET ENABLED = 'YES' WHERE NAME LIKE '%events_stages_%';
次に、プロファイルしたいクエリを実行。
SELECT * FROM thread t INNER JOIN thread_comment c ON (c.threadId = t.id) WHERE t.appId = 723 ORDER BY c.id DESC LIMIT 21 OFFSET 100000;
その後、上記クエリの EVENT_ID
を取得します。WHERE
句の SQL_TEXT LIKE ?
でプロファイルしたいクエリを特定できる条件を書きましょう。
SELECT EVENT_ID, TRUNCATE(TIMER_WAIT/1000000000000,6) AS Duration, SQL_TEXT FROM performance_schema.events_statements_history_long WHERE SQL_TEXT LIKE '%OFFSET 100000%';
すると LIKE
句に書いた部分にマッチするクエリと EVENT_ID
が表示されます。
ここでは仮に EVENT_ID
を 1323 として、下記クエリを発行します。
SELECT event_name AS Stage, TRUNCATE(TIMER_WAIT/1000000000000,6) AS Duration FROM performance_schema.events_stages_history_long WHERE NESTING_EVENT_ID=1323;
結果。ステージ毎にどれだけ時間がかかっていたかが表示されます。このケースでは Sending data
に時間がかかっていることがわかります。
mysql> SELECT -> event_name AS Stage, -> TRUNCATE(TIMER_WAIT/1000000000000,6) AS Duration -> FROM -> performance_schema.events_stages_history_long -> WHERE -> NESTING_EVENT_ID=1323; +------------------------------------------+----------+ | Stage | Duration | +------------------------------------------+----------+ | stage/sql/starting | 0.000033 | | stage/sql/Waiting for query cache lock | 0.000000 | | stage/sql/starting | 0.000000 | | stage/sql/checking query cache for query | 0.000066 | | stage/sql/checking permissions | 0.000001 | | stage/sql/checking permissions | 0.000002 | | stage/sql/Opening tables | 0.000015 | | stage/sql/init | 0.000020 | | stage/sql/System lock | 0.000006 | | stage/sql/optimizing | 0.000006 | | stage/sql/statistics | 0.000148 | | stage/sql/preparing | 0.000011 | | stage/sql/Sorting result | 0.000003 | | stage/sql/executing | 0.000000 | | stage/sql/Sending data | 9.995573 | | stage/sql/end | 0.000001 | | stage/sql/query end | 0.000006 | | stage/sql/closing tables | 0.000006 | | stage/sql/freeing items | 0.000015 | | stage/sql/logging slow query | 0.000030 | | stage/sql/cleaning up | 0.000001 | +------------------------------------------+----------+ 21 rows in set (0.00 sec)
相関サブクエリを使うなど一部のケースではこの結果セットが長大になることがあります。その時は Stage カラムでグルーピングするとわかりやすいでしょう。
SELECT event_name AS Stage, TRUNCATE(SUM(TIMER_WAIT)/1000000000000,6) AS Duration FROM performance_schema.events_stages_history_long WHERE NESTING_EVENT_ID=1323 GROUP BY event_name;
Sending data
について一つ注意点があります。Sending data
は MySQL がクライアントにデータを送信するだけのイベント ではありません。 実際は、データベースからレコードをフェッチしてフィルタリング処理をしているか、クライアントへのデータ送信を指しているので注意しましょう。このケースでは OFFSET が巨大ゆえのレコードのスキャン数の多さがボトルネックになっています。
クエリのプロファイルについては SET profiling=1;
して表示する方法もあるのですが、Deprecated なのでここでの紹介は割愛します。
参考
MySQL :: MySQL 5.7 Reference Manual :: 25.18.1 Query Profiling Using Performance Schema
ロック情報を見よう
MySQL の性能を引き出せない原因の一つとして、ロック待ちが多発してしまうケースが挙げられます。
ここでは information_schema
を活用してロックに関する情報を取得する例を紹介します。information_schema
とは MySQL の各種テーブルのメタデータを持つテーブルで、テーブル構造などはもちろん、テーブルが使用しているデータ量、ロック情報などを取得することができます。
ロック情報の取得手順は sh2 氏のこの記事が詳しいです。
MySQL InnoDBにおけるロック競合の解析手順
上記記事からクエリを引用します。
select t_b.trx_mysql_thread_id blocking_id, t_w.trx_mysql_thread_id requesting_id, p_b.HOST blocking_host, p_w.HOST requesting_host, l.lock_table lock_table, l.lock_index lock_index, l.lock_mode lock_mode, p_w.TIME seconds, p_b.INFO blocking_info, p_w.INFO requesting_info from information_schema.INNODB_LOCK_WAITS w, information_schema.INNODB_LOCKS l, information_schema.INNODB_TRX t_b, information_schema.INNODB_TRX t_w, information_schema.PROCESSLIST p_b, information_schema.PROCESSLIST p_w where w.blocking_lock_id = l.lock_id and w.blocking_trx_id = t_b.trx_id and w.requesting_trx_id = t_w.trx_id and t_b.trx_mysql_thread_id = p_b.ID and t_w.trx_mysql_thread_id = p_w.ID order by requesting_id, blocking_id \G
このクエリで、リクエストしている方とブロックしている方のクエリやトランザクションレベルを見ることができます。結果を解析すれば INSERT
クエリや FOR UPDATE
がついたクエリ、はたまた SERIALIZABLE
なトランザクションが他のトランザクションをブロックしているというようなことが読み取れるでしょう。
サイボウズではこのクエリを定期的に発行し、結果を整形してログに出力しています。そのため日々気軽にロック情報の調査を行うことができ、その知見は製品改善に活かされています。調査方法としてはブロックしているクエリでグルーピングしてソート、あたりが簡単で明瞭です。他のクエリをブロックしやすいクエリが仮に INSERT
クエリだったら一度に INSERT
する量を減らすなどの対処を行い、なるべくロック競合が起きないよう製品を日々改善しています。
InnoDB の詳細な状態を見よう
SHOW ENGINE INNODB STATUS
クエリで InnoDB の詳細な情報を取得できます。ただしこの結果は SHOW GLOBAL VARIABLES
などとは異なり、ひとつの項目の長大なテキストとして表示されるので解読しにくいです。下記記事を参考に頑張って解読しても良いですが、innotop
というツールを使うのも手です。
なぜあなたは SHOW ENGINE INNODB STATUS を読まないのか
innotop
は MySQL の各種状態を可視化して top
コマンド風に表示してくれるツールです。innotop
は SHOW ENGINE INNODB STATUS
の結果もパースしてロック待ちの状況等を表示してくれます。あまりメンテナンスは活発ではないようですが、一応 MySQL 5.7 でも動きます。
MySQLのリアルタイムモニタリングに「innotop」
サイボウズではこの情報を元に Adaptive Hash Index
のロック待ち時間が多いことを突き止めました。対策として innodb_adaptive_hash_index_parts
パラメータの値を増やして改善を試みています。
実践編
オンライン DDL を活用しよう
性能問題に取り組むとスキーマの修正をしたくなることがあります。しかし性能問題が出るようなテーブルは大抵巨大で、カラムやインデクスを追加するだけでもそれなりに時間がかかります。以前の MySQL ではスキーマの修正をするとデータの更新が行えなくなり、サービスが正常稼働しているとは言えない状況に陥りました。
これを改善するのがオンライン DDL という仕組みで、この仕組みを使えばサービスを正常に稼働させたままスキーマの修正が行えるようになります。スキーマ修正の内容によっては処理中に書き込み出来ないケースもありますが、大抵のケースではオンライン DDL として扱うことができます。
MySQL :: MySQL 5.7 Reference Manual :: 14.13.1 Online DDL Operations
似たような仕組みでオンラインスキーマ変更を可能にする pt-online-schema-change というツールもあります。
また、スキーマの変更はしなくともカラムのデータを一気に更新したい場合があります。その際 UPDATE クエリで全データを一気に更新してしまうとクエリ実行中はデータの挿入ができなくなるので注意が必要です。例えば次に示すクエリは全レコードの body
カラムをスキャンするので、レコード行数によっては時間がかかることが想定されます。
UPDATE blob_file SET size = LENGTH(body);
テーブル全体に対する UPDATE クエリはレコード件数によっては危険なので、アプリケーション側で id を指定するなどで小分けにすることを検討しましょう。
例:
UPDATE blob_file SET size = LENGTH(body) WHERE id BETWEEN 12000 AND 13000;
カバリングインデクスを活用しよう
クエリをチューニングする際はカバリングインデクス化することも検討しましょう。
通常、インデクスを利用したクエリは、
- インデクスデータにアクセスし、条件に一致する主キーを取り出す
- テーブルデータにアクセスし、手順1.で得られた主キーを元にレコードデータを取り出す
という手順で行われています。この時、もしクエリ実行に必要なデータが全てインデクスデータ内に載っていたら手順2.のレコードデータを取り出す作業が不要になり、インデクスデータのアクセスのみで済む ので高速に処理を終えることができます。
クエリがインデクスデータへのアクセスのみで済んでいるかどうかは EXPLAIN
の Extra
欄に Using index
があるかどうかで判断できます。Using index
ならインデクスデータのアクセスのみで済んでいます。ちなみに、Using index
なクエリにはインデクスカバークエリという名称がついています。
例えば下記のようなクエリがある時、user
テーブルに (joinDate, name)
というインデクスがあってそれを利用していればインデクスカバークエリになるでしょう。
SELECT name FROM user WHERE joinDate > ?
とは言え、常にカバリングインデクスを利用すれば良いというわけではありません。まず第一に、インデクスを作り過ぎると更新速度の低下を招きます。ケースによってはレコードの挿入が数十倍も遅くなることがあります。第二に、インデクスデータが大きくなると空間効率が悪化し、メモリを効率良く使えなくなる可能性があります。
また、クエリの変更に弱くなるという弱点も言えるかもしれません。インデクスカバークエリに何か条件文や SELECT するカラムを増やした時などはテーブルデータへのアクセスが発生し、予想外に性能が劣化するケースがあります。特に WHERE 句に条件を追加した時は「条件を追加したのだから選択するレコード数が減って速くなるのでは?」と思いがちですが、テーブルデータへのアクセスが発生することで思わぬ劣化を招くことがあります。よってインデクスカバークエリを修正する際はインデクスの更新も迫られるケースがある点に注意です。
JOIN する順序を制御しよう
性能の観点で言えば、JOIN するテーブルが一体どの順序で JOIN されるのかは非常に重要です。当然ですが、レコードのスキャン数が少なくて済むほどクエリは高速に処理を終えることができます。どの順序で JOIN を行うかは MySQL のオプティマイザが判定するのですが、前述したように MySQL のオプティマイザはさほど賢くありません。 よって、アプリケーション側でオプティマイザ以上に賢い戦略を取れるなら、その情報を用いて JOIN の順序を制御しましょう。
JOIN の順序を制御する方法は、JOIN するテーブルのインデクスを FORCE INDEX
で強制したり、STRAIGHT_JOIN
を使って制御したりする方法があります。EXPLAIN
して実験しましょう。
JOIN の順序制御をする際は各テーブルのレコード件数が重要になりますが、COUNT クエリは遅い ので COUNT クエリを発行して計算するのは本末転倒になりかねません。近似値で良いなら EXPLAIN
を発行して rows カラムで高速に取得できるので、これで代替するのも手です。
複数ソート条件を持つクエリに注意しよう
MySQL は複数ソート条件を持ったクエリの処理が苦手です。 複数ソート条件を持つクエリは人間の直感より激しく遅くなるケースがあります。
例えば下記のような article テーブルのデータを create_date
、id
でソートするケースを考えてみます。
SELECT id FROM article ORDER BY create_date DESC, id LIMIT 20
この時、create_date
の降順 20 件を取得し、その 20 件の中で id
でソートすれば良いはずです(20個目と21個目の create_date
が等しいならもう少し先まで読む必要がありますが)。しかし実際このクエリは article テーブルを全てスキャンしてしまいます。
これを改善するため、スキャンするレコードを削減するようにします。まず create_date
で降順ソートした時の 21 件目の create_date
の値を取得します。
SELECT create_date FROM article ORDER BY create_date DESC LIMIT 1 OFFSET 20
元々のクエリで得られる結果に含まれる create_date
は上記クエリで得た create_date
の値より大きいので、そのような条件を追記します。このクエリは元のクエリに比べてスキャンするレコード数が少なく済むので性能が改善します。
SELECT id FROM article WHERE create_date >= '2018-07-15' -- 上記クエリで得た作成日をここに追加 ORDER BY create_date DESC, id LIMIT 20
テーブルを最適化しよう
MySQL は レコードを削除しても実データは縮小しません。 そのため、レコードの追加と削除が頻繁に行われるようなテーブルではデータがフラグメンテーションを起こし、パフォーマンスに影響を与えることがあります。そのようなテーブルには OPTIMIZE TABLE table_name
クエリを発行すれば最適化が行われ、実データも縮小します。
ではどのテーブルに対して最適化を行えば良いのでしょうか?
そのヒントとして、information_schema
を使う方法があります。information_schema.tables
テーブルには data_free
カラムがあり、これはテーブル内の空きスペースを表しています。つまりこの data_free
カラムのサイズが大きいほど、フラグメンテーションしていると考えられ、OPTIMIZE TABLE
の効果を発揮できることでしょう。
data_free
が大きいテーブルから順に出力するクエリを示します。ちなみに data_length
はレコードのデータ量、index_length
はインデクスのデータ量を表します。ただし実データ(*.ibd)のファイルサイズとは乖離があるようなのであくまで目安程度としておくのが無難です。
SELECT table_schema, table_name, sys.format_bytes(data_length) AS data_size, sys.format_bytes(index_length) AS index_size, sys.format_bytes(data_free) AS data_free_size FROM information_schema.tables ORDER BY data_free DESC LIMIT 10;
このクエリで得られたテーブルは他のテーブルに比べてフラグメンテーションが進んだ状態と考えられるので、深夜にでも OPTIMIZE TABLE
を発行しましょう。
統計情報を更新しよう
MySQL には各テーブルの統計情報を保持する機能があり、この情報はクエリの実行計画時に利用されています。統計情報は InnoDB であれば自動更新されるので通常気にする必要はありませんが、この統計情報はテーブルデータのうち一部をランダムに取得して計算しているため偏りが発生することがあります。また、最近挿入されたレコードでデータのカーディナリティが大きく変わった等でも実行計画に影響が出ることがあります。
実際のところ問題になるケースはあまり多くありませんが、気になったら ANALYZE TABLE table_name
クエリを発行して統計情報を更新しましょう。よく分からないけど遅いというようなクエリがある時は ANALYZE TABLE
の前後で EXPLAIN
結果を比較したり性能比較したりすると良いです。
巨大オフセットについて
たとえ単純なクエリであっても、OFFSET に 100 万などの巨大な値が指定されるとどうしてもレコードのスキャン数が増えてしまい、遅くなります。巨大な OFFSET が指定されている以上少なくともその箇所まではレコードをスキャンする必要があるので、クエリの改善で超高速化はちょっと難しいです。
そのため、このケースでは別の手段を検討しましょう。kintone では巨大 OFFSET が指定されるケースのほとんどはレコードの全データのバックアップを目的としたプログラムによるアクセスであるということがわかっています。そこで OFFSET を増加させるのではなく、前回取得したデータの最後のレコード ID から N 件取得、という方式に変更することにより高速化出来ることが判明しており、下記記事で紹介しています。
kintoneの大量レコード取得を高速化 - cybozu developer network
これはユーザー側で対応してもらう必要があるのですが、データ取得処理が速くなるのでユーザー側にもメリットがあり、引き続き啓蒙していく予定です。
実践結果
最近はどのリリースにも性能改善が取り込まれているのですが、ここでは2018年7月で行った kintone の性能改善の成果を紹介します。
この更新ではいくつかの性能改善取り込まれましたが、一番効果が大きかったのは複数 JOIN を持つクエリの順序制御でした。複数テーブルを JOIN するクエリについて、各テーブルの行数の概算値を見積もり、どのテーブルから JOIN すれば良いのかを計算して組み立てるというものです。
kintone はユーザーがアプリケーションを定義でき、細かく検索条件やソート条件を指定できることからもわかる通り、クエリは非常に多種多様です。そのためユーザーの使い方によって効果のほどに差があるのですが、実際に適用した結果を見る限り大きな効果があったと言えると思います。
このグラフはあるユーザー環境でのレスポンスタイム 99 パーセンタイル値です。7/8(日)に行われた更新の適用前後で 1/6 ほどにまで下がりました。
こちらは今回の改善が良く効いたとある環境の MySQL の Slow_queries
の数で、更新後激減してることがわかります。
kintone 全体で見た時も、MySQL の Innodb_rows_read
の値がおよそ半減していました。kintone を使う全ての環境、あらゆる使い方をひっくるめて半減というのはなかなか効果が大きいのではと思います。
性能に関する様々な調査をしてきて、改めて思うのは MySQL は驚異的に超高速である ということです。多くの遅いクエリは MySQL の気持ちに沿って書きなおせばきっと超高速化を実現できるでしょう。各種設定可能なパラメーターも非常に多く、複雑ではありますが、そういうチューニング可能なところもまた MySQL の魅力の一つだと思います。
今回紹介した性能改善の結果はあくまで一部であって、サイボウズクラウド全体を見渡すと遅い処理がまだまだあります。サイボウズと MySQL は切っても切れない関係にあり、今後もノウハウを蓄積し、引き続き改善していく予定です。サイボウズは大量のデータに多種多様で複雑なクエリと、なかなかチャレンジングで魅力的な環境だと思います。性能改善が好きな皆様、サイボウズで腕を奮ってみませんか。We Are Hiring!