06 · 错误处理与 Token 生命周期¶
目标读者:要做生产级推送系统的后端工程师 核心问题:推送失败怎么办?哪些错误要删 token?哪些要重试? 前置: 03-Admin-SDK实战 / 04-批量发送 相关: 08-iGaming多平台批量推送实战 最后更新:2026-04-11
一、核心原则¶
FCM 错误码分两大类,处理策略完全不同:
错误发生
│
▼
判断错误类别
│
├─ 永久性错误(token 已死 / payload 错)
│ → 立即处理,永远不要重试
│ → 清理 DB(如果是 token 死)
│
└─ 瞬时性错误(服务器繁忙 / 限流)
→ 指数退避重试
→ 最大 N 次后记入死信日志
铁律: - 永久性错误 → 绝不重试,否则只是在浪费 API 配额 - 瞬时性错误 → 必须重试,否则会丢消息
二、Admin SDK 错误码完整对照表¶
| Admin SDK 错误码 | HTTP 状态 | 类别 | 处理 |
|---|---|---|---|
messaging/invalid-argument |
400 INVALID_ARGUMENT | 永久 | 检查 payload(多见于 data value 非 string) |
messaging/invalid-registration-token |
400 | 永久 | 删除 token |
messaging/registration-token-not-registered |
404 UNREGISTERED | 永久 | 删除 token(用户卸载/重装/清数据) |
messaging/invalid-package-name |
400 | 永久 | 检查 restrictedPackageName 配置 |
messaging/invalid-payload |
400 | 永久 | 修 payload |
messaging/payload-size-limit-exceeded |
400 | 永久 | 缩 payload(≤4096 bytes / topic 2048 bytes) |
messaging/invalid-data-payload-key |
400 | 永久 | data 不能有 from/gcm.*/google.* 等保留 key |
messaging/mismatched-credential |
403 | 永久 | service account 和 token 不属同一项目 —— 多项目场景必须特别小心 |
messaging/sender-id-mismatch |
403 | 永久 | 同上 |
messaging/authentication-error |
401 | 永久 | service account JSON 失效或权限不足 |
messaging/invalid-apns-credentials |
401 THIRD_PARTY_AUTH_ERROR | 半永久 | APNs 证书过期,需要后台上传新证书 |
messaging/message-rate-exceeded |
429 | 瞬时 | 退避重试,至少 1 分钟 |
messaging/device-message-rate-exceeded |
429 | 瞬时 | 退避,单设备 240 msg/min 上限 |
messaging/topics-message-rate-exceeded |
429 | 瞬时 | 退避,至少 1 分钟 |
messaging/too-many-topics |
400 | 永久 | 单 token 最多订阅 2000 topic |
messaging/server-unavailable |
503 UNAVAILABLE | 瞬时 | 遵守 Retry-After header,否则指数退避 |
messaging/internal-error |
500 | 瞬时 | 指数退避 |
messaging/unknown-error |
- | 视情况 | 看 raw error 再定 |
2.1 分类速查¶
删除 token:
· messaging/registration-token-not-registered
· messaging/invalid-registration-token
· (可能) messaging/invalid-argument
· messaging/mismatched-credential
修代码:
· messaging/invalid-payload
· messaging/payload-size-limit-exceeded
· messaging/invalid-data-payload-key
· messaging/too-many-topics
重试(指数退避):
· messaging/message-rate-exceeded
· messaging/topics-message-rate-exceeded
· messaging/device-message-rate-exceeded
· messaging/server-unavailable
· messaging/internal-error
要告警(人工介入):
· messaging/authentication-error → service account JSON 失效
· messaging/invalid-apns-credentials → APNs 证书过期,一年一换
三、重试策略(官方推荐)¶
| HTTP 状态 | 重试? | 策略 |
|---|---|---|
| 401 / 403 / 404 | ❌ 不重试 | |
| 400(通用) | ❌ 不重试 | 只有 INVALID_ARGUMENT 单 token 例外,可能要删 token |
| 429 | ✅ | 等 Retry-After header;没 header 默认等 60 秒 |
| 500 | ✅ | 指数退避,最小起始 1 秒,最长不超过 60 分钟,加 jitter |
| 503 | ✅ | 同 500,强制读 Retry-After |
其它要求:
- 超时设置至少 10 秒(FCM 内部 RPC 超时是 10 秒)
- Firebase Admin SDK 内置了指数退避,sendEach 自带重试
四、完整 Node.js 批量发送 + 错误分类 + 清理代码¶
// scripts/send-with-cleanup.js
// 作者: Bob
// 用途: 批量发推送 + 自动清理失效 token + 重试瞬时错误
import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
initializeApp({ credential: cert('./service-account.json') });
// 永久性错误:直接删 token
const PERMANENT_TOKEN_ERRORS = new Set([
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
'messaging/invalid-argument', // 可能是 token 坏也可能 payload 坏,要二次确认
'messaging/mismatched-credential',
]);
// 瞬时性错误:退避重试
const TRANSIENT_ERRORS = new Set([
'messaging/server-unavailable',
'messaging/internal-error',
'messaging/message-rate-exceeded',
'messaging/topics-message-rate-exceeded',
'messaging/device-message-rate-exceeded',
'messaging/unknown-error',
]);
/**
* 指数退避:1s, 2s, 4s, 8s, ... 上限 60s,加 0~30% jitter
*/
function backoffDelay(attempt) {
const base = Math.min(1000 * 2 ** attempt, 60_000);
const jitter = base * Math.random() * 0.3;
return base + jitter;
}
async function sendBatchWithRetry(tokens, payload, maxAttempts = 5) {
const messaging = getMessaging();
const tokensToDelete = [];
let pendingTokens = [...tokens];
let totalSuccess = 0;
for (let attempt = 0; attempt < maxAttempts && pendingTokens.length > 0; attempt++) {
if (attempt > 0) {
const delay = backoffDelay(attempt - 1);
console.log(
`[retry ${attempt}] 等待 ${Math.round(delay)}ms 后重试 ${pendingTokens.length} 个 token`,
);
await new Promise((r) => setTimeout(r, delay));
}
const multicastMessage = { ...payload, tokens: pendingTokens };
let response;
try {
response = await messaging.sendEachForMulticast(multicastMessage);
} catch (e) {
// 整批级别错误(鉴权失败等),直接抛
console.error('整批失败:', e.code, e.message);
throw e;
}
const nextRound = [];
response.responses.forEach((resp, idx) => {
const tk = pendingTokens[idx];
if (resp.success) {
totalSuccess++;
} else {
const code = resp.error?.code;
if (PERMANENT_TOKEN_ERRORS.has(code)) {
tokensToDelete.push({ token: tk, reason: code });
} else if (TRANSIENT_ERRORS.has(code)) {
nextRound.push(tk); // 下一轮重试
} else {
// 未知错误,记日志但不重试
console.error(`未知错误 token=${tk.slice(0, 16)}.. code=${code}`);
}
}
});
pendingTokens = nextRound;
}
return {
successCount: totalSuccess,
permanentlyFailedCount: tokensToDelete.length,
transientFailedAfterRetry: pendingTokens.length,
tokensToDelete,
deadLetter: pendingTokens,
};
}
// 配合数据库清理(PostgreSQL 示例)
async function cleanupDeadTokens(db, tokensToDelete) {
if (tokensToDelete.length === 0) return;
const tokens = tokensToDelete.map((t) => t.token);
await db.query('DELETE FROM fcm_tokens WHERE token = ANY($1)', [tokens]);
console.log(`已清理 ${tokens.length} 个失效 token`);
}
// 调用示例
// const result = await sendBatchWithRetry(allTokens, payload);
// await cleanupDeadTokens(db, result.tokensToDelete);
// console.log(`成功 ${result.successCount}, 需清理 ${result.permanentlyFailedCount}`);
五、Token 生命周期¶
5.1 Token 失效定义(Google 官方)¶
| 平台 | 规则 |
|---|---|
| Android | 270 天未连接 FCM → 自动失效 |
| iOS | 依赖 APNs,无 270 天硬性规则 |
| 推荐自定义 | 30 天未活跃就清理 —— 既能识别死设备又不浪费 battery |
5.2 Token 更新机制¶
App 客户端
│
│ ① onNewToken / onTokenRefresh 触发
│ (首次安装 / 用户重装 / 清数据 / 恢复到新设备)
▼
客户端调后端 API
│
│ POST /api/fcm/register-token
│ body: { user_id, token, platform }
▼
后端 upsert 数据库
│
│ (user_id, platform_id) 唯一键
│ 同时更新 updated_at
▼
数据库表 fcm_tokens
数据库表结构建议:
CREATE TABLE fcm_tokens (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
platform_id TEXT NOT NULL, -- 'platform_a' / 'platform_b' / ...
token TEXT NOT NULL,
app_version TEXT,
device_type TEXT, -- 'android' / 'ios' / 'web'
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, platform_id, device_type) -- 一个用户在一个平台一个设备只有一个 token
);
CREATE INDEX idx_fcm_tokens_platform ON fcm_tokens(platform_id);
CREATE INDEX idx_fcm_tokens_updated ON fcm_tokens(updated_at);
5.3 定期清理任务(推荐每日跑)¶
-- 删除 30 天未活跃的 token
DELETE FROM fcm_tokens WHERE updated_at < NOW() - INTERVAL '30 days';
或在推送脚本里实时清理(推送时遇到 UNREGISTERED 即删)。
5.4 FCM 单设备速率限制¶
| 平台 | 限额 |
|---|---|
| Android | 240 messages/min, 5,000/hour |
| iOS | 受 APNs 限制 |
iGaming 每天 5 次推送,远低于上限,单设备不用担心限流。
六、❗ iGaming 多平台场景的特殊陷阱¶
6.1 mismatched-credential 是杀手级错误¶
场景:脚本里给 A 平台的 token 错用了 B 平台的 service account 发推送 → 全批 403 messaging/mismatched-credential。
为什么会发生:
错误的做法:
所有平台的 token 混在一张表里,用一个默认 service account 发 → 肯定错
正确的做法:
数据库里每个 token 标 platform_id
发送时按 platform 分组,每组用对应的 service account(Admin SDK app 实例)
6.2 防御性代码¶
// 按 platform 分组 token
const tokensByPlatform = await db.query(`
SELECT platform_id, array_agg(token) as tokens
FROM fcm_tokens
WHERE updated_at > NOW() - INTERVAL '30 days'
GROUP BY platform_id
`);
// 对每个平台用对应的 app 实例发送
for (const row of tokensByPlatform.rows) {
const app = apps[row.platform_id]; // 从之前 initializeApp 的 map 里拿
if (!app) {
console.error(`无法找到 platform ${row.platform_id} 的 Firebase app 实例`);
continue;
}
const messaging = getMessaging(app);
await sendBatchWithRetry(row.tokens, payload, messaging);
}
七、APNs 证书管理(一年一换)¶
iOS 推送依赖 Apple 的 APNs Authentication Key(.p8 文件)。
7.1 生命周期¶
| 类型 | 有效期 | 说明 |
|---|---|---|
| APNs Auth Key (.p8) | 永不过期 | 但 Apple 可能要求轮换 |
| APNs 证书 (.p12) | 一年过期 | 老式方案,已不推荐 |
推荐用 .p8 Auth Key,避免每年换证的麻烦。
7.2 失效告警¶
如果看到 messaging/invalid-apns-credentials (401 THIRD_PARTY_AUTH_ERROR):
1. 所有 iOS 推送都会失败(Android 不受影响)
2. 需要到 Firebase Console → 项目设置 → 云消息传递 → APNs 身份验证密钥 → 上传新的 .p8
3. 要监控告警:如果 iOS 推送成功率突降,立刻查是不是证书失效
八、死信处理¶
瞬时错误重试 N 次后仍失败的 token,要记入死信队列以便人工分析:
// 死信日志格式(JSONL,每行一个记录)
{
"timestamp": "2026-04-11T04:03:00Z",
"slot": "11:00",
"platform": "platform_a",
"token": "xxx...",
"error_code": "messaging/server-unavailable",
"attempts": 5,
"payload_hash": "abc123"
}
写到 /var/log/fcm-dead-letter.jsonl,每周用 jq 分析一次。
九、陷阱清单¶
-
INVALID_ARGUMENT是个陷阱 同一个错误码既可能是 payload 写错(应修代码),也可能是 token 格式坏(应删 token)。官方建议:先确认 payload 在其它 token 上能成功,再认定是 token 问题。 -
mismatched-credential在多平台场景特别危险 把 A 平台的 token 错发到 B 平台的 service account → 全批 403。数据库里必须给每个 token 标platform_id,发送时按 platform 分组。 -
不要在 catch 里立即重试 会触发 retry amplification,把 FCM 拉爆并被加入黑名单。必须用指数退避 + jitter。
-
APNs 证书每年要续 过期会全 iOS 推送返回
THIRD_PARTY_AUTH_ERROR,必须接告警。 -
不要 TTL 设得太长(> 1 周) 超过实际优惠码截止时间的消息发出去反而骚扰用户。
-
失效 token 不清理的后果 几个月下来累积一堆无效 token,每次推送浪费 API 配额 + 统计数据被污染。必须实时清理 + 定期扫描。
-
瞬时错误指数退避最少 1 秒起,最长 60 分钟
Math.min(1000 * 2**n, 60_000)。
十、权威来源¶
- https://firebase.google.com/docs/cloud-messaging/error-codes(完整 Admin SDK 错误码表)
- https://firebase.google.com/docs/cloud-messaging/manage-tokens(token 生命周期、30 天 staleness、Cloud Firestore 清理示例)
- https://firebase.google.com/docs/cloud-messaging/scale-fcm(重试策略、指数退避、jittering)
- https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas(600k/min 配额、429 处理)