Spring 工程实践中的隐式规则:数据访问、事务、HTTP 调用与测试的常见陷阱
Spring 工程实践中的隐式规则:数据访问、事务、HTTP 调用与测试的常见陷阱
Spring 最大的优势,是把大量底层细节包装成统一、易用的抽象;Spring 最容易让人踩坑的地方,也恰恰来自这些抽象。
很多问题看起来互不相关:Redis 明明写进去了却读不到、事务抛了异常却没有回滚、内层事务明明被 try-catch 了却把外层事务一起回滚、切换数据源的注解看起来执行了但 SQL 仍然落在原库、RestTemplate 提交表单却发成了 JSON、同一份 spring.xml 在应用里能加载而测试里找不到、测试代码没有变多少却反复启动 Spring Context……
把这些现象放在一起看,会发现真正需要掌握的并不是几十个零散“坑”,而是一套理解 Spring 的方法:不要只看 API 表面,还要知道 Spring 在什么时机选择了什么策略、创建了什么代理、使用了什么默认值,以及这些行为最终绑定到了哪个底层对象。
本文围绕 Spring Data、声明式事务、HTTP 客户端与 Spring Test,把这些问题重新组织成一套可以用于开发和排障的知识体系。
版本说明:资料中的主要代码与源码调用链来自 2021 年前后的 Spring Boot 2.4.x、Spring Framework 5.3.x、DataStax Java Driver 4.6.x 等版本。核心机制仍具有很高的理解价值,但在更高主版本中,自动配置注册方式、部分 API 与默认行为可能变化。遇到版本差异时,应以当前项目依赖对应的源码和文档为准。
一、先建立一个统一心智模型:Spring 的错误通常发生在哪一层
Spring 的很多问题,都可以归入下面几类根因:
| 根因 | 典型表现 | 本质 |
|---|---|---|
| 隐式规则 | Bean 扫描不到、事务注解不生效 | 框架存在默认匹配条件或代理边界 |
| 默认配置不适合生产 | Cassandra 刚写入却读不到 | 默认值为了“快速可用”,不代表适合你的部署 |
| 策略自动选择 | HashMap 表单被发送为 JSON | Spring 根据类型、MediaType、依赖动态选择实现 |
| 代理调用边界 | private 方法、this.xxx() 事务失效 |
调用没有经过 AOP Proxy |
| 上下文/线程绑定 | 数据源切换失效 | DataSource key、Connection、事务上下文绑定时机错误 |
| 依赖或资源环境变化 | 测试与正式启动行为不同 | 相同 API 在不同 ApplicationContext/ResourceLoader 中含义不同 |
| 协议本身的规则 | URL 中 # 被当作 Fragment |
问题并非 Spring 特有,而是 HTTP/URI 语义导致 |
可以把本文涉及的主要机制抽象成一张图:
flowchart TD
A[业务代码] --> B{Spring 抽象层}
B --> C[自动配置]
B --> D[AOP / Proxy]
B --> E[策略选择]
B --> F[资源抽象]
B --> G[上下文缓存]
C --> C1[条件装配 Bean]
D --> D1[事务拦截器]
D --> D2[数据源切面]
E --> E1[Redis Serializer]
E --> E2[HttpMessageConverter]
F --> F1[ClassPathResource]
F --> F2[ServletContextResource]
G --> G1[MergedContextConfiguration]
C1 --> H[最终运行行为]
D1 --> H
D2 --> H
E1 --> H
E2 --> H
F1 --> H
F2 --> H
G1 --> H
所以排查 Spring 问题时,一个非常有效的问题不是“这个注解为什么没用”,而是:
这个注解最后由谁处理?什么时候处理?处理结果是什么对象?真正执行时有没有经过这个对象?
这套思路贯穿下面所有案例。
二、Spring Data:同样是“存数据”,真正决定行为的是序列化、一致性与连接生命周期
Spring Data 的抽象很统一,但它所代理的底层系统差异巨大。Redis 的关键问题可能是序列化,Cassandra 的关键问题可能是一致性级别和 Session 元数据。只记 Repository、Template 这些表层 API,远远不够。
2.1 Redis:写和读必须使用同一套序列化协议
一个非常典型的问题是:
1 | |
表面上看,两次读取使用的 key 都是 mykey,但 Redis 实际收到的并不是 Java 的 String 对象,而是序列化后的字节数组。
Spring Data Redis 在执行命令前会把 key 转换成 raw key,逻辑可以简化理解为:
1 | |
StringRedisTemplate 默认针对 key/value/hash key/hash value 使用字符串序列化器:
1 | |
字符串序列化本质上就是按照字符集转成字节:
1 | |
而普通 RedisTemplate<Object, Object> 在资料所用版本的默认配置下会使用 JDK 序列化。于是:
1 | |
Redis 看到的是两个不同的 key,自然无法互相读取。
这类问题真正的工程结论
不要只问“是不是使用同一个 RedisTemplate”,而要检查整条读写协议是否一致:
- key serializer 是否一致;
- value serializer 是否一致;
- hash key serializer 是否一致;
- hash value serializer 是否一致;
- 不同服务、不同语言、不同版本是否采用同一套编码约定;
- 已经存在的历史数据采用什么格式。
在微服务中,Redis 往往是跨服务共享的数据层。此时“序列化器”实际上已经变成了跨服务数据协议的一部分。如果一个服务写 JSON,另一个服务按 JDK Serialization 读,问题不是 Redis 出错,而是双方协议根本不一致。
RedisTemplate 和 StringRedisTemplate 是怎么来的
资料进一步追到了 Spring Boot 自动配置过程。在 Spring Boot 2.4.x 的实现中,RedisAutoConfiguration 会在满足条件时创建这两个 Bean:
1 | |
在资料对应的 Spring Boot 2.x 自动配置链路中,可以理解为:
flowchart LR
A[@SpringBootApplication] --> B[@EnableAutoConfiguration]
B --> C[AutoConfigurationImportSelector]
C --> D[META-INF/spring.factories]
D --> E[RedisAutoConfiguration]
E --> F{条件是否满足}
F -->|是| G[RedisTemplate]
F -->|是| H[StringRedisTemplate]
这也解释了一个很重要的 Spring Boot 思维:你没有显式 new 出来的 Bean,不代表它没有明确的创建路径。 自动配置只是在帮你写配置代码,并不是魔法。
2.2 Cassandra:默认配置“能跑”不代表满足生产一致性要求
第二类问题比 Redis 更隐蔽:写入成功、没有异常,但紧接着读取却可能拿不到刚写的数据。
资料中的 DataStax Java Driver 4.6.1 默认配置包含:
1 | |
如果复制因子为 3,可以用一个简化模型理解:
1 | |
资料用 R + W > N 来帮助理解读写集合产生交集的条件。
假设数据存在 A、B、C 三个副本:
sequenceDiagram
participant ClientW as 写客户端
participant A as Replica A
participant B as Replica B
participant C as Replica C
participant ClientR as 读客户端
ClientW->>A: 写入
A-->>ClientW: LOCAL_ONE 已满足,返回成功
A-->>B: 后续同步
A-->>C: 后续同步
ClientR->>C: 立即读取
C-->>ClientR: LOCAL_ONE 返回旧值/空值
如果读写都使用 LOCAL_ONE,一次写只需要一个本地副本确认,一次读也只需要一个本地副本响应。刚写完时,如果读取命中了尚未同步完成的副本,就可能读到旧数据。
在资料的部署场景中,将一致性调整为 LOCAL_QUORUM 更符合其多节点生产环境:
1 | |
这里最重要的不是背住 LOCAL_ONE 或 LOCAL_QUORUM,而是形成一个习惯:
框架或驱动的默认值通常优先照顾“开箱即用”,生产配置必须结合拓扑、复制策略、延迟目标和一致性要求重新审视。
数据库连接池大小、超时、重试、隔离级别、批量大小、序列化方式,也都属于同一类问题。
2.3 Cassandra Session:自定义 Bean 时要警惕“看起来覆盖,实际上新增”
另一个非常有代表性的错误,是内存里出现多份 Cassandra Metadata。
Spring Data Cassandra 建立 Session 后会加载集群 Metadata,其中包括 Token Range 等信息。资料中的示例里,一份 Metadata 已经达到 40 MB 量级,而堆里却出现了四份相似的大对象。
根因来自配置继承关系。
父配置定义:
1 | |
子配置如果使用同名方法覆盖:
1 | |
逻辑仍然只有一个 Bean。
但如果子类不小心换了方法名:
1 | |
此时不是“覆盖父配置”,而是新增了另一个 Bean。
Cassandra 案例中,父类已经定义:
1 | |
自定义配置却写成:
1 | |
方法名不同,父类 Bean 仍然存在,因此会产生额外的 CqlSessionFactoryBean。
修正时应真正覆盖父类定义:
1 | |
此外,资料中的 CqlSessionFactoryBean#afterPropertiesSet() 本身还会创建 system session 和 normal session:
1 | |
所以一旦你重复创建了 CqlSessionFactoryBean,Metadata 的数量可能不只是“多一份”,而是成倍增长。
这个案例背后还有一个通用经验:
当堆中出现多份结构几乎相同的大对象时,不要第一时间怀疑 GC。先确认创建它们的 Bean、Factory、Session、Client 是否重复实例化。
三、Spring 声明式事务:核心不是 @Transactional,而是 Proxy + TransactionInterceptor
很多事务问题之所以难理解,是因为代码表面看起来只有一个注解:
1 | |
但运行时真正执行的是:
sequenceDiagram
participant Caller as 调用方
participant Proxy as Spring AOP Proxy
participant TI as TransactionInterceptor
participant TM as TransactionManager
participant Target as 目标 Bean
Caller->>Proxy: 调用事务方法
Proxy->>TI: invoke()
TI->>TM: 创建/加入事务
TI->>Target: 执行业务方法
alt 正常返回
Target-->>TI: return
TI->>TM: commit
else 抛出异常
Target-->>TI: exception
TI->>TI: rollbackOn(exception)
TI->>TM: rollback 或按规则提交
end
理解事务问题时,最关键的三个对象是:
TransactionInterceptorTransactionAspectSupportPlatformTransactionManager
@Transactional 只是告诉 Spring:这个方法应该被事务 Advisor 匹配,并生成相应的事务属性。
3.1 为什么抛出 Exception 却没有回滚
一个常见误区是:
“只要方法抛异常,Spring 就一定回滚。”
并不是。
资料中的业务代码大致如下:
1 | |
异常确实抛出了,但插入记录仍然提交。
事务异常处理最终会进入类似下面的判断:
1 | |
也就是说,在没有额外 rollback rule 的情况下,资料对应版本的默认策略是:
RuntimeException:回滚;Error:回滚;- 普通 checked
Exception:默认不回滚。
如果业务要求 checked Exception 也回滚,应显式声明:
1 | |
如果需求更细,例如“Exception 回滚,但 RuntimeException 不回滚”,可以同时配置正向和反向规则:
1 | |
这里的工程原则非常明确:事务回滚策略属于业务语义,不要依赖团队成员对默认值的记忆。 对关键业务,最好让规则在注解或统一事务规范里显式可见。
3.2 为什么 private 方法上的 @Transactional 不生效
资料所用 Spring 5.3.x 场景中,注解事务属性解析存在这样的判断:
1 | |
AnnotationTransactionAttributeSource 的默认配置使 publicMethodsOnly 为 true,所以 private 方法不会被事务 Advisor 当作可代理事务方法。
即使先不讨论这个判断,private 方法本身也不适合作为基于代理的 AOP 连接点。
错误示例:
1 | |
应该把事务边界设计在可被 Spring Proxy 调用的方法上:
1 | |
但还有第二层问题:方法是 public,也不代表一定经过代理。
3.3 Self Invocation:同一个类里 this.xxx() 为什么绕过事务
假设:
1 | |
外部调用 studentService.saveStudent() 时,入口经过 Proxy;但进入目标对象之后执行 this.doSaveStudent(),调用发生在目标对象内部,并没有再次回到 Proxy。
因此事务拦截器没有机会拦截 doSaveStudent()。
flowchart LR
A[外部调用] --> B[Spring Proxy]
B --> C[saveStudent target method]
C -->|this.doSaveStudent| D[doSaveStudent target method]
D -. 没有重新经过 Proxy .-> B
工程上更清晰的处理方式通常是把真正的事务边界拆到另一个 Spring Bean,让调用自然经过代理:
1 | |
这通常也比“注入自己再调自己”更容易维护,因为事务边界与业务职责同时变得清晰。
四、事务传播:为什么 catch 住异常,外层事务还是回滚了
事务传播是 Spring 声明式事务中最容易“代码看起来正确,结果却很意外”的部分之一。
资料中的业务关系可以抽象为:
1 | |
内层:
1 | |
开发者期望:
1 | |
实际却得到 UnexpectedRollbackException,最终整个事务都回滚。
4.1 REQUIRED:逻辑上两层,物理上同一个事务
Spring 默认传播行为是 Propagation.REQUIRED:
- 当前没有事务:创建一个;
- 当前已有事务:加入已有事务。
因此:
1 | |
内层 regCourse() 抛出符合 rollback rule 的异常后,Spring 会把当前共享事务标记为 rollback-only。
外层代码虽然 catch 了 Java 异常,但它只阻止了异常继续传播,没有取消事务已经写下的 rollback-only 标记。
外层方法正常返回后,Spring 尝试提交:
1 | |
于是最终仍然回滚,并抛出类似:
1 | |
整个过程可以画成:
sequenceDiagram
participant O as 外层 REQUIRED
participant I as 内层 REQUIRED
participant TX as 同一个事务
O->>TX: 开始事务
O->>I: 调用
I->>TX: 加入已有事务
I-->>I: 抛异常
I->>TX: 标记 rollback-only
I-->>O: exception
O-->>O: catch exception
O->>TX: commit
TX-->>O: 检测 rollback-only
TX->>TX: rollback
TX-->>O: UnexpectedRollbackException
这个案例非常重要,因为它揭示了:
Java 异常控制流和数据库事务状态是两套不同的状态机。
catch 只能改变 Java 控制流,不能自动恢复数据库事务状态。
4.2 REQUIRES_NEW:需要真正隔离时,创建独立事务
如果业务语义确实要求“选课失败不影响学生注册”,内层事务可以声明为:
1 | |
REQUIRES_NEW 会挂起当前事务,并创建一个新的事务。内层回滚只影响自己的事务。
flowchart TB
A[外层 saveStudent] --> B[Tx-A]
B --> C[调用 regCourse]
C --> D[挂起 Tx-A]
D --> E[创建 Tx-B]
E --> F{regCourse 结果}
F -->|异常| G[回滚 Tx-B]
G --> H[恢复 Tx-A]
H --> I[外层继续并提交 Tx-A]
但 REQUIRES_NEW 不是“解决嵌套事务的万能开关”。它会改变业务原子性。
例如:
- 内层选课事务先成功提交;
- 外层学生事务随后失败;
- 最终可能留下没有学生主记录的选课数据。
所以传播行为必须从业务一致性出发选择,而不能只为了让异常“别往外影响”。
4.3 七种传播行为速查
资料列出了 Spring 的七种传播行为。结合其标准语义,可以这样理解:
| Propagation | 当前有事务 | 当前无事务 | 典型语义 |
|---|---|---|---|
REQUIRED |
加入 | 新建 | 默认,大多数单库业务 |
SUPPORTS |
加入 | 非事务执行 | 有事务就参与,没有也可以 |
MANDATORY |
加入 | 抛异常 | 强制调用方先开启事务 |
REQUIRES_NEW |
挂起原事务,新建 | 新建 | 独立提交/回滚 |
NOT_SUPPORTED |
挂起原事务 | 非事务执行 | 明确要求无事务 |
NEVER |
抛异常 | 非事务执行 | 禁止在事务中调用 |
NESTED |
使用嵌套事务/保存点(依赖事务管理器支持) | 通常按新事务处理 | 需要部分回滚且保持外层事务结构 |
真正重要的是:传播行为描述的是“当前方法的事务应该如何与已有事务发生关系”,不是简单的开关。
五、多数据源:切换 DataSource 的真正时机必须早于事务获取 Connection
多数据源场景经常会设计一个类似这样的注解:
1 | |
最容易忽略的问题是:@DataSource 和 @Transactional 都依赖 AOP,它们的执行顺序会直接决定最后使用哪个数据库连接。
5.1 AbstractRoutingDataSource 的核心模型
Spring 提供 AbstractRoutingDataSource 作为动态数据源路由抽象。
关键数据结构可以简化成:
1 | |
获取连接时:
1 | |
目标 DataSource 由 lookup key 决定:
1 | |
自定义实现通常会借助 ThreadLocal 保存当前线程的数据源 key:
1 | |
5.2 为什么 AOP 顺序决定是否切换成功
事务真正开始时,DataSourceTransactionManager#doBegin() 会获取 Connection:
1 | |
而 obtainDataSource().getConnection() 最终会进入 AbstractRoutingDataSource。
因此顺序必须是:
1 | |
如果事务已经先绑定了一个 Connection,之后再切换 ThreadLocal key,通常已经晚了。
sequenceDiagram
participant A as DataSource AOP
participant T as Transaction AOP
participant TM as DataSourceTransactionManager
participant R as RoutingDataSource
participant DB as Target DB
A->>A: set lookupKey = card
A->>T: proceed
T->>TM: begin transaction
TM->>R: getConnection()
R->>R: determineCurrentLookupKey()
R->>DB: cardDataSource.getConnection()
资料使用 @Order(1) 让数据源切面获得高于事务切面的优先级。
5.3 ThreadLocal 一定要在 finally 中清理
原始示例的思想是正确的,但工程实现应该确保异常时也清理上下文:
1 | |
否则在线程池环境中,线程被复用后可能把上一次请求的数据源选择“带到”下一次请求,产生极其危险的跨库访问。
5.4 为什么多数据源场景经常配合 REQUIRES_NEW
如果外层事务已经从 core 数据源获取并绑定了 Connection,内层仍使用默认 REQUIRED,它会尝试参与已有事务,导致路由切换没有机会创建一套新的事务资源。
资料因此在 card 服务上使用:
1 | |
这样内层建立新事务时,会重新获取 Connection,路由数据源才有机会根据新的 lookup key 选择 card 库。
需要特别区分两个概念:
- 动态路由数据源解决“这次获取 Connection 应该去哪个库”;
- 事务管理器与事务传播解决“Connection 如何绑定到当前事务、何时提交/回滚”。
二者不是同一个问题。
另外,资料示例使用 DriverManagerDataSource 是为了简化演示。真实高并发服务通常应使用连接池 DataSource,并结合项目实际架构决定是动态路由、多个 TransactionManager,还是更明确的数据访问边界。
六、RestTemplate:真正控制 HTTP 请求格式的是 HttpMessageConverter 与 URI 编码链
RestTemplate 的几个案例表面上分别属于“表单”“特殊字符”“中文编码”,其实可以归成两个核心问题:
- Body 到底由哪个
HttpMessageConverter写出; - URI 在整个调用链上到底被编码了几次。
6.1 为什么 HashMap 表单最后变成了 JSON
服务端接口:
1 | |
客户端错误写法:
1 | |
抓包后看到的却是:
1 | |
因此服务端的 @RequestParam 找不到表单参数。
根因:RestTemplate 在遍历 HttpMessageConverter
请求发送阶段会根据:
- request body 的 Java 类型;
- Content-Type;
- converter 的
canWrite(...);
选择第一个合适的 HttpMessageConverter。
HashMap 可以被 Jackson 序列化,因此 JSON converter 可以写它。
而 FormHttpMessageConverter 的关键条件是:
1 | |
也就是说,普通 Map 并不会自动等价于“表单”。
正确做法:
1 | |
请求会变为:
1 | |
这与 Redis 序列化问题其实有相似结构:
1 | |
如果只盯着 Java 变量 Map,很容易忽略最终在网络上传输的东西完全不同。
6.2 @RequestParam 为什么既能接 Query,也能接 Form
从 Spring MVC API 视角看,@RequestParam 不仅用于 URL Query,也可以处理表单参数。
在资料所用 Tomcat 调用链里,两类数据最终都会汇总到 Servlet request parameters 中:
1 | |
因此服务端接口可以保持:
1 | |
客户端既可以:
1 | |
也可以用表单提交:
1 | |
这也再次说明:服务端注解名称相同,不代表客户端 wire format 相同。
6.3 URL 中的 # 为什么会“丢失”
假设调用:
1 | |
开发者可能认为参数值是:
1 | |
但 URI 语义中 # 开始的是 Fragment:
1 | |
因此解析后实际上更接近:
1 | |
服务端自然只能收到:
1 | |
如果 # 是参数值的一部分,它必须作为 query value 被正确编码,而不是把完整字符串直接当作“已经合法的 URL”处理。
资料使用 UriComponentsBuilder 构造并编码 URI:
1 | |
这个案例真正应该记住的是:
特殊字符问题必须从 URI 结构和编码阶段分析,不要把它当成“Spring 把字符吃了”。
6.4 双重 URL Encoding:encode 一次是编码,encode 两次是数据污染
另一个典型错误:
1 | |
问题在于:
toUriString()已经进行了编码;- 把编码后的结果再次作为
String URL交给 RestTemplate; - RestTemplate 的 URI 构造链又编码一次。
第一次:
1 | |
第二次:
1 | |
最终服务端得到的是“编码字符串本身”,而不是原始中文。
更稳妥的做法是明确“在哪一步完成唯一一次编码”,然后直接传递 URI:
1 | |
调试 URI 问题时,不要只打印最终字符串。最好逐阶段观察:
1 | |
一旦知道“哪一步发生第一次编码”,双重编码问题通常很快就能定位。
七、Spring Test:测试慢、资源找不到,本质上都是 TestContext 的运行环境与你想象的不一样
Spring Test 很容易制造一种错觉:测试代码看起来只是“启动应用然后调用一下 Bean”。实际上,测试框架会构建自己的 ApplicationContext、ResourceLoader、Context Cache key 和 ContextCustomizer。
7.1 为什么 @ImportResource("spring.xml") 应用能跑,测试却失败
配置:
1 | |
spring.xml 位于:
1 | |
在普通应用启动时,资料中的调用链最终把它当作 ClassPathResource,因此能够从 classpath 找到。
但在某种 @SpringBootTest Web 测试上下文里,同样的相对 location 可能进入 ServletContextResource 加载逻辑。此时搜索位置更接近 Web resource 目录,例如:
1 | |
而编译后的 src/main/resources/spring.xml 实际通常位于:
1 | |
并不是:
1 | |
于是测试环境找不到文件。
更明确的写法是:
1 | |
DefaultResourceLoader#getResource() 的设计可以解释为什么一个前缀会改变整个加载路径:
1 | |
相对路径最后会落到当前 ApplicationContext 对 getResourceByPath() 的具体实现;显式 classpath: 则直接锁定到 ClassPathResource。
资源加载的工程原则
如果资源的语义就是“classpath 上的配置”,就把语义写出来:
1 | |
不要依赖“在我当前启动方式下,相对路径刚好能工作”。
7.2 为什么 @MockBean 会让 Spring Context 重建
Spring Test 为了提高速度,会缓存 ApplicationContext。
核心流程可以简化为:
1 | |
缓存 key 是 MergedContextConfiguration。
其 hash/equality 会受到多项配置影响,例如:
1 | |
两个测试分别写:
1 | |
1 | |
由于 @MockBean 会参与构造 Mockito 相关 ContextCustomizer,两个测试得到的 MergedContextConfiguration 不同,Context Cache key 也不同。
结果:
flowchart LR
A[ServiceOneTests] --> B[Mock ServiceOne]
B --> C[MergedContextConfiguration A]
C --> D[Context A]
E[ServiceTwoTests] --> F[Mock ServiceTwo]
F --> G[MergedContextConfiguration B]
G --> H[Context B]
即使两个测试的业务差异很小,也可能分别启动 Spring Context。
如何减少无意义的 Context 重建
如果多个测试确实需要同一组 Mock,可以把 Mock 组合保持一致,例如抽到共同的测试基类或共享测试配置:
1 | |
1 | |
1 | |
这使两者更可能得到相同的 Context Cache key,从而复用 Context。
但不要为了“缓存命中率”无限扩大共享 Mock。测试配置必须先保证语义正确,再谈性能。
更根本的问题是:这个测试真的需要启动 Spring Context 吗?
如果只是在测试一个纯业务类,直接使用 Mockito/JUnit 做普通单元测试,通常比启动完整 ApplicationContext 更便宜、更稳定。
八、把这些案例串起来:Spring 中最值得掌握的五类“隐式机制”
前面的案例可以进一步抽象成五种经常决定 Spring 行为的机制。
8.1 类型驱动的策略选择
典型案例:
HashMap→ Jackson JSON converter;MultiValueMap→ Form converter;RedisTemplate/StringRedisTemplate→ 不同 serializer。
排查问题时要问:
1 | |
8.2 Annotation 只是元数据,真正执行的是基础设施 Bean
典型案例:
@Transactional→ TransactionAttribute → Advisor → Proxy → TransactionInterceptor;@DataSource→ 自定义 Aspect → ThreadLocal lookup key;@MockBean→ ContextCustomizer → Context Cache key 变化。
所以看到注解不生效时,应该继续追:
1 | |
8.3 默认值属于“框架的默认假设”,不是你的业务需求
典型案例:
- Cassandra
LOCAL_ONE; - Spring Transaction 默认 rollback rule;
- Transaction propagation 默认
REQUIRED。
每次引入中间件或框架,都值得做一次“effective configuration review”:
1 | |
生产事故里,“我以为默认值应该是……”往往是一句危险的话。
8.4 上下文绑定决定了“切换”是否还来得及
事务绑定 Connection、路由绑定 ThreadLocal、测试绑定 ApplicationContext cache、Servlet 绑定 request parameter map,这些机制都在提醒我们:
框架里的很多状态不是全局自由变化的,而是绑定在某个线程、事务、请求或上下文生命周期里。
当状态已经绑定后,再改配置可能已经无法影响本次执行。
8.5 底层协议不会因为 Spring 抽象而消失
URL 的 # 仍然是 Fragment;application/x-www-form-urlencoded 和 JSON 仍然是不同 wire format;Cassandra 仍然有复制与一致性问题;Redis 存储的仍然是字节。
Spring 只是把它们包装得更友好,并没有取消底层规则。
九、源码级排障方法:比记住“50 个坑”更重要
这些资料真正有价值的地方,不只是给出修复代码,而是反复展示了一套定位方式:先把现象最小化,再沿着框架真正的执行链找到决策点。
9.1 第一步:构造最小复现
线上代码有几十层调用时,不要一上来就在完整项目里猜。
把问题缩成:
1 | |
如果最小案例仍能复现,问题边界会瞬间清晰很多。
如果最小案例不能复现,那么差异本身就是线索:
- 依赖差异;
- 配置差异;
- Bean 数量差异;
- AOP 顺序差异;
- 数据或并发条件差异。
9.2 第二步:先看“事实”,再看代码
几个案例都使用了非常有效的事实验证手段:
- Redis:直接比较不同 serializer 后的 key bytes;
- RestTemplate:Wireshark 抓包确认 Content-Type 与 Body;
- Cassandra:Heap dump 看 Metadata 是否重复;
- Spring Test:Debug 查看实际 Resource 类型;
- Transaction:查看调用栈与 rollback-only 状态。
很多问题可以先用一句话验证:
“系统实际上做了什么?”
而不是:
“我觉得 Spring 应该做什么?”
9.3 第三步:找到框架的“策略决策点”
不同问题对应不同决策点:
| 问题 | 决策点 |
|---|---|
| Redis 读不到 | keySerializer()/valueSerializer() |
| 表单变 JSON | HttpMessageConverter#canWrite |
| 事务是否回滚 | rollbackOn(Throwable) |
| 方法是否有事务属性 | computeTransactionAttribute(...) |
| 嵌套事务如何参与 | Propagation + TransactionManager |
| 数据源选哪个 | determineCurrentLookupKey() |
| 资源如何加载 | ResourceLoader#getResource() |
| Test Context 是否复用 | MergedContextConfiguration |
找到这个点以后,再向上看“谁传入了这个值”,向下看“它决定了什么行为”。比从业务代码漫无目的地一路 Step Into 高效得多。
9.4 第四步:重点关注对象的真实数量与真实类型
Spring 问题里,“我以为只有一个”经常是错的:
1 | |
因此 Debug 时非常值得看:
- runtime class;
- Bean name;
- Bean count;
- Proxy class;
- ConnectionHolder;
- ThreadLocal 值;
- ApplicationContext identity;
- converter/serializer 实例。
9.5 第五步:修复之后必须验证副作用
一个修复“让当前错误消失”,并不代表它正确。
例如:
- 把异常改成
RuntimeException,可能只是迁就默认回滚规则; - 使用
REQUIRES_NEW,可能破坏原来的原子性; - 合并所有
@MockBean,可能让测试上下文变得过重; - 动态切数据源,如果没有
finally清理 ThreadLocal,会制造更严重的问题; - 为了方便使用奇怪的 Spring 隐式写法,可能在编译参数或依赖变化后失效。
工程上的修复标准应该是:
1 | |
十、工程实践 Checklist
Spring Data
- Redis 跨服务读写前,统一 key/value/hash 的序列化协议。
- 不要把 Java 对象类型当成存储协议,确认最终字节格式。
- 新中间件上线前审查 driver/framework 的默认一致性、超时、重试等配置。
- 堆中出现重复 Metadata/Client/Session 时,检查配置继承与 Bean 是否重复创建。
- 自定义框架 Bean 时先查父配置和自动配置中是否已经定义同类型 Bean。
Spring Transaction
- 明确
@Transactional的代理边界,不要依赖 private/self-invocation。 - 关键业务显式声明 rollback policy,不要只凭默认规则。
catch住异常前先理解事务是否已经被标记 rollback-only。- 使用
REQUIRES_NEW前分析独立提交可能带来的数据一致性后果。 - 多数据源下确认事务获取 Connection 之前已经完成数据源选择。
HTTP 调用
- Form 使用
MultiValueMap,不要默认认为普通Map会发送表单。 - 遇到 HTTP 参数异常先抓包,看实际 Content-Type、URL 与 Body。
- Query value 中出现
#、&、=、空格、中文等字符时,明确 URI 构造与编码流程。 - 避免“先编码成 String,再交给客户端重新编码”的双重编码链。
- 能传
URI时,尽量让 URI 的编码状态保持明确。
Spring Test
- classpath 资源显式使用
classpath:前缀表达语义。 - 测试变慢时先确认是不是 Context Cache miss,而不是只盯单个测试方法执行时间。
- 比较
MergedContextConfiguration,尤其关注 profiles、properties、customizers 和 Mock 配置。 - 能用纯单元测试解决的问题,不要默认启动完整
@SpringBootTest。
十一、从“Spring 黑盒”走向可解释的工程系统
Spring 的便利来自大量自动行为:自动配置、条件 Bean、AOP 代理、类型转换、资源抽象、事务传播、上下文缓存。只要项目足够简单,这些机制都可以安静地待在幕后;一旦进入多服务、多数据源、复杂事务和大型测试套件,它们就会从“便利”变成必须理解的工程约束。
真正稳定的 Spring 开发方式,不是记住某个报错该复制哪段代码,而是形成几个固定动作:
1 | |
问题的表象可以无限多,但框架作出决策的地方是有限的。一旦能把 Redis Serializer、TransactionInterceptor、Propagation、AbstractRoutingDataSource、HttpMessageConverter、ResourceLoader、MergedContextConfiguration 这些关键节点串成一张知识网络,Spring 就不再是一个“出了问题只能搜索报错”的黑盒,而会变成一套可以被分析、验证和预测的系统。