CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
JDBC 与 MyBatis
这一课你将能做到
配置数据源并用 JdbcTemplate 完成增删改查,理解 DataSource 与连接池的角色
用 MyBatis 注解和 XML 两种方式写 Mapper,会用 <if>/<where> 写动态 SQL
说清 #{} 与 ${} 的区别,避免 SQL 注入这个最高频的安全漏洞
为什么要学
到目前为止任务数据都存在内存 Map 里,应用一重启就没了。真实系统要落库,而Java 后端绕不开 SQL :复杂报表、多表关联、性能调优,最终都落在 SQL 上。
MyBatis 是国内企业的事实标准(本站后端用的 MyBatis-Flex 也是 MyBatis 系的增强框架):SQL 完全由你掌控、可读可调优,正好补足 ORM 框架对复杂 SQL 的短板。学它的前置是理解 JDBC 这套地基——DataSource、Connection、参数占位符这些概念在所有持久层框架里通用。
先建立图像
你的代码 MyBatis 帮你干的活 数据库
───────── ────────────────── ──────
TaskMapper ① 根据接口方法找到对应 SQL
.findById(id) → ② 把 id 填进 SQL 占位符 → SELECT ... WHERE id = ?
③ 执行查询,拿到 ResultSet
Task 对象 ← ④ 按映射规则把行转成对象 ← 一行行数据
人话:JDBC 是 Java 操作数据库的原始标准(手动开关连接、手动填参数、手动取结果);MyBatis 把「填参数、取结果、转对象」这些机械劳动自动化,SQL 本身还是你写 。
名词 人话 DataSource 连接池 + 配置(URL、账号、密码),管着一池子可复用的连接 Connection 到数据库的一条 TCP 会话,用完归还池子,不 new 不 close 原始对象 Mapper MyBatis 的数据访问接口,方法 ↔ SQL 的一一映射 ResultMap 「查询结果列 → Java 字段」的映射规则
跟着做
步骤 1:引入依赖与数据源配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- H2:本课零安装练手用;生产换 MySQL 驱动 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 生产 MySQL 时启用:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
-->
application-dev.yml 配置数据源(Boot 检测到 jdbc 依赖会自动装配 HikariCP 连接池):
spring:
datasource:
url: jdbc:h2:mem:taskflow;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password: ""
h2:
console:
enabled: true # 提供 /h2-console 网页端查看数据(仅开发用)
步骤 2:用 schema.sql 建表
Boot 对内嵌数据库会自动执行 classpath 下的 schema.sql 和 data.sql。新建 src/main/resources/schema.sql:
CREATE TABLE IF NOT EXISTS task (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(50) NOT NULL,
priority INT NOT NULL DEFAULT 3,
status VARCHAR(20) NOT NULL DEFAULT 'TODO',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO task (title, priority, status) VALUES
('学习 Spring Boot 配置', 1, 'DONE'),
('学习 MyBatis 动态 SQL', 2, 'DOING'),
('复习事务传播行为', 3, 'TODO');
启动后打开 http://localhost:8080/h2-console(JDBC URL 填 jdbc:h2:mem:taskflow)能直接看到表和数据——先确认库通了,再写代码 。
步骤 3:JdbcTemplate 版 CRUD package com.example.taskflow.repository;
import com.example.taskflow.model.Task;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public class TaskJdbcRepository {
private final JdbcTemplate jdbcTemplate;
public TaskJdbcRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
private static final RowMapper<Task> MAPPER = (rs, rowNum) -> mapRow(rs);
private static Task mapRow(ResultSet rs) throws SQLException {
Timestamp ts = rs.getTimestamp("created_at");
LocalDateTime createdAt = ts != null ? ts.toLocalDateTime() : null;
return new Task(
rs.getLong("id"),
rs.getString("title"),
rs.getInt("priority"),
rs.getString("status"),
createdAt
);
}
public List<Task> findAll() {
return jdbcTemplate.query("SELECT * FROM task ORDER BY priority, id", MAPPER);
}
public Optional<Task> findById(long id) {
List<Task> list = jdbcTemplate.query(
"SELECT * FROM task WHERE id = ?", MAPPER, id);
return list.stream().findFirst();
}
public int insert(String title, int priority) {
return jdbcTemplate.update(
"INSERT INTO task(title, priority, status) VALUES (?, ?, 'TODO')",
title, priority);
}
public int updateStatus(long id, String status) {
return jdbcTemplate.update("UPDATE task SET status = ? WHERE id = ?", status, id);
}
public int delete(long id) {
return jdbcTemplate.update("DELETE FROM task WHERE id = ?", id);
}
}
体会三点:? 占位符防注入;query 返回 List、update 返回影响行数;RowMapper 负责「行 → 对象」的手工转换——这种模板代码正是 MyBatis 要消灭的。
步骤 4:MyBatis 注解版 Mapper 添加依赖(Boot 3 对应 mybatis-spring-boot-starter 3.x):
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.4</version>
</dependency>
package com.example.taskflow.mapper;
import com.example.taskflow.model.Task;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Optional;
@Mapper
public interface TaskMapper {
@Select("SELECT * FROM task WHERE id = #{id}")
Optional<Task> findById(long id);
@Select("SELECT * FROM task ORDER BY priority, id")
List<Task> findAll();
@Insert("INSERT INTO task(title, priority, status) VALUES(#{title}, #{priority}, 'TODO')")
@Options(useGeneratedKeys = true, keyProperty = "id") // 回填自增主键
int insert(Task task);
@Update("UPDATE task SET status = #{status} WHERE id = #{id}")
int updateStatus(@Param("id") long id, @Param("status") String status);
@Delete("DELETE FROM task WHERE id = #{id}")
int delete(long id);
}
Controller/Service 里直接注入使用:
@RestController
@RequestMapping("/api/tasks")
public class TaskDbController {
private final TaskMapper taskMapper;
public TaskDbController(TaskMapper taskMapper) {
this.taskMapper = taskMapper;
}
@GetMapping("/{id}")
public Task detail(@PathVariable long id) {
return taskMapper.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.TASK_NOT_FOUND));
}
}
为什么接口不用写实现?MyBatis 在启动时为每个 @Mapper 接口生成动态代理 ,调用方法时代理根据「注解或 XML 里同 id 的 SQL」去执行——和上一课 AOP 的代理思想同源。
步骤 5:XML 版 + 动态 SQL(企业里最常用的形态) mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true # created_at → createdAt 自动映射
src/main/resources/mapper/TaskMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.taskflow.mapper.TaskMapper">
<!-- 动态 SQL:条件拼装,解决「筛选条件可空」的经典问题 -->
<select id="search" resultType="com.example.taskflow.model.Task">
SELECT * FROM task
<where>
<if test="status != null and status != ''">
AND status = #{status}
</if>
<if test="keyword != null and keyword != ''">
AND title LIKE CONCAT('%', #{keyword}, '%')
</if>
<if test="minPriority != null">
AND priority <= #{minPriority}
</if>
</where>
ORDER BY priority, id
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO task(title, priority, status)
VALUES(#{title}, #{priority}, #{status})
</insert>
</mapper>
List<Task> search(@Param("status") String status,
@Param("keyword") String keyword,
@Param("minPriority") Integer minPriority);
<where> 标签会自动处理开头多余的 AND;<= 是 XML 转义的小于等于(或用 <![CDATA[ <= ]]>)。map-underscore-to-camel-case 开启后,SELECT * 的下划线列自动对上驼峰字段,resultMap 都省了。
步骤 6:用日志亲眼看到 SQL logging:
level:
com.example.taskflow.mapper: DEBUG
==> Preparing: SELECT * FROM task WHERE id = ?
==> Parameters: 1(Long)
<== Total: 1
「Preparing + Parameters」两行是排查数据问题的第一现场:SQL 长什么样、参数填了什么,一目了然。
讲清楚要点
占位符是安全底线 :#{} 预编译参数化,值永远当「数据」处理;${} 是字符串拼接,值直接混进 SQL 文本。只有动态表名/排序字段这类「结构拼接」才允许用 ${},且必须白名单校验。WHERE title = '${keyword}' 传入 ' OR '1'='1 即注入成功——这是面试和代码审查双重必考。
连接池是性能基石 :HikariCP 复用 TCP 连接,省去每次建连的握手开销。配置里最重要的是 maximum-pool-size(默认 10,按数据库承载能力调),连接泄漏(借了不还)是典型事故源。
Mapper 是接口不是类 :动态代理 + SQL 定位(注解或 XML 的 namespace.method)。报错 Invalid bound statement (not found) 意味着接口方法找不到对应 SQL——十有八九是 XML 路径、namespace 或方法 id 对不上。
注解 SQL 适合简单 CRUD,XML 适合一切复杂的 :动态 SQL、多表关联、结果映射,XML 的可读性和可维护性碾压注解里的字符串拼接。企业实践:能写 XML 就写 XML。
JdbcTemplate 没过时 :临时运维脚本、复杂动态查询、不想引 MyBatis 的小服务,它依然是最轻量的选择。学它是为了理解持久层的地基动作。
你可能会懵的地方
MyBatis 和 JPA 到底选哪个?
国内业务系统主流是 MyBatis 系:SQL 可控、便于调优和 DBA 审查;JPA 在标准 CRUD 和领域建模上更省力。两者可以共存:报表走 MyBatis、标准 CRUD 走 JPA,下一课详细对比。
MyBatis-Flex 是什么?和本课学的什么关系?
它是基于 MyBatis 的增强框架(同族还有 MyBatis-Plus),提供 QueryWrapper 链式构造、逻辑删除、分页插件等能力,底层仍是 MyBatis 的 Mapper 机制。本课的 @Mapper、XML、动态 SQL 概念完全适用,学完直接能看懂本站后端代码。
数据库下划线命名和 Java 驼峰为什么对不上?
SQL 习惯 created_at,Java 习惯 createdAt。开启 map-underscore-to-camel-case: true 自动映射;或者在 XML 里显式写 <resultMap> 逐列映射,控制更精细。
分页是 LIMIT 自己写还是用插件?
手写 LIMIT #{offset}, #{size} 需要再写一条 COUNT,繁琐;MyBatis 系项目通常引分页插件(PageHelper / MyBatis-Flex 的 paginate),一行代码返回「数据 + 总数」。原理都是拦截 SQL 自动改写。
为什么我不能在 Mapper 方法里重载?
动态代理靠「方法名 + 参数位置」定位 SQL id,重载会造成多个同名方法无法区分。Mapper 的方法签名必须唯一。
错法对照 错法 问题 正解 ${keyword} 拼接用户输入SQL 注入漏洞 用 #{};仅结构拼接用 ${} + 白名单 忘记 mapper-locations 配置 Invalid bound statement XML 放对路径并配置扫描 XML 里直接写 <= XML 解析报错 <= 或 CDATA 包裹实体字段 camelCase 但没开驼峰映射 查出来的字段全是 null 开启 map-underscore-to-camel-case Connection 用完手动 close 到底 连接池被掏空 交给 JdbcTemplate/MyBatis 自动归还
卡点急救
Invalid bound statement (not found) :按序检查——XML 文件在不在 mapper-locations 指向的路径;namespace 是不是接口全限定名;方法 id 与接口方法名是否一致;项目是否重新编译(target 里有没有该 XML)。
H2 建表没生效 :schema.sql 放在 src/main/resources 根目录;确认连接 URL 的库名一致;非内嵌库需显式配 spring.sql.init.mode: always。
插入后拿不到自增 id :注解方式确认 @Options(useGeneratedKeys = true, keyProperty = "id"),XML 方式确认 useGeneratedKeys="true" keyProperty="id",且传参是实体对象。
查询字段全为 null :列名与字段名不一致,开驼峰映射或补 resultMap;顺便看步骤 6 的 SQL 日志确认查到了数据。
面试常问
#{} 和 ${} 的区别?
#{} 预编译占位,参数以 PreparedStatement 参数传入,防 SQL 注入;${} 直接字符串替换进 SQL,有注入风险,仅用于表名、列名等结构拼接且必须白名单校验。
MyBatis 的执行原理?
启动时解析 Mapper 接口与 XML/注解,为每个接口创建动态代理;调用方法时根据 namespace + 方法名定位 MappedStatement,参数对象解析成 SQL 参数,执行后按映射规则把 ResultSet 转成目标类型。
一级缓存和二级缓存?
一级缓存是 SqlSession 级(默认开启,同一会话内相同查询复用;Spring 集成下每次请求新 session,基本感知不到);二级缓存是 namespace 级,跨会话共享,更新频繁场景易脏读,实践中基本不用,缓存交给 Redis。
动态 SQL 有哪些标签?
<if>、<where>、<set>、<choose>/<when>/<otherwise>、<foreach>(IN 条件与批量插入)、<trim>。能手写一个「多条件 + IN 查询」的完整 XML。
自测三问
SELECT * FROM user WHERE name = '${name}' 传入 x' OR '1'='1 会发生什么?怎么改?
Mapper 接口为什么不需要实现类?Invalid bound statement 的排查清单是什么?
动态 SQL 里 <where> 标签替你干了什么?
小结一句话 DataSource 管连接、#{} 防注入、Mapper 接口靠动态代理绑定 SQL——MyBatis 把 JDBC 的机械劳动自动化,而 SQL 的掌控权仍然在你手里。
延伸