
先说结论Java配PostgreSQL做CRUD真正值得你花时间的不是那几个增删改查方法本身而是驱动版本怎么选、连接怎么配、事务边界怎么划、批量操作怎么写。这四个点我在这几年的开发里都实打实踩过坑这篇就按项目落地顺序从环境准备讲到问题排查把一套能直接抄走的方案完整写出来。这篇内容适合正在学JDBC的初级工程师、从MySQL往PostgreSQL迁移的老开发以及想要在团队内搭建一套通用CRUD模板的后端负责人。全文以实际可运行为准代码都验证过参数都给了依据你跟着走一遍就能跑通。1. 环境准备与版本选型1.1 PostgreSQL版本怎么选很多新手上来就问“下载哪个版本”其实这个问题得分角色看。如果你是在本地开发机上跑直接选最新的稳定版就好比如当前的16.x或17.x。如果你是在生产环境部署那就选上一个稳定大版本等社区把新版本的坑踩得差不多了再升级。别一上来就追最新数据库这种基础组件稳定比新功能重要得多。以我个人的实际体会PostgreSQL 16是个分水岭。它把逻辑复制的性能优化做上去了还加了pg_stat_io这样的视图排查IO问题比之前舒服很多。17版本在真空处理和WAL锁方面又有改进但如果你不关心这些底层细节16和17对你写Java CRUD来说几乎没有差别。真正影响你的是JDBC驱动和数据库大版本之间的兼容性这一点后面专门讲。选择版本时还要注意字符集和排序规则。PostgreSQL初始化数据库时默认的locale跟你的操作系统强相关如果你用Docker镜像官方镜像默认是C或POSIX排序写入中文数据时排序可能不符合预期。建议在docker run或initdb时明确指定-e LANGC.UTF-8或--localeC.UTF-8避免后面排序、索引出现诡异行为。1.2 安装方式对比与选择常见安装方式有四种Docker容器、Windows安装包、Linux发行版包管理器、源码编译。这四种我全用过各自适用场景完全不同。安装方式适用场景优势注意点Docker本地开发、CI环境环境隔离、版本切换快、一键拉起数据目录要挂载卷端口别冲突安装包Windows/exe个人开发机图形界面、自带pgAdmin服务以Windows服务方式常驻发行版apt/yumLinux服务器和系统整合好、systemd管理版本一般偏旧官方仓库源要配置源码编译定制安装路径、嵌入式可裁剪插件、完全可控编译耗时长依赖库易缺Docker方式我用的最多。一条命令就是一套完整数据库不用的时候直接删掉容器完全不污染本机环境。但有个前提容器数据必须挂载到宿主机否则容器一删数据全没。我的习惯是这样docker run -d \ --name pg16 \ -e POSTGRES_USERtest \ -e POSTGRES_PASSWORDtest123 \ -e POSTGRES_DBmydb \ -e LANGC.UTF-8 \ -p 5432:5432 \ -v /data/pg16:/var/lib/postgresql/data \ postgres:16这里-v挂载的是数据目录宿主机上的/data/pg16会在容器首次启动时被初始化成PostgreSQL数据目录。如果你用的是macOS或Windows的Docker Desktop磁盘性能会比本机原生安装差一些但日常开发写CRUD完全感觉不出来。源码编译这个路子我劝你非必要不碰。虽然网上很多教程强调“可以完全定制”但代价是你得自己解决readline、zlib、openssl这些依赖编译一次少说二十分钟而且后续维护还是自己扛。真正的价值场景是离线内网环境连yum源都没有的机器上你用源码包在另一台同架构机器编译出二进制再拷过去这个方案反而最省事。1.3 JDK与构建工具准备Java这边我建议直接用JDK 17或21。Spring Boot 3.x强制要求JDK 17以上MyBatis-Plus 3.5.x在JDK 17下跑得很稳。之前我试过在JDK 8上跑新版本的PostgreSQL驱动倒是能跑但要么是驱动不支持新特性要么是老依赖拖后腿整体体验不值当。JDK 21的主要价值在于虚拟线程如果你用Tomcat或Jetty的虚拟线程模式并发能力会有质的提升。不过对普通CRUD项目来说JDK 17已经超过够用的标准了真正的瓶颈通常在数据库侧而不是应用侧。构建工具我推荐Maven虽然Gradle的构建速度更快但Maven在依赖管理和可读性上更直观绝大多数Spring Boot项目的脚手架也默认用Maven。后面所有配置我都基于Maven写你照着建项目就行。2. 连接层配置与驱动选型2.1 JDBC驱动版本匹配Java连PostgreSQL的官方驱动是org.postgresql:postgresqlgroupId是org.postgresql。版本选择参考官方兼容矩阵驱动42.3.x支持PostgreSQL 10到1442.6.x支持到15和1642.7.x则全面适配16和17。别老想着用最新版稳定够用就好。我的建议是数据库16配驱动42.7.4以上数据库15配42.6.0以上。驱动是向后兼容的旧驱动连新数据库偶尔会有“认证方式不支持”之类的问题但新驱动连旧数据库基本没问题。直接看Maven中央仓库的Release版本挑最新的42.7系列就是个稳妥选择。dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId version42.7.4/version /dependency这里有个经验JDBC驱动除了postgresql这个主包之外没有额外依赖。它不像MySQL驱动那样还需要单独的protobuf或gson所以关联依赖非常干净这算是PostgreSQL驱动做得好的一点。2.2 JDBC URL与关键参数连接串的格式长这样jdbc:postgresql://localhost:5432/mydb?currentSchemapublicapplicationNamemyappconnectTimeout5socketTimeout30每个参数背后都有实际意义。currentSchema指定默认schema如果你想连的不是public而是别的schema必须显式设置它否则所有SQL里的表名都得加前缀。applicationName是给DBA用的数据库的pg_stat_activity视图里能直接看到当前连接属于哪个应用排查问题的时候一眼就能定位比在服务端抓IP方便多了。connectTimeout我习惯设成5秒。默认值如果是0表示无限等待一旦网络环境抖动业务线程会全部卡在建立连接上那场面非常难看。socketTimeout设成30秒防止某条SQL执行时数据库端挂了而客户端傻等。还有一个容易被忽略的参数是prepareThreshold。PostgreSQL驱动默认在同一个连接上执行同一个SQL五次之后才会把这个SQL切换成服务端预编译语句。对频繁执行的写操作来说这个机制能显著降低SQL解析开销但如果你在分析慢日志时发现大量unnamed prepared statement可以把这个值改成1强制第一次执行就走服务端预编译。需要注意的是这也会让连接占用的服务端缓存更大所以在一般场景下保持默认就够。2.3 连接池HikariCP的配置要点在Java里裸写JDBC连接是可行但不可持续的。每次建立连接都要经过TCP握手、认证、参数协商一个连接的生命周期可能上百毫秒这个开销在业务请求里是白白浪费的。所以生产环境必须用连接池HikariCP是Spring Boot的默认选择也是目前性能最好的池子。spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 idle-timeout: 600000 max-lifetime: 1800000 connection-timeout: 3000这四个关键参数我一个个说。maximum-pool-size决定池子里最多有多少个连接公式是核心并发线程数 × (1 平均等待时间 / 平均执行时间)。如果你的服务是20个核心线程、每个请求平均耗10ms、数据库查询平均3ms那差不多就是20×1.530。但一般业务系统用不着算这么精细20到30就足够了池子开太大反而浪费数据库资源。minimum-idle是池子至少保持多少空闲连接应对突发流量设成5就行。max-lifetime是连接的最大存活时长一定要小于数据库端的wait_timeout或清理策略我们把它设成30分钟能让数据库更平滑地回收连接。connection-timeout是客户端从池子里获取连接的最长等待时间我设了3秒一旦池子不够用而请求又等不到连接就直接抛异常暴露问题而不是无限阻塞。连接池最常见的坑是“连接泄漏”。代码里拿到连接后不往池子里还随着请求堆积把池子耗尽然后用HikariPool-1 - Connection is not available, request timed out的报错提醒你。对付这个问题的有效手段有两个一是代码里务必做到try-with-resources二是开启HikariCP的泄漏检测leak-detection-threshold: 60000连接空闲超过60秒不归还就打印堆栈。这个参数对排查问题帮助极大生产上建议保留观察。3. CRUD核心实现3.1 建表与基础数据准备先建一张最简单的用户表作为示例。PostgreSQL的自增主键推荐用generated always as identity而不是serial。从功能上两者都能实现自增但identity是SQL标准语法权限控制和类型约束更严格已经是社区推荐的写法。create table t_user ( id bigint generated always as identity primary key, username varchar(64) not null, email varchar(128) not null, nickname varchar(64), age int, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); comment on table t_user is 用户表; comment on column t_user.id is 主键ID; comment on column t_user.username is 用户名;这里有两个值得说的习惯。第一created_at和updated_at都用timestamptz类型推荐用带时区的时间戳。Java端的Instant和OffsetDateTime可以无歧义地映射避免“明明是同一时刻库里存的时间却跟你本地差了八小时”的尴尬。第二字段名用全小写加下划线风格因为PostgreSQL会把不带引号的标识符折叠成小写Java实体里用username直接对应username两边都不用做特殊处理。3.2 JDBC原生方式增删改查先来一段最基础的JDBC代码不带任何框架明白底层之后你会发现后面用ORM只是换了个封装方式。新增并返回主键String sql insert into t_user(username, email, nickname, age) values (?, ?, ?, ?) ; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, zhangsan); ps.setString(2, zhangsanexample.com); ps.setString(3, 张三); ps.setInt(4, 25); ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { Long id rs.getLong(1); System.out.println(生成的ID: id); } } }prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)这个重载方法一定要写否则你拿不到数据库自增生成的ID。PostgreSQL的JDBC驱动内部其实是用INSERT ... RETURNING id帮你实现的但驱动细节你不用关心按这个写法来就行。查询String sql select id, username, email, nickname, age, created_at from t_user where username ? ; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, zhangsan); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Long id rs.getLong(id); String username rs.getString(username); OffsetDateTime createdAt rs.getObject(created_at, OffsetDateTime.class); // 组装实体... } } }注意created_at用rs.getObject(created_at, OffsetDateTime.class)读取比getTimestamp再手动转更省事时区信息也不会丢。更新与删除String updateSql update t_user set nickname ?, updated_at now() where id ? ; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setString(1, 新昵称); ps.setLong(2, 1L); int rows ps.executeUpdate(); if (rows 0) { // 没有匹配的记录业务上要处理这种情况 } }executeUpdate返回的是受影响行数。rows 0可能是ID不存在也可能是数据没变化。如果你用了updated_at now()这种每次都刷新的字段那不存在和没变化是能区分出来的。如果没更新任何字段PostgreSQL也会返回0。事务边界这是CRUD里最容易被新手忽略的一环。默认情况下每条SQL都是自动提交的但真实业务里的“用户注册”往往是“插入用户表插入账户日志更新统计计数”这种多步骤操作任何一个步骤失败都要整体回滚。如果全部写在同一个连接里你就可以控制事务try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); try { // execute insert ... // execute update ... conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } }事务边界规则很简单要么全部成功要么全部回滚。这里的常见失误是把conn.commit()写在finally块的close()之前或者根本没调commit等连接池回收连接时驱动才隐式回滚最终表现为“数据偶尔写入、偶尔丢失”。我的建议是小事务几十毫秒内完成的就在方法体内明确提交长事务超过几秒尽量拆小避免长时间持锁影响并发。批量插入String sql insert into t_user(username, email, nickname, age) values (?, ?, ?, ?) ; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (User user : userList) { ps.setString(1, user.getUsername()); ps.setString(2, user.getEmail()); ps.setString(3, user.getNickname()); ps.setInt(4, user.getAge()); ps.addBatch(); } int[] results ps.executeBatch(); }注意executeBatch()返回的是每批语句的执行结果数组Statement.SUCCESS_NO_INFO表示执行成功但没有具体的行数。批量插入一次别太大建议每500到1000条作为一个批次太多的话驱动端Buffered内存和数据库端WAL压力都会上来。3.3 基于MyBatis-Plus实现通用CRUD如果项目里有若干张表都要做增删改查而你又不想为每张表都写一遍Mapper XML那MyBatis-Plus就是当前Java生态里最顺手的方案。社区里最近讨论的“通用CRUD服务”本质上就是MyBatis-Plus的BaseMapper和IService组合。首先引入依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency注意如果你用的是Spring Boot 3.x必须引入mybatis-plus-spring-boot3-starter而不是老的mybatis-plus-boot-starter。这个坑我踩过老版依赖在Spring Boot 3下会直接启动失败。然后定义实体TableName(t_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private String email; private String nickname; private Integer age; TableField(fill FieldFill.INSERT) private LocalDateTime createdAt; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; // getter/setter/toString... }TableName把实体映射到t_user表TableId(type IdType.AUTO)对应数据库的自增主键。这里要特别提醒在MyBatis-Plus里千万别把id的type配置成IdType.INPUT除非你打算自己给ID赋值否则插入时主键字段会被当成默认值0处理接着就会触发主键冲突。接下来定义Mapper接口public interface UserMapper extends BaseMapperUser { }就一行增删改查单个对象的基础方法全都有了selectById、selectList、insert、updateById、deleteById。如果你需要根据某个字段查询就用QueryWrapperListUser users userMapper.selectList( new LambdaQueryWrapperUser() .eq(User::getUsername, zhangsan) .like(User::getNickname, 张) .orderByDesc(User::getId) .last(limit 10) );LambdaQueryWrapper最大的价值是类型安全。你写User::getUsername时编译器就能帮你确认字段存在如果数据库字段改名了编译期就能发现。这里有个常用技巧模糊查询时like默认会在参数两边加%你不需要自己在参数里拼%。而last(limit 10)是直接拼在SQL末尾的如果传入的是用户可控的值必须白名单校验否则就是SQL注入点。如果你要写一个“无状态”的通用CRUD服务核心思路是把这些操作封装成一个通用基类public interface BaseCrudServiceT extends IServiceT { } public class BaseCrudServiceImplM extends BaseMapperT, T extends ServiceImplM, T implements BaseCrudServiceT { }这样每张表只需要继承基类单表的所有CRUD能力自动具备。所谓无状态是指这个服务内部不持有业务状态每次调用都只依赖传入的参数天然支持高并发水平扩展。在一个后台管理系统的项目里这种设计能帮你把几十张表的增删改查代码压缩到只有实体类和Mapper接口省下的精力可以用来处理真正的业务逻辑。但要注意ServiceImpl自带的方法适合单表操作如果你的SQL里有JOIN、子查询或者复杂的聚合还是得在Mapper XML里写自定义SQL别硬用MyBatis-Plus的Wrapper硬凑那种SQL写完会非常难维护。我的经验是单表CRUD直接继承通用服务多表关联查询走自定义XML两者结合才是最佳实践。3.4 PostgreSQL特有的CRUD操作Upsert在CRUD之外PostgreSQL有一个MySQL用户特别羡慕的语法INSERT ... ON CONFLICT DO UPDATE也就是常说的Upsert。它的意义在于一条SQL完成“存在就更新不存在就插入”的原子操作不需要先查再判断。String sql insert into t_user(username, email, nickname, age) values (?, ?, ?, ?) on conflict (username) do update set email excluded.email, nickname excluded.nickname, age excluded.age, updated_at now() ;这里的on conflict (username)是冲突判断的约束条件。在业务上比如用户通过第三方账号登录时先按username尝试更新资料如果没有这个用户则插入用一条语句就能解决且天然没有并发竞态问题。注意excluded前缀它代表你本次试图插入的那一行数据。如果冲突时你想完全保留旧数据那就不写do update只写do nothing。这种方式特别适合日志流水表、批量同步任务这类场景。写入并发高、重复提交频繁时Upsert能省掉一条查询SQL同时避免在代码里做“查重-插入-更新”三步操作造成的竞态窗口。4. 常见问题与排查技巧实录4.1 连接失败类问题问题现象Connection refused或The connection attempt failed。排查步骤我总结成一句话先确认数据库进程在跑再确认端口通不通最后看防火墙和Docker端口映射。如果你用Docker部署最常见的坑是-p 5432:5432没映射对或容器已经因为数据目录权限问题退出了。docker ps docker logs pg16 --tail 100如果日志里出现FATAL: data directory /var/lib/postgresql/data has invalid permissions说明宿主机挂载目录的属主不对。解决办法是给目录授权chown -R 1000:1000 /data/pg16PostgreSQL官方镜像里默认用UID 1000跑数据库进程直接给权限就行。问题现象Password authentication failed for user xxx。这个报错一般不是密码错了而是用户、数据库、认证方式三者里的某一件事情没对上。PostgreSQL默认的pg_hba.conf里本地连接用scram-sha-256认证如果你用trust或md5在较新版本里会直接拒绝。检查认证配置时关键是看pg_hba.conf不是看密码本身local all all scram-sha-256 host all all 0.0.0.0/0 scram-sha-256问题现象The driver has not received any packets from the server。这属于连上了但没完成握手就超时了。常见原因一是服务器listen_addresses没配置成*只监听了localhost二是你在JDBC URL里用的主机名解析到了IPv6的::1但PostgreSQL只监听了IPv4。遇到这类问题时先用telnet或nc直接验证端口可通能快速缩小范围。4.2 驱动类加载问题老教程会让你写Class.forName(org.postgresql.Driver)这在JDBC 4.0以后的版本里已经不需要了。驱动JAR包在META-INF/services/java.sql.Driver文件里自动声明了驱动类DriverManager会通过SPI机制自动加载。如果你手动写Class.forName在大多数情况下也没事但在某些容器环境里可能会导致驱动被加载两次出现奇怪的Multiple drivers found警告。真正需要检查驱动类加载的场景是你用了瘦身工具如Spring Boot的spring-boot-maven-plugin的repackage或者本地依赖冲突把驱动包从最终产物里排除了。这时报错通常是No suitable driver found for jdbc:postgresql://...。解决办法是检查mvn dependency:tree确认postgresql驱动没有被别的东西排掉。4.3 时区与时间类型映射问题问题现象java.time.LocalDateTime写入timestamptz字段后读出来差8小时。这几乎是所有从MySQL转PostgreSQL的团队都会遇到的问题。原因在于timestamp with time zone存储的是绝对时间点写入时会把你传入的时间按会话时区转成UTC存储读取时又会按会话时区转回来。如果Java代码里用的是LocalDateTime不携带时区信息驱动会默认把系统默认时区套上去转换一旦应用服务器设置了Asia/Shanghai数据库会话时区是UTC就会产生偏差。我给的建议是统一规范Java代码和时间交互一律用OffsetDateTime或InstantJDBC URL里加上TimeZoneAsia/Shanghai数据库连接属性里设置serverTimezoneAsia/Shanghai。注意这里不是让数据库存你本地时间而是让驱动知道你的应用期望什么时区转换才不会出错。如果你的表本身只需要记录“哪天”而不是“哪一刻”干脆用date类型就彻底避开时区问题了。4.4 大小写与Schema搜索路径问题问题现象relation t_user does not exist。我遇到过好多次把表名写成T_User或T_User的情况。PostgreSQL对不带引号的标识符会折叠成小写所以T_User实际会变成t_user。如果你的表真的是大写字母开头的在Java代码里就得给SQL加双引号查询自己把自己绕晕。问题现象能连上数据库但SQL执行时找不到表。当你连接的数据库里存在多个schema而表不在public下时就会出现这个现象。解决方案在JDBC URL里加currentSchemamy_schema或者在连接后执行SET search_path TO my_schema。推荐前者简单直接。4.5 批量慢与写入性能问题如果发现批量插入几千条数据耗时惊人第一步要确认是不是在循环里单条提交。单条commit的开销在PostgreSQL里非常高因为每次提交都要刷WAL日志。最简单的优化就是addBatch加executeBatch把几百条攒在一个事务里提交。如果还不够快可以用PostgreSQL的COPY协议通过org.postgresql.copy.CopyManager实现流式导入10万条数据从几秒降到几百毫秒是完全可能的。CopyManager copyManager pgConnection.getCopyAPI(); String copySql COPY t_user(username, email, nickname, age) FROM STDIN WITH (FORMAT csv); copyManager.copyIn(copySql, new InputStreamReader(inputStream));COPY是PostgreSQL性能最好的写入方式但它不做行级冲突检测适合数据导入和离线同步不适合在线业务写主表。这点需要把握好。5. 安全与查询性能的进阶细节5.1 SQL注入防护的两条铁律CRUD里的每个参数都可能成为注入点。JDBC里所有用户输入都必须走PreparedStatement的参数占位符而不是字符串拼接。${}在MyBatis里是直接拼接#{}才是预编译参数。不要为了省事把排序字段、动态列名也搞成占位符拼接因为表和列名没法参数化这时必须用白名单校验。举一个实际案例。动态排序列你收到的排序字段是sortFieldemail如果直接拼进SQL里攻击者传一个email; drop table t_user; --就能让你当场社会性死亡。白名单的做法是后端先定义允许排序的字段集合private static final SetString SORT_WHITELIST Set.of(id, username, email, created_at); if (!SORT_WHITELIST.contains(sortField)) { throw new IllegalArgumentException(非法排序字段); }这样做之后才是安全的。5.2 利用PreparedStatement缓存重复SQLPostgreSQL的会话有个“语法分析缓存”同一个SQL文本重复执行时服务端可以跳过重复解析。JDBC驱动的prepareThreshold参数控制了“同一个连接上同一条SQL执行多少次后转为服务端预编译”。在高频CRUD场景里把prepareThreshold从默认的5改成1能显著减少服务端的解析开销但代价是占用的服务端内存会上升。批量接口和热点查询建议开启一次性的报表SQL就没必要了。5.3 慢查询定位写完CRUD之后迟早会遇到“某个接口突然变慢”。定位手段第一步是在数据库侧开慢查询日志PostgreSQL的log_min_duration_statement要设一个阈值比如500毫秒alter system set log_min_duration_statement 500;这个值是全局配置改完需要重启或者pg_reload_conf()。别在生产环境随便打开日志量会特别大。临时排查可以用EXPLAIN (ANALYZE, BUFFERS)单独跑那条慢SQL重点看有没有Seq Scan、有没有对索引列的隐式类型转换。比如你查询where username ?但username列类型是varchar(64)Java里传的是String这没问题如果你传的是数字、或者后端把字符串转成了char就会导致索引失效。这个坑在隐式类型转换那一栏特别明显。另外推荐安装pg_stat_statements扩展它是数据库内置的SQL统计插件能按总耗时排序展示你系统里最耗时的SQL集合这对定位“最该优化的SQL”非常有帮助。5.4 连接数规划与池参数最佳实践PostgreSQL默认最大连接数是100这个数字对于小型团队的系统来说往往够用。很多人一遇到连接不够就调大max_connections这是治标不治本。每个PostgreSQL连接都需要独立进程和内存连接数开到500以上不仅没好处反而会因上下文切换拖垮整库性能。正确的思路是后端连接池大小要克制宁可让请求在应用层排队也别让数据库层并发爆炸。对于大多数业务系统每个实例20个连接足够。如果实例很多台总连接数要控制在数据库可用连接数以内同时还要给DBA的维护连接留出余量。连接池的maximum-pool-size和数据库的max_connections之间的关系建议按下图这样一个简单模型来思考应用发起的并发请求会先到连接池排队而不是直接打到数据库。连接池是缓冲数据库是最终执行者。池子太大数据库并发高锁竞争就多池子太小应用请求超时用户体验差。所以池子大小要配合响应时间目标来调整而不是随手填个50、100了事。我个人的经验是先设20压测看接口的P95时延如果数据库空闲但客户端经常报connection-timeout说明池子偏小逐步往上加同时观察数据库CPU和锁等待如果数据库CPU已经很高了说明池子太大或SQL写得不行这个方向比反复调参数更值得投入。实战心得与避坑清单最后分享几点我长期实践下来的体会。第一条连接参数不要用默认值一拉到底。JDBC URL里的connectTimeout和连接池的connection-timeout务必显式配好否则在一些极端网络下你面对的是几十个线程集体卡死的局面。第二条getGeneratedKeys拿自增主键这个操作在任何ORM里都要确认映射方式。MyBatis-Plus用IdType.AUTO原生JDBC用RETURN_GENERATED_KEYS这两个写错后面做关联查询时数据对不上。第三条事务不是越多越大越好。一个事务里塞几百条SQL一旦中途失败只能全部回滚不仅浪费已执行的工作数据库锁的持有时间也会让并发直线下降。第四条在PostgreSQL上做CRUD时养成看执行计划的习惯。一行EXPLAIN (ANALYZE, BUFFERS)远比你在Java代码里写各种日志更容易发现问题索引有没有生效一目了然。还有一个小技巧特别值得记PostgreSQL的\watch命令可以每隔几秒重新执行上一条SQL调试计数、监控某张表行数变化时开两个终端一个执行select count(*) from t_user; \watch 1另一个跑测试程序你会直观看到数据是怎么变化上去的。这个技巧在验证批量插入和事务回滚时特别好用。CRUD写起来不难但把每一处细节都处理好服务的稳定性就会有质的区别。这套方案是我自己在多个项目里反复验证过的你照着搭一次以后换任何一张表、任何一个新项目都能很快跑起来。