数十億レコードをゼロダウンタイム移行 ── SQL ServerからAurora MySQLへのデュアルデータベース戦略

数十億レコードをゼロダウンタイム移行 ── SQL ServerからAurora MySQLへのデュアルデータベース戦略

はじめに

こんにちは。商品基盤部・商品基盤2ブロックの小原です。私が所属するブロックではお気に入り機能のマイクロサービスを担当しています。

ZOZOTOWNではさらなる成長に向けて、さまざまなリプレイスプロジェクトが進行中です。本記事では、その中でもお気に入り機能のリプレイスについて紹介します。SQL ServerからAurora MySQLへ数十億レコードをゼロダウンタイムで移行するために設計したデュアルデータベース戦略を解説します。

こんな方に読んでもらいたい

  • 段階的なマイクロサービス移行戦略を策定する担当者
  • ゼロダウンタイム移行の手法を探すアーキテクト
  • Spring BootでマルチDataSourceを実装する開発者
  • 数十億レコード規模の移行戦略に関心があるデータエンジニア
  • オンプレミスからAWS移行でダウンタイム最小化に課題を抱えるチーム

なぜデュアルデータベース構成を採用したのか

背景:オンプレミスからクラウドへの段階的移行

既存システムの状況

ZOZOTOWNでは各機能のマイクロサービス化とAWS移行を段階的に進めており、既に多くの機能がクラウド化されています。お気に入り機能はこの移行プロジェクトの対象の1つで、移行前はオンプレミス環境のSQL Serverで運用されていました。モバイルアプリ(iOS、Android)やウェブアプリ(スマートフォン、PC)からオンプレミスバックエンドへアクセスし、お気に入り機能を提供してきました。

既存のオンプレミスシステムを分析し、お気に入り機能のマイクロサービス化を検討しました。検討の過程で、システム構成に起因する課題が明らかになりました。お気に入り機能は複数の画面や機能から呼び出されています。複数箇所からの呼び出しに対応するため、クライアントアプリケーションの接続先切り替えは機能単位で段階的に進める必要があります。

切り替えを終えた機能は新しいお気に入りマイクロサービスを呼び出します。未切り替えの部分は従来のオンプレミスバックエンドを利用し続けます。

オンプレミスバックエンドとマイクロサービスの双方に並行してリクエストが送られます。同じお気に入りデータに対して両経路から読み書きが行われ、データベース分離時の整合性確保が大きな課題となりました。

お気に入り機能の概要

ZOZOTOWNではユーザーの購入体験を高めるためにお気に入り機能を提供しています。

  • 商品お気に入り:商品をリストに登録し、後から一覧表示や購入へつなげる
  • ブランドお気に入り:ブランドを登録し、商品一覧や新着情報を受け取る
  • ショップお気に入り:ショップを登録し、ショップの取扱商品を取得する

お気に入りデータの規模

2025年9月時点のデータ量は次のとおりで、今も増加しています。

  • 商品お気に入り:数十億レコード(メインデータ)
  • ブランドお気に入り:数億レコード
  • ショップお気に入り:数千万レコード

段階的な移行アプローチ

3つのフェーズで段階的に移行を進め、現在はフェーズ1で本番稼働しています。データベース間の同期にはEmbulkを利用し、SQL ServerからAurora MySQLへの安定的な差分同期を実現します。

  • フェーズ1(本番稼働中) - SQL Server単体運用
  • フェーズ2(予定) - デュアルデータベース運用
  • フェーズ3(目標) - Aurora MySQL単体運用

デュアルデータベース戦略の採用

数十億レコードの無停止移行を実現するため、さまざまな移行方式を検討しました。検討の結果、デュアルデータベース戦略を採用し、ゼロダウンタイムでの安全な移行を実現します。

オンプレミスのモノリシックシステムからAWSマイクロサービスへの移行前の構成と、数十億レコードの無停止移行における技術的課題を示した構成図

