18

可观测性与稳定性

1,197 行约 53 分钟33 个代码块29 张表格更新于 2026-11-03

18 · 可观测性与稳定性(详解版)

一句话定位:可观测性是「不用问用户,就能知道系统发生了什么」;稳定性是「出了问题能快速发现、快速止血、不再复发」。

读完本篇你应该能做到

  1. 搭一套前端错误、性能、行为的采集与上报;
  2. 用 Source Map 还原堆栈、用指纹聚类异常、用 traceId 串起全链路;
  3. 设计可执行的告警与 SLO,而不是「告警风暴」或「静默故障」;
  4. 建立故障响应、复盘与稳定性治理机制。

0. 领域导论

0.1 这个领域解决什么问题

现象根因归属
用户说白屏,你没有任何日志没有全局错误捕获采集缺失
错误量突增,但堆栈全是压缩代码未上传/未关联 Source Map可读性
接口慢,不知道是 DNS、网关还是后端没有分段耗时链路追踪
一天 500 条告警,没人看无聚合、无分级、无抑制告警设计
故障恢复后没人知道为什么无复盘、无改进项流程

可观测性解决的是「未知的未知」:你无法预先为每个 bug 写日志,但可以采集足够的信号,在出问题时推断发生了什么。

0.2 一个类比

把系统想成医院里的病人:

  • 日志是病历:发生了什么,按时间记录。
  • 指标是监护仪:心率、血压的连续曲线,超阈值就响。
  • 追踪是造影:一次完整的血流路径,能看到哪里堵了。
  • 告警是护士站的铃:只在真正危险时响,不能一直响。
  • 复盘是病例讨论:下次遇到同类情况怎么处理。

只有病历没有监护仪,你会错过突发;只有监护仪没有病历,你知道异常但不知道为什么。

0.3 学习地图

18 可观测性与稳定性
  ├─ 前置:11 性能(指标)、14 Node/BFF(服务端)
  ├─ 紧接:20 工程效能(复盘与组织)
  ├─ 交叉:12 安全(异常检测)、13 测试(回归用例)
  └─ 长期:稳定性治理体系

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础会接错误监控、看面板错误采集能定位报错页面与版本
L2 进阶会做性能采集、上报优化、告警RUM 看板能解释指标含义
L3 高级会做链路追踪、异常聚类、SLO稳定性平台能定量评估影响面
L4 专家建稳定性体系与应急机制SLO/预案/演练能对可用性负责

1. L1 基础:先把错误收上来

1.1 三类信号

信号回答什么前端例子
日志(Logs)发生了什么错误堆栈、用户操作
指标(Metrics)趋势如何错误率、LCP、白屏率
追踪(Traces)慢在哪一段请求 → BFF → 下游

顺序建议:先做错误采集(收益最高),再做性能指标,最后做全链路追踪。

1.2 前端错误的四个来源

/**
 * 1) JS 运行时错误
 */
window.addEventListener('error', (event) => {
  // 资源加载失败也会触发 error,但没有 error 对象
  if (!event.error) return;
  report({
    type: 'js',
    message: event.message,
    stack: event.error?.stack,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
  });
});

/**
 * 2) Promise 未捕获拒绝
 */
window.addEventListener('unhandledrejection', (event) => {
  const reason = event.reason;
  report({
    type: 'promise',
    message: reason?.message ?? String(reason),
    stack: reason?.stack,
  });
});

/**
 * 3) 资源加载失败(捕获阶段才能拿到)
 */
window.addEventListener('error', (event) => {
  const target = event.target;
  if (target instanceof HTMLScriptElement ||
      target instanceof HTMLLinkElement ||
      target instanceof HTMLImageElement) {
    report({ type: 'resource', tag: target.tagName, url: target.src || target.href });
  }
}, true); // 关键:捕获阶段

/**
 * 4) 接口错误(在请求封装层统一处理)
 */
async function request(url, options) {
  try {
    const res = await fetch(url, options);
    if (!res.ok) report({ type: 'api', url, status: res.status });
    return res;
  } catch (err) {
    report({ type: 'network', url, message: err.message });
    throw err;
  }
}

注意:window.onerror 只能拿到同源脚本的详细信息;跨域脚本需要在 <script> 上加 crossorigin,否则堆栈是 Script error.。

1.3 上报:别让监控拖慢页面

/**
 * sendBeacon:页面卸载也能发出,不阻塞导航。
 * 降级:fetch keepalive。
 */
function send(payload) {
  const body = JSON.stringify(payload);

  if (navigator.sendBeacon) {
    navigator.sendBeacon('/log', new Blob([body], { type: 'application/json' }));
    return;
  }

  fetch('/log', { method: 'POST', body, keepalive: true }).catch(() => {});
}
方式页面卸载阻塞建议
sendBeacon支持否首选
fetch keepalive支持否备选
img src支持否老方案,体积受限
XMLHttpRequest 同步支持是禁用

1.4 采样与去重

/**
 * 采样:错误全采,行为按比例采,避免成本失控。
 */
const SAMPLE = 0.1;

function shouldSample(type) {
  if (type === 'error') return true;        // 错误不采样
  return Math.random() < SAMPLE;
}

/**
 * 去重:同一错误在同一会话只报一次,避免风暴。
 */
const seen = new Set();
function dedupe(key) {
  if (seen.has(key)) return false;
  seen.add(key);
  return true;
}

1.5 上下文比堆栈更有用

/**
 * 只报 "Cannot read property 'id' of undefined" 基本无法定位。
 * 必须带:页面、版本、用户、设备、操作路径、请求 id。
 */
