发布于

日志的系统观:从 Next.js 到 Spring Boot 的全栈实践

作者
  • avatar
    姓名
    Terry
    Twitter

引子:从前端监控到后端链路

上一篇文章中,我们讨论了前端三大监控闭环:错误监控、行为监控、性能监控。但当 LCP(最大内容绘制)延迟达到 4 秒时,我们发现问题往往不在前端——可能是数据库慢查询、消息队列堆积,或者下游服务超时。

这时候,日志系统就成了串联全栈可观测性的关键。但日志不是简单的 console.log,而是需要:

  1. 结构化:机器可读,支持聚合分析
  2. 全链路:通过 trace_id 串联前端到数据库
  3. 可采样:控制成本,保留关键信息
  4. 可告警:基于日志指标触发自动化响应

国庆假期,我躺在海边的躺椅上。手机突然连续震动,公司的运维群里刷出几条「磁盘空间不足」的告警。问题的源头并不复杂:某个服务的日志没控住体积,几天里把节点撑满了。同事临时删除旧文件,服务恢复,群里回复「忘了挂自动清理脚本」。事情到此为止,但我脑子里留下了两个问题:日志到底记录了些什么?为什么这点基础设施还是靠人工兜底?

日志看似不起眼,却承载系统运行时的全部记忆。把它当垃圾桶,磁盘会被撑爆;把它当调试信息,真正出事故时你又会发现线索全丢了。要把这件“小事”做好,需要系统思维:先保证数据结构正确,再谈实现细节。

日志在可观测性体系中的位置

可观测性三大支柱(Metrics、Logs、Traces)的关系:

