ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入理解Spring Data:从JDBC样板代码到Repository自动化原理

深入理解Spring Data:从JDBC样板代码到Repository自动化原理 过去几年里我带过不少刚入行的Java开发大多数人第一次听到“Spring Data”这个词时第一反应都是这是个ORM框架吧是不是跟MyBatis差不多等真正接手项目看到Service层里一个个接口注入、方法调用就能完成数据库操作又开始觉得这东西是不是有魔法。前阵子刚好在改造一个老项目整个DAO层还停留在手工JDBC时代我就在想如果能把这段经验讲明白应该能帮不少卡在“会用但不知道原理”阶段的人少走弯路。这篇内容我想抛开那些官方文档式的介绍从“它到底为了解决什么问题”说起讲清楚Spring Data为什么设计成这样、底层实现思路是什么、实际项目里怎么用最顺手以及我踩过的几个比较深的坑。适合正在学Spring Boot、背过一堆注解但没理解本质的初学者也适合那些想在项目里更合理使用Spring Data的开发者。1. 从让你厌倦的JDBC样板代码说起Spring Data到底解决了什么问题1.1 再看一眼十几年前的数据库访问方式如果你想真正理解Spring Data最直接的办法是先回到没有它的时代。我改造的那个老项目DAO层的代码风格是这样的public ListProduct findProductsByPriceRange(double min, double max) { Connection conn null; PreparedStatement ps null; ResultSet rs null; ListProduct result new ArrayList(); try { conn dataSource.getConnection(); ps conn.prepareStatement(SELECT * FROM product WHERE price BETWEEN ? AND ?); ps.setDouble(1, min); ps.setDouble(2, max); rs ps.executeQuery(); while (rs.next()) { Product product new Product(); product.setId(rs.getLong(id)); product.setName(rs.getString(name)); product.setPrice(rs.getDouble(price)); result.add(product); } } catch (SQLException e) { throw new RuntimeException(e); } finally { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (ps ! null) { try { ps.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } return result; }这段代码的质量其实不算差连接关闭、异常处理都有但它最大的问题是十个这样的方法有九段代码长得一模一样只换了SQL语句和结果集的赋值方式。你不觉得是这段代码不能工作而是它让整个项目变成了一个巨大的复制粘贴现场。一个分页查询要写一大坨一个带条件统计要再来一坨写的人烦改的人更烦。Spring Data想解决的不是“能不能操作数据库”而是“能不能把操作数据库的成本从重复劳动里剥离出来”。它希望你把注意力放在“我要查什么数据”而不是“我怎么连数据库、怎么关连接、怎么处理结果集”。1.2 Repository不是新名词但Spring把它做成了能落地的标准化抽象数据库访问这块Java生态演进过几代最早大家用JDBC后来Spring封了个JdbcTemplate把连接管理、异常转换这些底层脏活接走了再后来ORM框架出现Hibernate让你用对象视角操作数据库MyBatis走的是半自动路线SQL仍然写在你手里。这一路走来程序员越来越省事但每一代方案都没真正解决一个结构性问题。那个结构性问题是什么是业务代码和数据库访问代码之间的边界到底应该画在哪。领域驱动设计里有“Repository”这个概念中文一般叫“仓库”或“仓储”。它把数据访问抽象成一个接口业务层只依赖接口不关心数据到底是存在MySQL还是Redis也不关心查询用的是JPQL还是原生SQL。它就像一个超市你要买什么告诉服务员就行不用自己跑去货架翻。这个思想本身不新但Spring Data是第一个把Repository做成了框架级标准的——你只需要写接口Spring在运行时把实现给填了。这就是理解Spring Data最关键的一步它不是某一个具体的数据库访问技术而是一整套建立在Repository抽象之上的生态JPA、Redis、MongoDB、Elasticsearch这些只是它不同的落地实现。2. Repository接口不用写实现拆开Spring Data Commons的设计内核2.1 启动时Spring其实偷偷给你造了实现类很多人第一次看到下面的代码都会觉得不合理public interface ProductRepository extends JpaRepositoryProduct, Long { ListProduct findByPriceGreaterThan(double price); }接口里就一个方法名没写任何实现也没写SQL为什么直接注入Service里就能用答案是Spring在启动阶段就为这个接口生成了一份“看不见的实现类”。Spring Boot应用启动时EnableJpaRepositories会扫描所有继承了Repository的接口然后交给RepositoryFactorySupport给接口创建代理对象。这里的核心机制是JDK动态代理——代理对象在接口方法被调用时会拦截调用并转交给内部的执行逻辑执行真实数据库操作。市面上大多数Spring系列框架的动态代理都有相似套路理解这一个基本上就能举一反三。代理对象里都藏着什么对Spring Data JPA来说代理对象内部关联着EntityManager和持久化上下文。当你调用findById时代理会把请求转换为JPA的查询逻辑当你调用save时代理会判断当前实体是新建还是更新再触发对应的merge或persist。整个过程中业务代码完全不知道底层发生了什么它们只认识那个接口。从调用方的角度看接口成了纯粹的契约这就让单元测试变得特别舒服。你可以随意Mock接口不需要启动整个数据库。2.2 方法名到SQL的翻译过程PartTree如何拆解方法名代理对象不仅能把CRUD方法映射到操作还能把方法名翻译成查询。你写的这个方法ListProduct findByPriceBetweenAndStatusOrderByCreateTimeDesc(double min, double max, ProductStatus status);Spring Data内部有个专门负责解析方法名的类叫MethodNameBasedQueryLookupStrategy它在底层依赖PartTree来完成解析。解析过程大致是这样找到方法名中的谓词部分find、read、count、exists等确定这次操作类型是查询、计数还是存在性判断用By作为分界把方法名切成条件段以上面的方法为例条件段就是PriceBetweenAndStatusOrderByCreateTimeDesc条件段再用And、Or拆成独立条件PriceBetween和Status每个条件里属性名Price、Status被识别成实体字段后缀Between被映射成SQL的BETWEEN ? AND ?OrderByCreateTimeDesc被拆成排序信息按createTime字段倒序。这套解析逻辑听起来有点绕但你要记住的结论很简单方法名就是一条查询的压缩描述。这也解释了为什么Spring Data能”猜“出你的意图——它其实不是猜而是有一套严谨的语法解析规则这套规则只认方法名和实体属性名所以实体里改了字段名方法解析就可能崩这一点后面还会提到。2.3 为什么默认能省略注解约定优于配置除了方法名派生Spring Data也支持在方法上写Query注解来指定具体查询语句。很多初学者会有个疑问既然能用方法名为什么还需要Query原因是方法名派生查询有边界——当查询涉及多表复杂筛选、分组统计、子查询时把条件全部塞进方法名会变成一个超长的怪物可读性极差。这个时候Query就派上用场了。但Spring Data的默认设计思路是约定优于配置能通过方法名表达清楚的就不要写额外配置因为方法名本身就是活文档findByPriceGreaterThan(double price)一眼就能看懂要干什么。只有当约定不足以覆盖场景时才用注解显式干预。这个设计哲学贯穿整个Spring Data家族你读代码时能感觉到框架在努力帮你减少重复。3. 别再误会Spring Data就是JPA全家桶模块地图该怎么看3.1 一张模块对应表看懂Spring Data的真身Spring Data不是一个单独的框架而是一个由大量模块组成的家族。每一个模块解决一类数据源的访问抽象但接口风格、核心思想、操作模式高度一致。模块对应数据源典型接口典型场景Spring Data JPA关系型数据库JpaRepository标准ORM基于Hibernate实现适合绝大多数业务系统Spring Data JDBC关系型数据库CrudRepository不想引入重量级ORM又希望拥有简洁Repository抽象Spring Data MongoDBMongoDBMongoRepository文档型数据库、灵活schemaSpring Data RedisRedisCrudRepository操作Redis数据结构缓存、计数器、队列等Spring Data ElasticsearchElasticsearchElasticsearchRepository结构化搜索、日志检索、全文搜索Spring Data REST任何Repository无自动暴露RESTful接口适合快速原型你看JPA只是其中一块。我经常在项目里看到有人把“Spring Data”和“Spring Data JPA”混为一谈一聊到Spring Data就默认是在聊Hibernate这个理解在遇到多数据源项目时会吃大亏。3.2 同一套Repository概念为什么能覆盖关系型和非关系型Spring Data能做到“一个接口风格覆盖多个数据库”靠的是顶层抽象和底层实现分离。所有模块都遵循一个公共约定你定义interface XxxRepository extends Repository实体, 主键Spring Data负责把实体的字段映射到对应数据源的操作方式上。同样是findByIdJPA模块翻译成SQL查询Redis模块翻译成GET命令MongoDB模块翻译成文档查询但这些细节都被隔离在实现层。所以对一个开发来说掌握思路比背接口更重要。你只要理解了Repository查询方法名解析这一套换了数据源学习成本是很低的。这也是我第一次接触MongoRepository时最大的感受几乎不需要重新学一套全新框架。3.3 实际项目的常见组合与模块边界实际项目里比较常见的一种组合是主业务表用Spring Data JPA来管理热点数据缓存用Spring Data Redis搜索场景用Spring Data Elasticsearch。三个模块共用一套Repository思维系统没有因为技术栈不同而割裂出三套风格迥异的代码。但要注意混合使用多个模块时事务边界需要格外小心。JPA的事务由Spring管理Redis缓存操作如果也在同一事务里做回滚处理需要自己封装补偿逻辑不要指望Redis操作跟着数据库一起回滚。这是多模块混用来得最猛的一个坑后面避坑部分我会展开聊。4. 实战用Spring Data JPA快速搭一个带审计字段的商品模块4.1 引入依赖和基础配置光说不练属于假把式下面我们直接搭一个商品模块把Spring Data JPA完整跑一遍。先引入依赖Spring Boot项目里你只需要加一个starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency接着配置数据源和JPA参数spring: datasource: url: jdbc:mysql://localhost:3306/shop?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root jpa: hibernate: ddl-auto: update properties: hibernate: format_sql: true show-sql: true这里面有几点新手比较容易迷惑ddl-auto: update意味着Hibernate会根据实体定义自动建表或更新表结构开发环境很方便但生产环境我建议改成validate只校验实体与表是否一致避免框架动了你的表结构。show-sql: true会打印生成的SQL开发阶段很有用但日志量大生产环境关掉。4.2 实体和Repository接口两分钟写完基础CRUD商品实体Entity Table(name product) EntityListeners(AuditingEntityListener.class) public class Product { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; private BigDecimal price; Enumerated(EnumType.STRING) private ProductStatus status; CreatedDate private LocalDateTime createTime; LastModifiedDate private LocalDateTime updateTime; // 省略getter/setter }Repository接口就一行public interface ProductRepository extends JpaRepositoryProduct, Long { }你没看错就这么点代码已经具备分页查询、按ID查找、保存、删除、批量操作等等功能。JpaRepository接口扩展自PagingAndSortingRepository再往上扩展自CrudRepository这一串继承关系把通用操作都定义好了你的接口只需要指定实体类型和主键类型。Service里直接用Service public class ProductService { private final ProductRepository productRepository; public ProductService(ProductRepository productRepository) { this.productRepository productRepository; } public Product create(Product product) { return productRepository.save(product); } public OptionalProduct getById(Long id) { return productRepository.findById(id); } public PageProduct page(int page, int size) { return productRepository.findAll(PageRequest.of(page, size)); } }看到没有一段全新的数据库访问模块不需要手写SQL不需要写实现类业务代码直接可跑。这就是Spring Data在提高开发效率上最直接的价值。4.3 审计字段自动填充的正确姿势以及我踩过的坑代码里我用了CreatedDate和LastModifiedDate很多人照抄以后发现字段根本没被赋值原因往往是少了两个配置。第一实体类上必须加EntityListeners(AuditingEntityListener.class)不写这个Spring Data的审计拦截器不会介入你的实体生命周期第二启动类或配置类上必须开启EnableJpaAuditing。两个条件缺一不可。SpringBootApplication EnableJpaAuditing public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }还有一个比较隐蔽的坑当你配置了自定义的AuditorAware来填充创建人、修改人字段时如果getCurrentAuditor返回了空值Spring Data很可能直接把null写进去而不是报错。所以getCurrentAuditor里最好给一个默认值或至少打印日志方便排查。Bean public AuditorAwareString auditorAware() { return () - Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) .map(auth - auth.getName()) .or(() - Optional.of(system)); }这算是我实际项目里踩过的一个典型问题审计字段不全导致后期排查数据变更记录的时候找不到操作人最后还得翻业务日志。所以我把这步放在前面重点提醒免得后面出问题再返工。5. 查询能力四把刀方法名派生、Query、Specification、EntityGraph怎么选5.1 一个查询需求的四种实现写法对照假设要做一个商品列表页需要按价格区间过滤、按状态过滤并且带上分类信息。这个需求看起来不复杂但用不同方式能达到相同效果它们的适用范围完全不一样。方法名派生写法最简单ListProduct findByPriceBetweenAndStatus(BigDecimal min, BigDecimal max, ProductStatus status);当查询条件多到五六个的时候方法名会变得很长。如果还要加排序、加限制条数长度直接失去控制阅读体验极差。Query写法固定住了语句Query(SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max AND p.status :status) ListProduct search(Param(min) BigDecimal min, Param(max) BigDecimal max, Param(status) ProductStatus status);这种写法的好处是语句稳定、可读性好、可预编译适合SQL逻辑基本不变的固定查询。动态条件查询比如用户勾选了价格范围可能也可能不勾选状态可能也可能不筛选这时候如果都靠Query拼条件会非常痛苦。JPA给了一个更优雅的方案——Specificationpublic static SpecificationProduct priceBetween(BigDecimal min, BigDecimal max) { return (root, query, cb) - cb.between(root.get(price), min, max); } public static SpecificationProduct statusEquals(ProductStatus status) { return (root, query, cb) - cb.equal(root.get(status), status); }Service里把多个条件组合起来SpecificationProduct spec Specification .where(null) .and(priceBetween(min, max)) .and(statusEquals(status)); ListProduct list productRepository.findAll(spec);Repository要支持Specification需要额外继承JpaSpecificationExecutorpublic interface ProductRepository extends JpaRepositoryProduct, Long, JpaSpecificationExecutorProduct { }EntityGraph则专门用来解决关联对象的加载问题。如果你查询商品时要关联加载分类信息懒加载会让N1查询爆炸直接在查询里声明:EntityGraph(attributePaths category) Query(SELECT p FROM Product p WHERE p.status :status) ListProduct findByStatusWithCategory(Param(status) ProductStatus status);这样生成的SQL会一次性把category联查出来避免逐个访问时再触发数据库查询。5.2 什么时候选谁我的实用决策标准我自己的选择逻辑有一套很朴素的判断标准单表简单查询条件少于三个优先方法名派生因为代码最简洁固定复杂查询查询结果对象、表关系、排序规则基本不变直接用Query并尽量用JPQL条件组合不固定、后台系统里那种筛选面板极其丰富的场景用Specification动态组装容易扩展和维护提到关联对象懒加载先想到EntityGraph别急着改fetch类型。这里要特别提醒一点方法名派生的方法名既是代码也是文档但千万别把业务逻辑硬塞进名字里。一个超长的方法名新人看到都会头皮发麻遇到复杂查询多写一个Query不丢人。5.3 Query里写JPQL还是原生SQL听我一句劝很多有MyBatis背景的人一上来就写nativeQuery true因为原生SQL手感熟悉。我的建议是能用JPQL就别用原生SQL除非你遇到了极端复杂的数据库特性比如特定的窗口函数、复杂的存储过程调用。原因有几个第一JPQL面向实体和属性和数据库表结构实现解耦换数据库方言时不用重写语句第二原生SQL映射到实体时返回结构一旦和实体字段对不上很容易出现莫名其妙的类型转换错误第三JPQL自带实体生命周期管理和持久化上下文的联动原生SQL在级联处理和缓存使用上都没有那么顺滑。如果你在某些场景确实必须用原生SQL那么分页查询一定要额外写好countQueryQuery(value SELECT * FROM product WHERE price ?1, countQuery SELECT COUNT(*) FROM product WHERE price ?1, nativeQuery true) PageProduct findByNativePrice(BigDecimal price, Pageable pageable);不写countQuery时框架会自己生成一条简化版的count语句但因为原生SQL表达力太强框架自动生成的count语句经常不符合预期导致分页总数统计错乱。我踩过一次当时统计数总是和实际数对不上排查了半天才发现是countQuery没有显式声明。6. 用了三年Spring Data我在这里踩过最深的几个坑6.1 驼峰命名策略你以为的userName不一定是user_nameSpring Boot 2.x版本里Hibernate默认的物理命名策略会把实体属性的驼峰命名转换成下划线命名。比如实体里是userName默认生成的列名是user_name实体里写passwordHash默认列名是password_hash。问题最容易出现在新老项目交接时。老项目的数据库字段可能已经叫user_name实体属性你写成了username那么默认策略下生成的列名是username和实际库里的user_name对不上启动时不会立刻报错一旦查询就会报“unknown column”。反过来实体属性是userName库里列名叫username也会出问题。碰到这类情况最稳妥的做法是显式指定列名在字段上加上Column(name user_name)不要依赖默认命名策略。我曾经接过一个改造项目就是因为十几张表全部是驼峰字段风格而新框架默认下划线策略上线前测试才发现一堆列名映射错误返工成本极高。6.2 懒加载在JSON序列化和事务边界外的“幽灵异常”JPA默认的关联关系比如ManyToOne通常都是懒加载。实体在事务内查询时正常一旦出了事务边界你想把实体直接返回给前端或做JSON序列化就会遇到经典的LazyInitializationException提示你无法访问懒加载属性因为没有会话。很多人第一反应是在关联字段上设置fetch FetchType.EAGER或者在整个实体上注解JsonIgnore。这两种我都试过体验都很糟。EAGER会让每次查询都强制加载所有关联数据性能下降很快尤其列表页根本不需要这些数据JsonIgnore则是把数据藏起来前端拿不到治标不治本。我的建议是实体和传输对象要做分离。Controller层不要直接返回JPA实体先把需要的数据映射成DTO再序列化返回。查询时需要用关联数据就使用EntityGraph或JOIN FETCH主动加载不需要就保持懒加载状态。这虽然多写一点映射代码但长期维护下来是最稳的。6.3 同一事务里批量更新的那些“灵异事件”这是一个让我印象特别深的问题。在一个事务里批量处理一堆商品价格时我循环了商品列表修改每个商品的价格然后再次查询某个商品居然拿到了旧的价格——明明刚才set过新值。当时第一反应是代码哪里写错了排查到最后才发现是JPA一级缓存惹的祸。JPA的EntityManager有一个一级缓存同一个事务内同一个ID的实体只会被查询一次并放入缓存。后续再查同一个ID时只要事务没有结束它默认直接从缓存返回。所以如果代码流程是先查询A商品修改某字段然后又重新findById拿到的还是第一次查询时的那个对象引用。在新代码里你修改对象属性时修改的是同一个引用所以后一次读取到的其实是修改后的值这通常在单线程事务里问题不大但当多个步骤、多线程交织时一级缓存和flush的时序会导致你看到的“旧值”和“新值”混乱不堪。理解这个机制后正确的处理方式是不轻易调用findById去拿引用而是直接用查询列表返回的结果集需要强制刷新时调用entityManager.flush()和entityManager.clear()清除一级缓存。这不算高频场景但碰上定时批处理任务时非常磨人。6.4 关于N1查询我很后悔没有早点做规范化N1是JPA最经典的问题。在列表页查询商品列表时只查了商品表然后遍历列表获取每个商品的分类名称每遍历一个就会触发一条分类查询。商品有100条最终执行101条SQL。这问题我早年在项目里出现过很多次后来总结了一句话列表页和详情页永远先考虑批量加载。用Query做JOIN FETCH或者用EntityGraph声明额外加载路径或者在Repository里写批量查询方法都比逐个访问关联对象好。还有个小技巧是利用分页查询时的批处理机制把集合型关联字段加上BatchSize注解让Hibernate在加载时通过IN查询批量获取关联数据。但这对字段位置有要求不是到处都能适用真要优化数量级还是用DTO和联查更直观。6.5 该回滚的一定要回滚别信”扩展模块能跟着事务走“的错觉前面提到过JPA与Redis等模块混合使用时事务一致性要靠自己。比如业务要求扣库存的同时更新缓存里的库存数据数据库扣减是在事务里缓存更新也在这段逻辑里如果后面抛了异常数据库回滚了缓存的更新却可能已经执行两边就出现了不一致。解决思路有几个先更新缓存再做数据库操作让数据库成为最终一致性的判断依据或者把缓存更新放到事务提交后的事件监听里更极端的直接让缓存从数据库读取不做同步写入。没有银弹能靠的就是对事务边界的敬畏——每次在事务里写了非JPA代码先问自己一句如果这里回滚了外面这些数据会不会不一致。7. 该不该用Spring Data我的选型建议和落地心得7.1 适合用的场景和不适合的场景先说你该放心用的场景。凡是标准CRUD占主导的业务系统Spring Data JPA几乎都是省事的选择。后台管理系统、运营平台、典型的订单-用户-商品结构这类项目用Spring Data可以大幅减少样板代码而且可读性高新人上手也快。做数据密集型报表或者对SQL执行计划有极致调优需求的系统Spring Data未必合适因为你想控制SQL的每一个细节时框架会变成一个掣肘。极端复杂的多表关联、存储过程调用、特定数据库的批量特性这类场景MyBatis或JdbcTemplate反而更容易精准控场。选型时不要单看功能还要看团队的技术积累。如果一个团队里大部分人对SQL和数据库优化非常熟但不太懂对象关系映射的深层原理强上Spring Data会让他们觉得像隔了一层纱。相反团队整体熟悉JPA实体生命周期、持久化上下文、懒加载机制那Spring Data会让协作效率明显提升。7.2 如果从零开始学Spring Data我会这么做说点我自己的学习路径建议。先花一天时间老老实实用手写JDBC做一个增删改查的完整流程体验一遍连接打开、语句执行、结果集映射、资源关闭然后了解动态代理的简单用法知道Spring能在运行时生成接口实现类的原理最后再碰Spring Data这时候你看到JpaRepository时就不会觉得是魔法反而会想原来它是在替我写那些我熟得要命的东西。不要一上来就纠缠各种复杂方法名派生。先掌握save、findById、findAll、deleteById这几个通用操作再学会一个Query自定义查询大部分日常需求都能覆盖。之后按需深入学习Specification和EntityGraph。这样安排节奏你会在最短时间内获得正反馈而不是被一堆抽象概念淹没。做个简单的横向对比基本能帮你确定自己该不该深钻Spring Data维度Spring Data JPAMyBatis手工JDBC/JdbcTemplate开发效率极高CRUD几乎零代码中高但需手写SQL低样板代码多SQL掌控力中可通过Query控制强SQL完全在手强缓存机制一级缓存可选二级缓存一般靠扩展无学习曲线中概念抽象但成体系低写SQL就会中但理解底层层报表复杂查询较吃力顺手顺手我个人的体会是Spring Data解决的是数据访问中那部分“重复而无趣”的工作而不是所有数据访问问题的终极答案。真正用好它的前提是你对底层机制有清醒的认知——知道它在什么时候委托给了Hibernate什么时候在方法名解析层就把需求吞了什么时候又因为一分懒加载把你坑得欲哭无泪。等你能把这套逻辑内化成自己的判断你会发现在绝大多数业务项目里它依然是最值得优先考虑的持久层方案之一。如果你现在正好在学或者正在用Spring Data我最后再分享一个小习惯遇到任何不太确定的Repository方法先打开show-sql看一眼它生成的SQL再想想这个SQL有没有可能在数据量大时拖垮接口。保持这个习惯你会比大多数只抄注解的人更早成为项目里那个能搞定数据访问问题的人。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进