news 2026/9/10 7:14:31

Spring Boot整合JdbcTemplate:告别MyBatis繁琐,轻量数据访问实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot整合JdbcTemplate:告别MyBatis繁琐,轻量数据访问实战

Spring Boot 整合 JdbcTemplate,绕开 MyBatis 的繁琐也能把数据访问写得明明白白

先聊聊我自己的选型经历。早几年做项目,团队一上来就上 MyBatis,生成 XML、配置 mapper、管理 resultMap,一套流程下来,小项目光搭架子就耗掉半天。后来维护一个遗留系统,里面大量 SQL 是纯 JDBC 风格,压根没有 ORM,业务逻辑又复杂,硬套 MyBatis 反而别扭,最后我用 Spring Boot 整合 JdbcTemplate 把那一堆数据库操作重写了一遍,整个 DAO 层代码量直接缩了一半,而且 SQL 的掌控力还是在自己手里。这篇文章就把这套整合过程完整拆开,从环境准备、核心 API 使用、多数据源配置到性能优化和常见坑,全部基于我实际跑过的代码来写。无论你是刚接触 Spring Boot 的新手,还是被 MyBatis 的映射规则折腾到头疼的老手,只要想在项目里快速、轻量地把数据库操作跑起来,这篇都能直接参考着动手。

1. 整合思路:为什么偏偏是 JdbcTemplate

JdbcTemplate 是 Spring 对 JDBC 的轻量封装,核心价值一句话概括:它把“获取连接、创建 Statement、处理 ResultSet、释放资源”这些重复到令人麻木的样板代码全部吃掉,但同时又把 SQL 的执行权完整交回给你。相比 MyBatis 的“半自动 ORM”,相比 Spring Data JPA 的“全自动抽象”,JdbcTemplate 处在两者之间的微妙位置:没有 SQL 生成器,没有一级二级缓存,没有懒加载,但正因为没有这些机制,它在很多场景下更快、更可控、更容易排查问题。

选择 JdbcTemplate 的典型场景,我总结下来大概是这三类:

  • 表结构复杂、SQL 多变,需要精细控制每一条 SQL,ORM 的“自动生成 SQL”反而碍事。
  • 项目本身数据访问层不重,不想引入 MyBatis 的 XML 配置体系,也不想被 JPA 的实体状态管理搞迷糊。
  • 团队里新成员多,JdbcTemplate 的学习曲线比 MyBatis 和 JPA 平缓太多,掌握 JDBC 基础就能上手。

Spring Boot 对 JdbcTemplate 的支持非常彻底:只要在 classpath 里引入spring-boot-starter-jdbc,再配置好数据源,Spring Boot 的自动配置机制(具体是JdbcTemplateAutoConfiguration)就会自动创建JdbcTemplateNamedParameterJdbcTemplate这两个 Bean,你拿来就能注入使用,不需要写一行@Bean配置。这种“零配置”体验,是 Spring Boot 整合 JdbcTemplate 最舒服的地方。

2. 环境准备:依赖、配置、建表一次到位

2.1 最小依赖组合和版本选择

从 Spring Boot 2.x 到目前的 3.x,整合 JdbcTemplate 的依赖都没变化,差异主要在 JDK 版本和包名(3.x 需要 JDK 17,javax.*换成了jakarta.*)。我建议新项目直接上 Spring Boot 3.x,老项目保持原版本即可,核心代码差异不大。Maven 依赖就三个核心:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

第一行spring-boot-starter-jdbc是主角,它会传递引入spring-jdbcspring-tx(事务支持)以及 HikariCP 连接池。第二行是 MySQL 驱动,具体依赖名称随版本有差异:Spring Boot 2.x 管理的是mysql:mysql-connector-java,Spring Boot 3.x 改为com.mysql:mysql-connector-j,两者本质是同一个东西的坐标迁移。

注意:如果不小心在 Spring Boot 3.x 里用了mysql:mysql-connector-java,依赖能拉下来,但运行时会报驱动类加载异常,排查起来非常坑。建议用spring-boot-dependencies统一管理的版本,不要手动写版本号。

2.2 application.yml 配置数据源的关键参数

Spring Boot 的数据源配置高度集中在spring.datasource下。最基本的写法:

spring: datasource: url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000

