新卒1年目が100億レコード超のDBマイグレーションをした話

成田篤基氏:発表を始めます。みなさんはじめまして。成田と申します。私は2021年にディー・エヌ・エーに新卒で入社して、現在入社から2年が経とうとしています。

私は新卒1年目で、大規模なデータベースマイグレーションを行う貴重な経験ができました。本日はそのマイグレーションプロジェクトについて、体験から得た学びをみなさんにお伝えします。題して「新卒1年目が100億レコード超のDBマイグレーションをした話」です。どうぞよろしくお願いいたします。

目次です。本日はこちらの目次に沿って発表を進めていきます。

まずは私たちディー・エヌ・エーが提供している「Pococha」について紹介させてください。Pocochaはスマホ1つで、いつでも・どこでもライブ配信を通じて誰かの特別な存在になれるアプリです。Pocochaは配信をするライバーと、コメントやアイテムで応援するリスナーの双方向のコミュニケーションを楽しむサービスです。

なぜDBのマイグレーションをしたのか

それでは本題に入っていきます。まずはなぜマイグレーションをしたのかについてお伝えしていきます。Pocochaサーバーのリポジトリは開発開始から6年以上が経過しており、リポジトリサイズもデータベースのサイズも大規模です。(スライドを示して)下の図はPocochaの年ごとの売上です。このように毎年大きく成長しているPocochaですが、サービスの成長に伴い、データベースも昔とは比較にならないほど大規模になってきています。

日々の開発、運用をしている中でパフォーマンス観点での課題が顕在化して、データベースの再設計が必要な局面に直面しています。

今回パフォーマンス改善の対象としたのはコメント機能です。コメント機能はライバーやリスナー同士のコミュニケーション手段として用いられていて、Pocochaリリース当初から実装されている昔ながらの機能です。(スライドを示して)左の図の青色で囲ってある部分が、コメント機能のUIです。データはPococha最大規模のcommentsテーブルに保存されていました。

今回のパフォーマンス改善では、このcommentsテーブルの抱えていた課題を解決するためにマイグレーションを行いました。

commentsテーブルが抱えていた2つの課題

次に、commentsテーブルの抱えていた2つの課題についてお伝えします。1つ目の課題は、多すぎるレコード数です。コメント機能はPocochaの開発初期フェーズから実装された機能で、Pocochaがここまで成長することを見越したテーブル設計にはなっていませんでした。

そのため、移行完了時のレコード数は優に100億を超えて、かなり大規模なテーブルとなっていました。さらに、レコードの増加ペースはサービスの拡大に応じて速くなっている上に、過去の古いレコードを削除するような仕組みもないため、レコードは増え続ける一方です。

2つ目の課題はRead heavyです。先ほどお伝えしたとおり、commentsテーブルはかなり大規模なテーブルでした。テーブルサイズが小さければインデックス設計が多少最適なかたちではなくても、それほどパフォーマンス面で問題になることはないでしょう。しかし、これほど大規模なテーブルでは検索効率の良いインデックスを選択できないと、Read処理の効率が落ちて、たちまちslow-queryを発生させてしまいます。

今回私はこれらの課題を解決すべく、マイグレーションプロジェクトを立ち上げました。

課題を踏まえて、今回のマイグレーションプロジェクトの目的は、レコード数の削減とRead処理時間の削減と定めました。今回のプロジェクト完了後は全体的にレコード数が減っていて、Read処理にかかる時間も削減されている状態を目指します。

目的達成のための2つのアプローチ

目的達成のために2つのアプローチを取りました。1つ目のアプローチはRANGEパーティショニングの追加です。RANGEパーティショニングとは、通常1つのテーブルに入れてあるデータを特定の範囲ごとにデータを分けて保存する技術です。パーティショニングの機能のDROP PARTITIONを用いて一定期間後に古いデータをまとめて削除することで、レコード数を効率良く削減できます。また、パーティションテーブルの絞り込みやレコード数の削減によって、Read処理の効率アップが期待できます。

2つ目のアプローチはインデックスの再設計です。ユースケースにあったインデックス設計に変更することで、Read処理の効率アップが期待できます。さらに、パーティショニングを用いてレコード数を削減することで、インデックスに変更を加える際のALTERの所要時間が短縮されます。

