Published on

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

Authors
  • avatar
    Name
    Terry
    Twitter

引子:海滩上的磁盘告警

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

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

日志的系统三问

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

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

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

字段说明
event事件名,直接描述发生了什么
context上下文,至少包含 trace_id 和主体 ID
payload结构化数据,描述状态或参数

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

{
  "event": "order.create.failed",
  "trace_id": "04b5...",
  "user_id": 102938,
  "order_id": "SO20241017001",
  "error": {
    "type": "NullPointerException",
    "message": "customer is null"
  }
}

前端(Next.js 客户端):让每条日志能追溯到用户

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

  • 统一入口:封装日志函数,只暴露 infowarnerror
  • 自动挂载上下文:启动时生成 session_id,在每条日志里自动附带。
  • 及时上报:用户刷新页面之前,把结构化事件推送到收集端点。
  • 控制开销:高频行为做采样,避免重型对象的 JSON.stringify
  • 只透传、不生成 trace:从后端响应头里拿到 traceparentrequest-id,在后续调用中原样带回,绝不尝试在浏览器侧自建 ID。
// lib/client-logger.ts
type LogEvent = {
  level: 'info' | 'warn' | 'error';
  event: string;
  payload?: Record<string, unknown>;
  error?: Error;
};

const sessionId = crypto.randomUUID();

const emit = async (entry: LogEvent) => {
  const body = {
    event: entry.event,
    level: entry.level,
    session_id: sessionId,
    payload: entry.payload ?? {},
    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 }),
};

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 中完成创建与透传。

// 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 等信息绑定到当前异步调用栈,后续日志自动携带。这样写,控制器层的 try/catch 不需要额外判断,也不会在重试时漏掉上下文。

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面」即可获取。愿每条线上告警都不再让你手足无措。