Java 连接 PostgreSQL
Java 生态通过 JDBC 标准接口访问 PostgreSQL,官方驱动是 pgjdbc(Maven 坐标 org.postgresql:postgresql)。JDBC 的抽象让「连接、预处理、事务」有一套跨数据库的标准姿势,但 PostgreSQL 特有的能力——COPY 批量装载、LISTEN/NOTIFY、JSONB、数组类型——都需要落到 pgjdbc 的扩展 API(CopyManager、PGConnection、PGobject)才能用足。本页的主线就是「标准 JDBC 打底 + pgjdbc 扩展补全」,再以 HikariCP 与 Spring Boot 收尾到生产形态。
Java 服务通常是长驻进程,连接管理与 PHP/脚本语言有本质差异:应用自己持有连接池、连接生命周期横跨数小时,因此池参数、语句缓存、批量重写这类「连接级优化」在 Java 里的杠杆特别大——一个 reWriteBatchedInserts=true 就能把批量插入提速一个数量级,一条错误的池配置也能让数据库在流量高峰瞬间耗尽连接。
阅读本页需要 JDBC 基础(Connection/PreparedStatement/ResultSet 的基本用法);我们把笔墨集中在「PostgreSQL 视角」的差异点与工程实践上。
前置知识
建议先阅读:Python 连接 PostgreSQL
基础概念
| 概念 | 英文 | 说明 |
|---|---|---|
| JDBC | Java Database Connectivity | Java 的数据库访问标准接口 |
| pgjdbc | PostgreSQL JDBC Driver | 官方驱动,实现 JDBC 4.x |
| 扩展查询协议 | extended query protocol | 服务端预处理的底层协议,pgjdbc 默认使用 |
| 批量重写 | batch rewrite | reWriteBatchedInserts 把 N 条 INSERT 合并成多值 INSERT |
| CopyManager | — | pgjdbc 对 COPY 协议的 Java 封装 |
| PGConnection | — | pgjdbc 的 PostgreSQL 扩展接口(通知、大对象等) |
| HikariCP | — | 事实标准的 JDBC 连接池 |
连接链路的完整图景:
应用线程 ──► HikariCP 连接池 ──► PgJDBC Connection ──► PostgreSQL 后端进程
(池化复用) (语句缓存/批量重写) (每连接一进程)两个决定性能的默认行为需要先知道:
- pgjdbc 默认走扩展查询协议(
PREPARE/BIND/EXECUTE),参数在服务端绑定,注入安全由协议层保证;同名语句复用服务端执行计划(默认第 5 次起,由prepareThreshold控制); executeBatch()默认是逐条发送——只是省了 API 样板,网络往返一点没省;配合 URL 参数reWriteBatchedInserts=true才会把同构 INSERT 重写成多值语句,这是 Java 批量插入的第一优化。
语法与用法
Maven 依赖(版本以 Maven Central 最新 42.7.x 为准):
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.4</version>
</dependency>JDBC URL 与常用参数:
jdbc:postgresql://db1.prod.internal:5432/appdb
?user=app_rw&password=secret
&sslmode=require
&connectTimeout=5&socketTimeout=60
&ApplicationName=svc-orders
&reWriteBatchedInserts=true| 参数 | 说明 |
|---|---|
sslmode | disable/prefer/require/verify-ca/verify-full(同 libpq 语义) |
connectTimeout | 建连超时,单位秒 |
socketTimeout | 读超时秒数(0 为不限),防止查询永久挂死 |
ApplicationName | 应用标识,pg_stat_activity 里可溯源 |
reWriteBatchedInserts | true 时批量 INSERT 重写为多值语句 |
prepareThreshold | 同一语句执行多少次后改用服务端命名预处理(默认 5) |
currentSchema | 等价 search_path,如 currentSchema=tenant_a,public |
标准 JDBC 用法(现代驱动经 SPI 自动注册,无需 Class.forName):
try (Connection conn = DriverManager.getConnection(url, user, pass);
PreparedStatement ps = conn.prepareStatement(
"SELECT id, name FROM users WHERE status = ? AND age > ?")) {
ps.setString(1, "active");
ps.setInt(2, 18);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
String name = rs.getString("name");
}
}
}事务与批量:
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO items (id, name) VALUES (?, ?)")) {
for (Item it : items) {
ps.setLong(1, it.id());
ps.setString(2, it.name());
ps.addBatch();
}
int[] counts = ps.executeBatch(); // 配合 reWriteBatchedInserts 提速
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}COPY 导入(pgjdbc 扩展):
CopyManager cm = conn.unwrap(PgConnection.class).getCopyAPI();
try (InputStream in = new FileInputStream("orders.csv")) {
long rows = cm.copyIn(
"COPY stg_orders FROM STDIN WITH (FORMAT csv, HEADER true)", in);
}详细说明
依赖、驱动版本与兼容性
pgjdbc 版本号与 JDBC 规范、JDK 版本的对应关系需要留意:42.x 系列支持 JDK 8+ 与 JDBC 4.2,能连接各代 PostgreSQL 服务器(驱动向下兼容多年前的服务器版本)。实践要点:
- 升级驱动是低风险高收益的运维动作:42.x 系列修过连接泄露、批量重写边界、SSL 握手等多类问题,保持跟随最新补丁版;
- 驱动版本与服务器大版本解耦:不像 libpq 那样强调同源编译,Maven 依赖即用;
- 不要同时引入多个驱动实现(如残留的旧 net/postgres 驱动),
DriverManager的注册顺序会带来诡异行为。
Gradle 用户对应 implementation 'org.postgresql:postgresql:42.7.4';Jakarta EE 容器部署时把 jar 放入应用即可,现代容器普遍支持 SPI 自动发现。
JDBC URL 与连接参数全集
pgjdbc 的 URL 参数分两族:libpq 同名参数(sslmode、connectTimeout 等,语义与 libpq 一致)与 pgjdbc 专属参数(reWriteBatchedInserts、prepareThreshold、ApplicationName 等)。几个高频组合:
# 生产 Web 服务标配
jdbc:postgresql://db1:5432/appdb?sslmode=verify-full&connectTimeout=3&socketTimeout=60&ApplicationName=svc-orders
# 批量任务
jdbc:postgresql://db1:5432/warehouse?reWriteBatchedInserts=true&ApplicationName=etl-daily
# 多 schema 应用
jdbc:postgresql://db1:5432/appdb?currentSchema=tenant_a,publicsocketTimeout 值得单独强调:默认 0(无限等待),一条卡死的查询会把调用线程永久占住,最终拖垮线程池。按业务 P99 的数倍设置一个上限,配合服务端 statement_timeout 双保险。verify-full 需要提供 CA 证书(sslrootcert 参数或 JVM 信任库),公网与跨机房链路应作为默认。
DriverManager 与 DataSource
DriverManager 适合脚本与原型;生产代码一律走 DataSource——它是连接池、JNDI、Spring 等基础设施的标准接入点。pgjdbc 自带两个轻量 DataSource 实现:PGSimpleDataSource(不池化,每次 getConnection 新建连接,适合需要显式控制生命周期的场景)与 PGConnectionPoolDataSource(应用内简单池)。真实项目里这两者通常被 HikariCP 替代(后述),但理解 DataSource 接口本身很重要:
PGSimpleDataSource ds = new PGSimpleDataSource();
ds.setURL("jdbc:postgresql://db1:5432/appdb");
ds.setUser("app_rw");
ds.setPassword("secret");
ds.setSslMode("require"); // 参数也有 setter,IDE 可发现性好
ds.setApplicationName("svc-orders");
try (Connection conn = ds.getConnection()) { /* … */ }PreparedStatement 与类型映射
PreparedStatement 是 Java 侧防注入与提性能的基石:语句模板一次解析、参数另行绑定。PostgreSQL 类型映射的要点:
| PostgreSQL 类型 | JDBC 获取 / 设置 | 说明 |
|---|---|---|
int8/bigint | getLong / setLong | 64 位整数 |
numeric | getBigDecimal / setBigDecimal | 金额必用,杜绝 double |
timestamptz | getOffsetDateTime / setOffsetDateTime | 带时区时间 |
bool | getBoolean / setBoolean | |
uuid | getObject(..., UUID.class) / setObject(i, uuid) | 原生支持 |
jsonb | getString 读 / PGobject 或 ?::jsonb 写 | 见下 |
text[] | getArray / createArrayOf("text", …) | |
bytea | getBytes / setBytes | 二进制列 |
JSONB 写入的两种姿势:
// 方式一:PGobject 显式声明类型
PGobject json = new PGobject();
json.setType("jsonb");
json.setValue("{\"kind\":\"click\",\"n\":3}");
ps.setObject(1, json);
// 方式二:SQL 侧强转 + setString
ps = conn.prepareStatement("INSERT INTO events (payload) VALUES (?::jsonb)");
ps.setString(1, "{\"kind\":\"click\"}");方式二更简洁且不依赖驱动扩展类,但要注意传入内容必须是合法 JSON;两种方式都让 JSON 解析留在数据库层,客户端不做任何字符串拼接。
事务、隔离级别与 SAVEPOINT
conn.setAutoCommit(false);
conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
try {
// …业务 SQL…
Savepoint sp = conn.setSavepoint("before_risky");
try {
// 风险段
} catch (SQLException e) {
conn.rollback(sp); // 局部回滚
}
conn.commit();
} catch (SQLException e) {
conn.rollback(); // 整体回滚
}Java 长驻进程里事务的三个纪律:事务边界放服务层(DAO 不开事务,只参与外层事务);事务内不调用慢 IO(HTTP、MQ、文件),连接被长时间占用是池耗尽的头号原因;异常路径必然 rollback(try-with-resources + finally 或框架的 @Transactional)。
批量执行与 reWriteBatchedInserts
addBatch()/executeBatch() 的默认行为是把 N 条语句逐条发给服务器,省的是代码与解析,费的是网络。reWriteBatchedInserts=true 让驱动把同构的 INSERT INTO t VALUES (?,?) 重写成一条多值语句:
INSERT INTO t VALUES (?,?) × 1000 条批量
↓ reWriteBatchedInserts=true(每 ~128 行重写一条)
INSERT INTO t VALUES (?,?),(?,?),(?,?)… × ~8 条网络往返从 1000 次降到个位数,插入吞吐通常提升 2~5 倍,配合 COPY 之前这是 Java 批量写入的第一步。使用注意:
- 只对同构 INSERT(同一表、同一列集)生效,异构批退化为逐条;
- 重写有单语句长度上限的考量,驱动内部按批次拆分;
executeBatch()的返回值在重写模式下是「每批合计」语义,按行精确统计需要用RETURNING或另行核对。
批量更大(百万行级)时,请直接进入下节的 CopyManager。
CopyManager:Java 侧最快的写入通道
CopyManager 封装 COPY 协议,输入/输出可以是流、Reader/Writer 或字节数组。获取它需要拿到 pgjdbc 的原生连接:
import org.postgresql.copy.CopyManager;
import org.postgresql.jdbc.PgConnection;
// 池化连接(HikariCP 包装)也能 unwrap
CopyManager cm = conn.unwrap(PgConnection.class).getCopyAPI();
// 导入:InputStream 逐块流入,内存平稳
try (InputStream in = Files.newInputStream(Path.of("events.csv"))) {
long copied = cm.copyIn(
"COPY stg_events (event_id, user_id, kind, event_time) "
+ "FROM STDIN WITH (FORMAT csv, HEADER true)", in);
System.out.println("copied " + copied + " rows");
}
// 导出:流式输出
try (OutputStream out = Files.newOutputStream(Path.of("dump.csv"))) {
cm.copyOut("COPY orders TO STDOUT WITH (FORMAT csv, HEADER true)", out);
}copyIn(String, InputStream) 返回拷贝行数,失败抛 SQLException 且事务中止(整个 COPY 是一个事务)。与 \copy 一页的性能清单完全相通:摘索引、关外键、synchronous_commit=off、PG17 的 ON_ERROR ignore 选项都可以写进 copyIn 的 SQL 字符串。
LISTEN/NOTIFY:Java 侧的发布订阅
PostgreSQL 的进程内消息通道在 pgjdbc 里是 LISTEN + getNotifications() 轮询组合:
import org.postgresql.PGConnection;
import org.postgresql.PGNotification;
try (Connection conn = ds.getConnection()) {
PGConnection pg = conn.unwrap(PGConnection.class);
try (Statement st = conn.createStatement()) {
st.execute("LISTEN order_events");
}
while (!closed) {
PGNotification[] ns = pg.getNotifications();
if (ns != null) {
for (PGNotification n : ns) {
handle(n.getName(), n.getParameter());
}
}
Thread.sleep(500); // 轮询间隔
}
}工程要点:必须独占连接(池借出后专用于监听);通知只在事务提交后可见(NOTIFY 所在事务提交时才投递);轮询间隔决定延迟下限,实时性要求高时配合 socketTimeout 阻塞读或改用专业消息队列。适用边界是「轻量、进程内、小负载」的协作信号,不适合做业务消息总线。
ServerErrorMessage:错误上下文的细粒度取用
SQLException 之外,pgjdbc 把服务端错误的完整结构暴露在 ServerErrorMessage:
catch (SQLException e) {
org.postgresql.util.ServerErrorMessage sem = e.getServerErrorMessage();
if (sem != null) {
log.warn("sqlstate={} constraint={} table={} detail={}",
sem.getSQLState(),
sem.getConstraint(), // 约束名,如 users_email_key
sem.getTable(),
sem.getDetail()); // 如 Key (email)=(a@b.com) already exists.
}
throw e;
}getConstraint() 的约束名可以做成「约束名 → 业务错误码」的映射表——同一张表有多个唯一约束时能区分「邮箱冲突」还是「手机号冲突」,比只看 SQLSTATE 精确;getDetail() 常包含冲突行的键值,写日志时注意脱敏。与 SQLSTATE 分支模板组合:先用 getSQLState() 判类别,再用约束名判具体业务语义。
HikariCP:生产连接池的标准配置
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://db1:5432/appdb?sslmode=require&reWriteBatchedInserts=true");
cfg.setUsername("app_rw");
cfg.setPassword(System.getenv("DB_PASS"));
cfg.setMaximumPoolSize(20);
cfg.setMinimumIdle(5);
cfg.setConnectionTimeout(3_000); // 借连接最多等 3s
cfg.setMaxLifetime(1_800_000); // 连接最长存活 30min,短于服务端 idle 超时
cfg.setKeepaliveTime(300_000); // 5min 保活探测
cfg.addDataSourceProperty("ApplicationName", "svc-orders");
cfg.addDataSourceProperty("prepareThreshold", "5");
HikariDataSource ds = new HikariDataSource(cfg);池大小不是「越大越好」:PostgreSQL 每连接一个进程,池过大反而加剧上下文切换与锁竞争。经验起点是 CPU 核数的 2~4 倍,再按压测调整;池参数与 PostgreSQL 侧配置的关系详见《连接池最佳实践》与《PgBouncer》。当应用实例多、连接数不可控时,Hikari 之外再叠加 PgBouncer 做集中收敛。
大对象(Large Object)的读写
大对象 API 在 pgjdbc 的 LargeObjectManager,必须配合事务:
import org.postgresql.largeobject.LargeObject;
import org.postgresql.largeobject.LargeObjectManager;
conn.setAutoCommit(false);
LargeObjectManager lom = conn.unwrap(PGConnection.class)
.getLargeObjectAPI();
// 上传:创建 OID,流式写入
long oid = lom.createLO();
try (LargeObject lo = lom.open(oid, LargeObjectManager.WRITE);
InputStream in = Files.newInputStream(Path.of("report.pdf"))) {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) {
lo.write(buf, 0, n);
}
}
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO documents (name, lo_oid) VALUES (?, ?)")) {
ps.setString(1, "report.pdf");
ps.setLong(2, oid);
ps.executeUpdate();
}
conn.commit();
// 下载:按 OID 流式读取
try (PreparedStatement ps = conn.prepareStatement(
"SELECT lo_oid FROM documents WHERE id = ?")) {
ps.setLong(1, docId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
try (LargeObject lo = lom.open(rs.getLong(1), LargeObjectManager.READ);
OutputStream out = Files.newOutputStream(Path.of("copy.pdf"))) {
byte[] buf = new byte[8192];
int n;
while ((n = lo.read(buf)) > 0) {
out.write(buf, 0, n);
}
}
}
}
conn.commit();工程要点:读写都必须在事务内(无事务直接报错);LargeObject 支持 seek() 随机访问,是 bytea(整块读写)做不到的;删除行不会自动清理大对象,需要 lo_unlink 或 lo_manage 触发器配合——大对象与 bytea 的完整取舍见大对象总览。
Spring Boot 集成
Spring Boot 自动装配 HikariCP + pgjdbc,配置集中在 application.yml:
spring:
datasource:
url: jdbc:postgresql://db1:5432/appdb?sslmode=require&reWriteBatchedInserts=true&ApplicationName=svc-orders
username: app_rw
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
max-lifetime: 1800000
jpa:
properties:
hibernate:
jdbc:
batch_size: 100 # JPA 批量(配合 reWriteBatchedInserts)JPA/Hibernate 用户的补充:batch_size 让 Hibernate 走 JDBC 批量,与 URL 的 reWriteBatchedInserts 是互补关系(前者攒批、后者重写);IDENTITY 主键策略会禁用批量插入,用 SEQUENCE 配合 allocationSize 才能吃满批量通道。数据校验、Flyway 迁移、@Transactional 的传播语义都是常规 Spring 内容,此处不展开。
实战示例
示例一:标准业务事务模板
public void transfer(Connection conn, long from, long to, BigDecimal amount)
throws SQLException {
boolean oldAuto = conn.getAutoCommit();
conn.setAutoCommit(false);
try {
try (PreparedStatement ps = conn.prepareStatement(
"UPDATE accounts SET balance = balance - ? "
+ "WHERE id = ? AND balance >= ?")) {
ps.setBigDecimal(1, amount);
ps.setLong(2, from);
ps.setBigDecimal(3, amount);
if (ps.executeUpdate() != 1) {
throw new IllegalStateException("insufficient balance");
}
}
try (PreparedStatement ps = conn.prepareStatement(
"UPDATE accounts SET balance = balance + ? WHERE id = ?")) {
ps.setBigDecimal(1, amount);
ps.setLong(2, to);
ps.executeUpdate();
}
conn.commit();
} catch (SQLException | IllegalStateException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(oldAuto);
}
}示例二:批量插入的两级优化
public void bulkInsert(Connection conn, List<Item> items) throws SQLException {
String sql = "INSERT INTO items (id, name, price) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
int i = 0;
for (Item it : items) {
ps.setLong(1, it.id());
ps.setString(2, it.name());
ps.setBigDecimal(3, it.price());
ps.addBatch();
if (++i % 5_000 == 0) {
ps.executeBatch(); // 分批提交,控制单事务体积
}
}
ps.executeBatch();
}
// 前提:URL 含 reWriteBatchedInserts=true
}对照测试(100 万行、同机):不开重写约 60s,开启后约 15s,改用 CopyManager 约 4s——三级通道的量级差距一目了然。
示例三:CopyManager 导入 CSV 并统计
public long importCsv(Connection conn, String table, Path csv) throws Exception {
CopyManager cm = conn.unwrap(PgConnection.class).getCopyAPI();
String sql = "COPY " + table + " FROM STDIN WITH (FORMAT csv, HEADER true)";
try (InputStream in = new BufferedInputStream(Files.newInputStream(csv), 1 << 16)) {
return cm.copyIn(sql, in);
} catch (SQLException e) {
// COPY 失败 → 当前事务已被中止,调用方决定 rollback 与重试
throw e;
}
}示例四:HikariCP 工厂类(非 Spring 环境)
public final class PoolFactory {
public static HikariDataSource build() {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://db1:5432/appdb"
+ "?sslmode=require&connectTimeout=3&socketTimeout=60"
+ "&reWriteBatchedInserts=true&ApplicationName=svc-orders");
cfg.setUsername("app_rw");
cfg.setPassword(System.getenv("DB_PASSWORD"));
cfg.setMaximumPoolSize(20);
cfg.setConnectionTimeout(3_000);
cfg.setMaxLifetime(1_800_000);
cfg.setKeepaliveTime(300_000);
return new HikariDataSource(cfg);
}
}示例五:LISTEN/NOTIFY 的轻量事件消费
public void listenOrders(DataSource ds) {
Thread t = new Thread(() -> {
try (Connection conn = ds.getConnection()) {
PGConnection pg = conn.unwrap(PGConnection.class);
conn.createStatement().execute("LISTEN order_events");
while (!Thread.currentThread().isInterrupted()) {
PGNotification[] ns = pg.getNotifications();
if (ns != null) {
for (PGNotification n : ns) {
System.out.println("notify: " + n.getParameter());
}
}
Thread.sleep(500);
}
} catch (Exception e) {
Thread.currentThread().interrupt();
}
}, "pg-notify-listener");
t.setDaemon(true);
t.start();
}示例六:大结果集的流式读取(setFetchSize)
JDBC 默认把整个结果集拉进内存;设置 fetch size 后驱动按块取回(需要事务内 + 关闭 autoCommit):
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
"SELECT id, payload FROM big_events WHERE created_at >= ?")) {
ps.setTimestamp(1, Timestamp.from(Instant.parse("2026-10-01T00:00:00Z")));
ps.setFetchSize(10_000); // 每 1 万行一批
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
process(rs.getLong(1), rs.getString(2)); // 逐行处理,内存平稳
}
}
} finally {
conn.commit();
conn.setAutoCommit(true);
}要点:autoCommit=false 是前提——自动提交模式下 pgjdbc 无法使用服务端游标(游标必须在事务内),fetch size 会被忽略,行为退化为全量加载。对照《PHP 连接 PostgreSQL》的可滚动游标与《Python 连接 PostgreSQL》的 itersize,这是同一机制在三种语言里的同一要求。
注意事项
executeBatch 默认不省网络往返。 没有 reWriteBatchedInserts=true 的批量只是 API 层攒批,性能与逐条执行接近;这是 Java 批量写入最常见的「以为在优化」误区。
事务内做慢 IO 是池耗尽的头号原因。 @Transactional 方法里调 HTTP/发 MQ,连接占用时间被外部延迟放大,高峰期 connection is not available 大都源于此。
socketTimeout 与 statement_timeout 是两道独立的闸。 客户端 socketTimeout 防线程挂死,服务端 statement_timeout 防语句失控;只配一个都存在绕过路径。
PostgreSQL 连接是进程级资源。 Hikari 池大小 × 应用实例数要对着 max_connections 规划;多实例扩容前先算连接总账,必要时上 PgBouncer。
numeric 金额用 BigDecimal。 double 的精度损失在金额、计量场景必然出事;getBigDecimal/setBigDecimal 是唯一正确通道。
CopyManager 失败会中止事务。 COPY 是单事务原子操作,失败后当前事务必须 rollback;超大导入按行数切片分批,避免「一行坏数据全量重来」。
LISTEN/NOTIFY 不是消息队列。 无持久化、无重放、连接断开即丢(期间通知不缓存);它适合协作信号与缓存失效,业务事件流请用专业中间件。
驱动升级要跟。 42.x 系列的补丁包含安全与稳定性修复;固化旧版本会让已知问题(连接泄露、SSL 握手边界)长期潜伏。
unwrap 是池化连接访问扩展 API 的正道。 对 Hikari 包装的连接直接强转会抛 ClassCastException,conn.unwrap(PgConnection.class) 才是兼容池的实现。
大结果集的 fetch size 需要 autoCommit=false。 自动提交模式下驱动无法开启服务端游标,setFetchSize 被忽略、结果全量加载;「关自动提交 + 设 fetch size + 逐行读」三个动作必须成对出现。
时间类型的时区语义要显式声明。 读 timestamptz 优先用 OffsetDateTime(JDBC 4.2+),遗留代码的 getTimestamp 依赖会话/容器时区——容器时区漂移引发的隐性错位在生产上屡见不鲜。
批量 + RETURNING 的返回语义要在评审里说清。 批量通道返回批级计数,需要行级精确回流时改用「单语句 + RETURNING」的循环;避免测试环境没撞到、生产数据对不上。
最佳实践
- 生产连接一律走池 + DataSource,URL 参数显式声明 sslmode/connectTimeout/socketTimeout/ApplicationName。 四件套分别对应加密、快速失败、挂死防护、连接溯源。
- 批量插入三级递进:addBatch + reWriteBatchedInserts → 分批事务 → CopyManager。 十万行是 COPY 的分水岭,万行以下重写批就够。
- 金额 numeric + BigDecimal,时间 timestamptz + OffsetDateTime。 让类型系统承担精度与时区责任。
- 事务边界放服务层,事务内禁慢 IO。 DAO 只参与事务;
@Transactional的粒度评审纳入 code review 清单。 - JSONB 用 PGobject 或 ?::jsonb,解析留在数据库。 客户端只传字符串/结构体,不做 SQL 拼接。
- 动态标识符(表名/列名/排序)走白名单校验。 PreparedStatement 只保护值,不保护语句结构。
- Hikari 池大小从 CPU 核数 2~4 倍起步,按压测收敛。 并配合 maxLifetime 短于服务端 idle 超时,避免拿到「已被服务器断开」的连接。
- 大导入先摘索引外键,导完重建并 ANALYZE。 与
\copy一页的性能清单同源,maintenance_work_mem调优留给重建阶段。 - 错误处理按 SQLSTATE 分支。
e.getSQLState()的 23505/23503/40001/40P01 分别对应唯一冲突、外键冲突、序列化失败、死锁,重试逻辑只对可重试码生效。 - Spring Boot 批量链路三件套齐备:batch_size + reWriteBatchedInserts + SEQUENCE 主键。 缺一环 Hibernate 批量就退化成逐条。
- 多实例部署先做连接总账再扩容。 应用池 × 实例数 ≤ max_connections 的安全余量,超出部分用 PgBouncer 收敛。
- 把 ApplicationName 规范化到每个服务。
pg_stat_activity里连接可溯源,是排查连接泄漏与慢查询归属的第一线索。
下一节
继续学习:Go 连接 PostgreSQL