Pocochaで利用しているMySQLではオンラインDDLをサポートしているので、ALTERでのインデックス変更ができます。しかし、もともと大規模なテーブルに、ALTERによるインデックスの変更をするとデータベースにかかる負荷面や不具合の心配があるため、今回のマイグレーションプロジェクトではALTERによるインデックス変更は行いませんでした。パーティショニングについては次のスライドで詳しく補足します。

RANGEパーティショニングを入れた理由に納得してもらうために、パーティショニングに関してもう少し詳細にお伝えします。非パーティショニングのテーブルでは、全期間分のデータが1つのテーブルに存在しています。これではSELECT句を発行した際に全期間分のデータの中から必要なデータをシークする必要があり、とても非効率でRead heavyな状態です。

(スライドを示して)図を見てください。例えば2022年4月15日にあったコメントのデータを探すケースを考えます。分割されていないデータベースでは全期間分のデータから該当のデータをシークする必要があるため、Read処理の効率がとても悪いです。

次にRANGEパーティショニングの説明をします。パーティショニングを入れてあるテーブルでは、データを分割して保持しています。

今回のプロジェクトの例では、1ヶ月単位でコメントデータを分けて保存するように設定しました。パーティショニングによるメリットは、TABLE DROPのように、特定の期間のパーティションテーブルを効率良くDROP PARTITIONしてパージできることです。通常のデータ削除ではWHERE句で絞り込んでからDELETEを実行する必要があるため、効率が悪いです。

また、SELECT句を発行する際にデータ取得先のパーティションテーブルを指定すると、全期間分のデータからシークするのではなく、特定の期間分のデータをシークするだけで良いです。これによってRead処理の効率がとても良くなります。これをパーティション・プルーニングといいます。また、テーブル全体のデータ数自体もパーティションテーブルのパージによって減っているので、全体的なRead処理の効率が良くなります。

メリットばかりのようですが、パーティショニングにはデメリットもあります。パーティションをまたぐようなクエリは非効率になる場合があるので、ユースケースを考慮して分割のルールを決めないと、パフォーマンスが劣化する場合があります。

(スライドを示して)図を見てください。古くなったデータを効率良く削除するとは、例えば2022年1月のデータがもう使われなくなった際に、そのパーティションテーブルごとまとめて一気に削除するようなイメージです。

また、先ほど例として考えた2022年4月15日にあったコメントデータを探すケースを、パーティショニングの入っているパターンでも考えてみます。1ページ前で見ていたパーティショニングが入っていない場合、2022年4月15日にあったコメントのデータを探すため、全期間分のデータの中から該当のデータを探して(いたので)効率が悪かったです。

しかし、パーティショニングが入っていれば2022年4月分のパーティションテーブルの限定された少ないデータの中から該当データをシークするだけなので、Read処理の効率がとても良いです。

また、古くなったパーティションテーブルをパージする際は注意が必要です。それは集計クエリや過去のデータを使えない場合があることです。

実際に今回起きた例として、一度でもコメントしたことのあるユーザーかを判定するロジックを考えてみます。パージしたデータの中にとあるユーザーの唯一のコメントデータが含まれていた場合、そのユーザーがコメントしたという情報が、commentsテーブルから完全に失われてしまいます。

そのため、一度でもコメントしたことのあるユーザーかの判定結果は、正しい結果を返さなくなってしまいます。これでは期待した結果を得られないので、今回は「一度でもコメントしたことのあるユーザー集計テーブル」を新規に作成して、コメントしたことのあるユーザーデータをその新しいテーブルへ保存するようにしました。これでcommentsテーブルそのものからデータをパージしたとしても、「一度でもコメントしたことのあるユーザー」という情報は保存され続けます。

このように、古くなったパーティションテーブルをパージする際は、集計クエリや過去のデータを使えない場合があるので、別のテーブルにロジックを持たせることで解決しました。

パーティショニングの説明がやや長くなってしまったので、再度目的とアプローチの確認をします。今回のプロジェクトの目的は、レコード数の削減とRead処理時間の削減でした。これを達成するためのアプローチとして、インデックスの再設計とRANGEパーティショニングの追加を行います。

