从原生 JDBC 到 JdbcTemplate:数据访问的进化史
访问数据库这件事,你真正要做的决定只有两个:这条 SQL 怎么写,查回来的行变成什么对象。其余部分——借一条连接、绑参数、执行、把异常看懂、归还连接——每个方法都一模一样,写一百遍也还是一百遍。原生 JDBC 逼你亲手做完全部;JdbcTemplate 做的事就一句话:把不变的收进模板,把变化的留给你。
先把六个词钉死(全文都会反复用到):
- 连接(
Connection):应用与数据库之间一条已经建好的通道,底层是一个 TCP 套接字。建立很贵,所以要用「借」和「还」 PreparedStatement:带?占位符的预编译语句。它是防 SQL 注入的唯一正解,顺带还是性能赢家(执行计划可复用)SQLException:JDBC 层定义的受检异常。主键冲突、表不存在、网线断了全是它,光看类型分不出病DataSource:JDBC 规范里的「连接供应商」接口。JdbcTemplate从不自己造连接,只向它要JdbcTemplate:Spring 的模板类,把「借连接—建语句—绑参数—执行—翻译异常—归还」这套固定流程封成方法,只把 SQL 与映射留给你RowMapper:一个「一行 → 一个对象」的回调,签名就是mapRow(ResultSet rs, int rowNum)
写原生 JDBC 就像手洗碗。收桌、刮渣、接热水、倒洗洁精、搓七遍、冲净、擦干、归位——八道工序一道不能少,而且每一只碗都要重来一遍。JdbcTemplate 是洗碗机:你只负责两件事,放什么进去(SQL)和取什么出来(结果映射),进水、加热、排水、烘干它全包了。三个对应关系特别值得记:① 洗碗机停电了照样会把最后一段水排干,对应模板里 finally 中一定执行的那句 releaseConnection;② 你不能把菜单和脏盘子一起塞进去——盘子是数据、菜单是指令,混在一起机器分不出来,这正是字符串拼接 SQL 犯的错;③ 洗碗机不会让一只碗洗得更快,它让你不必再为每只碗重复八步,这就是模板方法的全部价值。