几个参数我得单独说明一下,不然你照着写容易踩坑:

  • useSSL=false:本地开发连 MySQL 8.x 如果不开 SSL,不关这个参数会频繁报 SSL 握手警告,虽然不影响功能但日志很烦。
  • serverTimezone=Asia/Shanghai:MySQL 8.x 的连接器默认时区和中国差了 8 小时,不加这个参数,Timestamp查询结果会出现诡异的时间偏移。
  • allowPublicKeyRetrieval=true:新版 MySQL 驱动用caching_sha2_password认证时,首次连接可能需要这个参数,否则会报Public Key Retrieval is not allowed
  • driver-class-name其实可以省略,Spring Boot 会根据 URL 自动推断驱动类,但写出来更明确,尤其在多数据源场景下必须显式写。

HikariCP 参数里,我实际使用中比较在意maximum-pool-size。很多错误教程建议设成 100、200,在数据库连接数本来就紧张的场景直接打满数据库。合理的经验值是:maximum-pool-size = (CPU核数 × 2) + 有效磁盘数,一般 10 到 20 足够大多数业务系统使用。

2.3 建表与数据准备

为了后面演示 API 方便,这里先建一张非常通用的用户表:

CREATE TABLE `user_info` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL, `email` VARCHAR(100) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

对应的实体类:

@Data public class UserInfo { private Long id; private String username; private String password; private String email; private LocalDateTime createTime; }

这里用了@Data(Lombok),项目里需要额外加lombok依赖。如果你不想用 Lombok,手写 getter/setter 也完全没问题,JdbcTemplate 不关心这些。

3. JdbcTemplate 核心 API 实战:增删改查全拆解

3.1 注入方式与基础结构

Spring Boot 自动配置的JdbcTemplateNamedParameterJdbcTemplate可以直接注入到任何 Spring 管理的 Bean 中。我习惯的做法是在 Service 层直接注入JdbcTemplate,而不是再包一层 DAO。这样代码更扁平,但要注意职责边界,复杂 SQL 还是要沉淀到专门的 DAO 类:

@Service public class UserService { private final JdbcTemplate jdbcTemplate; public UserService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } }

3.2 插入数据:从update()GeneratedKeyHolder

插入单条记录最简单的写法:

public int insertUser(UserInfo user) { String sql = "INSERT INTO user_info (username, password, email) VALUES (?, ?, ?)"; return jdbcTemplate.update(sql, user.getUsername(), user.getPassword(), user.getEmail()); }

update()方法返回受影响行数,通常用来判断执行是否成功。但这里有个实战中几乎一定遇到的问题:插入后你需要自增主键 ID 回填到实体对象里,否则接下来要做关联操作还得再查一次。JdbcTemplate 提供了GeneratedKeyHolder

public int insertUserWithId(UserInfo user) { String sql = "INSERT INTO user_info (username, password, email) VALUES (?, ?, ?)"; KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); return ps; }, keyHolder); Number key = keyHolder.getKey(); if (key != null) { user.setId(key.longValue()); } return user.getId() != null ? 1 : 0; }

这里有个容易忽略的细节:prepareStatement时如果不传Statement.RETURN_GENERATED_KEYS,MySQL 驱动不会返回自增主键,keyHolder.getKey()会一直是null。这个参数是 JDBC 规范层面的,和 JdbcTemplate 封不封装没关系,很多封装框架内部帮我们做了,用裸 JDBC 封装时必须自己记着。

3.3 查询单条记录:queryForObject与 RowMapper

查单条最直观的方法:

public UserInfo getUserById(Long id) { String sql = "SELECT id, username, password, email, create_time FROM user_info WHERE id = ?"; return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper<>(UserInfo.class), id); }

BeanPropertyRowMapper能把结果集按 JavaBean 属性名自动映射,注意说的是“属性名”,即实体类里的createTime对应数据库的create_time,这种经典的下划线转驼峰映射,BeanPropertyRowMapper默认是不支持的,它会去找create_time属性而不是createTime属性,结果就是抛异常或者字段为null

所以实际项目里,我更推荐手写一个RowMapper,SQL 里直接用别名对齐实体属性名:

