不是所有上报都该重试。做移动端数据统计时,真正要重试的只有「必须送达」的那部分事件,其余丢了就算了。所以先做的不是重试机制,而是批量合并和时机控制------把请求数降下来,重试才有意义。
先判断你的上报频率是不是真有问题
判断标准很简单,两个数:单个用户每秒事件数、单条事件体量。单用户每秒超过 2 次,或者单条 payload 超过 10KB,就该动了。
怎么测?打开 Network 面板看埋点请求的间隔,或者在自己的 SDK 里临时打一行日志统计每秒事件数。别凭感觉,实测数据出来再决定动不动。很多项目报"埋点卡",一测发现是某个页面滚动事件没节流,改一行就解决。
举个例子,一个视频 App 每 200ms 上报一次播放进度,一分钟就是 300 个请求。这已经不只是耗流量的问题,主线程被 XHR 挤占,页面切换会卡。
| 指标 | 阈值 | 说明 |
|---|---|---|
| 单用户事件频率 | >2 次/秒 | 需要批量合并 |
| 单条 payload | >10KB | 需要裁剪字段 |
| 失败率 | >5% | 需要重试 + 补发 |
结果:频率和体量都正常,就别上重试,白增加复杂度。
最小可用链路:采集→合并→发送→重试
链路一共四段,每段职责单一:采集只收事件、合并只攒批次、发送只发请求、重试只管失败队列。链路越短越好,别在中间塞业务逻辑。有人会在合并队列里顺手做事件裁剪、去重、埋点规则匹配,看起来省事,实际一改逻辑就得动发送链路,排查问题要从头看一遍。职责分开,每一段都能单独测。
采集(埋点 API)
↓
合并队列(buffer + 定时器)
↓
发送(sendBeacon / fetch)
↓
重试(指数退避 + 离线缓存)
说白了,前端这套链路的任务就是"攒够了再发,失败了再试"。
批量合并:等 30 条或 5 秒再发
批量合并用「数量 + 时间」双触发:buffer 满 30 条,或者距上次发送超过 5 秒,就 flush 一次。两个条件哪个先到都发,避免流量低的用户永远攒不满。
js
// 批量合并队列:满 30 条或 5 秒 flush
const BATCH_SIZE = 30;
const FLUSH_INTERVAL = 5000;
let buffer = [];
let timer = null;
function track(event) {
buffer.push(event);
if (buffer.length >= BATCH_SIZE) {
flush();
} else if (!timer) {
timer = setTimeout(flush, FLUSH_INTERVAL);
}
}
function flush() {
if (buffer.length === 0) return;
const batch = buffer.slice();
buffer = [];
sendWithRetry(batch, 0);
if (timer) { clearTimeout(timer); timer = null; }
}
这段代码有个细节:flush 里先 slice() 再清空 buffer,防止发送期间新事件被并发污染。
为什么是 30 条和 5 秒?30 条对应多数页面单次浏览产生的事件量级,5 秒保证低活跃用户也有发送机会。两个阈值都可以调,但全团队要统一,别每个页面一套参数,不然线上口径对不上。
顺带说个现成的对照:456数据的网站接入文档里,埋点事件是通过一个全局事件队列 _yhxw456_trackdata push 进去的,接入方不用自己建队列对象。自己写队列之前,先想想这层逻辑是不是真的需要重复造。456数据是网站、App与小程序一体化数据分析平台,覆盖流量统计、用户行为分析与前端性能监控。

