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
2
3
4
5
6
7
8
9
String key = "mykey";

stringRedisTemplate.opsForValue().set(key, "myvalue");

Object value1 = stringRedisTemplate.opsForValue().get(key);
System.out.println(value1); // myvalue

Object value2 = redisTemplate.opsForValue().get(key);
System.out.println(value2); // null

表面上看,两次读取使用的 key 都是 mykey,但 Redis 实际收到的并不是 Java 的 String 对象,而是序列化后的字节数组。

Spring Data Redis 在执行命令前会把 key 转换成 raw key,逻辑可以简化理解为:

1
2
3
4
5
6
byte[] rawKey(Object key) {
if (keySerializer() == null && key instanceof byte[]) {
return (byte[]) key;
}
return keySerializer().serialize(key);
}

StringRedisTemplate 默认针对 key/value/hash key/hash value 使用字符串序列化器:

1
2
3
4
5
6
7
8
9
public class StringRedisTemplate extends RedisTemplate<String, String> {

public StringRedisTemplate() {
setKeySerializer(RedisSerializer.string());
setValueSerializer(RedisSerializer.string());
setHashKeySerializer(RedisSerializer.string());
setHashValueSerializer(RedisSerializer.string());
}
}

字符串序列化本质上就是按照字符集转成字节:

1
2
3
public byte[] serialize(String value) {
return value == null ? null : value.getBytes(StandardCharsets.UTF_8);
}

而普通 RedisTemplate<Object, Object> 在资料所用版本的默认配置下会使用 JDK 序列化。于是:

1
2
"mykey" --StringRedisSerializer--> 一组 UTF-8 字节
"mykey" --JdkSerializationRedisSerializer--> 另一组带 Java 序列化格式的字节

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
public class RedisAutoConfiguration {

@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
@ConditionalOnSingleCandidate(RedisConnectionFactory.class)
public RedisTemplate<Object, Object> redisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}

@Bean
@ConditionalOnMissingBean
@ConditionalOnSingleCandidate(RedisConnectionFactory.class)
public StringRedisTemplate stringRedisTemplate(
RedisConnectionFactory connectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(connectionFactory);
return template;
}
}

在资料对应的 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
2
3
basic.request {
consistency = LOCAL_ONE
}

如果复制因子为 3,可以用一个简化模型理解:

1
2
3
N = 副本数
W = 写操作需要确认的副本数
R = 读操作需要读取的副本数

资料用 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
2
3
4
5
6
7
@Bean
public DriverConfigLoaderBuilderCustomizer driverConfigLoaderBuilderCustomizer() {
return loaderBuilder -> loaderBuilder.withString(
REQUEST_CONSISTENCY,
ConsistencyLevel.LOCAL_QUORUM.name()
);
}

这里最重要的不是背住 LOCAL_ONELOCAL_QUORUM,而是形成一个习惯:

框架或驱动的默认值通常优先照顾“开箱即用”,生产配置必须结合拓扑、复制策略、延迟目标和一致性要求重新审视。

数据库连接池大小、超时、重试、隔离级别、批量大小、序列化方式,也都属于同一类问题。


2.3 Cassandra Session:自定义 Bean 时要警惕“看起来覆盖,实际上新增”

另一个非常有代表性的错误,是内存里出现多份 Cassandra Metadata。

Spring Data Cassandra 建立 Session 后会加载集群 Metadata,其中包括 Token Range 等信息。资料中的示例里,一份 Metadata 已经达到 40 MB 量级,而堆里却出现了四份相似的大对象。

根因来自配置继承关系。

父配置定义:

1
2
3
4
5
6
7
8
@Configuration
public class BaseConfig {

@Bean
public MyService service() {
return new MyService("from base config");
}
}

子配置如果使用同名方法覆盖:

1
2
3
4
5
6
7
8
9
@Configuration
public class Config extends BaseConfig {

@Bean
@Override
public MyService service() {
return new MyService("from config");
}
}

逻辑仍然只有一个 Bean。

但如果子类不小心换了方法名:

1
2
3
4
@Bean
public MyService service2() {
return new MyService("from config");
}

