埋点上报太频繁拖性能,先做批量合并和时机控制

不是所有上报都该重试。做移动端数据统计时,真正要重试的只有「必须送达」的那部分事件,其余丢了就算了。所以先做的不是重试机制,而是批量合并和时机控制------把请求数降下来,重试才有意义。

先判断你的上报频率是不是真有问题

判断标准很简单,两个数:单个用户每秒事件数、单条事件体量。单用户每秒超过 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数据官网

相关推荐
java_nnnn2 小时前
Spring IoC & DI 从入门到理解:控制反转、依赖注入与 Bean 管理详解
数据库·sql·spring
code_slave(码畜)4 小时前
微服务架构落地:慢 SQL 治理与连接池调优 —— 守住数据库的最后一道防线
数据库·spring boot·sql·spring cloud·微服务·架构
码农阿豪5 小时前
MongoDB迁移到金仓数据库:商品属性继续灵活存,订单报表交给 SQL
数据库·sql·mongodb
秋名RG5 小时前
一条 SQL 语句在 MySQL 中的执行过程详解
数据库·sql·mysql
smilejingwei18 小时前
TRAE 插件上新 | SQLazy:让 AI 写出可信复杂 SQL
数据库·人工智能·sql
这个DBA有点耶1 天前
主库秒级,从库延迟2小时:深挖Online DDL回放的MDL阻塞与算法降级机制
数据库·sql·dba
郭小李1 天前
mybatis 动态 sql
java·sql·mybatis
xcLeigh1 天前
KingbaseES数据库教程:初识数据库,从安装到跑通第一条SQL
android·数据库·sql·安装·数据库教程·kes