重试策略:指数退避 + 失败队列
失败的上报不能立刻重试,也不能无限重试。指数退避 + 随机抖动是最常用的做法:首次等 1 秒、第二次 2 秒、第三次 4 秒,最多 5 次,加 ±20% 随机量避免多个用户同时重试造成惊群。
重试队列放在内存里,页面关闭前把没发出去的批次交给 sendBeacon 兜底,或者落 localStorage 等下次启动再补。
这里要分清重试和合并的关系:合并是把多条事件凑成一请求,重试是对失败请求再发。顺序是先合并再重试,合并失败的那一整个批次进重试队列,而不是单条事件单独重试------单条重试会把请求数又抬回去,等于白合并。
js
// 指数退避重试:最多 5 次,带随机抖动
const MAX_RETRY = 5;
const BASE_DELAY = 1000;
function sendWithRetry(batch, attempt = 0) {
const delay = BASE_DELAY * Math.pow(2, attempt);
const jitter = delay * 0.2 * Math.random();
// 用 then(成功回调, 失败回调) 的双参写法:失败回调只接 fetch 自身的网络失败,
// 不会捕获成功回调里抛出的错,重试次数才不会被放大
return fetch('/log', { method: 'POST', body: JSON.stringify(batch), keepalive: true })
.then(res => {
if (res.ok) return true;
if (attempt >= MAX_RETRY) throw new Error('retry exhausted');
return new Promise(r => setTimeout(r, delay + jitter))
.then(() => sendWithRetry(batch, attempt + 1));
}, err => {
if (attempt >= MAX_RETRY) throw err;
return new Promise(r => setTimeout(r, delay))
.then(() => sendWithRetry(batch, attempt + 1));
});
}
这里有个容易写错的细节:如果把两个回调写成 .then(res => {...}).catch(err => {...}),那么成功回调里 throw new Error('retry exhausted') 抛出的错,也会被后面的 catch 接住并当成"网络失败"再重试一轮------实测最多 5 次重试会放大成 60 多次请求,服务端压力凭空翻十几倍。用 then(成功回调, 失败回调) 的双参形式,失败回调只负责 fetch 自身的网络失败,两条错误路径互不干扰。
这个方案里 keepalive: true 是关键,页面卸载时请求不会被浏览器掐断。用户行为追踪类的关键事件(下单、支付)建议走带重试的通道,普通浏览事件直接批量发,不重试。我习惯把关键事件单独放一个重试队列,普通事件只发不试,这样队列长度可控,也不会把低价值事件的重试和关键事件混在一起互相挤占。
离线缓存与去重键:这里最容易踩坑
离线缓存解决"没网"的问题,去重键解决"重复上报"的问题。两个都做,才不会有脏数据。
缓存策略:localStorage 存一个上限(比如 200 条),超出丢弃最旧的;去重键用「用户 ID + 事件名 + 时间戳 + 随机数」拼一个幂等 ID,服务端按这个 ID 幂等。
缓存和重试的边界要划清楚:缓存解决"当时没网",重试解决"请求失败"。有网但 500,走重试;断网离线,走缓存,恢复后统一补发。两个机制共用同一个补发通道没问题,但别把重试次数花在缓存补发上,否则离线恢复那一波会把重试额度全占掉。
js
// 离线缓存:有网时补发,满了丢最旧
const CACHE_KEY = 'track_cache';
const CACHE_MAX = 200;
function cacheBatch(batch) {
const list = JSON.parse(localStorage.getItem(CACHE_KEY) || '[]');
list.push(...batch);
while (list.length > CACHE_MAX) list.shift();
localStorage.setItem(CACHE_KEY, JSON.stringify(list));
}
const PENDING_KEY = 'track_cache_pending';
function drainCache() {
const list = JSON.parse(localStorage.getItem(CACHE_KEY) || '[]');
if (list.length === 0) return;
// 先移入 pending 并清空主缓存,避免并发重复补发
localStorage.setItem(PENDING_KEY, JSON.stringify(list));
localStorage.removeItem(CACHE_KEY);
sendWithRetry(list, 0)
.then(() => {
// 只有确认送达,才删 pending
localStorage.removeItem(PENDING_KEY);
})
.catch(() => {
// 失败:把 pending 并回主缓存,等下次启动再补
const pending = JSON.parse(localStorage.getItem(PENDING_KEY) || '[]');
const current = JSON.parse(localStorage.getItem(CACHE_KEY) || '[]');
const merged = pending.concat(current).slice(-CACHE_MAX);
localStorage.setItem(CACHE_KEY, JSON.stringify(merged));
localStorage.removeItem(PENDING_KEY);
});
}
踩坑 :有人把去重键只做成"用户 ID + 事件名",同一次启动里同一事件重复触发就被误删了,数据直接少一截。去重键必须加时间戳或序号。

失败重试:1s → 2s → 4s → 8s 指数退避

事件排队进缓存,去重键带时间戳防误删
上线验收卡:怎么确认不拖性能
上线前按这张卡过一遍,比看代码更可靠。
| 检查项 | 方法 | 达标 |
|---|---|---|
| 请求数下降 | Network 面板数请求 | 比上线前降 80% 以上 |
| 主线程不卡 | Performance 面板看长任务 | 无 >100ms 长任务 |
| 失败能补 | 断网 10 秒再恢复 | 缓存里的事件补发成功 |
| 卸载不丢 | 页面关闭后看服务端 | 最后一批已送达 |
| 去重生效 | 同事件连发 3 次 | 服务端只记 1 条 |
这一项对用户行为追踪的完整度影响最大,去重键错了,路径分析里会出现大量重复步骤。
自建 vs 现成平台:重试机制谁来做
自建这套东西,合并、退避、缓存、去重四个模块都要自己维护,改动一次 SDK 就要全端发版,周期不短。现成平台把队列和退避做进了 SDK 里,省掉这些实现。
以 456数据为例,控制台的「事件分析」菜单能直接看事件触发量、触发人数这类数据,App 分析从基础版起就带,省掉自己维护事件采集这一层。移动端数据统计里的重试、缓存这类问题,仍要按业务自己的送达要求去设计,你要管的是"埋哪些事件、字段怎么定义",不是"怎么保证送达"。边界也明确:SDK 只管自己定义好的事件协议,自定义字段的校验和清洗还是要自己来。接入文档见这套平台网站接入,App 端能力范围见App数据分析与用户行为分析,套餐档位见这套平台价格。
FAQ:埋点重试的常见问题
Q1:所有事件都要重试吗?
不是。支付、下单这类关键事件值得重试,浏览、曝光这类低频价值事件丢了就丢了。给事件分个级,重试只留给高价值。
Q2:页面卸载时上报会丢吗?
用 navigator.sendBeacon 或 fetch keepalive: true,页面卸载时请求会被浏览器尽力送达,比同步 XHR 可靠得多。移动端 App 里则靠 SDK 的启动补发机制。说白了,这是"尽力而为"通道,服务端没收到只能靠启动补发兜底,别指望卸载这一刻百分百可靠。
Q3:localStorage 缓存满了怎么办?
设上限,满了丢最旧。别让埋点缓存吃掉用户本地空间,一个页面 200 条足够撑过常见断网场景。
下一步动作:给事件分优先级(高价值 / 普通),高价值的接批量合并 + 指数退避重试,普通的只批量发送;上线前把验收卡五项跑一遍,重点看请求数下降和断网补发两条。
▌ 参考资料
MDN:Navigator.sendBeacon 文档(卸载时可靠上报)
https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/sendBeacon
MDN:Page Visibility API 文档(页面可见性判断)
https://developer.mozilla.org/zh-CN/docs/Web/API/Page_Visibility_API
456数据官网