此时不是“覆盖父配置”,而是新增了另一个 Bean。

Cassandra 案例中,父类已经定义:

1
2
3
4
@Bean
public CqlSessionFactoryBean cassandraSession() {
// ...
}

自定义配置却写成:

1
2
3
4
5
@Bean
@Primary
public CqlSessionFactoryBean session() {
// ...
}

方法名不同,父类 Bean 仍然存在,因此会产生额外的 CqlSessionFactoryBean

修正时应真正覆盖父类定义:

1
2
3
4
5
6
@Bean
@Primary
@Override
public CqlSessionFactoryBean cassandraSession() {
// 自定义配置
}

此外,资料中的 CqlSessionFactoryBean#afterPropertiesSet() 本身还会创建 system session 和 normal session:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void afterPropertiesSet() {
CqlSessionBuilder sessionBuilder = buildBuilder();

this.systemSession = buildSystemSession(sessionBuilder);
initializeCluster(this.systemSession);

this.session = buildSession(sessionBuilder);
executeCql(getStartupScripts().stream(), this.session);
performSchemaAction();

this.systemSession.refreshSchema();
this.session.refreshSchema();
}

所以一旦你重复创建了 CqlSessionFactoryBean,Metadata 的数量可能不只是“多一份”,而是成倍增长。

这个案例背后还有一个通用经验:

当堆中出现多份结构几乎相同的大对象时,不要第一时间怀疑 GC。先确认创建它们的 Bean、Factory、Session、Client 是否重复实例化。


三、Spring 声明式事务:核心不是 @Transactional,而是 Proxy + TransactionInterceptor

很多事务问题之所以难理解,是因为代码表面看起来只有一个注解:

1
2
3
4
@Transactional
public void save() {
// ...
}

但运行时真正执行的是:

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

理解事务问题时,最关键的三个对象是:

  • TransactionInterceptor
  • TransactionAspectSupport
  • PlatformTransactionManager

@Transactional 只是告诉 Spring:这个方法应该被事务 Advisor 匹配,并生成相应的事务属性。


3.1 为什么抛出 Exception 却没有回滚

一个常见误区是:

“只要方法抛异常,Spring 就一定回滚。”

并不是。

资料中的业务代码大致如下:

1
2
3
4
5
6
7
8
9
10
@Transactional
public void saveStudent(String realname) throws Exception {
Student student = new Student();
student.setRealname(realname);
studentMapper.saveStudent(student);

if ("小明".equals(realname)) {
throw new Exception("该学生已存在");
}
}

异常确实抛出了,但插入记录仍然提交。

事务异常处理最终会进入类似下面的判断:

1
2
3
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}

也就是说,在没有额外 rollback rule 的情况下,资料对应版本的默认策略是:

  • RuntimeException:回滚;
  • Error:回滚;
  • 普通 checked Exception:默认不回滚。

如果业务要求 checked Exception 也回滚,应显式声明:

1
2
3
4
@Transactional(rollbackFor = Exception.class)
public void saveStudent(String realname) throws Exception {
// ...
}

如果需求更细,例如“Exception 回滚,但 RuntimeException 不回滚”,可以同时配置正向和反向规则:

1
2
3
4
5
6
7
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = RuntimeException.class
)
public void saveStudent(Student student) throws Exception {
// ...
}

这里的工程原则非常明确:事务回滚策略属于业务语义,不要依赖团队成员对默认值的记忆。 对关键业务,最好让规则在注解或统一事务规范里显式可见。


3.2 为什么 private 方法上的 @Transactional 不生效

资料所用 Spring 5.3.x 场景中,注解事务属性解析存在这样的判断:

1
2
3
4
if (allowPublicMethodsOnly()
&& !Modifier.isPublic(method.getModifiers())) {
return null;
}

AnnotationTransactionAttributeSource 的默认配置使 publicMethodsOnlytrue,所以 private 方法不会被事务 Advisor 当作可代理事务方法。

即使先不讨论这个判断,private 方法本身也不适合作为基于代理的 AOP 连接点。

错误示例:

1
2
3
4
5
6
7
8
9
10
11
public void saveStudent(String realname) throws Exception {
Student student = new Student();
student.setRealname(realname);
doSaveStudent(student);
}