Metrics(指标)← 告警触发(如:错误率 > 5%Logs(日志)← 根因分析(如:具体哪个 SQL 慢)
Traces(追踪)← 链路定位(如:请求经过了哪些服务)

日志的独特价值

  • Metrics 太粗:告诉你"错了 100 次",但不知道"为什么错"
  • Traces 太窄:告诉你"哪里慢",但不知道"慢的具体原因"
  • Logs 太细:需要结构化和采样,否则信息过载

日志的系统三问

  1. 这是个真问题还是臆想出来的? 日志不是为了满足「写一点心安」的幻觉,而是要回答线上真实存在的问题:谁、在何时、在哪个链路节点出错。
  2. 有更简单的方法吗? 数据结构先行,少依赖条件判断。把关键信息打包进统一上下文,就不用在每个函数里反复拼字段。
  3. 会破坏什么吗? 「Never break userspace」在日志体系里意味着任何改动都不能让既有查询失效,不能让报警链条断掉,不能吞掉用户正在依赖的指标。

核心数据结构:事件、上下文、状态

好日志的第一性原理是:所有信息都是事件。事件包含三个最小字段:

字段说明示例值
event事件名,直接描述发生了什么order.create.failed
context上下文,至少包含 trace_id 和主体 IDtrace_id: abc123, user_id: 456
payload结构化数据,描述状态或参数{duration_ms: 3500, error: "timeout"}

把事件写成 JSON,就不必在查询时再拆字符串。

{
  "timestamp": "2026-01-10T10:00:00Z",
  "level": "ERROR",
  "event": "order.create.failed",
  "trace_id": "abc123",
  "user_id": 102938,
  "order_id": "SO20241017001",
  "service": "order-service",
  "duration_ms": 3500,
  "error": {
    "type": "NullPointerException",
    "message": "customer is null",
    "stack_trace": "..."
  }
}

与前端监控的关联

  • trace_id:与前端 Performance API 采集的 trace_id 保持一致
  • duration_ms:对应前端的 LCP、TTFB 等指标
  • user_id:与埋点系统的用户 ID 关联

前端(Next.js 客户端):与性能监控联动

浏览器日志的常见误区有两个:滥用 console.log 和在出错时只记录字符串。前端应该像服务端一样严肃,并与性能监控联动。

设计原则

  1. 统一入口:封装日志函数,只暴露 infowarnerror
  2. 自动挂载上下文:启动时生成 session_id,在每条日志里自动附带
  3. 及时上报:用户刷新页面之前,把结构化事件推送到收集端点
  4. 控制开销:高频行为做采样,避免重型对象的 JSON.stringify
  5. 与性能数据关联:日志自动携带当前页面的性能指标

实现示例

// lib/client-logger.ts
import { getLCP, getFID, getCLS } from 'web-vitals'

type LogEvent = {
  level: 'info' | 'warn' | 'error'
  event: string
  payload?: Record<string, unknown>
  error?: Error
}

const sessionId = crypto.randomUUID()
let currentPerformance = { lcp: 0, fid: 0, cls: 0 }

// 采集性能指标
getLCP((metric) => {
  currentPerformance.lcp = metric.value
})
getFID((metric) => {
  currentPerformance.fid = metric.value
})
getCLS((metric) => {
  currentPerformance.cls = metric.value
})

const emit = async (entry: LogEvent) => {
  // 采样:只上报 10% 的 info 日志
  if (entry.level === 'info' && Math.random() > 0.1) return

  const body = {
    event: entry.event,
    level: entry.level,
    session_id: sessionId,
    payload: entry.payload ?? {},
    performance: currentPerformance, // 关联性能指标
    error: entry.error
      ? {
          name: entry.error.name,
          message: entry.error.message,
          stack: entry.error.stack,
        }
      : undefined,
    timestamp: new Date().toISOString(),
  }

  navigator.sendBeacon('/api/logs', JSON.stringify(body))
}

export const clientLogger = {
  info: (event: string, payload?: Record<string, unknown>) =>
    emit({ level: 'info', event, payload }),
  warn: (event: string, payload?: Record<string, unknown>) =>
    emit({ level: 'warn', event, payload }),
  error: (event: string, error: Error, payload?: Record<string, unknown>) =>
    emit({ level: 'error', event, payload, error }),
}

与性能监控的联动场景

场景:用户点击"购买"按钮后无反应

性能监控:INP = 850ms(主线程阻塞)
行为监控:记录"purchase.click"事件,但无"purchase.success"
日志系统:记录"purchase.timeout",携带当前性能数据
综合分析:主线程被加密计算阻塞,导致用户重复点击

Next.js 端使用 App Router,可以在 layout.tsx 里注入 ErrorBoundary,捕获渲染错误并调用 clientLogger.error。同时监听 window.addEventListener('unhandledrejection', ...) 把 Promise 异常纳入同一通道。

Next.js 全栈:Server Actions 和 Route Handlers 的共识

服务端是链路的第一个可信节点。遵循 W3C Trace Context 的做法是:在入口处生成 trace_id,仅当上游(例如 API 网关或边缘节点)已经携带合法的 traceparent 时才复用,绝不依赖浏览器自己造 ID。Next.js 作为 BFF(Backend For Frontend),需要在 Route Handler 中完成创建与透传。

Trace ID 传递机制

// app/api/checkout/route.ts
import { headers } from 'next/headers'
import { randomBytes } from 'node:crypto'
import { log, runWithContext } from '@/lib/server-logger'

const TRACEPARENT_PATTERN = /^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/

const createTraceContext = (incoming: Headers) => {
  const header = incoming.get('traceparent')
  if (header && TRACEPARENT_PATTERN.test(header)) {
    const [, traceId] = header.split('-')
    return { traceId, traceparent: header }
  }

  const traceId = randomBytes(16).toString('hex')
  const spanId = randomBytes(8).toString('hex')

  return {
    traceId,
    traceparent: `00-${traceId}-${spanId}-01`,
  }
}

export async function POST(req: Request) {
  const incoming = headers()
  const trace = createTraceContext(incoming)
  const requestStart = Date.now()

  return runWithContext(trace, async () => {
    log.info('checkout.request.received', {
      userId: incoming.get('x-user-id'),
    })

    try {
      const payload = await req.json()
      const order = await createOrder(payload)
      log.info('checkout.request.completed', {
        orderId: order.id,
        durationMs: Date.now() - requestStart,
      })
      return new Response(JSON.stringify(order), {
        status: 201,
        headers: { traceparent: trace.traceparent },
      })
    } catch (error) {
      log.error('checkout.request.failed', {
        durationMs: Date.now() - requestStart,
        error,
      })
      throw error
    }
  })
}

这里的关键是 runWithContext:底层通过 AsyncLocalStorage 或 OpenTelemetry 的 context API,把 trace_idspan_id 等信息绑定到当前异步调用栈,后续日志自动携带。

与前端监控的完整链路

浏览器 Performance API
  ↓ trace_id: abc123
前端日志(LCP=2.1s, FID=80ms)
  ↓ trace_id: abc123 (通过 fetch header 传递)
Next.js Route Handler
  ↓ trace_id: abc123
后端服务(Java/Node/Go)
  ↓ trace_id: abc123
数据库查询
日志聚合系统(ELK

查询示例

trace_id: "abc123"
→ 前端:LCP 2.1s,用户点击购买
Next.js:checkout.request.received
Java 服务:order.create.success,耗时 500ms
→ 数据库:慢查询 300ms(缺少索引)

根因定位:数据库缺少索引导致订单创建慢,前端 LCP 延迟,用户流失。

Edge Runtime 与缓存

全栈项目常用中间层缓存(例如 Vercel Edge)。在边缘节点也应该沿用同样的事件模型,并把 traceparent 向下游透传,否则链路会断。缓存命中可以写成 cache.hit 事件,失效写成 cache.miss 事件,无需额外判断;统一结构后,Grafana、Jaeger 或 DataZoom 等平台才能把整条链路完整地画出来。

Next.js + Spring Boot:跨语言的 trace_id

大多数团队仍然用 Java 服务承载核心交易。Node + Java 组合最容易出问题的地方是跨语言的上下文丢失。系统的做法是:BFF 端将自己生成的 traceparent 透传给下游服务,每个服务利用 OpenTelemetry 或 MDC 保留同一个 trace_id

从 Next.js 发起请求

// lib/payment-client.ts
import { log } from '@/lib/server-logger'

export async function createPayment(input: PaymentPayload) {
  const trace = log.currentTrace() // { traceId, traceparent }
  const res = await fetch(`${process.env.PAYMENT_URL}/payments`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      traceparent: trace.traceparent,
    },
    body: JSON.stringify(input),
  })

  log.info('payment.request.sent', { traceId: trace.traceId, amount: input.amount })

  if (!res.ok) {
    log.error('payment.request.failed', { traceId: trace.traceId, status: res.status })
    throw new Error('payment service error')
  }

  const payload = await res.json()
  log.info('payment.request.succeeded', { traceId: trace.traceId, paymentId: payload.id })
  return payload
}