private static final RowMapper<UserInfo> USER_ROW_MAPPER = (rs, rowNum) -> { UserInfo user = new UserInfo(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setEmail(rs.getString("email")); user.setCreateTime(rs.getTimestamp("create_time").toLocalDateTime()); return user; }; public UserInfo getUserById(Long id) { String sql = "SELECT id, username, password, email, create_time FROM user_info WHERE id = ?"; return jdbcTemplate.queryForObject(sql, USER_ROW_MAPPER, id); }

这种方式瞧着多一点代码,但好处非常明显:映射逻辑完全掌握在自己手里,类型转换冲突、字段隔离改名、嵌套对象组装,都能在这个RowMapper里做。查多列、多表关联查询、DTO 属性名和表字段完全不一致时,这种手写RowMapper是收拾烂摊子的最佳方案。

如果表字段和实体属性刚好完全一致(都不带下划线),用BeanPropertyRowMapper是很省事的,但表名、字段命名稍有不规范,它的表现就会让新人大呼“为什么查出来全是 null”。

3.4 查询列表:query()与空结果集处理

查列表基本同上,只是用query()替换queryForObject()

public List<UserInfo> listUsers() { String sql = "SELECT id, username, password, email, create_time FROM user_info ORDER BY id DESC"; return jdbcTemplate.query(sql, USER_ROW_MAPPER); }

注意query()查询结果为空时返回的是空列表,不会抛异常。而queryForObject()在结果为空时抛EmptyResultDataAccessException,实际业务里查单条要提前判断“查不到怎么办”,我通常的做法有两种:

  • 捕获EmptyResultDataAccessException返回null
  • 改用query(...).stream().findFirst(),列表为空时返回Optional.empty()

第一种更直接,第二种少一次异常栈的开销。如果这个方法会被高频调用(比如按主键查缓存),建议用第二种。

public UserInfo getUserByIdQuietly(Long id) { String sql = "SELECT id, username, password, email, create_time FROM user_info WHERE id = ?"; List<UserInfo> list = jdbcTemplate.query(sql, USER_ROW_MAPPER, id); return list.isEmpty() ? null : list.get(0); }

3.5 更新和删除:update()的变体

更新操作和插入完全同一个套路:

public int updateEmail(Long id, String newEmail) { String sql = "UPDATE user_info SET email = ? WHERE id = ?"; return jdbcTemplate.update(sql, newEmail, id); } public int deleteUser(Long id) { String sql = "DELETE FROM user_info WHERE id = ?"; return jdbcTemplate.update(sql, id); }

注意update()返回的是影响行数,不是主键。删除时影响 0 行不代表 SQL 执行失败,只是条件没匹配到记录,业务层需要根据这个返回值决定是否抛出“数据不存在”之类的异常。

3.6 命名参数:NamedParameterJdbcTemplate让 SQL 不再数问号

?占位符时,参数一多,数问号真的是噩梦。比如一个更新 SQL 有七八个字段,中间漏了一个参数,运行时直接Incorrect column count或者参数错位,排查非常痛苦。

NamedParameterJdbcTemplate解决的就是这个痛点,它允许 SQL 里用:paramName形式的命名参数,参数通过MapSqlParameterSource传入:

@Service public class UserService { private final NamedParameterJdbcTemplate namedJdbcTemplate; public UserService(NamedParameterJdbcTemplate namedJdbcTemplate) { this.namedJdbcTemplate = namedJdbcTemplate; } public int insertUserByNamed(UserInfo user) { String sql = "INSERT INTO user_info (username, password, email) VALUES (:username, :password, :email)"; MapSqlParameterSource params = new MapSqlParameterSource() .addValue("username", user.getUsername()) .addValue("password", user.getPassword()) .addValue("email", user.getEmail()); return namedJdbcTemplate.update(sql, params); } }

NamedParameterJdbcTemplate本身就是对JdbcTemplate的包装,底层会把命名参数展开成?占位符再交给JdbcTemplate执行。所以事务、连接池、RowMapper 这些用法完全一致,只是换了个入口。我现在的项目基本都是直接注入NamedParameterJdbcTemplate,可读性提升了一个档次,强烈建议新代码直接用这个。

3.7 批量操作:batchUpdate的正确打开方式

批量插入常见两种做法,效果天差地别:

  • 写一个for循环,调一万次update()。性能差,每次都要走完整的 SQL 解析、执行、事务提交(如果没加外层事务),一万条数据跑起来肉眼可见的慢。
  • batchUpdate(),一次提交一批,JDBC 底层走批量执行,网络和数据库开销都压缩得很低。
public int[] batchInsertUsers(List<UserInfo> users) { String sql = "INSERT INTO user_info (username, password, email) VALUES (?, ?, ?)"; return jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { UserInfo user = users.get(i); ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); } @Override public int getBatchSize() { return users.size(); } }); }

实现BatchPreparedStatementSetter时要注意,setValues里的索引i是从 0 开始的,而users.get(i)也是从 0 开始,这两个从对应关系上不会错位。但如果你的users是个很大的集合,一次性batchUpdate会撑爆内存或数据库单次事务的大小,稳妥的做法是每 500 条一组分片提交。

