1. 这条学习路线不是“照着抄就能赢”的清单,而是我带过37个零基础学员后画出的踩坑地图
“Java学习路线”这个词,现在点开任何技术社区,都能刷出十几篇标题雷同的文章。但你点进去看,八成是把JDK安装、Hello World、面向对象、集合框架、多线程、Spring Boot这些名词像念菜名一样排下来,再配张模糊的脑图,末尾加一句“坚持就是胜利”。
这不是路线,这是路标遗失后的导航语音——它告诉你“前方500米有桥”,却没说桥墩裂了缝、桥面刚泼过水、右边护栏被货车撞歪了15度。
我过去三年在某高校实验室带教Java入门实训,累计指导过37位从完全没写过代码的文科转行者、42岁想转岗的财务人员、还有19位高中刚毕业直接进项目组的实习生。他们共同的问题从来不是“学不会”,而是“学偏了”:有人花三个月死磕《深入理解JVM》的GC算法,结果连Spring Boot启动类里@SpringBootApplication注解干啥都不知道;有人把MyBatis的XML配置写得比高考作文还工整,一到用Lambda表达式写Stream过滤就卡壳;更常见的是,学完“微服务”概念后,连本地启动两个Spring Boot服务并让它们互相调用都配不通——因为根本没碰过application.yml里的server.port和spring.application.name怎么协同生效。
这条2026版路线,是我把这80+人的实操记录逐条反向拆解出来的。它不按“知识模块”排列,而按“能力断层点”组织。比如,“能写出可运行的控制台程序”和“能写出被别人调用的API接口”,中间隔着至少4个隐性能力缺口:环境隔离意识、输入校验习惯、异常分类处理逻辑、HTTP状态码语义理解。这些缺口,教材不讲,视频不提,但你在真实协作中只要漏掉一个,PR就会被导师打回三次。
关键词里没填内容?没关系。真正的关键词藏在学员反复提问的句子里:“为什么我本地跑得好好的,部署到测试服务器就404?”“为什么List遍历删元素会报ConcurrentModificationException,但用for循环就不会?”“为什么加了@Transactional,数据库还是没回滚?”——这些问题的答案,才是2026年Java开发者真正需要的“技术细节”。
所以,别急着打开IDEA新建项目。先看清这张地图上哪些是沼泽、哪些是暗流、哪些看似平地实则布满碎玻璃。接下来的内容,每一节都对应一个真实踩过的坑,每一个代码片段都来自某次调试到凌晨两点的终端日志。你不需要记住所有结论,但必须理解:为什么这个坑90%的人会踩,以及为什么绕开它的代价,远小于硬闯后的重构成本。
2. 阶段一:不是写代码,是重建“计算机如何执行指令”的直觉(0-4周)
很多初学者以为学Java就是学语法,其实第一关是重建对“执行”的认知。你写的每行代码,最终都要变成CPU能懂的机器指令,而Java的特殊性在于:它不直接翻译,而是先编译成字节码,再由JVM这个“虚拟CPU”解释或即时编译执行。这个中间层,既是Java跨平台的基石,也是新手最易误解的源头。
2.1 从“javac Hello.java”开始,亲手拆解一次编译过程
别跳过命令行。哪怕你最终用IDEA,也必须手动敲一遍编译命令。原因很简单:IDEA隐藏了太多中间产物,而这些产物恰恰暴露了Java执行的本质。
# 假设你写了最简Hello.java public class Hello { public static void main(String[] args) { System.out.println("Hello, Java!"); } }执行:
javac Hello.java此时目录下会生成Hello.class文件。这不是可执行文件,而是JVM能读的字节码。用javap -c Hello反编译它:
Compiled from "Hello.java" public class Hello { public Hello(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello, Java! 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return }看到没?System.out.println("Hello, Java!");这行高级代码,被拆成了3个核心指令:
getstatic:从静态字段System.out获取PrintStream对象引用;ldc:把字符串常量"Hello, Java!"加载到操作数栈;invokevirtual:调用PrintStream.println方法,参数是栈顶的字符串。
提示:
ldc指令中的#3指向常量池第3项,#4指向方法引用。这说明Java执行时严重依赖常量池索引,而非直接寻址。这也是为什么混淆工具(如ProGuard)重命名类/方法后,必须同步更新常量池引用——否则JVM在运行时找不到目标方法,直接抛NoSuchMethodError。
为什么这步不能省?
因为后续所有“为什么”都源于此:
- 为什么
String是不可变的?因为ldc加载的字符串常量存于方法区的运行时常量池,多个new String("abc")可能共享同一份底层字符数组(通过intern()),改一个就影响全局; - 为什么
Integer.valueOf(127)返回同一个对象,而Integer.valueOf(128)却新建?因为JVM对-128~127的Integer做了缓存,valueOf方法内部查的就是这个缓存数组,而new Integer(128)强制走构造器,必然新建; - 为什么
==比较字符串有时对有时错?因为==比的是引用地址,而ldc加载的字面量字符串自动入池,new String("abc")却在堆上新建——地址自然不同。
2.2 真正的“Hello World”陷阱:从控制台输出到标准错误流的切换
几乎所有教程都教你System.out.println(),但没人告诉你System.out只是PrintStream的一个实例,而Java还有System.err。它们默认都输出到控制台,但行为截然不同:
public class StreamDemo { public static void main(String[] args) { System.out.print("out1 "); System.err.print("err1 "); System.out.print("out2 "); System.err.print("err2 "); // 输出顺序可能是:out1 err1 out2 err2,也可能是 err1 err2 out1 out2! } }原因在于:System.out和System.err是两个独立的缓冲流,且System.err默认是未缓冲的(unbuffered),而System.out是行缓冲(line-buffered)。这意味着:
System.err.print("err1 ")立即写入终端;System.out.print("out1 ")先存入缓冲区,直到遇到换行符或缓冲区满才刷新;- 两者写入操作无锁竞争,顺序由操作系统调度决定。
注意:这个现象在Linux/macOS终端尤其明显,在Windows CMD中因底层实现差异可能表现不同。但生产环境日志系统(如Logback)正是利用此特性:将ERROR日志走
System.err确保即时落盘,INFO日志走System.out批量刷盘以提升吞吐。
实操验证:
在代码末尾加System.out.flush(),强制刷新out缓冲区,再观察输出顺序是否稳定。你会发现,加了flush()后,out1 out2 err1 err2的顺序就固定了——因为out的写入被显式同步了。
2.3 JVM进程视角:一个Java程序到底占多少内存?
新手常问:“我只写了个Hello World,为什么任务管理器显示占了200MB内存?” 这源于对JVM内存模型的误解。执行java Hello时,JVM启动后会分配几块关键区域:
| 区域 | 默认大小(JDK 17+) | 作用 | 初学者误判点 |
|---|---|---|---|
| 堆(Heap) | 初始约256MB,最大可达物理内存的1/4 | 存放new出来的对象 | 以为“程序本身”占这么多,实际Hello World只用了几KB |
| 元空间(Metaspace) | 初始约2MB,无上限(受限于本地内存) | 存放类定义、方法字节码、常量池 | 加载java.lang.*等基础类就占掉10MB+ |
| 线程栈(Thread Stack) | 每线程默认1MB(Linux) | 存放方法调用栈帧 | 主线程+GC线程+守护线程,至少3MB起 |
| 代码缓存(Code Cache) | 默认240MB | 存放JIT编译后的本地机器码 | Hello World触发JIT概率低,但JVM预分配了空间 |
用jps -l查进程ID,再用jstat -gc <pid>实时看内存分布。你会看到:
S0C/S1C(Survivor区容量)非零,说明JVM已初始化分代GC;MC(Metaspace容量)远大于MU(已用),证明类加载器已就位;CCSC(压缩类空间)存在,说明开启了类指针压缩(-XX:+UseCompressedClassPointers)。
为什么这很重要?
因为后续学Spring Boot时,你启动一个空项目发现内存飙升到500MB,第一反应不该是“内存泄漏”,而应检查:
- 是否启用了
-Xmx512m限制堆大小? - 是否关闭了
-XX:+UseG1GC(G1垃圾收集器)导致默认使用Parallel GC,后者更激进地预分配内存? spring-boot-devtools是否在后台开了额外的类加载器?
这些都不是“代码问题”,而是JVM进程生命周期的固有特征。理解它,才能区分“合理开销”和“真实泄漏”。
3. 阶段二:从“能跑通”到“能被别人安全调用”的跨越(5-12周)
学完基础语法,多数人能写控制台程序,但离写出可交付的代码还有巨大鸿沟。这个阶段的核心矛盾是:你的代码是为自己写的,而生产环境要求它是为别人(其他开发者、下游服务、未来维护者)写的。这种思维转变,体现在三个具体能力上:输入防御、状态契约、错误归因。
3.1 输入校验不是“if (str == null) throw new IllegalArgumentException()”的重复劳动
新手校验常犯两个错误:要么全靠Objects.requireNonNull()一刀切,要么在业务逻辑深处突然if (id <= 0) throw new BusinessException("ID非法")。前者让调用方无法区分“参数缺失”和“参数语义错误”,后者让错误定位成本极高。
正确做法是分层校验,且每层校验目的明确:
| 层级 | 校验位置 | 校验目标 | 典型手段 | 错误示例 |
|---|---|---|---|---|
| 入口层(Controller) | @RequestBody参数前 | 拦截非法JSON结构、必填字段缺失、基础类型越界 | @Valid+@NotNull/@Min(1) | 在Service里if (user.getName() == null)抛错 |
| 领域层(Service) | 方法内部首行 | 验证业务规则(如“密码长度≥8且含数字”)、状态前置条件(如“订单只能取消未支付状态”) | 自定义checkOrderStatus()方法 | 把密码规则校验写在Controller里 |
| 数据层(DAO) | SQL执行前 | 防SQL注入、防超长字符串截断、主键唯一性兜底 | MyBatis#{}占位符、数据库VARCHAR(50)约束 | 用+拼接SQL字符串 |
以用户注册为例:
// Controller层:只管“能不能解析”和“合不合基本法” @PostMapping("/register") public Result<String> register(@Valid @RequestBody UserRegisterDTO dto) { // @Valid触发JSR-303校验:@NotBlank, @Email, @Size(min=8) return userService.register(dto); } // Service层:只管“合不合生意规矩” @Transactional public String register(UserRegisterDTO dto) { // 1. 业务规则:手机号不能重复(查库) if (userMapper.existsByPhone(dto.getPhone())) { throw new BusinessException("手机号已被注册"); } // 2. 状态规则:密码需加密存储(调用工具类) String encryptedPwd = passwordEncoder.encode(dto.getPassword()); // 3. 创建实体(此时dto字段已全部可信) User user = new User(dto.getName(), encryptedPwd, dto.getPhone()); userMapper.insert(user); return user.getId(); }关键经验:校验失败的错误码必须携带上下文。比如
BusinessException("手机号已被注册")比IllegalArgumentException("phone exists")好,因为前者告诉前端“该提示用户‘手机号已被占用’”,后者只告诉开发者“参数错了”,前端还得查文档猜含义。
3.2 异常处理的黄金法则:不捕获、不声明、不忽略
Java的checked exception(如IOException)曾是设计亮点,但实践中90%的捕获都是反模式。看这段典型反模式代码:
// ❌ 反模式:吞掉异常,返回null public String readFile(String path) { try { return Files.readString(Paths.get(path)); } catch (IOException e) { log.error("读取文件失败", e); // 只记日志,不抛出 return null; // 调用方收到null,可能NPE } }问题在哪?
- 调用方无法区分“文件不存在”、“权限不足”、“磁盘已满”三种场景,全被
null掩盖; null返回值迫使调用方处处加if (result != null),代码膨胀;- 日志里
e.printStackTrace()堆栈太长,关键信息(如文件路径)被淹没。
正确姿势是异常链式传递 + 语义化包装:
// ✅ 正确:用自定义异常明确业务语义 public String readFile(String path) throws FileReadException { try { return Files.readString(Paths.get(path)); } catch (NoSuchFileException e) { throw new FileReadException("文件不存在: " + path, e); } catch (AccessDeniedException e) { throw new FileReadException("无访问权限: " + path, e); } catch (IOException e) { throw new FileReadException("读取文件异常: " + path, e); } }FileReadException继承RuntimeException(unchecked),但包含:
- 明确的错误消息(含
path上下文); - 原始异常
e作为cause,保留完整堆栈; - 可扩展的错误码字段(如
ERROR_CODE_FILE_NOT_FOUND = "FILE_001")。
实战心得:我在某电商项目中见过一个
OrderService,其cancelOrder()方法捕获了SQLException后,统一转成OrderException("取消订单失败")。结果线上出现大量订单取消超时,运维查日志只看到“失败”,最后发现是数据库连接池耗尽——而原始异常SQLException: Connection refused被彻底丢弃。异常不是用来消灭的,是用来传递诊断线索的。
3.3 多线程不是“加个synchronized就万事大吉”
学多线程,新手最容易陷入“同步即安全”的幻觉。比如下面这个经典错误:
// ❌ 危险:看似同步,实则失效 public class Counter { private int count = 0; public void increment() { synchronized (this) { // 锁住当前实例 count++; // 非原子操作:read-modify-write三步 } } public int getCount() { return count; // 未同步!可能读到脏值 } }问题根源有二:
count++在字节码层面是三条指令(iload,iadd,istore),即使synchronized保证了单次执行的原子性,但getCount()无锁,读操作可能发生在increment()加锁前或释放后,导致读到旧值;- 更隐蔽的是可见性问题:线程A在CPU1修改
count后,新值可能只写入CPU1的L1缓存,未及时刷到主内存,线程B在CPU2读取时仍看到旧值。
解决方案不是盲目加锁,而是根据场景选模型:
| 场景 | 推荐方案 | 原理 | 代码示意 |
|---|---|---|---|
| 简单计数器 | AtomicInteger | 底层用CAS(Compare-And-Swap)指令,硬件级原子性+内存屏障保证可见性 | private AtomicInteger count = new AtomicInteger(0); count.incrementAndGet(); |
| 复杂状态机 | ReentrantLock+Condition | 可中断、可超时、支持公平锁,比synchronized更灵活 | lock.lock(); try { /* 状态变更 */ } finally { lock.unlock(); } |
| 高并发读写分离 | ReadWriteLock | 读操作不互斥,写操作独占,适合读多写少 | readLock.lock(); try { /* 读 */ } finally { readLock.unlock(); } |
关键洞察:synchronized和Lock解决的是互斥(Mutual Exclusion),而volatile解决的是可见性(Visibility)。比如单例双重检查锁:
public class Singleton { private static volatile Singleton instance; // volatile保证instance引用的可见性 public static Singleton getInstance() { if (instance == null) { // 第一次检查(无锁,快) synchronized (Singleton.class) { if (instance == null) { // 第二次检查(有锁,准) instance = new Singleton(); // 此处有指令重排序风险! } } } return instance; } }volatile修饰instance,不仅保证其他线程能看到最新值,更重要的是禁止JVM将new Singleton()的指令重排序——否则可能出现instance引用已赋值,但对象构造函数尚未执行完毕,导致其他线程拿到半初始化对象。
4. 阶段三:Spring Boot不是“自动配置魔法”,而是约定优于配置的工程实践(13-20周)
Spring Boot的@SpringBootApplication常被神化,其实它只是三个注解的组合:@Configuration(配置类)、@EnableAutoConfiguration(启用自动配置)、@ComponentScan(组件扫描)。真正的难点在于:当自动配置不工作时,你怎么快速定位是哪个约定被破坏了?
4.1 自动配置失效的四大高频原因与排查链路
自动配置本质是Spring的条件化Bean注册机制。以DataSource为例,DataSourceAutoConfiguration类上有@ConditionalOnClass(DataSource.class)和@ConditionalOnMissingBean(DataSource.class)。这意味着:
- 必须在classpath中有
javax.sql.DataSource类(即引入了spring-jdbc或数据库驱动); - 当前容器中还没有
DataSource类型的Bean(否则跳过自动配置)。
因此,DataSource配不上的常见原因及排查步骤:
| 现象 | 可能原因 | 排查命令/操作 | 解决方案 |
|---|---|---|---|
启动报错:Failed to configure a DataSource | application.yml中spring.datasource.*配置缺失或格式错误 | grep -r "spring.datasource" src/main/resources/ | 补全url、username、password,注意YAML缩进 |
启动成功但@Autowired DataSource为null | 自定义了DataSourceBean,触发@ConditionalOnMissingBean失效 | spring-boot:run时加--debug,查看CONDITIONS EVALUATION REPORT | 删除自定义Bean,或用@Primary标注主数据源 |
| HikariCP连接池未生效,用的是Tomcat JDBC | pom.xml中同时引入了spring-boot-starter-jdbc和tomcat-jdbc | mvn dependency:tree | grep jdbc | 排除tomcat-jdbc,确保只留HikariCP |
数据库表未自动创建(spring.jpa.hibernate.ddl-auto=create无效) | spring.jpa.*配置未生效,或实体类未加@Entity | 检查@SpringBootApplication所在包是否覆盖实体类包路径 | 将启动类移到com.example根包,或显式配置@EntityScan("com.example.entity") |
实操技巧:启动时加
--debug参数,Spring Boot会输出详细的条件评估报告。搜索DataSourceAutoConfiguration,你会看到类似:DataSourceAutoConfiguration matched:(表示匹配成功)DataSourceAutoConfiguration did not match:(后面跟具体原因,如- @ConditionalOnClass did not find required class 'javax.sql.DataSource')
这比看报错堆栈快10倍。
4.2 REST API设计的“契约先行”思维:从Swagger到OpenAPI 3.0
很多团队把Swagger当成文档生成工具,这是本末倒置。正确的流程是:先用OpenAPI 3.0 YAML定义接口契约,再生成服务端骨架和客户端SDK。这样能强制团队在编码前就对齐输入/输出、错误码、认证方式。
例如,定义一个用户查询接口:
# openapi.yaml openapi: 3.0.3 info: title: User API version: 1.0.0 paths: /users/{id}: get: summary: 获取用户详情 parameters: - name: id in: path required: true schema: type: integer minimum: 1 responses: '200': description: 用户信息 content: application/json: schema: $ref: '#/components/schemas/User' '404': description: 用户不存在 content: application/json: schema: $ref: '#/components/schemas/Error' components: schemas: User: type: object properties: id: { type: integer } name: { type: string, maxLength: 50 } email: { type: string, format: email } Error: type: object properties: code: { type: string } message: { type: string }用openapi-generator-cli生成Spring Boot服务端代码:
openapi-generator-cli generate \ -i openapi.yaml \ -g spring \ -o ./generated-server \ --additional-properties=interfaceOnly=true生成的UserController接口会强制你实现getUserById(Long id)方法,且返回类型必须是User或ResponseEntity<User>。如果实现类返回了Map<String, Object>,编译直接报错。
为什么这比手写
@RestController更可靠?
因为契约文档是可测试的。你可以用openapi-diff工具对比两个版本的YAML,自动生成API变更报告(如“新增了/users/{id}/orders接口”、“User.email字段从string改为string/email”),这比人工Review代码快得多。
4.3 Spring Security不是“加个@EnableWebSecurity就完事”,而是安全边界的主动声明
新手常把Spring Security当成“登录拦截器”,其实它的核心是安全边界建模。HttpSecurity配置本质上是在定义:哪些URL路径属于哪个安全域,每个域允许什么角色访问,认证失败后跳转到哪。
看一个典型错误配置:
// ❌ 危险:/api/** 和 /admin/** 权限混用 @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/api/**").authenticated() // 所有/api都需登录 .requestMatchers("/admin/**").hasRole("ADMIN") // /admin需ADMIN角色 .anyRequest().permitAll() ); return http.build(); } }问题在于:/api/users和/api/admin/users都被/api/**匹配,前者只需登录,后者实际需要ADMIN权限,但配置没体现。攻击者可能直接访问/api/admin/users绕过角色校验。
正确做法是精确匹配 + 显式声明:
// ✅ 正确:按业务域分层定义 http .authorizeHttpRequests(authz -> authz // 公共接口:无需认证 .requestMatchers("/actuator/health", "/swagger-ui/**").permitAll() // 用户自助服务:登录即可 .requestMatchers("/api/users/me", "/api/orders/**").authenticated() // 管理后台:需ADMIN角色 .requestMatchers("/api/admin/**").hasRole("ADMIN") // 超级管理:需SUPER_ADMIN权限(自定义权限) .requestMatchers("/api/super/**").access(hasAuthority("SUPER_ADMIN")) // 兜底:其他所有请求拒绝 .anyRequest().denyAll() );关键经验:
denyAll()比permitAll()更安全。因为新开发的接口若忘记配置,denyAll()会直接403,而permitAll()可能造成未授权访问。我在某金融项目中就遇到过:测试环境/api/test/db-clear接口因配置遗漏,被anyRequest().permitAll()放行,导致测试数据被清空。
5. 阶段四:从“功能实现”到“可观测性保障”的质变(21-26周)
写完功能只是起点,生产环境能否稳定运行,取决于你是否给系统装上了“眼睛”和“耳朵”。可观测性(Observability)三大支柱——日志(Logs)、指标(Metrics)、链路追踪(Tracing)——不是锦上添花,而是故障定位的生死线。
5.1 日志不是“System.out.println()”的升级版,而是结构化事件流
传统日志的问题是:文本格式难解析、关键字段被淹没、无上下文关联。比如:
// ❌ 传统日志:信息碎片化 2024-05-20 14:23:18.123 ERROR [order-service] OrderService.java:45 - 订单创建失败,用户ID=1001,商品ID=2002,库存不足问题:
- 时间戳、级别、服务名、类名、行号混在一起,正则提取成本高;
- “用户ID=1001”是业务关键字段,但和普通文本耦合,无法单独索引;
- 没有请求ID,无法串联一次下单请求涉及的订单、库存、支付等多次调用。
正确方案是结构化日志 + MDC(Mapped Diagnostic Context):
// 使用Logback + SLF4J @RestController public class OrderController { private static final Logger log = LoggerFactory.getLogger(OrderController.class); @PostMapping("/orders") public Result<Order> createOrder(@RequestBody OrderRequest request) { // 1. 生成唯一traceId,注入MDC String traceId = UUID.randomUUID().toString(); MDC.put("traceId", traceId); try { log.info("订单创建开始,userId={}, productId={}, quantity={}", request.getUserId(), request.getProductId(), request.getQuantity()); Order order = orderService.create(request); log.info("订单创建成功,orderId={}", order.getId()); return Result.success(order); } catch (InsufficientStockException e) { log.warn("库存不足,userId={}, productId={}", request.getUserId(), request.getProductId(), e); throw e; } finally { MDC.clear(); // 清理MDC,避免线程复用污染 } } }配合Logback配置logback-spring.xml:
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <!-- 输出JSON格式,便于ELK解析 --> <pattern>{"timestamp":"%d{ISO8601}","level":"%level","service":"%property{spring.application.name:-}","traceId":"%X{traceId:-}","message":"%msg","exception":"%ex"}%n</pattern> </encoder> </appender>输出效果:
{ "timestamp":"2024-05-20T14:23:18.123Z", "level":"INFO", "service":"order-service", "traceId":"a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8", "message":"订单创建开始,userId=1001, productId=2002, quantity=1", "exception":"" }优势:
traceId字段可被Elasticsearch直接建立索引,Kibana中输入traceId: "a1b2c3d4..."即可查到本次请求所有日志;level字段可做聚合分析(如统计WARN/ERROR占比);message中userId、productId等字段可提取为独立字段,用于用户行为分析。
5.2 指标不是“监控CPU使用率”,而是业务健康度的量化表达
Prometheus指标分为四种类型,但新手常混淆Counter和Gauge:
| 类型 | 特点 | 适用场景 | 错误用法 |
|---|---|---|---|
| Counter | 单调递增,只增不减,用于累计事件数 | HTTP请求数、方法调用次数、错误总数 | 用Counter记录当前在线用户数(会一直涨) |
| Gauge | 可增可减,表示瞬时值 | 当前在线用户数、内存使用量、队列长度 | 用Gauge记录总请求数(无法求导得QPS) |
以订单服务为例,正确指标定义:
@Component public class OrderMetrics { // Counter:累计订单创建总数(用于计算QPS) private final Counter orderCreateTotal = Counter.builder("order.create.total") .description("Total number of orders created") .register(Metrics.globalRegistry); // Gauge:当前待处理订单数(瞬时值) private final Gauge pendingOrderCount = Gauge.builder("order.pending.count", () -> orderRepository.countByStatus("PENDING")) .description("Number of pending orders") .register(Metrics.globalRegistry); // Timer:订单创建耗时分布(用于计算P95/P99) private final Timer orderCreateDuration = Timer.builder("order.create.duration") .description("Time taken to create an order") .register(Metrics.globalRegistry); public void recordOrderCreate(Order order) { orderCreateTotal.increment(); orderCreateDuration.record(Duration.between(order.getCreateTime(), Instant.now())); } }Prometheus查询示例:
- QPS:
rate(order_create_total[5m]) - P95耗时:
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) - 待处理订单:
order_pending_count
关键原则:指标必须回答一个业务问题。比如“订单创建成功率”不是直接定义的指标,而是通过两个Counter计算:
(order_create_total - order_create_failure_total) / order_create_total
这个比率比单纯的“失败数”更有业务意义——失败10次在1000次请求中是1%,在10次请求中就是100%。
5.3 链路追踪不是“看一张大图”,而是故障根因的逆向推导
Jaeger/Zipkin的调用链图很炫,但真正价值在于:当/orders接口超时,你能快速定位是order-service内部慢,还是它调用的inventory-service慢,或是网络抖动。
关键在于传播TraceID。Spring Cloud Sleuth默认支持,但需注意两点:
异步调用需手动传递上下文:
@Async // 此方法在新线程执行 public void sendOrderConfirmation(Order order) { // ❌ 错误:新线程无TraceID log.info("发送确认邮件"); // ✅ 正确:从父线程继承上下文 Span currentSpan = tracer.currentSpan(); if (currentSpan != null) { tracer.withSpan(currentSpan).run(() -> { log.info("发送确认邮件"); }); } }外部HTTP调用需注入TraceID头:
// RestTemplate自动注入,但Feign需配置 @Bean public RequestInterceptor requestInterceptor(Tracing tracing) { return template -> template.header("X-B3-TraceId", tracing.tracer().currentSpan().context().traceIdString()); }
故障排查实战:
某次线上/orders接口平均响应时间从200ms飙升至2s。在Jaeger中搜索该接口,发现95%的慢请求都卡在inventory-service的/stock/check调用上。进一步点开inventory-service的链路,发现其DB查询耗时1.8s。此时去查inventory-service的Prometheus指标,发现jdbc_connections_active持续100%,确认是数据库连接池耗尽。最终定位到一个未关闭的ResultSet导致连接泄露。