
1. 项目概述为什么在Spring Boot里还要同时聊JDBC和JPA在某高校后端开发实训课上我带过一批刚学完Java基础、正啃《Spring实战》第4章的学生。第一周作业是“写个用户注册接口”结果交上来的代码五花八门有人用原生DriverManager.getConnection()手拼SQL连PreparedStatement的?占位符都漏写有人直接把Entity类扔进RestController返回JSON数据库字段名和JSON键对不上还死活查不到原因还有人卡在Transactional不生效上反复重启应用——其实就差一行EnableTransactionManagement没加。这些不是理论漏洞是真实踩出来的坑。这恰恰说明一个问题Spring Boot的自动配置再强大也掩盖不了底层数据访问机制的本质差异。你用JdbcTemplate执行一条INSERT和用JPA调用userRepository.save()表面看都是“存数据”但背后走的是两条完全不同的技术路径前者是面向SQL的命令式操作后者是面向对象的声明式映射。不厘清这个区别就像开车不看仪表盘——油表见底了还在猛踩油门。所以这篇内容不是教你怎么“选一个”而是带你把JDBC和JPA放在同一张解剖台上看清它们的肌肉走向、神经连接和供血路径。你会看到JdbcTemplate如何把String sql INSERT INTO user(name, age) VALUES(?, ?)这种字符串安全地编译成预编译语句并绑定参数SimpleJdbcInsert怎么通过反射读取实体类字段自动生成INSERT INTO user (name, age) VALUES (:name, :age)这样的命名参数SQLJPA的EntityManager如何在save()时触发一级缓存检查、脏数据检测、级联保存甚至可能悄悄执行多条SQL当你需要批量插入10万条日志时为什么JdbcTemplate.batchUpdate()比JpaRepository.saveAll()快3倍以上而JPA却要开ModifyingQuery才能绕过实体生命周期。关键词全部落在“Spring Boot”“JDBC”“JPA”“JdbcTemplate”“SimpleJdbcInsert”这五个词上没有一个跑偏。它适合三类人刚从SSM框架转过来想搞懂Spring Boot数据层的同学、正在做性能压测发现DAO层拖后腿的工程师、以及需要给团队写内部技术规范的技术负责人。接下来我们就从设计源头开始拆解。2. 核心设计思路为什么Spring Boot要保留两套数据访问方案2.1 JDBC与JPA的根本分歧SQL驱动 vs 对象驱动先说结论JDBC是SQL的搬运工JPA是对象的翻译官。这个比喻不是为了好记而是直指核心。JDBCJava Database Connectivity本质是一套标准API定义了Connection、Statement、ResultSet这些接口。Spring的JdbcTemplate只是对它的封装——它不改变JDBC的DNA只是帮你省去try-catch-finally里关连接的样板代码。你写的每一条SQL最终都会原封不动地发给数据库执行。比如String sql SELECT id, name, email FROM user WHERE status ? AND created_at ?; ListUser users jdbcTemplate.query(sql, new BeanPropertyRowMapper(User.class), ACTIVE, LocalDateTime.now().minusDays(7) );这里sql字符串就是你的“命令”jdbcTemplate.query()就是执行命令的“士兵”。士兵不会质疑命令是否合理也不会帮你把User对象自动转成INSERT语句——它只忠实地执行你下达的每一行指令。而JPAJava Persistence API是另一套哲学。它不关心SQL只关心Java对象。你定义一个Entity类Entity Table(name user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name user_name) private String name; Column(name email_addr) private String email; }然后调用userRepository.save(new User(张三, zhangexample.com))JPA实现如Hibernate会干这些事检查User对象是否在一级缓存中避免重复加载判断id为null确定这是新对象走INSERT流程根据Table和Column注解拼出INSERT INTO user (user_name, email_addr) VALUES (?, ?)绑定参数执行SQL把生成的主键值回填到User.id字段将该对象放入一级缓存后续同ID查询直接返回。你看你没写一句SQL但背后发生了6步逻辑。这就是“对象驱动”的代价与收益你获得开发效率但失去对SQL执行路径的绝对控制权。提示很多初学者以为“用了JPA就不用写SQL了”这是巨大误解。当JPA生成的SQL性能差比如N1查询、或需要复杂关联更新时你依然得写Query原生SQL。JPA不是SQL的替代品而是SQL的“高级调度员”。2.2 Spring Boot的务实选择不替你做决定但给你最顺手的工具Spring Boot的spring-boot-starter-jdbc和spring-boot-starter-data-jpa两个starter并非互斥选项而是分层供给spring-boot-starter-jdbc提供JdbcTemplate、SimpleJdbcInsert、NamedParameterJdbcTemplate等定位是轻量、可控、贴近SQL。它适合报表类查询大量GROUP BY、WINDOW FUNCTION批量数据迁移从老系统导数据字段映射复杂对性能极度敏感的场景如金融交易日志写入要求微秒级响应需要精确控制事务边界的场景比如一个方法里既要更新订单又要调用第三方支付必须保证两者原子性。spring-boot-starter-data-jpa则提供JpaRepository、Query、EntityGraph等定位是快速建模、减少样板、支持领域驱动。它适合CRUD为主的业务系统CMS、OA、CRM需要快速迭代的MVP产品用Entity定义模型几分钟就能生成REST API团队有较强领域建模能力能合理设计OneToMany、ManyToMany关系需要审计、软删除、多租户等横切关注点通过MappedSuperclass统一注入。Spring Boot没强制你二选一是因为现实项目从来不是非黑即白。我参与过的一个电商后台系统商品管理模块用JPA因为SKU、SPU、分类关系复杂而订单对账模块用JdbcTemplate因为要对比千万级订单表和银行流水表需手写MERGE INTO语句。这种混合模式才是工程常态。2.3 JdbcTemplate与SimpleJdbcInsert的分工逻辑谁该负责什么很多人把SimpleJdbcInsert当成JdbcTemplate的“升级版”这是典型误区。它们解决的是不同维度的问题维度JdbcTemplateSimpleJdbcInsert核心职责执行任意SQLSELECT/INSERT/UPDATE/DELETE专精于INSERT操作的便捷封装SQL来源你手动编写SQL字符串自动生成SQL基于表结构实体类参数绑定位置参数?或命名参数:name强制命名参数:name,:email主键处理不处理需额外调用getGeneratedKeys()自动获取并回填主键支持IDENTITY、SEQUENCE适用场景复杂查询、动态条件、批量操作单表插入、字段映射固定、追求开发速度举个实际例子假设你要插入用户但邮箱字段允许为空而数据库设了DEFAULT CURRENT_TIMESTAMP。用JdbcTemplate// 必须显式写出所有字段空值用null占位 String sql INSERT INTO user(name, email, created_at) VALUES(?, ?, ?); jdbcTemplate.update(sql, 李四, null, LocalDateTime.now());而SimpleJdbcInsert可以这样// 只传非空字段created_at由数据库默认值填充 SimpleJdbcInsert insert new SimpleJdbcInsert(jdbcTemplate) .withTableName(user) .usingGeneratedKeyColumns(id); // 告诉它主键是自增的 MapString, Object params new HashMap(); params.put(name, 李四); // email没put就相当于NULLcreated_at没提就用DEFAULT Number generatedId insert.executeAndReturnKey(params);看到区别了吗JdbcTemplate像一把瑞士军刀功能全但每用一次都要自己调校SimpleJdbcInsert像一把专用螺丝刀只干插入一件事但拧螺丝又快又准。Spring Boot保留两者正是因为它尊重不同场景下的“工具理性”。3. 核心细节解析手把手拆解JDBC与JPA的关键实操环节3.1 JdbcTemplate的三大核心能力查询、更新、批量操作查询BeanPropertyRowMapper不是万能的但足够聪明JdbcTemplate.query()最常用的RowMapper是BeanPropertyRowMapper它通过反射将ResultSet列名映射到Java Bean属性。但这里有两个关键细节常被忽略第一列名匹配规则BeanPropertyRowMapper默认按snake_case转camelCase。比如数据库列user_name会自动映射到User.name字段前提是User类有setName()方法。但如果列名是USERNAME它就找不到setUsername()——因为默认策略是underscore_to_camel_case。解决方案是显式指定BeanPropertyRowMapperUser mapper BeanPropertyRowMapper.newInstance(User.class); mapper.setCaseInsensitive(true); // 忽略大小写 mapper.setMappedClass(User.class); ListUser users jdbcTemplate.query(sql, mapper, params);第二空值处理陷阱如果数据库字段为NULL而Java Bean属性是基本类型如int ageBeanPropertyRowMapper会抛NullPointerException。必须改用包装类型Integer age。这是硬性约束没有绕过办法。更新update()方法的返回值是黄金指标JdbcTemplate.update()返回int表示影响的行数。这个数字不是摆设而是验证逻辑正确性的第一道防线// 删除用户前先检查是否存在 String checkSql SELECT COUNT(*) FROM user WHERE id ?; int count jdbcTemplate.queryForObject(checkSql, Integer.class, userId); if (count 0) { throw new UserNotFoundException(用户不存在); } // 执行删除 String deleteSql DELETE FROM user WHERE id ?; int affected jdbcTemplate.update(deleteSql, userId); if (affected ! 1) { // 如果返回0说明WHERE条件没匹配到任何行 throw new RuntimeException(删除失败未找到ID为 userId 的用户); }很多线上Bug就源于忽略这个返回值。比如你写UPDATE user SET statusDELETED WHERE id?但id传错了update()返回0程序却继续往下走导致业务状态不一致。批量操作batchUpdate()的两种姿势与性能真相JdbcTemplate.batchUpdate()有两种重载batchUpdate(String sql, BatchPreparedStatementSetter pss)适用于SQL相同、参数不同的场景如批量插入用户batchUpdate(String[] sql)适用于SQL不同的场景如一条INSERT、一条UPDATE混用。重点说第一种因为它最常用也最容易出错// 错误示范在循环里反复创建PreparedStatement ListUser users getUsersToInsert(); for (User user : users) { String sql INSERT INTO user(name, email) VALUES(?, ?); jdbcTemplate.update(sql, user.getName(), user.getEmail()); // 每次都编译SQL } // 正确示范用batchUpdate一次编译多次执行 String sql INSERT INTO user(name, email) VALUES(?, ?); jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { Override public void setValues(PreparedStatement ps, int i) throws SQLException { User user users.get(i); ps.setString(1, user.getName()); ps.setString(2, user.getEmail()); } Override public int getBatchSize() { return users.size(); } });性能差距有多大实测1000条记录循环update()耗时约850ms每次编译执行batchUpdate()耗时约120ms一次编译1000次执行。原理很简单数据库对预编译语句Prepared Statement有缓存机制。batchUpdate()让数据库只编译一次INSERT INTO user(name, email) VALUES(?, ?)后续1000次都复用这个执行计划。而循环update()等于让数据库编译1000次相同的SQL纯属浪费。注意batchUpdate()的批处理大小不是越大越好。MySQL默认max_allowed_packet4MB如果单条SQL参数太大可能触发包超限。建议单批控制在100~500条根据字段长度实测调整。3.2 JPA的核心生命周期save()背后隐藏的6个步骤JpaRepository.save()看似简单但它是JPA实体状态机的入口。理解这个状态机是避免“数据没保存”“版本号冲突”等问题的关键。JPA定义了四种实体状态Transient瞬时刚new出来的对象没和EntityManager关联Persistent持久已调用save()或find()在EntityManager一级缓存中Detached游离EntityManager关闭后对象仍存在内存中Removed删除调用delete()后等待flush()真正删除。save()方法的行为取决于当前状态当前状态save()行为实际执行SQLTransient变为Persistent触发INSERTINSERT INTO user (...) VALUES (...)Persistent检查是否修改脏检查若修改则UPDATEUPDATE user SET ... WHERE id ?Detached先merge()重新关联再按Persistent处理SELECT ... WHERE id ?UPDATE ...Removed无效操作抛异常无这就是为什么你有时save()后数据库没变化——因为对象已经是Persistent状态且字段没变JPA认为无需UPDATE。更隐蔽的问题是Detached状态。比如你在Controller里查出一个User传给Service层修改再save()// Controller User user userRepository.findById(1L).orElseThrow(); model.addAttribute(user, user); // 传给前端 // Service在另一个事务里 user.setName(王五); userRepository.save(user); // 这里user是Detached状态此时save()会先执行SELECT查一遍因为要merge再UPDATE。如果并发高两次查询间数据被别人改了就会覆盖别人的修改。正确做法是// 在Service里重新查一次推荐 User dbUser userRepository.findById(user.getId()).orElseThrow(); dbUser.setName(user.getName()); userRepository.save(dbUser); // 或者用Version乐观锁需User类加Version Long version3.3 SimpleJdbcInsert的三个隐藏技巧超越基础用法SimpleJdbcInsert常被当作“玩具”但它有几个生产环境真有用的技巧技巧一动态表名与字段名SimpleJdbcInsert支持运行时指定表名和字段这对多租户场景极有价值。比如SaaS系统中不同客户数据隔离在不同表// 表名格式user_tenant_001, user_tenant_002... String tenantId getCurrentTenantId(); String tableName user_tenant_ tenantId; SimpleJdbcInsert insert new SimpleJdbcInsert(jdbcTemplate) .withTableName(tableName) .usingGeneratedKeyColumns(id); MapString, Object params Map.of(name, 赵六, email, zhaoexample.com); Number id insert.executeAndReturnKey(params);技巧二复合主键的优雅处理当表有联合主键如order_id item_seq时SimpleJdbcInsert也能处理SimpleJdbcInsert insert new SimpleJdbcInsert(jdbcTemplate) .withTableName(order_item) .usingColumns(order_id, item_seq) // 显式指定主键列 .withSchema(sales); // 指定schema MapString, Object params Map.of( order_id, 1001L, item_seq, 1, product_name, iPhone 15, quantity, 2 ); // executeAndReturnKey()返回CompositeKey对象可强转 CompositeKey key (CompositeKey) insert.executeAndReturnKey(params);技巧三批量插入的性能开关SimpleJdbcInsert本身不支持批量但可以结合JdbcTemplate.batchUpdate()使用// 先用SimpleJdbcInsert生成SQL模板 SimpleJdbcInsert insert new SimpleJdbcInsert(jdbcTemplate) .withTableName(user) .usingGeneratedKeyColumns(id); String sql insert.getInsertString(); // 获取INSERT语句 // 再用batchUpdate执行 jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { Override public void setValues(PreparedStatement ps, int i) throws SQLException { User user users.get(i); ps.setString(1, user.getName()); ps.setString(2, user.getEmail()); } Override public int getBatchSize() { return users.size(); } });这样既享受了SimpleJdbcInsert自动生成SQL的便利又获得了batchUpdate()的批量性能。4. 实操过程详解从零搭建对比Demo逐行分析执行效果4.1 环境准备最小化依赖与配置我们用Spring Boot 3.2最新稳定版搭建一个极简项目只引入必要依赖!-- pom.xml -- dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency /dependenciesH2数据库用于本地演示配置application.ymlspring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true # 开启H2控制台访问 http://localhost:8080/h2-console jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop # 每次启动重建表方便演示 show-sql: true properties: hibernate: format_sql: true注意ddl-auto: create-drop仅用于开发。生产环境必须设为validate或none由Flyway/Liquibase管理DDL。4.2 数据库建模一张表三种写法我们只建一张user表但用三种方式操作它凸显差异CREATE TABLE user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, email VARCHAR(100), status VARCHAR(20) DEFAULT ACTIVE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );对应的Java实体Entity Table(name user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name name, nullable false) private String name; Column(name email) private String email; Column(name status, length 20) private String status ACTIVE; Column(name created_at) private LocalDateTime createdAt; Column(name updated_at) private LocalDateTime updatedAt; // 构造函数、getter/setter省略 }4.3 JDBC操作实录JdbcTemplate与SimpleJdbcInsert的现场对比场景一单条插入看SQL生成差异写一个测试方法Test void testInsertWithJdbcTemplate() { String sql INSERT INTO user(name, email, status) VALUES(?, ?, ?); int rows jdbcTemplate.update(sql, JDBC用户, jdbcexample.com, PENDING); System.out.println(JdbcTemplate插入影响行数 rows); }控制台输出Executing prepared SQL update Executed SQL update (1 row): INSERT INTO user(name, email, status) VALUES(?, ?, ?) JdbcTemplate插入影响行数1再试SimpleJdbcInsertTest void testInsertWithSimpleJdbcInsert() { SimpleJdbcInsert insert new SimpleJdbcInsert(jdbcTemplate) .withTableName(user) .usingGeneratedKeyColumns(id); MapString, Object params Map.of( name, SimpleJDBC用户, email, simpleexample.com, status, PENDING ); Number id insert.executeAndReturnKey(params); System.out.println(SimpleJdbcInsert生成ID id.longValue()); }控制台输出Executing SQL statement [INSERT INTO user (name, email, status) VALUES (?, ?, ?)] Generated key: 2 SimpleJdbcInsert生成ID2关键发现两者生成的SQL完全一样SimpleJdbcInsert只是帮你省去了手写SQL和参数绑定的体力活底层仍是JdbcTemplate。场景二批量插入性能实测我们插入1000条测试数据分别用两种方式// JdbcTemplate.batchUpdate() long start System.currentTimeMillis(); String sql INSERT INTO user(name, email, status) VALUES(?, ?, ?); jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { Override public void setValues(PreparedStatement ps, int i) throws SQLException { ps.setString(1, BatchUser i); ps.setString(2, batch i example.com); ps.setString(3, ACTIVE); } Override public int getBatchSize() { return 1000; } }); long end System.currentTimeMillis(); System.out.println(JdbcTemplate.batchUpdate 1000条耗时 (end - start) ms);// JPA saveAll() start System.currentTimeMillis(); ListUser users IntStream.range(0, 1000) .mapToObj(i - { User u new User(); u.setName(JPAUser i); u.setEmail(jpa i example.com); u.setStatus(ACTIVE); return u; }) .collect(Collectors.toList()); userRepository.saveAll(users); end System.currentTimeMillis(); System.out.println(JPA saveAll 1000条耗时 (end - start) ms);实测结果H2内存数据库本机Mac M1JdbcTemplate.batchUpdate约95msJPA saveAll约320ms。差距主要来自JPA的开销每条记录都要走EntityManager状态检查、一级缓存管理、脏数据检测。如果你的业务真的需要高频批量写入JdbcTemplate是更务实的选择。4.4 JPA操作实录save()、saveAll()、Query的执行路径剖析场景一save()的“静默更新”现象写一个测试Test void testSaveSilentUpdate() { // 先查出用户 User user userRepository.findById(1L).orElseThrow(); System.out.println(查询到用户 user.getName() , 状态 user.getStatus()); // 修改状态 user.setStatus(INACTIVE); // 再save userRepository.save(user); System.out.println(save后用户状态 user.getStatus()); }控制台SQL输出select ... from user where id? // 第一次查询 update user set ... where id? // save时的UPDATE注意save()后user.getStatus()还是INACTIVE但数据库已更新。这是因为JPA的save()是同步操作执行完SQL立即生效。场景二saveAll()的N1问题预警saveAll()默认是逐条执行但你可以开启批量# application.yml spring: jpa: properties: hibernate: jdbc: batch_size: 20 # 每20条合并为一个批次 order_inserts: true order_updates: true开启后1000条插入的SQL从1000条INSERT变成约50条每批20条。但注意batch_size只对INSERT/UPDATE有效DELETE仍逐条执行。场景三Query原生SQL的不可替代性当JPA无法满足复杂查询时Query是救命稻草。比如统计每个状态的用户数Repository public interface UserRepository extends JpaRepositoryUser, Long { Query(SELECT u.status, COUNT(*) FROM User u GROUP BY u.status) ListObject[] countByStatus(); // 或者用原生SQL Query(value SELECT status, COUNT(*) FROM user GROUP BY status, nativeQuery true) ListObject[] countByStatusNative(); }调用countByStatus()JPA会生成select user0_.status as col_0_0_, count(*) as col_1_0_ from user user0_ group by user0_.status而countByStatusNative()直接执行你写的SQL不经过JPA解析。这是性能关键点原生SQL绕过JPA的ORM层执行更快但失去数据库无关性。5. 常见问题与排查技巧实录那些文档里不写的坑5.1 JDBC常见问题速查表问题现象根本原因排查技巧解决方案SQLException: Parameter index out of rangePreparedStatement参数个数与?数量不匹配检查SQL字符串中的?个数再数setXXX()调用次数用IDE的SQL高亮插件如IntelliJ的Database Tools实时校验批量插入后数据库没数据但batchUpdate()返回正常事务未提交在Transactional方法外加Rollback(false)或用H2控制台实时查表确保方法在Transactional内或手动调用TransactionTemplateBeanPropertyRowMapper映射失败字段为null数据库列名与Java属性名不匹配大小写、下划线开启show-sql: true复制SQL到H2控制台执行看列名实际是什么用Column(namexxx)显式指定或setCaseInsensitive(true)SimpleJdbcInsert不返回主键表主键不是IDENTITY或SEQUENCE查数据库表结构SHOW CREATE TABLE user改用usingColumns(id)并确保id字段有AUTO_INCREMENT5.2 JPA经典陷阱与避坑指南陷阱一LazyInitializationException —— “懒加载异常”现象在Service层查出一个带OneToMany关系的User返回给Controller时抛org.hibernate.LazyInitializationException: failed to lazily initialize a collection。原因OneToMany默认fetch FetchType.LAZY即用到时才查。但Controller层EntityManager已关闭无法触发懒加载。解决方案有三方案A推荐用EntityGraph在Repository层一次性查全EntityGraph(attributePaths {orders, addresses}) ListUser findAllWithRelations();方案B在Controller层用Hibernate.initialize()强制初始化Hibernate.initialize(user.getOrders()); // 在返回前调用方案C改用FetchType.EAGER不推荐易引发N1。实操心得我在某电商项目里吃过亏。订单列表页要显示用户头像User和Avatar是OneToOne关系。一开始用EAGER结果查100个订单触发100次SELECT avatar。后来改成EntityGraph一次JOIN搞定QPS从120升到380。陷阱二Modifying Query的事务失效现象写了Modifying Query(UPDATE user SET status ?1 WHERE id ?2)但执行后数据库没变化。原因Modifying方法默认不开启事务必须显式标注Transactional。解决方案Transactional Modifying Query(UPDATE User u SET u.status :status WHERE u.id :id) int updateUserStatus(Param(status) String status, Param(id) Long id);注意Modifying方法不能返回List或Page只能返回int影响行数或void。陷阱三save()不触发数据库更新的“幽灵BUG”现象userRepository.save(user)后数据库字段没变。排查步骤检查user对象的id是否为null如果是JPA认为是新对象会INSERT而非UPDATE检查user是否是Detached状态用entityManager.contains(user)验证检查字段是否真的被修改JPA有脏检查机制如果user.setName(张三)但原来就是张三不会UPDATE检查Version字段是否冲突并发修改时版本号不匹配会静默失败。终极调试法开启logging.level.org.hibernate.SQLDEBUG和logging.level.org.hibernate.type.descriptor.sql.BasicBinderTRACE看JPA到底绑定了什么参数。5.3 JDBC与JPA混合使用的协同要点在真实项目中JDBC和JPA常共存。这时要注意三点协同第一事务管理器统一必须用同一个PlatformTransactionManager。Spring Boot自动配置DataSourceTransactionManager只要不手动定义多个事务管理器就天然统一。第二一级缓存隔离JdbcTemplate不走JPA的EntityManager所以JdbcTemplate.update()修改的数据JpaRepository.findById()可能查不到最新值因为一级缓存没刷新。解决方案调用entityManager.clear()清空缓存或用Cacheable注解配合CacheManager做二级缓存最佳实践读操作用JPA写操作用JDBC避免混合读写。第三数据库连接池共享JdbcTemplate和JPA都用DataSource所以HikariCP等连接池是共享的。这意味着JdbcTemplate.batchUpdate()占用的连接会影响JPA的查询并发数。监控连接池活跃数如/actuator/metrics/hikaricp.connections.active是性能调优关键。6. 性能与选型决策树什么情况下该用哪个6.1 一张表看懂技术选型逻辑场景特征推荐方案关键理由风险提示单表CRUD字段少关系简单如配置表、字典表JpaRepository开箱即用findAll()、findById()一行代码搞定避免过度设计别为5个字段写10个DTO复杂报表多表JOIN窗口函数如销售TOP10、月度同比JdbcTemplateQuery(nativetrue)直接写SQL性能可控避免JPA生成低效SQL需手动处理ResultSet代码量稍大高频批量写入如日志采集、IoT设备上报JdbcTemplate.batchUpdate()批处理性能碾压JPA无ORM开销需自行管理事务和错误回滚强一致性事务如转账扣A账户加B账户JdbcTemplateTransactionalSQL级事务控制精准无JPA缓存干扰需手写SQL开发成本略高领域模型复杂需事件驱动如订单创建触发库存扣减、消息推送JPAEntityListenersPostPersist等钩子天然支持业务事件过度使用监听器会导致事务臃肿6.2 实战决策树跟着问题走不跟概念走遇到具体问题时按此流程决策第一步问“这是读还是写”读多写少 → 优先JPA缓存友好写多读少 → 优先JDBC无ORM开销。第二步问“SQL复杂吗”简单WHERE条件 →