Java 与数据库
Java 应用访问数据库的链路很长:JDBC 驱动 → 连接池 → 事务管理器 → ORM/MyBatis → Spring 的代理。任何一层配置错了,表现出来的都是「慢」「锁等待」「连接池耗尽」,但根因可能在完全不同的地方。
1. 一张总表
| 数据库概念 | Java 侧 | 应用开发中的表现 |
|---|---|---|
| 连接 | DriverManager、DataSource、HikariCP | 连接泄漏、池耗尽 |
| 语句 | Statement、PreparedStatement | SQL 注入、执行计划复用 |
| 结果集 | ResultSet、RowMapper | 大结果集 OOM、流式读取 |
| 事务 | Connection.commit/rollback、@Transactional | 长事务、锁等待、回滚失效 |
| 索引 | CREATE INDEX、EXPLAIN | 查询慢、写入放大 |
| 连接池 | HikariCP、Druid | 并发容量、数据库 max_connections |
| ORM | JPA/Hibernate、MyBatis | N+1、懒加载、脏检查 |
| 迁移 | Flyway、Liquibase | schema 版本管理 |
2. JDBC:最小的共同接口
String url = "jdbc:postgresql://localhost:5432/app";
String user = "app";
String password = "secret";
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement(
"SELECT id, name FROM users WHERE email = ?")) {
ps.setString(1, "alice@example.com");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getLong("id") + " " + rs.getString("name"));
}
}
}
2.1 永远不要拼接 SQL
// 错误:SQL 注入
String sql = "SELECT * FROM users WHERE email = '" + email + "'";
Statement st = conn.createStatement();
st.executeQuery(sql);
// 正确:PreparedStatement
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE email = ?");
ps.setString(1, email);
PreparedStatement 防注入是确定的;能否复用执行计划取决于驱动和数据库(例如 MySQL Connector/J 默认 useServerPrepStmts=false,SQL 在客户端拼好后整条发送)。表名、列名、ORDER BY 字段不能用 ? 占位,必须用白名单校验后拼接。
2.2 批量写入
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO events (id, payload) VALUES (?, ?)")) {
for (Event e : events) {
ps.setLong(1, e.id());
ps.setString(2, e.payload());
ps.addBatch();
}
ps.executeBatch();
}
MySQL 需要在 JDBC URL 加 rewriteBatchedStatements=true,否则驱动可能仍然逐条发送。批量大小要结合 max_allowed_packet 和事务日志,不要一次几十万条。
2.3 大结果集
ResultSet 默认会把驱动能拿到的行都放进内存(取决于驱动和 fetch size)。大结果集要设置 fetchSize 并流式读取:
ps.setFetchSize(1000);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// 处理一行
}
}
PostgreSQL 要关闭自动提交才能让 fetchSize 生效;MySQL 需要 useCursorFetch=true 并使用 TYPE_FORWARD_ONLY。
3. 事务:边界比语法重要
3.1 JDBC 事务
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try {
// 多条 SQL
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}
}
3.2 @Transactional 的默认行为
Spring 的 @Transactional 默认:
- 只对
RuntimeException和Error回滚;受检异常(checked exception)默认不回滚; - 传播行为是
REQUIRED:有事务就加入,没有就新建; - 通过代理生效:同类内部
this.method()调用不会走代理,事务注解失效; final类/方法无法被 CGLIB 代理(Spring 6 默认 CGLIB);readOnly = true只是给驱动/ORM 的提示,不是所有数据库都强制只读。
// 伪代码:Order、orderRepo 定义省略
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) throws IOException {
orderRepo.save(order);
// 受检异常也会回滚
}
public void outer(Order order) throws IOException {
this.createOrder(order); // 同类调用,事务不生效
}
}
事务失效的典型原因不止自调用:
- 同类
this.method()调用:不走代理; - 方法不是
public:默认不被代理; - 异常被
catch后没有抛出:Spring 看不到异常; @Transactional加在接口/实现类的代理方式不一致:JDK 动态代理 vs CGLIB;- 异步/多线程:
@Async、手动new Thread不会传播事务上下文; - 类不在 Spring 容器里:
new出来的对象没有代理。
需要同类调用也生效时,把方法拆到另一个 Bean,或用 TransactionTemplate:
transactionTemplate.execute(status -> {
orderRepo.save(order);
return null;
});
3.3 隔离级别与长事务
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 否 | 可能 | 可能 |
| REPEATABLE READ | 否 | 否 | 可能(取决于实现) |
| SERIALIZABLE | 否 | 否 | 否 |
MySQL InnoDB 默认 REPEATABLE READ;PostgreSQL 默认 READ COMMITTED。隔离级别越高,锁等待和冲突越多。
长事务的危害:
- 持有行锁/间隙锁,阻塞其他写入;
- 让 MVCC 旧版本无法回收,表膨胀;
- 占用连接,池子被耗尽;
- 回滚代价大。
典型错误是把「读数据 → 调外部 API → 写数据」放在一个事务里。正确做法是缩小事务范围:先读、提交,调用外部服务,再用短事务写入。
4. 索引:B+ 树不是 HashMap
Java HashMap | 数据库索引 | |
|---|---|---|
| 存储 | 内存桶数组 | 磁盘 B+ 树(页) |
| 查找 | 平均 O(1) | O(log n),受 I/O 影响 |
| 范围查询 | 不支持 | 天然支持 BETWEEN、ORDER BY |
| 排序 | 无 | 索引顺序可满足排序 |
| 写入成本 | 低 | 维护 B+ 树、页分裂、写放大 |
InnoDB 里要区分两种索引:
- 聚簇索引:主键索引的叶子节点直接存整行数据,一张表只有一个;
- 二级索引:叶子节点存「索引列 + 主键值」,查非索引列要拿主键回聚簇索引再查一次,叫回表;
- 覆盖索引:查询需要的列都在索引里,不需要回表。比如索引
(user_id, created_at)上的SELECT created_at FROM t WHERE user_id = ?。
所以 SELECT * 往往比只取需要的列慢:它更容易破坏覆盖索引,还会多读列、放大网络传输。联合索引 (user_id, created_at) 的 B+ 树先按 user_id 排、再按 created_at 排,这就是「最左前缀」的物理原因。
4.1 看执行计划
MySQL:
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'a@b.com';
PostgreSQL:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM users WHERE email = 'a@b.com';
关注:
- MySQL:
type(ALL/index/range/ref/const)、key、rows、filtered、Extra(Using index、Using filesort、Using temporary)。 - PostgreSQL:
Seq Scan/Index Scan/Bitmap Heap Scan、Filter、Rows Removed by Filter、Buffers。
两套字段名不一样,不要拿 MySQL 的 type=ALL 去 PostgreSQL 的输出里找。EXPLAIN ANALYZE 在 MySQL 需要 8.0.18+。
4.2 联合索引的最左前缀
索引 (user_id, created_at) 支持:
WHERE user_id = 1
WHERE user_id = 1 ORDER BY created_at
WHERE user_id = 1 AND created_at > '2026-01-01'
不支持单独 WHERE created_at > ...。设计时把等值条件放前面,范围/排序放后面。
4.3 索引失效
- 在索引列上做函数/运算:
WHERE DATE(created_at) = ...; - 隐式类型转换:字符串列和数字比较;
- 前导通配符:
LIKE '%abc'; OR连接未索引列;- 选择性太差:状态、性别这类低基数列;
- 统计信息过期:需要
ANALYZE。
5. 连接池:并发容量的闸门
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/app");
config.setUsername("app");
config.setPassword("secret");
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
config.setConnectionTimeout(3_000);
config.setMaxLifetime(1_800_000);
config.setLeakDetectionThreshold(60_000);
DataSource ds = new HikariDataSource(config);
关键参数:
| 参数 | 作用 | 建议 |
|---|---|---|
maximumPoolSize | 最大连接数 | 不是越大越好,按数据库容量定 |
minimumIdle | 最小空闲连接 | 和 max 接近可减少创建抖动 |
connectionTimeout | 等连接超时 | 3~5 秒,快速失败 |
maxLifetime | 连接最大寿命 | 小于数据库/中间件的超时 |
leakDetectionThreshold | 连接借出多久没还就告警 | 排障期开启;阈值要大于 P99 事务耗时,长期开启有性能开销 |
validationTimeout | 连接探活超时 | 默认即可 |
硬约束:
- 所有实例的
maximumPoolSize总和不能超过数据库max_connections; - 多 worker/多 Pod 时,
实例数 × poolSize才是总连接数; - 连接池不是越大越好:活跃连接太多,数据库上下文切换和锁竞争会让整体更慢;
- 中间件 PgBouncer/ProxySQL 能复用连接,但 transaction pooling 下会话状态/预处理语句要小心。
6. JPA / Hibernate
6.1 N+1
// 每个 order 都触发一次 user 查询:1 + N
List<Order> orders = orderRepo.findAll();
for (Order o : orders) {
System.out.println(o.getUser().getName());
}
解决:
@Query("select o from Order o join fetch o.user")
List<Order> findAllWithUser();
// 或
@EntityGraph(attributePaths = "user")
List<Order> findAll();
- 多对一/一对一:
join fetch通常合适; - 一对多:
@EntityGraph或@BatchSize,避免 JOIN 笛卡尔积; - 只读查询用 DTO projection,不要加载整个实体图。
6.2 open-in-view
Spring Boot 默认 spring.jpa.open-in-view=true,把 EntityManager 开到视图渲染结束。它会让懒加载在 Controller/模板里「碰巧能用」,同时掩盖 N+1 和连接占用。生产建议设为 false,在 Service 层完成数据加载。
6.3 乐观锁与批量
@Entity
public class Account {
@Id Long id;
long balance;
@Version
long version; // 乐观锁
}
批量处理时,EntityManager 的持久化上下文会越来越大:
for (int i = 0; i < entities.size(); i++) {
em.persist(entities.get(i));
if ((i + 1) % 50 == 0) {
em.flush();
em.clear(); // 清一级缓存,避免 OOM
}
}
em.flush();
em.clear(); // 处理最后不足 50 条的部分
7. MyBatis
#{}是预编译参数,走PreparedStatement;${}是字符串替换,有注入风险,只用于表名/列名等白名单场景。- 一对多/多对一用
resultMap或嵌套查询;嵌套查询在循环里容易 N+1,优先join+collection。 - 分页用 PageHelper 或手写
LIMIT,深分页用 keyset(上一页最后一个 id)。 - 批量插入用
<foreach>拼接 values,注意 SQL 长度和事务大小。
<select id="findByEmail" resultType="User">
SELECT id, name FROM users WHERE email = #{email}
</select>
8. 异步与响应式
- 传统 JDBC 是阻塞的;在虚拟线程里可以直接用,每个请求一个虚拟线程,阻塞时卸载。
- WebFlux/Reactor 里不要用阻塞 JDBC,会卡住 EventLoop;用 R2DBC 或把 JDBC 调用丢到
boundedElastic调度器。 @Async返回CompletableFuture,但事务上下文不会自动传播;异步方法里要重新开事务。
9. 应用开发中的组合
| 场景 | 做法 |
|---|---|
| 列表分页 | LIMIT/OFFSET 小数据量;深分页用 keyset |
| 计数 | 大表 COUNT(*) 很贵;用近似统计/计数表 |
| 批量写入 | addBatch/executeBatch,控制批大小 |
| 幂等 | 唯一索引 + ON CONFLICT DO NOTHING / INSERT IGNORE |
| 软删除 | deleted_at IS NULL 放进联合索引 |
| 多租户 | 所有查询带 tenant_id,索引以它开头 |
| 缓存 | 先查 Redis,再查库;注意穿透/雪崩 |
| 分布式事务 | 尽量避免;用本地消息表/Saga/最终一致 |
10. 常见坑
@Transactional同类调用失效:this.method()不走代理。- 受检异常不回滚:默认只回滚
RuntimeException/Error。 - 长事务:外部 API、文件上传、睡眠都放在事务里。
- 连接泄漏:没有
try-with-resources,池子逐渐耗尽。 open-in-view=true:懒加载在视图层触发 N+1,还占着连接。- N+1:ORM 懒加载在循环里触发大量查询。
${}拼接 SQL:MyBatis 里用${}接收用户输入,注入风险。- 时区:数据库存 UTC,展示层转本地时区;
LocalDateTime不带时区,跨时区会错。 - 实体
equals/hashCode:用数据库自增 id 生成,未保存时都是 null;用业务键或 UUID。 - 池子大于数据库容量:所有实例一起抢连接,数据库先崩。
11. 动手实验
需要把 H2 驱动放进 classpath(JDBC 4 会自动发现驱动):
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.224</version>
<scope>runtime</scope>
</dependency>
Gradle:runtimeOnly("com.h2database:h2:2.2.224")。
import java.sql.*;
public class JdbcLab {
public static void main(String[] args) throws Exception {
String url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1";
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
conn.createStatement().execute(
"CREATE TABLE users (id BIGINT PRIMARY KEY, email VARCHAR(100), name VARCHAR(100))");
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (id, email, name) VALUES (?, ?, ?)")) {
for (int i = 0; i < 1000; i++) {
ps.setLong(1, i);
ps.setString(2, "u" + i + "@example.com");
ps.setString(3, "user" + i);
ps.addBatch();
}
ps.executeBatch();
}
try (PreparedStatement ps = conn.prepareStatement(
"SELECT id, name FROM users WHERE email = ?")) {
ps.setString(1, "u500@example.com");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getLong("id") + " " + rs.getString("name"));
}
}
}
}
}
}
观察:
- 用
PreparedStatement的批量插入,比逐条executeUpdate快; - 把
email加上索引后,EXPLAIN从全表扫描变成索引查找; - 索引让读变快,但插入/更新要多维护一棵 B+ 树,写放大是代价。
12. 小结
- JDBC 是连接、语句、结果集、事务的最小接口;参数化查询是安全底线。
@Transactional靠代理生效,同类调用会失效;默认只回滚运行时异常。- 索引是 B+ 树,不是
HashMap;联合索引遵守最左前缀,用EXPLAIN验证。 - 连接池是并发闸门,
实例数 × poolSize不能超过数据库容量。 - ORM 提升开发效率,但 N+1、懒加载、
open-in-view、批量 flush 要自己管。 - 事务边界要短,外部调用不要放在事务里;这是比任何 ORM 技巧都重要的原则。