データ整合性の課題

  • 同じユーザーのデータが2つのシステムに分散
  • リアルタイムでの同期が困難
  • 機能によってお気に入り状態が異なって見える

移行方式の比較検討

無停止移行を実現するため、複数のアプローチを検討しました。各方式のメリット・デメリットを評価した結果は次のとおりです。

  • ビッグバン移行不採用
    • 数十時間のダウンタイムが発生する
  • データベースレプリケーション不採用
    • オンプレミスSQL Server→Aurora MySQL間の直接レプリケーションが困難
    • コストも高額
  • ETL/CDC同期(定期的なデータ抽出・変換・ロード)一部採用
    • 分単位の遅延があり、リアルタイム要件を満たさない
  • デュアルデータベース✓ 採用
    • 完全なゼロダウンタイムを実現できる

選択した段階的な移行戦略

デュアルデータベース戦略の概要を次の図に示します。

SQL ServerからAurora MySQLへの段階的移行を実現する3つのフェーズ(SQL Server単体、デュアル運用、Aurora MySQL単体)の全体構成とデータフロー図

ゼロダウンタイムを実現するポイント

  • SQL Serverが常にメインデータベースとして稼働
  • Aurora MySQLは段階的に同期状態を構築
  • 設定変更のみでフェーズ切り替えが可能

デュアルデータベース戦略のリスクとトレードオフ

デュアルデータベース構成にはリスクもあります。実際に直面した課題と対策を以下にまとめました。

デュアルデータベース戦略が向かないケースも存在します。

  • 小規模データ(数百万レコード未満)での移行
  • ダウンタイムが許容できるシステム
  • 運用チームのリソースが限定的な場合
  • 短期間で移行完了が求められる場合

運用面のリスクと対策を整理した表です。

リスク項目 具体的な課題 対策・軽減策
運用負荷の増加 2つのデータベースの監視・メンテナンス・チューニングが必要 監視基盤の統一、SREチーム体制強化
障害時の複雑化 どちらのデータベースで障害が発生したか、影響範囲の特定が困難 詳細なログ設計、障害対応の手順書整備
データ不整合リスク 非同期書き込みによる遅延や失敗時の不整合データ発生 定期的な整合性チェック、補正バッチ処理

技術面の制約と対応方針を整理した表です。

制約項目 影響 対応方針
Spring Events(アプリケーション内イベント機構)の信頼性 プロセス停止時のイベントロスト Embulkによる定期補正で補完
メモリ使用量の増加 2つのコネクションプールとイベント処理でメモリを消費 JVMチューニング、適切なプール設定
トランザクション複雑化 2つのデータベース間で分散トランザクションを扱う必要 結果整合性(eventual consistency)で妥協

ビジネス面で考慮した点は次のとおりです。

  • 移行期間の長期化:デュアル運用期間が数か月に及ぶ可能性
  • コスト増:Aurora MySQLとSQL Serverの並行運用コスト
  • チーム学習コスト:新技術習得のための時間投資

DataSourceとトランザクション制御による段階的な移行戦略

数十億レコードを安全に移行するため、プロパティでデータベースを段階的に切り替える仕組みを開発しました。

DataSource設定とプロパティ制御

環境変数やプロパティファイルの設定値を変更するだけでフェーズを切り替える仕組みを構築しました。以下はAurora MySQLの接続設定例です。

# application.yml
app:
  config:
    database: dual # mssql → dual → mysql の段階的変更
    datasources:
      writer:
        jdbc-url: jdbc:aws-wrapper:mysql://writer-host:3306/favorite-db
        read-only: false
        aws:
          wrapper-plugins: failover
          wrapper-dialect: aurora-mysql
      reader:
        jdbc-url: jdbc:aws-wrapper:mysql://reader-host:3306/favorite-db
        read-only: true
      mssql:
        etc:
          jdbc-url: jdbc:sqlserver://mssql-host:1433;database=zozoetc
          read-only: false

