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

不要在新代码里用 sendAll 或 sendMulticast。

  • 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 流量高峰说明)