18
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Firebase Cloud Messaging]nest.js大量ユーザーにPush通知を「いい感じ」に配信する方法

Posted at

大量ユーザーにPush通知を「いい感じ」に配信する方法を紹介します

ここでのいい感じの定義は、
「一斉送信してもパンクせず、部分的な失敗を許容しながら、再試行で自然に回復するような、運用に優しい仕組み」
のことを指します。

目的

例えば1000人以上のユーザーなど大量ユーザーに Push 通知を一斉送信するとき、
何も考えずに Promise.all() で投げるとこうなります!

  • FCMから429エラー(レート制限)
  • ネットワーク混雑で再試行が重なり雪だるま式に悪化
  • 一部ユーザーに通知が届かない

そこで今回は、FCM(Firebase Cloud Messaging)向けに最適化された “いい感じ” の配信設計を
ドキュメントのベストプラクティスを読みながら実装したので紹介します。

NestJS を例にしていますが、考え方は他のバックエンドでも同じです。

全体フロー

    A[Cron: 毎日18時] --> B[通知バッチ開始]
    B --> C[DBから対象ユーザー取得]
    C --> D[10並列で配信開始]
    D --> E[指数バックオフ + ジッターで再試行]
    E --> F[成功: 続行]
    E --> G[失敗: ログに記録]

コアアイデア
1️⃣ p-limitで並列度を制限する

await Promise.all(
  users.map((user) =>
    this.limit(async () => { // ← 最大10並列
      await this.retryWithExponentialBackoff(() =>
        this.sendNotificationToUser(user),
      );
    }),
  ),
);

制限なしだと → 1000件を同時送信 → APIがパンクする

制限あり(10並列)なら → 負荷を分散して安全に送信できる

実際の挙動イメージ

時刻 実行中のユーザー
18:00 User 1〜10
18:01 User 11〜20
18:02 User 21〜30

これで「滑らかに流れるようなバッチ処理」になります。

今回は10並列で行いましたg、FCM公式には「具体的に〇〇並列が良い」といった数値は出ていませんのでドキュメントには「レートを下げる」「段階的にスケールアップする」などの方針はあります。並列数の上限・最適値はアプリケーション/トークン数/地域/ネットワーク条件によって変わるためです。

よって、日運用でモニタリングしながら調整すると良いでしょう。

2️⃣ 指数バックオフ + ジッターで再試行を分散

private async retryWithExponentialBackoff<T>(
  sendRequest: () => Promise<T>,
  retries = 5,
  baseDelay = 10000, // 10秒
): Promise<T> {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      return await sendRequest();
    } catch (error) {
      const exponentialDelay = baseDelay * 2 ** attempt;
      const jitter = 0.75 + Math.random() * 0.5; // ±25%
      const delay = exponentialDelay * jitter;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

ジッターとは?
同時再試行をずらして、「一斉再攻撃」状態を防ぐためのランダム化です。

ジッターなし → 全員が10秒後に再試行 → また同時に失敗

ジッターあり → 再試行タイミングがズレる → 成功率UP

「最大再試行5回」という設定を今回示していますが、公式ドキュメントでは明確に “5回” と記されてはいませんので「最大間隔・最大試行回数を設けて無限ループを避けるべし」という記述はあります。
アプリ・ビジネス要件(通知の“新鮮さ”/期限/重要度)によって回数を変えてください。

3️⃣ 失敗しても他ユーザーには影響なし

try {
  await this.retryWithExponentialBackoff(() =>
    this.sendNotificationToUser(user),
  );
} catch (error) {
  logger.error(`通知送信失敗: user=${user.id}`, error);
}

1人が失敗しても、他の999人の処理は継続。
部分的な失敗を許容することで、全体の安定性を最優先します。

Firebase公式推奨にも準拠
ベストプラクティス
最小再試行間隔10秒以上 ✅ FCM側のリソース回復を待つ
ジッター(±25%) ✅ トラフィック集中を回避
指数バックオフ ✅ 一時的なエラーを自然に回復
最大再試行5回 ✅ 無限ループ防止
4xxエラーは再試行しない ✅ 不要な再試行を排除
🔹 並列度(p-limit)

スタートは 10 がおすすめそう。

実環境でFCMのレスポンスタイムを見ながら調整していくとよさそうです。(20〜30程度まで可)

再試行間隔

基本は 10 → 20 → 40 → 80 → 160 秒

ネットワーク障害が長引いても「5分で諦める」のが現実的っぽい

ログ戦略

warn: 一時的な失敗

error: 5回再試行しても失敗

info: 通知成功件数/失敗件数を集計してSlack通知などすると検知が早そう

まとめ
ポイント 効果
並列度制限 APIを守る
指数バックオフ 自動回復
ジッター 同時再試行を防ぐ
局所的失敗許容 安定稼働
FCMベストプラクティス準拠 実運用に強い

一言でいうと:
「通知バッチは、スピードより“安定性”を優先すべき」
上記を参考に実装いただければ大きな規模規模でも“いい感じ”に動くのでは。

株式会社シンシア
株式会社xincereでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら

シンシアでは、年間100人程度の実務未経験の方が応募し技術面接を受けます。
その経験を通し、実務未経験者の方にぜひ身につけて欲しい技術力(文法)をここでは紹介していきます。

18
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?