@ConditionalOnPropertyによるRepository切り替え

Spring Boot 3 + Java 21をベースにした社内標準スタックでの実装例です。プロパティ値に応じたRepositoryをDIコンテナに注入します。

重要なポイントは2つあります。@ConditionalOnPropertyでフェーズごとにRepositoryを切り替えます。デュアルモード時には非同期Spring Eventsを活用します。

// フェーズ1: SQL Server単体モード(現在)
@Repository
@ConditionalOnDatabaseMssqlEnabled  // app.config.database=mssql時に有効
public class FavoriteItemMssqlRepository implements FavoriteItemRepository {

  @Override
  public FavoriteItem save(SaveCommand command) {
    // フェーズ1はシンプル: SQL Serverにのみ保存
    return sqlServerDao.insert(command);
  }
}

// フェーズ2: デュアルモード(最重要部分)
@Repository
@ConditionalOnDatabaseDualEnabled   // app.config.database=dual時に有効
public class FavoriteItemDualRepository implements FavoriteItemRepository {

  @Override
  public FavoriteItem save(SaveCommand command) {
    // 1. メインのSQL Serverに同期的に保存(確実性優先)
    var result = sqlServerDao.insert(command);

    // 2. デュアル戦略のキーポイント: Aurora MySQLへの非同期反映
    // Spring Eventsでの非同期データ同期の要所
    applicationEventPublisher.publishEvent(
        new FavoriteItemSavedEvent(command, result.getId()));

    return result;
  }
}

// フェーズ3: Aurora MySQL単体モード(目標)
@Repository
@ConditionalOnDatabaseMySQLEnabled
public class FavoriteItemMySqlRepository implements FavoriteItemRepository {

  @Override
  public FavoriteItem save(SaveCommand command) {
    // Aurora MySQLにのみデータを保存
    return mysqlDao.insert(command);
  }
}

Repository実装では、以下の3つの設計を組み込みました。

  • 条件付きBean登録:@ConditionalOnPropertyで設定値に応じた実装を注入する
  • デュアル戦略の本質:フェーズ2でSQL Serverへ同期書き込み後にSpring Eventsで非同期反映する
  • 段階的移行:設定変更のみで3フェーズを切り替えられ、実装コードを変更しない

AbstractRoutingDataSourceによるReader/Writer自動振り分け

Aurora MySQLの読み取り専用レプリカを使い、トランザクション種別に応じて接続先を自動切り替えします。

Reader/Writer分離により以下を実現します。

  • Writer(プライマリ)で書き込みを高速化し、一貫性を確保する
  • Reader(リードレプリカ)で読み込みを分散し、プライマリの負荷を軽減する
  • 数十億レコードでも高速な読み取りを維持する
@Configuration
public class DataSourceConfig {

  @ConditionalOnMySQLDataSourceRequired
  @Bean
  public DataSource mysqlDataSource() {
    final var routingDataSource = new AbstractRoutingDataSource() {
      @Override
      protected Object determineCurrentLookupKey() {
        // トランザクションの読み取り専用フラグで自動振り分け
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? RouteFor.READER    // @Transactional(readOnly = true) → Reader
            : RouteFor.WRITER;   // @Transactional → Writer
      }
    };

    // Reader/Writer両方のDataSourceを設定
    routingDataSource.setTargetDataSources(Map.of(
        RouteFor.READER, createReaderDataSource(),
        RouteFor.WRITER, createWriterDataSource()
    ));

    return routingDataSource;
  }

  public enum RouteFor {
    READER,   // 読み取り専用レプリカ
    WRITER,   // プライマリ(書き込み用)
  }
}

トランザクションのreadOnlyフラグで接続先を自動で振り分けます。

  • @Transactional(readOnly = true) → Readerに自動ルーティングされる
  • @Transactional → Writerに自動ルーティングされる
  • アプリケーションコードは接続先を意識せず、Spring Bootが制御する