四个世代,一条主线:每一代都在砍掉上一代重复劳动的那一部分。这一篇走的是第一站到第二站——把原生 JDBC 重构成 JdbcTemplate,顺便把数据访问的三个底层问题一次讲透:注入、泄漏、异常难读。第三、第四站(MyBatis 与 JPA)分别在 #29、#30,它们都站在这一篇的肩膀上。
学完这一篇,你应该能回答三个问题:
- 为什么
?占位符能防注入,而「把输入里的单引号转义一下」不能? try-with-resources已经解决了泄漏,为什么还需要JdbcTemplate?catch (DataAccessException e)和catch (SQLException e)到底差在哪?为什么 Spring 敢把数据访问异常做成非受检异常?
先动手再讲道理。这个沙盘把「同一条查询的四种写法」摆在一起:左边换写法,右边立刻给出代码行数、注入风险,以及出错时你实际看到什么。
有效代码:6 行,另需 7 行 finally 收尾发往数据库:SELECT * FROM t_user WHERE name = 'a' OR '1'='1'注入风险:致命 —— 输入直接改变了 SQL 结构出错时你看到:java.sql.SQLException: Unknown column 'x' in 'field list'
右边那一列「出错时你看到」是四种写法真正拉开差距的地方。代码行数是成本,异常类型是债——债早晚要还:拼字符串欠的是安全事故,SQLException 欠的是满屏 catch (Exception e) 和一句「系统异常,请稍后重试」。
在 JdbcTemplate 出现之前,查一条记录要走完一整套流程。下面这段代码你是不是很眼熟——它功能完全正确,但缺点同样明显:
public User findById(long id) throws SQLException { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { Class.forName("com.mysql.cj.jdbc.Driver"); // ① 加载驱动 conn = DriverManager.getConnection(URL, USER, PASS); // ② 获取连接 ps = conn.prepareStatement( // ③ 创建语句 "SELECT id, username, email FROM t_user WHERE id = ?"); ps.setLong(1, id); // ④ 绑定参数 rs = ps.executeQuery(); // ⑤ 执行查询 if (rs.next()) { // ⑥ 遍历结果并手工映射 User u = new User(); u.setId(rs.getLong("id")); u.setUsername(rs.getString("username")); u.setEmail(rs.getString("email")); return u; } return null; } finally { // ⑦ 关闭资源 if (rs != null) rs.close(); if (ps != null) ps.close(); if (conn != null) conn.close(); }}七个步骤,一行都不能少。可真正让人崩溃的是——这段代码里和「查询用户」这件事相关的,只有第 3 行的 SQL 和第 6 行的映射,其余全是重复劳动。归纳起来是四大痛点:
- 样板代码爆炸:每个方法都要写一遍连接、语句、关闭,删除和新增几乎一样长
- 资源泄漏:任何一个
close()漏掉,连接就不还了,跑久了连接池被榨干 - 异常难读:
SQLException是个「万能异常」,主键冲突、表不存在、网络断开全都是它,catch里根本认不出问题 - SQL 注入:只要有人图省事用字符串拼 SQL,系统就向攻击者敞开大门(下一节详解)
把这条生命线摊开看,就是你刚才那七步——注意第 5 步「异常翻译」和第 6 步「归还」在传统写法里是最容易被漏掉的两步:

Class.forName(...) 在 JDBC 4.0 之后已可省略(SPI 自动发现驱动),很多老教程还在写,是因为它曾长期是「必须的第一步」。这段代码保留它,是为了让你看清「七步」到底有多啰嗦。
一个真实事故现场。 某内部系统上线三个月后凌晨告警:CannotGetJdbcConnectionException: Failed to obtain JDBC Connection,接口大面积 500,重启立刻恢复,半小时后再次恶化。复盘时很讽刺——那个团队每个人都老老实实写了 finally,只是某次重构把一句提前 return 挪进了 else 分支,七个 close() 里漏掉一个,池子就每天少一条连接。这类 bug 的可怕之处不在难写,而在难看见:它不在任何一次 code review 的视野里,只在生产环境的第三个月爆发。这正是「靠人记住关闭」这条路的上限。
先看一个真实的危险写法——登录查询用字符串拼接:
// ❌ 危险:用户输入被直接拼进 SQL 文本String sql = "SELECT * FROM t_user WHERE username = '" + username + "' AND password = '" + password + "'";Statement st = conn.createStatement();ResultSet rs = st.executeQuery(sql);if (rs.next()) { loginSuccess(); // 竟然登录成功了}当攻击者在用户名框里输入 ' OR '1'='1 时,拼出来的 SQL 变成了:
SELECT * FROM t_user WHERE username = '' OR '1'='1' AND password = '''1'='1' 恒为真,整个 WHERE 条件被攻破——不需要任何密码,直接登录成功。更狠的是 admin' --,-- 是 SQL 注释符,它把后面验证密码的条件整段注释掉,于是以 admin 身份登了进去。
修复方式只有一个,且必须成为肌肉记忆——用 PreparedStatement 的占位符:
// ✅ 安全:参数用 ? 占位,输入只当数据,永远不改 SQL 结构String sql = "SELECT * FROM t_user WHERE username = ? AND password = ?";PreparedStatement ps = conn.prepareStatement(sql);ps.setString(1, username); // 输入被当作「值」绑定ps.setString(2, password);ResultSet rs = ps.executeQuery();为什么占位符能防注入? 因为 PreparedStatement 会先把 SQL 文本(带 ? 的那份)发送给数据库预编译,生成固定的执行计划;参数随后作为纯数据单独传过去,只落在 ? 的位置。无论参数里含多少个引号、--、OR,它都只是字符串值,永远无法改变已经编译好的 SQL 结构。这就是「预编译」二字的真正含义。

把一条 SQL 想成递给后厨的一张点菜单。拼接字符串的做法,等于让顾客直接在菜单纸上改指令——他写「宫保鸡丁,另外把收银机的钱带走」,厨房照做,因为那张纸上的每一行都被当成指令。PreparedStatement 的规矩是两张纸:菜单先交进去,厨房据此定好操作流程;顾客填的那张单据永远只被当作「一道菜的名称」,写什么都不会变成一条新指令。防注入的本质不是「过滤危险字符」,而是从一开始就不让数据和指令写在同一张纸上。
占位符救不了的那一半。 这是新手最容易忽略、也是面试最爱追问的一点:? 只能替换值,替换不了结构。表名、列名、ORDER BY 字段、排序方向都属于 SQL 结构,数据库预编译时就要确定,天然不能做成参数:
// ❌ 仍然危险:排序字段来自前端,拼进 SQL 结构,占位符无能为力String sql = "SELECT id, username FROM t_user ORDER BY " + orderBy;// ✅ 白名单映射:最终拼进 SQL 的是程序里的常量,不是外部输入private static final Map<String, String> SORTABLE = Map.of("id", "id", "username", "username", "createTime", "create_time");String column = SORTABLE.get(orderBy);if (column == null) { throw new IllegalArgumentException("不支持的排序字段: " + orderBy);}String sql = "SELECT id, username FROM t_user ORDER BY " + column;- 判断标准只有一句:拼进 SQL 的那个东西,最终来源是不是用户输入? 是,就必须先过白名单变成程序内的常量
LIKE查询同理:WHERE name LIKE ?是安全的,但把%拼进参数值里再传进去也是安全的——?会把整串当值- 别指望「转义单引号」:那是在假设所有危险字符只有单引号,而真实攻击面还包括注释符、
UNION、十六进制编码
坑:把密码明文存进数据库、或用 MD5 存密码,都是典型的二次伤害。SQL 注入防住了不代表密码安全——密码必须用 BCrypt 这类加盐慢哈希存储,每次登录比对哈希,而不是「查密码是否相等」。这两件事经常被混为一谈。
随堂测一下,这题很多人第一次都答错:
解决了注入,还有「泄漏」这只鬼。Java 7 引入的 try-with-resources 让关闭变得自动:
public User findById(long id) { String sql = "SELECT id, username, email FROM t_user WHERE id = ?"; try (Connection conn = dataSource.getConnection(); // 自动 close PreparedStatement ps = conn.prepareStatement(sql)) { // 自动 close ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { // 自动 close return rs.next() ? mapRow(rs) : null; } } catch (SQLException e) { throw new RuntimeException("查询用户失败", e); // 注意:异常仍然「难读」 }}- 实现了
AutoCloseable的资源,写在try(...)里就会按声明的逆序自动关闭,无论正常返回还是抛异常 - 关闭顺序是
ResultSet → PreparedStatement → Connection,正好和打开顺序相反——这不是强迫症,Statement关闭时会自动关掉它产生的结果集,反过来写则可能触发驱动的告警 - 但请注意:泄漏解决了,样板代码和「异常难读」还没解决——
catch (SQLException)里依然分不清是主键冲突还是表不存在
把「谁该关、谁替谁关、漏了会怎样」摊成一张表,才看得出这一步其实还有多少细节:
| 资源 | 是 AutoCloseable 吗 | 谁负责关 | 漏掉的后果 |
|---|---|---|---|
ResultSet | 是 | 随 Statement 关闭,也建议自己写进 try(...) | 服务端游标长时间占用内存 |
PreparedStatement / Statement | 是 | try-with-resources | 语句句柄泄漏,攒到上限报 Prepared statement count exceeded |
Connection | 是 | try-with-resources 或模板的 releaseConnection | 连接不归还,池被抽干 → CannotGetJdbcConnectionException |
在连接池时代,还书这件事不该由你手动放回书架。手写 finally 就像下雨天抱着书回图书馆,自己找架子塞回去——十次里有两次忘了,因为你正急着躲雨(异常分支提前 return)。try-with-resources 是门口的自动还书机:你把书丢进去,识别、登记、上架一气呵成,无论你来不来得及、昏不糊涂,机器都会把这一段流程走完。JdbcTemplate 更进一步——它连「你走到图书馆」这一步都替你省了,finally 里那句 releaseConnection 写在模板内部,一次写好,全项目复用。
亲手看一眼「漏掉那个 finally」到底会发生什么——这个实验只放了「忘记释放」这一个参数,点一下就能看到连接被谁攥住、什么时候再也借不出来:
JdbcTemplate 用两个经典设计模式,把上面的痛点一次性收编:
- 模板方法模式:「获取连接 → 创建语句 → 执行 → 关闭」这套固定流程写在模板里,一次写好
- 回调模式:把「和业务相关、每次都不同」的部分——SQL 文本和结果映射——抽成回调,交给你来写
剥掉外壳,JdbcTemplate 的核心长这样:
public <T> T query(String sql, ResultSetExtractor<T> rse, Object... args) { Connection con = DataSourceUtils.getConnection(dataSource); // 从池里拿(或复用事务连接) try (PreparedStatement ps = con.prepareStatement(sql)) { ArgumentPreparedStatementSetter.setValues(ps, args); // 统一绑定参数,杜绝拼接 try (ResultSet rs = ps.executeQuery()) { return rse.extractData(rs); // ← 唯一的「业务差异」交给你 } } catch (SQLException ex) { throw translateException(ex); // 统一翻译成 DataAccessException } finally { DataSourceUtils.releaseConnection(con, dataSource); // 一定归还 }}- 你只需要提供两样东西:SQL 文本和结果映射回调,其余全被模板包办
- 参数绑定走
?占位符,从 API 层面就堵死了字符串拼接(方法签名里根本没有「把变量拼进 SQL」的口子) - 异常被统一翻译(见第六节),不再是一个大而全的
SQLException - 连接通过
DataSourceUtils获取和归还,还兼顾了事务复用——同一事务里多个操作共用一条连接
「一人一半」这句话值得一张图来钉死:左边是模板一次写好、全项目复用的部分,右边才是每写一个查询方法要填的部分。以后凡是觉得 JdbcTemplate 麻烦,先看看麻烦落在哪半边:

光看图还不够——真正容易想岔的是执行顺序。你的 RowMapper 不是「先整个查完、再轮到你上场」,而是模板反过来在每个结果行上调用你。把上面那段骨架单步走一遍:
Connection con = DataSourceUtils.getConnection(dataSource);PreparedStatement ps = con.prepareStatement(sql); // 先把 SQL 编译成固定计划ArgumentPreparedStatementSetter.setValues(ps, args); // 参数只落在 ? 的位置ResultSet rs = ps.executeQuery(); // 网络往返发生在这里return rse.extractData(rs); // ← 你的 RowMapper 在这里被调用} catch (SQLException ex) { throw translateException(ex); // 厂商方言在这里变成语义异常} finally { DataSourceUtils.releaseConnection(con, dataSource); }| 当前线程 | request-1 |
| 事务里已有连接吗 | 否 → 向池借 |
| 借到的是 | 池里的空闲连接 |
JdbcTemplate.queryDataSourceUtils.getConnection常用的几个方法,彼此分工明确:
| 方法 | 用途 | 返回值 |
|---|---|---|
execute(...) | 执行任意 SQL(DDL 等没有返回值的场景) | void |
update(...) | INSERT / UPDATE / DELETE | int(受影响行数) |
query(...) | 查询多行 | List<T> |
queryForObject(...) | 查询单行 / 单值 | T(查不到会抛异常) |
queryForList(...) | 查询简单列表 | List<Map<String, Object>> 或 List<T> |
batchUpdate(...) | 批量增删改 | int[](每条的受影响行数) |
update 用于所有「写」操作,名字容易让人误以为只能 UPDATE;queryForObject 的语义是「必须查到」,这一点是第九节「坑」的源头。
现在把内核实验打开,五个参数按顺序点一遍。这不是「看动画」,而是同一套模板在五种输入下的五种现场:
逐个参数该怎么读:
| 参数 | 你会看到的画面 | 对应现实 |
|---|---|---|
查询一条 query | 借连接 → 预编译 → 绑定 → 读一行 → 归还,一条链走完 | 最日常的路径,findById 就长这样 |
更新与影响行数 update | 返回的是整数「受影响行数」,而不是布尔 | 别写 if (jdbc.update(...) == true);0 常常意味着「WHERE 没命中」,是业务信号 |
批量操作 batch | 一批参数复用同一条语句,一次网络往返 | 循环 update 与 batchUpdate 的耗时差一个数量级 |
ResultSet → 对象 map | 逐行回调,列名到属性的对应关系由你决定 | 第四、第五节的 RowMapper 与 BeanPropertyRowMapper |
忘记释放会怎样 leak | 连接被攥住,后续请求排队、超时 | 第一节那个凌晨告警的完整成因 |
先看最常用的增、查(手写 RowMapper 与自动映射各一遍)和批量操作:
@Repositorypublic class UserRepository { private final JdbcTemplate jdbc; public UserRepository(JdbcTemplate jdbc) { // Spring Boot 自动配置注入 this.jdbc = jdbc; } // 增:update 用于所有写操作,返回受影响行数 public int save(User u) { return jdbc.update("INSERT INTO t_user(username, email) VALUES (?, ?)", u.getUsername(), u.getEmail()); } // 查单行:手写 RowMapper(lambda),最灵活 public Optional<User> findById(long id) { return jdbc.query("SELECT id, username, email FROM t_user WHERE id = ?", (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("username"), rs.getString("email")), id) .stream().findFirst(); } // 查多行:BeanPropertyRowMapper 按「列名 → 属性名」自动映射,省去手写 public List<User> findByStatus(String status) { return jdbc.query("SELECT id, username, email FROM t_user WHERE status = ?", new BeanPropertyRowMapper<>(User.class), status); } // 批量写:比循环单条 update 快一个数量级 public int[] batchInsert(List<User> users) { return jdbc.batchUpdate("INSERT INTO t_user(username, email) VALUES (?, ?)", users, 500, // 每 500 条 flush 一次 (ps, u) -> { ps.setString(1, u.getUsername()); ps.setString(2, u.getEmail()); }); }}(rs, rowNum) -> ...就是RowMapper的函数式写法:入参是当前行,返回值是领域对象BeanPropertyRowMapper会自动把user_name/username这类列名对齐到 Java 属性(开启驼峰转换后更省心),但要求目标类有无参构造器和 setter——record用不了它,这正是很多团队在 JDK 17 下退回普通类的原因- 因此本节示例里的
User是带 setter 的普通 POJO;第十一节的练习改用record,那里的映射一律手写RowMapper,两种写法别混用 batchUpdate的第三个参数是批大小,配合rewriteBatchedStatements=true(MySQL 驱动)收益最明显findById这里用query(...).stream().findFirst()而不是queryForObject(...),就是为了不把「查不到」变成异常——第九节详解
当参数一多、又有 IN 查询时,? 就不好数了。此时换上具名参数版本:
@Repositorypublic class OrderRepository { private final NamedParameterJdbcTemplate namedJdbc; public OrderRepository(NamedParameterJdbcTemplate namedJdbc) { this.namedJdbc = namedJdbc; } public List<Order> search(String status, List<Long> userIds, int limit) { String sql = """ SELECT id, user_id, amount, status FROM t_order WHERE status = :status AND user_id IN (:ids) -- IN 查询无需手动拼问号 ORDER BY id DESC LIMIT :limit """; MapSqlParameterSource params = new MapSqlParameterSource() .addValue("status", status) .addValue("ids", userIds) // List 会自动展开为 (?, ?, ?) .addValue("limit", limit); return namedJdbc.query(sql, params, new BeanPropertyRowMapper<>(Order.class)); }}- 具名参数
:name让 SQL 自解释,改参数时不用再逐个数字对齐 IN (:ids)传入List会自动展开成正确数量的占位符——这正是?版本最容易写错的地方NamedParameterJdbcTemplate是JdbcTemplate的包装,底层还是同一套模板,能力完全继承
「自动展开」这四个字值得单独看一遍——注意它展开的只是问号的数量,SQL 文本本身从不被外部输入改写,这正是它安全的原因:

看完再回看第九节第二个坑(别自己拼那一串问号),那句警告就有了画面。
结果映射这一层是新手最常见的 500 来源:「列 → 属性」对不上时,异常在回调里抛出。回到第四节的实验切到 map,你能看到每一行如何被喂给回调、类型不匹配时停在哪一步;切到 batch,则能看清一批参数怎样复用同一条语句。
还记得第一节「异常难读」的痛点吗?Spring 的解法是:把每个数据库厂商各不相同的 SQLException(它只有 SQLState 和 errorCode 两个线索)翻译成语义明确的运行时异常。
| 场景 | 厂商原生异常 / SQLState | Spring 异常类 |
|---|---|---|
| 唯一约束冲突 | SQLIntegrityConstraintViolationException / 23000 | DuplicateKeyException |
| 外键 / 数据完整性违反 | SQLException / 23000 | DataIntegrityViolationException |
| 表或列不存在 | SQLSyntaxErrorException / 42S02 | BadSqlGrammarException |
| 死锁 / 加锁失败 | SQLTransactionRollbackException / 40001 | CannotAcquireLockException |
| 查询超时 | SQLTimeoutException | QueryTimeoutException |
| 连接失败 | SQLTransientConnectionException | CannotGetJdbcConnectionException |
| 期望一行但没有数据 | —(JdbcTemplate 语义) | EmptyResultDataAccessException |
| 期望一行却查到多行 | —(JdbcTemplate 语义) | IncorrectResultSizeDataAccessException |
- 所有 Spring 数据访问异常都继承自
DataAccessException,而它是RuntimeException——不必再强制throws SQLException - 层级清晰:
DuplicateKeyException继承DataIntegrityViolationException,catch的粒度可粗可细 - 全局异常处理可以按语义分类响应:唯一键冲突返回「已存在」,超时返回 503,而不是一律 500
把一次唯一键冲突的完整翻译链走一遍,你就能明白「换数据库不用重学异常」是怎么做到的:

各家数据库说的是方言。Oracle 报 ORA-00001,MySQL 报 Duplicate entry 'bee' for key 'username',PostgreSQL 报 duplicate key value violates unique constraint ...——三句话其实是同一件事。SQLException 就是那句方言,你公司的客服(业务代码)听不懂,只能一句「系统异常」挡回去。Spring 的翻译器是总部的接线员:它听着方言、对着错误码对照表(sql-error-codes.xml)把这句话登记成标准工单类型——DuplicateKeyException、BadSqlGrammarException、QueryTimeoutException。上层从此只按工单类型办事,哪天换一家数据库,客服的话术一个字都不用改。
翻译发生在模板内部,落到代码上,你得到的是一处能按语义分支的干净现场:
@RestControllerAdvicepublic class DataAccessAdvice { private static final Logger log = LoggerFactory.getLogger(DataAccessAdvice.class); /** 唯一索引冲突:这是业务规则生效,不是故障 */ @ExceptionHandler(DuplicateKeyException.class) ResponseEntity<ApiError> onDuplicateKey(DuplicateKeyException e) { log.info("唯一键冲突: {}", e.getMostSpecificCause().getMessage()); return ResponseEntity.status(HttpStatus.CONFLICT) .body(new ApiError("USER_EXISTS", "该用户名已经被注册了")); } /** 外键不存在 / 字段过长:请求本身有问题,回 400 */ @ExceptionHandler(DataIntegrityViolationException.class) ResponseEntity<ApiError> onIntegrity(DataIntegrityViolationException e) { log.warn("完整性约束失败", e); return ResponseEntity.badRequest().body(new ApiError("BAD_DATA", "提交的数据不符合约束")); } /** 表不存在、SQL 写错:程序缺陷,500 + error 级日志 + 告警,不给用户看细节 */ @ExceptionHandler(BadSqlGrammarException.class) ResponseEntity<ApiError> onGrammar(BadSqlGrammarException e) { log.error("SQL 语法或对象缺失,需要立刻修", e); return ResponseEntity.internalServerError().body(new ApiError("INTERNAL", "系统繁忙")); }}getMostSpecificCause()会一路挖到厂商那句原文,日志里打它比getMessage()有用得多- 注意顺序:
DuplicateKeyException是DataIntegrityViolationException的子类,两个@ExceptionHandler写反了子类那支永远进不去 - 「非受检」不等于「不用管」:Spring 故意让异常冒到最外层,所以你必须有一处
@RestControllerAdvice兜住它,否则用户看到的是白页
提示:异常翻译依赖「数据库 + 错误码」的映射规则,不同厂商的规则不同。Spring 内置了主流数据库的翻译表;如果用的是冷门数据库,可能出现「翻译不到位」的情况——此时抛出的会是笼统的 UncategorizedSQLException,可以通过自定义 SQLExceptionTranslator 补上映射。
异常冒到最外层之后,由谁接住、接不住会是什么样子,可以直接在实验里切出来看——左边是有人 @ExceptionHandler 接管,右边是没人接管:
回头看第四节那段伪代码里的 DataSourceUtils.getConnection(dataSource)——JdbcTemplate 自己并不创建连接,它只向 DataSource 要。这是一次关键的职责分离:
JdbcTemplate负责「怎么写 SQL、怎么映射、怎么翻译异常」DataSource负责「连接从哪来、能不能复用、什么时候回收」
也正因如此,JdbcTemplate 的构造器就是 new JdbcTemplate(dataSource)。而真实项目里,这个 DataSource 几乎总是一个连接池(HikariCP),连接是借来还去、反复复用的——这正是第 28 篇的主题。理解了这一层,你才算真正理解数据访问的全貌。
先在实验里确认一件让新手意外的事:同一个 JdbcTemplate 连续查两次,两次拿到的是同一条物理连接——因为它借自池,用完就还:
这两次查询之间发生的事,连起来就是一个环——从 ① 点到 ⑤ 再回到 ①,其中第 ④ 格正是第一节那个凌晨告警的成因:
再往前一步:一旦方法被 @Transactional 包住,连接的借还次数会变成「整个事务一次」。这不是性能优化,而是正确性要求——回滚只能针对同一条连接:
这一篇只需要记住一句——JdbcTemplate 拿连接、还连接,但它从不拥有连接。 拥有它的是 DataSource(通常是池),事务管理器决定它什么时候被释放。这条职责线在 #28(池)和 #31(事务)里会被反复重用。
在 Spring Boot 里,你从没写过 new JdbcTemplate(...),却能直接注入它。秘密还是自动配置:
引入 spring-boot-starter-jdbc(或 spring-boot-starter-data-jpa)后,JdbcTemplateAutoConfiguration 会生效——它标注了 @ConditionalOnClass(DataSource.class) 和 @ConditionalOnSingleCandidate(DataSource.class),只要容器里有一个 DataSource,且你没自己定义 JdbcOperations,它就自动注册一个 JdbcTemplate,并顺手把 NamedParameterJdbcTemplate 也准备好。
先把这段话翻译成 pom 里看得见的东西——勾两下,自动配置的生效条件就摆在眼前:jdbc 那件决定「classpath 上有没有 spring-jdbc」,h2 是第十一节要用的零安装内存库:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>两件依赖到位,链上的两个自动配置就都有了生效前提:DataSourceAutoConfiguration 先建好 DataSource,JdbcTemplateAutoConfiguration 再在它之上建好模板(先后顺序由 @AutoConfigureAfter 钉死,不需要你操心)。
配置侧只需要这几行:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true username: root password: ${DB_PASSWORD} # 从环境变量注入,别写进 git driver-class-name: com.mysql.cj.jdbc.Driver # JDBC 4 以上可省略,写上更直观一句话:你只管配 spring.datasource.url,DataSource 由 DataSourceAutoConfiguration 建好,JdbcTemplate 再基于它由 JdbcTemplateAutoConfiguration 建好——链式条件装配,全自动。 想接管的边界也很清晰:只要你自定义了同类型 Bean,自动配置就会让位(详见第 18、19 篇)。
这条依赖链可以在容器装配实验里逐层展开——从 Web 层一直到 DataSource,看谁被谁注入:
上面那条链是「点着看」的,现在换成「敲着看」的。这台控制台连着同一个内核,回显全部由它算出来:先 beans 数出容器里的注入点,再用 cond jdbcOnClasspath false 把 classpath 上的 jdbc 条件抽掉——看模板 Bean 怎么跟着消失,这就是 @ConditionalOnClass 未命中的现场:
beans 要敲两次——cond jdbcOnClasspath false 前后各一次,两次清单的差集才是这一节的内容。返回里那句「@ConditionalOnClass 未命中 → 跳过」,就是条件装配求值的现场版(详见第 19 篇);di 打出的那条链,则和上面这个实验完全对应。
Boot 还留了一个免费的体检开关——配了 schema.sql / data.sql 时启动会自动执行,但 2.5+ 对非内存库默认关闭,需要 spring.sql.init.mode=always。第一次报「表不存在」,先确认建表脚本到底跑没跑。
queryForObject 查不到数据会抛 EmptyResultDataAccessException,不是返回 null。 很多人的写法是 User u = jdbc.queryForObject(sql, mapper, id); if (u == null) {...}——这行 if 永远不会执行,因为没数据时异常先抛出来了。要「可能为空」的语义,请用 query(...) 再取第一个(或包成 Optional),明确区分「查不到」和「查询出错」。反过来,如果 WHERE 条件不够唯一、数据变多之后查出多行,你会拿到 IncorrectResultSizeDataAccessException: expected 1, actual 8——加 LIMIT 1 只是遮丑,真正要问的是「凭什么这个条件唯一」。
IN 查询别自己拼问号。 写 String placeholders = idList.stream().map(i -> "?").collect(joining(",")) 再拼进 SQL,看似聪明,实则又回到了字符串拼接——数量一多就易错,还埋下注入隐患。用 NamedParameterJdbcTemplate 的 IN (:ids),让框架替你展开。 另外,ids 为空集合时要提前判断,展开成 IN () 会直接报语法错误。
大结果集必须用 RowCallbackHandler 流式处理,别一次性查进内存。 导出百万行数据时用 query(...) 返回 List<T>,会瞬间把整张表读进堆内存,直接 OOM。正确姿势是用 jdbc.query(sql, rowCallbackHandler) 逐行回调、边读边写,必要时配合 fetchSize 与分批:
// 导出:内存占用与结果集大小无关public void exportOrders(Path target) { try (BufferedWriter w = Files.newBufferedWriter(target, StandardCharsets.UTF_8)) { w.write("id,user_id,amount\n"); jdbc.query( "SELECT id, user_id, amount FROM t_order ORDER BY id", rs -> { // RowCallbackHandler,逐行回调 try { w.write(rs.getLong("id") + "," + rs.getLong("user_id") + "," + rs.getBigDecimal("amount")); w.newLine(); } catch (IOException e) { // 回调签名只声明 throws SQLException,IO 异常必须包一层非受检的 throw new UncheckedIOException("写入导出文件失败", e); } }); } catch (IOException e) { throw new UncheckedIOException("导出订单失败: " + target, e); }}大结果集这个坑最隐蔽的地方是「10 万行这一档能跑」:List<Order> 约 200 MB,加上驱动侧缓冲还会翻倍,Young GC 频繁、接口 P99 抖到 3 s 以上,却还没 OOM——测试全绿。流量一叠加就变成 Full GC 风暴,而那条连接在整个读取期间无法归还,池里 pending 飙升,别的接口跟着一起超时。300 万行则直接 OutOfMemoryError: Java heap space,或者驱动先报 Packet for query is too large。大导出的正确形态是「后台任务 + 生成文件 + 通知下载」,单次取用不超过一万行。
update 返回的是「受影响行数」,0 是一次业务信号,不是成功。 「改了状态但返回 0 行」通常意味着 WHERE 没命中(记录不存在,或并发下已被别人改走)。把 jdbc.update(...) 的返回值丢掉,就等于把这类问题埋进日志里。乐观锁更是靠这个返回值判定成败:if (jdbc.update(sql, ...) == 0) throw new ConcurrentModifyException();
这一节的报错原文都可以整段粘进搜索框。新手最慌的不是概念,是那屏红字;先认清是哪一行,再按第三列自救。
| 报错原文(片段) | 现象 | 真实原因 | 一句修复 |
|---|---|---|---|
CannotGetJdbcConnectionException: Failed to obtain JDBC Connection | 启动或第一次访问就 500,所有走数据库的接口一起挂 | spring.datasource.url、账号密码、驱动依赖三者之一不对,或数据库没起、网络与白名单不通 | 先确认端口通不通,再用同一份账号密码跑一次最小连接代码;启动日志里 HikariPool-1 - Start completed. 没出现就是连接串问题(详见 #28) |
java.sql.SQLSyntaxErrorException: Table 'demo.t_user' doesn't exist → BadSqlGrammarException | 本地跑得好,一上线就报;报错里 SQL 文本明明是对的 | schema / 库名不对,或建表脚本没在新环境执行,或 SQL 里写死了库前缀 | 在目标库执行 SHOW TABLES 定位它在哪个库,把建表脚本交给 Flyway / Liquibase 管理,别靠手 |
DuplicateKeyException: Duplicate entry 'bee' for key 'username' | 注册、保存类接口偶发失败,重复提交时必现 | 唯一索引挡住了一条重复写入——这是业务规则生效,不是程序坏了 | catch (DuplicateKeyException e) 返回「已存在」语义(HTTP 409),或用 INSERT ... ON DUPLICATE KEY UPDATE 显式表达 upsert |
DataIntegrityViolationException: could not execute statement | 表单提交后保存失败,前端只看到「系统异常」 | 非空约束、外键不存在、字段长度超限、类型装不下(DECIMAL 精度)都在这一类 | 打 e.getMostSpecificCause().getMessage(),厂商原文会直接点名是哪个约束;别让一句「数据异常」糊过去 |
EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0 | 明明写了判空,线上仍然 500 | queryForObject 的语义是「必须查到一行」,0 行先抛异常,判空分支永远走不到 | 改 query(...) + stream().findFirst(),或用 Optional 语义的封装,让「查不到」回到正常返回通道 |
IncorrectResultSizeDataAccessException: expected 1, actual 8 | 同一句 SQL 昨天还好,今天就炸 | WHERE 条件并不唯一,数据变多后查出多行 | 先问「凭什么这个条件唯一」;真需要多行就换 query(...),LIMIT 1 只是把问题藏起来 |
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure | 早上第一个请求必失败,重试就好;日志里有 last packet ... 28800 seconds | 空闲连接被数据库(wait_timeout)单方面切断,池里留了一条死连接 | 把 maxLifetime 调到明显小于 wait_timeout,并开 keepaliveTime 保活(详见 #28 第十节) |
java.sql.BatchUpdateException ... Batch entry 0 was aborted | batchUpdate 跑到中途失败,前一半已经写进去了 | 批次里某条违反约束,而驱动的 continueBatchOnError 默认继续执行 | 捕获后读 getUpdateCounts() 判断哪些成功;把批大小切小、事务边界收紧,不要把 10 万行塞进一个批次 |
这一批报错有个共同规律——Spring 的异常类名说的是「语义」,getCause() 里的厂商原文说的才是「细节」。排查动作永远是三步:看 Spring 类型定性质 → 挖 getMostSpecificCause() 定约束名 → 看 SQL 与执行计划定根因。
表里第一行那个「写了判空却仍然 500」的现场,完整堆栈如下。先别看解析——点出你认为的凶手帧,再对照第九节第一个坑:
用户详情接口偶发 500,代码里明明白白写着 User u = jdbc.queryForObject(sql, mapper, id); if (u == null) { return notFound(); }。开发环境复现不出来,线上每周来几次,每次都是同一个异常类名。
再来一道判读题,这题答错的话,第九节那几个坑你会一个不落地再踩一遍:
目标:用 H2 内存库(org.h2.Driver + jdbc:h2:mem:demo,零安装)跑通「建表 → 插两行 → 查列表 → 触发一次唯一键冲突 → 查一条不存在的记录」。依赖只需 spring-jdbc 与 com.h2database:h2 两项。
package com.example.jdbc;/** 领域对象:JDK 17 的 record,最省样板 */public record User(long id, String username, String email) {}package com.example.jdbc;import org.springframework.dao.DuplicateKeyException;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.jdbc.datasource.DriverManagerDataSource;import java.util.List;import java.util.Optional;public class TwoGenerationsDemo { public static void main(String[] args) { DriverManagerDataSource ds = new DriverManagerDataSource(); ds.setDriverClassName("org.h2.Driver"); ds.setUrl("jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1"); // 内存库,进程退出即清空 ds.setUsername("sa"); ds.setPassword(""); JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute(""" CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, email VARCHAR(64) NOT NULL )"""); // ① DDL 用 execute,无返回值 jdbc.update("INSERT INTO t_user(username, email) VALUES (?, ?)", "bee", "bee@example.com"); jdbc.update("INSERT INTO t_user(username, email) VALUES (?, ?)", "ant", "ant@example.com"); List<String> names = jdbc.queryForList("SELECT username FROM t_user ORDER BY id", String.class); System.out.println("names = " + names); // ② 查询列表 try { // ③ 撞唯一索引 jdbc.update("INSERT INTO t_user(username, email) VALUES (?, ?)", "bee", "again@example.com"); } catch (DuplicateKeyException e) { System.out.println("重复用户名 -> " + e.getClass().getSimpleName() + " / mostSpecificCause = " + e.getMostSpecificCause().getMessage()); } Optional<User> missing = jdbc.query( // ④ 查不存在的行 "SELECT id, username, email FROM t_user WHERE id = ?", (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("username"), rs.getString("email")), 9999L) .stream().findFirst(); System.out.println("id=9999 -> " + missing); }}预期控制台输出(H2 的原文消息会随版本略有差异,形状必须一致):
names = [bee, ant]重复用户名 -> DuplicateKeyException / mostSpecificCause = Unique index or primary key violation: "PUBLIC.PRIMARY_KEY_A ON PUBLIC.T_USER(ID)"id=9999 -> Optional.empty对着输出确认四件事:
- 第 ① 步用的是
execute(...):DDL 没有返回值,这正是方法表里那一行的用途 - 第 ③ 步你能在没有读错误码的前提下写出
catch (DuplicateKeyException e)——这就是异常翻译的全部意义。换成 MySQL,厂商消息是Duplicate entry 'bee' for key 'username'(错误码 1062),Spring 仍然抛同一个类 - 第 ④ 步把
query(...).stream().findFirst()改成queryForObject(...)再跑一次,你会亲眼看到EmptyResultDataAccessException的堆栈——第九节第一个坑的现场版 - 结尾
Optional.empty说明「查不到」走的是正常返回通道,不是异常
每改一处重跑一遍,把观察写进笔记:
- 把
findById的 SQL 改成字符串拼接:" ... WHERE username = '" + input + "'",输入' OR '1'='1。你会观察到 查出了不该出现的行;这就是第二节演示过的攻击,只不过现在是你自己写的。 - 把
WHERE id = ?改成WHERE username LIKE ?,参数传%。你会观察到 全表被扫,配合ORDER BY id仍然返回多行——参数化只保证「不改变结构」,它不负责性能。 - 把
queryForObject("SELECT ... WHERE status = ?", String.class, "ON")加进来,而表里有 8 行ON。你会观察到IncorrectResultSizeDataAccessException,并顺带明白「唯一」必须由数据约束保证,而不是由「我记得只有一条」保证。 - 把
jdbc.update循环 5 万次与一次jdbc.batchUpdate各跑一遍,记下耗时。你会观察到 数量级差异;MySQL 下再加上rewriteBatchedStatements=true,差距还会拉大——这解释了第五节批量方法为什么值得单独存在。
需求:给 t_order 做一套完整 DAO,并把异常出口做漂亮。验收清单:
- [ ] 建表脚本包含一个唯一约束和一个外键(
user_id指向t_user),因为你要用它制造两类不同的异常 - [ ]
OrderRepository提供四个方法:findById(long): Optional<Order>、findByUserIds(List<Long>): List<Order>(必须用NamedParameterJdbcTemplate的IN (:ids))、batchInsert(List<Order>): int[]、exportTo(Path): void(必须用RowCallbackHandler流式写文件) - [ ] 空集合防御:
findByUserIds(List.of())直接返回空列表,而不是让 SQL 展开成IN ()报语法错误 - [ ]
@RestControllerAdvice至少分出三类响应:DuplicateKeyException→ 409、DataIntegrityViolationException→ 400、其余DataAccessException→ 500 且日志用error级别 - [ ] 写一个测试:往登录或搜索参数里塞
' OR '1'='1,断言返回空结果集而不是全表——这条测试是防注入回归的唯一护栏 - [ ] 用
EXPLAIN检查findByUserIds生成的 SQL,说出它走了哪个索引;如果type=ALL,补上索引再跑一次 - [ ] 加分项:把
findById用 JPA 或 MyBatis 各重写一遍,对比三份代码的行数与可读性,写 5 行结论——这正是 #29、#30 的开场白
做完这一档,你手里就不只是「会调 JdbcTemplate 的 API」,而是「有一条完整的数据访问路径:参数化、映射、异常语义、性能与可观测」。
PreparedStatement 为什么能防注入?答案的关键在「两次发送」——带 ? 的文本先过去被编译成固定执行计划,参数随后作为纯数据送达,只能落在 ? 的位置,永远无法改变结构。
ORDER BY 字段能不能用 ? 占位?不能。排序字段属于 SQL 结构,预编译阶段就要定下来;正解是白名单映射,把外部输入换成程序内的常量。
try-with-resources 已经解决了泄漏,JdbcTemplate 还多给了什么?多给了样板的消失(不必每次借还连接)和异常的语义化(DataAccessException 体系),以及事务内复用同一条连接。
queryForObject 查不到和查到多行,分别抛什么?EmptyResultDataAccessException(expected 1, actual 0)和 IncorrectResultSizeDataAccessException(expected 1, actual N)。
DuplicateKeyException 和 DataIntegrityViolationException 谁是谁的父类?后者是父类。所以 catch 顺序必须子类在前,否则子类分支永远进不去。
JdbcTemplate 自己创建连接吗?不。它只向 DataSource 要、用完经 DataSourceUtils.releaseConnection 归还;连接真正属于谁,去看 #28。
指令与数据分家,连接与归还分家,方言与工单分家——三个「分家」就是注入、泄漏、异常三大痛点的统一解法。
本篇的进化脉络是 原生 JDBC(七步 + 手写资源管理)→ try-with-resources(解决泄漏)→ JdbcTemplate(模板方法 + 回调,一举消灭样板、注入、异常三大痛点)。四条必须记住的底层认知:① 防注入的唯一正解是参数化占位符,让参数只当数据、永不改变 SQL 结构,而结构类输入(表名、排序字段)只能靠白名单;② JdbcTemplate 把固定流程收进模板、把 SQL 与映射交还给你,连接来自 DataSource,用完归还(下一站是连接池 #28);③ SQLException 会被翻译成语义明确、非受检的 DataAccessException 体系,所以异常该在「有能力做决定」的那一层接住;④ queryForObject 断言「必须恰好一行」,查不到抛 EmptyResultDataAccessException,大结果集请走 RowCallbackHandler。 顺着这条线再去看 MyBatis 与 JPA,你会发现它们都站在 JdbcTemplate 的肩膀上。