@Transactional
private void doSaveStudent(Student student) throws Exception {
studentMapper.saveStudent(student);
throw new RuntimeException("保存失败");
}

应该把事务边界设计在可被 Spring Proxy 调用的方法上:

1
2
3
4
5
@Transactional(rollbackFor = Exception.class)
public void doSaveStudent(Student student) throws Exception {
studentMapper.saveStudent(student);
// ...
}

但还有第二层问题:方法是 public,也不代表一定经过代理。


3.3 Self Invocation:同一个类里 this.xxx() 为什么绕过事务

假设:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class StudentService {

public void saveStudent(String name) {
this.doSaveStudent(name);
}

@Transactional
public void doSaveStudent(String name) {
// DB 操作
}
}

外部调用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class StudentRegistrationService {

private final StudentPersistenceService persistenceService;

public StudentRegistrationService(StudentPersistenceService persistenceService) {
this.persistenceService = persistenceService;
}

public void register(String name) {
persistenceService.saveStudent(name);
}
}

@Service
public class StudentPersistenceService {

@Transactional
public void saveStudent(String name) {
// DB 操作
}
}

这通常也比“注入自己再调自己”更容易维护,因为事务边界与业务职责同时变得清晰。


四、事务传播:为什么 catch 住异常,外层事务还是回滚了

事务传播是 Spring 声明式事务中最容易“代码看起来正确,结果却很意外”的部分之一。

资料中的业务关系可以抽象为:

1
2
3
4
5
6
7
8
9
10
@Transactional(rollbackFor = Exception.class)
public void saveStudent(String name) {
saveStudentRecord();

try {
courseService.regCourse();
} catch (Exception e) {
// 希望只忽略选课失败
}
}

内层:

1
2
3
4
5
6
@Transactional(rollbackFor = Exception.class)
public void regCourse() throws Exception {
saveStudentCourse();
increaseCourseNumber();
throw new Exception("注册失败");
}

开发者期望:

1
2
学生保存成功
课程注册失败并回滚

实际却得到 UnexpectedRollbackException,最终整个事务都回滚。

4.1 REQUIRED:逻辑上两层,物理上同一个事务

Spring 默认传播行为是 Propagation.REQUIRED

  • 当前没有事务:创建一个;
  • 当前已有事务:加入已有事务。

因此:

1
2
3
saveStudent()     ┐
├── 同一个物理事务 / 同一个 Connection 上下文
regCourse() ┘

内层 regCourse() 抛出符合 rollback rule 的异常后,Spring 会把当前共享事务标记为 rollback-only。

外层代码虽然 catch 了 Java 异常,但它只阻止了异常继续传播,没有取消事务已经写下的 rollback-only 标记

外层方法正常返回后,Spring 尝试提交:

1
2
3
4
5
if (!shouldCommitOnGlobalRollbackOnly()
&& status.isGlobalRollbackOnly()) {
processRollback(status, true);
return;
}

于是最终仍然回滚,并抛出类似:

1
Transaction rolled back because it has been marked as rollback-only

整个过程可以画成:

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
2
3
4
5
6
7
@Transactional(
rollbackFor = Exception.class,
propagation = Propagation.REQUIRES_NEW
)
public void regCourse(int studentId) throws Exception {
// ...
}

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 不是“解决嵌套事务的万能开关”。它会改变业务原子性。

例如:

  1. 内层选课事务先成功提交;
  2. 外层学生事务随后失败;
  3. 最终可能留下没有学生主记录的选课数据。

所以传播行为必须从业务一致性出发选择,而不能只为了让异常“别往外影响”。

4.3 七种传播行为速查

资料列出了 Spring 的七种传播行为。结合其标准语义,可以这样理解:

Propagation 当前有事务 当前无事务 典型语义
REQUIRED 加入 新建 默认,大多数单库业务
SUPPORTS 加入 非事务执行 有事务就参与,没有也可以
MANDATORY 加入 抛异常 强制调用方先开启事务
REQUIRES_NEW 挂起原事务,新建 新建 独立提交/回滚
NOT_SUPPORTED 挂起原事务 非事务执行 明确要求无事务
NEVER 抛异常 非事务执行 禁止在事务中调用
NESTED 使用嵌套事务/保存点(依赖事务管理器支持) 通常按新事务处理 需要部分回滚且保持外层事务结构