function buildContext() {
  return {
    url: location.href,
    release: window.__APP_VERSION__,
    userId: getUserId(),          // 注意脱敏
    ua: navigator.userAgent,
    viewport: `${innerWidth}x${innerHeight}`,
    dpr: devicePixelRatio,
    network: navigator.connection?.effectiveType,
    traceId: window.__TRACE_ID__,
  };
}

1.6 框架层错误:全局监听抓不到什么

window.onerror 是兜底,不是全部。框架有自己的错误边界,SSR 与资源加载还有独立路径。

错误类型window.onerror 能否捕获正确捕获方式备注
渲染期异常(React)能,但会卸载整棵树ErrorBoundary + 全局兜底边界只包住子树,避免全页白屏
事件回调异常能全局监听边界抓不到事件回调里的错误
异步回调异常能(未捕获时)unhandledrejection / onerror被 catch 吞掉则无法感知
Vue 组件异常能app.config.errorHandler可拿到组件名与生命周期钩子
动态 import() 失败能监听 + 重试 + 兜底 UI发版后旧 chunk 404 的高频场景
水合不一致(SSR)视框架框架 hydration 错误钩子生产环境常被降级为警告
资源加载失败需捕获阶段addEventListener('error', fn, true)目标元素上有 src/href

React 的两个关键事实:

/**
 * ErrorBoundary 只捕获子树「渲染、生命周期、构造函数」中的错误。
 * 事件回调、定时器、Promise、SSR 数据请求都不在它的范围内。
 */
class Boundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    // info.componentStack 能定位到组件层级
    report({ type: 'react', error, componentStack: info.componentStack });
  }

  render() {
    return this.state.error ? <Fallback /> : this.props.children;
  }
}

动态 chunk 失败的处理(发版后最常见的一类「白屏」):

/**
 * 动态导入失败时重试一次,仍失败则提示刷新。
 * 原因:发版后旧 chunk 被清理,用户手里的 index.html 仍指向旧文件。
 */
async function lazyImport(factory, retries = 1) {
  try {
    return await factory();
  } catch (error) {
    if (retries > 0 && /Loading chunk|Importing a module script failed/.test(String(error))) {
      return lazyImport(factory, retries - 1);
    }
    report({ type: 'chunk-load', error });
    showReloadTip(); // 提示用户刷新,而不是停在空白页
    throw error;
  }
}

工程上的三条防线:

  1. 路由级 ErrorBoundary + 根级兜底,避免一个组件拖垮整页;
  2. 旧版本静态资源保留一个发布周期,或给 chunk 失败加重试;
  3. 框架错误钩子里补「组件栈 + 路由 + 版本」,否则只剩一句 message。

1.7 采集 SDK 的六个工程约束

监控代码本身不能成为故障源。上线前逐条检查:

约束失败后果做法
体积首屏变慢,被业务方要求下线核心 < 10KB gzip,插件按需加载
初始化时机早期错误漏采<head> 内联最小引导脚本,先入队后补发
异常隔离SDK 抛错导致业务白屏所有入口 try/catch,上报失败静默
队列与重试弱网/卸载时丢数据内存队列 + 批量 + 退避重试 + 隐藏时 flush
隐私合规风险字段白名单、脱敏、征得同意、最小化采集
自身可回滚新版 SDK 引入新问题SDK 独立版本号 + 灰度 + 远程开关
/**
 * 极简上报队列:批量、失败退避、页面隐藏时补发。
 * @param {{endpoint: string, maxBatch?: number, interval?: number}} options
 */
function createReporter({ endpoint, maxBatch = 10, interval = 5000 }) {
  const queue = [];
  let timer = null;
  let retry = 0;

  const flush = async () => {
    if (queue.length === 0) return;
    const batch = queue.splice(0, maxBatch);
    try {
      await fetch(endpoint, {
        method: 'POST',
        body: JSON.stringify(batch),
        keepalive: true,
      });
      retry = 0;
    } catch {
      // 失败放回队首,指数退避,最多保留 200 条
      queue.unshift(...batch);
      if (queue.length > 200) queue.length = 200;
      retry += 1;
      setTimeout(flush, Math.min(30000, 1000 * 2 ** retry));
    }
  };

  const push = (item) => {
    queue.push(item);
    if (queue.length >= maxBatch) flush();
    else if (!timer) timer = setTimeout(flush, interval);
  };

  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush();
  });

  return { push, flush };
}

一个常被忽略的点:SDK 自己也要有「错误率」与「丢弃率」指标。否则采集链路坏了,你看到的只是「线上很稳定」。

1.8 L1 小结与自检

主题关键结论
错误源JS / Promise / 资源 / 接口
上报sendBeacon 优先,不阻塞
采样错误全采,行为采样
上下文页面/版本/用户/设备/链路

自检题

  1. 为什么跨域脚本的错误信息是 Script error.?——浏览器为防信息泄漏会隐藏跨域脚本细节;需要 <script crossorigin="anonymous"> 且 CDN 返回 Access-Control-Allow-Origin。
  2. 资源加载失败为什么要在捕获阶段监听?——资源错误不冒泡,只有捕获阶段能拿到 target 与资源 URL。
  3. 只上报错误信息不上报上下文,会带来什么问题?——无法复现与定位;需要版本、路由、用户环境、面包屑与 traceId 才能判断影响面。
  4. 有了 window.onerror,为什么还需要 ErrorBoundary?——全局监听只能上报,无法阻止整棵树被卸载;边界能把故障限制在局部并给出降级 UI,两者职责不同。

2. L2 进阶:可读、可用、可告警

