搞Java的对PostgreSQL应该都不陌生。这些年不少团队从MySQL迁到PG,原因无非是复杂查询、JSON字段、全文检索这些场景,PG确实比MySQL省心不少。但话说回来,网上聊PG架构、聊优化的一大堆,真正把“用Java对PostgreSQL做CRUD”这件事从头到尾讲明白、讲透的教程反而不多。很多新手上来就卡在版本选择、驱动配置、连接串参数这些地方,还没碰到CRUD就劝退了。
这篇教程就是来填这个坑的。我会按实际开发顺序,从PostgreSQL版本选型、Java工程初始化讲起,再到建库建表、JDBC连接配置,最后用原生JDBC手写一套完整的增删改查,再顺带讲讲MyBatis-Plus这类通用CRUD方案怎么省事。适合刚入门Java后端、准备用PostgreSQL做项目的人,也适合已经会CRUD但想搞清楚底层连接和SQL细节的兄弟。看完你不仅知道每一步怎么做,还知道为什么要这么做——后者的价值往往更大。
1. 技术栈选型与开发环境搭建
1.1 PostgreSQL版本选择:别一上来就装最新版
先解决第一个问题:用哪个版本?搜索热词里“postgresql下载哪个版本”“postgresql 16便携版”出现频率很高,说明大家都在这上面纠结过。我的建议很简单:本地学习和个人项目,直接用PostgreSQL 16或17;公司生产环境,跟着团队已有的大版本走,或者选16——16已经发布两三年,生态适配和稳定性都经过了验证,是当前阶段最稳妥的选择。
为什么非要强调版本?因为PG的大版本升级不是无缝的,跨大版本(比如14升到16)需要用pg_dump导出再导入,或者pg_upgrade工具,整个过程有一定成本。如果项目已经跑在14上,你非要引入16的新特性,那意味着团队要专门排期做升级。所以生产环境的原则是:够用就好,不追新。
再说安装方式。Windows用户直接去官网下载安装包,一路Next,记住设置postgres超级用户密码就行。macOS可以brew install postgresql@16。Linux用户分两种:有外网环境的用apt或yum装;内网离线环境,搜索热词里有人提到“linux离线安装postgresql”,这个场景通常是下载rpm包或源码编译。源码编译我多说一句:PG对编译依赖有要求,需要readline、zlib这些库,缺了会在configure阶段报错,提前yum install -y readline-devel zlib-devel能省掉很多麻烦。
还有“docker安装postgresql”这条,我自己本地开发现在基本都用Docker,一条命令起一个实例,用完就删,干净利落。命令很简单:
docker run -d \ --name pg16 \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=123456 \ -e POSTGRES_DB=testdb \ -p 5432:5432 \ postgres:16注意容器里的数据是写在容器层的,删容器就丢数据,所以要把数据目录挂载出来,加一个-v pgdata:/var/lib/postgresql/data参数。Docker适合开发和学习,但正式环境还是建议用物理机或云主机部署,运维和备份策略更可控。
1.2 Java访问数据库的三种主流姿势
环境搞定之后要选Java侧的访问方案。现在主流有三种:原生JDBC、Spring JDBC Template、MyBatis。还有一个JPA/Hibernate这里先不提,它适合以领域建模为主的业务,但CRUD的控制力不如前几个直观。
我直接给结论:新手阶段不要绕过JDBC直接上MyBatis。JDBC是Java访问关系型数据库的底层规范,MyBatis、Hibernate这些框架底层都是基于JDBC封装的。你用JDBC亲手写过一次CRUD,再回头看MyBatis的Mapper,就会发现那些SQL、参数映射、结果映射全都看得懂了。反过来,如果一上来就用框架,遇到奇怪的报错会很懵,因为不知道框架帮你做了什么。
Spring JDBC Template是介于JDBC和MyBatis之间的选择,JDBC的繁琐体现在连接管理、结果集封装上,Spring JDBC Template把这两块简化掉,自己只需要写SQL。MyBatis则更进一步,连结果映射都可以半自动完成,还能把SQL写在XML里独立管理。如果你跟我一样,项目里用了MyBatis-Plus,那就更省了,单表增删改查连SQL都不用写。
这篇教程的主线用原生JDBC,因为它的每一步都清晰可见,你看到的错误就是底层真实发生的错误。学完JDBC,我会在第3节末尾补一段MyBatis-Plus的通用CRUD写法,两条路都走一遍,体验更完整。
1.3 工程初始化与驱动引入
工程初始化最省事的方式是用Spring Initializr生成一个Maven项目,JDK用11或17都行,考虑到国内很多公司还在8,但PG驱动本身对JDK版本很宽容,8也能跑。搜索热词里有一堆“java环境变量配置详细教程”“win11系统java环境配置”,说明不少人卡在环境上。这里不展开,只说关键:JAVA_HOME要配置到JDK安装根目录,而不是bin目录,PATH里加%JAVA_HOME%\bin,命令行敲java -version能输出版本号就对了。
下一步是引入PostgreSQL的JDBC驱动。Maven坐标:
<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.4</version> </dependency>驱动版本要和PG服务器版本匹配吗?不需要严格匹配,JDBC驱动是向后兼容的,42.7.x能连PG 12到17都能正常用。但如果PG服务器有大的行为变更,驱动也要相应升级,所以保持驱动是较新的稳定版就好。
如果你是纯手写JDBC不用Maven,就手动下载postgresql-42.7.4.jar,放在项目的lib目录,IDE里把它加入ClassPath。传统的Class.forName("org.postgresql.Driver")在JDBC 4.0之后其实可以省略了,驱动JAR包里通过SPI机制会自动注册,但写上也没错,很多老项目都有这行,我习惯保留它,在面试场景里聊到驱动加载也更完整。
2. 数据库设计与连接配置要点
2.1 建库建表与类型映射
进入实操。先建一个用户表,字段尽量覆盖日常CRUD会遇到的类型:主键、字符串、整数、时间、布尔类型。我直接用SQL写清楚:
CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL, age INT, active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() );这里用BIGSERIAL做主键自增,PG 10以后推荐用GENERATED BY DEFAULT AS IDENTITY,更贴近SQL标准,迁移到其他数据库更容易。BIGSERIAL本质是创建一个序列,再给字段加默认值nextval(),两种写法最终效果类似,但IDENTITY语义更清晰。字段类型上,TIMESTAMP WITH TIME ZONE对应Java的OffsetDateTime,如果用了LocalDateTime,读写时要注意时区偏差。
PostgreSQL里布尔类型直接用boolean,Java侧用boolean或Boolean映射;integer对应int/Integer;varchar对应String;numeric对应BigDecimal。类型映射基本是直译的,比MySQL那边char/varchar、datetime/timestamp的纠结要少得多。
2.2 JDBC连接串里的坑与参数含义
连接串是很多人第一个踩坑的地方。PostgreSQL的JDBC URL格式:
jdbc:postgresql://localhost:5432/testdb?currentSchema=public&connectTimeout=10&socketTimeout=30&stringtype=unspecified几个参数逐个说。host和port不用说。database是库名。currentSchema指定schema,PG里一个库下可以有多个schema,默认是public,如果你建了业务schema,这里必须指定,否则后面SQL里全得写schema名。connectTimeout是建立TCP连接的超时秒数,socketTimeout是执行SQL时等待返回的超时秒数,不设置的话可能遇到网络抖动时一直挂起,连接池线程被占满,运维排查起来很被动。
还有一个容易被忽略的:PG的JDBC驱动不需要serverTimezone参数,这和MySQL的驱动不一样。MySQL要指定serverTimezone=Asia/Shanghai,否则日期会乱。PG驱动会自己处理session时区,默认读取数据库配置,只要你数据库时区没问题,Java这边用OffsetDateTime就不会出错。这一点很多从MySQL转过来的兄弟不知道,拿着MySQL的URL模板改个前缀就用,结果一看驱动源码才发现PG根本没有这个参数。
2.3 连接池:正式项目必备的配置方案
JDBC原生操作里,获取连接的经典写法是DriverManager.getConnection(url, user, password)。这个写法在每次请求都创建一个物理连接,用完又释放。PostgreSQL建立一次连接要经历TCP握手、认证、参数协商,动辄几十毫秒,在高并发下连接反复创建销毁,数据库会被拖垮。生产环境一定要用连接池,我推荐HikariCP,Spring Boot 2.x以后的内置连接池就是它,性能和稳定性都经过了大规模验证。
HikariCP的核心配置:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000最大连接数不是越多越好。PG每个连接都是独立进程,连接数过高反而内存暴涨,还容易触发最大连接数限制。一个常规Spring Boot应用,最大连接池10到20足够支撑几百的QPS,除非有长时间运行的慢SQL,才需要往上调。连接池的等待超时时间要特别关注,如果连接池被打满,新请求会进入等待队列,超过connection-timeout就抛异常,这个异常出现在日志里,说明SQL执行太慢或者连接泄漏了,要去查慢SQL和游标关闭的情况,而不是盲目调大连接数。
3. CRUD实操:从JDBC到通用服务
3.1 新增:PreparedStatement的正确打开方式
写代码之前明确一个原则:所有SQL参数必须用PreparedStatement占位符,严禁字符串拼接SQL。这条没得商量,字符串拼接SQL等于把数据库裸奔给注入攻击。前后端项目再怎么加固,这一处漏了就是致命的。PostgreSQL的PreparedStatement写法:
String sql = "INSERT INTO users (username, email, age, active) VALUES (?, ?, ?, ?)"; try (PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, "zhangsan"); ps.setString(2, "zhangsan@mail.com"); ps.setInt(3, 28); ps.setBoolean(4, true); int rows = ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { long id = rs.getLong(1); System.out.println("生成的主键: " + id); } } }注意两点。第一,prepareStatement第二个参数Statement.RETURN_GENERATED_KEYS用于取回自增主键,不传这个参数,执行后getGeneratedKeys可能什么都拿不到。第二,PostgreSQL返回主键需要走一个额外的数据库往返,如果你的业务不关心主键是什么,可以不传这个参数,性能上每一条能省一次交互。批量插入时,这条差异会被放大。
批量新增是很多文章没细讲的点。比如一次性导入一万条用户数据,循环单条insert效率极低。正确做法是用addBatch:
String sql = "INSERT INTO users (username, email, age, active) VALUES (?, ?, ?, ?)"; try (PreparedStatement ps = connection.prepareStatement(sql)) { for (User user : userList) { ps.setString(1, user.getUsername()); ps.setString(2, user.getEmail()); ps.setInt(3, user.getAge()); ps.setBoolean(4, true); ps.addBatch(); if (batchSize >= 1000) { // 每1000条提交一批 ps.executeBatch(); batchSize = 0; } } ps.executeBatch(); }批量提交的核心是控制单批大小,1000条左右是一个比较稳的经验值。每条SQL都要做prepare、参数绑定、网络传输,分批提交既节省了单条提交的往返开销,又避免一个大批次占用的内存过大。实测PG的批量插入比单条循环快10倍以上,数据处理场景这个优化必须做。
3.2 查询:结果集映射与条件拼接
查询是CRUD里最常写的,JDBC的套路固定:执行查询拿到ResultSet,然后遍历结果集,把每行字段映射成Java对象。新手容易忽略的是ResultSet、Statement、Connection这三层资源释放的顺序——后创建的先关闭。用try-with-resources可以自动释放,但要注意嵌套顺序,最外层连接、中间statement、内层resultSet,顺序反了会先关连接再关结果集,虽然多数驱动不报错,但属于不规范操作。
几个PostgreSQL查询特有的点说一下。
带条件拼装时,LIKE查询不能直接用?传参拼%,要把占位符和拼接符分开处理:
String sql = "SELECT * FROM users WHERE username LIKE ?"; ps.setString(1, "zhang%");另外PG的字符串比较受collation影响,大小写不敏感搜索可以用ILIKE,这个是PG的独有特征,MySQL那边让人头疼的utf8mb4_bin和utf8mb4_unicode_ci的区别,在PG里通过collation来配置,默认情况下普通LIKE是大小写敏感的。
分页查询是另一个高频操作。PG的语法是LIMIT ? OFFSET ?:
String sql = "SELECT * FROM users ORDER BY id DESC LIMIT ? OFFSET ?"; ps.setInt(1, pageSize); ps.setInt(2, (page - 1) * pageSize);LIMIT后面不能用参数代替?其实PG的JDBC驱动支持LIMIT ?这种写法,参数会走预编译。但有个坑:老版本驱动或者连接参数不对时,LIMIT ?会被当成普通占位符处理而报语法错误。建议在预编译SQL里先把LIMIT的值作为参数传入,OFFSET计算放在Java侧用int乘出来,别在SQL里做pageSize * (page - 1)这种运算,让数据库去处理无意义的整数乘法没必要。
3.3 更新与删除:影响行数与事务边界
UPDATE和DELETE的JDBC代码骨架和INSERT高度一致,核心在于判断影响行数。executeUpdate返回的是受影响的行数,也就是UPDATE匹配并修改了多少行。这里的细节是:如果SET的值和原值一样,PostgreSQL默认仍然认为行被更新,返回的行数不会减少。但有的时候协同逻辑期望的是只统计真正被改变的记录,这就要靠过滤条件来控制了,其实底层行为已经写在PG文档里,是驱动透传的真实结果。
删除操作要考虑的不只是DELETE怎么写,而是要不要物理删除。业务角度,用户数据直接物理删掉,关联外键、审计日志全都断掉了,所以很多系统用软删除:表里加一个deleted_at字段,删除操作变成UPDATE deleted_at = NOW() WHERE id = ?。PostgreSQL还有个特性是返回删除的数据,DELETE ... RETURNING id, username,这个在某些场景超级好用,比如删除后需要记录日志。MySQL没有RETURNING子句,这也算PG的一个亮点,面试时能提出来会很加分。
事务边界是个大问题,尤其是多表操作。JDBC默认autocommit是true,每一条SQL执行完自动提交。如果你在“更新用户信息同时写入操作日志表”这种场景下还开着autocommit,就会出现用户信息更新成功、日志写入失败、最终两端数据不一致的经典事故。正确做法:
connection.setAutoCommit(false); try { updateUser(connection, user); insertLog(connection, log); connection.commit(); } catch (Exception e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(true); }事务有几个注意点。第一,不要在finally里无条件commit,只有try成功才提交,异常必须rollback。第二,事务内的操作要尽量快,长事务会锁住大量行,PG的MVCC机制下,事务ID膨胀到一定程度还会触发vacuum压力,影响整库性能。第三,PostgreSQL的事务隔离级别默认是READ COMMITTED,如果你要保证一个事务里两次查询看到同样的快照,需要手动设置REPEATABLE READ。
3.4 通用CRUD的进阶:MyBatis-Plus免写SQL
原生JDBC适合理解原理,但日常业务开发里大家更爱用MyBatis-Plus。它的价值在于:单表增删改查的方法已经预置在BaseMapper里,不需要写任何SQL。
public interface UserMapper extends BaseMapper<User> { } // 使用示例 UserMapper userMapper = ...; User user = userMapper.selectById(1L); // 查询 userMapper.insert(user); // 新增 user.setAge(29); userMapper.updateById(user); // 更新 userMapper.deleteById(1L); // 删除搜索热词里有条“通用CRUD服务:基于mybatis-plus工具类实现无状态增删改查”,说的就是这种玩法。MyBatis-Plus还提供了LambdaQueryWrapper,条件构造很灵活:
List<User> list = userMapper.selectList( new LambdaQueryWrapper<User>() .eq(User::getActive, true) .likeRight(User::getUsername, "zhang") .orderByDesc(User::getCreatedAt) .last("LIMIT 10") );注意.getActive的boolean值,MyBatis-Plus会把它原样传到SQL里,默认生成active = true的片段,在PG里会被正确识别成布尔常量。还有.likeRight的方法名对应SQL的LIKE 'zhang%',如果你用like,它生成的是LIKE '%zhang%'。
使用MyBatis-Plus的前提是实体类字段和表字段的映射关系正确,默认注解是@TableName指定表名,主键用@TableId。字段映射上,PG的camel_case和Java的驼峰命名的转换MyBatis-Plus是自动处理的,只要表字段用下划线风格命名。
框架虽好,但别滥用。单表CRUD用MyBatis-Plus效率最高,但到了多表关联、复杂聚合查询的场景,我还是宁愿写原生SQL或者XML里的自定义SQL,让SQL可读、可控。通用CRUD服务适合做一个基础模板,具体业务里的查询千变万化,必须保留手写SQL的出口。
4. 常见问题排查与避坑实录
4.1 连接失败:认证错误优先查这里
连接类的报错是所有人最先遇到的。最常见的是这个:
org.postgresql.util.PSQLException: FATAL: password authentication failed for user "postgres"原因无非这几类:密码输错、pg_hba.conf配置了scram-sha-256但密码策略不匹配、用户名不对。排查顺序:先用命令行psql -U postgres -h localhost试一遍,命令行能连上说明数据库本身没问题,问题在Java侧。命令行都连不上,就去看pg_hba.conf。
pg_hba.conf是PG的客户端认证配置文件,在数据目录下。里面每一行定义了允许谁能连、用什么方式认证。本机连接通常是trust或scram-sha-256,trust是无密码信任——如果你发现任何密码都能连上,就是被设成了trust,开发时无所谓,生产环境必须改成scram-sha-256。
另一个高发问题:地址连接被拒。连接串里写localhost,实际PG监听在127.0.0.1没问题,但如果是Docker容器里起的PG,端口没映射或者映射到别的宿主端口,就会报Connection refused。Docker场景要检查docker ps看端口映射。Linux远程连接还要确认防火墙有没有放行5432,很多云服务器默认安全组不放行这个端口。
4.2 日期时间类型与时区问题
PG的日期类型比MySQL复杂一些。timestamptz和timestamp的区别需要搞懂:timestamptz存的是UTC时间,显示时按当前会话时区转换;timestamp不带时区,存的就是你写入的字面值。
Java侧对应关系:timestamptz建议用OffsetDateTime,timestamp用LocalDateTime。如果你用LocalDateTime去读写timestamptz字段,JDBC驱动会按JVM默认时区做转换,两台服务器时区不一致,数据就会偏差好几个小时。这个在生产环境踩过的人不少。
解决办法就一句话:数据库统一用timestamptz,Java侧统一用Instant或OffsetDateTime,连接串里不要乱指定时区,让应用服务器和数据库都设为UTC跑。展示层再把UTC转成用户本地时区。
要注意PostgreSQL JDBC有一个参数:一个reWriteBatchedInserts=true,默认false。批量插入时,驱动会把多条INSERT重写成单条多VALUES语句,减少网络往返,性能提升非常明显。开启后批量插入一万条记录,耗时能从十几秒降到两三秒,这是JDBC配置里性价比最高的一个参数。
4.3 性能问题:慢SQL和连接泄漏
CRUD写起来容易,但一旦数据量大了,一些小问题会被放大。典型的是SELECT *,全字段查询会把所有列都查回来,表加了个字段,查询结果集也跟着变大,Java对象里多了不需要的属性,白白浪费内存和网络。我习惯显式写出需要的字段,尤其在前端只需要两三个字段时。
索引问题要特别关注WHERE条件。users表如果经常按email查,没有索引时PG会走全表扫描。数据量几千条感觉不出来,十万条以上就会明显变慢。解决办法:
CREATE INDEX idx_users_email ON users (email);另外PG的查询计划器很聪明,但是地统计信息可能过时,数据频繁增删后,用ANALYZE更新统计信息能让规划器做出更好的执行计划。VACUUM的问题也提一下,PG的MVCC机制导致删除和更新会产生死元组,如果没有autovacuum及时清理,表会膨胀,查询性能下降。这块是PG运维的重点,很多人的PG用久了变慢,一查发现没开autovacuum。
连接泄漏是最隐蔽的问题。很多人在JDBC代码里try-with-resources写得不严谨,异常时连接没归还连接池,连接池默认空闲超时又不回收,慢慢地连接被耗尽,系统开始假死。排查方法:HikariCP有leak-detection-threshold参数,设置成60000(毫秒),连接借出超过60秒未归还就打印告警日志,报错信息里能看到是哪个线程借的连接。
4.4 问题排查速查表
整理一份我平时排查问题常用速查表,覆盖上面提到的所有问题,尤其是搜索热词里反复出现的“postgresql使用教程”“postgresql数据库操作”相关痛点。
| 症状 | 常见原因 | 排查方向 |
|---|---|---|
| password authentication failed | 密码错或认证方式不符 | 命令行psql试连、检查pg_hba.conf认证方式 |
| Connection refused | 端口未监听、防火墙未放行、Docker端口未映射 | 检查listen_addresses、ss -lntp看5432、检查云安全组 |
| Relation does not exist | schema错误或库连错 | 检查currentSchema参数、确认库名 |
| PSQLException: 无quote的列名 | 驼峰字段没加映射 | 实体类@TableField或SQL加双引号列名 |
| 日期少了8小时 | 时区参数理解错误 | 统一timestamptz+OffsetDateTime |
| executeBatch特别慢 | 没开reWriteBatchedInserts | 连接串加&reWriteBatchedInserts=true |
| 连接池耗尽异常 | 连接泄漏或慢SQL占满连接 | 开leak-detection-threshold、查慢SQL日志 |
| 表越大查询越慢 | 缺索引或表膨胀 | EXPLAIN ANALYZE、建索引、VACUUM |
| 中文乱码 | 客户端编码与服务端编码不一致 | 数据库初始化用UTF8、连接串加characterEncoding=UTF8 |
这张表我一直在更新,每次遇到线上故障都会往里补一行。最想强调的其实还是运维基本功:不管CRUD写得多顺手,数据库层面的监控和排查知识才是后端工程师拉开差距的地方。你可以用pg_stat_statements扩展查最耗时的SQL,在pg_stat_activity里看当前正在跑的长事务,这些工具的优先级远高于写花哨代码。
最后分享一下我自己的实操习惯。每次新建一个Java操作PostgreSQL的项目,我会先把连接池的配置单独抽到一个配置类里,连接串里的每个参数都写注释,防止团队成员看不懂什么意思,也防止自己三个月后回来忘掉。然后一定会先写一个最简单的SELECT 1验证连接,再写正式的CRUD,这个习惯让我避开了百分之八十的连库阶段折腾。
还有个小技巧:本地开发时,我会把PG的日志级别调成log_statement = 'all',这样每条SQL都会打到日志文件。开发环境写CRUD时可以直观看到框架帮我生成的SQL是什么样,排查问题时也省去了猜的环节。上线前再改回默认级别,避免日志量过大。
PostgreSQL CRUD这件事,代码层面的复杂度确实只有那么多,但真正考验人的是连接管理、事务边界、类型映射、查询性能这些看起来不起眼的细节。把这套基础打扎实,后面接触ORM框架、读写分离、分库分表都会轻松很多。你可以在本地把PG和JDBC装好,照着上面的代码敲一遍,再切换到MyBatis-Plus体验一下“免写SQL”的爽快。实践出来的感受,比看十篇文章都直观。