我々はいかにして技術選択を間違えたのか? 2016

どうも!アプリケーション基盤チームの横田(@yokotaso)です!

kintoneなどで利用していたJavaフレームワークのSeasarのEOLに伴い、S2Daoからの脱却を試みたのですが、パフォーマンス問題や障害を発生させてしまうなど問題を多々発生させてしまいました。

同じ過ちを繰り返さないという強い決意のもと、今回の失敗をブログで公開いたします。 失敗をあえて公開する点で斬新かつ濃いブログ記事となっております!

失敗体験の公開は恥だが役に立つ!

移行先の選定の失敗

移行先として選定したプロダクトは Hibernate*1です。

Hibernateを選んだ理由としては

  1. Spring Framework を選定した

  2. Spring Frameworkで Interface + アノテーションでプログラミングするならSpring Data JPA が有力

  3. JPAに準拠したのORMの中でも、Hibernateがメジャーである。

この3つを主な理由として、S2Daoから、Spring Data JPAへ移行を試みました。

どう失敗だったのか?

以下の3つの点で失敗だったと考えています。

  • JPAに準拠することを目指しながらもEntityのプロパティ変更を自動的にDatabaseに反映する機能は我々は必要としていなかった

  • パフォーマンスの劣化、運用障害の原因になってしまった

  • Hibernateに問題があるケースの調査が難しく、コストが辛い

技術選定になぜ失敗したのか

設計思想の違いを意識できていなかった

失敗の原因の一つ目はS2DaoとHibernateの設計思想の違いです。

Hibernateはキャッシュ機構が充実したORMです。一方S2Daoはキャッシュ機能はあまり持たない、軽量なORMになります。

S2DaoとHibernateで同じように動作することに注力したため、S2Daoと比較した時に Hibernateが「できてしまう」ことの調査に注力ができませんでした。

結果的にこのキャッシュ機構に苦しめられることになります。

HibernateとS2Daoのキャッシュの仕組みの比較

S2Dao Hibernate
クエリ・キャッシュ
エンティティ・キャッシュ
メタデータ・キャッシュ

単純な比較表ですが、キャッシュの機構としてはHibernateのほうが充実しています。

クエリ キャッシュ
  • Hibernate
    アノテーションで宣言されたクエリをキャッシュする仕組み。それとは別にQueryPlanCacheオブジェクトでキャッシュする

  • S2Dao
    SQLコメント機能を利用するためにパースしたクエリをキャッシュする仕組みを持つ

エンティティ キャッシュ
  • Hibernate
    SELECTなどで生成したEntityは、EntityManagerにキャッシュされる。EntityManagerはEntityの変更を検知する。Databaseと自動的にシンクされる。

  • S2Dao
    エンティティ キャッシュの仕組みはない

メタデータ キャッシュ
  • Hibernate,S2Daoの両方でクラスのメタデータをキャッシュする仕組みがある

  • Hibernateのほうがメタデータに関しても多機能である

得られた教訓

フレームワーク移行は、互換性を確保すると同時に、新しく「できるようになること」の影響も調べよう。 できるようになることも、できなくなることも、移行元のフレームワークから差が大きくないのが望ましい。

長いものに巻かれることにこだわりすぎた

もうひとつの失敗の原因がズバリ長いものに巻かれることにこだわりすぎたことです。

SeasarがEOLになったこともあり、EOLに対する恐怖心がありました。

そのため、移行先のプロダクトは「コミュニティが活発である」、「JPAに準拠している」ことを重視しました。上のような理由のため他ORMの選択肢が少なくなりました。

途中からHibernate一択という状態になってしまい、他のORMとの比較もおろそかになってしまった点も反省しています。JPAに準拠することも諦めるべきでした。JPAにできるだけ準拠することも他のプロダクトの選択肢を減らす原因にもつながりました。

JPAを諦めていればspring-jdbcの中に入っているJdbcTemplateを利用することも検討できました。S2Daoに比べると機能落ちになる面もあるのですが、キャッシュ機構がないので、提供される機能はS2Dao時代と近いです。

後で障害の例を紹介しますが、JdbcTemplateを利用すれば運用障害になるケースは避けられた可能性があります。

得られた教訓

長いものに巻かれるのは大事だが、選択を誤る(謝る)バイアスになる可能性があることに注意すべきである。

移行後に発生してしまった障害

Hibernateがらみの移行で、パフォーマンス問題や障害につながってしまうケースが発生してしまったのですが、味わい深い2つの障害を紹介します。

バッチ処理でOutOfMemoryError

Hibernateはトランザクションの内部で実行されたDELETE/UPDATE文はすべて保持するようになっています。

これはHibernateがSQLとは別にEntityをキャッシュする仕組みを持っているためです。トランザクションの最後にDELETE/UPDATEをSQLとして実行します。

100回程度のDELETE/UPDATEを実行する分には問題になりませんが、10万回レベルのDELETE/UPDATEを実行するようなケースは、OutOfMemoryErrorを発生させる原因になります。

移行後、長時間にわたるバッチ処理が、処理途中からFullGCが大量発生し、OutOfMemoryErrorに至る現象が発生するようになり、原因調査とHibernate内部の調査に大きな時間を割くことになりました。

参考: DELETE/UPDATEでOutOfMemoryError

クエリ・キャッシュが原因でOutOfMemoryError

Hibernateはクエリを内部にキャッシュする仕組みがあります。

Hibernateは次のようなクエリは、すべて別のクエリとしてキャッシュされてしまいます。IN句の数が違うだけです。S2Daoではこの機能はありませんでした。

SELECT * FROM book WHERE id IN (p1, p2, p3);
SELECT * FROM book WHERE id IN (p1, p2, p3, p4);
SELECT * FROM book WHERE id IN (p1, p2, p3, p4, p5);

Hibernateのクエリキャッシュは、LIRSキャッシュアルゴリズムが利用されており、キャッシュが増殖しつづけるのを抑制する仕組みは存在しています。しかし、次の2つの現象が同時に発生したときに障害になる可能性があります。

  • IN句の中身が大量

  • IN句の個数が1つでも違うとすべてキャッシュされる

これが原因で、FullGCに伴うJVMのハングとOutOfMemoryErrorを発生し、障害が発生しました。

参考: QueryPlanCacheでOutOfMemoryError

まとめ

  • フレームワークの移行は後方互換性とともに移行後に「できるようになってしまう」ことも調査しよう

  • 長いものに巻かれるのも大事な要素だが、それにこだわりすぎる余り、技術選択の視野が狭くならないように注意しよう

  • 移行先のフレームワークが一択の状態は危険なサイン。移行先フレームワークは複数用意するようにしよう

最後に

楽しんでいただけたでしょうか?皆様のお役に立てれば幸いです。

今回の移行に際して運用環境のパフォーマンス低下や運用障害を発生させたことは我々の不徳のいたすところであり、猛省を重ねております。

サイボウズは、今後も同じような失敗を繰り返さぬように技術の研鑽を続けていきます。

*1:我々の技術選択の失敗に至るプロセスを公開・共有が目的です。Hibernateを貶める意図は一切ないことを強調させていただきます。