1. “编译通过”只是入场券,行为正确才是真正的战场
先抛一个我自己的判断:在 Spring Boot 项目里,“语法正确”是一个被严重高估的状态。不是说编译通过不重要,而是它太容易达成了,容易到让你误以为“代码没问题”。真正难缠的 Bug,极少是编译器会拦下来的那种;恰恰相反,它们往往长着一张“人畜无害”的脸——IDE 不报错、mvn compile 全绿、甚至应用都能正常启动,但一跑业务逻辑,结果就是和你想的不一样。
这种“行为不相符”的 Bug,才是 Java 后端排障里最消耗精力的部分。我见过太多同事在一个诡异问题上耗掉一整天,最后发现根因根本不是他改的那几行代码。所以我想把这类问题的排查思路系统化地整理一遍,用几个真实踩过的案例,聊清楚“语法正确”到“行为相符”之间到底隔了什么。
1.1 编译器替你挡住的,只是最浅一层
先说个基础的:Java 编译器验证的是什么?是类型、语法、可见性、方法是否存在、异常是否受检。这些本质上是静态契约。编译器能确定的,是“这段代码在语法和类型层面是合法的”,但它在运行期间会被放进什么样的容器、被谁代理、在什么时机初始化、配置从哪个数据源加载——这些统统不在静态检查的范围内。
打个比方:你把一把车钥匙配得整整齐齐,齿形完全正确,这是“语法正确”。但钥匙能不能打开门,取决于门锁本身有没有被换过、钥匙坯料是不是匹配那个锁芯系列——这是“行为相符”。Spring 项目里,门锁就是 IoC 容器、代理机制、自动配置、序列化器这一整套运行时环境。钥匙对了,锁不对,门照样开不了。
1.2 Spring 容器让“行为主体”发生了位移
这是 Spring 项目区别于普通 Java 程序的核心特征:你以为执行的类,不一定是运行时真正执行的那个类。
Spring 会通过 JDK 动态代理 / CGLIB 生成代理对象,你注入的 Service、Mapper、Repository 很可能是一个增强后的子类或者代理实例。@Transactional、@Async、@Cacheable 这些注解,本质都是“额外的行为注入”。可一旦调用链绕过了代理入口——比如同类里 this 调用——这套增强就完全失效,而且不会给你任何报错。这是“行为不相符”最重要的来源之一。
另外还有自动配置。Spring Boot 的理念是“约定优于配置”,但这套约定是通过数百个@ConditionalOnXxx条件注解、自动配置类的加载顺序共同实现的。你引入一个 starter,觉得“应该”会帮你配好某些东西,但当条件不满足时,它静默地不装配。没有报错,只有行为与预期不符。
1.3 三类“行为不相符”的典型形态
结合我这几年的排障经验,Spring Boot 项目里所谓疑难 Bug,基本可以归类成三种形态:
- 配置没接上:配置项写错了命名空间、用错了版本属性、或者因宽松绑定把值绑到了错误的字段上,程序按默认值或空值运行;
- 增强没生效:事务/异步/缓存/AOP 切面因为自调用、代理方式、异常类型等原因被绕过,方法本身执行了,但附加行为缺失;
- 数据变了形:对象经过序列化、反序列化或类型转换后,内容与之前不一致,常见于 Redis、JSON、数据库字段映射,典型如 Long 精度丢失、BigDecimal 精度、Redis 二进制序列化头。
后续我复盘的具体案例,基本都逃不出这三类。你排障时如果能先把现象对号入座,思路会清晰很多。
2. 我在项目中反复踩到的四类“表面正常”Bug
这里先把高频故障类别列出来,方便大家对照。不是让你背,而是给你一张“故障地图”——遇到怪问题时先看它属于哪个区域,能少走很多弯路。
2.1 配置项写对了名字,但对错了版本
Spring Boot 版本迭代里,配置属性的迁移是最容易埋雷的点。比如数据源初始化脚本,Spring Boot 2.4 及之前用spring.datasource.initialization-mode、spring.datasource.schema,从 2.5 开始官方推出了spring.sql.init.*这套新属性。我用过不少老项目,代码里还在写spring.datasource.initialization-mode=always,升级到 3.x 之后旧属性被移除,初始化脚本直接不执行了。更气人的是,启动日志里啥异常都没有,表没建成,等我查到那一层才发现是配置属性失效。
还有一种更隐蔽的:spring.redis.host和spring.data.redis.host。Spring Boot 3.x 把 Redis 配置挪到了spring.data.redis.*命名空间下,如果你沿用 2.x 的习惯写spring.redis.host,配置就不会被绑定,连接默认指向 localhost:6379。生产环境上这个坑最狠——项目启动完全正常,但连的是本机或者错误地址,数据读写全部走偏。
排查这类问题,核心是确认配置项到底有没有被加载。别用眼“看”,用 Actuator 的 env 端点去看 Spring 容器里实际绑定的值。
2.2 代理小节:方法明明被执行,却没有被“增强”
这是 Spring 事务失效重灾区。同一个类里,A 方法调用 B 方法,B 标了 @Transactional,但事务根本没开启。原因很简单:Spring 的声明式事务基于代理实现,外部调用者拿到的才是代理对象,而内部的this.method()调用直接打在原始对象上,相当于绕过安检进站,事务逻辑自然不生效。
@Async、@Cacheable 同理。这类 Bug 最让人头疼的地方是:方法确实执行了,业务结果好像也没错,只有当你刻意制造一个异常去验证回滚——才会发现数据纹丝不动。要让这种行为问题暴露,最好的办法是主动写坏场景测试,而不是只验证正常路径。
2.3 序列化器不一致:数据不是你想的那个数据
这个我单独拿出来,因为它真的坑过我很久。Spring Boot 默认提供的RedisTemplate<Object, Object>使用 JDK 序列化(JdkSerializationRedisSerializer),key 和 value 在 Redis 里存的是一坨带\xAC\xED开头的二进制序列化数据。而StringRedisTemplate用的是字符串序列化。两边混用,就会出现这种局面:你在 redis-cli 里用keys看到的是二进制乱码 key,业务代码里用字符串 key 去查,永远查不到;或者你查到 key 了,但值不是 plain text,increment 操作直接报“not integer or out of range”。
这不是 Redis 的 Bug,也不是 Spring 的 Bug,是使用方式的 Bug。但它在编译期、启动期都完全无感,直到运行到特定数据才爆发。
2.4 条件装配悄悄失效,标注了 @Bean 却没人用
Spring Boot 自动配置的“智能”建立在条件注解上。最常见的一个反直觉场景是:你在配置类里定义了一个 @Bean 方法去覆盖默认组件,但它没有生效。原因可能是 @ConditionalOnMissingBean 的判定顺序、自动配置类的加载顺序、或者你定义 @Configuration 的包路径根本没有被主类的扫描覆盖。
这类 Bug 的神奇之处在于:代码里看什么都对,Bean 方法写得没错,注解也标了,但容器里根本没有这个实例。排查这类问题,直接看 Actuator 的 conditions 端点比翻源码高效得多——它会告诉你哪些条件匹配、哪些不匹配,以及匹配失败的原因。
| 故障类别 | 典型表现 | 排查突破口 |
|---|---|---|
| 配置绑定 | 参数未生效、连错环境 | env 端点、配置属性文档、版本迁移 |
| 代理生效 | 事务/切面未执行,方法却正常返回 | 调用链、自调用、异常类型 |
| 序列化 | 数据格式错、increment 报错 | redis-cli 直接看 key/value、序列化器配置 |
| 条件装配 | @Bean 不生效,注入为 null | conditions 端点、包扫描、自动配置顺序 |
3. 排查Spring Boot 疑难Bug的系统化思路
我见过太多排障现场,第一步就开启“搜索引擎模式”——把报错信息复制粘贴,看五六个网页,然后逐个试。不能说完全没用,但效率极低。因为疑难 Bug 通常不是“答案缺失”,而是“上下文缺失”。复制粘贴来的方案可能适用于别人的数据源、别人的版本、别人的调用方式,换到你这里就水土不服。
建议把排障当成一次“科学实验”,而不是“碰运气”。下面是我自己沉淀下来的步骤。
3.1 先建立问题坐标系,而不是急着改代码
拿到一个怪问题时,第一件事不是查资料,而是回答以下五个问题:
- 这个现象是稳定复现,还是偶发?
- 是某个接口、某个方法,还是全局都有影响?
- 影响的是“数据读出来不对”,还是“操作报错”?
- 最近改动过什么?配置、依赖、代码、环境?
- 相同代码在别的环境/分支上表现如何?
这些问题看似基础,但它们能快速建一个排除区间。比如偶发问题要优先怀疑并发/缓存/懒加载;全局问题要优先怀疑配置或自动装配;只有某个接口有问题则优先怀疑数据流和对象转换。官方一点的叫法是建立问题的“坐标系”——横向是影响范围,纵向是时间线。
3.2 用好这三个 Actuator 端点,让容器“开口说话”
排查 Spring Boot 问题,我最常用的三个端点:
/actuator/env:查看所有配置属性的实际绑定值,特别适合排查“为什么我的配置不生效”;/actuator/conditions(旧版叫autoconfig):查看自动配置类的匹配/不匹配条件及原因,直接定位“为什么这个 Bean 没被加载”;/actuator/beans:查看 Spring 容器里所有 Bean 实例及其类型,判断你注入的到底是原始对象还是代理对象。
这三个端点比看源码高效得多。容器本身的“心理活动”通过这些端点一览无余,剩下的就是你判断它为什么会这么想了。注意:生产环境使用时要做好权限控制,别把 Actuator 裸奔暴露到公网。
3.3 假设-验证循环:一次只验证一个变量
排障过程中的最大敌人是“同时改了好几个地方,恰好不报了,却不知道是哪个改动治好的”。这看起来像是解决了问题,其实是把问题推向了更深的未知。
正确的做法是:先基于现象建立假设,然后一次只修改一个变量去验证。比如假设是 Redis 序列化器不一致导致的,那就只把 key 序列化器统一,其他什么都不动,观察是否恢复。如果没恢复,说明假设错误;如果恢复了,再继续做下一步犀利观察。这个过程要形成闭环,直到找到真正的最小根因。
4. 实战复盘一:Redis 扣减库存报 “not integer or out of range”
第一个案例,我印象特别深。一个订单库存扣减接口,线上突然开始报异常,错误信息很直接:
io.lettuce.core.RedisCommandExecutionException: ERR value is not an integer or out of range伴生的还有两个信息:这个接口之前一直正常,是某次发布后出现的;出错操作是redisTemplate.opsForValue().increment(key, -1),业务意图是“把库存减一”。
4.1 现象与第一反应
我的第一反应是:Redis 里这个 key 的 value 不是数字。但奇怪的是,代码里存的明明是Integer,怎么会不是数字?如果只看到这里就开始查搜索,大概率会得到“把 value 先转成 Long 再 increment”之类的建议——方向就错了。
4.2 逐步缩小范围:从异常到 key 再到序列化器
我按前面说的坐标系思路来:
第一步,确认受影响范围。发现只有部分商品 key 报错,还有一些 key 完全查不到。于是决定去 redis-cli 里直接看 key 和 value:
127.0.0.1:6379> keys *stock* 1) "product:stock:1001" 2) "\xac\xed\x00\x05t\x00\x10product:stock:1001"看到这里我基本明白了:Redis 里存在两个“长得不一样”的 key。第一个是正常的字符串 key,第二个开头带着\xac\xed的,是 JDK 序列化后的二进制 key。
也就是说,同一个业务 key,有一部分写入时用的是字符串序列化,另一部分写入时用的是 JDK 序列化。两套 key 并存,代码里用字符串 key 去查 JDK 序列化出来的 key,自然查不到或者格式不匹配。
用 TYPE 命令看一下出问题的 key:
127.0.0.1:6379> type "\xac\xed\x00\x05t\x00\x10product:stock:1001" string再 GET 一下:
127.0.0.1:6379> get "\xac\xed\x00\x05t\x00\x10product:stock:1001" "\xac\xed\x00\x05sr\x00\x11java.lang.Integer..."结论很清楚:这个 key 的 value 是被 JDK 序列化的 Java 对象二进制,不是 Redis 认知里的数字字符串,increment肯定报错。
继续查代码,发现项目里一部分代码用的StringRedisTemplate(字符串序列化),另一部分用的原生的RedisTemplate(默认 JDK 序列化)。发布时新增的“减库存”逻辑用了后者,新写入的 key/value 全部是二进制序列化形式。存量 key 却是字符串形式。几个因素叠加,一次性把“序列化器不一致”的所有问题全都引爆了。
4.3 根因解释与修复方案
根因不复杂:RedisTemplate的默认 key/value 序列化器是JdkSerializationRedisSerializer,存进 Redis 后是二进制数据;而StringRedisTemplate是字符串序列化。两者本质上是两套序列化协议,不能混用。
修复方案有两层:
第一层,代码层面统一序列化器。项目中自定义一个 RedisTemplate Bean,key 一律用字符串序列化,value 按业务选 Jackson 或字符串序列化:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); // value 序列化器根据业务决定,不需要跨系统交互可直接用 GenericJackson2JsonRedisSerializer GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }第二层,数据层面清理脏数据。把所有二进制序列化的旧 key 导出、转成字符串 key,或者直接删除让系统重建。这个步骤不要省,否则代码上线后还是会被老脏数据干扰。
4.4 这个坑的衍生版本
后来我遇到类似场景:不是减库存,而是统计 PV、计数、做分布式自增 ID。只要用到increment/decrement,就必然要求 Redis 里的值是纯数字字符串。衍生问题包括:
decrement操作,本质就是increment(key, -1);- 两个系统对接,一个用 Jackson 序列化,一个用 JDK 序列化,互相读对方的 key 也全乱;
- value 本身是字符串
"1"和数字1的区别,直接决定increment会不会报错。
排这类问题,核心就一句话:先看 Redis 里真实存的字节长什么样,再讨论代码里的逻辑。数据在存储层的“物理形态”,往往直接决定了哪些操作能跑通。
5. 实战复盘二:MyBatis 查询提示表不存在,自动建表为什么没执行
第二个案例相对隐蔽。一个项目用 Spring Boot 整合 MyBatis,为了部署简化,在 schema.sql 里写了建表语句,指望应用启动时自动建表。结果服务启动完全正常,第一次请求查询业务表时,MyBatis 抛:
java.sql.SQLSyntaxErrorException: Table 'xxx.t_order' doesn't exist典型的“启动成功但没有建表”——数据库连接没问题、SQL 没有语法错、但表根本不存在。
5.1 项目配置与现象
我看了一下项目的配置,大概是下面这样:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/xxx username: root password: xxx schema: classpath:sql/schema.sql data: classpath:sql/data.sql这段配置是从网上一篇文章里抄的。看着像是老版本 Spring Boot 的写法。
接着做检查。先查了 target/classes 目录,确认sql/schema.sql确实被复制进去了;再手动执行 schema.sql 里的建表语句,MySQL 里能正常建表。排除 SQL 本身的问题。
然后我在启动日志搜索 schema、init、initialization 相关的关键字,没有任何输出。也就是说,Spring Boot 根本没执行 schema.sql。
5.2 配置版本迁移带来的“静默失效”
翻了一下项目的 pom,Spring Boot 版本是 3.x。问题就出在版本迁移上。
Spring Boot 2.5.0 开始,SQL 初始化相关的配置属性从spring.datasource.*迁移到了spring.sql.init.*。旧配置在 2.5 里被标记废弃,到了 3.x 直接失效。项目里写的是老属性,新版本完全不认。
正确的配置是:
spring: sql: init: mode: always schema-locations: classpath:sql/schema.sql >logging: level: org.springframework.jdbc.datasource.init: DEBUG打开这个日志级别后,如果脚本被执行,你会看到类似Executing SQL script from class path resource [sql/schema.sql]的输出。如果什么都没输出,说明初始化流程压根没走到。
这个案例的通用教训是:Spring Boot 的版本升级不能只依赖“编译通过”。编译不会告诉你配置属性是否还会被读取,API 是否还会被支持。唯一的办法是发布前查一遍用到的配置属性在你实际使用的版本里是否还有效。
6. 实战复盘三:方法没有报错,事务却不回滚
第三个案例非常经典,也非常容易在面试中被问到:一个转账接口,扣款成功,加款失败,但最终结果两边都变了。为什么会这样?
6.1 转账案例
代码结构是这样的:
@Service public class TransferService { public void transfer(Long fromId, Long toId, BigDecimal amount) { this.doDeduct(fromId, amount); this.doAdd(toId, amount); } @Transactional(rollbackFor = Exception.class) public void doDeduct(Long accountId, BigDecimal amount) { // 扣款 SQL } @Transactional(rollbackFor = Exception.class) public void doAdd(Long accountId, BigDecimal amount) { // 加款 SQL } }调用方调的是transfer方法。doDeduct执行成功,doAdd抛了 RuntimeException,但doDeduct的数据没有回滚。注意这里没有吞异常,也没有 catch——异常明明向上抛了,事务为什么没回滚?
6.2 检查调用链
排这个问题的第一件事,是确认调用链。我在doAdd里打上断点,看当前的this到底是谁。结果很有意思——调试器里显示的类是TransferService的原始对象,不是 Spring 的 CGLIB 代理子类。
继续看,发现transfer方法内部用的是this.doDeduct(...)和this.doAdd(...)。this是原始对象,不是代理对象,那么直接调用doAdd时,@Transactional的注解根本不会被事务拦截器识别。失败,回滚无从谈起。
这就是 Spring 事务失效问题里最经典的场景:事务方法被同类内部方法通过 this 调用,导致代理不生效。
6.3 代理的真相
底层原理是这样的:Spring 的@Transactional是通过 AOP 实现的,AOP 建立在对 Spring Bean 的代理之上。外部调用方注入TransferService时,拿到的是代理对象;代理对象在执行transfer方法时,会把事务增强逻辑织入进去。但当你通过this在原始对象内部调用另一个方法时,没有任何代理参与,增强逻辑自然缺失。
除了自调用,还有几个同族原因也容易让事务悄悄失效:
- 方法不是
public—— Spring 的声明式事务基于代理,非 public 方法无法被增强; - 异常被吞掉 —— 方法内部
try/catch捕获后没抛出,事务拦截器根本不知道出错了; - 抛出的是 checked exception ——
@Transactional默认只对RuntimeException和Error回滚,rollbackFor = Exception.class不配置的话,检查型异常不会触发回滚。
6.4 修复的必要方式与权衡
修复方式有几种,各有利弊:
第一种,把@Transactional提到对外暴露的方法上,也就是transfer方法本身加事务。这是最自然的做法,因为事务的边界本来就应该定义在“一个完整的业务操作”上,而不是拆散的子步骤上。
第二种,把doDeduct和doAdd拆到另一个 Service 类里,由外部类通过注入调用。调用链经过代理,事务能正常生效。这也符合“一个事务对应一个独立业务服务”的设计思想。
第三种,在同一个类内部通过AopContext.currentProxy()拿代理对象。但需要额外配置exposeProxy=true,代码里写起来也不直观,不到万不得已不建议用。
这个案例的教训比修复方案更重要:如果代码里的方法标注了 @Transactional,但它在运行时没有经过代理入口,这个注解就是一句废话。验证方式很简单——在方法里做一个故意抛异常的操作,然后去数据库看数据是否真的被回滚。这个验证动作应该在写代码时、而不是线上出故障时才做。
7. 从行动到习惯:把“语法正确”的项目打磨成“行为相符”的项目
三个案例复盘完了,最后聊点方法论层面之外的体会。
先说排查 Bug 时的心态。我发现自己年轻时候排障有个毛病:一旦定位到某个可疑点,就会特别兴奋,急着改代码验证,修不好就换个地方继续改。这种“打地鼠”式排障,本质是用修改代码的快感掩盖思考的懒惰。系统化排障是要对抗这种本能的——每一次修改都应该服务于一个明确的假设,而不是“顺手试试”。
再说工具和习惯。上面三个案例,如果非要说一个共同点,那就是都在关键节点用到了“查看实际运行时状态”的动作:
- Redis 案例里,我直接去 redis-cli 里看了 key 的二进制形态;
- 建表案例里,我打开了初始化日志和 Actuator 端点;
- 事务案例里,我在调试器里确认了
this到底是什么对象。
这三个动作都不是“看代码”能替代的。代码是源代码层面的叙事,运行时才是真实发生的事件。遇到行为不相符的 Bug,与其反复读代码,不如先问一句:运行时的容器里,到底发生了什么?
最后分享一个我自己的习惯:每次遇到疑难 Bug,修完后我会把“现象—假设—排查路—根因—修复—验证”这个过程记录下来,哪怕只有几百字。这个习惯坚持了几年,效果非常明显——很多问题不是第一次遇到,有记录在手,第二次基本能分钟级定位。而且这类记录写得多了,你对 Spring Boot 的“脾性”会越来越熟,知道它什么时候会静默跳过、什么时候会悄悄换一种行为方式。
“语法正确”是底线,“行为相符”才是目标。编译器只能帮你守住底线,行为层面的保障,靠的是对框架机制的深入理解、系统化的排查方法,以及在关键时刻愿意停下来多看一步的耐心。