こんにちは。Yahoo!広告 ディスプレイ広告(以下、ディスプレイ広告)エンジニアの池田です。
本記事では、ディスプレイ広告において広告主の予算に対する課金処理・配信制御に使われるRelational Database(以下、RDB)を障害を起こさずにサービス無停止で移行した事例について紹介します。無停止でのRDBの移行を検討されている方がいらっしゃったらぜひ参考にしていただければと思います。
サービスを停止できない理由
今回、私たちが運用していたOracle Database(以下、OracleDB)のハードウェアのEOLおよびソフトウェアのEOLに伴うバージョンアップ(Oracle12c→Oracle19c)などの理由により、DBを移行することとなりました。
RDBの移行は多くの場合、DBおよびDBに接続しているアプリケーションのダウンタイムを設けて実施されるかと思います。私たちと同じタイミングでOracleDBの移行を行っていたチームもOracle Data PumpやOracle GoldenGateを使うといった差分はあるものの、基本的にはアプリケーション・DBを停止して移行作業を実施されていました。
しかし、私たちが運用しているアプリケーション・DBは下記の理由よりダウンタイムおよびサービスの機能停止期間を設けた移行が非常に難しい状態でした。
- 前提
- 広告配信システムは24時間365日間止まることはない
- 全社で用意されているOracleDB移行の方法は最低でも2時間程度のダウンタイムが発生する
- 課金・配信制御処理によって、400~600QPSでデータが更新され続けている
- サービスの機能またはDBを停止することによって発生する問題
- DBを停止している間の機会損失によって、かなりの売り上げが毀損してしまう
- 広告の課金制御が不可能になるため、広告主が設定した予算を大幅に超過して広告が配信される可能性がある(この状態を許容していただくための営業および広告主・広告代理店とのコミュニケーションが非常に難しい)
- DBと接続しているサービスのAPIの利用者が多く、コミュニケーションコストが高い
- 約30分の停止で関連コンポーネントのSLO違反につながる
- etc…
上記に挙げた理由より、サービス無停止でのDB移行を実施することとなりました。
サービス無停止でのRDB移行の手順
次に、今回実施したサービス無停止でのRDB移行の手順について紹介します。
RDBの移行でよくある手段としては、ダンプデータをエクスポートしてコピーする方法や新旧DB間のデータを同期する方法などが挙げられます。これらの方法でもダウンタイムを大きく減らすことや場合によってはDBを停止せず移行することはできます。
しかし、新旧DBに格納されたデータに差分を生まないようにするため、新DBに完全に接続先を切り替えるまで旧DBを読み取り専用にする必要があります。私たちが運用していたDBは上述の通り、リアルタイムにデータが更新し続けられており、この処理を止めることが難しかったため、これらの方法だけではサービス無停止でのDB移行は実現できません。
今回のDB移行では、ダンプデータをエクスポートする方法に加え、独自に新旧DBへの並行書き込み・マイグレーション機能を用意することで、サービス無停止でのDB移行を実現しました。
今回、私たちは以下の手順によって3日間かけて営業時間内でDB移行を実施しました。
1. 新DBへのデータコピー
初めに旧DBと旧DBに接続したサービスを稼働させながら、旧DBのダンプをとり、ダンプしたデータを新DBにコピーします。データコピーのための操作は旧DBに若干負荷がかかるものの、事前に性能試験用の環境を使って問題ないことを確認済みでした。
本作業はダウンタイムを設けずに実施したため、常に旧DB側のデータは更新され続けており、この時点では新旧DBのデータに差分が発生しています。
後の工程で新旧DB間の差分のマイグレーションを行います。
2. 並行書き込み機能リリース
次に新旧DBに更新処理を実行するよう実装したアプリケーションをリリースします。
本リリース後、アプリケーションが更新処理を実行するときのみ、旧→新の順で書き込みを行います。リリースが完了した後、新旧DB間に新たな差分は発生しなくなります。
データ更新時は更新対象のDBからデータを取得し、内容をビジネスロジックによって更新してDBに書き込みます。例えば、新DBの更新時は新DBからデータを取得し、値を書き換えて更新するようにしています。また、旧DBのデータを正とし、データ更新時は旧DBを処理して得られた結果をレスポンスに利用します。旧DBに対する処理が失敗した場合は新DBに対する処理を実行しないような仕様としています。
データ取得時は旧DBからデータを取得しそのままレスポンスに利用します。更新時と同様に旧DBを正と扱っています。
並行書き込み機能リリース後、更新処理についてはトランザクション処理にかかる時間が2倍になるため、QPSも約2倍となっていましたが、以下の作業を事前に実施しておいたこともあって問題なく運用し続けることができました。
- 影響がないことの利用者への確認
- リハーサルによってマイグレーション(後述)に伴う負荷が問題ないことの確認
- 負債となっていたインデックス構成の見直しと修正(対応時にテーブルロックが発生するため、新DBのみ対応)
3. 差分比較・マイグレーション
並行書き込み機能リリースによって、新旧DB間に差分は発生しなくなったものの、リリースされるまでの差分は残ったままとなっています。この問題を解消するために新旧DB間の各テーブル内のデータに対して差分を比較し、差分が発生してあるデータのマイグレーションを行います。差分比較・マイグレーションにはそれぞれ専用のスクリプト・APIを用意しました。
差分比較では、新旧DB間で差分が発生してある可能性のあるレコードのprimary key(id)をスクリプトに読み込ませます。スクリプトによって差分比較APIにリクエストし、アプリケーション側で新旧DBにデータを問い合わせて差分の比較結果をスクリプトが受け取り、差分が存在するid一覧を保存します。
差分比較に利用するidの一覧はアプリケーションが落としているリクエストログから取得します。新DBへのデータコピーから並行書き込み機能のリリース完了までの期間に更新系リクエストがあったidを「差分発生の可能性があるレコードのid」の一覧としています。
差分比較処理はリクエスト時に受け取ったロックの有無によって、行ロックをかけて新旧DBからデータを取得し、差分比較が終わった後に行ロックを解除する機能も備えています。差分比較→マイグレーションの流れは何度も実行されるため、差分が発生している件数が多い間は行ロックを行わずに差分を比較し、差分が少なくなったら行ロックを行って可能な限り正確に差分比較とマイグレーション作業を進めました。
マイグレーションは差分比較によって差分が発生していると判定されたレコードを対象に旧DBのデータを正とし、新DBに書き込むようにして実施しました。
差分比較と同様にスクリプトがid一覧を読み込み、マイグレーションAPIにリクエストし、マイグレーションの成否を保存するような仕様となっています。マイグレーションに失敗した場合はアプリケーションで発生した例外を確認し、単純な更新トランザクションの失敗であればマイグレーションを再実行するようにして作業を進めました。
また、各種スクリプトには並行処理・QPS制限機能を実装しており、可能な限り差分比較・マイグレーションのサイクルを早めながらも・アプリケーションの稼働状況に影響がないよう作業を進めました。
4. 全件突合
差分比較とマイグレーションで新旧DBの差分が発生していないことが確認できた後、改めて全てのレコードを確認しました。運用していく中でデータがずれていないことを保証するためにこのステップを実施しています。
DBで管理されているデータ(テーブル)は3種類あり、それぞれ約40万件、約200万件、約7千万件のデータを保持していました。この内、前者2つのデータについては全てのレコードの差分比較が数時間で完了するため、前述の差分比較スクリプトを利用して各テーブルの全てのレコードを比較することで突合処理を行いました。差分比較スクリプトによる全レコードの突合処理は1回3.5時間程度かかることがわかっていたため、マイグレーションが完了した後、少し時間を置いてから夜間に実施しました。
約7千万件データが存在するテーブルは3日分の課金処理に関するログを蓄積しており、日次で実行される広告主ごとの課金額の集計や返金処理に利用するために利用されます。7千万件のレコードを稼働状況に影響がないよう全件比較するのは非現実的であったため、日次で実行される集計結果を比較することで、突合処理を行いました。(下図点線)
実際にこの突合処理でいくつかのレコードに差分が発生していることが判明したため、差分が発生したレコードについては原因を調査した後、マイグレーションを実施しました。
DB移行作業の3日間のうち、ほとんどはこの差分の発生原因の調査に使いました。
5. 新DB完全移行
ここまで説明してきた差分比較・マイグレーション・全件突合によって、新旧DB間に差分が発生していないことを確認した後、新DBにのみ接続するよう実装を修正したアプリケーションをリリースしました。本リリース時点から旧DBにデータは書き込まれなくなるため、この作業以降はロールバックができず、リリース本番はかなり緊張しました。
また、カナリアリリースによって新旧DBを更新するサーバーと新DBのみ更新するサーバーが混在していることで、データの整合性がうまく取れないケースがありました。
事前に事象を想定済みであったため、完全移行のリリース後に利用者に更新リクエストを流し直してもらうことでリカバリーしました。
工夫した点
リハーサル
当日に想定外の事象が発生しても焦らないよう、3度リハーサルを実施しました。
リハーサルを実施する中で、マイグレーション時に新旧DBで実行されるSQLについて、Oracle19cでのみインデックスがうまく使われず、レイテンシが悪化するといった事象の早期発見につながりました。うまくインデックスが使われない事象についてはSQLにヒント句を加えることで解消しました。また、本番より高い負荷をかけながら差分比較・マイグレーションスクリプトのQPSを決定することができました。
リハーサルは当日利用する手順書を参照しながら実施しました。OracleDBを運用しているチームにも確認いただき、手順に抜け漏れがないようにしたことも障害を起こさず移行できたことに寄与しているかと思います。
並行書き込みの実装
マイグレ準備を進めていた期間、アプリケーションに対して3つの大規模な案件開発が並行して進んでいました。並行書き込み機能の実装はService層・UseCase層および設定値の読み込みコードに大きく影響があるため、可能な限り、実装がコンフリクトしないように改修しました。
具体的にはDependency Injection(以下、DI)を有効活用し、以下のように実装しました。幸いにもアプリケーションはJava + SpringBootで開発されていたため、DIを利用するハードルが低かったこともDIの有効活用につながりました。
- Repository層
- 新旧DBが同じ製品であったため、DBの設定を埋め込むだけで新旧DBに向けたRepositoryのBean定義を用意
- Service層
- 新旧DBのRepositoryをService層のクラスに埋め込むことで、新旧DBに向けたServiceのBean定義を用意
- DBを参照するService層はDB以外と外部通信しない実装にしてあったため、新旧DB向けのServiceで実装を分ける必要がなかった
- UseCase層
- 新旧DB用のトランザクション処理に関わるServiceクラスをInjectし、旧→新の順で更新処理を行うよう実装
- この層のみ実装を修正する必要があり、案件開発とのコンフリクトが発生する可能性があった
- 以下のようなアノテーションを有効利用
- Qualifier:同一クラスのBeanの区別ができる。UseCase層で新旧DBどちら向けのBeanを使うかの識別に利用
- Configuration:新旧DB向けのRepository・Serviceの定義に利用。設定値の読み込みのための定義と案件開発で改修されるクラス・メソッドを分離できる
- 並行書き込みを実行するか制御するFeatureFlagを用意
- 並行書き込み機能はFeatureFlagがONの時のみ実行。利用開始時はフラグをONに変更するだけ
- 案件開発と同時並行で開発することを実現
結果、案件開発とのコンフリクトをほとんど発生させず、トランザクション処理に関わる実装の二重開発をせずに開発を進めることができました。
おわりに
本記事ではアプリケーション・DBを停止させず、RDBを移行した際の手順と工夫した点について紹介させていただきました。
要件によってはどうしてもサービスを止めずにDBを移行しなければならない場合もあると思います。そういった場合に本記事で紹介した方法が少しでも参考になれば幸いです。
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました