Spring Data 其他模块与 Session:五套映射、连接与事务边界
同一条"保存"在五套 Spring Data 模块里做的不是同一件事:Redis 写哈希加索引 set,Mongo 写文档并附加 _class,Data JDBC 立即发 INSERT、没有会话与脏检查,R2DBC 返回 Mono/Flux,Elasticsearch 走 REST 客户端且客户端与服务端版本必须同线。Spring Session 则是另一层:它把 HttpSession 的存储从容器搬到 Redis,但序列化、过期与并发写都有各自的真实边界。本页固定基线:Spring Data BOM 2025.1.8(Redis 4.0.8 / MongoDB 5.0.8 / Elasticsearch 6.0.8 / JDBC 4.0.8 / R2DBC 4.0.8)、Spring Session 4.0.5、Boot 4.0.9、Framework 7.0.10、JDK 21;服务端为本机 Redis 8.6.4 / MongoDB 7.0.45 / MySQL 8.4.7 / ES 8.19.23。可运行结论来自 python3 labs/spring-data-session/run.py 的一次完整运行(主轮 12 个测试 exit 0,ES 轮如实受阻)。本页是 U44(#81)的权威正文。
本页不重复:Boot 集成条件讲 RedisTemplate/Session starter 的自动配置退让;WebFlux/R2DBC讲 ConnectionFactory 与响应式事务;引擎机制(Redis 数据结构、Mongo 文档模型、ES 倒排索引)归对应课程。JPA/Hibernate 归 U43。
五套模块的映射与查询:同一动作,不同事实
先把"看起来一样的 CRUD"拆成每套模块真实做的事:
| 模块 | 持久化语义 | "保存"之后立刻发生什么 | 查询能力 | 版本 |
|---|---|---|---|---|
| Spring Data Redis | 键值(哈希 + 索引 set) | HSET 写字段、SADD 进索引、EXPIRE 设 TTL | 只能按 id 或索引 set 取;没有 SQL 派生 | 4.0.8 |
| Spring Data MongoDB | 文档 | insert/replace 一条文档,附加 _class | 方法名派生 + @Query BSON | 5.0.8 |
| Spring Data JDBC | 关系表,无会话 | 立即 INSERT/UPDATE,返回已带 id 的实例 | 派生 + @Query 原生 SQL | 4.0.8 |
| Spring Data R2DBC | 关系表,响应式 | save 返回 Mono<T>,订阅才执行 | 响应式派生 + @Query | 4.0.8 |
| Spring Data Elasticsearch | 倒排索引 | HTTP PUT /index/_doc(本基线跨线受阻) | 派生 + @Query | 6.0.8 |
实测(主轮 12 个测试,同一套实体与库):
U44 redisRepository hash=u44:cart:<uuid> ttlSeconds=60:@RedisHash("u44:cart")产生哈希键u44:cart:<id>,@TimeToLive(unit=SECONDS)让 TTL 在保存时写入。U44 mongoDerived size=2 count=2 audienceIndex=true:findByAudienceOrderByCreatedAtAsc走派生查询;@Indexed在auto-index-creation=true下真实创建(Spring Data MongoDB 3.x 起默认关闭自动建索引,不配就查不到索引)。U44 mongoDocument classField=true nativeCount=1:文档带_class=study.datasession.Greeting,原生驱动按 BSON 查询可见。U44 jdbcDerived byAuthor=1 prefix=1:Data JDBC 派生方法与@Query都可用;MySQL 的||是逻辑或,字符串拼接必须concat(:p, '%')——写 SQL 逻辑或的方言坑在 Spring Data 层不会替你兜底。U44 jdbcNoSession immediateInsert=true silentChangeIgnored=true:repository.save返回时 INSERT 已执行(查库为 1);随后改对象字段不调用 save,库里不变——Data JDBC 没有 JPA 的持久化上下文与脏检查,与 U43 的 merge/flush 语义是两套模型。U44 r2dbcRepository id=2 count=1、U44 r2dbcSharedDb reactiveSeesOwnWrite=true:R2DBC repository 的save返回Mono,block()后才执行;DatabaseClient用同一连接工厂看到刚写入的行。
多 store 共存:两个真实冲突
同一应用里装多个 Spring Data 模块时,Boot 4 暴露两个冲突,本实验都真实撞到并修正:
- DataSource 与 ConnectionFactory 互斥。Boot 4 的
DataSourceAutoConfiguration带@ConditionalOnMissingBean(ConnectionFactory);一旦 R2DBC starter 在场,JDBC 侧DataSource、JdbcTemplate、事务管理器全部消失,@ConditionalOnBean(JdbcOperations, PlatformTransactionManager)的 Data JDBC 仓库自动配置随之失效。本实验用@ActiveProfiles("r2dbc")拆分出独立的 R2DBC 上下文,主上下文排除 R2DBC 自动配置。 - repository 扫描按 store 抢类。Spring Data 各模块默认扫描整个应用包,看到接口就尝试认领:JDBC 扩展遇到
R2dbcRepository子接口时报Reactive Repositories are not supported by JDBC;Mongo/Redis 扩展则对每个接口刷 "Could not safely identify store assignment"。解决办法是把不同 store 的实体与接口放进独立包,用@EnableJdbcRepositories(basePackages=...)/@EnableR2dbcRepositories(basePackages=...)显式限定,而不是靠全包扫描。
这两条不是配置瑕疵,是多 store 架构的显式边界:装配顺序、候选 Bean 与扫描范围都要写清楚,否则一个新增 starter 就能把另一条数据路径整条关掉。
Spring Data Redis:序列化与 TTL 的真实语义
RedisTemplate 的默认序列化不是"字符串",而是 JDK 序列化,而且 key 和 value 一起。源码里 afterPropertiesSet() 在未显式配置时把所有序列化器都兜底为 JdkSerializationRedisSerializer:
@Override
public void afterPropertiesSet() {
super.afterPropertiesSet();
if (defaultSerializer == null) {
defaultSerializer = new JdkSerializationRedisSerializer(
classLoader != null ? classLoader : this.getClass().getClassLoader());
}
if (enableDefaultSerializer) {
if (keySerializer == null) {
keySerializer = defaultSerializer;
}
if (valueSerializer == null) {
valueSerializer = defaultSerializer;
}
if (hashKeySerializer == null) {
hashKeySerializer = defaultSerializer;
}
if (hashValueSerializer == null) {
hashValueSerializer = defaultSerializer;
}实测后果(U44 redisSerialization plainKeyLookup=false roundTripClass=List12 binaryKeyVisible=false):
- 用
RedisTemplate<Object,Object>存List.of("a","b"),再用StringRedisTemplate按明文 key 查,拿到 null——不是值读不出,是 key 本身被 JDK 序列化成二进制,明文 key 根本不存在。 KEYS u44:obj:*也扫不到这条记录,运维排查"数据去哪了"时会以为没写进去。- 用同一个
RedisTemplate读回,类型信息完整(List12,JDK 不可变列表的类名),所以"存进去能读出来"不等于"别人能读"。
正确做法按场景选:跨语言/可观测的键值用 StringRedisTemplate 或显式 StringRedisSerializer 做 key;RedisTemplate<String,Object> 至少把 key 换字符串序列化、value 用 JSON;只在 Java 内部闭环、且能接受类版本耦合时才留 JDK。TTL 是独立的:@TimeToLive 字段与 stringRedisTemplate.opsForValue().set(k,v,Duration) 都在真实 Redis 上设为 60/30 秒并观测到剩余 TTL。
Spring Session:存储、序列化、过期与并发
Spring Session 把会话从容器内存搬到 Redis。本实验用 repository-type=default(即 RedisSessionRepository,非 indexed),直接注入 SessionRepository<Session> 做存取,绕开登录流程只验证存储层语义。
存储结构与会话键
会话在 Redis 里是一个哈希:键是 namespace + "sessions:" + sessionId,字段是属性映射。源码清楚给出键构造与查找/过期判定:
@Override
public RedisSession findById(String sessionId) {
String key = getSessionKey(sessionId);
Map<String, Object> entries = this.sessionRedisOperations.<String, Object>opsForHash().entries(key);
if (entries.isEmpty()) {
return null;
}
MapSession session = this.redisSessionMapper.apply(sessionId, entries);
if (session == null || session.isExpired()) {
deleteById(sessionId);
return null;
}
return new RedisSession(session, false);
}
@Override
public void deleteById(String sessionId) {
String key = getSessionKey(sessionId);
this.sessionRedisOperations.delete(key);
}
/**
* Returns the {@link RedisOperations} used for sessions.
* @return the {@link RedisOperations} used for sessions
*/
public RedisOperations<String, Object> getSessionRedisOperations() {
return this.sessionRedisOperations;
}
private String getSessionKey(String sessionId) {
return this.keyNamespace + "sessions:" + sessionId;
}配置里 spring.session.data.redis.namespace=u44:session 让键落在 u44:session:sessions:<id>,只有本实验的键会被清理(不 FLUSHDB)。实测:
U44 sessionStored redis=true attrRoundTrip=true ttl=90s:Redis 里能扫到包含该会话 id 的键;重新findById后属性一致、maxInactiveInterval为 90 秒。U44 sessionExpiry after1s=null:把maxInactiveInterval设为 1 秒,1.5 秒后findById返回 null——findById里的session.isExpired()判断会把过期会话当作不存在并删除;Redis 键本身的物理过期由 TTL 另行保证,两层不是同一件事。
并发写:最后写覆盖,没有合并
同一会话被两个请求同时读-改-写时会发生什么?RedisSessionRepository 是 HSET 覆盖语义,没有版本号、没有 compare-and-set、也没有合并。实测 8 个真实线程各 findById 同一会话、改 counter 为各自的序号、再 save:
U44 sessionConcurrent saved=8 finalCounter=8 lastWriteWins=truefinalCounter 每次运行可能是 1..8 之间的任意值(这不影响判定):最终值是"最后一个成功写入者"的值,而不是累加。这解释了两类线上现象:并发请求里的会话属性"改了又变回去";以及"并发登录/并发写会话导致属性丢失"。要避免只能在上层做:同一会话串行化(粘性路由 + 单请求串行)、或把需要并发的状态移出会话(放数据库并加版本)、或换用支持乐观锁的存储实现(本基线未运行)。
Data JDBC:没有会话,就没有"脏检查"
JdbcAggregateTemplate.save 直接构造变更集并执行,不存在延迟写入的上下文——这点和 U43 的 JPA形成对照:
@Override
public <T> T save(T instance) {
Assert.notNull(instance, "Aggregate instance must not be null");
verifyIdProperty(instance);
return performSave(new EntityAndChangeCreator<>(instance, changeCreatorSelectorForSave(instance)));
}
@Override
public <T> List<T> saveAll(Iterable<T> instances) {
return saveInBatch(instances, instance -> changeCreatorSelectorForSave(instance));
}
/**
* Dedicated insert function to do just the insert of an instance of an aggregate, including all the members of the
* aggregate.
*
* @param instance the aggregate root of the aggregate to be inserted. Must not be {@code null}.
* @return the saved instance.
*/
@Override
public <T> T insert(T instance) {
Assert.notNull(instance, "Aggregate instance must not be null");
return performSave(
new EntityAndChangeCreator<>(instance, entity -> createInsertChange(prepareVersionForInsert(entity))));
}推论:Data JDBC 里"改了对象忘记 save"不会被静默保存(与 JPA 脏检查相反);"save 之后 JdbcTemplate 查不到"在这里不会发生(没有延迟 flush),但有外键/级联聚合时 save 是多条语句,事务边界要自己用 @Transactional 划。本实验实测 U44 jdbcNoSession immediateInsert=true silentChangeIgnored=true。
R2DBC:没有连接就绪之前,什么都不执行
R2dbcRepository.save 返回 Mono<T>,没有订阅就没有 SQL。本实验显式 block() 之后用 DatabaseClient 同库查询证实 U44 r2dbcSharedDb reactiveSeesOwnWrite=true;与 JDBC 的半开差异(DataSource vs ConnectionFactory 互斥)已在上一节记录。R2DBC 驱动 r2dbc-mysql 1.4.1 不在 Boot BOM 内,显式钉版本的依据与连接串写在实验 POM/配置里,不能复制到生产而不复核。
Elasticsearch:客户端与服务端必须同线(受阻记录)
Boot 4.0.9 BOM 的 Spring Data Elasticsearch 6.0.8 依赖 elasticsearch-java 9.2.9(9.x 客户端),本机服务端是 8.19.23。按 Elastic 官方兼容表,9.x 客户端不支持 8.x 服务端。实验单独跑 EsDataTest,真实异常:
U44 esClientBoot / esRoundTrip 上下文启动失败:
node: http://127.0.0.1:9200/, status: 400, [es/indices.exists]
Expecting a response body, but none was sent
co.elastic.clients.transport.TransportException这不是"我们的代码写错了",而是版本矩阵的硬边界:9.2.9 客户端向 8.19.23 服务端发的请求,服务端返回 400 且无响应体。可行路径是服务端升级到 ES 9.x,或把 SDE 降到 5.x(面向 8.x 客户端),两者都需要重新锁定版本并重跑;本票如实登记受阻,不把连接失败写成通过。这条记录本身有教学价值:版本文档写着"支持 8.x"不等于所有 8.x 客户端都能连,必须看精确的客户端-服务端配对表并实际连一次。
观测、排障与恢复
不同 store 的排障入口不一样:
| 症状 | 先查什么 | 真实原因举例 |
|---|---|---|
| Redis 里有 key 但读出来是 null | 序列化器是否一致(key 是否明文) | 默认 JDK 序列化把 key 变二进制 |
| Session 突然失效 | 是 findById 的 isExpired 判定还是 Redis TTL | 两者是两层;前者按属性时间戳、后者按物理过期 |
| 并发改会话后值"变回去" | 是否多点写同一会话 | HSET 覆盖、无合并 |
| Mongo 派生查询查不到但原生能查到 | 索引是否真实创建 | auto-index-creation 默认关闭 |
| Data JDBC 改了对象库没变 | 是否忘记 save | 无会话/无脏检查 |
| 新加一个 starter 后原有数据路径全挂 | DataSource 与 ConnectionFactory 互斥、repository 扫描 | 见"多 store 共存" |
| ES 报 400 无响应体 | 客户端与服务端版本是否同线 | 9.x 客户端 + 8.x 服务端 |
恢复动作也按层:Redis 序列化需要双写迁移或版本化键;Session 并发需要把状态移出会话或串行化;ES 需要同线换版本后重跑连接测试而不是重试。
容量与取舍
- Session 存储:Redis 单实例适合中小规模;会话数 × 每会话字节 × 副本数决定内存。会话过大(放业务对象)会拖慢读写与主从复制,认证态/少量标记适合放会话,业务工作集应放数据库并携带 id。
indexed仓库支持按用户查会话与过期通知,但维护额外索引、清理更重(本票未运行)。 - 序列化:JSON 体积大但跨语言;JDK 小但类版本耦合;性能不是唯一因素,可运维性(能否用 redis-cli 直接读)往往更重要。
- 五套模块的管理成本:每多一套 store 就多一套版本矩阵、连接池、事务与观测;能用一个关系库解决的不要为"技术多样"引入五种存储。
- 演进触发:会话规模化 → 独立 Session 服务或 indexed 仓库;搜索需求增长 → ES 版本升级并重锁客户端;多 store 冲突 → 显式
basePackages与 starter 隔离。
实验读法
python3 labs/spring-data-session/run.py主轮 12 测试 + ES 受阻轮;观察行以 U44 前缀逐字回收;证据见 u44-2026-10-31.json。开发中真实踩到的坑:application.properties 写 YAML 语法、RedisTemplate 泛型不匹配、@RedisHash 的 TTL 与 StringRedisTemplate 的 TTL 是两套 API、repository-type=default 不是 FindByIndexNameSessionRepository、MySQL || 不是字符串拼接、@AfterAll 需要 PER_CLASS 生命周期——全部改了代码才绿。
条件变化与未运行边界
- 未运行:Redis Cluster/Sentinel/主从切换、Mongo 复制集/分片、ES 真实索引读写(客户端-服务端跨线受阻)、Session 的 indexed 仓库与过期通知、并发会话在多实例下的行为、TLS、生产容量。
- 构造:Session 过期用 1 秒(生产通常 30 分钟);并发写用同进程 8 线程;Mongo 自动建索引在测试配置里开启(生产默认关闭)。
- 版本边界:ES 8.19.23 服务端与 SDE 6.0.8(9.2.9 客户端)跨线;R2DBC 驱动 1.4.1 不在 BOM;两者都不能直接外推到其他版本组合。
- 教材"已讲清"是审读结论,不代表学习者掌握。
闭卷验收
本页 20 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
Spring Data Redis RedisTemplate.java | 4.0.8 | 默认序列化(真实运行) |
Spring Session RedisSessionRepository.java | 4.0.5 | 会话键与过期判定(真实运行) |
Spring Data JDBC JdbcAggregateTemplate.java | 4.0.8 | 立即写入语义 |
| Spring Data BOM | 2025.1.8 | 五套模块版本 |
| Spring Session 4.0.5 | 4.0.5 | 会话存储版本 |
| Boot 4.0.9 BOM | 4.0.9 | 工程配对 |
| Elasticsearch 客户端兼容说明 | 查询 2026-10-29 | 客户端-服务端同线 |
访问与运行日期:2026-10-31。源码片段为固定 tag 的连续行段,整文件 SHA-256 随每段元数据登记;运行版本以证据 JSON 为准。规范、实现源码与实验是三类证据,不能互相替代;Redis Cluster/Mongo 复制集/ES 真实读写/Session indexed 仓库未运行。