2.1 Source Map:把压缩代码还原成人话

构建 → 生成 .map → 上传到监控平台 → 用 release + filename 关联
上报 → 堆栈是压缩后的位置 → 平台用 map 反解出源码行列

工程要点:

  1. 构建时生成 sourcemap(hidden 模式);
  2. 上传时带上 release(版本号)与 dist(构建号);
  3. .map 不要公开可访问;
  4. 发布与上传必须原子(先上传 map 再发布)。
/**
 * 上报时带上版本,否则平台无法匹配对应的 map。
 */
report({ ..., release: '1.8.3', dist: '20261030.1' });

2.2 性能采集:RUM 的最小实现

/**
 * 采集核心指标 + 分位统计所需的原始值。
 * 注意:只上报数值,不要在客户端聚合,便于后端按维度切分。
 */
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    report({
      type: 'metric',
      name: 'LCP',
      value: Math.round(entry.startTime),
      element: entry.element?.tagName,
      url: location.pathname,
    });
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });
指标采集方式注意
LCPPerformanceObserver交互后停止更新
INPevent + 最差值需要 durationThreshold
CLSlayout-shift排除用户输入后的偏移
TTFBNavigation Timing区分缓存命中
资源resource大资源单独告警

2.3 用户行为链路:还原「用户做了什么」

/**
 * 面包屑:记录最近 N 个操作,出错时一起上报。
 * 注意:不记录输入内容(隐私)。
 */
const breadcrumbs = [];
const MAX = 20;

function addBreadcrumb(action) {
  breadcrumbs.push({ action, at: Date.now() });
  if (breadcrumbs.length > MAX) breadcrumbs.shift();
}

// 常用埋点
addBreadcrumb({ type: 'click', target: 'submit-order' });
addBreadcrumb({ type: 'route', from: '/cart', to: '/checkout' });
addBreadcrumb({ type: 'api', url: '/api/order', status: 500 });

2.4 异常聚类:把一万条错误变成十条

/**
 * 指纹:用「错误类型 + 消息模板 + 关键堆栈帧」生成稳定 hash。
 * 消息里的动态内容要归一化,否则每条都不同。
 */
function fingerprint(error) {
  const msg = error.message
    .replace(/\d+/g, 'N')
    .replace(/"[^"]*"/g, '"S"')
    .replace(/'[^']*'/g, "'S'");

  const frame = error.stack?.split('\n')[1]?.trim() ?? '';
  return hash(`${error.name}|${msg}|${frame}`);
}

聚类后看什么:影响用户数、影响页面、首次出现版本、是否新错误。

2.5 告警设计:少而准

反模式后果改进
每个错误都告警风暴,被忽略按影响面聚合
只看单机指标误报看用户影响
无分级小事叫醒人P0/P1/P2
无抑制同一问题刷屏去重 + 静默窗口
无 owner没人处理明确责任人
告警分级:
P0:核心链路不可用(白屏率 > 5%、下单成功率 < 90%)→ 立即电话
P1:部分用户受影响(错误率翻倍、P99 超阈值)→ 工作时间处理
P2:趋势异常(缓慢上涨)→ 每日巡检

好告警的三个条件:可行动、有 owner、有明确阈值与影响面。

2.6 日志规范

/**
 * 结构化日志:字段固定,便于检索与聚合。
 * 禁止:拼接字符串、打印敏感信息、无级别。
 */
logger.error('order.create.failed', {
  requestId,
  userId: mask(userId),
  orderId,
  code: err.code,
  durationMs: Date.now() - start,
});
级别用途
debug开发排查,生产关闭
info关键流程节点
warn可恢复异常
error需要人处理

2.8 分位数、维度与基数

为什么看分位数:均值会被大量快请求稀释。1% 的用户等了 10 秒,均值可能只涨 100ms,但那 1% 正是流失用户。

口径适合回答陷阱
均值总量/成本估算掩盖长尾
P50典型用户体验对尾部不敏感
P75官方 Web Vitals 口径低流量页面波动大
P95/P99稳定性与 SLA样本少时不可信
最大值极端个案易被单次异常带偏

分位数不能直接求平均:两个页面的 P95 取平均没有意义。正确做法是保留直方图桶或原始值,在查询时按目标维度重新聚合。

维度设计:能回答「谁、在哪、什么版本、什么网络下更差」。

维度用途基数风险
页面路由定位问题页面中(动态路由需归一化)
版本/release判断是否新引入低
设备/系统兼容性问题中
网络类型弱网优化低
地域CDN/运营商问题中
用户 ID禁止作为指标标签极高

基数爆炸:标签组合数 = 各维度取值数相乘。页面(200) × 版本(50) × 机型(100) × 地域(30) 已经是 3000 万条时间序列。控制方法:

  1. 指标只保留可枚举的低基数字段;
  2. 高基数信息放进日志/追踪,用检索而不是聚合;
  3. 动态路由归一化(/order/123 → /order/:id);
  4. 对长尾维度做 other 归并。

2.9 上报链路:批量、重试与顺序

采集 ─► 内存队列 ─► 批量序列化 ─► 压缩 ─► 传输 ─► 网关 ─► 存储 ─► 查询
        │              │                        │
        └─ 上限淘汰     └─ 超过阈值立即发        └─ 鉴权/限流/采样
环节风险做法
队列内存涨、页面卡固定上限(如 200 条),先进先出
批量卸载时丢最后一批visibilitychange 强制 flush
传输弱网失败keepalive + 退避重试 + 上限
网关被打爆客户端限流 + 服务端按来源配额
顺序事件乱序带客户端时间戳与自增序号,服务端按会话排序