将来的な削除を見据えたトランザクション制御の設計

Aurora MySQLでの単独運用を最終ゴールとし、将来的なコード削除を見据えた設計を採用しました。

Aurora MySQLのDataSource@Beanで登録し、Spring Boot標準のトランザクション制御機構を使います。フェーズ3での単独運用を見据えてクリーンに実装しました。ZOZO社内の標準的な使い方に沿うため、長期運用しやすい構成です。

SQL Serverは将来削除する予定のため、Spring Bootの標準機構を使わず独立させました。削除時の影響を最小限に抑えられます。

項目 Aurora MySQL SQL Server
DataSourceのBean登録 @Beanで登録 Bean登録せず独立管理
アプリケーション側の記述 @Transactional(宣言的) @Transactional(宣言的)
内部のトランザクション実装 Spring Boot標準マネージャ AOPでTransactionTemplate実行
トランザクション境界の管理 Spring Bootが自動管理 TransactionTemplateが制御
例外時のロールバック Spring Boot標準で自動 TransactionTemplate内で処理
フェーズ3時点のコード 残す 削除
設計思想 長期運用を前提とした標準実装 削除を前提とした分離設計

SQL Serverの実装は削除を前提に設計しました。2つのコンポーネントで構成されています。

1. SQL Server設定クラス(Bean登録なし)

Spring Bootの標準機構を使わず、独自のTransactionManagerTransactionTemplateを管理します。独立した管理により、フェーズ3での削除時に他のコンポーネントへの影響を最小限に抑えられます。

@Configuration
@ConditionalOnMssqlDataSourceRequired  // SQL Serverが必要なフェーズでのみ有効
public class MssqlDatabaseConfig {
    private final DataSource dataSource;
    private final PlatformTransactionManager transactionManager;

    public MssqlDatabaseConfig(ApplicationProperties properties) {
        // HikariCP設定でSQL Server接続(実際のプロダクション設定)
        this.dataSource = new TransactionAwareDataSourceProxy(createMssqlDataSource(properties));
        // 重要: Spring Boot標準と分離した独自管理(@Primaryではない)
        this.transactionManager = new DataSourceTransactionManager(dataSource);
    }

    // 書き込み用TransactionTemplate(デフォルト分離レベル)
    @Bean
    public TransactionTemplate mssqlEtcTransactionTemplate() {
        var template = new TransactionTemplate(transactionManager);
        template.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT);
        return template;
    }

    // パフォーマンス最適化: 読み取り用はREAD_UNCOMMITTED
    @Bean
    public TransactionTemplate mssqlEtcTransactionTemplateForSelect() {
        var template = new TransactionTemplate(transactionManager);
        template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
        return template;
    }
}

2. AOPによる@Transactionalの自動検知と適用

UseCase層のメソッドに付与された@TransactionalアノテーションをAOPで検知し、readOnlyの値に基づいてTransactionTemplateを選択して実行します。開発者は通常通り@Transactionalを使うだけで、SQL Serverのトランザクションが自動制御されます。

@ConditionalOnMssqlDataSourceRequired
@Aspect
public class MssqlTransactionAop {
    private final TransactionTemplate mssqlEtcTransactionTemplate;
    private final TransactionTemplate mssqlEtcTransactionTemplateForSelect;

    // UseCase層パッケージ全体でTransactionTemplateを自動適用
    @Around("execution(public * jp.zozo.favorite.api.usecase..*(..))")
    public Object transactionJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
        // @Transactional(readOnly = true)の有無でパフォーマンス最適化
        var transactionTemplate = isReadOnly(joinPoint)
            ? mssqlEtcTransactionTemplateForSelect  // READ_UNCOMMITTEDで高速化
            : mssqlEtcTransactionTemplate;          // DEFAULTで確実性

