后端做了这些年,Spring Boot 加 MyBatis 基本是 Java 项目里的固定搭配,但一旦数据库从 MySQL 换成 PostgreSQL,很多以前顺手抄过来的代码就开始出问题:表名突然“找不到了”、时间字段精度对不上、JSON 字段不知道该往哪个类型上映射、批量插入拿不回主键。这篇文章就围绕 Spring Boot 整合 MyBatis 与 PostgreSQL 的完整实战过程,把版本怎么选、配置怎么填、类型怎么映射、分页怎么写、JSONB 怎么处理、报错怎么排查,一条条捋清楚。适合准备从 MySQL 迁到 PostgreSQL 的团队,也适合新项目想直接用这套组合的个人开发者。
1. 项目环境与基础配置思路
1.1 为什么选 PostgreSQL + MyBatis
先说选型思路。Spring Boot 的生态里持久层方案无非就是 Spring Data JPA、MyBatis、JDBCTemplate 这几类。JPA 对单表 CRUD 很省事,但报表类、复杂查询类、动态条件拼 SQL 的场景,一旦上了 JPA 就容易写出“看起来能跑、实际慢成狗”的查询,尤其在数据库切到 PostgreSQL 之后,JPA 自动生成的 SQL 往往不会利用到 PG 特有的能力。MyBatis 的核心优势是“半自动 ORM”,SQL 完全自己控制,方言差异可控,这也是国内团队长期偏爱它的原因。
再说 PostgreSQL 本身。它跟 MySQL 最大的差异在于类型系统更丰富、约束更严格、扩展能力更强。JSONB 原生类型、数组类型、GIN 索引、CTE、窗口函数、全文检索、PostGIS 这些能力,都是实际业务里会用到的。举例来说,一个用户表想存“多组标签”,MySQL 要么拆表要么存逗号分隔的字符串,PG 直接用 text[] 数组或 jsonb 列就解决了,配合 GIN 索引还能做包含查询。
这套组合的适用场景很明确:中小业务系统、报表系统、对 SQL 有强控制需求的项目。如果你只有一个三张表的 demo,JPA 确实更快;如果业务跑起来之后 SQL 越来越复杂、要上 PG 的 JSONB 和窗口函数,那 MyBatis 的掌控感是别的方案给不了的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1.2 版本选型与依赖清单
版本这块我先给结论,再解释为什么。新项目建议 Spring Boot 3.2.x + JDK 17 + mybatis-spring-boot-starter 3.0.x;老项目如果还在 JDK 8,就用 Spring Boot 2.7.x + mybatis-spring-boot-starter 2.3.x。
Spring Boot 3 系列把 javax 换成了 jakarta 命名空间,所以 starter 也必须跟着换。我见过不少人把 Boot 升到 3.x,结果 MyBatis starter 还用的 2.x,一启动直接 ClassNotFoundException,就是因为 javax.sql.DataSource 和 jakarta 体系对不上。PostgreSQL 驱动用 org.postgresql:postgresql,版本号 Boot 父工程统一管理就行,Boot 3.2 对应的驱动版本是 42.6.x,单数驱动也可以直接提。PostgreSQL 服务端版本建议直接上 16 或 17,PG 15 以上的新项目没必要再选旧版。
Maven 依赖如下:
xml复制<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
注意 mybatis-spring-boot-starter 从 2.x 到 3.x 不只是版本号变了,包结构也有调整,配置项总体保持兼容,但依赖传递时该锁的版本要锁死,尤其是在多模块工程里,别让子模块自己拉了一个老版本的 mybatis 核心包。
1.3 数据源与连接池配置
连接池直接用 Spring Boot 默认的 HikariCP,没必要换。HikariCP 对于 PG 的适配很好,配置项也不复杂。我实际生产用的配置大概是这样的:
yaml复制spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb?stringtype=unspecified
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
hikari:
minimum-idle: 2
maximum-pool-size: 10
connection-timeout: 30000
pool-name: MySqlPool
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
URL 里的 stringtype=unspecified 是个容易被忽略但很关键的参数。它的作用是让 JDBC 驱动不强制把字符串参数绑定成 varchar,而是交给 PostgreSQL 自己推断,这能解决很多 json、uuid、数组类型的参数绑定问题,后面讲 JSONB 的时候会再提。map-underscore-to-camel-case 打开之后,数据库里的 created_at 就能直接映射到 Java 的 createdAt,少写一堆 resultMap 的属性映射。
日志配置用 StdOutImpl 只是在开发环境方便排查,生产环境建议改成 org.apache.ibatis.logging.slf4j.Slf4jImpl,避免 SQL 全部打到控制台造成日志量爆炸。
2. 核心细节解析:MyBatis 与 PostgreSQL 整合的关键实操要点
2.1 类型映射:MySQL 换到 PG 最容易被坑的地方
PostgreSQL 的类型比你以前用的 MySQL 要严格得多。MySQL 里一个 varchar 字段塞 '123'、数字字段隐式转字符串这种事很常见,PG 基本不给这种机会。下面这张表是我实际迁移踩过之后整理出来的对照关系:
| MySQL 类型 | PostgreSQL 类型 | Java 映射类型 | 备注 |
|---|---|---|---|
int |
int4 |
Integer |
PG 没有 tinyint,smallint 对应 Short |
bigint |
int8 |
Long |
|
varchar |
varchar |
String |
PG 的 text 类型也很常用 |
datetime |
timestamp |
LocalDateTime |
微秒精度,且不带时区 |
timestamp |
timestamptz |
OffsetDateTime |
带时区,读取会受会话时区影响 |
tinyint(1) |
boolean |
Boolean |
PG 原生布尔类型,别再用 0/1 |
decimal(10,2) |
numeric(10,2) |
BigDecimal |
精度计算比 MySQL 更严格 |
json |
jsonb |
String + TypeHandler |
重点,见 2.3 |
时间类型是重灾区。MySQL 的 datetime 不带时区,用户存进去什么读出来就是什么;PG 的 timestamptz 存储时把时间转成 UTC,查询时会根据 JDBC 会话时区换算回来,如果你在连接串没指定时区、应用容器时间又是 UTC,读出来的值就会比预期少 8 小时。我常用的处理方式是在连接串显式加 ?TimeZone=Asia/Shanghai,同时实体字段对带时区列用 OffsetDateTime 接收,字段精度由 PG 保证到微秒。
布尔类型也是一个习惯问题。MySQL 老项目普遍用 tinyint(1) 表示布尔,换个库之后要么把字段改成 boolean,要么在 SQL 里做 status = 1 的条件判断。改动成本不大,但如果不改,MyBatis 的 resultMap 在 PG 的 boolean 字段上读 Integer 会直接抛异常。
2.2 主键生成策略:serial、identity 与 UUID
PG 生成主键的方式比 MySQL 多,这是好事也是坑。MySQL 的 AUTO_INCREMENT 一行搞定,PG 有三种常见选择。
第一是 serial / bigserial,本质是“序列 + 默认值”,老项目用得很多:
sql复制create table t_user (
id bigserial primary key,
username varchar(64) not null
);
对应序列名是 t_user_id_seq,可以在 MyBatis 的 insert 里用 selectKey 提前取序列值。
第二是 SQL 标准的 identity 列,PG 10 之后支持,我推荐新表都这么建:
sql复制create table t_user (
id bigint generated always as identity primary key,
username varchar(64) not null
);
单条插入时 MyBatis 的 useGeneratedKeys="true" keyProperty="id" 是有效的,PG 的 JDBC 驱动会把返回的主键填回实体,这个我实测没问题。
第三是 UUID。批量插入需要拿回主键的场景,我强烈建议业务主键直接用 UUID 或雪花 ID,别过度依赖数据库的自增序列。原因是 MyBatis 对批量插入的 useGeneratedKeys 支持在 PG 上不可靠,一条多 values 的 insert 加上 returning 很难把每个 id 再回填到对应对象上。与其在框架层面死磕,不如插入前在 Java 端生成 UUID,既省一次数据库交互,也方便分库分表。
序列和 identity 还有一个注意点:generated always as identity 不允许手动插入 id,而 serial 可以,这两种语义在迁移数据、同步数据时差异很大,建表前要根据业务选好。
2.3 JSONB 字段的读写:TypeHandler 是绕不开的一环
PostgreSQL 的 jsonb 类型是它区别于 MySQL 的核心卖点之一,支持 GIN 索引,支持 @> 包含查询、->/->> 取值等操作符。但 MyBatis 默认不认识 jsonb,不加处理直接 select 会得到一个 org.postgresql.util.PGobject,映射到 String 会报错。
最简单的做法是依赖前面提到的 stringtype=unspecified 参数,插入时直接把 String 参数绑定给 jsonb 列,驱动会自动处理,不需要写 TypeHandler。读取时还是要处理,所以我更建议直接写一个 TypeHandler,一劳永逸:
java复制@MappedTypes(String.class)
@MappedJdbcTypes(JdbcType.JAVA_OBJECT)
public class JsonbTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setObject(i, parameter, Types.OTHER);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return rs.getString(columnName);
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return rs.getString(columnIndex);
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return cs.getString(columnIndex);
}
}
然后在 MyBatis 配置里注册:
yaml复制mybatis:
type-handlers-package: com.example.demo.handler
实体字段用 String profileJson 接收,XML 里 normal 的 #{profileJson} 就能读写 jsonb 列了。存入时 PG 会做 jsonb 合法性校验,格式不对直接在数据库层拦截,这一点比 MySQL 的 json 类型更严格,传非法 JSON 会直接抛异常。
如果要做 JSONB 的查询,比如查 profile->>'nickname' = '小明',SQL 里要小心 XML 的 > 符号转义。我习惯用 CDATA 包起来,比如:
xml复制<select id="queryByProfile" resultType="com.example.demo.entity.UserEntity">
<![CDATA[
select * from t_user
where profile -> 'nickname' ->> 'nickname' = #{nickname}
]]>
</select>
实际使用中 jsonb 最舒服的场景是“动态属性”,比如用户扩展信息、商品规格参数。方案定下来之后,应用模型能少建好多张“扩展属性表”。
2.4 分页方案选型:手写 LIMIT 比插件更可控
MySQL 那边 PageHelper 用得很多,因为 MySQL 的 LIMIT 语法简单,PageHelper 拦截器也好用。PG 这块其实也一样支持,但我建议优先考虑手写分页。
理由很简单:PG 原生就是 LIMIT #{offset}, #{limit},不对,PG 语法是 LIMIT #{limit} OFFSET #{offset},手写非常直观,不需要借助 PageHelper 的物理分页插件。自己写分页还能顺便控制排序和 count 查询,不会出现 PageHelper 在复杂 SQL 里 count 生成错误的情况。
xml复制<select id="pageQuery" resultType="com.example.demo.entity.UserEntity">
select id, username, nickname, created_at
from t_user
<where>
<if test="username != null and username != ''">
username like concat('%', #{username}, '%')
</if>
</where>
order by id desc
limit #{limit} offset #{offset}
</select>
分页参数名称我用 limit 和 offset,配合 Page 对象传参就行。有一点要特别注意:offset 分页在数据量大、翻页深之后性能会退化,PG 建议用 keyset 分页也就是 where id < 上页最后一条 id 来避免大 offset。如果列表是按主键倒序排的,where id < ? order by id desc limit 20 这种方式比 offset 快一个数量级,接口层稍微改一下就能支持“下一页”式加载。
3. 实操过程与核心环节实现
3.1 从零搭建可运行的项目骨架
为了把上面的理论落地,我建了一个最简单的用户管理 demo。项目结构如下:
text复制src/main/java/com/example/demo
├── DemoApplication.java
├── controller/UserController.java
├── service/UserService.java
├── mapper/UserMapper.java
├── entity/UserEntity.java
└── handler/JsonbTypeHandler.java
src/main/resources
├── application.yml
└── mapper/UserMapper.xml
初始化表结构:
sql复制create table t_user (
id bigint generated always as identity primary key,
username varchar(64) not null unique,
nickname varchar(64),
email varchar(128),
profile jsonb,
status smallint default 1,
created_at timestamptz default now()
);
实体类:
java复制public class UserEntity {
private Long id;
private String username;
private String nickname;
private String email;
private String profileJson;
private Integer status;
private LocalDateTime createdAt;
// getter/setter 省略
}
注意 created_at 是 timestamptz,用 OffsetDateTime 接收更严谨,但为了横向对比 MySQL 的习惯,demo 里我用 LocalDateTime 也能读,因为它存的 now() 带时区,JDBC 会根据会话时区转成本地时间。这里我明确说一句:生产环境的时间字段,建表我建议全用 timestamptz,实体统一 OffsetDateTime,能省掉一堆时区换算的麻烦。demo 为了演示两种写法的差异,保留了 LocalDateTime。
3.2 核心 CRUD 与批量插入的 XML 实现
Mapper 接口:
java复制@Mapper
public interface UserMapper {
int insertUser(UserEntity entity);
UserEntity findById(Long id);
List<UserEntity> pageQuery(@Param("username") String username,
@Param("limit") int limit,
@Param("offset") int offset);
int updateUser(UserEntity entity);
int batchInsert(List<UserEntity> list);
}
XML 里最关键的就是单条插入,用 useGeneratedKeys 拿回自增主键:
xml复制<insert id="insertUser" parameterType="com.example.demo.entity.UserEntity"
useGeneratedKeys="true" keyProperty="id">
insert into t_user (username, nickname, email, profile, status)
values (#{username}, #{nickname}, #{email}, #{profileJson}::jsonb, #{status})
</insert>
#{profileJson}::jsonb 这个写法是显式让 PG 把参数转成 jsonb,配合 TypeHandler 之后非常稳。update 用动态 set,只更新非空字段:
xml复制<update id="updateUser" parameterType="com.example.demo.entity.UserEntity">
update t_user
<set>
<if test="nickname != null">nickname = #{nickname},</if>
<if test="email != null">email = #{email},</if>
<if test="profileJson != null">profile = #{profileJson}::jsonb,</if>
</set>
where id = #{id}
</update>
批量插入我推荐两种做法。数据量在几十条以内,直接 foreach 拼一条 SQL,这是最直观的方式:
xml复制<insert id="batchInsert">
insert into t_user (username, nickname, email, profile, status)
values
<foreach collection="list" item="item" separator=",">
(#{item.username}, #{item.nickname}, #{item.email}, #{item.profileJson}::jsonb, #{item.status})
</foreach>
</insert>
数据量再大,比如一次性导入几千条,foreach 拼出来的 SQL 包体积会非常大,PG 服务端甚至可能因为报文过大报错。这时候应该用 MyBatis 的批量执行器:
java复制SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
for (UserEntity item : list) {
mapper.insertUser(item);
}
sqlSession.flushStatements();
注意批量拿回主键这个需求:上面两种方式,PG 都不会像 MySQL 那样优雅地把每个自增 id 回填到实体里,所以在需要主键的业务场景,最好在 Java 端生成 UUID 主键,别纠结这个点。
3.3 基于注解的常见查询写法
简单查询用注解确实清爽,不需要开 XML 文件。比如按用户名查询:
java复制@Select("select * from t_user where username = #{username}")
UserEntity findByUsername(@Param("username") String username);
但注解方式碰到两个问题就很痛苦:一是 SQL 长了之后字符串拼接读起来困难;二是动态条件只能用 <script> 标签包起来,可读性很差。我的习惯是:单表简单查询用注解,三个字段以上的条件组合、动态更新、多表 join 一律走 XML。
注解里还有一个 PG 专属的小坑:@Select 里写 ::jsonb、::int8 这类 PG 类型转换语法没问题,但如果 SQL 里出现了 < 符号,比如时间比较,注解方式会直接报 XML 解析错误,因为注解内容本身要当 XML 解析。这种情况下要么改成 < 转义,要么直接用 XML 文件加 CDATA,我一律选择后者。
3.4 事务管理与动态 SQL 的最佳实践
事务直接用 Spring 的 @Transactional,注意加 rollbackFor = Exception.class。Spring 默认只对 RuntimeException 回滚,如果你在服务里抛了受检异常,不加 rollbackFor 事务是不会回滚的,这个已经是老生常谈了,但真出问题时十次里有八次是这里。
java复制@Service
public class UserService {
@Transactional(rollbackFor = Exception.class)
public void createUserWithProfile(UserEntity user) {
userMapper.insertUser(user);
// 其他业务操作,比如写操作日志
}
}
事务的坑在同一个类里 method A 调用 method B,B 的事务注解会失效,因为走的是 this 调用,没有经过代理对象。解决方案是拆到另一个 Service 类,或者自己注入代理对象。
动态 SQL 方面,PG 跟 MySQL 没太大区别,where、set、trim、if、choose、foreach 都是 MyBatis 通用的。这里提醒一个 PG 场景:做多条件模糊查询时,PG 的 concat 函数比 || 更稳,因为 concat 遇到 null 会返回空串而不是 null,条件里有 null 时不会把整个 where 条件搞挂。
xml复制<select id="searchUsers" resultType="com.example.demo.entity.UserEntity">
select * from t_user
<where>
<if test="keyword != null and keyword != ''">
(username like concat('%', #{keyword}, '%')
or nickname like concat('%', #{keyword}, '%'))
</if>
<if test="status != null">
and status = #{status}
</if>
</where>
</select>
4. 常见问题与排查技巧实录
4.1 连接数据库失败:驱动、认证、防火墙
最常见的问题是启动时 No suitable driver found,原因就两类:一是 spring-boot-starter 里没引入 postgresql 驱动,二是引入了但依赖冲突导致驱动没被加载。解决方式很简单,检查依赖树 mvn dependency:tree,看看 postgresql 是否被某个模块 exclude 掉了。
认证失败报错 FATAL: password authentication failed for user "xxx",这个要检查 PostgreSQL 的 pg_hba.conf 认证方式。PG 15 之后默认把密码认证改成 scram-sha-256,如果连接串传输的密码加密方式不匹配会出现连不上。另外还有一个小坑:localhost 和 127.0.0.1 在 pg_hba.conf 里是两条不同的记录,明明密码对了,用 127.0.0.1 连不上,换 localhost 就好了,或者两条都配上。
防火墙层面,云服务器要放行 5432 端口,这个简单但真的很常踩。另一个容易忽略的是:PG 默认只监听 localhost,要允许远程连接必须改 listen_addresses = '*',不然换成生产 IP 就是连不上。
4.2 PostgreSQL 大小写敏感导致的“表不存在”
这个话题我在 2.1 提过,但值得单独拉出来说,因为它产生的报错极具迷惑性:relation "T_USER" does not exist。MySQL 在 Linux 下表名是区分大小写的,很多人习惯建表用大写或驼峰命名,然后 SQL 里也带大写。PG 对不带引号的标识符统一折叠成小写,你用 T_USER 去查 t_user 的表,会直接报不存在。
解决方式很粗暴:建表、建字段、写 SQL,全部统一小写下划线命名,这是 PG 社区的通用规范,也跟 Java 驼峰转下划线天然对应。打开 map-underscore-to-camel-case 之后,nickname_eng 这种数据库字段能自动映射到 nicknameEng。
还有 MySQL 的转义字符是反引号,select * from \user`` 这种 SQL 在 PG 里会直接语法错误。PG 的转义符是双引号,但表名用了双引号就代表“严格区分大小写”,等于自己给自己挖坑。统一小写之后这些转义都可以不写。
4.3 时间字段类型不匹配与“无限日期”
时间字段的报错大致分两类。一类是 JDBC 驱动类型与实体类型不匹配,比如 timestamptz 列读出来默认是 OffsetDateTime,实体用 LocalDateTime 接收,运行时会报 class cast 或类型转换异常。另一类是 PG 的 timestamp 范围是 4713 BC 到 294276 AD,偶尔因为框架初始化或历史数据写入了一个 -infinity 或 infinity 特殊值,Java 端无法把它映射成 LocalDateTime。
我处理这类问题的经验是:建表规范定为 created_at timestamptz default now(),实体用 OffsetDateTime。老数据如果已经出现 -infinity,先 SQL 排查一遍再决定是修数据还是写特殊 TypeHandler,别在应用层硬抗。
4.4 批量插入性能
批量插入慢的现象很常见:几千条数据插了半天。排查思路分两条线。一条是确认是不是一条一条 insert 的网络往返,另一条是检查 SQL 拼接后包体是否过大。
MyBatis 的 ExecutorType.BATCH 会走 JDBC 的 addBatch,PG 驱动从 42.x 开始支持 reWriteBatchedInserts=true 的 URL 参数,开启后驱动会自动把批处理改写成多 values 的 insert,减少解析次数,性能提升非常明显。
yaml复制url: jdbc:postgresql://localhost:5432/mydb?stringtype=unspecified&reWriteBatchedInserts=true
如果你用的是 foreach 拼 SQL 的方式,一条 SQL 里几千个 values 反而是另一个极端,PG 需要一次性解析一个大语句,内存和 CPU 都不低。实测下来单批 500 到 1000 条比较合适,再往上收益不大,反而可能因为 SQL 过长触发服务端 max_allowed_packet 类的限制。
4.5 LIKE 模糊匹配与 ILIKE 的差异
PG 的 LIKE 是区分大小写的,MySQL 的 LIKE 在大多数排序规则下不区分。业务里做用户名模糊搜索,用户在搜索框输大写字母,在 PG 里可能搜不到小写记录,这种“查不到”的 bug 很隐蔽。
解决方案是用 PG 的 ILIKE:
sql复制select * from t_user where username ilike concat('%', #{keyword}, '%');
另一个更推荐的做法是让 PG 字段统一走 lower() 表达式,或者对搜索字段建 pg_trgm 索引配合 ILIKE,这样还能走索引而不是全表扫。像用户名搜索这种高频场景,直接在表上建 GIN 索引:
sql复制create extension if not exists pg_trgm;
create index idx_user_username_trgm on t_user using gin (username gin_trgm_ops);
换成 ILIKE 之后 SQL 能用到这个索引,搜索性能远比 like '%xxx%' 全表扫好。
4.6 SQL 日志打印与慢 SQL 排查
开发阶段把 SQL 打出来看参数是否绑对,生产阶段定位慢 SQL,这两件事都离不开日志配置。MyBatis 打印 SQL 的参数是 mybatis.configuration.log-impl,设成 StdOutImpl 会直接打印到控制台,生产环境设成 Slf4jImpl 走 logback。
但 MyBatis 打出来的日志只包含“最终执行的 SQL 和参数”,不包含数据库端的执行时间和执行计划,所以慢 SQL 排查要到 PG 侧去看。PG 没有 MySQL 那种开箱即用的慢查询日志表,常规做法是启用 pg_stat_statements 扩展,然后查看排名靠前的 SQL:
sql复制create extension if not exists pg_stat_statements;
select query, calls, mean_exec_time, total_exec_time
from pg_stat_statements
order by total_exec_time desc
limit 10;
单条 SQL 慢的话,直接在客户端工具里 explain analyze 一把,看是不是走了全表扫。比如上面的用户名搜索,没建 GIN 索引时 explain analyze 会看到 Seq Scan on t_user,建完索引之后变成 Bitmap Index Scan,性能差距在数据量上来之后是数量级的。
这套组合用下来的体会是:PostgreSQL 对类型的要求比 MySQL 严格很多,以前靠隐式转换蒙混过关的 SQL,在 PG 下基本藏不住。整合 MyBatis 和 PG,不只是换一个数据源,而是逼着你把每一列的类型、每一条 SQL 的返回类型都想清楚。最后再分享一个实用的组合拳:JDBC URL 里的 stringtype=unspecified 加上 map-underscore-to-camel-case,再配一个 JsonbTypeHandler,这能解决日常开发里八成以上奇奇怪怪的类型和映射问题。剩下的两成,基本都在常见问题清单里能找到对应解法。