さて、ここまではなぜマイグレーションしたのかについてお伝えしてきました。次のページ以降では、具体的にどのようにマイグレーションを進めていったのかについて説明していきます。

「ストラングラーフィグパターン」を用いたマイグレーション

それでは、ここからはダウンタイムなしでマイグレーションした方法についてお伝えしていきます。サービスのユーザー体験を損なわないためにも、今回は可能な限りメンテナンスを入れたり、障害が発生したりしてダウンタイムを発生させたくないと私は考えました。

そこで、今回はフェーズ分けして徐々に機能をリリースしていく、ストラングラーフィグパターンという手法を用いてマイグレーションを進めました。

この手法の名前の由来は、時間をかけて徐々に他の植物を絞め殺すように成長する木という意味の「スラングラーフィグ」から来ているそうです。(スライドを示して)図を見てください。一番左のもともとあった木に、次のフェーズで別の木が寄生を始めます。そして寄生した木は徐々に範囲を拡大して、やがてもともとあった木全体を覆うようになります。最終的にはもともとあった木を朽ち果てさせ、寄生した新しい木だけが残ります。

先ほどの木の説明と同様にシステムの移行も説明できます。イメージとしては、特定の部分を徐々に新しいシステムに置き換えることで、時間をかけて古いシステムから新しいシステムへ段階的に移行するイメージです。

(スライドを示して)図を見てください。最初はレガシーなシステムだけが存在する状態ですが、そこへ部分的にモダンなシステムを導入します。徐々にモダンなシステムを参照する箇所を増やしていき、最終的にモダンなシステムへと完全に移行します。このように徐々に移行することによって、システムが常に稼働した状態を保てます。これによって、ダウンタイムが発生しない移行が実現可能となります。

今回は新しいテーブルを作成して、徐々に古いテーブルから新しいテーブルに移行することで、ダウンタイムなしでのマイグレーションを実現しました。次のページ以降では、どのようにフェーズ分けをしたのかについて、詳しくお伝えしていきます。

(スライドを示して)まずこの図は、今回のマイグレーションプロジェクトの全体像です。このページでは全体像をざっくりとお伝えしていきます。まずはv2_commentsテーブルを新規に作成しました。そこから徐々に各フェーズごとにcommentsテーブルからv2_commentsテーブルに参照を移行していきました。各フェーズリリース後1ヶ月程度は期待どおりの挙動になっているか様子を見て、慎重に次のフェーズに進めていきます。

また、commentsテーブルに入っていた過去の古いデータの移植については、古いデータの参照頻度が低いことと、古いデータはどんどんパージしていく設計のため行いませんでした。次のページ以降で、各フェーズで行ったことについて詳しく説明していきます。

まずはフェーズ1です。フェーズ1では、先ほどアプローチとしてお伝えしたインデックスの再設計とRANGEパーティショニングの追加を行ったv2_commentsテーブルを作成して、commentsテーブルと同様のデータのWrite処理を行います。このフェーズは、新しいテーブルにデータを溜める期間です。Read処理はすべてcommentsテーブルが行い、v2_commentsテーブルはこの段階ではRead処理を行いません。

(スライドを示して)図に注目してください。commentsテーブルとv2_commentsテーブルに並行してWrite処理を始めてはいるものの、Read処理はcommentsテーブルを参照しています。

次にフェーズ2です。フェーズ2では、すべてのRead処理の向き先をcommentsテーブルからv2_commentsテーブルに変更します。このフェーズは、commentsテーブルにSELECT句が飛んでいないか監視する期間です。すべてのRead処理の向き先をcommentsテーブルから変更したので、commentsテーブルにSELECT句は飛ばないはずです。

障害を発生させないためにも、SELECT句がcommentsテーブルに対して飛んでいないと確認できるまで、次のフェーズには移りません。また、不具合が起こった際にRead処理の向き先をv2_commentsテーブルからcommentsテーブルに戻せるように、念のためcommentsテーブルにもWrite処理を行います。

(スライドを示して)図に注目してください。フェーズ1ではcommentsテーブルに対してRead処理を実行していましたが、フェーズ2ではv2_commentsテーブルに対してRead処理が移行されています。また、障害が発生した際にRead処理の参照をcommentsテーブルに戻せるように、並行してcommentsテーブルに対してもWrite処理は一応実行されています。

