前阵子帮一个旧项目做数据层重构,翻代码的时候发现一个有意思的现象:团队里不少同事平时用ORM用得很溜,但一碰到需要手写SQL的场景就犯怵,连最基本的JDBC连接和PreparedStatement都写不利索。正好那阵子新项目把数据库切到了PostgreSQL,我就顺手整理了一份纯JDBC方式操作PostgreSQL的CRUD教程,从建库建表到增删改查一步步走。今天把这套东西完整贴出来,给那些想搞懂底层、或者被ORM“惯坏了”想补一补基本功的读者做个参考。
这篇内容不涉及任何框架封装,核心就是Java自带的JDBC接口直接连PostgreSQL,把增加、查询、修改、删除四类操作从零实现一遍,顺便把我在真实项目中踩过的连接管理、时区转换、批量操作之类的坑也一并讲清楚。适合刚接触PostgreSQL的Java开发,也适合那些能跑通ORM但没仔细想过SQL层细节的同学。
1. 动手前的版本选型与驱动依赖
1.1 JDK、PostgreSQL和驱动版本怎么搭
写CRUD之前,先把环境版本这关过了。版本搭配不当有时候会闹出一些莫名其妙的问题,比如驱动连不上旧版数据库、TLS握手报错之类。
我的建议是:JDK用11以上,PostgreSQL用12以上,JDBC驱动用postgresql-42.x.x系列。这套组合经过大量生产环境验证,兼容性比较稳。如果你还在用JDK 8,那驱动版本尽量选42.2.x的末版,不要直接上最新的42.5+,因为新版驱动有些代码路径已经默认按JDK 11来编译了,强行跑在老JDK上会遇到UnsupportedClassVersionError。
PostgreSQL驱动在Maven中央仓库的坐标是org.postgresql:postgresql,最新版本号和你的数据库版本没有严格的对应关系,一般驱动向后兼容老版本数据库。比如PostgreSQL 14的库,用42.6.0的驱动完全没问题。这点和某些商业数据库的驱动策略不太一样,PostgreSQL的驱动做得比较厚道。
1.2 Maven依赖引入
在pom.xml里加上这段:
<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.3</version> </dependency>不需要引入其他额外的依赖,JDBC接口本身就是JDK标准库的一部分,驱动只是提供实现。
有一点值得注意:如果你用的是Spring Boot,它会在spring-boot-dependencies里锁定一个驱动版本,这时候你自己指定的版本可能被覆盖。建议查一下当前Spring Boot版本对应的驱动版本,别直接复制上面的坐标就完事了。我见过有同事手动改成最新驱动,结果和Spring Boot内置的版本冲突,日志里出现两条驱动的诡异报错。
1.3 驱动类的加载机制
写第一行连接代码之前,先理解一下驱动的加载逻辑。传统的写法是:
Class.forName("org.postgresql.Driver");这句在JDBC 4.0以后其实可以省略了。因为驱动jar包的META-INF/services/java.sql.Driver文件里已经声明了驱动类,DriverManager会自动发现并加载。但老项目里这句话很常见,留着也不算错,只是没必要。
真正会出问题的是:如果你把PostgreSQL驱动和MySQL驱动的jar同时放在classpath里,不写Class.forName也能正常连,因为DriverManager会根据URL的jdbc:postgresql://前缀自动匹配合适的驱动。这一机制在绝大多数场景下都是可靠的。
所以连接代码里我一般不写Class.forName,保持代码干净。动手之前只要确认postgresql.jar在classpath里即可。
2. 建库建表:先给数据准备好存放的框架
2.1 创建数据库和专用账号
连接数据库之前,先把数据库侧准备好。这里我建议不要直接用postgres超级用户跑业务,而是单独建一个业务账号,权限只给到需要用到的库。
用psql执行:
CREATE USER app_user WITH PASSWORD 'your_password'; CREATE DATABASE app_db OWNER app_user;之所以单独建账号,一方面是权限收敛,另一方面是为了后面排查问题方便。如果你把所有东西都跑在超级用户下,万一出现锁表、连接数打满这类问题,日志里全混在一起,根本分不清是哪个业务干的。
2.2 设计一张适合演示CRUD的表
教程要演示增删改查,就得有一张结构稍微丰富一点的表。我建一张用户信息表,字段涵盖主键、普通文本、整数、时间戳几种常用类型:
CREATE TABLE IF NOT EXISTS app_user ( id BIGSERIAL PRIMARY KEY, username VARCHAR(64) NOT NULL, email VARCHAR(128) NOT NULL, age INT, created_at TIMESTAMPTZ DEFAULT now() );这里解释几个关键选择:
BIGSERIAL:自增主键,对应Java里的Long类型。从PostgreSQL 10开始,官方更推荐IDENTITY语法,即id BIGINT GENERATED ALWAYS AS IDENTITY。两者功能类似,但IDENTITY是标准SQL,SERIAL是PostgreSQL的历史写法。从可维护性角度,新项目我是推荐用GENERATED ALWAYS AS IDENTITY,但老项目里大量存量表是SERIAL,所以两种都要认识。TIMESTAMPTZ:带时区的时间戳类型。PostgreSQL内部按UTC存储,输出时按会话时区转换。Java侧对应OffsetDateTime或Instant,这一点后面讲时间类型的时候会展开。age INT:允许为空。这是故意设计的,为了后面演示NULL值在JDBC里怎么处理。
2.3 JDBC URL怎么写
PostgreSQL的JDBC URL格式是:
jdbc:postgresql://host:port/database?参数1=值1&参数2=值2最基础的写法:
jdbc:postgresql://localhost:5432/app_db有几个参数我强烈建议一开始就加上:
currentSchema=public:显式指定schema。如果你的库里有多个schema,不指定的话默认走的是数据库用户同名的schema,找不到表就会报relation does not exist。stringtype=unspecified:这个参数挺有用,它让PostgreSQL在比较时自动推断Java字符串的类型,避免某些场景下text和varchar类型不匹配导致SQL失败。connectTimeout=10:连接超时,默认是无限等待,生产环境必须设一个值。
我实际用的连接串:
jdbc:postgresql://localhost:5432/app_db?currentSchema=public&connectTimeout=10&stringtype=unspecified这些参数细节看起来不起眼,但关键时刻能省很多事。
3. 搞懂Connection:从最原始的连接到连接池
3.1 最朴素的连接写法
用DriverManager获取连接的代码非常直白:
String url = "jdbc:postgresql://localhost:5432/app_db?currentSchema=public&connectTimeout=10"; String user = "app_user"; String password = "your_password"; try (Connection conn = DriverManager.getConnection(url, user, password)) { System.out.println("连接成功,是否只读:" + conn.isReadOnly()); }DriverManager.getConnection每次都会新建一个物理连接。PostgreSQL建立连接的过程涉及TCP握手、认证、参数协商,开销不小。在教程和本地小工具里这么用没关系,一旦放到生产环境的高并发场景里,就必须要引入连接池。
3.2 连接池选型
Java生态里最常用的连接池是HikariCP,Spring Boot 2.x之后默认用的就是它。引入方式:
<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.1.0</version> </dependency>配置核心参数时,我的习惯是这样:
HikariConfig config = new HikariConfig(); config.setJdbcUrl(url); config.setUsername(user); config.setPassword(password); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); HikariDataSource dataSource = new HikariDataSource(config);这里几个参数背后是有讲究的:
maximumPoolSize:连接池最大连接数。经验值是((核心线程数 * 2) + 有效磁盘数),但具体还要看数据库端的max_connections。别把这个值调得太大,PostgreSQL默认上限100,你给连接池配了50,再来两套应用就可能把连接数打满。maxLifetime:连接最大存活时间,官方建议比数据库的wait_timeout短一点。MySQL默认8小时断开空闲连接,PostgreSQL这方面相对宽容,但驱动本身有个socketTimeout的默认行为,保险起见还是设一下。validationTimeout:连接有效性检测超时,默认5秒,一般不用改。
用连接池之后,正常业务代码里还是try (Connection conn = dataSource.getConnection()),只是连接从池里借的,用完归还,而不是真正关闭。这个语义上的区别很关键。
3.3 连接泄漏是最大的隐形杀手
连接池用上以后,随之而来的最大风险就是连接泄漏。什么叫泄漏?就是代码里拿到了Connection,但使用结束后没有归还,池里的连接被一点点耗尽,最终应用彻底无连接可用。
我的经验法则是:凡是手动获取了Connection、Statement、ResultSet的代码,一律用try-with-resources关闭资源。这三者的关闭顺序是反向的:先关ResultSet,再关Statement,最后关Connection。try-with-resources会自动按声明逆序关闭,所以不用操心。
具体到增删改查的代码里,我们统一沿用这个原则,下面每一段代码都这样做。这样才能保证连接池在长期运行下依然是健康的。
4. 增删改查一步步实现
4.1 Create:插入数据并拿到自增主键
插入数据的核心是PreparedStatement,它有两个好处:一是预编译SQL更高效,二是参数化查询天然免疫SQL注入。
public Long insertUser(String username, String email, Integer age) { String sql = "INSERT INTO app_user (username, email, age) VALUES (?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, username); ps.setString(2, email); ps.setObject(3, age); int rows = ps.executeUpdate(); if (rows == 0) { throw new RuntimeException("插入失败,影响行数为0"); } try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { return rs.getLong(1); } throw new RuntimeException("插入成功但未拿到自增主键"); } } catch (SQLException e) { throw new RuntimeException("插入用户失败", e); } }几个关键点拆开讲:
第一,为什么prepareStatement要传RETURN_GENERATED_KEYS?因为我们的主键id是数据库自增的,Java侧一开始不知道新行的主键值。让驱动在executeUpdate之后把生成的主键通过getGeneratedKeys()回传,省去了插入后再查一次的麻烦。
第二,setObject(3, age)和setInt(3, age)的区别。用setObject时,如果age为null,会正确设置成SQL的NULL;而setInt传入null会直接抛NullPointerException。所以对可空字段,我统一用setObject。
第三,关于批量插入。写CRUD教程不能只聊单条插入。批量插入用addBatch配合executeBatch:
public void batchInsertUsers(List<User> users) { String sql = "INSERT INTO app_user (username, email, age) VALUES (?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (User user : users) { ps.setString(1, user.getUsername()); ps.setString(2, user.getEmail()); ps.setObject(3, user.getAge()); ps.addBatch(); } ps.executeBatch(); conn.commit(); } catch (SQLException e) { // 这里需要回滚 throw new RuntimeException("批量插入失败", e); } }批量插入时事务边界尤其重要。注意上面代码里我没有在catch里写conn.rollback(),是因为try-with-resources在异常抛出时会自动把连接关闭或归还给连接池,未提交的事务会被回滚。但这依赖连接池配置,为了代码可读性,生产代码里我还是建议显式rollback,不依赖隐藏行为。
批量插入的性能提升非常明显。我实测过插入1万条数据,单条循环提交耗时约8秒,改成批量提交只需要600毫秒左右。原因在于每次executeUpdate都是一次完整的网络往返,而executeBatch把多条语句合并成一次交互。批量大小一般控制在500到1000条合适,太大会增加内存开销,反而容易出现性能拐点。
4.2 Read:查询与ResultSet的处理
查询是CRUD里最常用也最容易写出问题的一块。先看一个简单的按ID查询:
public User findById(Long id) { String sql = "SELECT id, username, email, age, created_at FROM app_user WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return mapRow(rs); } return null; } } catch (SQLException e) { throw new RuntimeException("查询用户失败", e); } } private User mapRow(ResultSet rs) throws SQLException { User user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setEmail(rs.getString("email")); user.setAge(rs.getObject("age", Integer.class)); user.setCreatedAt(rs.getObject("created_at", OffsetDateTime.class)); return user; }这里有几个细节决定了你是“会写”还是“写得好”:
ResultSet的列名访问方式。我习惯用列名而不是列索引来取值。列索引在没有SELECT *的情况下顺序是固定的,但一旦SQL字段顺序调整,按索引取值的代码就会静默拿错数据。按列名取值虽然有微小的查表开销,但可读性和健壮性高出一大截。
读取age用了rs.getObject("age", Integer.class)。如果直接用rs.getInt("age"),数据库里的NULL会变成Java的0,这不是我们想要的结果。JDBC规范里getInt对NULL就是返回0,这是一个常见但很容易忽略的坑。使用getObject并指定类型,才能拿到真正的null。
时间字段created_at用OffsetDateTime.class取。这是PostgreSQL驱动对TIMESTAMPTZ的标准映射。如果你用LocalDateTime去接,驱动会把数据库时区先转成JVM默认时区再转换,很容易出问题。我的建议是数据库侧一律用TIMESTAMPTZ,Java侧一律用OffsetDateTime或Instant,这样时区问题从源头就消失。
列表查询的代码和单条查询几乎一样,区别在于用一个while循环遍历结果集:
public List<User> searchByUsername(String keyword) { String sql = "SELECT id, username, email, age, created_at FROM app_user WHERE username LIKE ? ORDER BY id DESC LIMIT 100"; List<User> result = new ArrayList<>(); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { result.add(mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException("搜索用户失败", e); } return result; }列表查询的注意点是避免一次性拉取过多数据到内存。LIMIT 100只是最粗浅的兜底,真实场景下应该配合分页参数。但作为CRUD的基础实现,先把结果集的遍历方式讲清楚就够了。
4.3 Update:更新并校验影响行数
更新操作的SQL并不复杂,容易踩坑的是怎么处理“更新了0行”这个语义。
public boolean updateUserEmail(Long id, String newEmail) { String sql = "UPDATE app_user SET email = ? WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, newEmail); ps.setLong(2, id); int rows = ps.executeUpdate(); return rows > 0; } catch (SQLException e) { throw new RuntimeException("更新用户邮箱失败", e); } }executeUpdate返回的是受影响的行数。这里要区分两种语义:
- 返回
0表示id对应的记录不存在,或者email本来就是这个值。 - 返回
1表示更新成功。
如果你需要把“记录不存在”和“值未变化”区分开,就得在更新前先做一次查询,或者利用PostgreSQL的RETURNING子句拿到更新后的数据:
UPDATE app_user SET email = ? WHERE id = ? RETURNING id, email用RETURNING时有几点要注意:如果更新语句只更新了记录的某些字段,UPDATE语句只允许更新一张表,不能涉及多表连接。PostgreSQL在这方面比MySQL严格,很多MySQL习惯直接迁移到PostgreSQL的人会在这里卡一下。
动态更新字段的场景(比如前端只传了部分字段),拼接SQL时一定要用StringBuilder,并且只往参数列表里加实际传入的值。这里最容易犯的错误是把没传的字段用null覆盖掉,导致数据被意外清空。安全写法是先判断字段非空再拼进SQL,参数个数跟着SQL动态变化。
4.4 Delete:单条删除与批量删除
删除操作逻辑最直接,但责任最重。再看单条删除:
public boolean deleteById(Long id) { String sql = "DELETE FROM app_user WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); return ps.executeUpdate() > 0; } catch (SQLException e) { throw new RuntimeException("删除用户失败", e); } }批量删除时,很多人会用循环调deleteById,接口简单但性能很差,而且每条删除单独提交,中间失败一半成功一半,事务边界完全失控。
批量删除的推荐写法:
public void batchDeleteByIds(List<Long> ids) { if (ids == null || ids.isEmpty()) { return; } String sql = "DELETE FROM app_user WHERE id = ANY (?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { // 使用数组转成PostgreSQL的bigint数组 Long[] idArray = ids.toArray(new Long[0]); ps.setArray(1, conn.createArrayOf("bigint", idArray)); int rows = ps.executeUpdate(); System.out.println("本次删除了 " + rows + " 条记录"); } catch (SQLException e) { throw new RuntimeException("批量删除失败", e); } }这里用了PostgreSQL特有的ANY(?)语法配合数组参数。一次SQL调用删除所有目标记录,性能远高于循环调用。但要注意conn.createArrayOf("bigint", idArray)里的类型名称必须和数据库字段类型一致,字符串大小写不敏感,但类型名称错了会直接报错。
批量删除一定要放在事务里执行。要么全删成功,要么全不删,不能出现删一半的情况。用连接池获取连接之后,先setAutoCommit(false),执行完以后commit(),异常时rollback()。这一点看起来简单,但在真实项目里我看到太多删除操作不包事务的代码了,实际造成的线上故障不少。
4.5 完整示例:把CRUD串成一个小类
上面每个操作都拆开讲了,最后把它们组合成一个标准的DAO类。这个类的骨架可以作为你以后写PostgreSQL数据访问层的模板:
public class UserDao { private final DataSource dataSource; public UserDao(DataSource dataSource) { this.dataSource = dataSource; } // 插入 public Long insert(User user) { ... } // 批量插入 public void batchInsert(List<User> users) { ... } // 按ID查询 public User findById(Long id) { ... } // 按关键词查询 public List<User> searchByUsername(String keyword) { ... } // 更新 public boolean updateEmail(Long id, String email) { ... } // 删除 public boolean deleteById(Long id) { ... } // 批量删除 public void batchDelete(List<Long> ids) { ... } }把DataSource通过构造器注入而不是在DAO内部new一个连接,这一点很关键。这样DAO层不关心连接来自哪里——是DriverManager直连还是连接池,对DAO来说都是同一个DataSource接口。这也方便单元测试时用测试库的DataSource替换生产配置。
5. 实测中常见的坑与排查链路
5.1 “relation does not exist”的排查思路
这是新人在PostgreSQL上最常遇到的报错之一,我第一次也被它折腾了半天。
先描述现象:代码里明明建了表app_user,执行SQL却报relation "app_user" does not exist。表都建了怎么会不存在?
排查链路是这样的:
- 第一反应是查表是否真的建成功了,用
\dt在psql里看,表存在。 - 然后怀疑是权限问题,检查用户权限,有
ALL PRIVILEGES,排除。 - 后来发现问题是schema。PostgreSQL有
public和pg_catalog等多个schema,连接用户是app_user,默认的search_path是"$user", public。按说应该能找到public.app_user。 - 再往深挖,发现建表时用的连接串里加了
currentSchema=public,但JDBC URL中该参数名拼错了,导致会话的search_path根本没生效,变成了空路径。
解决方法是确认JDBC URL里参数名准确,并且尽量在SQL里显式指定schema前缀,比如:
SELECT * FROM public.app_user或者连接串里加options=-csearch_path=public。用JDBC的话,更推荐在URL里写currentSchema=public,这个参数是PostgreSQL驱动专门支持的。
5.2 SQL注入与LIKE查询的特殊字符
PreparedStatement的参数化查询已经挡住了99%的SQL注入,但有一个边角容易被忽略:LIKE查询里的通配符。
如果我传的关键词是%或_,SQL语义就变了。比如搜索"100%",期望是匹配字符串包含“100%”的用户,结果却把“1001”“1002”都查了出来。
处理方案是手动转义:
String escaped = keyword .replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_"); String sql = "SELECT * FROM app_user WHERE username LIKE ? ESCAPE '\\'"; ps.setString(1, "%" + escaped + "%");这个坑在真实项目里非常隐蔽,往往线上搜索功能用了很久之后才有人发现结果不对。属于那种“不爆大故障但很磨人”的问题。
5.3 时间类型转换的坑
TIMESTAMPTZ是我在项目里推进过的一项统一规范。在PostgreSQL里,timestamp without time zone存的是“墙上时钟时间”,不带时区信息;而timestamp with time zone存的是瞬时时间,内部按UTC存储。
Java侧如果用了LocalDateTime去接TIMESTAMPTZ,驱动会做一次隐式转换,按JVM默认时区把UTC时间转成本地时间。这样代码在不同时区的服务器上运行,读出来的时间可能不一样。
我的方案很固定:
- 数据库字段:
TIMESTAMPTZ - Java实体字段:
OffsetDateTime - 写入时:
ps.setObject(offsetDateTime) - 读取时:
rs.getObject("created_at", OffsetDateTime.class)
这套组合能保证无论应用部署在哪个时区,时间语义都不失真。
5.4 字符串类型与setString的隐式转换
PostgreSQL在JDBC下有个著名的行为差异:用setString往integer列写数据,有些数据库会直接报错,但PostgreSQL在某些配置下会尝试隐式转换。比如:
ps.setString(1, "123");如果目标列是bigint,PostgreSQL在stringtype=unspecified设置下可以自动转换,不设置则可能报column "id" is of type bigint but expression is of type character varying。
这个问题的背后是PostgreSQL的“严格类型检查”设计。相比MySQL的宽松,PostgreSQL在类型上更像一个严谨的守门员,好处是不会出现“数据悄悄被截断”这种脏数据问题,坏处是刚开始用的人容易碰壁。
我的建议是:不要依赖隐式转换,Java侧什么类型就对应什么方法,int用setInt或setObject,String用setString,Long用setLong。类型写对了,跨数据库移植时也更安全。
5.5 连接池参数配错导致的问题
连接池是生产环境必备,但配置错了也会带来麻烦。我碰过的一个真实案例是:连接池的maximumPoolSize设成了50,但PostgreSQL库的max_connections只有100,同一个库还挂着另一个应用,两边加起来把连接数打满,新连接排队,应用大面积超时。
排查链路是这样的:
- 应用日志大量出现
FATAL: sorry, too many clients already。 - 查PostgreSQL的
pg_stat_activity,看到大量idle in transaction状态的连接。 - 发现代码里有一个事务处理分支忘了
commit或rollback,连接被事务“粘住”不放。 - 修复代码后,把连接池的
maximumPoolSize从50调低到20,同时在数据库侧用ALTER SYSTEM SET max_connections = 200扩容,问题彻底解决。
这类问题排查起来其实不难,关键是别把锅都甩给数据库。大部分“连接数打满”的故障,根源都在应用层没有正确管理连接的生命周期。
6. 从教程到真实项目的工程化建议
6.1 分层结构别全堆在一个类里
教程里我把所有代码塞在一个UserDao里,是为了方便讲清楚每一步。真实项目里这么干就会痛不欲生——一个DAO类几百行,只为一张表,维护起来极其痛苦。
我的分层习惯是这样的:
entity包:和表结构一一对应的实体类。dao包:每个实体一个DAO接口加实现类。util包:放DataSource工厂、SQL工具类。service包:业务逻辑层,事务边界在这里控制。
对于简单的项目,做到这一步就够了。别一上来就上各种重型框架,JDBC配合手写DAO可以撑住大多数中小型项目。
6.2 事务边界应该放在服务层
CRUD的每个操作本身可以自带简单事务,但真正的业务往往涉及多个步骤:比如下单要扣库存、写订单、记流水,三步必须在一个事务里。
正确做法是:DAO层不控制事务,连接从服务层传入。常见写法:
public void createOrder(Order order, List<OrderItem> items) { try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); orderDao.insert(conn, order); for (OrderItem item : items) { orderItemDao.insert(conn, item); } conn.commit(); } catch (SQLException e) { throw new RuntimeException("创建订单失败", e); } }DAO方法签名因此要增加一个Connection参数,把连接从Service层传下去。这样整个事务只用一个连接,中间任何一个环节失败,整个业务逻辑回滚。
6.3 数据库迁移脚本管理
教程里用CREATE TABLE IF NOT EXISTS建表,但真实项目的表结构一定会变化——加字段、加索引、改类型。这时候就需要一套数据库迁移机制。
我常用的方案是Flyway。它用版本号管理SQL脚本,每次启动时检查当前数据库版本并执行未应用的脚本。接入很简单:
<dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-core</artifactId> <version>9.22.3</version> </dependency>脚本放在src/main/resources/db/migration/下,命名规则是V1__create_user_table.sql、V2__add_user_age.sql这样。每次修改表结构就新建一个版本号更大的脚本文件,不去改动已经执行过的历史文件。
这里分享一个经验:写CRUD代码前先把表结构和迁移脚本定下来,比代码先行要靠谱得多。因为表结构一旦在多个环境都应用过,再改就要写额外的迁移脚本,麻烦指数直线上升。
6.4 最后再分享两个小技巧
第一个:PostgreSQL JDBC驱动自带一个很实用的debug日志开关。连接串加上loggerLevel=DEBUG&loggerFile=/tmp/pg-jdbc.log就能看到驱动层的SQL和参数明细。排查诡异问题时,这比在业务代码加日志快得多。不过生产环境要慎用,日志量大。
第二个:如果只是本地快速验证SQL效果,可以用PostgreSQL自带的EXPLAIN ANALYZE命令看执行计划。很多CRUD慢的问题,90%出在缺索引。比如WHERE username = ?这种查询,给username建了索引之后性能差距可能是几百倍。
CREATE INDEX idx_app_user_username ON app_user (username);不要一上来就优化代码,先看执行计划,往往结果会让你意外——问题根本不在代码,而在索引缺失。
我做这个教程其实还有一个私心:很多同事习惯了ORM之后,写代码就是“定义一个方法,调用一个API”,底层SQL被完全屏蔽了。可一旦遇到批量操作性能问题、事务边界控制、复杂查询调优,这些屏蔽的东西全都会变成瓶颈。把JDBC这一层吃透,再回头看ORM,你会更清楚它替你做了什么,也会更明白什么时候该绕过它直接手写JDBC。