- 发布于
日志的系统观:从 Next.js 到 Spring Boot 的全栈实践
- 作者

- 姓名
- Terry
引子:从前端监控到后端链路
在上一篇文章中,我们讨论了前端三大监控闭环:错误监控、行为监控、性能监控。但当 LCP(最大内容绘制)延迟达到 4 秒时,我们发现问题往往不在前端——可能是数据库慢查询、消息队列堆积,或者下游服务超时。
这时候,日志系统就成了串联全栈可观测性的关键。但日志不是简单的 console.log,而是需要:
- 结构化:机器可读,支持聚合分析
- 全链路:通过
trace_id串联前端到数据库 - 可采样:控制成本,保留关键信息
- 可告警:基于日志指标触发自动化响应
国庆假期,我躺在海边的躺椅上。手机突然连续震动,公司的运维群里刷出几条「磁盘空间不足」的告警。问题的源头并不复杂:某个服务的日志没控住体积,几天里把节点撑满了。同事临时删除旧文件,服务恢复,群里回复「忘了挂自动清理脚本」。事情到此为止,但我脑子里留下了两个问题:日志到底记录了些什么?为什么这点基础设施还是靠人工兜底?
日志看似不起眼,却承载系统运行时的全部记忆。把它当垃圾桶,磁盘会被撑爆;把它当调试信息,真正出事故时你又会发现线索全丢了。要把这件“小事”做好,需要系统思维:先保证数据结构正确,再谈实现细节。
日志在可观测性体系中的位置
可观测性三大支柱(Metrics、Logs、Traces)的关系:
Metrics(指标)← 告警触发(如:错误率 > 5%)
↓
Logs(日志)← 根因分析(如:具体哪个 SQL 慢)
↓
Traces(追踪)← 链路定位(如:请求经过了哪些服务)
日志的独特价值:
- Metrics 太粗:告诉你"错了 100 次",但不知道"为什么错"
- Traces 太窄:告诉你"哪里慢",但不知道"慢的具体原因"
- Logs 太细:需要结构化和采样,否则信息过载
日志的系统三问
- 这是个真问题还是臆想出来的? 日志不是为了满足「写一点心安」的幻觉,而是要回答线上真实存在的问题:谁、在何时、在哪个链路节点出错。
- 有更简单的方法吗? 数据结构先行,少依赖条件判断。把关键信息打包进统一上下文,就不用在每个函数里反复拼字段。
- 会破坏什么吗? 「Never break userspace」在日志体系里意味着任何改动都不能让既有查询失效,不能让报警链条断掉,不能吞掉用户正在依赖的指标。
核心数据结构:事件、上下文、状态
好日志的第一性原理是:所有信息都是事件。事件包含三个最小字段:
| 字段 | 说明 | 示例值 |
|---|---|---|
event | 事件名,直接描述发生了什么 | order.create.failed |
context | 上下文,至少包含 trace_id 和主体 ID | trace_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 和在出错时只记录字符串。前端应该像服务端一样严肃,并与性能监控联动。
设计原则
- 统一入口:封装日志函数,只暴露
info、warn、error - 自动挂载上下文:启动时生成
session_id,在每条日志里自动附带 - 及时上报:用户刷新页面之前,把结构化事件推送到收集端点
- 控制开销:高频行为做采样,避免重型对象的
JSON.stringify - 与性能数据关联:日志自动携带当前页面的性能指标
实现示例
// 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_id、span_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 负责解析/注入 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面」即可获取。愿每条线上告警都不再让你手足无措。