log.currentTrace() 读取的就是 runWithContext 放进 AsyncLocalStorage 的那份上下文,因此不会凭空生成新 ID,只是把已有的 traceparent 往下游复用。

在 Spring Boot 里继续关联

// com/example/payment/web/TraceFilter.java
import io.opentelemetry.sdk.trace.IdGenerator;

@Component
public class TraceFilter extends OncePerRequestFilter {
  private static final IdGenerator ID_GENERATOR = IdGenerator.random();
  private static final Pattern TRACEPARENT =
      Pattern.compile("^00-([0-9a-f]{32})-([0-9a-f]{16})-0[0-9a-f]$");

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws ServletException, IOException {
    String header = Optional.ofNullable(request.getHeader("traceparent")).orElse("");
    Matcher matcher = TRACEPARENT.matcher(header);

    String traceId;
    String traceparent;
    if (matcher.matches()) {
      traceId = matcher.group(1);
      traceparent = header;
    } else {
      traceId = ID_GENERATOR.generateTraceId();
      String spanId = ID_GENERATOR.generateSpanId();
      traceparent = String.format("00-%s-%s-01", traceId, spanId);
    }

    MDC.put("trace_id", traceId);
    response.addHeader("traceparent", traceparent);

    try {
      chain.doFilter(request, response);
    } finally {
      MDC.clear();
    }
  }
}

真实项目中建议直接引入 OpenTelemetry:W3CTraceContextPropagator 负责解析/注入 traceparentSlf4jSpanExporter 自动把 trace_id 映射到 MDC。抛出异常时仍然要 log.error("payment.create.failed orderId={}", orderId, ex); 保留第一现场的堆栈。

数据库与存储层:慢查询也是日志

日志不仅属于应用服务,还包含数据库、消息队列、缓存等基础设施。

  • 数据库:开启慢查询日志,并把 trace_id 写进 SQL 注释,如 /* trace_id=04b5... */ SELECT ...。MySQL 8 可以配合 performance_schema 分析热点。
  • 消息队列:生产与消费都要写 event,例如 queue.publish.failed,并记录 topicpartitionoffset
  • 对象存储:下载失败、签名过期等错误同样要记录,避免重传时无据可查。

当所有层都有统一字段,ELK 或 ClickHouse 中的查询才能一次性把链路拉通。

消除特殊情况

把日志写清楚的秘诀其实是减少条件分支。一旦引入「如果是支付就多打几条」「如果是海外用户就换格式」这样的特例,很快就会陷入无法维护的状态。正确的做法是:

  • 事件名表达实体与动作,例如 order.createorder.create.failed
  • 结构体字段保持统一,可选信息用 null 表示缺失。
  • 采样策略集中配置,而不是在代码里套 if (random() > 0.1).

这样,新增业务只需要增加事件,不需要修改既有的查询脚本。老的告警也不会被破坏。

打造成熟日志系统的四个阶段

  1. 统一格式:所有服务输出 JSON,字段名对齐。
  2. 统一上下文trace_idspan_iduser_id 等基础字段全链路透传。
  3. 统一存储与索引:集中化采集(SLS、ELK、ClickHouse),支持按字段检索。
  4. 统一响应:基于日志指标设置阈值报警,严重场景触发自动化脚本(降级、熔断、回滚)。

做到这四步,日志才是系统的「黑匣子」,而不是调试时的便利贴。

实用检查表

在真正发布前,把下面几件事逐条确认:

  • 新功能是否定义了事件?字段是否与旧功能保持一致?
  • 前端、Next.js、Spring Boot、数据库是否都能在日志里看到同一个 trace_id
  • 日志量是否受采样与保留策略保护,不会再次撑爆磁盘?
  • 告警能否在异常发生后的几分钟内触发?负责人知道去哪儿查链路?

结语

日志从来不是锦上添花,而是确保系统可观测的底盘。磁盘满了只是症状,真正的问题是我们是否把日志当成系统的一部分来设计。希望这份笔记能帮你把零散的经验串成方法论,在前端、全栈和 Java 服务之间搭建起同一条透明的调用链。

如果你想更深入地研究日志实践,我整理了《技术专家日志打印秘籍》《程序员 B 面生存手册》等资料。关注公众号「大厂码农老A」,回复关键字「日志」「B面」即可获取。愿每条线上告警都不再让你手足无措。