关于返回的int[]:数组里的每个元素代表每条语句影响的行数。一般只要没有抛异常,就可以认为批量执行成功,不用太纠结具体值。

3.8 查询结果集映射的更多写法

除了手写RowMapperBeanPropertyRowMapper,还有一个常用变体是ColumnMapRowMapper,直接把每一行封装成Map<String, Object>,适合不需要强类型实体、临时取数据的场景:

public List<Map<String, Object>> listUserMaps() { return jdbcTemplate.queryForList("SELECT * FROM user_info"); }

这种写法方便灵活,但拿到手的是StringObject的松散结构,类型转换全靠自觉,适合报表、导出、临时调查这类场景,不适合核心业务逻辑。

4. 进阶玩法:多数据源、事务、查询超时控制

4.1 多数据源配置:从“双库操作”到“读改写分离”

JdbcTemplate 整合里最常遇到的进阶需求是多数据源。我曾经在一个项目里同时操作 MySQL(业务主库)和 PostgreSQL(报表库),同一个 Service 方法里先更新 MySQL 再查询 PostgreSQL。Spring Boot 默认只自动配置一个DataSource,多数据源时要用@Primary指定主数据源,并对第二个数据源手动构建。

@Configuration public class DataSourceConfig { @Bean @Primary @ConfigurationProperties(prefix = "spring.datasource.primary") public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties(prefix = "spring.datasource.secondary") public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } @Bean @Primary public JdbcTemplate primaryJdbcTemplate(@Qualifier("primaryDataSource") DataSource ds) { return new JdbcTemplate(ds); } @Bean public JdbcTemplate secondaryJdbcTemplate(@Qualifier("secondaryDataSource") DataSource ds) { return new JdbcTemplate(ds); } }

注意:@ConfigurationProperties(prefix = "spring.datasource.primary")意味着我们不再用spring.datasource.url统一配置,而是拆成两个独立配置块:

spring: datasource: primary: url: jdbc:mysql://localhost:3306/main_db... username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver secondary: url: jdbc:postgresql://localhost:5432/report_db... username: report_user password: 123456 driver-class-name: org.postgresql.Driver

然后使用时通过@Qualifier指定你要操作的数据源:

@Service public class MixedService { private final JdbcTemplate primaryJdbcTemplate; private final JdbcTemplate secondaryJdbcTemplate; public MixedService(@Qualifier("primaryJdbcTemplate") JdbcTemplate primaryJdbcTemplate, @Qualifier("secondaryJdbcTemplate") JdbcTemplate secondaryJdbcTemplate) { this.primaryJdbcTemplate = primaryJdbcTemplate; this.secondaryJdbcTemplate = secondaryJdbcTemplate; } }

这种方式清晰直白,适合数据源数量固定且少的场景。还有一个更动态的方案是继承AbstractRoutingDataSource,在运行时通过ThreadLocalAOP切面动态决定走哪个数据源,适合做读写分离(一个写库、多个读库)的场景,但架构复杂度明显上升,如果只是双库操作,没必要上路由那套。

4.2 事务管理:@Transactional的正确用法

JdbcTemplate 本身没有事务能力,但 Spring Boot 的DataSourceTransactionManager已经自动配置好了。只要在 Service 方法上加@Transactional,就能把多个数据库操作包进同一个事务:

@Transactional(rollbackFor = Exception.class) public void transferUser(Long fromUserId, Long toUserId, int amount) { String reduceSql = "UPDATE user_account SET balance = balance - ? WHERE id = ?"; String addSql = "UPDATE user_account SET balance = balance + ? WHERE id = ?"; jdbcTemplate.update(reduceSql, amount, fromUserId); // 模拟中间异常 if (amount > 10000) { throw new RuntimeException("转账金额超限"); } jdbcTemplate.update(addSql, amount, toUserId); }

加上rollbackFor = Exception.class非常关键。Spring 默认只在运行时异常(RuntimeException)和Error时才回滚,如果你抛的是自定义Exception而不指定rollbackFor,事务不会回滚,数据就产生奇怪的中间状态。

多数据源环境下@Transactional会默认绑定@Primary数据源对应的事务管理器,如果第二个数据源的操作也需要事务,就要指定不同的事务管理器或使用JtaTransactionManager实现分布式事务。这个复杂度比较高,建议能避免就避免,实在需要分布式事务再考虑 Seata 这类方案,而不是在 Spring 层面硬凑。

金玉良言:@Transactional失效的几大经典坑——方法必须被 Spring 代理调用(同类内部调用直接失效)、方法必须public、异常必须传播出方法边界、数据库引擎必须支持事务(MyISAM 表结构加了也没用)。每个我都踩过。

4.3 查询超时控制:防止慢 SQL 拖垮整个连接池

JdbcTemplate 提供了queryForObject(sql, requiredType, queryTimeout)之类的重载方法,也可以给JdbcTemplate设置全局超时:

JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); jdbcTemplate.setQueryTimeout(5); // 单位:秒