真正重要的是:传播行为描述的是“当前方法的事务应该如何与已有事务发生关系”,不是简单的开关。


五、多数据源:切换 DataSource 的真正时机必须早于事务获取 Connection

多数据源场景经常会设计一个类似这样的注解:

1
2
3
4
5
@DataSource("card")
@Transactional
public void createCard(int studentId) {
// 写 card 库
}

最容易忽略的问题是:@DataSource@Transactional 都依赖 AOP,它们的执行顺序会直接决定最后使用哪个数据库连接。

5.1 AbstractRoutingDataSource 的核心模型

Spring 提供 AbstractRoutingDataSource 作为动态数据源路由抽象。

关键数据结构可以简化成:

1
2
3
4
5
6
targetDataSources:
"core" -> CoreDataSource
"card" -> CardDataSource

defaultTargetDataSource:
CoreDataSource

获取连接时:

1
2
3
4
@Override
public Connection getConnection() throws SQLException {
return determineTargetDataSource().getConnection();
}

目标 DataSource 由 lookup key 决定:

1
2
3
4
5
6
protected DataSource determineTargetDataSource() {
Object lookupKey = determineCurrentLookupKey();
DataSource dataSource = resolvedDataSources.get(lookupKey);
// fallback ...
return dataSource;
}

自定义实现通常会借助 ThreadLocal 保存当前线程的数据源 key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MyRoutingDataSource extends AbstractRoutingDataSource {

private static final ThreadLocal<String> KEY = new ThreadLocal<>();

@Override
protected Object determineCurrentLookupKey() {
return KEY.get();
}

public static void use(String dataSource) {
KEY.set(dataSource);
}

public static void clear() {
KEY.remove();
}
}

5.2 为什么 AOP 顺序决定是否切换成功

事务真正开始时,DataSourceTransactionManager#doBegin() 会获取 Connection:

1
Connection newCon = obtainDataSource().getConnection();

obtainDataSource().getConnection() 最终会进入 AbstractRoutingDataSource

因此顺序必须是:

1
2
3
4
5
6
7
先设置 ThreadLocal lookup key

再进入事务拦截器

TransactionManager 获取 Connection

RoutingDataSource 根据 key 选择真实 DataSource

如果事务已经先绑定了一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Aspect
@Component
@Order(1)
public class DataSourceSwitchAspect {

@Around("@annotation(dataSource)")
public Object around(
ProceedingJoinPoint point,
DataSource dataSource) throws Throwable {

MyRoutingDataSource.use(dataSource.value());
try {
return point.proceed();
} finally {
MyRoutingDataSource.clear();
}
}
}

否则在线程池环境中,线程被复用后可能把上一次请求的数据源选择“带到”下一次请求,产生极其危险的跨库访问。

5.4 为什么多数据源场景经常配合 REQUIRES_NEW

如果外层事务已经从 core 数据源获取并绑定了 Connection,内层仍使用默认 REQUIRED,它会尝试参与已有事务,导致路由切换没有机会创建一套新的事务资源。

资料因此在 card 服务上使用:

1
2
3
4
5
@Transactional(propagation = Propagation.REQUIRES_NEW)
@DataSource("card")
public void createCard(int studentId) {
// ...
}

这样内层建立新事务时,会重新获取 Connection,路由数据源才有机会根据新的 lookup key 选择 card 库。

需要特别区分两个概念:

  • 动态路由数据源解决“这次获取 Connection 应该去哪个库”;
  • 事务管理器与事务传播解决“Connection 如何绑定到当前事务、何时提交/回滚”。

二者不是同一个问题。

另外,资料示例使用 DriverManagerDataSource 是为了简化演示。真实高并发服务通常应使用连接池 DataSource,并结合项目实际架构决定是动态路由、多个 TransactionManager,还是更明确的数据访问边界。


六、RestTemplate:真正控制 HTTP 请求格式的是 HttpMessageConverter 与 URI 编码链