        // TransactionTemplate.execute()でプログラマティック制御
        return transactionTemplate.execute(status -> {
            try {
                // フェーズ3でSQL Server削除時に該当AOPも同時に削除予定
                return joinPoint.proceed();
            } catch (Throwable e) {
                throw new RuntimeException(e);
            }
        });
    }

    // @Transactional(readOnly = true)の有無を検出するヘルパーメソッド
    private boolean isReadOnly(ProceedingJoinPoint joinPoint) {
        var method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        // メソッド上の@Transactionalアノテーションを取得し、readOnly属性をチェック
        return Optional.ofNullable(AnnotationUtils.findAnnotation(method, Transactional.class))
            .map(Transactional::readOnly)  // readOnly=trueならtrue、falseまたは未設定ならfalse
            .orElse(false);                // @Transactionalが無い場合はfalse
    }
}

UseCase層での使用例

UseCase層で@Transactionalを使うと、プロパティ設定に応じてフェーズごとのデータベースを利用します。

@Service
@RequiredArgsConstructor
public class SaveFavoriteItemUseCase {

  private final FavoriteItemRepository favoriteItemRepository;

  @Transactional  // 書き込み用Writerデータベースに自動ルーティング
  public FavoriteItemDTO handle(SaveCommand command) {
    // フェーズ1: SQL Serverのみ、フェーズ2: デュアルモードで自動切り替え
    return favoriteItemRepository
        .findByUserAndItem(command.getUserId(), command.getItemId())
        .filter(FavoriteItem::isActive)
        .map(item -> item.update())  // 既存アイテムの更新
        .map(favoriteItemRepository::save)  // DBへ保存
        .orElseGet(() -> favoriteItemRepository.save(command))  // 新規作成
        .toDTO();
  }
}

@Service
@RequiredArgsConstructor
public class GetFavoriteListUseCase {
  private final FavoriteItemRepository favoriteItemRepository;

  @Transactional(readOnly = true)  // 読み取り用Readerデータベースに自動ルーティング
  public FavoriteListDTO handle(GetListCommand command) {
    // フェーズ2以降: Aurora MySQL Readerでパフォーマンス最適化
    return favoriteItemRepository.findFavoriteList(command);
  }
}

Spring Eventsによる非同期データベース同期

デュアルモードではSpring Eventsを活用します。SQL Serverへの書き込み成功後にAurora MySQLへ非同期で反映します。

@RequiredArgsConstructor
@ConditionalOnDatabaseDualEnabled  // デュアルモード時のみ有効
@Service
@Transactional
public class DataSyncEventListener {
  private final DataSyncRepository dataSyncRepository;

  @Async
  @EventListener
  public void handleSaveEvent(FavoriteItemSavedEvent event) {
    // Aurora MySQLへの非同期データ同期
    dataSyncRepository.syncToMySQL(event);
  }

  @Async
  @EventListener
  public void handleUpdateEvent(FavoriteItemUpdatedEvent event) {
    // Aurora MySQLへの非同期データ同期
    dataSyncRepository.syncToMySQL(event);
  }

  @Async
  @EventListener
  public void handleDeleteEvent(FavoriteItemDeletedEvent event) {
    // Aurora MySQLへの非同期データ同期
    dataSyncRepository.syncToMySQL(event);
  }
}

// カスタムアノテーション
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ConditionalOnProperty(value = "app.config.database", havingValue = "dual")
public @interface ConditionalOnDatabaseDualEnabled {}

クライアントアプリケーション移行期の課題と補完

フェーズ2ではマイクロサービスとオンプレミスバックエンドが混在します。オンプレミス経由でSQL Serverに直接書き込まれたデータは、Embulk同期で補完します。

データ整合性の検証機能

デュアル運用中のデータ品質を保つため、読み取り時の検証を自動化しました。

@Service
@ConditionalOnDatabaseDualEnabled
@Transactional(readOnly = true)
public class DatabaseVerificationEventListener {
  private final DatabaseVerificationRepository databaseVerificationRepository;

