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→ 改用sendEachsendMulticast→ 改用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_userstopic,之后每次推送只调 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 稳稳避开,且运营心理上仍然是"准点推送" - 运营/玩家的感知都是"差不多到点就推了",不影响体验
八、陷阱清单¶
-
不要并行启多个 fanout 项目级 fanout 容量在所有 in-progress fanout 间均分。每天 5 次定时推送相互错开几分钟,比同时触发更快。
-
避开 :00 :15 :30 :45 ± 2 分钟 FCM 全球流量高峰。把 11:00 改成 11:03,以此类推。
-
sendEachForMulticast的 500 上限是硬上限 超了直接报错,必须分批。 -
不要用
sendAll/sendMulticast已退役,新代码用sendEach/sendEachForMulticast。 -
跨项目并行是安全的 N 个不同 Firebase 项目的配额各自独立,跨项目并行推送 是推荐做法(见 08 实战)。
-
同项目内不要同时跑多个 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 流量高峰说明)