上报协议建议:字段固定、可扩展、带 schemaVersion;服务端对未知字段宽容,避免 SDK 升级导致整批丢弃。

成本控制优先级:

错误:全量(但做指纹去重)
性能:全量原始值 + 后端降采样
行为:按会话采样(1%–10%)
调试日志:仅灰度/白名单开启

2.10 L2 小结与自检

主题关键结论
Source Maprelease 关联,私有上传
性能采原始值,后端切分维度
面包屑还原操作路径,不记隐私
聚类消息归一化 + 堆栈指纹
告警可行动、有 owner、有分级

自检题

  1. 为什么 sourcemap 上传要和发布绑定版本?——堆栈行列对应特定产物,版本错配会还原到错误位置;发布时同步上传并保留历史版本。
  2. 错误聚类为什么必须归一化消息中的数字?——ID、时间、金额会让同一错误产生海量 fingerprint,聚类失效;替换为占位符再归并。
  3. 一条「可行动」的告警应该包含哪些信息?——什么错、影响多少用户、哪个版本与路由、首次/最近时间、堆栈与 traceId、负责人与处置建议。
  4. 为什么不能把用户 ID 作为指标标签?——每个用户都会生成新的时间序列,基数爆炸导致存储与查询成本失控;用户维度应放在日志或追踪里检索。

3. L3 高级:链路、SLO 与定位

3.1 traceId:把前端与后端串起来

浏览器生成 traceId
  → 请求头 x-trace-id
  → BFF 透传 + 生成 span
  → 下游服务继续透传
  → 日志/错误都带上 traceId
/**
 * 前端注入 traceId,并在错误上报时携带,
 * 这样「用户报错」能直接查到后端日志。
 */
const traceId = crypto.randomUUID();

function withTrace(headers = {}) {
  return { ...headers, 'x-trace-id': traceId };
}

report({ ..., traceId });

3.2 OpenTelemetry 前端接入

信号前端能力
Trace页面加载、路由切换、fetch 调用
MetricWeb Vitals、自定义计数
Log结构化错误日志

注意:前端不采集所有请求(成本高),只采集关键链路与慢请求。

3.3 白屏检测

/**
 * 采样法:在视口内取若干点,检查是否命中有效元素。
 * 有效元素:非 html/body、有尺寸、非透明。
 */
function isBlank() {
  const points = [
    [innerWidth / 2, innerHeight / 2],
    [innerWidth / 4, innerHeight / 3],
    [innerWidth * 0.75, innerHeight * 0.6],
  ];

  return points.every(([x, y]) => {
    const el = document.elementFromPoint(x, y);
    if (!el || el === document.body || el === document.documentElement) return true;
    const rect = el.getBoundingClientRect();
    return rect.width === 0 || rect.height === 0;
  });
}

// 在 load + 3s 时检测,避免误判慢加载
setTimeout(() => {
  if (isBlank()) report({ type: 'blank-screen', url: location.href });
}, 3000);

局限:单页应用路由切换后也要检测;骨架屏会被误判为「非白屏」,需要结合业务标记。

3.4 SLI / SLO / 错误预算

概念定义例子
SLI实际测量值下单成功率 99.2%
SLO目标值99.9% / 30 天
错误预算允许的失败量0.1% × 请求数
燃烧率预算消耗速度1 小时烧掉 10%
错误预算用完后:
  冻结非必要发布 → 集中修复稳定性 → 复盘 → 恢复发布

前端 SLO 示例:

指标目标
页面可用率(非白屏)99.9%
关键接口成功率99.95%
LCP P75< 2.5s
JS 错误率(每千次会话)< 5

3.5 故障定位的标准动作

1. 确认影响面:多少用户、哪些页面、哪些版本、哪些地域
2. 看时间线:什么时候开始,和什么变更重合
3. 分层排查:前端(错误/白屏)→ 网络(DNS/CDN)→ BFF → 下游
4. 用 traceId 找具体请求
5. 对比版本/灰度分组
6. 止血优先于根因

经验:90% 的线上问题与「最近的变更」有关。先看发布记录。

3.6 数据质量:漏报与误报

问题原因处理
漏报上报被拦截、采样过高、SDK 未初始化多通道 + 采样校准
误报白屏检测误判、第三方脚本错误过滤规则 + 白名单
重复同一错误多处捕获指纹去重
偏差只在高端机采集分设备/网络统计

校验方法:用合成监控(固定环境)与 RUM 对比,差异过大说明采集有问题。

3.7 隐私与合规

/**
 * 上报前统一脱敏:
 * - 手机号/身份证/邮箱
 * - URL 中的 token/查询参数
 * - 输入框内容
 */
function mask(text) {
  return String(text)
    .replace(/\d{11}/g, '***********')
    .replace(/[\w.-]+@[\w.-]+/g, '***@***')
    .replace(/(token|password|secret)=[^&]*/gi, '$1=***');
}

红线:不上报用户输入内容、不上报完整 Cookie、不上报身份证/银行卡。

3.8 采样策略:头部、尾部与偏差

策略何时决定优点缺点适用
头部采样请求开始时实现简单、开销低可能丢掉真正有价值的慢/错请求高流量正常请求
尾部采样请求结束后保留错误与慢请求需要缓冲与集中决策链路追踪
分层采样按类型/路由关键链路全采规则需维护前端 RUM
自适应采样按流量动态成本可控统计口径复杂大促/突发

前端推荐组合:

错误:100%
接口慢请求(> 2s):100%
关键页面(下单/支付):50%–100%
普通页面性能:5%–10%
用户行为:1%–5%,按会话一致性采样

