Lewati ke isi

04 · 批量发送与广播模式

目标读者:要做批量推送的后端开发者 核心问题:几千、几万、几十万用户,怎么选 Token 批量 vs Topic 广播?会不会被限流? 前置: 03-Admin-SDK实战 后续: 05-消息载荷与平台定制 最后更新:2026-04-11


一、批量 API 全家福

FCM 批量发送有 三种 API,对应三类业务场景。

API 用途 单次上限
send(message) 单条消息(单 token / 单 topic / 单 condition) 1
sendEachForMulticast(multicastMessage) 同一内容发给多个 token 500 tokens/次
sendEach(messages[]) 不同内容发给不同接收者(混合 token/topic/condition) 500 messages/次
subscribeToTopic(tokens, topic) 服务端批量把 token 订阅到 topic 1,000 tokens/次
send({topic, ...}) 一次请求扇出给 topic 所有订阅者 无订阅人数限制

1.1 ⚠️ 关于已废弃的 sendAll / sendMulticast

不要在新代码里用 sendAllsendMulticast

  • sendAll → 改用 sendEach
  • sendMulticast → 改用 sendEachForMulticast

旧 API 是单次 HTTP 批量请求,受 FCM 后端取消批量端点的影响。新 API 在客户端做并发,每条消息独立 HTTP 请求,更可靠,每条独立成败(一条失败不拖垮整批)。


二、三种 API 详解

2.1 sendEachForMulticast —— 同内容广播给 token 列表

这是 iGaming 的主用法(当你还没 topic 化时)。

输入:MulticastMessage,包含一份 message 主体 + tokens: string[](≤500)

返回:BatchResponse

  • successCount —— 成功数
  • failureCount —— 失败数
  • responses[] —— 每一条与 tokens[i] 顺序一一对应

内部实现:SDK 端并发发起 N 个独立 HTTP v1 请求 → 每条独立成败 → 允许逐条识别并处理失败的 token。

2.2 sendEach —— 异质消息批量

输入:Message[](≤500)

每条 message 可以是 token / topic / condition 任一目标,可以每条不同的 notification/data。

典型场景:一次任务里给 VIP 发 A 模板、给普通用户发 B 模板。

2.3 Topic 广播(大规模推荐)

发送:send({ topic: 'all_users', notification: {...} }) —— 一次请求触发后端 fanout。

关键指标: - 后端 fanout 实际可达约 10,000 QPS(单项目) - 但 fanout 容量在所有项目间共享,不保证 - 项目内同时并发 fanout 越多,单 fanout 速率越低 - 一个项目最多 1,000 个并发 fanout(超出会被拒或推迟) - 单 app 实例最多订阅 2,000 个 topic

❗ 核心建议:同一时刻只跑一个 fanout(多个定时任务要错开几分钟,不要同时触发)。

2.4 Condition(多 topic 布尔组合)

语法:

"'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)"

限制:最多支持 5 个 topic 的布尔组合。

典型 iGaming 场景: - 'vip' in topics && 'ios' in topics —— VIP ∩ iOS 用户 - 'all_users' in topics && !('opt_out' in topics) —— 已订阅且未取消推送 - 'platform_a' in topics && 'indonesia' in topics —— 平台 A 在印尼的用户


三、Token vs Topic 选型决策树

  要推送多少用户?
        │
        ├─ < 500 人(有确定 token 列表)
        │    → sendEachForMulticast 一次请求
        │
        ├─ 500 ~ 5,000 人
        │    → sendEachForMulticast + 分批(Promise.all 并行多批)
        │
        ├─ > 5,000 人 且频繁推
        │    → 客户端预订阅 topic → 发到 topic(1 次 API)
        │
        ├─ 广播全量用户,无个性化
        │    → topic(首选)
        │
        ├─ 按用户分群广播
        │    → 多 topic + condition 表达式
        │
        └─ 个性化内容(每人不同优惠码)
             → sendEach(每人一条 message)

iGaming 运营场景的黄金答案:

预先把所有玩家 token 订阅到 all_users topic,之后每次推送只调 1 次 API。把"5 次 × N 平台 × M 用户"压缩到"5 次 × N 平台"。


四、性能对比(10,000 设备)

方案 请求次数 耗时粗估 特点
sendEachForMulticast 分 20 批(每批 500) 20 次 multicast,内部并发 20 × 500 = 10,000 个 HTTP 请求 几秒 ~ 十几秒 ✅ 每个 token 独立成败可逐个清理
发到 topic(已订阅) 1 次 API 请求,后端 fanout 几秒 ~ 几分钟(fanout 不保证瞬时) ❌ 不返回每个 token 状态

取舍: - 要精准统计交付率、要清理失效 token → Token 批量 - 追求最简代码、最低 API 调用数 → Topic - iGaming 每日定时优惠码推送:建议 Topic,清理失效 token 放在单独的维护任务里跑


五、完整代码:Node.js sendEachForMulticast 批量循环

// scripts/send-multicast.js
// 作者: Bob
// 用途: 给一个 Firebase 项目的 N 个 token 批量推送优惠码(同内容)

import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
import { readFileSync } from 'fs';

initializeApp({
  credential: cert(JSON.parse(readFileSync('./service-account.json', 'utf8'))),
});

const BATCH_SIZE = 500; // FCM 单次 multicast 硬上限

/**
 * 把一个长 token 数组按 500 切片,逐批 sendEachForMulticast
 * 返回汇总结果 + 失效 token 列表
 */
