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

- Name
- Terry
引子:海滩上的磁盘告警
国庆假期,我躺在海边的躺椅上。手机突然连续震动,公司的运维群里刷出几条「磁盘空间不足」的告警。问题的源头并不复杂:某个服务的日志没控住体积,几天里把节点撑满了。同事临时删除旧文件,服务恢复,群里回复「忘了挂自动清理脚本」。事情到此为止,但我脑子里留下了两个问题:日志到底记录了些什么?为什么这点基础设施还是靠人工兜底?
日志看似不起眼,却承载系统运行时的全部记忆。把它当垃圾桶,磁盘会被撑爆;把它当调试信息,真正出事故时你又会发现线索全丢了。要把这件“小事”做好,需要系统思维:先保证数据结构正确,再谈实现细节。
日志的系统三问
- 这是个真问题还是臆想出来的? 日志不是为了满足「写一点心安」的幻觉,而是要回答线上真实存在的问题:谁、在何时、在哪个链路节点出错。
- 有更简单的方法吗? 数据结构先行,少依赖条件判断。把关键信息打包进统一上下文,就不用在每个函数里反复拼字段。
- 会破坏什么吗? 「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 和在出错时只记录字符串。前端应该像服务端一样严肃。
- 统一入口:封装日志函数,只暴露
info、warn、error。 - 自动挂载上下文:启动时生成
session_id,在每条日志里自动附带。 - 及时上报:用户刷新页面之前,把结构化事件推送到收集端点。
- 控制开销:高频行为做采样,避免重型对象的
JSON.stringify。 - 只透传、不生成 trace:从后端响应头里拿到
traceparent或request-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_id、span_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 负责解析/注入 traceparent,Slf4jSpanExporter 自动把 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,并记录topic、partition、offset。 - 对象存储:下载失败、签名过期等错误同样要记录,避免重传时无据可查。
当所有层都有统一字段,ELK 或 ClickHouse 中的查询才能一次性把链路拉通。
消除特殊情况
把日志写清楚的秘诀其实是减少条件分支。一旦引入「如果是支付就多打几条」「如果是海外用户就换格式」这样的特例,很快就会陷入无法维护的状态。正确的做法是:
- 事件名表达实体与动作,例如
order.create、order.create.failed。 - 结构体字段保持统一,可选信息用
null表示缺失。 - 采样策略集中配置,而不是在代码里套
if (random() > 0.1).
这样,新增业务只需要增加事件,不需要修改既有的查询脚本。老的告警也不会被破坏。
打造成熟日志系统的四个阶段
- 统一格式:所有服务输出 JSON,字段名对齐。
- 统一上下文:
trace_id、span_id、user_id等基础字段全链路透传。 - 统一存储与索引:集中化采集(SLS、ELK、ClickHouse),支持按字段检索。
- 统一响应:基于日志指标设置阈值报警,严重场景触发自动化脚本(降级、熔断、回滚)。
做到这四步,日志才是系统的「黑匣子」,而不是调试时的便利贴。
实用检查表
在真正发布前,把下面几件事逐条确认:
- 新功能是否定义了事件?字段是否与旧功能保持一致?
- 前端、Next.js、Spring Boot、数据库是否都能在日志里看到同一个
trace_id? - 日志量是否受采样与保留策略保护,不会再次撑爆磁盘?
- 告警能否在异常发生后的几分钟内触发?负责人知道去哪儿查链路?
结语
日志从来不是锦上添花,而是确保系统可观测的底盘。磁盘满了只是症状,真正的问题是我们是否把日志当成系统的一部分来设计。希望这份笔记能帮你把零散的经验串成方法论,在前端、全栈和 Java 服务之间搭建起同一条透明的调用链。
如果你想更深入地研究日志实践,我整理了《技术专家日志打印秘籍》《程序员 B 面生存手册》等资料。关注公众号「大厂码农老A」,回复关键字「日志」「B面」即可获取。愿每条线上告警都不再让你手足无措。