会话一致性:同一个会话内的所有事件用同一个采样决定,否则会出现「有错误上报、没有对应行为路径」的断链。

/**
 * 会话级采样:用 sessionId 的哈希决定,保证同一会话内一致。
 * @param {string} sessionId
 * @param {number} rate 0–1
 */
function sampled(sessionId, rate) {
  let h = 0;
  for (let i = 0; i < sessionId.length; i += 1) {
    h = (h * 31 + sessionId.charCodeAt(i)) >>> 0;
  }
  return h % 10000 < rate * 10000;
}

采样后的统计:如果按 10% 采样,分子分母都要按同一权重放大,或者直接用采样后的分母算比例;混用会导致错误率被高估/低估。

一个反直觉的结论:错误全量采集的成本通常远低于行为全量采集,因为错误是稀疏事件;先保证错误不采样,再考虑行为采样。

3.9 L3 小结与自检

主题关键结论
链路traceId 贯穿前后端
白屏采样点检测 + 延迟判定
SLOSLI 测量、预算约束发布
定位影响面 → 时间线 → 分层 → trace
质量合成监控校验 RUM

自检题

  1. traceId 为什么必须由前端生成并透传?——入口最早,能覆盖首屏与静态资源阶段;透传给后端才能形成端到端链路,后端再生成会丢前端段。
  2. 白屏检测有哪些误判场景?——骨架屏、空态、深色背景、弹窗遮挡、iframe 内容、采样点落在空白区,以及渲染尚未完成。
  3. 错误预算用完了应该做什么?——冻结非必要变更,优先修稳定性,复盘后调整 SLO 或架构,而不是直接放宽指标。
  4. 头部采样和尾部采样的根本差别是什么?——决策时点不同:头部在请求开始时按概率丢弃,可能丢掉错误;尾部在请求结束后根据结果决定,能保留错误与慢请求,但需要缓冲。

4. L4 专家:体系与应急

4.1 稳定性体系四层

预防:测试、灰度、容量、依赖治理
发现:监控、告警、合成拨测、用户反馈
止血:降级开关、回滚、限流、切流
改进:复盘、用例沉淀、机制建设

多数团队只做了「发现」,缺预防与止血,导致每次故障都靠人救火。

4.2 故障响应流程

阶段动作owner
发现告警/反馈,5 分钟内响应oncall
定级P0/P1/P2,通知相关方值班负责人
止血回滚/降级/切流技术负责人
沟通对内同步进展,对外公告指定接口人
恢复确认指标回归oncall
复盘时间线、根因、改进项全员

原则:止血优先;单一指挥;信息同步有固定节奏(如每 15 分钟)。

4.3 演练

演练目的
故障注入验证降级是否真的生效
混沌工程随机杀实例/断网,验证韧性
预案演练确认回滚与切流能在 X 分钟内完成
告警演练确认告警能到人、有人响应

没有演练的预案 = 文档。

4.4 复盘的正确写法

1. 事实时间线(精确到分钟,不含猜测)
2. 影响面(用户数、时长、金额)
3. 根因(技术 + 流程)
4. 改进项(每条有 owner 与截止时间)
5. 哪些做得好(可复制的经验)

反模式:只写「加强测试」「提高意识」——不可执行、不可验收。

4.5 成本控制

成本优化
日志存储分级保留、采样、冷热分离
指标基数控制维度组合(避免高基数标签)
Trace 存储头部采样 + 慢请求全采
告警噪音聚合、抑制、分级

注意:高基数标签(用户 id、完整 URL)是指标成本爆炸的头号原因。

4.6 事故指挥:让多人协作不乱

故障期间最大的浪费不是技术排查,而是信息不同步与重复劳动。规模稍大的事故必须分角色:

角色职责不该做什么
事故指挥(IC)定优先级、分配任务、决定止血方案亲自钻进日志里查
操作负责人执行回滚/降级/切流同时对外解释
沟通负责人对内同步、对外公告、更新状态页承诺未验证的恢复时间
记录人时间线、决策、指标变化参与技术争论

沟通节奏:固定每 15 分钟同步一次,即使没有进展也要说「当前假设、下一步、需要谁」。没有节奏的同步会变成持续打断。

对内同步模板(可直接粘贴到群里):

[P1] 下单页白屏
影响:约 8% 用户,集中在 iOS 16 / 版本 3.2.1
开始:14:03,当前状态:已定位到 chunk 加载失败
已做:回滚静态资源版本至 3.2.0(14:18)
待做:观察白屏率 15 分钟;确认 CDN 缓存已刷新
下次同步:14:35
指挥:@张三  沟通:@李四

对外公告的三条纪律:

  1. 只讲已确认的影响面与已采取的动作,不猜测根因;
  2. 给「下次更新时间」而不是「预计恢复时间」;
  3. 恢复后补一条总结,说明影响时段与后续改进。

止血优先级(从快到慢):

关闭功能开关 → 回滚版本 → 切流/降级 → 限流 → 紧急修复

判断指挥是否称职:故障结束后,团队能否在 10 分钟内说出「影响谁、什么时候开始、做了什么、还差什么」。

4.7 L4 自检

  1. 如何把一个「经常小故障」的系统做到 99.9%?——先量化故障源(发布、依赖、容量、前端),再做冗余与降级、灰度与回滚、演练与 oncall,用错误预算驱动优先级。
  2. 告警每天 300 条,如何在不漏报的前提下降噪?——按 fingerprint 聚合、按影响面分级、设置抑制与静默、合并同源告警,只保留「新增且影响大」的信号。
  3. 如何证明稳定性投入的 ROI?——把投入换算成故障损失(资损、工时、口碑)与恢复时长的下降,用事故前后数据对比。

