SODA Engineering Blog
🌊

MySQL アップグレード(5.7 → 8.0)の話

2025/01/10に公開

こんにちは。SODAのSREチームです。
この記事でメインデータベースを MySQL 5.7 から MySQL 8.0 (Aurora MySQLv2からv3) にアップグレードした話を紹介させていただきます。

背景

2024年10月31日をもって、AWS は Aurora MySQL 5.7 のサポートを終了しました。サポート終了の1ヶ月後から延長サポートの費用が発生するため、Aurora MySQL 5.7を使用していた我々のデータベースをアップグレードする必要がありました。

そのデータベースクラスターには下記の特徴があります。

  • データベースの容量は約1TB
  • 最もサイズが大きいテーブルは500GBを超えています
  • 中心システムのデーターベースです。多くのものがこれに依存しています (メインサイト、小規模システムの一部、BI ツールなど)
  • 読み取りワークロードが非常に重い
  • ワークロードは日中に非常に高くなり、夜間は低くなります。特にユーザーにプッシュ通知が届くとき (1日に4、5回) に高くなります。

アップグレードの流れ

まずはAWSコンソール上から確認してみる

クローンクラスターを作成して、AWSコンソールからエンジンバージョンアップグレードを実行しました。このアップグレードは成功しましたが、重要なのはアップグレード事前チェックの結果を確認することです。
MySQLの公式クライアントを使用することもできますが、MySQL AuroraはAWSのMySQLの修正バージョンであるため、これが妥当な方法であると考えています。

私たちの場合、主に次の2つの警告を懸念していました。
a. Usage of database objects with names conflicting with new reserved keywords
b. Usage of the utf8mb3 charset
(a)の問題は、予約済みキーワードと衝突している箇所をバッククォートで囲めば簡単に修正できます。(b)の問題を解決するにはちょっと対応を考える必要がありそうです。あとで説明します。

アップグレード戦略を決定する

ダウンタイムを短縮するには、みなさんご存知だと思いますが、binlogレプリケーションを利用する必要があります。

  • セカンダリクラスターを作成し、binlogレプリケーションを使用してオリジンからすべてのデータを同期します
  • セカンダリクラスターをアップグレードし、時間になったら切り替えを行います
    非常に簡単でしょう? さらに、RDS は Blue/Green Deployment をサポートしており、面倒な切り替え作業をすべて実行してくれます。
    残念ながら、いくつかtechnical debtがあり、それを解決するために、これを使用することはできません。

当時、以下2つの切り替え方法を検討していました。

  1. 簡単な方法
  • 関連するすべてのシステムをメンテナンスモードにします
    DMLクエリがないことを確認した後、レプリケーションを停止し、データベースのすべてのエンドポイントを新しいクラスターに切り替えます。
  • 私たちの場合、完了するまでに約2時間かかります。つまりダウンタイムは2時間になります
  1. より高度な方法
  • アプリケーションを修正して、MySQL の両方のバージョンで動作するようにする。移行先のクラスターをMySQL 8 にアップグレードした後、アプリケーションを新しいクラスターのリーダーに徐々に誘導します。
    ライターとリーダーのエンドポイントには内部ドメインを使用する必要があります。次に、Route53の加重ルーティングを使用して、SELECTクエリを古いクラスターから新しいクラスターに徐々にシフトします。
    下の画像のような構成です。
    DB-weighted.drawio.png
  • すべての SELECT クエリが新しいリーダー エンドポイントを使用したら、レプリケーションを停止し、最後にライターエンドポイントを切り替えます。
  • 切り替えは非常に速いため、ダウンタイムは最小限に抑えられます (数秒)
    この方法はダウンタイムが最小限であるため非常に魅力的ですが、データ整合性に対するリスクがありました。また、2時間のダウンタイムであればビジネスの観点からもSLOポリシーの観点からも許容範囲でした。慎重に検討した結果、1つ目のソリューションを選択することになりました。

ロールバック戦略