RestTemplate 的几个案例表面上分别属于“表单”“特殊字符”“中文编码”,其实可以归成两个核心问题:

  1. Body 到底由哪个 HttpMessageConverter 写出;
  2. URI 在整个调用链上到底被编码了几次。

6.1 为什么 HashMap 表单最后变成了 JSON

服务端接口:

1
2
3
4
5
6
@PostMapping("/hi")
public String hi(
@RequestParam("para1") String para1,
@RequestParam("para2") String para2) {
return "helloworld:" + para1 + "," + para2;
}

客户端错误写法:

1
2
3
4
5
6
7
8
9
10
11
RestTemplate template = new RestTemplate();

Map<String, Object> params = new HashMap<>();
params.put("para1", "001");
params.put("para2", "002");

String result = template.postForObject(
"http://localhost:8080/hi",
params,
String.class
);

抓包后看到的却是:

1
2
3
Content-Type: application/json

{"para1":"001","para2":"002"}

因此服务端的 @RequestParam 找不到表单参数。

根因:RestTemplate 在遍历 HttpMessageConverter

请求发送阶段会根据:

  • request body 的 Java 类型;
  • Content-Type;
  • converter 的 canWrite(...)

选择第一个合适的 HttpMessageConverter

HashMap 可以被 Jackson 序列化,因此 JSON converter 可以写它。

FormHttpMessageConverter 的关键条件是:

1
2
3
if (!MultiValueMap.class.isAssignableFrom(clazz)) {
return false;
}

也就是说,普通 Map 并不会自动等价于“表单”。

正确做法:

1
2
3
4
5
6
7
8
9
MultiValueMap<String, Object> params = new LinkedMultiValueMap<>();
params.add("para1", "001");
params.add("para2", "002");

String result = template.postForObject(
"http://localhost:8080/hi",
params,
String.class
);

请求会变为:

1
2
3
Content-Type: application/x-www-form-urlencoded

para1=001&para2=002

这与 Redis 序列化问题其实有相似结构:

1
2
3
4
5
6
7
Java 对象类型

Spring 根据规则选择策略

策略转换成 wire format

服务端/存储端按 wire format 解释

如果只盯着 Java 变量 Map,很容易忽略最终在网络上传输的东西完全不同。


6.2 @RequestParam 为什么既能接 Query,也能接 Form

从 Spring MVC API 视角看,@RequestParam 不仅用于 URL Query,也可以处理表单参数。

在资料所用 Tomcat 调用链里,两类数据最终都会汇总到 Servlet request parameters 中:

1
2
3
Query String -----------------┐
├--> Parameters --> @RequestParam
application/x-www-form-urlencoded ┘

因此服务端接口可以保持:

1
public String hi(@RequestParam("para1") String para1)

客户端既可以:

1
GET /hi?para1=001

也可以用表单提交:

1
2
3
Content-Type: application/x-www-form-urlencoded

para1=001

这也再次说明:服务端注解名称相同,不代表客户端 wire format 相同。


6.3 URL 中的 # 为什么会“丢失”

假设调用:

1
http://localhost:8080/hi?para1=1#2

开发者可能认为参数值是:

1
para1 = 1#2

但 URI 语义中 # 开始的是 Fragment:

1
scheme://host/path?query#fragment

因此解析后实际上更接近:

1
2
query:    para1=1
fragment: 2

服务端自然只能收到:

1
para1=1

如果 # 是参数值的一部分,它必须作为 query value 被正确编码,而不是把完整字符串直接当作“已经合法的 URL”处理。

资料使用 UriComponentsBuilder 构造并编码 URI:

1
2
3
4
5
6
7
8
9
10
11
String url = "http://localhost:8080/hi?para1=1#2";

UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
URI uri = builder.build().encode().toUri();

HttpEntity<String> response = restTemplate.exchange(
uri,
HttpMethod.GET,
new HttpEntity<>(null),
String.class
);

这个案例真正应该记住的是:

特殊字符问题必须从 URI 结构和编码阶段分析,不要把它当成“Spring 把字符吃了”。


6.4 双重 URL Encoding:encode 一次是编码,encode 两次是数据污染