5. 综合实战:白屏率从 3% 降到 0.3%

背景:移动端 H5,白屏率 3%,客服每天收到大量反馈,但研发看不到具体原因。

步骤 1 · 建立采集

// 1) 全局错误 + 资源失败 + 白屏检测 + 版本
// 2) 上报带:url、release、设备、网络、traceId、面包屑

步骤 2 · 聚类分析

聚类占比特征
老版本 SDK 报错45%集中在 3 个月前的版本
首屏 JS 加载失败30%弱网 + 大 chunk
接口 50215%特定地域
未知10%无堆栈

步骤 3 · 对症处理

问题措施效果
老版本残留强制更新 + 兼容补丁-1.2%
首屏 JS 过大分包 + 预加载 + CDN-0.9%
地域 502多可用区 + 重试-0.4%
未知白屏增加骨架屏与重试按钮-0.2%

步骤 4 · 验收

  • 白屏率 < 0.5%
  • 白屏可归因(> 90% 有分类)
  • 告警能在 5 分钟内触达
  • 回滚 < 10 分钟
  • 每个根因都有回归用例

6. 常见误区对照表

误区事实
「接个监控 SDK 就完事」没有上下文与聚类,数据无法定位问题
「错误全部实时上报」成本高且无必要,错误全采、行为采样
「告警越多越安全」噪音会让人忽略真正的问题
「有日志就能排查」没有 traceId,跨服务无法串联
「白屏检测很准」骨架屏、慢加载都会误判,要结合业务标记
「SLO 是运维的事」前端体验指标同样是 SLO 的一部分
「复盘就是写文档」没有 owner 与截止时间的改进项等于没做
「监控能替代测试」监控是发现,测试是预防
「采样会漏掉问题」错误不采样,只对高频行为采样
「稳定性投入看不到收益」用故障时长 × 影响用户 × 成本量化

7. 面试高频题(附答题思路)

Q1:前端怎么做错误监控? 四类错误源 → 捕获方式 → 上下文 → 上报(beacon)→ Source Map → 聚类 → 告警。

Q2:Source Map 怎么用? 构建生成 → 私有上传 → release 关联 → 反解堆栈 → 注意安全与原子发布。

Q3:如何设计告警? 分级、聚合、抑制、owner、可行动;给 P0/P1/P2 例子。

Q4:traceId 的作用? 串联前端/BFF/下游日志;前端生成并透传;错误上报携带。

Q5:如何检测白屏? 采样点 + elementFromPoint + 延迟判定 + 业务标记;再说误判与 SPA 场景。

Q6:SLO 与错误预算? SLI 测量 → SLO 目标 → 预算 → 燃烧率 → 冻结发布机制。

Q7:线上故障怎么定位? 影响面 → 时间线 → 分层 → trace → 版本对比 → 止血优先。

Q8:如何降低监控成本? 错误全采 + 行为采样 + 分级存储 + 控制指标基数 + 头部采样。


8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
采集完整性是否覆盖四类错误源说明资源错误要捕获阶段只监听 window.onerror
可用性上报是否影响页面sendBeacon + 采样 + 队列同步 XHR 上报
还原能力Source Map 链路上传、权限、行列映射「开 sourcemap 就行」
归因能否从告警到代码traceId + 用户行为链路只看错误数量
治理SLO 与噪声错误预算 + 聚合 + 分级所有错误都告警

8.2 追问链

链 1 · 错误捕获 → 覆盖盲区 → 上下文

  • 面试官:前端错误有哪些来源?
    • 要点:JS 运行时错误、未处理的 Promise rejection、资源加载失败(img/script/link)、接口错误、框架渲染错误、跨域脚本错误(Script error.)。每一类的捕获方式不同。
  • 追问:为什么只写 window.onerror 会漏?
    • 要点:它拿不到资源错误的详细信息(需要 addEventListener('error', handler, true) 在捕获阶段监听);也拿不到 unhandledrejection;onerror 还会被后续赋值覆盖。
  • 再追问:跨域脚本的错误为什么是 Script error.?
    • 要点:浏览器出于安全不暴露跨域脚本的细节,需要 CDN 返回 Access-Control-Allow-Origin 并在 <script crossorigin="anonymous"> 上声明,才会给出完整堆栈。

链 2 · Source Map → 还原链路 → 成本与安全

  • 面试官:线上堆栈怎么还原?
    • 要点:构建生成 .map,上传到监控平台并与版本号关联;平台用 source-map 库按行列反查源文件与原始位置;发布时保证版本号与 map 一一对应。
  • 追问:Source Map 能公开吗?
    • 要点:不能。它会暴露源码结构与业务逻辑,应上传到私有存储,通过鉴权访问;产物里用 //# sourceMappingURL 指向内部地址或直接移除。
  • 再追问:还原不准的常见原因?
    • 要点:版本错配(发了新版但 map 没更新)、压缩器改动了行列、内联脚本无 map、多个 chunk 的 map 未合并、堆栈被错误处理截断。

链 3 · traceId → SLO → 告警降噪

  • 面试官:前端怎么和后端串起来?
    • 要点:入口生成 traceId(或从响应头透传),所有请求带 X-Trace-Id,错误与埋点都带上;后端日志与链路系统用同一 ID 关联,形成端到端视图。
  • 追问:SLO 怎么定才有意义?
    • 要点:选用户可感知的 SLI(如白屏率、接口成功率、INP p75),设目标与错误预算;预算耗尽时冻结非必要变更,优先修稳定性。
  • 再追问:告警太多怎么办?
    • 要点:按影响面聚合(同一错误按 fingerprint 合并)、分级(P0 电话、P1 群消息、P2 日报)、设静默与抑制规则;只对「可行动」的信号告警。