  @Async
  @EventListener
  public void listen(GetFavoriteItemListEvent event) {
    // 読み取り処理後に非同期で検証を実行
    databaseVerificationRepository.verify(event);
  }
}

@Repository
@ConditionalOnDatabaseDualEnabled
public class DatabaseVerificationDomaRepository implements DatabaseVerificationRepository {
  private final DataDifferenceLogger dataDifferenceLogger;

  @Override
  public void verify(GetFavoriteItemListEvent event) {
    // Aurora MySQLから同一条件でデータを取得
    var mysqlData = mysqlDao.selectByCommand(event.command());

    // SQL Serverの結果とAurora MySQLの結果を比較
    dataDifferenceLogger.difference(
        event.dto(),      // SQL Serverから取得済みの結果
        mysqlData,        // Aurora MySQLから取得した結果
        event.getUserId()
    );
  }
}

@Component
@ConditionalOnDatabaseDualEnabled
public class DataDifferenceLogger {

  public <T> void difference(T mssqlData, T mysqlData, String userId) {
    if (Objects.equals(mssqlData, mysqlData)) {
      log.debug("Data is same, userId = {}", userId);
    } else {
      // 差分検出時はログとSentryへ送信
      log.warn("Data is different, userId = {}", userId);

      Sentry.withScope(scope -> {
        scope.setTag("userId", userId);
        scope.setExtra("mssqlData", String.valueOf(mssqlData));
        scope.setExtra("mysqlData", String.valueOf(mysqlData));
        Sentry.captureException(new DataDifferenceException(userId));
      });
    }
  }
}

両データベースの結果を比較し、差分はSentryで検知します。運用チームがすぐ対応できる仕組みを構築しました。

3つのフェーズによる段階的移行

デュアルデータベース構成への移行を3つのフェーズに分けて進めています。

フェーズ1: SQL Server単体運用(現在)

現在はフェーズ1で本番稼働中です。既存のSQL Serverを活用しながら、マイクロサービス化を先行して進めています。

フェーズ1におけるSQL Server単独運用の構成図:お気に入りマイクロサービスがSQL Serverのみを使用し、EmbulkでAurora MySQLへの初期データ移行を準備している状態

フェーズ1ではSQL Serverのみでマイクロサービスを稼働し、Aurora MySQLへのデータ移行準備を並行で進めています。

フェーズ2: デュアルデータベース運用(予定)

両データベースを並行稼働させる移行期間で、データ整合性を保ちながら新システムへ切り替えます。

フェーズ2のデュアルDB運用構成図:SQL ServerとAurora MySQLへの二重書き込みとSpring Events/Embulk同期

SQL Serverをメインとし、Spring Eventsで非同期にAurora MySQLへ複製します。オンプレミス経由の変更はEmbulk同期で補完します。

フェーズ3: Aurora MySQL単体運用(目標)

最終目標であるクラウドネイティブ環境への完全移行を目指します。

フェーズ3におけるAurora MySQL単独運用の最終構成図:SQL Server関連コードを完全削除し、Aurora MySQLのみで稼働するクリーンなマイクロサービス構成

最終的にSQL Server関連コードを削除し、Aurora MySQLのみで運用します。

今回の学び

数十億レコードの無停止移行を実現するデュアルデータベース戦略について、設計思想から実装詳細まで解説しました。

今回の実装で工夫した点は4つです。

  • プロパティ切り替えによる3フェーズの段階的移行
  • @Transactionalの統一APIで異なる内部実装を使い分け
  • 将来のコード削除を見据えた意図的な設計分離
  • Spring EventsとEmbulk同期を組み合わせた整合性確保

現在はフェーズ1で安定稼働中です。フェーズ2・3に向けて本番環境とテスト環境の構築、デュアルデータベース運用のテスト手法の確立を進めています。


ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。興味がある方は以下の採用情報をご確認ください。

corp.zozo.com

カテゴリー