もし入れ替えた後、バグがあることがわかった場合や、 データベースのパフォーマンス低下が明らかになった場合 (MySQL 8.0 バージョンが常に 5.7 よりも優れているわけではありません。https://www.percona.com/blog/mysql-8-0-vs-5-7-are-the-newer-versions-more-problematic/ を参照してください)、以前のバージョンにロールバックするにはどうすればよいですか?
メンテナンスページを解除してユーザーがアプリケーションを使用し始めると、新しく追加されたデータは MySQL 8.0 クラスターにのみ存在し、5.7バージョンに戻すことはできません。 (理論上は、SQL ファイルを使用してダンプと復元を行うことができますが、データベースが大きい場合は数日かかり、アプリケーションのダウンタイムも長くなります)。
データをできるだけ迅速かつ安全に元に戻す対策が必要です。
最終的にこのアーキテクチャにしました。
ロールバック戦略.drawio.png

切り替え後、最新のデータでロールバックする必要がある場合は、ロールバックデータベースをプライマリに昇格できます。MySQL は、8.0から5.7へのリバースbinlogレプリケーションを公式にはサポートしていませんが、不可能ではありません。ただし、データベース内のすべてのものが両方のバージョンで機能することを確認する必要があります。

すべてのテーブルの文字セットを utf8mb4 文字セットを使用するように変更します

MySQL 8.0 以降、デフォルトのcharacterはutf8mb4で、デフォルトのcollationはutf8mb4_0900_ai_ciになりました。一方、MySQL 5.7はutf8utf8mb3のエイリアス)とutf8_general_ciです。なお、MySQL 8のutf8utf8mb3にエイリアスされています。
https://dev.mysql.com/doc/refman/8.4/en/charset.html
理論上は、文字セットがエイリアスされているにもかかわらず、2つのバージョンの MySQL 間で binlog レプリケーションは正常に動作するはずです。ただし、MySQL 5.7はすでにutf8mb4文字セットをサポートしており、utf8では一部の特殊文字(絵文字など)を処理できないためにすでに問題が発生しているため、後方レプリケーションの非互換性の問題のリスクを軽減するために、古いutf8文字セットを排除することにしました。

テーブル全体の文字セットと照合順序を変更するには

ALTER TABLE table_0 CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

テーブル内の一部の列の文字セットを変更するには

ALTER TABLE table_1
MODIFY column_0 varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,
MODIFY column_1 varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

変更された列がインデックスの一部である場合、MySQL はそのインデックスを再構築することに注意してください。インデックス再構築は大きなテーブルではIOに負荷をかけるため、しっかり別の環境で検証するか、必要な場合は Blue/Green Deploymentを使用することをお勧めします。

すべてのデータをutf8mb4を変更した後、一部のクエリが大幅に遅くなっていることに気付きました。新しい文字セットとcollationでは、一部のクエリが古いもののように正しいインデックスを使用していないことが判明しました。この問題を解決するには、FORCE INDEX オプションを使用する必要があります。

コードの変更

CI ワークフローで MySQL 8 を有効にした後、82 件のテストが失敗しました。
これはかなりの数でしたが、2週間ですべてを修正することができました。

CI (テスト環境) の準備

MySQLアップグレードプロジェクトは、他の機能の開発を停止せず並行に行いました。
そのため、互換性のない機能を修正しながら、開発フェーズで新しい機能がMySQL 8と互換性があることを確認する必要があります。
まず、すべてのテストですべてのPRに対してMySQLテストを有効にしました。数日後、GitHub Actions の請求額が高すぎることに気付きました。実行するテストの数が2倍になったので、当然料金も2倍になったのです。

結局、チームの仲間が素晴らしいアイデアを思いつきました。

  • コストダウンのため、一旦すべてのテストにはMySQL 8のCIを実行する要件を削除します
  • MySQL 8 CIのワークフローは、指定されたラベルがPRに添付されている場合にのみ実行されます
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'MySQL 8 Test')
  • 新しく開発されている機能がMySQL 8に対応しているかどうかを確認するために、mainブランチからfeatureブランチを作成し、上記のラベルを添付して MySQL 8データベースに対してテストし、1日に1回、最新のmainブランチをそれにマージします
    GitHub Actions でスケジュールされたワークフローを作成するか、どこかのサーバーからcronジョブで実装できます。

上記の対応により、GitHub Actions のコストを大幅に節約できました。

コードの修正

失敗したテストは、次の3つのセクションに分類できます。

  1. MySQL 8の予約語違反
    バッククォートを追加するだけで、問題なく実行できます。

  2. クエリ構文エラー
    mysqld_stmt_execute への引数が正しくありません エラーが報告されたテストをいくつか発見しました。
    調査した結果、一部のクエリでは、ページネーションパラメータの制限オプションを使用するために、下記のように書いています。

    limit := math.Abs​​(float64(perPage * (page - 1))),
    

    float数値であるにもかかわらず、どういうわけかMySQL 5.7で問題なく動作していました。
    適切なデータ型 (int) を修正すると、クエリは両方のMySQLで動くようになりました。

    limit := int(math.Abs​​(float64(perPage * (page - 1)))),
    
  3. クエリは、MySQL 5.7 とは異なる順序で結果を返します
    a. order by がないクエリ
    MySQL のバージョンを変更すると、テストの実行結果が変わりました。

select id from users where status = "active";
MySQL 5.7
+----+
| id |
+----+
| 10 |
| 11 |
| 12 |
| 13 |
| 14 |
+----+
MySQL 8.0
+----+
| id |
+----+
| 11 |
| 12 |
| 10 |
| 14 |
| 13 |
+----+

したがって、単純な order by id を追加するか、結果の順序が重要でない場合は、テストコードで無視できます。
参照: https://pkg.go.dev/github.com/google/go-cmp/cmp/cmpopts

