十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前端日志上报不丢包:从sendBeacon到重试队列的全链路方案

前端日志上报不丢包:从sendBeacon到重试队列的全链路方案 我接手过一个流量不小的Web项目线上日志上报的丢失率一直压不下来。运维同学丢过来一张对比图后端统计的错误数和前端打点记录差了整整三成第一反应是日志平台漏数据了。排查到最后问题全出在浏览器这一端——用户断网、弱网、直接关标签页请求根本没送到服务端。日志上报要做到不丢包不是加几行上报代码那么简单得把整条链路里的每个中断点都补上。这篇文章会把我在生产环境里验证过的方案完整讲一遍页面关闭时用sendBeacon和fetch keepalive兜底断网弱网时用本地缓存加队列重试服务端再做幂等去重。既讲为什么这么设计也讲代码怎么写、坑在哪适合正在做前端日志体系、监控上报的开发者参考。无论你是刚接触日志上报还是已经在上报链路上摔过跟头这篇应该都能给你一些可落地的思路。1. 日志上报为什么会丢先摸清几个典型断点1.1 页面生命周期中断请求最常见的丢包场景其实是用户主动结束页面。地址栏刷新、关闭标签页、手机App里左滑关闭WebView任何一个动作都会让当前页面的JavaScript执行环境进入销毁流程。在这个流程里浏览器会直接终止尚未完成的网络请求。很多人以为在beforeunload里发一个同步XHR就能把数据送出去这个方案在几年前确实能用。但现在Chrome、Firefox都明确限制甚至废弃了页面卸载过程中的同步XHR浏览器日志里会直接打出一行警告Synchronous XMLHttpRequest on the main thread is deprecated。意思很直白这条路被堵了。即使用fetch异步发请求也不行。页面一关整个渲染进程被回收异步请求根本来不及建立连接回调也没有机会执行。日志数据就停留在内存里随着页面一起消失了。1.2 网络不可用与弱网超时第二个典型断点是网络环境本身的问题。地铁隧道、电梯、地下车库这些场景手机经常处于断网或信号极弱状态。此时日志上报请求要么直接失败要么一直处于pending状态直到超时释放。弱网下的表现更隐蔽。网络不是完全断掉而是带宽极低、延迟极高。一个上报请求可能十几秒都回不来用户早就切走了。有些开发者在send包里加超时机制超时后重试但如果重试策略写得不对比如立即重试、无间隔重试只会把网络打得更差然后继续失败日志照样丢。这里有个容易被忽略的细节弱网不代表请求失败可能只是很慢。如果前端只处理fail状态很多请求其实是发了一部分但没回包这种日志没法判断是否送达处理起来最麻烦。1.3 日志量瞬时暴涨导致的内存溢出第三个断点很多人没意识到日志上报是瞬时并发的。用户一次页面操作可能触发十几个埋点事件同一个时间片内会有一大批日志同时产生。如果只是简单地循环调用fetch瞬间建立十几个连接在移动端和弱网环境下很容易失败。更严重的是内存问题。如果日志先push到内存数组里再由异步任务统一flush一旦异步任务排队靠后页面恰好在flush之前关闭这几十条日志就全丢了。我的经验是日志上报绝不能只依赖内存态必须引入持久化缓存层。2. 关页场景的保底方案sendBeacon与fetch keepalive的完整姿势2.1 navigator.sendBeacon浏览器官方兜底通道先明确一个结论页面关闭时的日志上报首选就是sendBeacon。这个API是浏览器专门为页面卸载时发送少量数据设计的页面关闭后浏览器仍然会尽力把请求发出去不阻塞卸载流程。基本用法很简单function reportWithBeacon(logs) { if (navigator.sendBeacon logs.length 0) { const blob new Blob([JSON.stringify(logs)], { type: application/json; charsetUTF-8 }); const sent navigator.sendBeacon(/api/log/report, blob); if (!sent) { // 发送失败转入缓存队列等网络恢复再重试 cacheLogs(logs); } } }注意几个细节sendBeacon支持传string、Blob、FormData、URLSearchParams。用Blob传JSON比直接传string更可控可以显式指定Content-Type。sendBeacon方法有返回值返回false表示数据量太大或浏览器拒绝发送这时候一定要有降级方案。sendBeacon本质上是POST请求浏览器会自动带上同源cookie不需要手动设置credentials。2.2 fetch keepalive需要自定义请求头时才用sendBeacon够简单但它有几个限制只能POST不能自定义header没法设置超时。有些场景下服务端要求统一签名认证需要在header里带tokensendBeacon就无能为力了。这时候可以改用fetch的keepalive属性function reportWithKeepalive(logs) { if (!(keepalive in Request.prototype)) { // 浏览器不支持降级 cacheLogs(logs); return; } fetch(/api/log/report, { method: POST, headers: { Content-Type: application/json, X-Request-Id: generateRequestId() }, body: JSON.stringify(logs), keepalive: true, credentials: include }).catch(() { // 失败后入队重试 cacheLogs(logs); }); }keepalive请求会在页面销毁后由浏览器继续完成发送这是官方行为和异步fetch的生命周期不一样。但要注意Chrome对keepalive请求体的大小限制在64KB左右超出会被浏览器直接拒绝。2.3 事件挂载的实战选择pagehide与visibilitychange有了上报通道还需要在正确的时机触发。这里我推荐组合使用visibilitychange和pagehide不推荐只用beforeunload。beforeunload的兼容性问题不大但它只覆盖页面即将卸载的场景用户切换到其他标签页时不会触发。而visibilitychange在页面从可见变成隐藏时触发覆盖面更广——包括切后台、切标签页、锁屏、Android上跳转其他App。我的推荐方案是在visibilitychange里检测document.visibilityState hidden时执行一次flush再用pagehide作为最后的兜底function initFlushListeners() { document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { flushLogsBySendBeacon(); } }); window.addEventListener(pagehide, () { flushLogsBySendBeacon(); }); }flushLogsBySendBeacon内部先尝试sendBeacon如果返回false再尝试fetch keepalive都失败就存到本地缓存。这个三级降级链路是我线上验证过最稳的组合。还有一个经验不要在visibilitychange里做太重的同步操作比如读取大量日志、JSON.stringify超大对象。这些操作会延长页面进入hidden状态的时间影响浏览器回收在移动端可能直接被系统杀掉。3. 断网弱网场景的保底方案本地缓存与重试队列3.1 缓存放哪localStorage与IndexedDB的选择页面关闭时的兜底解决的是时间窗口问题断网弱网解决的是网络状态问题。后者必须有持久化缓存把发不出去的日志先落盘。缓存选型通常有两个选择localStorage和IndexedDB。localStorage优点是同步API代码简单不用处理异步回调。缺点是容量小大部分浏览器限制在5MB左右而且它只能存字符串。如果日志量大会被其他业务配置项挤占写入时可能抛QuotaExceededError。IndexedDB容量大得多通常在几百MB甚至不受限适合存大量日志。缺点是API是异步的在页面关闭时调用indexedDB写入可能还没写完页面就被销毁了落盘不保险。我的做法是分层策略小日志和临时加速用内存队列中等量日志用localStorage做短期缓存只在特殊场景比如日志量特大或需要离线较长时间启用IndexedDB。另外localStorage写入本身是同步的在页面hidden事件里flush反而更可靠——它能在页面回收前完成写盘。localStorage的基础封装长这样const STORAGE_KEY fe_log_queue_v2; function readQueueFromStorage() { try { const raw localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function appendLogToStorage(log) { try { const queue readQueueFromStorage(); queue.push(log); // 只保留最近500条防止缓存失控 if (queue.length 500) { queue.splice(0, queue.length - 500); } localStorage.setItem(STORAGE_KEY, JSON.stringify(queue)); } catch (e) { // QuotaExceededError丢弃最老日志 console.warn(log cache write failed, e); } }这里加上长度限制很重要防止日志积压太多把localStorage整个占满影响其他业务。3.2 队列管理器怎么设计与实现日志队列是整套不丢包方案的核心。我的设计分三层内存队列、localStorage持久层、发送器。内存队列负责接收业务方埋点产生的日志发送器统一调度发送。发送成功后从内存和localStorage里同时移除发送失败则追加进localStorage等下次机会重试。核心管理器class LogReporter { constructor({ endpoint, flushInterval 10000, maxRetry 5 }) { this.endpoint endpoint; this.flushInterval flushInterval; this.maxRetry maxRetry; this.memoryQueue []; this.storageQueue readQueueFromStorage(); this.isFlushing false; this.timer null; this.retryCount 0; this.start(); } log(level, message, extra {}) { const item { id: generateRequestId(), level, message, extra, ts: Date.now(), retryTimes: 0 }; this.memoryQueue.push(item); appendLogToStorage(item); this.scheduleFlush(); } scheduleFlush() { if (!this.timer) { this.timer setTimeout(() this.flush(), this.flushInterval); } } async flush() { if (this.isFlushing) return; this.isFlushing true; const batch this.memoryQueue.length ? this.memoryQueue.splice(0) : readQueueFromStorage().slice(0, 20); if (batch.length 0) { this.isFlushing false; return; } try { await this.sendBatch(batch); // 成功后从storage中移除这批 removeLogsFromStorage(batch.map(i i.id)); this.retryCount 0; } catch (e) { // 失败重试 this.retryCount; if (this.retryCount this.maxRetry) { const delay this.getRetryDelay(this.retryCount); setTimeout(() this.flush(), delay); } else { // 超过最大重试次数丢弃最老的日志 dropOldestLogs(batch.length); this.retryCount 0; } } this.isFlushing false; } getRetryDelay(retryCount) { // 指数退避1s、2s、4s、8s... return Math.min(1000 * Math.pow(2, retryCount - 1), 30000); } async sendBatch(batch) { const res await fetch(this.endpoint, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(batch), keepalive: true }); if (!res.ok) { throw new Error(report failed: ${res.status}); } } start() { this.timer setInterval(() this.flush(), this.flushInterval); window.addEventListener(online, () this.flush()); } }指数退避是重试机制里必须的不然网络一抖动所有客户端同时重试服务端直接被冲垮。退避间隔按1s、2s、4s、8s递增封顶30s这个参数在实践里比较合理。3.3 网络状态监听与恢复触发浏览器提供了navigator.onLine和online/offline事件在代码里可以实时感知网络恢复。不过navigator.onLine在某些浏览器里不准Chrome把它理解为能否连上本地网络不是是否能访问互联网。更好的方式是结合重试机制自愈队列定时flush时发现网络通了自然就发出去了online事件只是加速这一过程。window.addEventListener(online, () { reporter.flush(); // 网络恢复立即尝试补发 });另外移动端App里的WebView经常有自己的网络栈JS侧的online事件不一定准确这种情况下定时器兜底就更重要了。4. 不丢包的另一半生产环境必须做幂等与去重4.1 重试机制带来的重复问题加入重试机制之后多了一个反直觉的问题日志可能被发送两次。比如网络超时其实请求已经到达服务端服务端处理了但响应超时前端收不到回包判定为失败重试后服务端又处理了一次。如果不做幂等日志平台就会多出一倍的错误数据影响监控告警的准确性。这里要区分至少一次和恰好一次语义。日志上报的工程惯例是至少一次因为日志丢一条影响分析多一条后续能通过去重清洗掉。所以幂等设计要放在服务端前端尽量保证不丢服务端用唯一标识消除重复。4.2 requestId怎么生成与传递幂等的前提是每个日志条目有唯一的requestId。生成规则最简单的是时间戳 随机数 递增计数保证同一用户同一时间点的并发请求不会撞ID。let seq 0; function generateRequestId() { const ts Date.now().toString(36); const rand Math.random().toString(36).slice(2, 8); const s (seq).toString(36); return ${ts}-${rand}-${s}; }requestId建议放在日志的body里而不是URL上。一个简单原则每个日志对象自己带id批量上报时服务端逐条校验这样即使一批里有一条重复也只去重那一条不影响其他日志。4.3 服务端去重的常见实现服务端去重有几种方案按项目复杂度选择。如果是小型自研后端用内存Set或者Redis的Set就能解决短期去重问题// Redis SETNX 示例 const key log:req:${requestId}; const isDuplicate await redis.set(key, 1, EX, 86400, NX); if (!isDuplicate) { // 已存在说明是重复日志 return; } // 正常入库用Redis做去重有几个好处分布式环境共享状态不用每台机器各存一份Set导致去重失效设置过期时间能自动清理历史watch避免Key无限增长。更严格的做法是数据库唯一索引。日志入库时把requestId设为唯一索引字段重复插入会被数据库拒绝从源头保证不重复。这个方案在数据量不大时最省心。时间窗口去重也常见用Redis的固定窗口比如5分钟内判断同一用户同一requestId是否出现过。窗口内的重复丢弃窗口外视为新日志。这样能覆盖99%的重试场景。5. 一个可落地的完整实现日志上报SDK核心代码5.1 SDK整体架构设计把前面几条链路整合起来一个最小可用的日志上报SDK应该有四个模块日志采集器、内存缓冲、发送器、持久化缓存。采集器接收业务埋点缓冲内存队列发送器优先走sendBeacon/keepalive路径失败则入持久化缓存等待重试。模块之间的依赖关系很简单业务方调用report(log)采集器封装数据、生成id写入内存队列和localStorage发送器定时或事件触发时把队列批量发出去。5.2 核心代码实现写一个精简但完整的核心实现你可以在项目里直接改改参数用class FrontendLogger { constructor(options {}) { this.endpoint options.endpoint || /api/log/report; this.cacheKey options.cacheKey || fe_logger_cache_v1; this.maxCached options.maxCached || 300; this.flushInterval options.flushInterval || 8000; this.queue this.restoreFromCache(); this.timer null; this.started false; } start() { if (this.started) return; this.started true; // 页面隐藏时优先用sendBeacon document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { this.flushWithBeacon(); } }); window.addEventListener(pagehide, () { this.flushWithBeacon(); }); // 定时兜底发送 this.timer setInterval(() { if (navigator.onLine) { this.flush(); } }, this.flushInterval); // 网络恢复时立即补发 window.addEventListener(online, () this.flush()); } report(level, message, extra {}) { const logItem { id: this.genId(), level, message, extra, ts: Date.now() }; this.queue.push(logItem); this.persist(); if (this.queue.length 5) { this.flush(); } } genId() { return Date.now().toString(36) - Math.random().toString(36).slice(2, 8) - (this._seq (this._seq || 0) 1).toString(36); } persist() { try { // 截断队列长度 if (this.queue.length this.maxCached) { this.queue this.queue.slice(-this.maxCached); } localStorage.setItem(this.cacheKey, JSON.stringify(this.queue)); } catch (e) { // 存储失败时只保留最近的旧数据尽力而为 } } restoreFromCache() { try { const raw localStorage.getItem(this.cacheKey); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } flushWithBeacon() { if (this.queue.length 0) return; let logs; try { logs this.queue.splice(0); const blob new Blob([JSON.stringify(logs)], { type: application/json }); const sent navigator.sendBeacon(this.endpoint, blob); if (!sent) { this.queue logs.concat(this.queue); } } catch (e) { this.queue logs.concat(this.queue); } } async flush() { if (this.flushing || this.queue.length 0) return; this.flushing true; const batch this.queue.splice(0, 10); try { const res await fetch(this.endpoint, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(batch), keepalive: true }); if (res.ok) { // 发送成功从缓存里移除这批日志 const remain this.queue.filter(item !batch.some(b b.id item.id)); this.queue remain; this.persist(); } else { throw new Error(HTTP ${res.status}); } } catch (e) { // 失败恢复队列并延迟重试 this.queue batch.concat(this.queue); this.persist(); if (navigator.onLine) { setTimeout(() this.flush(), Math.min(1000 * (this.retryTimes || 0), 10000)); this.retryTimes (this.retryTimes || 0) 1; } } this.flushing false; } }这个实现比较简洁flush之前的去重逻辑可以加一个Map或者Set避免queue里有重复id的日志。5.3 关键踩坑记录这块我踩过几个坑有必要单独展开。第一个是iOS Safari的页面冻结问题。iOS上页面切到后台JavaScript定时器会暂停等切回前台才继续执行。这意味着网络恢复回调、定时重试在页面处于后台时根本不会触发。日志队列只能等页面下次激活才能发送这不是代码问题是平台限制。我处理的办法在visibilitychange变为visible时也做一次flush让页面回到前台时有新的事件触发发送。第二个是localStorage写满导致所有写入抛异常。业务方可能在localStorage里存了大量缓存日志存储逻辑需要try-catch包裹并在异常时裁掉一部分旧日志保证缓存可写。第三个是sendBeacon发送的请求体带Content-Type问题。如果直接传stringContent-Type是text/plain服务端如果严格校验类型会拒收。明确用Blob并设置application/json是成本最低的解决方案。第四个关于队列乱序网络重试可能导致后发的日志先到服务端入库顺序和前端产生顺序不一致。日志平台如果依赖时间序列做分析服务端入库要按日志的ts字段排序而不是按接收顺序入库。6. 常见问题排查与性能成本优化速查6.1 问题排查表整理了一些我在对接和排障时经常碰到的问题直接对照处理问题现象可能原因排查方式与解决建议sendBeacon返回false请求体超过64KB压缩日志、分批发送改用批量上报localStorage写入抛异常容量已满或被其他业务占满try-catch包裹写入失败时丢弃最老日志后台页面定时器不执行iOS Safari冻结后台JS在visibilitychange visible时补发服务端做幂等去重HTTPS页面调用sendBeacon报错浏览器要求安全上下文确认站点是HTTPS或localhost不支持则降级fetch服务端收到大量重复日志重试机制导致重复提交服务端用requestId做唯一索引或Redis去重弱网下请求长时间pendingfetch没有超时设置用AbortController设置超时超时后入队重试切后台后日志没发出visibilitychange事件未触发检查是否在子页面元素上监听改用pagehide兜底日志平台时间轴乱序入库顺序和产生顺序不一致日志入库按ts字段排序不依赖服务端接收顺序6.2 性能与成本控制技巧日志上报成本主要来自流量和存储很多团队日志量大了之后才想起来控制成本已经上去了。我的建议是分级采样。访问量级的页面错误日志和关键业务日志100%上报普通的商品曝光等调试日志可以按10%采样。采样在采集端做if (Math.random() 0.1) report(...)简单有效。批量上报也可以大幅减少请求数。默认队列积攒5条或500ms发一次效果比单条发几十倍地节省连接资源。上面SDK代码里的flush逻辑已经带了批量发送。再一个经验是压缩。日志内容里大量重复字段使用JSON.stringify之后用Blob传可以在服务端做一次解压。简单场景下把常见的字段名缩写也可以省不少流量比如eventType写成etuserAgent写成ua。6.3 数据完整性验证方法最后补充一个验证不丢包的思路。要验证上报链路是否真的不丢单靠看日志平台数据是无法确定的。我在项目里会打一个心跳日志每次页面加载生成一个startup事件包含页面sessionId和唯一数。上线运行一段时间后统计后台收到的startup数和真实PV做对比偏差就是报损率。这个验证方法非常有效。如果报损率超过5%说明链路还有漏洞继续优化队列持久化或服务端接收逻辑直到报损率趋近于0。我在实际运行中最明显的一个感受是日志上报不丢包不是一个单一功能而是一条完整的链路工程。前端负责采集与缓存后端负责接收与去重每个环节都守住自己的职责整体才能稳定。所谓最稳的方案其实就是在每个可能丢包的断点上都准备一个兜底策略——页面关闭有sendBeacon断网弱网有本地队列重试有幂等去重层层的兜底叠在一起日志自然就丢不了了。最后再分享一个小技巧每次发版前我会在浏览器DevTools里手动切到Offline模式打一批日志等网络恢复后观察队列是否完整补发。这个测试流程走通一次线上就放心大半。日志上报这种东西线上出问题很难复现提前把链路验证扎实比啥都强。
返回列表