另一个典型错误:

1
2
3
4
5
6
7
8
UriComponentsBuilder builder =
UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hi");

builder.queryParam("para1", "开发测试001");

String url = builder.toUriString();
ResponseEntity<String> response =
restTemplate.getForEntity(url, String.class);

问题在于:

  1. toUriString() 已经进行了编码;
  2. 把编码后的结果再次作为 String URL 交给 RestTemplate;
  3. RestTemplate 的 URI 构造链又编码一次。

第一次:

1
2
3
开发测试001
↓ encode
%E5%BC%80%E5%8F%91...

第二次:

1
2
3
% 也再次被编码为 %25

%25E5%25BC%2580...

最终服务端得到的是“编码字符串本身”,而不是原始中文。

更稳妥的做法是明确“在哪一步完成唯一一次编码”,然后直接传递 URI

1
2
3
4
5
6
7
8
9
10
11
RestTemplate restTemplate = new RestTemplate();

URI uri = UriComponentsBuilder
.fromHttpUrl("http://localhost:8080/hi")
.queryParam("para1", "开发测试001")
.encode()
.build()
.toUri();

ResponseEntity<String> response =
restTemplate.getForEntity(uri, String.class);

调试 URI 问题时,不要只打印最终字符串。最好逐阶段观察:

1
2
3
4
5
原始值
→ builder 内部 query param
→ encode 后的 UriComponents
→ URI
→ 实际抓包请求行

一旦知道“哪一步发生第一次编码”,双重编码问题通常很快就能定位。


七、Spring Test:测试慢、资源找不到,本质上都是 TestContext 的运行环境与你想象的不一样

Spring Test 很容易制造一种错觉:测试代码看起来只是“启动应用然后调用一下 Bean”。实际上,测试框架会构建自己的 ApplicationContext、ResourceLoader、Context Cache key 和 ContextCustomizer。


7.1 为什么 @ImportResource("spring.xml") 应用能跑,测试却失败

配置:

1
2
3
4
@Configuration
@ImportResource(locations = {"spring.xml"})
public class Config {
}

spring.xml 位于:

1
src/main/resources/spring.xml

在普通应用启动时,资料中的调用链最终把它当作 ClassPathResource,因此能够从 classpath 找到。

但在某种 @SpringBootTest Web 测试上下文里,同样的相对 location 可能进入 ServletContextResource 加载逻辑。此时搜索位置更接近 Web resource 目录,例如:

1
2
3
4
5
classpath:META-INF/resources
classpath:resources
classpath:static
classpath:public
src/main/webapp

而编译后的 src/main/resources/spring.xml 实际通常位于:

1
target/classes/spring.xml

并不是:

1
target/classes/resources/spring.xml

于是测试环境找不到文件。

更明确的写法是:

1
2
3
4
@Configuration
@ImportResource("classpath:spring.xml")
public class Config {
}

DefaultResourceLoader#getResource() 的设计可以解释为什么一个前缀会改变整个加载路径:

1
2
3
4
5
6
7
8
9
10
if (location.startsWith("classpath:")) {
return new ClassPathResource(...);
}

try {
URL url = new URL(location);
// ...
} catch (MalformedURLException ex) {
return getResourceByPath(location);
}

相对路径最后会落到当前 ApplicationContextgetResourceByPath() 的具体实现;显式 classpath: 则直接锁定到 ClassPathResource

资源加载的工程原则

如果资源的语义就是“classpath 上的配置”,就把语义写出来:

1
classpath:xxx

不要依赖“在我当前启动方式下,相对路径刚好能工作”。


7.2 为什么 @MockBean 会让 Spring Context 重建

Spring Test 为了提高速度,会缓存 ApplicationContext。

核心流程可以简化为:

1
2
3
4
5
6
ApplicationContext context = contextCache.get(mergedContextConfiguration);

if (context == null) {
context = loadContextInternal(mergedContextConfiguration);
contextCache.put(mergedContextConfiguration, context);
}

缓存 key 是 MergedContextConfiguration

其 hash/equality 会受到多项配置影响,例如:

