做后端这几年,Spring Boot + MyBatis + PostgreSQL 这套组合我碰到的频率越来越高。一部分是接手老项目时它已经跑了好几年,另一部分是新项目在评审阶段就被团队定为默认技术栈——Spring Boot 负责应用骨架,MyBatis 管 SQL,PostgreSQL 做数据底座。有人觉得 MyBatis 是“过气”的 ORM,有人觉得 PostgreSQL 只是“高级一点的 MySQL”,但真要把三件事串起来落地,中间有不少细节值得认真过一遍。这篇内容就是给打算用这套组合、或者已经在用但经常被诡异问题卡住的人准备的,我不会只贴“能跑通的代码片段”,而是把每段配置、每个 SQL、每个报错背后的原因都讲透,尽量让你读完可以直接在自己的项目里复现,也能解释清楚为什么这么写。
文章会用一个“内容平台的文章标签系统”作为贯穿案例,从零建库建表,到写 Mapper、XML,再到 PostgreSQL 特有类型和上线前容易踩的坑,按正常项目的推进顺序走一遍。
1. 先交代清楚:为什么这套组合到现在还值得写
1.1 我的选型判断:什么项目适合这套组合
每隔一段时间就有人争论 JPA 和 MyBatis 谁更好。我的观点很直白:如果你的业务里有大量复杂查询、报表统计、多表联查,并且 SQL 需要由人精确控制,那 MyBatis 比 JPA 合适得多。JPA 适合数据模型规整、对查询性能要求不极致、希望少写 CRUD 的管理系统;但内容平台、订单系统、统计后台这类场景,SQL 往往非常长且经常要调优,MyBatis 的 XML 可以把 SQL 和 Java 代码隔离,DBA 也能直接拿走 SQL 去分析,协作成本低不少。
PostgreSQL 这边,除了大家常说的“开源界的 heavyweight”,一个很重要的吸引力是它同时具备了关系型数据库的严谨和部分 NoSQL 的能力。比如可以直接在表里用 JSONB 存非结构化数据,用数组存轻量级集合,用 tsvector 做全文检索,这样很多场景不需要额外引入 Elasticsearch 或 MongoDB 就能撑住初期业务。而且它对 SQL 标准的支持非常彻底,窗口函数、WITH 语法、约束机制都做得比老牌 MySQL 细致,数据完整性好控制。Spring Boot 提供了自动配置能力,生产环境跑这套组合是完全可靠的。
1.2 版本组合怎么定:不要无脑追新
版本坑我踩过一次之后,现在每次写整合都会先把版本组合钉死。Spring Boot 2.7.x 是目前很多企业存量项目的主流版本,对应的 mybatis-spring-boot-starter 用 2.3.x 这一代;如果项目直接上 Spring Boot 3.x,则必须把 mybatis-spring-boot-starter 换成 3.x 版本,因为它内部依赖的 mybatis-spring 需要适配 Spring 6 和 JDK 17。我下面的示例基于 Spring Boot 2.7.18 + mybatis-spring-boot-starter 2.3.2 + PostgreSQL JDBC 42.x,这也是我建议大多数业务项目使用的组合。
为方便对照,我把版本选择的逻辑整理如下:
| 项目情况 | 推荐版本组合 | 主要考虑 |
|---|---|---|
| 存量业务,JDK 8/11 | Spring Boot 2.7 + mybatis-spring-boot-starter 2.3 | 升级成本低,依赖生态成熟 |
| 全新项目,JDK 17/21 | Spring Boot 3.x + mybatis-spring-boot-starter 3.x | 拥抱新特性,注意驱动和配置差异 |
| 特殊合规改造 | 按公司内部基线锁定 | 用不到新特性就不要乱动版本 |
PostgreSQL 驱动不需要显式写版本,Spring Boot 的 dependency management 已经帮你管理好了。只有当你有特殊需求,比如要修复某个驱动 bug,才需要手动覆盖。版本选型是第一道关,选错后面会出现一堆“看起来跟代码无关”的报错,排查成本极高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初始化工程:用一个标签表把最小闭环跑通
2.1 引入依赖时需要避开的坑
最基础的一步是建 Spring Boot 工程,引入 web、mybatis starter 和 PostgreSQL 驱动。pom.xml 里长这样:
xml复制<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>2.3.2</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
这里有两个容易被忽略的点。第一,postgresql 依赖 scope 设置为 runtime 没问题,但如果你在单元测试里想手动加载 Class.forName("org.postgresql.Driver"),那就要把 scope 改成 compile 或用 test 作用域另引。第二,不要同时引入 mybatis-spring-boot-starter 和 mybatis-plus-boot-starter,这两套在 mapper 扫描、SqlSessionFactory 初始化时会互相打架。如果你用了 MyBatis-Plus 的代码生成器生成了一套 Mapper,又手写了一套原生 MyBatis,运行时会因为 namespace 冲突或者 Bean 重复定义报出一堆莫名其妙的错误。
2.2 application.yml:每个参数都值得看懂
数据源配置是整合里的头号难点,很多人从 MySQL 迁移过来后只改了驱动类名和 URL,结果各种服务启动失败或数据查不出来。我先给一份我常用的最小配置:
yaml复制spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/blog?currentSchema=public&stringtype=unspecified
username: postgres
password: postgres
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1800000
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.blog.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
逐段说明一下。driver-class-name 不能从 MySQL 的 com.mysql.cj.jdbc.Driver 平替成 postgresql 就完事,PostgreSQL 的驱动类名是 org.postgresql.Driver,拼错后启动时会说找不到 Driver。然后是 URL,currentSchema=public 是 PostgreSQL 特有参数,它的作用是切换 schema 搜索路径。如果你不在 URL 里指定当前 schema,后面每张表都要带 schema 前缀,比如 public.article_tag,否则 MyBatis 生成的 SQL 会去找默认 schema 里的表。第三是 stringtype=unspecified,这个是针对 PostgreSQL 比较隐蔽的参数。PostgreSQL 对类型的隐式转换非常严格,尤其 JSONB 字段,后面章节会详细讲,这里先记住:加上它之后,JDBC 驱动在设置字符串参数时会尽量让数据库自行推断类型,很多因为类型不匹配导致的报错都能缓解。
type-aliases-package 是用来给实体类起别名的,配置后 XML 的 resultType 可以直接写类名小写或大写,不用写全限定名。map-underscore-to-camel-case 设置为 true 后,数据库列名 tag_name 能自动映射到 Java 字段 tagName,省去写大量 resultMap。不过一旦你在 XML 里显式定义了 resultMap,这个驼峰映射就不会作用于该 resultMap 的配置,你需要自己在 resultMap 中写清楚。
2.3 建表、实体、Mapper、XML:跑通第一句查询
表结构我设计得稍微复杂一点,可以把后面的高级特性都带出来:
sql复制CREATE TABLE article_tag (
id BIGSERIAL PRIMARY KEY,
tag_name VARCHAR(64) NOT NULL UNIQUE,
article_count INT NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
related_tags BIGINT[] NOT NULL DEFAULT ARRAY[]::bigi