链 4 · 上报丢失 → 采样口径 → 数据可信度

  • 面试官:监控显示错误率下降,你怎么确认不是采集坏了?
    • 要点:先看「采集健康度」:上报成功率、队列丢弃数、SDK 初始化率、各版本覆盖率。再看合成监控(固定环境拨测)是否与 RUM 同步变化。
  • 追问:采样 10% 后,错误率怎么算?
    • 要点:分子分母必须用同一采样口径,或按权重还原;只放大分子会高估,只放大分母会低估。更稳妥的是错误全采、只对正常请求采样。
  • 再追问:为什么同一用户的行为和错误会对不上?
    • 要点:两套数据用了不同的采样决定。应按 sessionId 做一致性采样,保证同一会话内要么都采、要么都不采。

链 5 · 告警风暴 → 分级抑制 → SLO 燃烧率

  • 面试官:一天 300 条告警,怎么在不漏报的前提下降噪?
    • 要点:先按 fingerprint 聚合同源告警,再按影响面分级;对「已知且在处理」的告警设静默,对衍生告警设抑制(数据库挂了就不再报每个接口超时)。
  • 追问:为什么用燃烧率而不是固定阈值?
    • 要点:固定阈值对缓慢恶化不敏感。燃烧率看「错误预算消耗速度」:1 小时烧掉 2% 预算(30 天窗口)说明按此速度几天就会耗尽,值得叫醒人。
  • 再追问:静默会不会掩盖真正的问题?
    • 要点:静默要有时限与 owner,并在静默结束时补一条汇总;同时保留「总影响面」指标作为兜底,避免局部静默导致整体失明。

8.3 手写题

题 1 · 前端错误 SDK

/**
 * 极简错误采集:采样、去重、批量、页面隐藏时补发。
 * @param {{endpoint: string, sampleRate?: number, maxBatch?: number}} options
 */
function createErrorReporter({ endpoint, sampleRate = 0.3, maxBatch = 10 }) {
  const queue = [];
  const seen = new Set();

  /** 生成指纹:同名同栈只报一次 */
  const fingerprint = (err) => `${err.name}|${err.message}|${(err.stack ?? '').slice(0, 120)}`;

  const flush = () => {
    if (queue.length === 0) return;
    const batch = queue.splice(0, maxBatch);
    navigator.sendBeacon(endpoint, JSON.stringify({ ts: Date.now(), errors: batch }));
  };

  const push = (payload) => {
    if (Math.random() > sampleRate) return;
    const fp = fingerprint(payload);
    if (seen.has(fp)) return;
    seen.add(fp);
    queue.push(payload);
    if (queue.length >= maxBatch) flush();
  };

  // JS 运行时错误
  window.addEventListener('error', (e) => {
    // 资源错误没有 error 对象
    if (!e.error) {
      push({ type: 'resource', target: e.target?.src || e.target?.href, message: 'load failed' });
      return;
    }
    push({ type: 'js', message: e.message, stack: e.error.stack, filename: e.filename, line: e.lineno });
  }, true);

  // 未处理的 Promise rejection
  window.addEventListener('unhandledrejection', (e) => {
    const reason = e.reason;
    push({ type: 'promise', message: String(reason?.message ?? reason), stack: reason?.stack });
  });

  // 页面隐藏时兜底发送
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush();
  });

  return { push, flush };
}

追问:

  • 为什么用捕获阶段?→ 资源错误不冒泡,只有捕获阶段能拿到。
  • 去重会不会漏掉「同一错误不同用户」?→ 指纹只在单次会话内去重,服务端再按用户与版本聚合。
  • sendBeacon 有什么限制?→ 只支持 POST、有体积上限(约 64KB)、不能自定义部分头,适合小批量上报。

题 2 · 白屏检测

/**
 * 白屏检测:在首屏区域取采样点,命中内容过少则判定白屏。
 * @param {{points?: number, threshold?: number}} [options]
 * @returns {boolean}
 */
function isBlank({ points = 20, threshold = 0.02 } = {}) {
  const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
  if (!el) return true;

  const html = document.body;
  if (!html || html.scrollHeight < window.innerHeight * 0.2) return true;

  let hits = 0;
  for (let i = 0; i < points; i += 1) {
    const x = Math.floor((i % 5) * (window.innerWidth / 5) + window.innerWidth / 10);
    const y = Math.floor(Math.floor(i / 5) * (window.innerHeight / 4) + window.innerHeight / 8);
    const node = document.elementFromPoint(x, y);
    if (node && node !== document.body && node !== document.documentElement) hits += 1;
  }
  return hits / points <= threshold;
}

追问:

  • 为什么不用截图比对?→ 成本高、上传慢,且不同机型渲染差异大,容易误判。
  • 骨架屏会不会被判成「非白屏」?→ 会,所以要在骨架屏出现后、数据渲染后再检测,或排除骨架节点。
  • 误报怎么控制?→ 加多个时间点检测、结合路由与设备分布看比例,而不是单次命中就告警。

题 3 · 错误指纹聚类器

/**
 * 把动态消息归一化后生成稳定指纹,用于聚合「同一类错误」。
 * 关键:ID、时间、金额、URL 参数都要替换为占位符。
 * @param {{name: string, message: string, stack?: string}} error
 * @returns {string} 指纹
 */