实际项目里如果有个别 SQL 特别容易慢,我建议不要全局设置(会影响所有查询),而是在执行前用jdbcTemplate.setQueryTimeout(5)临时调整,执行后马上恢复默认值。更优雅的方式是使用 Spring 的TransactionTemplate或 AOP 对特定 Service 方法做超时控制。

关于超时要注意:setQueryTimeout最终是设置 JDBC 驱动的Statement.setQueryTimeout,MySQL 驱动在部分网络异常场景下可能不会严格执行这个超时,所以它更多是“尽力而为”的兜底措施,不能替代数据库层面的max_execution_time优化。

5. 常见问题与排查技巧:我踩过的坑都在这

5.1 中文乱码问题

现象是数据库里保存的中文变成了??,或者读出来一片乱码。这个问题基本绕不开三个层面:

  • 数据库表字符集:建表时用ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,utf8mb4 才是真正的完整中文支持,老的utf8(utf8mb3)对四字节表情符号会报错。
  • JDBC 连接串:useUnicode=true&characterEncoding=utf8要带上,现在新版 MySQL 驱动对 utf8mb4 支持更完善,但老项目迁移后容易漏配置。
  • 应用层编码:Spring Boot 默认使用UTF-8,一般不用改。如果还有乱码,检查server.servlet.encoding相关配置和请求响应头里的字符集。

排查顺序建议:先直接在数据库客户端执行 SQL 确认存储的数据是否正常,再挨个检查连接串和表字符集,最后才是应用层编码。数据库层面已经乱掉的时候,改应用层代码永远救不回来。

5.2 连接泄漏导致连接池耗尽

症状是系统跑一段时间后,接口响应越来越慢,数据库连接池报HikariPool-1 - Connection is not available, request timed out

绝大多数原因是代码里没有正确释放连接。用JdbcTemplate的好处是不需要手动管连接,但它依赖的DataSource如果被直接注入并用原生 JDBC 方式操作,忘记在finally里关闭连接,就会泄漏。另外,Spring@Transactional方法中如果出现异常链路不清、事务传播配置混乱,也会导致连接不释放。

排查方法:打开 HikariCP 的日志级别,调spring.datasource.hikari.pool-name和日志框架对com.zaxxer.hikari的 DEBUG 级别,再看连接池里active连接数量。出现异常峰值时,把线程栈 dump 出来(jstack),找到持有连接但卡住的线程,问题代码基本就现形了。

5.3 SQL 注入与参数绑定的边界

JdbcTemplate 的update(sql, args...)query(sql, mapper, args...)都支持参数绑定,?占位符的值是驱动层面预编译绑定,不会被拼接到 SQL 字符串里,天然防注入。真正危险的是自己拼 SQL:

// 危险:直接把参数拼进 SQL String sql = "SELECT * FROM user_info WHERE username = '" + username + "'";

这种代码即便用了 JdbcTemplate 也没有任何防护。凡涉及外部输入(前端传参、配置文件取值、接口返回数据)必须走占位符。如果参数是可变的排序字段、表名等结构对象,?无法绑定结构,需要在代码侧做白名单校验,比如排序字段只能匹配预设字符串集合。

5.4 Spring Boot 版本太高导致的依赖冲突

这个热词很多人碰到过。Spring Boot 3.x 要求 JDK 17,如果项目错用 JDK 8,各种类加载异常层出不穷。另外 3.x 把javax.servletjavax.persistence等迁到了jakarta.*命名空间,老项目迁移时所有import javax.annotation.Resource要批量替换成jakarta.annotation.Resource。我的建议是:新项目直接上 Boot 3.x 配 JDK 17,老项目如果用了大量第三方库(部分老库还没适配jakarta命名空间),慎重升级,升级前列个第三方依赖兼容性清单。

5.5 慢 SQL 的定位和优化

JdbcTemplate 没有内置慢 SQL 日志,但可以在 Spring Boot 配置里开启 Spring 的 JdbcTemplate 日志,看 SQL 执行时间:

logging: level: org.springframework.jdbc.core.JdbcTemplate: DEBUG

