简介:Java连接PostgreSQL是后端开发中常见需求,这份配套PDF面向需要快速掌握JDBC连接方式的Java开发者。内容围绕官方驱动下载与导入、连接URL配置、程序实现与运行结果展开,系统拆解Connection、Statement、ResultSet三个核心对象的使用流程,并附带数据库查询示例的具体代码。资源为单个PDF文件,压缩包大小仅98KB,轻量便携,可直接在电脑或手机上阅读。目前已有2132人学习下载。文档从驱动获取讲到代码运行,包含完整可参考的HelloWorld示例,同时总结了JDBC、PostgreSQL、JDBC驱动程序等基础概念,以及连接字符串、用户名密码设置、SQL语句执行和结果集遍历等关键步骤,既适合初学者按步骤复现,也可作为日常开发中连接PostgreSQL时的速查笔记。
1. Java连PostgreSQL:JDBC驱动的第一个坑不在代码里
很多人以为Java连接postgresql数据库的示例代码就是把Class.forName("org.postgresql.Driver")一写,DriverManager.getConnection一调就完事。真到自己动手,依赖没进classpath、驱动版本和数据库对不上、URL少写一个schema参数、连接用完不关,随便一个坑就能让你从“示例跑通”卡到“本地都起不来”。这篇文章按实际落地路径走一遍:从引入驱动、最小连接、增删改查,到连接池封装和避坑清单。适合刚在Java项目里接PostgreSQL的开发者,也适合维护老代码时被JDBC坑过的人——照着改,至少不会再犯我已经踩过的那些低级错误。
2. 搭环境与引入驱动:让第一个JDBC连接跑起来的完整步骤
2.1 先搞懂三件事:驱动、URL、JDBC版本
Java连接PostgreSQL,走的是JDBC(Java Database Connectivity)这一套标准接口。数据库厂商负责提供“驱动”,把JDBC调用翻译成PostgreSQL的线上协议。所以你写的连接代码其实是同一套DriverManager/Connection/Statement,换数据库时只需要换驱动jar和URL。这也是很多人把MySQL的代码改成PostgreSQL时经常只改URL、结果被Druid或HikariCP的配置坑住的原因——连接串后面那些参数,很多是数据库特有的。
PostgreSQL官方驱动的主类叫org.postgresql.Driver,Maven坐标是org.postgresql:postgresql。选版本有个原则:尽量选和数据库主版本匹配的稳定版。比如数据库是旧版13,驱动太新一般也能连;但反过来,数据库很新、驱动却停留在两三年前,就可能握手失败或者报unsupported startup message。我一般会先看驱动发布页的“兼容性”那段描述,再决定版本,而不是永远用最新。另外JDBC版本方面,Java 8用的JDBC 4.2和Java 11/17用的JDBC 4.3,驱动都兼容,但代码写法上,LocalDateTime、setObject这类接口在JDBC 4.2之后用起来更顺手,老代码里的java.sql.Timestamp也能继续跑,只是要多做转换。
URL格式是这个样子的:
jdbc:postgresql://<host>:<port>/<database>?<param1>=<value>&<param2>=<value>默认端口是5432,本地直连可以写127.0.0.1:5432。如果数据库在Docker映射了端口,端口号就跟着映射走。URL后面跟参数的方式是?开始、&连接,这跟MySQL的useUnicode=true&characterEncoding=utf8那套很像,但参数名和含义完全不同。PostgreSQL最常见的是currentSchema、connectTimeout、ssl这一组,后面章节会逐个说。
2.2 Maven与Gradle依赖:别把驱动装到JDK里
现在Java项目基本都走构建工具,最省事的做法是把驱动作为项目依赖。Maven的pom.xml里加入:
<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>使用当前稳定版</version> </dependency>Gradle则是在build.gradle里写:
implementation 'org.postgresql:postgresql:使用当前稳定版'这里我没有写死版本号,因为驱动版本更新频繁,直接用你查询到的当前稳定版即可。如果你项目的编译目标Java版本比较旧,比如还在Java 8,那要额外看一眼驱动要求的Java最低版本,高版本的驱动也可能要求Java 11。这个在依赖下载页的meta信息里能看到,别等运行时报UnsupportedClassVersionError才回头。
如果项目没上构建工具,只能手动放jar,比如老派web项目,那就把postgresql-*.jar放进WEB-INF/lib,或者放到应用服务器的lib目录。这里有个常见误操作:把驱动jar丢进${JAVA_HOME}/jre/lib/ext,想着“全局加载”。我见过有人这么干,确实能跑,但换了服务器环境就忘,而且如果容器里有两份驱动,类加载器会随机选一个,版本错乱时特别难排查。所以能走构建工具就走构建工具,实在手放的,请确保整个classpath里只有一份驱动jar。
依赖加好之后,可以先执行编译并看依赖树,确认驱动真的进来了。Maven项目跑:
mvn dependency:tree -Dincludes=org.postgresql:postgresql如果列出了版本,说明依赖没问题。这步能帮你排除“我明明写了依赖但代码找不到类”的玄学问题。
2.3 最小连接代码:从DriverManager开始
依赖就绪后,可以写一个最简连接示例,用来验证环境:
import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class QuickConnect { public static void main(String[] args) { String url = "jdbc:postgresql://127.0.0.1:5432/postgres"; String user = "postgres"; String password = "postgres"; try (Connection conn = DriverManager.getConnection(url, user, password)) { System.out.println("连接成功,当前数据库: " + conn.getCatalog()); } catch (SQLException e) { e.printStackTrace(); } } }这段代码里有个细节:JDBC 4.0之后,驱动只要在classpath里,DriverManager会自动加载它,所以不用写Class.forName("org.postgresql.Driver")。很多老教程还在写那行,写上也不算错,但对现代驱动来说已经多余。我自己的习惯是:不写Class.forName,用try-with-resources管理Connection,这样连接必然被关闭,不会因为异常路径漏关。
try-with-resources是Java 7以后的语法,括号里创建的Connection实现了AutoCloseable,代码块结束后自动调用close()。对于连接对象,这是最不容易出错的写法。后面正文里的所有示例都用这个写法。
conn.getCatalog()返回的是当前连接的数据库名,对PostgreSQL来说就是URL里/后面的那个名字。用它确认连接串没配错。
如果运行后报No suitable driver found,先别怀疑代码,去查classpath里驱动到底在不在。如果报连接超时,则去查网络和数据库的pg_hba.conf,这些在避坑章节里展开。
2.4 连接参数:schema、超时、ssl这些别用默认值
一个能连接的URL只是起点,实际项目中至少要关心下面几个参数:
| 参数 | 示例 | 作用 |
|---|---|---|
currentSchema | currentSchema=public | 指定默认schema,避免每次操作都写schema前缀 |
connectTimeout | connectTimeout=10 | 建连超时秒数,超时快速失败 |
socketTimeout | socketTimeout=300 | 读取超时秒数,避免连接假死 |
ApplicationName | ApplicationName=erp-service | 设置应用名,方便在数据库侧看来源 |
ssl | sslmode=require | 是否要求SSL,内网常不开启 |
currentSchema是PostgreSQL特有的概念。一个数据库下面可以有多个schema,默认是public。如果代码里写SELECT * FROM users,实际查的是当前schema下的users;如果业务把表建在别的schema下,不加这个参数就得写SELECT * FROM s1.users,不仅啰嗦,还容易在联表时混错schema。在连接串上指定当前schema,能让SQL干净很多:
jdbc:postgresql://127.0.0.1:5432/erp?currentSchema=sales&connectTimeout=10&socketTimeout=300注意schema名如果有大小写或特殊字符,URL里需要URL编码,普通小写没问题。connectTimeout和socketTimeout的单位都是秒。connectTimeout默认可能是0,即无限等待,生产环境建议至少设10秒,这样数据库不可达时应用能快速报错,而不是所有线程都挂在建立连接上。
SSL这一项,内外网差异大。公司内网数据库一般不开SSL,URL不需要带sslmode。云数据库或跨公网访问时,至少要sslmode=require,更严格可以用verify-full做证书校验。这里提醒:如果你在内网强制sslmode=require,而服务器没开SSL,连接会直接失败,别到时候怪代码。
除了URL参数,还可以在PostgreSQL的服务端配置里信任内部网络,避免每次连接都要求密码。但项目里原则上仍要传用户名密码,代码示例中只是作为变量,实际别把密码硬编码进源码。
3. 增删改查示例:PreparedStatement与ResultSet的实用写法
3.1 查询:PreparedStatement如何防注入又保性能
连接是基础,真正干活的还是SQL执行。Java里发SQL有两种方式:Statement和PreparedStatement。前者直接把字符串拼进SQL,有注入风险,还因为每次执行都要重新解析,性能差;后者是预编译,用占位符?留参数,PostgreSQL收到后先解析再绑定参数。我建议所有业务SQL都走PreparedStatement,不管是查询还是更新。
一个典型的分页查询长这样:
String sql = "SELECT id, username, email, created_at FROM users " + "WHERE status = ? ORDER BY id DESC LIMIT ? OFFSET ?"; try (Connection conn = Db.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, 1); ps.setInt(2, 20); ps.setInt(3, 0); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Long id = rs.getLong("id"); String username = rs.getString("username"); String email = rs.getString("email"); Timestamp createdAt = rs.getTimestamp("created_at"); System.out.println(id + " " + username + " " + email + " " + createdAt); } } } catch (SQLException e) { e.printStackTrace(); }这段代码有几个关键点。第一,通过prepareStatement让SQL先在数据库端编译,setInt、setString按位置绑定参数,参数值不会跟SQL字符串拼接,天然防注入。第二,limit ? offset ?这两个占位符也能用setInt绑定,PostgreSQL的limit表达式允许参数化,这没问题。第三,ResultSet也放进try-with-resources里,否则它在异常时不一定马上关闭,即使Connection关了,某些驱动也会延迟释放结果集资源。
读取字段时,我建议按列名rs.getLong("id")而不是下标rs.getLong(1)。列名可读性好,而且如果SELECT的字段顺序变了,按列名的代码不用改。但如果SQL里有JOIN,两个表都有id,就要用别名区分,比如SELECT u.id AS user_id,不然getLong("id")会拿到第一个匹配的列,很容易踩坑。
3.2 更新:INSERT、UPDATE、DELETE与返回自增主键
写操作和查询的区别主要在方法:executeUpdate()而不是executeQuery(),返回是一个整数,表示影响的行数。插入时经常需要拿到数据库生成的自增主键,比如BIGSERIAL或IDENTITY列,那就要在prepareStatement时显式声明要返回键:
String insertSql = "INSERT INTO users(username, email) VALUES (?, ?)"; try (Connection conn = Db.getConnection(); PreparedStatement ps = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, "alice"); ps.setString(2, "alice@example.com"); int affected = ps.executeUpdate(); if (affected > 0) { try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { long newId = keys.getLong(1); System.out.println("新用户ID: " + newId); } } } }Statement.RETURN_GENERATED_KEYS这个第二个参数告诉驱动:执行完后把自增键找回来。getGeneratedKeys()返回一个ResultSet,里面就是数据库返回的主键。注意不同数据库实现有差异,在PostgreSQL上,配合SERIAL和IDENTITY列通常没问题;但如果你用insert ... on conflict do nothing,且插入因冲突没成功,这个结果集里就没有键,所以在if (keys.next())前面要判断affected > 0。
更新和删除的写法类似:
String updateSql = "UPDATE users SET email = ? WHERE id = ?"; try (PreparedStatement ps = Db.getConnection().prepareStatement(updateSql)) { ps.setString(1, "new@example.com"); ps.setLong(2, 1L); int updated = ps.executeUpdate(); if (updated == 0) { System.out.println("记录不存在或没有变化"); } }这里有个容易忽略的行为:PostgreSQL默认executeUpdate返回的是“匹配到并更新的行数”,不是精确的“修改了值的行数”。如果新值跟旧值一样,PostgreSQL的UPDATE默认还是会计数,这点跟MySQL的默认行为不同,写业务逻辑别把返回值当成“值真的变了”。
3.3 时间类型读写:LocalDateTime与timestamp的映射
JDBC里历史遗留的时间类型让人很头疼。java.sql.Date、java.sql.Timestamp继承自java.util.Date,很多人图省事直接用它们,但在Java 8+项目中,我更推荐用java.time包下的类型。PostgreSQL JDBC驱动对java.time支持已经很成熟,setObject和getObject可以直接处理:
String sql = "INSERT INTO events(event_time) VALUES (?)"; try (PreparedStatement ps = Db.getConnection().prepareStatement(sql)) { ps.setObject(1, LocalDateTime.now()); ps.executeUpdate(); } String query = "SELECT event_time FROM events WHERE id = ?"; try (PreparedStatement ps = Db.getConnection().prepareStatement(query)) { ps.setLong(1, 1L); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { LocalDateTime eventTime = rs.getObject("event_time", LocalDateTime.class); } } }分页里用了Timestamp是因为示例代码保留老风格。新代码我建议用LocalDateTime,setObject和getObject都能自动映射。但这里有个容易忽略的点:PostgreSQL有两种时间类型。timestamp without time zone存的是“墙上时间”,不带时区;timestamp with time zone(timestamptz)存的是即时时间点,内部统一转成UTC存储。如果列是timestamptz,驱动返回OffsetDateTime更合适;如果你强行取LocalDateTime,驱动会按数据库会话时区转换,容易产生时区偏差。我的建议是:业务内部统一用LocalDateTime存“墙上时间”,存timestamptz就配合OffsetDateTime显式带时区。写成ps.setObject(1, OffsetDateTime.now()),读取时也按OffsetDateTime.class读,避免本地时区和服务端时区不一致导致莫名其妙差8小时。
3.4 事务控制:手动commit与rollback
默认情况下,DriverManager.getConnection拿到的连接是自动提交的,每条SQL立刻生效。但业务上经常需要“要么都成功,要么都失败”,这时候要关闭自动提交:
try (Connection conn = Db.getConnection()) { conn.setAutoCommit(false); try { // 例:创建订单,同时扣库存 String orderSql = "INSERT INTO orders(user_id, total) VALUES (?, ?)"; try (PreparedStatement ps = conn.prepareStatement(orderSql)) { ps.setLong(1, 1001L); ps.setBigDecimal(2, new BigDecimal("99.00")); ps.executeUpdate(); } String stockSql = "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?"; try (PreparedStatement ps = conn.prepareStatement(stockSql)) { ps.setInt(1, 1); ps.setLong(2, 77L); ps.setInt(3, 1); int affected = ps.executeUpdate(); if (affected == 0) { throw new SQLException("库存不足"); } } conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); } }这里有几个坑。第一,setAutoCommit(false)之后,所有executeUpdate都进入同一个事务,直到commit或rollback。第二,一旦出现异常,必须rollback,否则事务会一直挂着,占着连接和数据库锁。第三,finally块里恢复自动提交,这步很关键——如果连接返回连接池,或者被复用,自动提交状态不恢复,会让下一个人用这个连接时,所有SQL都神奇地不生效。第四,持有连接期间不要做耗时的外部调用,事务里握着锁,时间越长越容易造成数据库锁等待。
4. 避坑清单:Java连PostgreSQL常见的5个翻车现场
4.1 现象:ClassNotFoundException与"No suitable driver found"
这是我见过新手遇到最多的报错。ClassNotFoundException: org.postgresql.Driver,说明classpath里根本没有驱动包。SQLException: No suitable driver found for jdbc:postgresql://...,说明驱动类虽然存在,但DriverManager不认识这个URL——常见原因是URL的协议名称写错了,比如写成jdbc:postgres://,或者把MySQL的jdbc:mysql://改了一半。
原因:大多数是依赖没真正加入,或打war包时没把驱动jar打进去,再或者是依赖的scope选了provided,运行时容器里没有。比如Maven依赖写了<scope>provided</scope>,在可执行jar里不会带上驱动。另一种情况是,多个驱动共存时,PostgreSQL驱动不是默认首个被加载的,但DriverManager仍然能识别,所以归根结底还是classpath问题。
解决:先跑mvn dependency:tree -Dincludes=org.postgresql:postgresql确认依赖,再检查打包插件的mainClass和shade配置。如果是Spring Boot项目,看看BOOT-INF/lib里有没有驱动jar。我建议所有连接串统一用jdbc:postgresql://,这个协议名是驱动的注册标识,写错一个字母就会变成“No suitable driver”。
4.2 现象:连接超时,但psql能连上
本地用psql -h 127.0.0.1 -U postgres能连,Java一跑就connect timed out或者Connection refused。这两个错误还不一样:Connection refused说明端口没开或IP不对;Operation timed out说明包发出去但没人应答,通常是防火墙或网络策略拦截。
原因:最常见的是pg_hba.conf里只允许了127.0.0.1/32,而Java进程跑在容器或另一台机器上,来源IP不在白名单。其次,PostgreSQL默认监听localhost,如果数据库跑在Docker里只映射了端口,外部IP进不来。还有一种是java进程自己所在环境的出站防火墙拦了5432端口。
解决:先分清是refused还是timeout。refused时,检查ss -lnt | grep 5432看数据库监听地址;timeout时,检查防火墙或安全组。临时验证可以关掉防火墙后测试,但关键还是调整pg_hba.conf:
host all all 10.0.0.0/8 md5相应的,要在URL里设置connectTimeout=5,让失败快速暴露,否则默认可能等很久。注意:改了pg_hba.conf要重载:SELECT pg_reload_conf();,不用重启进程。
4.3 现象:中文变问号或数据库里已经是乱码
Java读出来是???,或者往里写再读就变成?。这个坑我在老项目里踩过。PostgreSQL建库时如果不指定编码,很多模板默认是UTF8,但老库可能是SQL_ASCII或LATIN1。SQL_ASCII不检查编码合法性,字符一旦写入,再按UTF-8读就全乱。
原因:数据库本身的编码不是UTF8,连接时又没有正确声明客户端编码。PostgreSQL JDBC驱动默认会按数据库的client_encoding来,但SQL_ASCII下不做转换,Java侧按UTF-8解码就出问题。还有可能是应用服务器的默认文件编码被改了,但这种情况少。
解决:先把新建数据库的编码固定为UTF8:
CREATE DATABASE demo ENCODING 'UTF8' LC_COLLATE 'zh_CN.UTF-8' LC_CTYPE 'zh_CN.UTF-8' TEMPLATE template0;连接串上可以显式加参数,但PostgreSQL的编码参数名不是characterEncoding,而是characterEncoding=UTF8也可以(驱动兼容这个名),更规范的是直接让数据库默认UTF8,连接不指定也是UTF8。检查一下当前数据库编码:
SHOW server_encoding;如果是SQL_ASCII,就算你在URL加了characterEncoding=UTF8,存进去的字节也未必是对的,正确做法是重建库。数据迁移的话,先pg_dump再用UTF8库pg_restore,别想在原库上改。
4.4 现象:时间比本地时间差8小时
写入数据库的时间,读出来发现比本地时间少8小时,或者反过来。这几乎是PostgreSQL新手必经的坑。原因在于数据库时区、JDBC会话时区、Java时区三者没有对齐。比如数据库服务器时区是UTC,Java跑在东八区,列类型是timestamp without time zone,驱动写入时按LocalDateTime字节直接存,但读出来时,驱动会结合会话时区转成Timestamp,Java再按本地时区格式化,就会出现偏移。
解决:最直接的办法是连接串上指定会话时区:
jdbc:postgresql://127.0.0.1:5432/app?TimeZone=Asia/ShanghaiTimeZone这个参数驱动支持,它会把timestamp类型的读取和写入都在该时区下解释。如果列用的是timestamptz(带时区),那么无论会话时区是什么,内部都存UTC时间戳,读取时转成指定时区,反而更可靠。我现在的习惯是:业务字段能用timestamptz就用它,Java侧统一用OffsetDateTime或Instant,这样时区问题从源头消失。如果你还在用旧的java.sql.Timestamp,那就确保连接串TimeZone和JVM默认时区一致,否则永远差着那几小时。
还有一个隐蔽点:PostgreSQL驱动读取timestamp without time zone时,会把数据库端“无时区”的时间强行当成“当前时区”解释。如果你数据库的timezone配置是UTC,而连接串没指定,本地读出来就会少8小时。用SELECT now()和Java打印的new Date()对比,如果差整小时,基本就是这个原因。
4.5 现象:应用卡死,数据库连接数被耗尽
线上应用跑着跑着突然全部请求超时,数据库侧看到too many connections。查应用日志,经常是“从连接池获取不到连接”。这个坑绝大多是资源泄漏导致的。
原因:代码里开了Connection、Statement、ResultSet,但只在finally里关了Connection,漏关了Statement和ResultSet;或者干脆连Connection都没关。连接池的作用是管理连接,但如果你自己不关Connection,池里的连接被借走不还,池很快就空。另外,如果配了minimumIdle和maximumPoolSize不合理,或者maxLifetime过长,数据库端对几十年不动的连接也会超时断开,池里还存着无效连接,取出来就报错。
解决:把所有JDBC对象都放进try-with-resources,或者用工具类统一封装,确保ResultSet→Statement→Connection按顺序关闭。同时给连接池设置合理的校验和回收:
config.setMaximumPoolSize(10); config.setMinimumIdle(5); config.setMaxLifetime(1800000); config.setValidationTimeout(5000); config.setConnectionTestQuery("SELECT 1");maxLifetime设为30分钟,低于数据库tcp_keepalives_idle的默认值,让池定期丢弃旧连接,避免被数据库单方面切断。排查时,在数据库侧执行:
SELECT pid, usename, application_name, state, now() - backend_start AS duration FROM pg_stat_activity WHERE datname = 'app';看到一堆陈旧连接,优先排查是不是哪条代码路径漏了close()。
5. 连接池与工具类封装:从一次性连接到生产可用
5.1 为什么生产环境不用DriverManager
前面所有示例都用DriverManager.getConnection(),这是理解JDBC的起点。但生产环境里,每个请求都新建连接、用完关闭,开销很大。PostgreSQL建连要TCP握手、认证、准备会话,大概几毫秒到几十毫秒,虽然不比网络调用那么贵,但在高并发下会成为瓶颈。更重要的是,连接不可重用,数据库要维护大量的短暂连接,连接数一旦被拉高,新来的请求直接排队。
连接池的想法很简单:启动时预先创建一小批连接放进池里,业务要连时从池里借,用完还回去,池自己负责创建、回收、保活。Java生态里最常用的连接池是HikariCP,它性能好、配置少,PostgreSQL官方文档也把它列为推荐方案。所以在项目里封装一个基于HikariCP的DataSource,比到处写DriverManager要靠谱得多。
5.2 用HikariCP配置一个实用的连接池
引入HikariCP,Maven依赖很简单:
<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>使用当前稳定版</version> </dependency>然后在程序里创建数据源:
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:postgresql://127.0.0.1:5432/app?currentSchema=public&TimeZone=Asia/Shanghai"); config.setUsername("app"); config.setPassword("请在配置中心或环境变量中读取"); config.setMaximumPoolSize(10); config.setMinimumIdle(5); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); config.setPoolName("pg-pool"); config.setConnectionTestQuery("SELECT 1"); HikariDataSource dataSource = new HikariDataSource(config);这些参数是连接池的核心,我给出自己常用的初始值:
| 参数 | 建议值 | 理由 |
|---|---|---|
maximumPoolSize | 10 | 根据应用并发和数据库能力调,不是越大越好 |
minimumIdle | 5 | 保持少量空闲连接,避免突发性能抖动 |
connectionTimeout | 30000 | 获取连接等待上限,单位毫秒 |
maxLifetime | 1800000 | 连接最大存活30分钟,防止数据库断开无效连接 |
connectionTestQuery | SELECT 1 | 取连接时校验连接是否可用 |
注意,PostgreSQL JDBC本身就支持setConnectionTestQuery,但如果已知连接池会校验,也可以在URL上加sslmode等参数。这里TimeZone=Asia/Shanghai是利用URL参数,正好解决前面时区差8小时的问题,不需要在Java代码里到处传时区。
5.3 封装一个线程安全的连接工具类
有了连接池,代码里不应该再直接new HikariDataSource,而是把数据源做成单例,提供静态方法获取连接。
public final class Db { private static final HikariDataSource DS = create(); private Db() { } public static Connection getConnection() throws SQLException { return DS.getConnection(); } private static HikariDataSource create() { HikariConfig config = new HikariConfig(); // 与上面一致,可读配置文件 config.setJdbcUrl("jdbc:postgresql://127.0.0.1:5432/app"); config.setUsername("app"); config.setPassword(System.getenv("DB_PASSWORD")); config.setMaximumPoolSize(10); return new HikariDataSource(config); } }使用方只需要:
try (Connection conn = Db.getConnection()) { // 执行业务SQL }这样整个项目只初始化一次连接池,线程安全由HikariDataSource保证,业务代码不用关心连接创建和销毁。但这只是基础封装,更高一层的做法是用DAO模式或ORM框架。如果团队已经用了Spring Boot,直接用spring.datasource配置更省事;这里手写工具类适合无框架的轻量项目。
5.4 验证连接池是否正常工作
封装完之后,别急着提交代码,先做个简单验证。写一个测试方法,循环获取并归还连接:
try (Connection conn = Db.getConnection()) { try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT 1")) { if (rs.next()) { System.out.println("连接池校验通过: " + rs.getInt(1)); } } }然后监控连接池的活跃连接数。HikariCP自带JMX,可用jconsole连上去看Pool:pg-pool的几个指标:ActiveConnections、IdleConnections、PendingConnections。正常情况调用后ActiveConnections回落到0,IdleConnections恢复到minimumIdle。如果ActiveConnections只增不减,说明业务层依然存在连接泄漏,回到第4.5节查。
一个我自己的习惯:在测试环境用wrk或JMeter压20个并发,观察PendingConnections是否增加。如果PendingConnections一直涨,说明连接池真的不够用,或者某个慢SQL持有连接的时间太长。这时候别急着调大maximumPoolSize,先看SQL执行计划和事务范围。连接池不是越大越好,每个连接对应PostgreSQL后端进程,连接数太多会让数据库CPU和内存同时飙升。
最后说一句血泪经验:我从第一个“示例跑通”到线上稳定,中间隔着一个连接池和一个try-with-resources。早期项目里为了省事直接new Connection,结果某次发版后并发一高,数据库连接数被打满,整个服务像被点了穴一样。后来统一改成HikariCP + 强制try-with-resources,同样的SQL,压力翻一倍也没再出过“too many connections”。现在回头看那段老代码,根本问题不在JDBC示例本身,而是没人把资源关闭当回事。希望这篇笔记帮到你,少走我已经走过的弯路。
本文还有配套的精品资源,点击获取