ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot稳定性实践:链路追踪、失败告警与兜底降级

Spring Boot稳定性实践:链路追踪、失败告警与兜底降级 刷到一个综艺名场面荡秋千环节有人被拉着当垫脚石追踪局里有人靠打电话过关弹幕一水的“还是郑恺承担了所有”。笑过之后我反而职业病犯了——这不就是很多系统的日常吗上游依赖出问题层层重试、层层甩锅最后承担所有异常的往往是那段日志、那段兜底逻辑、那条告警记录。所以今天不聊综艺聊一聊系统稳定性里的“垫脚石方案”链路追踪、失败告警、兜底降级。无论你是刚接触 Spring Boot 的初学者还是已经在维护线上微服务项目的开发者这套思路都能直接复制到自己的系统中。1. 背景与核心概念1.1 链路追踪给请求一张“身份证”先看一个最简单的场景。用户请求进入系统后会经历网关、业务服务、缓存、数据库甚至还会调用第三方接口。当这个请求最终失败时你看到的通常只是某个方法抛出的一个异常堆栈。问题是这个请求到底经过了哪些服务它在每一步花了多少时间完整的参数和返回结果是什么链路追踪要解决的就是这个问题。它的核心做法是给每一次外部请求生成一个全局唯一的 ID通常叫 traceId。这个 ID 会从请求入口一直往下游传递并且在各个服务的日志中输出。这样一来只要把 traceId 拿到日志平台一搜整个调用链路上的日志就都能串起来。很多同学会把链路追踪和 APM 监控混为一谈。简单说traceId 是自己能控制的基础设施你可以在 Filter、拦截器里生成它也可以在日志打印时带上它。而 SkyWalking、Zipkin 这类 APM 工具则是在 traceId 之上更进一步画出调用拓扑、显示每个节点的耗时和状态。对一个小型项目来说先实现 traceId 就足够解决 80% 的排障问题对大型微服务项目建议直接接入 APM而不是重复造轮子。1.2 失败告警让问题主动找上门很多团队排查线上问题的方式是用户投诉了反馈群炸了运维才去看监控。这种被动模式最大的问题在于从故障发生到人工介入中间可能已经过了十分钟甚至更久。如果这段时间内系统的错误率持续走高影响面会被瞬间放大。失败告警的思路是系统在检测到异常后主动把告警消息推到值班人员的手机或群里。常见渠道有钉钉机器人、企业微信机器人、飞书机器人、邮件、短信和电话。在综艺里“靠打电话过关”是个游戏技巧但在工程里告警电话代表的是最高优先级的故障通知路径一般留给核心服务不可用、支付链路失败这类重大事件。告警并不是越频繁越好。如果你把每次异常都发到同一个群里短时间内的几十条重复消息会把真正重要的信息淹没掉。所以工程上要做的不仅是“发出去”还要做告警聚合、限频、分级和恢复通知。换句话说告警是一门“刚刚好”的艺术不多发、不漏发、每条都能指导定位。1.3 兜底降级最后的垫脚石回到“垫脚石”这个词。在系统稳定性设计里兜底降级就是最后一块垫脚石。当下游服务不可用、数据查不到、第三方接口超时时系统不能直接把异常抛给用户而是先判断有没有备选方案读缓存里的旧数据、返回一个默认值、放宽某个非核心校验、或者直接走本地静态配置。兜底降级和熔断是经常一起出现但又不完全相同的概念。熔断指的是失败次数达到阈值后后续请求直接短路不再真正调用下游兜底则是在熔断或捕获异常后返回一个“不那么完美但还能用”的结果。你可以把熔断看成“开关”把兜底看成“备胎”。两者配合使用时能有效防止下游故障被无限放大。但兜底不是银弹。如果所有接口都不分业务场景地返回默认值可能掩盖真实故障甚至产生脏数据。所以实际项目中要区分“可兜底”和“不可兜底”的接口查询类接口可以兜底但涉及金额、库存、登录态的核心写操作更应该快速失败并告警。判断能不能兜底标准很简单用户拿到这个降级结果后会不会产生误解或损失。1.4 三者怎么配合链路追踪、失败告警、兜底降级单独看起来都挺简单但在真实项目里它们是互相咬合的。我们脑补一个完整流程用户请求进入系统链路追踪组件生成 traceId业务代码调用下游服务时发现连接超时异常被捕获系统先判断是否允许兜底如果允许则构造降级结果返回与此同时异步告警组件把 traceId、失败原因、业务主键一起推到值班群值班人点开告警拿着 traceId 去日志平台搜索几分钟内就能定位到是哪个服务、哪个方法出的问题。缺少任何一环稳定性建设都会打折。没有链路追踪你能定位到问题但很慢没有告警问题发现太晚没有兜底单个下游故障会直接变成用户可见的事故。三件事合在一起才是完整的“故障应对链路”。2. 环境准备与版本说明2.1 依赖与运行环境本文的示例是一个 Spring Boot 单机项目不需要额外安装数据库或 Redis直接跑起来就能观察效果。版本方面我不建议写死因为不同团队的基础设施差异很大。示例以 Spring Boot 2.7.x JDK 8/11 Maven 3.6 为基准核心代码在 Spring Boot 3.x 下也适用但有两处需要注意第一javax.servlet 在 3.x 中改成了 jakarta.servlet第二如果使用 Java 17Lombok 版本要升级到较新的版本否则 IDE 里会报注解处理相关的错误。你需要准备的软件如下一个 JDK 8 或 11、一个 Maven 3.6 以上版本、一个你顺手的 IDEIDEA、VSCode 都可以。如果你打算验证真实告警推送还需要准备一个 Webhook 接收地址比如钉钉或企业微信机器人本地临时验证也可以用 Python 或 Node 起一个简单的 HTTP 服务来收消息。不配置 Webhook 也可以运行代码里会跳过真实发送只看日志即可。2.2 项目结构为了让示例尽量贴近真实又不复杂我把项目拆成几个职责单一的文件。核心结构如下order-demo ├── pom.xml └── src/main ├── java/com/example/orderdemo │ ├── OrderDemoApplication.java # 启动类 │ ├── common/Result.java # 统一返回体 │ ├── config/TraceIdMdc.java # traceId 常量 │ ├── filter/TraceIdFilter.java # 链路追踪过滤器 │ ├── entity/Order.java # 订单实体 │ ├── service/OrderService.java # 主逻辑 兜底 │ ├── service/OrderRemoteClient.java # 模拟下游服务 │ ├── notice/AlertClient.java # 异步告警客户端 │ └── controller/OrderController.java # 接口入口 └── resources ├── application.yml └── logback-spring.xml每个文件的职责都很明确TraceIdFilter 负责生成和透传 traceIdOrderRemoteClient 模拟一个不稳定的下游服务OrderService 实现主流程、缓存兜底和异常处理AlertClient 负责异步发送告警。这样的拆分也符合实际项目中的分层习惯你完全可以把这套结构迁移到自己项目里。3. 核心设计拆解3.1 TraceId 如何生成与传递先来看一个最简单的例子用户请求到达 Spring Boot 应用后我们希望日志里出现一个 traceId。最常用的做法是写一个 Servlet Filter。为什么选择 Filter 而不是拦截器或 AOP因为 Filter 是 Servlet 容器层面的入口执行时机最早能够覆盖到 Controller、Service、第三方调用以及后续可能加入的全局异常处理器。只要在 Filter 里把 traceId 放入 SLF4J 的 MDC当前线程内所有日志框架的输出都会自动带上这个值。MDC 的全称是 Mapped Diagnostic Context它是日志框架提供的一个线程本地上下文。你可以在任意位置往 MDC 里放键值对日志 pattern 中通过%X{traceId}引用。需要注意的是MDC 是基于线程的如果不手动清理线程池中的线程被复用时会带着上一个请求的 traceId导致日志串号。所以规范写法是在 finally 块中调用MDC.remove()。3.2 兜底逻辑怎么设计才不算“坑”兜底逻辑看起来就是 catch 一个异常返回一个默认对象但这里面的坑非常多。第一兜底对象的字段值不能误导调用方。比如订单状态被降级为 UNKNOWN前端拿到之后应该明确展示“状态未知”而不是把它当成“已支付”或“未支付”。第二兜底结果不能直接污染数据库或下游缓存。如果下游服务只是暂时抖动而降级结果被长缓存保存了几个小时等下游恢复后用户看到的依然是旧数据问题反而更严重。第三兜底之后一定要有日志。很多团队只记录了异常堆栈却没有记录“本次返回的是降级结果”这一事实。结果就是排查问题时开发者看到数据不正常却不知道数据来自兜底逻辑。所以我的建议是在兜底对象里增加一个标记字段比如fromCachetrue同时在日志中输出 warn 级别记录并把 traceId 一起打出来。第四兜底也要有容量规划。如果所有请求全部落到兜底逻辑上本地内存缓存可能被大量写入甚至引发 GC 压力。简单的做法是控制缓存条数复杂的做法是使用 Caffeine 这类本地缓存库并设置最大容量和过期策略。3.3 告警发送为什么必须异步告警本质上是一次额外的 HTTP 调用但它和主业务请求的性质完全不同。主业务请求的耗时会影响用户体感而告警请求晚几秒到达并不会影响用户。因此最关键的一条设计原则是告警不能阻塞主流程也不能因为告警发送失败而导致主业务失败。实现异步并不难。最简单的方式是使用一个线程池比如Executors.newFixedThreadPool(2)在捕获异常后把告警任务丢给线程池执行。但这里有一个容易被忽略的坑线程池里的新线程不会自动继承主线程的 MDC所以你在异步任务里直接MDC.get(traceId)很可能是 null。正确的做法是在主线程中先把 traceId 取出来作为参数传给异步任务然后在任务内部输出。真实项目中还需要考虑告警的幂等和去重。同样的异常在短时间内反复出现时系统不应该发送一万条相同告警而应该做聚合同一 traceId 只发一条同一业务维度 5 分钟内最多发一条恢复后再发送恢复通知。4. 完整实战案例下面我以“用户查询订单详情”为例把链路追踪、兜底降级、异步告警完整串一遍。整个项目代码量不大但每一步都在模拟线上真实场景。4.1 创建项目骨架先创建 Maven 工程pom.xml 中只需要引入 spring-boot-starter-web 和 Lombok。为了演示方便我们不引入 Redis 或数据库缓存用本地 Map 实现避免读者缺少外部环境跑不起来。完整 pom 配置如下?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdorder-demo/artifactId version1.0.0/version properties java.version8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project这里写的是 2.7.18 版本只是一个演示版本。如果你本地有更熟悉的 Spring Boot 版本完全可以替换成对应版本核心配置和代码逻辑不会受影响。如果你使用 Spring Boot 3.x记得把 java.version 调到 17并把后面代码中所有的javax.servlet替换成jakarta.servlet。4.2 配置文件配置文件保持精简。告警地址和 token 通过环境变量注入避免硬编码也让你在本地运行时能灵活控制。server: port: 8080 spring: application: name: order-demo order: webhook: url: ${WEBHOOK_URL:} token: ${WEBHOOK_TOKEN:}${WEBHOOK_URL:}的写法表示如果环境变量 WEBHOOK_URL 存在就使用它的值否则使用空字符串。这样可以保证本地不配置任何告警地址时项目也能正常启动。由于我们要在日志中输出 traceId还需要配置 logback-spring.xml。重点是 pattern 中的%X{traceId}它负责从 MDC 读取 traceId 并打印。?xml version1.0 encodingUTF-8? configuration appender nameSTDOUT classch.qos.logback.core.ConsoleAppender encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [traceId%X{traceId}] %-5level %logger{36} - %msg%n/pattern /encoder /appender root levelINFO appender-ref refSTDOUT/ /root /configuration这样设置之后只要 MDC 中存在 traceId每行日志都会自动带出来。如果没有 traceId%X{traceId}会输出空字符串不影响日志格式。4.3 链路追踪过滤器先定义一个常量类便于在多处引用同一个 key。package com.example.orderdemo.config; public final class TraceIdMdc { public static final String TRACE_ID traceId; private TraceIdMdc() { } }然后编写核心过滤器。它的逻辑是先从请求头X-Trace-Id中获取 traceId如果外部没有传就生成一个 UUID 作为 traceId随后把 traceId 放入 MDC并在响应头里回写方便前端或调用方拿到后进行问题反馈最后在 finally 中清理 MDC避免线程复用导致 traceId 串号。package com.example.orderdemo.filter; import java.io.IOException; import java.util.UUID; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.slf4j.MDC; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import com.example.orderdemo.config.TraceIdMdc; Component public class TraceIdFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String traceId request.getHeader(X-Trace-Id); if (traceId null || traceId.isBlank()) { traceId UUID.randomUUID().toString().replace(-, ); } MDC.put(TraceIdMdc.TRACE_ID, traceId); response.setHeader(X-Trace-Id, traceId); try { filterChain.doFilter(request, response); } finally { MDC.remove(TraceIdMdc.TRACE_ID); } } }这里需要注意traceId.isBlank()是 Java 11 才有的方法。如果你使用 JDK 8建议改成traceId.trim().isEmpty()否则会编译报错。示例是为了演示思路具体写法请根据你的 JDK 版本调整。4.4 模拟远程客户端为了让演示效果可控我写了一个不稳定的远程客户端当订单 id 为偶数时直接抛出运行时异常模拟下游服务不可用当 id 为奇数时正常返回一个订单对象。这样只要切换不同的请求参数就能观察成功、失败、命中缓存三种情况。package com.example.orderdemo.entity; import java.math.BigDecimal; import lombok.Data; Data public class Order { private Long id; private Long userId; private BigDecimal amount; private String status; private Boolean fromCache; }package com.example.orderdemo.service; import java.math.BigDecimal; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import com.example.orderdemo.entity.Order; Component public class OrderRemoteClient { private static final Logger log LoggerFactory.getLogger(OrderRemoteClient.class); public Order getOrderById(Long id) { // 模拟下游不稳定id 为偶数时抛异常 if (id % 2 0) { throw new RuntimeException(下游订单服务暂时不可用: id); } Order order new Order(); order.setId(id); order.setUserId(1001L); order.setAmount(new BigDecimal(99.00)); order.setStatus(PAID); order.setFromCache(false); return order; } }真实项目中OrderRemoteClient 通常会通过 Feign、RestTemplate 或 WebClient 调用另一个服务。本文用本地方法模拟完全是为了让你在没有微服务环境的情况下也能跑通整条链路。4.5 核心服务主流程 兜底OrderService 是本例的核心。它的处理流程分成四步先查本地缓存命中则直接返回缓存未命中去调用远程客户端调用成功则返回真实数据调用失败则触发异步告警然后构造一个降级默认对象返回并写入本地缓存避免下游恢复前大量请求反复打向一个不稳定的服务。package com.example.orderdemo.service; import java.math.BigDecimal; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import com.example.orderdemo.entity.Order; import com.example.orderdemo.notice.AlertClient; import com.fasterxml.jackson.databind.ObjectMapper; Service public class OrderService { private static final Logger log LoggerFactory.getLogger(OrderService.class); private final MapString, CacheValue localCache new ConcurrentHashMap(); private static final long TTL_MILLS 30 * 60 * 1000L; private final OrderRemoteClient remoteClient; private final AlertClient alertClient; private final ObjectMapper objectMapper; public OrderService(OrderRemoteClient remoteClient, AlertClient alertClient, ObjectMapper objectMapper) { this.remoteClient remoteClient; this.alertClient alertClient; this.objectMapper objectMapper; } public Order getOrderById(Long id) { String cacheKey order: id; // 1. 查本地缓存 CacheValue cached localCache.get(cacheKey); if (cached ! null !cached.expired()) { try { Order order objectMapper.readValue(cached.json, Order.class); order.setFromCache(true); log.info(命中兜底缓存, orderId{}, id); return order; } catch (Exception e) { log.warn(缓存反序列化失败, 忽略缓存, e); } } // 2. 调用真实下游 try { Order order remoteClient.getOrderById(id); log.info(查询订单成功, orderId{}, 非缓存, id); return order; } catch (Exception e) { log.warn(查询订单异常, orderId{}, 开始兜底, id, e); // 3. 异步告警 alertClient.sendAsync(订单查询失败, id, e.getMessage()); // 4. 默认兜底 Order fallback new Order(); fallback.setId(id); fallback.setUserId(null); fallback.setAmount(BigDecimal.ZERO); fallback.setStatus(UNKNOWN); fallback.setFromCache(true); // 5. 写入本地缓存 try { String json objectMapper.writeValueAsString(fallback); localCache.put(cacheKey, new CacheValue(json, System.currentTimeMillis() TTL_MILLS)); } catch (Exception e2) { log.error(兜底对象序列化失败, e2); } return fallback; } } private static class CacheValue { final String json; final long expireAt; CacheValue(String json, long expireAt) { this.json json; this.expireAt expireAt; } boolean expired() { return System.currentTimeMillis() expireAt; } } }这个本地缓存实现非常粗糙但在演示场景下已经足够。它包含 json 字符串和过期时间两个字段避免直接缓存对象被后续修改。生产环境建议替换成 Caffeine 或 Redis并设置合理的最大容量和过期策略防止本地缓存无限增长。这里还有一个值得注意的细节兜底对象里我设置了fromCachetrue。这个字段不是业务字段而是给排查问题用的。读者在看到返回结果时能立即明白这份数据不是实时数据而是降级缓存数据。4.6 异步告警客户端AlertClient 负责发送告警。为了不让告警影响主流程我用线程池异步执行发送任务。在提交任务之前先把主线程里的 traceId 取出来避免异步线程里 MDC 为空。如果 webhook 地址没有配置则直接跳过发送只记录一条 warn 日志。package com.example.orderdemo.notice; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; import com.example.orderdemo.config.TraceIdMdc; Component public class AlertClient { private static final Logger log LoggerFactory.getLogger(AlertClient.class); private static final ExecutorService EXECUTOR Executors.newFixedThreadPool(2); private final RestTemplate restTemplate new RestTemplate(); private final String webhookUrl; private final String webhookToken; public AlertClient(Value(${order.webhook.url:}) String webhookUrl, Value(${order.webhook.token:}) String webhookToken) { this.webhookUrl webhookUrl; this.webhookToken webhookToken; } public void sendAsync(String title, Long orderId, String message) { if (!StringUtils.hasText(webhookUrl)) { log.warn(webhook url 未配置, 跳过告警发送); return; } final String traceId MDC.get(TraceIdMdc.TRACE_ID); EXECUTOR.submit(() - { try { MapString, Object payload new HashMap(); payload.put(title, title); payload.put(orderId, orderId); payload.put(message, message); payload.put(traceId, traceId); payload.put(timestamp, LocalDateTime.now().toString()); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(X-Webhook-Token, webhookToken); HttpEntityMapString, Object entity new HttpEntity(payload, headers); restTemplate.postForObject(webhookUrl, entity, String.class); log.info(告警发送成功, orderId{}, orderId); } catch (Exception e) { log.error(告警发送失败, orderId{}, orderId, e); } }); } }这段代码用Executors.newFixedThreadPool(2)创建线程池仅用于演示。生产环境不要直接用 Executors 创建线程池建议使用 ThreadPoolExecutor 并明确核心线程数、最大线程数、队列大小和拒绝策略避免线程资源被耗尽。4.7 Controller 与启动类Controller 很简单只暴露一个查询订单的接口。统一返回结果封装在 Result 类中代码也比较常规。package com.example.orderdemo.common; import lombok.Data; Data public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(int code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }package com.example.orderdemo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.example.orderdemo.common.Result; import com.example.orderdemo.entity.Order; import com.example.orderdemo.service.OrderService; RestController RequestMapping(/order) public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService orderService; } GetMapping(/{id}) public ResultOrder getOrder(PathVariable Long id) { Order order orderService.getOrderById(id); return Result.success(order); } }package com.example.orderdemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class OrderDemoApplication { public static void main(String[] args) { SpringApplication.run(OrderDemoApplication.class, args); } }到这里整个演示项目已经完整。如果你对 Spring Boot 的自动配置比较熟悉会发现我不能少写了任何一个注解。SpringBootApplication负责启动自动配置Component和Service让各个类被 Spring 容器接管Filter 也会被自动注册到 Servlet 过滤链中。4.8 运行与验证启动项目后打开终端执行以下命令curl http://localhost:8080/order/1id 为奇数OrderRemoteClient 正常返回响应结果中订单状态为 PAIDfromCachefalse。再看看控制台日志每行都会带一个 traceId 前缀。接着请求一个偶数 idcurl http://localhost:8080/order/2这一次 OrderRemoteClient 会抛出异常OrderService 进入兜底逻辑返回的订单状态为 UNKNOWNfromCachetrue。如果你配置了 WEBHOOK_URLAlertClient 会异步发送告警如果没有配置你会看到一条“webhook url 未配置, 跳过告警发送”的 warn 日志。连续第二次请求 id2curl http://localhost:8080/order/2此时本地缓存还没有过期OrderService 会直接命中缓存响应结果仍然是 UNKNOWN 状态但日志会打印“命中兜底缓存”。控制台的日志效果大致如下[order-demo] [traceIdxxx] 查询订单异常, orderId2, 开始兜底 [order-demo] [traceIdxxx] webhook url 未配置, 跳过告警发送 [order-demo] [traceIdxxx] 命中兜底缓存, orderId2可以看到整个异常链路中traceId 自始至终都在日志中连续出现。这就是链路追踪的价值。5. 常见问题与排查思路5.1 高频问题排查表下面的表格总结了这套方案落地时最常遇到的高频问题。如果你在实操中碰到类似现象可以先对照表格快速定位方向再结合日志深入排查。问题现象常见原因解决思路日志里 traceId 全部相同看不出每请求差别线程池复用导致 MDC 未清理在 finally 中调用 MDC.remove()异步任务要手动传递 traceId异步告警线程里 traceId 为 null异步线程不会继承主线程 MDC提交线程前先把 traceId 取出来作为参数传入任务多次请求偶数 id返回的都是 UNKNOWN 状态本地缓存 30 分钟内未过期缩短缓存 TTL或在服务恢复后主动清理缓存告警消息一次性冒出几百条没有做告警聚合和限频按 traceId 或业务维度做 5 分钟聚合支持静默期兜底默认值掩盖了真实故障兜底时没有输出 warn 日志也没有标记字段兜底对象增加 fromCache/fallback 标记日志打印 warnWebhook 地址不通收不到告警网络隔离、地址错误、签名校验失败先用 curl 测通地址再检查服务端日志和 token 白名单Spring Boot 3 启动报 Servlet 类不存在javax.servlet 包已迁移到 jakarta.servlet全局替换 import并升级到适配 Jakarta 的依赖版本5.2 一个典型的定位过程假设你在生产环境收到一条告警内容是“订单查询失败orderId20250101001请排查”。按照本文设计告警中会携带 traceId。你只需要去统一日志平台搜索这个 traceId就能看到从入口 Filter 到 Service 再到远程客户端的全部日志。正常情况下你会看到三层信息第一层是 TraceIdFilter 生成 traceId 的日志第二层是 OrderService 输出“查询订单异常开始兜底”的 warn 日志异常堆栈里会带着具体报错原因第三层是 AlertClient 输出的“告警发送成功”日志。如果这三层日志都在说明链路本身是健康的问题出在下游服务如果第二层日志缺失说明请求可能还没进入核心服务就在更早的网关或 Filter 被拦截了。这个定位过程看起来简单但没有 traceId 的时候排查同样一个问题可能需要打开十几个服务页面逐个对照时间戳和参数效率低得多。6. 最佳实践与工程建议6.1 链路追踪侧的建议在项目早期就统一 traceId 方案越晚改造成本越高。如果团队规模小自己实现 Filter 加 MDC 完全够用如果服务已经上了规模建议直接接入 SkyWalking通过 Java Agent 无侵入采集调用数据。还有一点容易被忽略前端或调用方也要参与 traceId 透传。网关收到请求时如果请求头里已有 traceId应当复用而不是重新生成如果需要在跨服务调用时传递要确保 Feign、RestTemplate 的拦截器里都设置了请求头。6.2 兜底降级侧的建议兜底方案要按业务接口分级。查询类接口可以缓存旧数据核心写接口不建议做静默兜底而是抛业务异常让用户知道操作失败。兜底对象的数据格式必须显式包含降级标记数据团队在后续做报表或数据分析时能清楚地知道哪些数据来自降级链路否则很容易把脏数据混入统计结果。另外兜底缓存不适合长时间保存建议根据业务容忍度设置 TTL比如商品列表 5 分钟订单详情 30 秒避免下游恢复后用户继续看到旧数据。6.3 告警通知侧的建议告警要设置分级。P0 级故障如核心服务不可用、支付链路异常需要电话或短信通知值班人P1 级故障如查询接口错误率超过阈值推送群消息即可P2 级问题如缓存命中率下降可以放到日报或周会中复盘。不要把所有异常都塞进同一个群否则告警群很快变成“告警瀑布”真正重要的消息反而没人看。实现时还要考虑恢复通知故障恢复后发送一条恢复消息值班人才能确认告警闭环。6.4 生产环境注意事项任何涉及线上配置的变更都要先在测试环境验证再走灰度发布流程。修改兜底开关、调整缓存 TTL、更换 Webhook 地址看起来都是小操作但影响面可能覆盖所有请求。需要清理缓存时先备份原值、评估影响范围再执行删除操作并准备回滚方案。敏感信息比如 Webhook token、数据库密码、第三方密钥严禁硬编码在代码里统一放到环境变量或配置中心并遵循最小权限原则只给有需要的人开放修改权限。7. 总结与下一步到这里我们已经把链路追踪、失败告警、兜底降级这条稳定性主线完整串起来了一次正常请求如何携带 traceId一次异常请求如何被捕获、如何触发兜底、如何把告警发出去收到告警之后又如何靠 traceId 定位问题。示例代码不复杂但背后每一个细节比如 MDC 清理、异步线程传递、缓存 TTL、告警限频都是生产环境真正会踩的坑。下一步可以继续学习 SkyWalking、Zipkin 的接入与调用链拓扑分析也可以了解 Resilience4j 或 Sentinel 的熔断、限流、舱壁隔离还可以结合 Prometheus Alertmanager 搭建一套完整的告警平台。在实际项目中我建议优先把“日志带 traceId”和“关键接口兜底”这两件事落实再逐步加上告警和 APM。稳定性建设没有终点一次故障一次复盘方案才会越来越完善。如果你在落地过程中遇到其他奇怪问题欢迎在评论区带上现象和日志一起讨论。
返回列表