这个日志会打出执行的 SQL 和参数列表,但不会直接显示耗时。更实用的做法是用 MySQL 自身的慢查询日志:slow_query_log=ONlong_query_time=1(单位秒),把超过 1 秒的 SQL 全部记录出来,再结合EXPLAIN分析执行计划。索引没走、全表扫描这类问题,EXPLAINtype字段一眼就能看出ALLrefconst的差别。

真正影响 JdbcTemplate 性能的往往不是封装层,而是 SQL 本身。JdbcTemplate 的每次query都会走一遍 JDBC 驱动和数据库执行,SQL 写得烂,换任何框架都救不回来。

5.6 空结果集和类型转换的几个隐蔽坑

public Integer countUsers() { String sql = "SELECT COUNT(1) FROM user_info WHERE status = ?"; return jdbcTemplate.queryForObject(sql, Integer.class, 1); }

COUNT结果超过Integer.MAX_VALUE或者如果表里 records 为亿万级别,Integer会不够用,建议用Long.class。另一个类型坑是queryForObject(sql, LocalDate.class),某些 MySQL 驱动版本返回java.sql.Date不能直接转换为LocalDate,需要rs.getDate(...).toLocalDate()手转。

这类问题通常不会立即报错,而是特定数据量上来之后突然抛类型转换异常。处理原则是:实体类字段类型尽量和数据库返回类型一一对应,拿不准就先查出来用Object接,再手动转换。

6. 性能优化建议:让 JdbcTemplate 再快一点

6.1 合理设置 fetchSize

JdbcTemplate默认是一次把所有ResultSet拉到内存里,大结果集(几万行以上)会导致内存陡增。在 MySQL 驱动下,更稳妥的做法是设置fetchSize为较小的值(比如 500),让驱动分批读取:

