跳到主要内容

Java 与数据库

Java 应用访问数据库的链路很长:JDBC 驱动 → 连接池 → 事务管理器 → ORM/MyBatis → Spring 的代理。任何一层配置错了,表现出来的都是「慢」「锁等待」「连接池耗尽」,但根因可能在完全不同的地方。

1. 一张总表​

数据库概念Java 侧应用开发中的表现
连接DriverManager、DataSource、HikariCP连接泄漏、池耗尽
语句Statement、PreparedStatementSQL 注入、执行计划复用
结果集ResultSet、RowMapper大结果集 OOM、流式读取
事务Connection.commit/rollback、@Transactional长事务、锁等待、回滚失效
索引CREATE INDEX、EXPLAIN查询慢、写入放大
连接池HikariCP、Druid并发容量、数据库 max_connections
ORMJPA/Hibernate、MyBatisN+1、懒加载、脏检查
迁移Flyway、Liquibaseschema 版本管理

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 技巧都重要的原则。
加载评论中...