b. order by を使用したクエリ
これは主に、テストデータが適切に定義されていなかったためです。
order by では、2つの行の値が同じである場合、MySQL 5.7 と 8.0 からの出力は同じではない可能性があります。

select id, lastLogin from users where status = "active" order by lastLogin;
MySQL 5.7
select id, lastLogin from users limit 5;
+----+---------------------+
| id | lastLogin           |
+----+---------------------+
| 10 | 2018-07-11 06:00:00 |
| 11 | 2018-07-11 06:00:00 |
| 12 | 2018-07-11 06:01:00 |
| 13 | 2018-07-11 06:02:00 |
| 14 | 2018-07-11 06:03:00 |
+----+---------------------+
MySQL 8.0
+----+---------------------+
| id | lastLogin           |
+----+---------------------+
| 11 | 2018-07-11 06:00:00 |
| 10 | 2018-07-11 06:00:00 |
| 12 | 2018-07-11 06:01:00 |
| 13 | 2018-07-11 06:02:00 |
| 14 | 2018-07-11 06:03:00 |
+----+---------------------+

fixtureデータを修正したら、問題は解消しました。

負荷試験

上記で説明した通りに、MySQL 8のパフォーマンスは MySQL 5.7より100%優れているわけではありません。負荷テストを実行して、アップグレード後にアプリケーションが少なくとも以前と同じパフォーマンスを発揮することを確認する必要があります。
負荷テストは非公開の環境でも実行できますが、MySQLの両方のバージョンと互換性があるようにコードを修正したので、慎重なQAを行えば、本番環境でA/Bテスト方法を採用しても問題はないと考えていました。
バックエンドサーバーとデータベースクラスター(古い 5.7 クラスターからデータを同期する) を含む「グリーン」環境を作成しました。唯一の違いは、アプリケーションが参照するリーダーデータベースのエンドポイントです。グリーン環境のサーバーは、新しい MySQL 8.0クラスターのリーダーで SELECT クエリを実行しますが、DML クエリは引き続き古いMySQL 5.7ライターで実行されます。
(私たちのワークロードは主に読み取り処理であるため、レプリカで SELECTを負荷試験すれば十分だと思っていました)
本格的なリリースではないため、最大25%のトラフィックのみが新しいクラスターを使用することを計画しました。そのため、現在実行中の環境の25%のキャパシティ (サーバー数、Aurora レプリカ数) でグリーン環境を作成しました。

load-testing.drawio.png

負荷テスト当日、DataDog ダッシュボードから注意深く監視しながら、グリーン環境へのトラフィックを段階的に増加させました (1% -> 5% -> 10% -> 15% -> 20% -> 25%)。現在の環境と比較してエラー率が10%を超える場合、新しい環境へのトラフィックを直ちに停止します。また、最も正確なデータを取得するために、すべての本番リリースを24時間一時的に停止しました。
DataDog APM のメトリックを使用すると、現在の環境と比較したすべてのAPIパフォーマンスを確認できます。

スクリーンショット 2024-12-16 at 20.11.35.png
スクリーンショット 2024-12-16 at 20.12.08.png

一部の API のパフォーマンスは向上したり、一部のAPIのパフォーマンスは少し低下したりしましたが、幸運なことに、24 時間の負荷テストの後、修正が必要のAPIはありませんでした。

いよいよ本番リリース

入れ替えの作業は下記の通り、複雑ではないです

  1. WAFからの全体的に503エラー返してメンテナンスページを表示する。システムへのすべてのトラフィックを停止します
  2. すべてのバッチジョブを停止し、実行中のすべての非同期処理が完了するまで待機します
  3. ライターにDMLクエリがないことを確認した後、binlogレプリケーションを止めて切り替えを実行します
  4. バッチジョブを再開します
  5. 社内ユーザーでアプリケーションに問題がないことを確認します
  6. メンテナンスページを落として、ユーザーが通常通りアプリケーションにアクセスできるようにします

途中で学んだ教訓 (TL;DR)

  • MySQLのメジャーバージョンのアップグレードは複雑度が高く、システムの規模によっては非常に時間のかかる作業です。弊社の場合は最低でもリリース目標の3か月前に計画する必要がありました
  • アップグレードしてそのままMySQL 8を使える訳ではないです。アプリケーションは、機能とパフォーマンスの両方で慎重にテストする必要があります
  • utf8mb4は、クエリオプティマイザーが誤ったインデックスを使用したり、インデックスをまったく使用しなかったりする可能性があるため、MySQL 5.7ではパフォーマンスが低下することを注意するべきです
  • 逆binlogレプリケーション (MySQL 8.0 から 5.7 へ) は不可能ではなく、必要な場合に備えて保険として使用できます
  • MySQLの複数バージョンに対してCI上でテストを実行する際は十分気をつけてください。CIのコストが爆発する可能性があります
SODA Engineering Blog
SODA Engineering Blog

Discussion