PAYATHON 2026

Node.js 中如何重试失败的 FCM 推送并避免多设备重复通知?

数据小陈

可以将失败的 tokens 传入后台任务,并在执行前查询数据库。查询时只筛选这些失败目标,避免按 userId 重新获取该用户的全部设备。

为了便于维护,可以保存失败目标的快照,包括 deviceId、userId 和发送时的 token,再关联本次业务通知的 notificationId。这样可以限定重试范围,并在任务执行时检查设备是否仍属于原用户。

为什么按 userId 重试会重复发送?

首次发送的目标是具体设备。如果重试时按用户查询,目标就会扩大到该用户的全部设备。

例如,用户 A 的 Token 1 发送失败、Token 2 发送成功,重试集合应只包含 Token 1。如果重新查询用户 A 的全部 tokens,就会把已经成功的 Token 2 也加进来。

重试目标可以理解为以下交集:

本次可重试的失败目标 ∩ 当前仍允许接收该通知的设备绑定

建议怎样调整流程?

  1. 首次发送时,根据 userIds 查询设备,保留每个发送目标与设备记录的对应关系。
  2. 根据逐目标发送结果记录状态:FCM 已接受、可重试失败或永久失败。
  3. 仅为可重试失败的目标创建 pg-boss 任务,携带通知标识和失败目标快照,或者携带持久化投递记录的 ID。
  4. 重试前检查目标是否仍存在、是否仍绑定原用户、是否仍允许接收通知,以及当前 token 是否与快照一致。
  5. 只向检查通过且尚未成功的目标发送;后续重试继续使用本轮失败集合。

如果使用 sendEachForMulticast(),可以根据返回结果的索引,在本次传入的 tokens 中找到具体失败目标。必须使用本次实际发送的数组建立对应关系,不能使用重新查询后的数组。Firebase Admin SDK 发送说明

Drizzle 中如何只查询失败目标?

下面的示例在重试前筛选目标。示例假设设备表包含 id、userId、token 和 isActive,使用时需要替换成实际表结构。isActive 表示业务上是否允许发送,不代表 FCM 已确认 token 有效。

import { and, eq, inArray } from 'drizzle-orm';

type RetryTarget = {
  deviceId: string;
  userId: string;
  token: string;
};

async function loadRetryTargets(targets: RetryTarget[]) {
  if (targets.length === 0) return [];

  const rows = await db
    .select()
    .from(userDevices)
    .where(
      and(
        inArray(userDevices.id, targets.map(t => t.deviceId)),
        inArray(userDevices.token, targets.map(t => t.token)),
        eq(userDevices.isActive, true),
      ),
    );

  const currentById = new Map(rows.map(row => [row.id, row]));

  return targets.filter(target => {
    const current = currentById.get(target.deviceId);
    return current !== undefined
      && current.userId === target.userId
      && current.token === target.token;
  });
}

两组 inArray() 先缩小候选范围,再逐条检查设备、用户和 token 的对应关系,避免将不同记录中的值错误组合起来。

这个示例只重试原 token。如果 token 已更新,就跳过旧目标。如果业务要求将未完成的通知转交给同一设备的新 token,应通过稳定的设备标识和投递记录实现,并重新检查用户绑定,不能直接改为查询该用户的全部设备。

用户退出登录或 token 失效时怎么办?

退出登录和 FCM token 失效是两件事。用户退出登录时,后端应删除、停用或解除该设备与用户的绑定,重试任务据此跳过目标。不能假定退出登录会自动使 FCM token 失效。

数据库只能检查本地保存的状态,无法保证 token 此刻仍被 FCM 接受。发送时仍需处理错误:

  • 对 messaging/registration-token-not-registered,停止重试并清理对应的旧 token。
  • 对无效参数错误,先检查消息内容,不能把所有 INVALID_ARGUMENT 都当成 token 失效。
  • 对明确可重试的临时错误,采用带随机抖动的指数退避。如果能够取得 Retry-After,应遵守该等待时间。同时设置重试次数或通知有效期,避免过期通知持续重发。

具体错误分类应以所用接口和 SDK 的返回值为准。FCM 错误码说明

清理旧 token 时,更新条件中也要匹配发送时的 token,避免旧请求的失败结果误删设备刚上报的新 token。

这样能完全消除重复通知吗?

这个改动能解决题目中因重试范围扩大造成的重复,重试机制仍需考虑结果不确定的情况。例如,请求发出后连接中断,应用可能无法确认 FCM 是否已经接受消息;任务进程也可能在发送完成后、保存成功状态前退出。

建议按 (notificationId, deviceId) 保存投递状态,并让 worker 原子领取待发送记录,避免多个任务同时处理同一目标。只有成功状态已持久化的目标,才能在后续执行中可靠地跳过。数据库事务无法将外部 FCM 请求与本地状态更新变成一个原子操作,因此服务端记录本身不能保证端到端只展示一次。

如果业务对重复展示敏感,可以随消息传递稳定的 notificationId,由客户端在处理或自行展示通知前去重。如果通知由系统自动展示,应用层去重能否介入取决于平台、消息类型和应用状态,不能直接套用同一方案。

查询后到发送前,用户仍可能退出登录。数据库检查可以减少向旧绑定发送的机会,但无法撤回已在途的消息;敏感内容更适合由客户端在确认当前身份后再获取。

备注:内容仅供参考。