private void queryLargeData() { String sql = "SELECT * FROM huge_table"; jdbcTemplate.setFetchSize(500); try { jdbcTemplate.query(sql, rs -> { while (rs.next()) { // 逐行处理 } return null; }); } finally { jdbcTemplate.setFetchSize(0); // 0 表示使用默认值 } }

注意,MySQL 驱动对fetchSize的支持和连接事务状态有关,某些旧版驱动在AUTOCOMMIT模式下不生效。遇到这种情况可以考虑Statement.setFetchSize加上按需分段查询,或者用游标机制处理超大结果集。

6.2 批处理分片,不要一把梭

前面说过batchUpdate,这里再展开讲。一次性batchUpdate一万条数据,数据库可能直接压力拉满,而且一旦中间某条失败,整批回滚还是部分回滚、日志怎么记都不好控制。我常用的分片策略:

public int batchInsertInSlice(List<UserInfo> users, int sliceSize) { String sql = "INSERT INTO user_info (username, password, email) VALUES (?, ?, ?)"; int total = 0; for (int i = 0; i < users.size(); i += sliceSize) { List<UserInfo> slice = users.subList(i, Math.min(i + sliceSize, users.size())); int[] result = jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int index) throws SQLException { UserInfo u = slice.get(index); ps.setString(1, u.getUsername()); ps.setString(2, u.getPassword()); ps.setString(3, u.getEmail()); } @Override public int getBatchSize() { return slice.size(); } }); total += Arrays.stream(result).sum(); } return total; }

分片大小我一般先按 500 起步,实测数据库耗时、网络往返都正常就保持;如果单批执行时间变长,调小到 200。不同数据库、不同服务器配置最合适的值不一样,没有万能参数,跑个压测对比是王道。

6.3 复用 RowMapper 而不是每次都 new

RowMapper是线程安全的,执行时没有内部状态,所以完全可以定义为static final字段复用,而不是每次查询都new一个。这在对象创建开销上微不足道,但代码整洁度和可维护性提升明显,也避免了一不留神在RowMapper里捕获外部可变状态导致的并发隐患。

6.4 配合 Redis 做缓存,别让 JdbcTemplate 背锅

JdbcTemplate 本身不带缓存,这是它轻量的代价,也是优势。频繁执行的读操作,我一般是 Service 层加一层 Redis 缓存,初次从 JdbcTemplate 查完写缓存,后续直接走 Redis。但要注意缓存穿透和缓存击穿的问题,高并发下给热点 key 加锁、给空结果也设置短过期时间,这样 JdbcTemplate 层也不会被高频请求打到崩溃。

7. 应用场景扩展:除了常规 CRUD 还能做什么

7.1 动态 SQL 拼装与条件查询

面向搜索引擎或者管理后台的筛选列表,查询条件是动态的,有几十种组合。用 JdbcTemplate 拼 SQL 时,用StringBuilder配合 List 参数收集:

public List<UserInfo> searchUsers(String username, String email, LocalDate startDate, LocalDate endDate) { StringBuilder sql = new StringBuilder("SELECT id, username, password, email, create_time FROM user_info WHERE 1=1"); List<Object> params = new ArrayList<>(); if (StringUtils.hasText(username)) { sql.append(" AND username LIKE ?"); params.add("%" + username + "%"); } if (StringUtils.hasText(email)) { sql.append(" AND email = ?"); params.add(email); } if (startDate != null) { sql.append(" AND create_time >= ?"); params.add(startDate.atStartOfDay()); } if (endDate != null) { sql.append(" AND create_time < ?"); params.add(endDate.plusDays(1).atStartOfDay()); } sql.append(" ORDER BY id DESC LIMIT 100"); return jdbcTemplate.query(sql.toString(), USER_ROW_MAPPER, params.toArray()); }

使用NamedParameterJdbcTemplate+MapSqlParameterSource时,“收集参数”会变成“收集 key-value”,代码可读性更好。动态 SQL 拼装时,WHERE 1=1这种技巧看着不优雅,但确实能省去判断“前面是否已有条件”的逻辑,实际项目里非常实用。

7.2 复杂结果集:一对多查询怎么映射

JdbcTemplate 做简单 CRUD 很舒服,做一对多关联查询时没有 MyBatis 的collection那么方便,但也能处理。比如查询用户和他的多个角色:

public List<UserWithRolesDTO> listUsersWithRoles() { String sql = "SELECT u.id AS user_id, u.username, r.id AS role_id, r.role_name " + "FROM user_info u LEFT JOIN user_role ur ON u.id = ur.user_id " + "LEFT JOIN role r ON ur.role_id = r.id " + "ORDER BY u.id"; Map<Long, UserWithRolesDTO> userMap = new LinkedHashMap<>(); jdbcTemplate.query(sql, rs -> { Long userId = rs.getLong("user_id"); UserWithRolesDTO dto = userMap.computeIfAbsent(userId, id -> { UserWithRolesDTO d = new UserWithRolesDTO(); d.setId(id); d.setUsername(rs.getString("username")); d.setRoles(new ArrayList<>()); return d; }); dto.getRoles().add(new RoleDTO(rs.getLong("role_id"), rs.getString("role_name"))); }); return new ArrayList<>(userMap.values()); }

核心思路是用Map按主键分组合并子表记录,手动实现类似 MyBatis 的嵌套结果映射。SQL 里排序必须先按主表主键排序,这样分组合并时顺序才稳定。

7.3 数据导入导出与报表查询

报表系统是 JdbcTemplate 的强项。查询结果用queryForListList<Map<String, Object>>,然后直接转成 JSON 输出给前端报表组件,或者配合 EasyExcel 写导出。连接线上库做报表查询时,建议用独立的数据源指向只读从库,避免报表查询拖垮主库性能。

7.4 生成环境中的批量更新策略

除了批量插入,批量更新也常用。例如批量给用户重置密码:

public int batchUpdatePassword(List<Long> userIds, String newPassword) { String sql = "UPDATE user_info SET password = ? WHERE id = ?"; return Arrays.stream(jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { ps.setString(1, newPassword); ps.setLong(2, userIds.get(i)); } @Override public int getBatchSize() { return userIds.size(); } })).sum(); }

批量更新建议也按 500 条分片,并放入事务,保证一批操作的原子性。

8. 从一个真实项目里提炼的整合经验

8.1 项目背景与取舍

我之前做一个中小型后台管理系统,表有三十多张,业务逻辑大多集中在订单、用户、报表模块。一开始同事建议直接上 MyBatis Plus,但我评估后发现:系统并不需要太多复杂的动态 SQL,大部分查询就是单表加几个条件,表关系也比较直白,没必要引入一套代码生成器和繁重的 BaseMapper 继承体系。最终选了 Spring Boot + JdbcTemplate,整个数据访问层用一个公共的BaseDao封装公共操作,各业务模块自己写 SQL 和 RowMapper,开发效率不高吗?反而是同行里完成最快的。后来接了一个第二数据源做统计报表,也因为是 JdbcTemplate 的配置模式,扩展起来几分钟就搞定了。

8.2 团队协作中的数据访问层规范