function fingerprint(error) {
  const normalize = (text = '') =>
    text
      .replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '<uuid>')
      .replace(/\b\d{10,}\b/g, '<num>')
      .replace(/"[^"]{0,200}"/g, '"<str>"')
      .replace(/'(?:[^'\\]|\\.){0,200}'/g, "'<str>'")
      .replace(/https?:\/\/[^\s)]+/g, '<url>');

  // 只取前 3 个业务栈帧,避免不同调用链导致同一错误分成多个指纹
  const frames = (error.stack ?? '')
    .split('\n')
    .slice(1)
    .map((line) => line.trim().replace(/:\d+:\d+$/, ''))
    .filter((line) => line && !line.includes('node_modules'))
    .slice(0, 3)
    .join('|');

  const raw = `${error.name}|${normalize(error.message)}|${normalize(frames)}`;

  // FNV-1a:短、稳定、无需依赖
  let hash = 0x811c9dc5;
  for (let i = 0; i < raw.length; i += 1) {
    hash ^= raw.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0).toString(16).padStart(8, '0');
}

追问:

  • 为什么只取前 3 个业务栈帧?→ 调用链上层的工具函数差异很大,取全部会把同一错误拆成很多指纹;取最靠近出错点的业务帧更稳定。
  • 归一化过度会怎样?→ 不同错误被合并,定位时互相干扰;要保留错误类型与关键文案,只替换真正动态的部分。
  • 指纹要不要包含版本?→ 聚合时不含版本(便于看趋势),查询时把版本作为维度;否则每次发版都会变成「新错误」。

8.4 场景设计题

题:白屏率 3%,但监控里错误量很低,告警又特别吵。你怎么在两周内把白屏降到 0.3%,同时把噪声压下去?

评估点:是否区分「没报错的白屏」、是否做归因与分级。

参考方案:

  1. 补检测:在 DOMContentLoaded、首屏渲染后、路由切换后三个时点做白屏检测,带上路由、版本、机型、网络。
  2. 归因:按版本与机型分布看突增;检查资源加载失败(CDN、DNS)、JS 执行错误被吞、接口超时导致空态、SSR 输出异常。
  3. 止血:对高频失败资源加备用 CDN;对接口加兜底与重试;坏版本快速回滚。
  4. 降噪:错误按 fingerprint 聚合,按影响用户数分级;只对「新增且影响面大」的告警;设静默窗口与抑制规则。
  5. 验收:白屏率 p75 与总量双降、告警条数下降、MTTR 缩短;把白屏检测加入发布卡点。

加分:指出「没有错误」不等于「没有故障」,白屏可能来自 CSS/资源/接口;指出要区分「全白」与「局部空白」。

减分:只加 try/catch;把所有错误都设成 P0;不做版本维度对比。

题 2:告警每天 300 条,其中真正需要处理的不到 10 条,但上个月漏掉了一次 P1。你怎么重构告警体系?

评估点:是否区分「信号」与「噪声」、是否建立分级与抑制、是否验证漏报。

参考方案:

  1. 盘点与标注:拉取近 30 天告警,标注「真实故障 / 已知问题 / 无效 / 重复」,算出每类占比与响应时长。
  2. 聚合:按 fingerprint 与服务维度合并同源告警;同一根因只保留一条主告警,衍生告警降级为事件记录。
  3. 分级:P0 电话(核心链路不可用)、P1 群消息(部分用户受影响)、P2 日报(趋势异常);每级明确响应时限与 owner。
  4. 抑制与静默:维护期、已知问题、上游故障时抑制下游告警;静默必须有时限与登记。
  5. 补漏:用合成拨测与 SLO 燃烧率兜底,防止「聚合后看不见」;每季度做一次告警演练,回放历史故障验证能否触发。

加分:指出「告警数量」不是目标,「可行动的告警占比」与「MTTD/MTTR」才是;指出要治理「告警疲劳」而不只是调阈值。

减分:直接关掉一半告警;把所有阈值调高;只加静默不做聚合。

8.5 最容易答错的点

常见说法纠正
「window.onerror 能捕获所有错误」漏资源错误、Promise rejection,且会被覆盖
「资源错误会冒泡」不冒泡,必须在捕获阶段监听
「Source Map 放 CDN 方便排查」会暴露源码,应私有存储并鉴权
「错误越少说明越稳定」可能是采集缺失,白屏与卡顿未必报错
「所有错误都该告警」只对可行动、影响面大的信号告警
「采样会漏掉问题」采样用于控制成本,错误类通常全量或高比例采样
「埋点越多越好」冗余埋点增加体积与维护成本,应有指标树
「SLO 是运维的事」前端指标(白屏、INP、接口成功率)需要前端定义与负责
「错误率下降就是变好了」也可能是采集坏了,要看采集健康度与合成监控
「告警阈值调低更安全」噪声会让人习惯性忽略,漏报反而增加
「有 traceId 就能定位」还需 span 分段、版本与变更记录,否则只有一串 ID
「复盘写完就结束了」改进项要进排期、有 owner、有验证,否则同类故障会复发

9. 延伸资源

  • Google SRE Book:SLI/SLO/错误预算
  • OpenTelemetry 文档(前端与 Node SDK)
  • web.dev:Web Vitals 采集与 RUM
  • Sentry / Datadog RUM 文档:错误聚类与 Source Map 上传
  • 《Observability Engineering》(O'Reilly)
  • Chaos Engineering 原则(Principles of Chaos Engineering)
  • 各厂稳定性白皮书与故障复盘公开案例
  • MDN:PerformanceObserver、Reporting API、sendBeacon