カウシェでは2024年5月から2025年5月にかけて、本番環境で運用中のFirestoreからCloud Spannerへの完全移行を実施しました。40以上のコレクション、数億件のデータを扱う本格的なeコマースアプリケーションにおいて、サービスを一切停止することなく移行を完了させた実体験をお話しします。
前回のikeの投稿した記事のこの部分にフォーカスしています。
この移行プロジェクトでは作業工程の約80%をShibataさんが担当し、残りの10%をチームメンバーによる手作業、最後の10%をLLMを活用して効率化しました。移行によりDB費用を大幅に削減でき、パフォーマンス面でも大きな改善を実現できました。
なぜFirestoreからCloud Spannerに移行したのか
カウシェはソーシャルECアプリとして、ユーザー、商品、グループ、購入履歴など複雑なデータ構造を持っています。
当初Firestoreを選択した背景
2020年のサービス開始時、データベース選択においてFirestoreを採用しました。
- Spannerのコスト問題:当時は100PU単位でのインスタンス作成ができず、スタートアップには高コスト
- Firestoreの魅力:従量課金で安くスタートでき、初期投資を抑えられた
サービス成長とともに顕在化したFirestoreの課題
しかし、サービスの成長とともに課題が顕在化してきました。
-
複雑なクエリの制限(複合インデックスの管理コスト)
- 抽出したデータを、アプリケーション側でソートするなんてこともあった
-
読み取り・書き込みコストの増大
- サービスの成長においてはこれが一番大きかったです
-
分析クエリのパフォーマンス不足
- firestore -> BQにCloud Functionを使ってデータを転送していました
- トランザクション処理の制約(500件制限)
- 書き込みの上限:単一トランザクションあたり500件のwrite操作制限
Cloud Spannerでは課題が解決
一方でCloud Spannerでは成長に伴う課題が解決されます。
-
複雑なクエリの実行
- SQLによる複雑な結合や集計処理が高速に実行できる
-
コスト構造の予測可能性
- ノード時間課金により従量制のコスト爆発を回避
-
分析クエリのパフォーマンス
- BigQueryとの高速連携により分析処理も効率化
- トランザクション処理の大容量化(80,000件制限)
- mutationの大幅拡張:80,000件/トランザクション、10,000件/コミット
特にカウシェのような複雑なビジネスロジックを持つアプリケーションでは、SQLの表現力がFirestoreの制約を大きく上回るメリットがありました。
移行戦略の基本設計
クリーンアーキテクチャを活用した段階的移行
カウシェのコードベースではすでにクリーンアーキテクチャを採用していたため、移行作業は主にRepositoryレイヤーの変更に集約できました。
type userRepository struct {
usecase.UserRepository
client *libspanner.Client
}
func NewUserRepository(
firestoreRepo usecase.UserRepository,
client *libspanner.Client,
) (usecase.UserRepository, error) {
return &userRepository{
UserRepository: firestoreRepo,
client: client,
}, nil
}
この設計により、SpannerリポジトリがFirestoreリポジトリを内包し、メソッド単位で段階的に移行できる仕組みを構築しました。
4段階の移行プロセス
各コレクションの移行は4段階の手順で実施しました。
1. Double Write(両DB書き込み)
リポジトリの初期化でSpannerリポジトリがFirestoreリポジトリをラップする構造を作ります。
var (
coinRepo = firestore.NewCoinRepository(firestoreClient)
productRepo = firestore.NewProductRepository(firestoreClient)
userRepo = firestore.NewUserRepository(firestoreClient)
)
productRepo = spanner.NewProductRepository(productRepo, spannerClient)
coinRepo = spanner.NewCoinRepository(coinRepo, spannerClient)
userRepo = spanner.NewUserRepository(userRepo, spannerClient)
実際の書き込み処理では、SpannerリポジトリがFirestoreリポジトリのメソッドを呼び出してから、自身のSpanner書き込みを実行します。
func (r *userRepository) PutUser(ctx context.Context, user *model.User) error {
if err := r.UserRepository.PutUser(ctx, user); err != nil {
return fmt.Errorf("Firestore write failed: %w", err)
}
return r.putUserToSpanner(ctx, user)
}
2. データ移行
バッチ処理で既存データをFirestoreからSpannerに移行しました。
const chunkSize = 1000
for i := 0; ; i++ {
users, err := firestoreRepo.GetMultiUsersByCreateTime(ctx, baseTime, chunkSize)
if err != nil {
return fmt.Errorf("GetMultiUsersByCreateTime failed: %w", err)
}
time.Sleep(500 * time.Millisecond)
if !dryRun {
if err = spannerRepo.InsertOrUpdateRawUsers(ctx, users); err != nil {
return fmt.Errorf("InsertOrUpdateRawUsers failed %w", err)
}
}
}
3. Read切り替え
Spannerからの読み取りに切り替え、書き込みは引き続き両方に実行しました。
func (r *userRepository) GetUsersByCreateTime(
ctx context.Context,
baseTime time.Time,
limit int,
) ([]*model.User, error) {
if r.UserRepository != nil {
return r.UserRepository.GetUsersByCreateTime(ctx, baseTime, limit)
}
}
func (r *userRepository) GetUsersByCreateTime(
ctx context.Context,
baseTime time.Time,
limit int,
) ([]*model.User, error) {
users := make([]*model.User, 0)
}
この段階では、Cloud Runのrevision機能を活用し、問題が発生した際は即座に前のrevisionに切り戻すことで安全にロールバックできる体制を整えていました。
また、瞬間的に古いCloud Runインスタンスへのトラフィックが残っていた場合のことも考慮し、両方に書き込んでいました。
4. Write切り替え
Spannerのみへの書き込みに変更しました。
func (r *userRepository) PutUser(ctx context.Context, user *model.User) error {
if r.UserRepository != nil {
err := r.UserRepository.PutUser(ctx, user)
if err != nil {
return fmt.Errorf("PutUser failed: %w", err)
}
}
return insertOrUpdateRow(ctx, r.client, user, toYoUser)
}
func (r *userRepository) PutUser(ctx context.Context, user *model.User) error {
return insertOrUpdateRow(ctx, r.client, user, toYoUser)
}
移行の優先順位付けとリスク管理
移行コストとリスクを考慮した段階的アプローチ
移行対象コレクションの選択では、技術的リスクをベースとしつつ、移行難易度と実際のFirestoreアクセス量を総合的に判断しました。
基本方針は「移行が比較的簡単で、Spannerに移行することで大きくインフラ費用が下がるコレクション」を優先することでした。
-
移行難易度の評価
- データ構造の複雑さ(ネストの深さ、関連性)
- 参照箇所の多さ(影響範囲の大きさ)
- ビジネスロジックの複雑さ
-
Firestoreでのアクセス量評価
- 読み書き頻度と従量課金への影響
- データ量の増加トレンド
- Key Visualizerでの実際のアクセスパターン
-
移行による費用削減効果
- Firestoreの読み書き課金削減額
- Spannerノード時間課金との比較
- 二重書き込み期間中のコスト増加
例えば、比較的影響範囲の限定的なコレクションでアクセス頻度が高く、Firestoreコストへの影響が大きいものを初期の移行対象として選択しました。逆に、ユーザーデータや購入履歴は移行による効果は大きいものの、ビジネスクリティカルで影響範囲が広いため、ノウハウが蓄積された後期に移行しました。
Key Visualizerによる監視
各段階でFirestore Key Visualizerを活用し、移行対象コレクションへのアクセスがゼロになったことを確認してから次の段階に進みました。これにより、確実にトラフィックが移行されていることを可視化できました。
以下の画像の右の方のように、アクセスがなくなると真っ黒になります。
トランザクション処理と冪等性の確保
自動リトライに対応した冪等設計
FirestoreとSpannerではclient libraryがtransactionのAbortや競合を検知すると 自動でリトライします。
そのため、ビジネスロジックは必ず冪等に設計しておく必要があります。
分割トランザクションへの対応
処理の都合上、トランザクションを2回に分けてコミットする必要がある箇所では、特別な制御フローを実装しました。1回目のFirestoreトランザクションコミット時はSpannerへのコミットをスキップし、2回目のFirestoreトランザクションが成功してからSpannerに書き込みを行う仕組みを構築しました。
IDハッシュ化による冪等性確保
ビジネスロジック側でも冪等性を保つため、処理に使用するIDをハッシュ化して一意性を担保しました。
func generateIdempotentUserID(userID string, requestID string) string {
data := fmt.Sprintf("%s:%s", userID, requestID)
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}
user.ID = generateIdempotentUserID(userID, requestID)
if err := u.userRepo.PutUser(ctx, user); err != nil {
return fmt.Errorf("failed to put user: %w", err)
}
E2Eテストによる安全性確保
データベース非依存のテスト設計
移行において最も重要だったのがE2Eテストの存在でした。外部APIの入出力のみをテストすることで、データベース実装に依存しない検証が可能でした。
func TestCreateUserProfile_OK(t *testing.T) {
res := customers.CreateUserProfile(t, client, ctx, &v1.CreateUserProfileRequest{
UserProfileIconId: "1",
UserProfileNickName: "Bob(自動テストユーザー)",
})
if res.Result.Code != v1.CreateUserProfileResponse_Result_CODE_OK {
t.Errorf("res.Result.Code is %v, want CODE_OK", res.Result.Code)
}
...
}
E2Eテストにおけるリポジトリ抽象化
E2Eテストでは基本的にAPIを呼び出してテストリソースを作成する方針にしていました。これにより、データベース実装の変更がテストに影響しない設計を実現できました。
func TestCreateUserProfile_OK(t *testing.T) {
res := customers.CreateUserProfile(t, client, ctx, &v1.CreateUserProfileRequest{
UserProfileIconId: "1",
UserProfileNickName: "Bob(自動テストユーザー)",
})
if res.Result.Code != v1.CreateUserProfileResponse_Result_CODE_OK {
t.Errorf("res.Result.Code is %v, want CODE_OK", res.Result.Code)
}
}
この方針により、FirestoreからSpannerへの移行中もテストを書き換える必要がありませんでした。
移行プロセスの型化とLLM活用
知見の蓄積と作業の標準化
複数のコレクションで移行を繰り返すうちに、前述の4段階プロセス(Double Write → データ移行 → Read切り替え → Write切り替え)が確立されました。移行パターンが型化されてくると、実装すべきコードも決まったパターンに収束していきました。
結果として、知見が蓄積された後期の移行作業では、LLMに移行パターンを学習させることで、新しいコレクションの移行コードを自動生成できるレベルまで標準化が進みました。40以上のコレクション移行を通じて培った移行ノウハウが、最終的には再現可能な手法として確立できたのです。
技術的な注意点と学び
Spannerインデックス設計の重要性
Spannerではホットスポットを避けるインデックス設計が重要でした。コードレビューでは特にインデックス設計を重点的にチェックしました。
実際のデータ量は分かっているので、データ移行をする前にしっかりと決め切る必要がありました。
ドキュメント構造の浅さが幸い
カウシェでは幸い、Firestoreのドキュメント階層が浅く、RDBのテーブルにマッピングしやすい構造でした。これにより、NoSQLからRDBへの移行が比較的スムーズに進みました。
Cloud Runによる安全なデプロイメント
サーバーがCloud Runで動作していたため、Read切り替え時に問題が発生した場合は、即座にrevisionを切り替えてロールバックできる体制が整っていました。これにより、移行リスクを大幅に軽減できました。
コスト削減とパフォーマンス向上
移行により以下の改善を実現しました。
- DB費用の大幅削減:Firestoreを利用したいた時と比較して93%(!)減りました
- 複雑なクエリのパフォーマンス向上:SQLによる効率的な結合・集計処理
- 分析クエリの実行時間短縮:BigQueryとの連携による高速分析
- 開発効率の向上:SQLの表現力による実装の簡素化
まとめ
1年間にわたるFirestoreからCloud Spannerへの移行プロジェクトを通じて、大規模プロダクション環境での無停止データベース移行の実現可能性を証明できました。
成功のポイントは以下の通りです。
- クリーンアーキテクチャの活用:リポジトリパターンにより影響範囲を限定
- 段階的移行:4段階のプロセスでリスクを最小化
- E2Eテストによる安全網:データベース実装に依存しない検証
- 慎重な監視:Key Visualizerによる確実な切り替え確認
- Cloud Runの活用:revision切り替えによる即座のロールバック体制
この経験を通じて、適切な設計と慎重な実行により、複雑なプロダクション環境でも安全にデータベース移行を実現できることを学びました。今後同様の移行を検討している方の参考になれば幸いです。
カウシェで働くことに興味を少しでも持ってもらえた方はぜひともカジュアル面談をしましょう!
直近開催するイベントの案内です!
Views: 0