1
2
3
4
5
6
7
8
9
10
locations
classes
contextInitializerClasses
activeProfiles
propertySourceLocations
propertySourceProperties
contextCustomizers
parent
contextLoader
...

两个测试分别写:

1
2
3
4
5
@SpringBootTest
class ServiceOneTests {
@MockBean
ServiceOne serviceOne;
}
1
2
3
4
5
@SpringBootTest
class ServiceTwoTests {
@MockBean
ServiceTwo serviceTwo;
}

由于 @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
2
3
4
5
6
7
8
public abstract class ServiceTests {

@MockBean
protected ServiceOne serviceOne;

@MockBean
protected ServiceTwo serviceTwo;
}
1
2
3
4
@SpringBootTest
class ServiceOneTests extends ServiceTests {
// ...
}
1
2
3
4
@SpringBootTest
class ServiceTwoTests extends ServiceTests {
// ...
}

这使两者更可能得到相同的 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
2
3
当前对象实际 runtime type 是什么?
Spring 在遍历哪些 strategy?
哪个 canWrite / supports / matches 最先返回 true?

8.2 Annotation 只是元数据,真正执行的是基础设施 Bean

典型案例:

  • @Transactional → TransactionAttribute → Advisor → Proxy → TransactionInterceptor;
  • @DataSource → 自定义 Aspect → ThreadLocal lookup key;
  • @MockBean → ContextCustomizer → Context Cache key 变化。

所以看到注解不生效时,应该继续追:

1
2
3
4
谁扫描它?
谁解析它?
谁根据它创建了什么对象?
运行时调用有没有经过这个对象?

8.3 默认值属于“框架的默认假设”,不是你的业务需求

典型案例:

  • Cassandra LOCAL_ONE
  • Spring Transaction 默认 rollback rule;
  • Transaction propagation 默认 REQUIRED

每次引入中间件或框架,都值得做一次“effective configuration review”:

1
2
3
4
5
6
7
默认超时是多少?
默认重试几次?
默认一致性级别是什么?
默认连接池大小是多少?
默认事务传播是什么?
默认序列化器是什么?
默认缓存是否开启?

生产事故里,“我以为默认值应该是……”往往是一句危险的话。

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
2
3
4
5
6
一个 Controller
一个 Service
一个事务方法
一张表
一个 Redis key
一个 HTTP 请求

如果最小案例仍能复现,问题边界会瞬间清晰很多。

如果最小案例不能复现,那么差异本身就是线索:

  • 依赖差异;
  • 配置差异;
  • 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
2
3
4
5
6
7
8
9
10
11
12
我以为只有一个 Session
→ 实际有 systemSession + session
→ 又因为 Bean 重复定义变成两套

我以为只有一个 Spring Context
→ 实际不同 Mock 生成了不同 cache key

我以为是一个事务
→ REQUIRES_NEW 后已经是两个事务

我以为这是 Form
→ runtime body type 是 HashMap,最后走 JSON converter

因此 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
2
3
4
问题消失
+ 机制解释得通
+ 边界条件可验证
+ 没有引入更坏的一致性/性能/可维护性问题

十、工程实践 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
2
3
4
5
6
先确认实际行为,而不是相信直觉;
先检查默认值,而不是默认它适合生产;
先确认代理边界,而不是看到注解就认为生效;
先找到策略决策点,再沿源码上下追踪;
先构造最小复现,再回到复杂业务系统;
修复后检查一致性、性能和生命周期副作用。

问题的表象可以无限多,但框架作出决策的地方是有限的。一旦能把 Redis Serializer、TransactionInterceptor、Propagation、AbstractRoutingDataSource、HttpMessageConverter、ResourceLoader、MergedContextConfiguration 这些关键节点串成一张知识网络,Spring 就不再是一个“出了问题只能搜索报错”的黑盒,而会变成一套可以被分析、验证和预测的系统。


Spring 工程实践中的隐式规则:数据访问、事务、HTTP 调用与测试的常见陷阱
https://allendericdalexander.github.io/2026/08/12/geeker/144/3spring-engineering-hidden-rules-and-pitfalls/
作者
AtLuoFu
发布于
2026年8月12日
许可协议