用 JdbcTemplate 最怕的是每个开发者各自为政,同一个查询写十种 SQL。我们在项目规范里定了几个硬性要求:

  • 所有 SQL 统一走NamedParameterJdbcTemplate,禁止直接用?占位符。
  • 所有 RowMapper 集中在一个XxxMapper工具类里,禁止散落在 Service 里。
  • 表名前缀统一,所有涉及多表查询的 DTO 单独建类,不直接把实体类拉出来硬凑字段。
  • SQL 中禁止SELECT *,必须显式列字段。

这些规范看着不起眼,但在项目规模变大、人员流动之后,它决定了你接手代码时是“痛不欲生”还是“一目了然”。

8.3 JdbcTemplate 与 MyBatis 的对比,什么项目选哪个

每次聊到 JdbcTemplate,就会被追问“和 MyBatis 比哪个好”。我的观点是:没有绝对的好坏,只有场景匹配度。

维度JdbcTemplateMyBatis / MyBatis Plus
学习成本极低,会 JDBC 就能上手中等,需要理解 mapper、XML、缓存机制
SQL 可控性完全可控,SQL 全手写可控,但复杂动态 SQL 在 XML 里调试更绕
开发效率简单 CRUD 需要手写 SQL,略繁琐BaseMapper 和代码生成器加持下 CRUD 开发极快
性能表现封装薄,接近原生 JDBC轻度封装,性能差距很小
适合场景数据访问需求清晰、团队规模小、原生 SQL 熟练需求多变、迭代快、ORM 习惯强的团队

如果你的系统有几十张表、实体关系复杂、通用 CRUD 占大头,MyBatis Plus 确实省事;但如果你像我一样喜欢精确控制 SQL、系统表结构稳定、又不想花精力维护 XML 映射文件和繁琐的注解,JdbcTemplate 是更轻快的那把刀。

9. 踩坑总结与最后的经验心得

写到最后,把这些年用 JdbcTemplate 的核心体会浓缩成几条,给准备动手整合的朋友:

第一,占位符参数一定要按顺序核对。用?风格写多个条件时,参数顺序错一个是 JdbcTemplate 新手最容易犯的错,而且不少错误要在真实数据量下才暴露。使用NamedParameterJdbcTemplate能系统性规避这个问题,新代码都该默认用它。

第二,不要随意在 SQL 字符串里拼接关键字。动态拼 SQL 时,ORDER BY字段、表名这类结构参数如果来自外部输入,必须做白名单校验。这不算 JdbcTemplate 的问题,是所有数据访问方案的共同底线。

第三,分页和超大结果集要提前规划。JdbcTemplate 没有内置的分页拦截,MySQL 用LIMIT ? OFFSET ?,PostgreSQL 用LIMIT ? OFFSET ?也有,但参数绑定方式略有差异。大结果集必须配合fetchSize或分页,不能在内存里处理几十万行数据。

第四,事务边界要清晰。@Transactional加在 Service 层的方法上,控制好粒度。太粗了锁表时间长,太细了每个方法都要写一遍事务注解,容易漏。事务不回滚问题排查时,先确认异常类型、再确认是否走 Bean 代理、再看rollbackFor有没有配置。

第五,JdbcTemplate 真的不难,难的是 SQL 功底。封装帮你解决了资源管理,帮你统一了异常处理,但 SQL 优化、索引设计、分表分库这些核心能力,任何时候都绕不开。

在 Spring Boot 项目里整合 JdbcTemplate,本质上是选了一条“轻封装、重 SQL”的数据访问路线。你不需要理解 MyBatis 的拦截器与二级缓存机制,不需要配置一堆 mapper 映射文件,只需要专注于把每条 SQL 写对写快,把参数传递清楚。这种朴素直接的风格,在大量真实业务开发里往往比花哨的 ORM 方案更耐糙。按上面这套配置和代码去搭,很快你就能体验到“原来数据库操作可以这么干净”的爽快感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 7:14:07

前端转AI应用开发:用Next.js与LangChain.js打造智能应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 7:13:01

CentOS7 上安装 Munin

关于Munin Munin是一个系统&#xff0c;像MRTG和cacti一样&#xff0c;监控磁盘、内存、CPU和网络等资源。 配置包括在被监控的服务器上安装“munin-node”&#xff08;代理&#xff09;&#xff0c;并在收集数据的服务器上安装“munin-server”&#xff08;数据收集服务器&a…

作者头像 李华
网站建设 2026/9/10 7:12:18

Serena 高级用法深度指南:提示规划策略与 Git Worktree 并行开发

Serena 高级用法深度指南&#xff1a;提示规划策略与 Git Worktree 并行开发 【免费下载链接】serena A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent 项目地址: https://gitcode.com/GitHub_Trending…

作者头像 李华