async function sendInBatches(allTokens, payload) {
  const messaging = getMessaging();
  let totalSuccess = 0;
  let totalFailure = 0;
  const tokensToDelete = [];

  for (let i = 0; i < allTokens.length; i += BATCH_SIZE) {
    const batch = allTokens.slice(i, i + BATCH_SIZE);
    const multicastMessage = { ...payload, tokens: batch };

    const response = await messaging.sendEachForMulticast(multicastMessage);
    totalSuccess += response.successCount;
    totalFailure += response.failureCount;

    // 解析每条响应,挑出失效 token
    response.responses.forEach((resp, idx) => {
      if (!resp.success) {
        const code = resp.error?.code;
        if (
          code === 'messaging/registration-token-not-registered' ||
          code === 'messaging/invalid-registration-token'
        ) {
          tokensToDelete.push(batch[idx]);
        }
        console.error(
          `[batch ${i}] token=${batch[idx].slice(0, 16)}... err=${code}`,
        );
      }
    });

    // 主动节流:避免触发 600k/min 配额或 on-the-hour 拥堵
    await new Promise((r) => setTimeout(r, 200));
  }

  return { totalSuccess, totalFailure, tokensToDelete };
}

// ====== 实际调用 ======
const allTokens = JSON.parse(readFileSync('./tokens.json', 'utf8'));
const payload = {
  notification: {
    title: '限时优惠码到啦',
    body: '今晚 9 点截止,输入 LUCKY888 领取 588 红包',
  },
  data: {
    promo_code: 'LUCKY888',
    deeplink: 'igaming://promo/LUCKY888',
  },
  android: {
    priority: 'high',
    notification: { channel_id: 'promo', sound: 'default' },
  },
  apns: {
    headers: { 'apns-priority': '10' },
    payload: { aps: { sound: 'default', badge: 1 } },
  },
};

const { totalSuccess, totalFailure, tokensToDelete } = await sendInBatches(
  allTokens,
  payload,
);
console.log(`成功 ${totalSuccess} / 失败 ${totalFailure}`);
console.log(`需要从数据库清理的失效 token: ${tokensToDelete.length} 条`);

关键细节: 1. BATCH_SIZE = 500 —— FCM 硬上限,超了直接报错 2. 每批之间 setTimeout 200ms —— 主动节流,避开拥堵 3. 失败原因分类 —— 不是所有失败都要删 token(见 06 错误处理)


六、关键配额与限制

指标 说明
sendEachForMulticast 单次 token 上限 500 硬上限,超了报错
sendEach 单次 message 上限 500 硬上限
subscribeToTopic 单次 token 上限 1,000 批量订阅用
单 app 实例 topic 订阅上限 2,000 超过要拆 app
项目级默认配额 600,000 msg/min (10K QPS) 覆盖 99% 用户
配额申请增加幅度 +25% 超过 18M/min 要提前 30 天报备
Topic fanout 实际速率(单项目) 约 10,000 QPS(不保证) 共享容量
项目内并发 fanout 上限 1,000 超出被拒/推迟
Topic 订阅 QPS 上限 3,000/项目 批量订阅时要注意
单设备 message 上限 (Android) 240/min, 5,000/hour iGaming 5 次/天远低于

iGaming 实际结论:5 次/天推送,远低于任何上限。唯一需要关注的是 on-the-hour 拥堵(下一节)。


七、⚠️ 避开 FCM on-the-hour 拥堵(极重要)

FCM 官方 Scale 文档明确:整点(:00 :15 :30 :45)± 2 分钟全球流量高峰,直接发会触发 429 限流。

对 iGaming 的影响:当前运营的 5 次推送时点 11:00 / 15:00 / 19:00 / 21:00 / 00:00 全部在高峰期

解决方案:把所有时点平移 +3 分钟

原时点(印尼 WIB) 改成 UTC cron
11:00 11:03 04:03 3 4 * * *
15:00 15:03 08:03 3 8 * * *
19:00 19:03 12:03 3 12 * * *
21:00 21:03 14:03 3 14 * * *
00:00 00:03 17:03 3 17 * * *

为什么选 +3 分钟而不是 +1 或 +5: - +1 或 +2 仍可能在高峰"尾巴"里 - +3 稳稳避开,且运营心理上仍然是"准点推送" - 运营/玩家的感知都是"差不多到点就推了",不影响体验


八、陷阱清单

  1. 不要并行启多个 fanout 项目级 fanout 容量在所有 in-progress fanout 间均分。每天 5 次定时推送相互错开几分钟,比同时触发更快。

  2. 避开 :00 :15 :30 :45 ± 2 分钟 FCM 全球流量高峰。把 11:00 改成 11:03,以此类推。

  3. sendEachForMulticast 的 500 上限是硬上限 超了直接报错,必须分批。

  4. 不要用 sendAll / sendMulticast 已退役,新代码用 sendEach / sendEachForMulticast

  5. 跨项目并行是安全的 N 个不同 Firebase 项目的配额各自独立,跨项目并行推送 是推荐做法(见 08 实战)。

  6. 同项目内不要同时跑多个 fanout 同一个 send({topic: ...}) 在前一个 fanout 还没完成时触发下一次,会互相拖累。


九、权威来源

  • https://firebase.google.com/docs/cloud-messaging/send/admin-sdk(sendEach / sendEachForMulticast 完整示例,500 上限明文)
  • https://firebase.google.com/docs/cloud-messaging/topic-messaging(fanout 速率 10K QPS、并发 1000 上限、单 app 2000 topic)
  • https://firebase.google.com/docs/cloud-messaging/manage-topic-subscriptions(subscribeToTopic 1000 上限)
  • https://firebase.google.com/docs/cloud-messaging/scale-fcm(on-the-hour 流量高峰说明)