资讯动态

Spring Data JDBC 实战:轻量级聚合映射与 CRUD 全解析

发布时间:2026/10/16 9:37:14 来源:尧图企业网站定制
1. Spring Data JDBC 到底解决了什么问题Spring Data JDBC 这个名字很多做 Java 后端的同事第一反应是这不就是 Spring Data JPA 的简写吗还真不是。它是 Spring Data 家族里一个独立的成员定位非常明确——在 JDBC 之上提供轻量级的对象映射但又不像 JPA/Hibernate 那样引入完整的持久化上下文、一级缓存和懒加载机制。我最早接触它是在一个订单类的模拟项目里当时我们既要保留关系数据库的明细查询能力又不希望被 JPA 复杂的实体状态管理坑到换成 MyBatis 又需要手写一大堆 XML 映射。试了一圈之后Spring Data JDBC 刚好卡在中间它保留了 Spring Data 那一套 Repository 接口风格但底层直接走 JdbcTemplate没有一级缓存、没有懒加载、没有变更追踪简单直接。它实际解决了什么问题呢第一不需要写样板 SQL方法名推导就可以完成大部分查询第二聚合边界非常清晰一个聚合根对应一个根表子表通过聚合根 ID 关联删除和保存都把整个聚合当作一个整体处理第三性能确定性很强你能大致猜到底层执行了几条 SQL而不是像 Hibernate 那样经常出现“你怎么发了这么多条”的情况。1.1 为什么需要这样一个“中间路线”方案JPA 本身是一套成熟的 ORM 规范但它的心智负担不低。实体生命周期、持久化上下文、脏检查、延迟加载、N1、一级缓存的失效问题……这些概念对新手不友好对老手也是日常排查的重灾区。尤其在一些高并发、低延迟的服务里Hibernate 的懒加载代理和会话管理稍不注意就会引入性能波动。MyBatis 则走向另一个极端映射规则透明、SQL 完全可控但代价是大量显式的 Mapper 接口、XML 文件或注解 SQL实体与数据库字段的映射要自己维护聚合之间的关联关系也基本靠手动编码实现。Spring Data JDBC 的思路是把“聚合”作为核心建模单位一个聚合内包含的所有实体和值对象在保存时整体落库、删除时整体清理、查询时整体加载。它不缓存实体状态不跟踪实体的逐字段变化每次 save 都是直接向数据库写入当前快照。这套模型在 DDD 风格的系统中非常顺手因为它天然跟“聚合根”的概念对齐。1.2 它和 JPA、MyBatis 的定位差异很多人容易把它跟 JPA 搞混尤其是依赖坐标里也带“data-jpa”这样的字样。简单做个对比能力维度Spring Data JDBCSpring Data JPA / HibernateMyBatis实体状态管理无状态只有快照完整持久化上下文无状态懒加载不支持支持手动实现联合查询聚合映射内置聚合根/子实体模型通过 OneToMany 等配置需手动组装SQL 可见性自动生成可通过日志查看自动生成较难完全控制完全手写批量操作较弱需要自行扩展有 saveAll 但也是逐条居多支持 foreach 批量 SQL学习曲线低中高中用一句话总结如果你追求的是“拿一个对象改几个字段一 save 就完事”JPA 最舒服如果你追求“每条 SQL 都尽在掌握”MyBatis 最直接如果你想要介于两者之间的可预测性Spring Data JDBC 是值得认真考虑的选择。1.3 适合哪类项目使用从我的实践经验看它非常适合以下场景数据模型比较规整以主键索引、唯一索引为主不涉及特别变态的报表级查询团队希望用领域模型表达业务但又不愿接受 JPA 的黑魔法并发写较多、读多也多的业务系统需要透明的数据库行为新项目起步不想一开始就引入 MyBatis 那套额外工程结构它不是万能药但作为“默认选项”来说性价比很高。2. 快速上手从依赖到第一段 CRUD如果只看官方文档三步就能跑起来加依赖、配数据源、写实体和 Repository。但实际落地时会遇到不少小细节这里按我常用的步骤展开讲。2.1 依赖配置与数据源我用的是 Spring Boot 3.x 的项目Maven 坐标是这样的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jdbc/artifactId /dependency注意不要跟spring-boot-starter-data-jpa一起加两个都引入的话自动配置会同时注册 JPA 和 JDBC 的仓库扫描容易出现一些匪夷所思的初始化错误。如果确实需要共存需要显式指定仓库类型但我不建议在同一个模块里混用。数据源配置就是标准的 JDBC 配置我用的是 MySQLspring: datasource: url: jdbc:mysql://localhost:3306/order_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: xxxxxx driver-class-name: com.mysql.cj.jdbc.DriverSpring Boot 会基于这个数据源自动创建JdbcTemplate而 Spring Data JDBC 内部正是依赖JdbcTemplate来执行 SQL所以性能和调试方式都与传统 JDBC 类似。2.2 聚合根实体与仓库接口以订单为例一个订单聚合包含订单头和订单明细。先定义订单头实体Table(t_order) public class Order { Id private Long id; private String orderNo; private BigDecimal amount; Version private Long version; }这里有几个容易忽略的点。Table可以指定物理表名不指定的话默认把类名转下划线作为表名Order会映射成order。我的经验是表名都显式写清楚避免将来类名重构时影响数据库映射。然后定义订单明细Table(t_order_item) public class OrderItem { Id private Long id; private String sku; private Integer quantity; private BigDecimal price; private Long orderId; }这里orderId是外键但 Spring Data JDBC 中不会像 JPA 那样用ManyToOne去建模。它认为订单和明细是同一个聚合所以更常见的建模方式是在聚合根里直接持有明细集合Table(t_order) public class Order { Id private Long id; private String orderNo; private BigDecimal amount; Version private Long version; private ListOrderItem items; }这样保存订单时明细会一起处理。如果没有orderId字段也可以框架会自动在子表中维护聚合根的外键列。仓库接口是最熟悉的风格public interface OrderRepository extends CrudRepositoryOrder, Long { }不需要写任何实现类Spring 会自动生成代理对象。到这里基本的增删改查已经可用了。2.3 第一个保存和查询示例写个简单的使用代码Order order new Order(); order.setOrderNo(SO20240001); order.setAmount(new BigDecimal(299.00)); OrderItem item1 new OrderItem(); item1.setSku(SKU-A100); item1.setQuantity(2); item1.setPrice(new BigDecimal(99.50)); OrderItem item2 new OrderItem(); item2.setSku(SKU-B200); item2.setQuantity(1); item2.setPrice(new BigDecimal(100.00)); order.setItems(List.of(item1, item2)); orderRepository.save(order);执行时打开 SQL 日志会发现框架先插入t_order再逐条插入t_order_item并且子表里会自动填上聚合根生成的主键作为外键。查询时OptionalOrder result orderRepository.findById(order.getId());findById返回的是一个Optional如果没有数据则返回空。这里跟 JPA 的习惯很像但要注意它永远不返回空指针。findById执行的是先查询聚合根表再按外键查询子表所以日志里会出现两条或更多 SELECT。这就是 Spring Data JDBC 的默认行为一次读取把整个聚合完整加载。这种“所见即所得”的加载方式调试时非常爽。3. 映射设计聚合、实体、值对象怎么落表Spring Data JDBC 的映射模型基于 DDD 的聚合概念。搞明白这个后面很多坑都可以避免。3.1 聚合根、实体与值对象的边界在这个框架里一个持久化图景被分成三种角色聚合根Aggregate Root拥有Id是 Repository 处理的对象一张主表实体Entity聚合内部有独立标识的对象比如订单明细一张子表值对象Value Object没有独立标识直接嵌入所属表的行数据中判断自己写的类属于哪一类最核心的标准就是“它有没有独立存在的意义”。订单明细单独拿出来没有业务价值所以它是实体金额加一个币种组合成 Money 对象它不需要单独表就是值对象。值对象在 Spring Data JDBC 里可以直接作为聚合根的字段public class Money { private BigDecimal value; private String currency; }保存时Money的值会直接变成value和currency两列而不产生单独的表。这是个很省心的设计比 JPA 的Embedded还要更自然。3.2 集合和复杂属性的列名规则聚合根的集合字段比如ListOrderItem items会被映射为子表t_order_item。子表与根表的关联列名默认是根表名加_id即根表t_order对应的外键列是order_id。可以用MappedCollection来自定义关联列public class Order { Id private Long id; MappedCollection(idColumn order_ref_id) private ListOrderItem items; }字段的列名默认遵循 Java 驼峰转下划线的规则orderNo-order_no。如果数据库里列名不规范可以给字段加Column注解Column(order_number) private String orderNo;如果整张表的命名风格都不同可以实现NamingStrategy接口或配置一个全局的命名策略。这个我没少折腾过最后发现还是统一数据库命名规范最省事。有一个容易踩的地方值对象里如果有集合它不能被放进同一行因此值对象内部禁止出现集合属性。如果确实需要应该把它提升为实体让它拥有一张独立子表。3.3 ID 生成与乐观锁配置Spring Data JDBC 对主键生成的默认策略是要求数据库自动生成。比如 MySQL 的自增主键插入完成后框架会把生成的主键回填到实体里。如果不想依赖数据库自增也可以自己在应用层生成Id private String id;然后在保存前手动填入 UUID 字符串。这种方案在分库分表场景非常好用但要注意数据库列类型长度。并发控制方面框架直接支持乐观锁Version private Long version;每次更新时SQL 都会带上where version ?更新成功后版本号加一。如果两个请求同时改同一个聚合只有一个会成功另一个更新行数为 0框架会抛出OptimisticLockingFailureException。在测试环境里为了验证这个机制我特意模拟过双请求并发更新异常抛出很及时业务侧 catch 后提示用户重试就行。4. 保存、更新与删除行为它比你想的更“整”Spring Data JDBC 的写操作思路是一种“整体替换”而不是“字段级更新”。这一点和 JPA 的脏检查完全不同我刚从 JPA 转过来时很不适应。4.1 save 到底做了什么save方法的逻辑概括起来是如果主键为空执行插入如果主键非空执行更新。但这里有个关键点——更新并不是按变更字段来的而是把整个聚合根连同所有子实体整体更新一遍。举例一个订单里有 5 条明细你只改了订单头金额调用save时框架仍会检查明细集合哪些明细还在、哪些新增、哪些被移除了。简单来说它拿数据库里已有的子表数据与当前实体内的集合比对删除不在集合里的旧数据新增新出现的数据保留两边都存在的数据。这里有个长期被大家讨论的问题它并不是把整个集合删除再重新插入而是基于 ID 进行比对和增量维护。前提是子实体必须可靠地携带Id字段。如果子实体没有稳定的主键框架的行为会退化成不可预测所以要养成给每个子实体都显式声明Id的习惯。我不推荐在一个事务里反复读取、修改、保存同一个聚合比如循环里调 10 次save。因为每次保存都会触发整套聚合比对逻辑包括子表查询和更新。正确做法是一次读取修改完整聚合只保存一次。4.2 删除时的子表处理顺序删除聚合是另一个值得注意的地方。调用deleteById时框架会先删除子表数据再删除聚合根数据。这个顺序是有讲究的如果先删主表数据子表的外键可能暂时悬空虽然大多数数据库不会立即报错但严谨的模式下会引起外键约束问题。我遇到过一个小坑如果子表有外键约束且删除时数据库设置了ON DELETE RESTRICTSpring Data JDBC 默认的删除顺序也可能因为时序导致外键冲突。解决方法是把外键约束改为ON DELETE CASCADE然后自己处理聚合根删除或者直接使用Transactional配合子表删除逻辑。实际项目中如果删除操作比较频繁我更倾向于在数据库层定义好外键级联规则同时让应用层只负责删除聚合根避免两条路径的删除行为不一致。4.3 审计字段与生命周期事件Spring Data JDBC 也支持审计功能跟 JPA 的用法类似。先开启审计Configuration EnableJdbcAuditing public class JdbcConfig { }然后在实体里添加审计注解CreatedDate private Instant createdAt; LastModifiedDate private Instant updatedAt;保存时框架会自动填充createdAt更新时自动更新updatedAt。这个功能底层也是通过实体回调实现的。除此之外Spring Data JDBC 还会发布生命周期事件比如BeforeSaveEvent、AfterSaveEvent、BeforeDeleteEvent。通过EventListener可以监听这些事件EventListener public void onBeforeSave(BeforeSaveEvent event) { Object entity event.getEntity(); // 这里可以做一些校验、日志或者数据补充 }我曾在一个场景里用这个机制做操作日志在AfterSaveEvent里把实体快照发到消息队列业务代码完全不用感知效果比在 Service 层手动埋点干净很多。注意不要在这些事件里执行太长耗时的操作比如调用远程接口或执行复杂的同步计算。因为事件监听器默认是在保存事务内部执行的监听器耗时过长会拖住整个事务极端情况下还会因为嵌套数据库操作导致事务异常。5. 查询链路方法名推导、Query 与分页排序Spring Data JDBC 的查询能力兼容 Spring Data 的大部分方法名推导规则也支持自定义Query。但它没有 JPA 那么“智能”有些方法名解析在实际使用中会有限制我会把踩过的点都列出来。5.1 方法名推导规则最常用的方式还是直接在 Repository 里声明方法public interface OrderRepository extends CrudRepositoryOrder, Long { ListOrder findByCustomerId(Long customerId); OptionalOrder findByOrderNo(String orderNo); ListOrder findByAmountGreaterThan(BigDecimal amount); }在我看来方法名推导的主战场是“单表或聚合内查询”因为它本质上是根据属性名生成 WHERE 条件。findByOrderNo会生成select * from t_order where order_no ?“没有结果时返回什么”还取决于方法返回类型是Optional还是List。如果查询条件涉及嵌套属性比如按明细的 SKU 查订单ListOrder findByItems_Sku(String sku);这个写法也能跑通它会在聚合查询层面生成关联条件但生成的 SQL 不一定是最优的。所以我建议这种跨层查询直接写Query别硬用方法名推导。5.2 Query 自定义语句Query注解在 Spring Data JDBC 里是真正的自定义 SQL 出口public interface OrderRepository extends CrudRepositoryOrder, Long { Query(select * from t_order where order_no :orderNo) OptionalOrder findRealByOrderNo(Param(orderNo) String orderNo); }注意这里的 SQL 是原生 SQL不是 JPQL。框架不会帮你做实体映射以外的翻译所以列名、表名都要和数据库保持一致。Param用于绑定参数没有 JPA 里那种“位置参数”的灵活性建议统一使用命名参数。还有一点Query查询的返回类型如果是一个实体框架会把查询结果映射为聚合根并额外执行子表查询。也就是说你写了一条 SQL 只查了聚合根表框架发现自己需要返回一个完整聚合会按照该聚合的映射定义继续把子表数据也查出来。这个行为有时候让人疑惑但只要理解“Repository 操作的单位是聚合”就不会觉得奇怪了。5.3 分页排序与 count 优化分页排序可以直接用PagingAndSortingRepositorypublic interface OrderRepository extends PagingAndSortingRepositoryOrder, Long { PageOrder findByCustomerId(Long customerId, Pageable pageable); }调用时传入PageOrder page orderRepository.findByCustomerId(1001L, PageRequest.of(1, 20, Sort.by(createdAt).descending()));分页查询会自动生成一条 count 查询和一条数据查询。复杂查询尤其是用Query自定义带 JOIN 的语句时自动生成的 count SQL 可能冗余甚至语法错误。解决办法是在Query里单独指定 countQueryQuery(value select o.* from t_order o join t_customer c on o.customer_id c.id where c.name like :name, countQuery select count(*) from t_order o join t_customer c on o.customer_id c.id where c.name like :name) PageOrder searchByName(Param(name) String name, Pageable pageable);分页时还有一个注意点排序字段如果是聚合内的字段比如items.sku排序很可能不生效因为生成的 SQL 不会为集合属性做特殊处理。遇到这种需求我一般直接写Query或者直接走数据库视图。5.4 写操作的自定义更新自定义UPDATE、DELETE需要配合Modifying注解public interface OrderRepository extends CrudRepositoryOrder, Long { Modifying Query(update t_order set amount :amount where id :id) void updateAmount(Param(id) Long id, Param(amount) BigDecimal amount); }注意这里的事务边界。如果方法不是默认事务方法更新的操作不会自动回滚最好在 Service 层加上Transactional。另外Modifying方法的返回值可以是void、int或longint代表受影响的行数可以用来判断是否存在匹配记录。自定义写操作不经过聚合的完整保存逻辑所以子表的数据不会被联动修改。它适合做一些独立的、局部的状态更新比如“更新订单状态”“更新库存”这类简单场景。如果改动涉及聚合内部结构还是应该走save让框架统一处理。6. 常见问题与踩坑实录这一节是我最想分享的。很多坑不是看文档能看出来的必须真写进项目里才会遇到。6.1 N1 问题与聚合读取策略N1 问题在 JPA 里是懒加载导致的但 Spring Data JDBC 的 N1 往往来自“一次查询带出一大堆聚合”。比如findAll()返回 100 个订单框架会对每个订单再执行明细查询最终产生 101 条 SQL。这不是“框架偷懒”而是一种模型策略始终加载完整聚合。解决思路也明确查询列表页不需要明细时不要直接返回聚合根而是写一个专用的 DTO 查询用Query只查需要的列不要指望框架按需加载需要完整聚合时能接受它一次性查出来总比 N 次查询好我在一个项目里就是列表页返回 50 条订单结果每条订单查明细响应时间直接卡到 3 秒。改成 DTO 查询后列表接口只需要一条 SQL响应降到 200 毫秒以内。6.2 没有懒加载别被“看似关联”误导Spring Data JDBC 没有懒加载代理这是它的设计不是缺陷。你在实体里写一个ListOrderItem查询时它就在那里查不到就是空集合不会有LazyInitializationException。这个特性和 JPA 对比反而是省心的地方。不用关心会话关闭、代理初始化这些问题。它的代价是如果你想做“部分字段延迟加载”框架不提供得自己设计查询。6.3 批量插入性能saveAll并不是一条 SQL 插入多条记录而是循环调用save。如果一次插 10 万条订单性能会很难看。解决办法是绕开 Repository直接用JdbcTemplate做批量插入jdbcTemplate.batchUpdate( insert into t_order(order_no, amount) values(?, ?), orders, orders.size(), (ps, order) - { ps.setString(1, order.getOrderNo()); ps.setBigDecimal(2, order.getAmount()); });批量导入场景完全可以用这种方案。平时业务写入量不大时saveAll也够用但要清楚它的底层不是优化过的批量语句。6.4 事务边界哪些默认哪些需要自己控制Spring Data JDBC 对 Repository 的方法默认是有事务的读方法默认走只读事务写方法默认走读写事务。但如果你在 Service 里把多个 Repository 调用组合在一起就需要显式声明事务Service public class OrderService { Transactional public void createOrder(Order order) { orderRepository.save(order); stockService.deduct(order.getItems()); } }这里如果不加TransactionalorderRepository.save和库存扣减之间失败的话订单可能已经落库但库存没扣数据就歪了。所以聚合之间的跨表写操作必须以 Service 层事务为单位这一点和 JPA 时代没有区别。7. 项目落地的一些经验如果能走到这一步说明你已经准备真实使用它了。我最后聊聊一些更偏“战略”层面的经验不一定写到代码里但对项目走向影响很大。7.1 聚合拆分原则Spring Data JDBC 的写入粒度就是聚合聚合越大一次操作关联的表越多SQL 越多事务时间越长。我建议把聚合拆得尽量小。举个例子用户和用户地址到底是同一个聚合还是两个聚合如果用户地址总是和用户一起查询、一起修改放一个聚合没问题。但如果是电商系统收货地址可以被订单快照独立引用那就不如拆成两个聚合Customer聚合根和Address聚合根订单里面只存地址 ID 或地址快照。聚合合并的标准是“业务不变式是否跨越多个实体”。如果 A 和 B 必须同生共死比如订单和订单明细放在一个聚合。如果只是频繁一起查询那就用查询接口去组装改变过程序员最初喜欢“一个大对象直接全部查出来”的习惯性能会好很多。7.2 测试建议Spring Data JDBC 比较适合做集成测试因为它的行为相对可预测。测试时用DataJdbcTest只加载 JDBC 相关组件配合嵌入式数据库比如 H2可以很快跑通 Repository 层DataJdbcTest class OrderRepositoryTest { Autowired private OrderRepository orderRepository; Test void shouldSaveAndFindOrder() { Order order buildOrder(); orderRepository.save(order); assertThat(orderRepository.findById(order.getId())).isPresent(); } }需要注意 H2 和 MySQL 的 SQL 方言差异比如某些数据类型、分页语法。线上还是用真实的 MySQL 环境做一轮回归再放心。这里我踩过一个大跟头H2 里测试通过部署到 MySQL 后才发现某个字段类型映射有问题花了不少时间定位。7.3 什么时候该换回 JPA 或 MyBatis如果项目里出现了大量复杂 JOIN 报表、高度动态的查询条件、或者实体间关联非常复杂Spring Data JDBC 就不太合适了。它擅长的是“领域模型 常规 CRUD 可预测的 SQL”不是数据查询引擎。如果你们团队已经有成熟的 MyBatis 基础那也不太需要强切过来工具没有高下只有适配场景。我自己做技术选型时会先问三个问题团队更熟悉哪套心智模型业务是领域模型驱动还是报表查询驱动对 SQL 可控性要求有多高答案偏向前者就选 Spring Data JDBC偏后者就别折腾了。还有一个很实在的点如果你正在纠结 JPA 和 MyBatis 之间怎么选Spring Data JDBC 值得拿来当“基线方案”。它的学习成本低、行为透明即使未来要换改动面也相对可控。先跑起来再逐步优化比一开始就押注高复杂度框架稳妥得多。最后分享一个我自己很习惯的做法在项目里同时保留JdbcTemplate。遇到 Repository 解决不了的复杂查询直接注入JdbcTemplate写原生 SQL不需要换框架也不需要给团队引入新概念。这是我在多个项目里验证过的最省心的组合方式也是 Spring Data JDBC 这类轻量级方案给我最大的自由度之一。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