跳转至

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 分析一次。


九、陷阱清单

  1. INVALID_ARGUMENT 是个陷阱 同一个错误码既可能是 payload 写错(应修代码),也可能是 token 格式坏(应删 token)。官方建议:先确认 payload 在其它 token 上能成功,再认定是 token 问题。

  2. mismatched-credential 在多平台场景特别危险 把 A 平台的 token 错发到 B 平台的 service account → 全批 403。数据库里必须给每个 token 标 platform_id,发送时按 platform 分组。

  3. 不要在 catch 里立即重试 会触发 retry amplification,把 FCM 拉爆并被加入黑名单。必须用指数退避 + jitter

  4. APNs 证书每年要续 过期会全 iOS 推送返回 THIRD_PARTY_AUTH_ERROR,必须接告警

  5. 不要 TTL 设得太长(> 1 周) 超过实际优惠码截止时间的消息发出去反而骚扰用户。

  6. 失效 token 不清理的后果 几个月下来累积一堆无效 token,每次推送浪费 API 配额 + 统计数据被污染。必须实时清理 + 定期扫描

  7. 瞬时错误指数退避最少 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 处理)