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-modealways升级到 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 默认提供的RedisTemplateObject, Object使用 JDK 序列化JdkSerializationRedisSerializerkey 和 value 在 Redis 里存的是一坨带\xAC\xED开头的二进制序列化数据。而StringRedisTemplate用的是字符串序列化。两边混用就会出现这种局面你在 redis-cli 里用keys看到的是二进制乱码 key业务代码里用字符串 key 去查永远查不到或者你查到 key 了但值不是 plain textincrement 操作直接报“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 不生效注入为 nullconditions 端点、包扫描、自动配置顺序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 和 value127.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 命令看一下出问题的 key127.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 Beankey 一律用字符串序列化value 按业务选 Jackson 或字符串序列化Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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 doesnt 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 配置版本迁移带来的“静默失效”翻了一下项目的 pomSpring 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()拿代理对象。但需要额外配置exposeProxytrue代码里写起来也不直观不到万不得已不建议用。这个案例的教训比修复方案更重要如果代码里的方法标注了 Transactional但它在运行时没有经过代理入口这个注解就是一句废话。验证方式很简单——在方法里做一个故意抛异常的操作然后去数据库看数据是否真的被回滚。这个验证动作应该在写代码时、而不是线上出故障时才做。7. 从行动到习惯把“语法正确”的项目打磨成“行为相符”的项目三个案例复盘完了最后聊点方法论层面之外的体会。先说排查 Bug 时的心态。我发现自己年轻时候排障有个毛病一旦定位到某个可疑点就会特别兴奋急着改代码验证修不好就换个地方继续改。这种“打地鼠”式排障本质是用修改代码的快感掩盖思考的懒惰。系统化排障是要对抗这种本能的——每一次修改都应该服务于一个明确的假设而不是“顺手试试”。再说工具和习惯。上面三个案例如果非要说一个共同点那就是都在关键节点用到了“查看实际运行时状态”的动作Redis 案例里我直接去 redis-cli 里看了 key 的二进制形态建表案例里我打开了初始化日志和 Actuator 端点事务案例里我在调试器里确认了this到底是什么对象。这三个动作都不是“看代码”能替代的。代码是源代码层面的叙事运行时才是真实发生的事件。遇到行为不相符的 Bug与其反复读代码不如先问一句运行时的容器里到底发生了什么最后分享一个我自己的习惯每次遇到疑难 Bug修完后我会把“现象—假设—排查路—根因—修复—验证”这个过程记录下来哪怕只有几百字。这个习惯坚持了几年效果非常明显——很多问题不是第一次遇到有记录在手第二次基本能分钟级定位。而且这类记录写得多了你对 Spring Boot 的“脾性”会越来越熟知道它什么时候会静默跳过、什么时候会悄悄换一种行为方式。“语法正确”是底线“行为相符”才是目标。编译器只能帮你守住底线行为层面的保障靠的是对框架机制的深入理解、系统化的排查方法以及在关键时刻愿意停下来多看一步的耐心。