最後にフェーズ3です。フェーズ2でcommentsテーブルへのSELECT句が飛んでいないことを確認した後に、commentsテーブルへのWrite処理を止めて、Read/Write処理ともに行き先をv2_commentsテーブルに変更しました。このフェーズが終わればマイグレーションはほぼ完了です。古いcommentsテーブルのデータは、フェーズ3完了後一定期間が経過したら、バックアップを取ってDROP TABLEでデータを削除します。

(スライドを示して)図に注目してください。フェーズ2ではcommentsテーブルへのWrite処理がありましたが、フェーズ3ではそれが撤廃されてv2_commentsテーブルのみ使用している状態になっています。ちなみに、このフェーズ以降、v2_commentsテーブルに保存されて一定期間経過したデータは、自動的にDROP PARTITIONによって効率良くパージするように設定しました。

障害を起こさないための3つの工夫

ここまでは、ダウンタイムなしでマイグレーションした方法についてお伝えしてきました。ここからは、障害を起こさないために私が行った3つの工夫についてお伝えしていきます。

今回工夫した点は、テストのない既存機能は先にテストを書く、影響箇所の洗い出しは丁寧に行う、メトリクス監視です。次のページ以降でそれぞれの工夫について説明していきます。

まずは「テストのない既存機能は先にテストを書く」です。コメント機能そのものや関連する機能のうち、テストが書かれていなかった既存のコードに変更を加えたい場面がありました。テストが書かれていない機能を変更した場合、不具合を起こしていることに気づけない可能性があります。

そこで、自分が行いたい変更の前に、既存機能のテストを追加して動作を担保するようにしました。これによって自分が行った変更によってデグレを引き起こした際に、テストを走らせるだけで検知できる体制を作りました。

次に「影響箇所の洗い出しは丁寧に行う」です。コメント機能はPocochaの中でも他機能による参照が多い機能です。考慮漏れで障害を発生させないためにも、他のどの機能から参照されているかの調査は、1〜2ヶ月時間をかけて慎重に地道に進めました。正直、今回のマイグレーションプロジェクトの中で一番苦労したのはこの調査でした。

調査に抜け漏れがあると障害につながってしまう恐れがあるので、緊張感を持ちながら調査を進めました。苦労はしましたが、この洗い出しを丁寧に行ったおかげで、最後まで一度も障害を起こさずにプロジェクトを完了させられました。

最後に「メトリクス監視」です。今回は障害を起こさないためにも、また障害を起こしてしまった場合はすぐに気づくためにも、さまざまなメトリクスを活用しました。

まずはGrafanaです。これはリリース後に障害や想定外のエラーが起きていないかを確認するために用いました。

次にDatadogです。これは各フェーズのリリース後に想定外のクエリが発行されていないかを確認する目的と、slow-queryが発生していないかを確認する目的で用いました。例えば、フェーズ2リリース後はSELECT句をすべてv2_commentsテーブルに発行する設計としていたため、誤ってcommentsテーブルにSELECT句を発行している箇所がないかを確認しました。これによって考慮漏れによる障害の発生を防ぐ体制を作れました。

最後にAmazon RDSのPerformance Insightsです。これは、リリース後発行されるようになったクエリが、データベースの負荷の原因になっていないことを確認するために用いました。これらのメトリクスを活用したので、次のフェーズに進んでも問題が起こらないと確信しながら作業を進められました。

最後まで障害なくプロジェクトを完了できた

最後にまとめです。今回私は新卒1年目のタイミングで、Pococha最大規模のcommentsテーブルのマイグレーションを行いました。レコード数の削減、Read処理時間の削減のためにインデックスの再設計、パーティショニングの追加を実施しました。また、ダウンタイムなしでマイグレーションをするためには、徐々に新しいシステムに移行するストラングラーフィグパターンが有効です。

さらに障害を起こさないための工夫として、テストのない既存機能は先にテストを書き、影響箇所の洗い出しは丁寧に行う。メトリクス監視を行うことで、最後まで障害なくプロジェクトを完了させました。

以上で私の発表を終了とします。ご清聴ありがとうございました。