
前段时间接手了一个重查询的服务端项目业务模型不算复杂但查询压力很大——多条件组合筛选、聚合统计、报表导出几乎每个接口背后都是一段不能含糊的 SQL。团队里有人提议直接用 JPA 把模型铺开靠注解和派生方法快速开发。我最后还是定了 Spring Boot 整合 MyBatis 与 PostgreSQL 的组合并在项目里完整跑通了从建表、配置、Mapper 编写到分页批量和事务的整个链路。这篇文章就把这套组合的选型逻辑、工程搭建细节、常见坑位和排错思路一次讲透所有代码和配置都是项目里验证过的。适合正在技术选型、或者已经在用这套技术栈但被各种隐藏问题卡住的朋友。1. 选型复盘为什么是 MyBatis 加 PostgreSQL而不是 JPA 一条路走到底1.1 PostgreSQL 那批“平时用不上、用到就真香”的能力很多人对 PostgreSQL 的印象停留在“开源关系型数据库”觉得和 MySQL 差不多。真正深入用下去会发现它有几个能力在业务复杂起来之后是刚需。第一是JSONB类型。业务里很多字段天生就是半结构化的——用户扩展资料、商品规格、日志上下文。以前要么拆成一堆子表要么用 varchar 存一段没人敢动的文本。JSONB 可以直接在数据库里做查询和更新比如下面这种过滤SELECT * FROM app_user WHERE profile {level: vip};这种能力让表设计可以先把核心关系定下来把“以后可能会变”的字段丢进 JSONB等真的需要检索时再加 GIN 索引不用动表结构。第二是窗口函数和 CTE。报表类的统计几乎每一条都能用窗口函数写得很干净。比如“每个用户最近一笔订单”这类需求用ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)一条 SQL 就解决Java 里不需要再按用户分组做内存聚合。第三是ON CONFLICT的 upsert 语义。很多数据库的“存在则更新、不存在则插入”写法各不相同PostgreSQL 的语法非常直接INSERT INTO app_user (username, email, status) VALUES (alice, aliceexample.com, 1) ON CONFLICT (username) DO UPDATE SET email EXCLUDED.email, status EXCLUDED.status;在同步任务、消息消费这类场景里这句 SQL 能省掉一整段“先查再决定插入还是更新”的 Java 代码还能避免并发下查改之间的竞态。1.2 MyBatis 的掌控力在处理动态条件时的真实体验选 MyBatis 而不是 JPA核心不是因为 JPA 慢而是因为在这个项目里SQL 本身就是需求说明书。多条件筛选、排序、分组、分页这些逻辑如果让 ORM 自动生成调试时你看到的是它输出的一大段“我猜你是这个意思”的 SQL出问题时你还得先学会读懂 ORM 的生成规则。MyBatis 的思路正好反着SQL 是你自己写的ORM 只负责参数绑定和结果映射。动态 SQL 用if、where、foreach直接写在 XML 里select idsearchUsers resultTypeAppUser SELECT * FROM app_user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset} /select这种写法的好处很实际任何一名团队成员打开 XML 就能看懂查询条件拿到数据库客户端里把参数替换掉就能复现问题SQL 优化器做了什么、索引有没有生效一目了然。对于重查询的项目这种透明性带来的维护成本下降非常明显。1.3 三者分工谁负责装配、谁负责映射、谁负责数据这套组合的职责边界我建议团队从一开始就对齐Spring Boot 负责依赖注入、自动配置、事务边界和接口层MyBatis 负责 Java 对象与 SQL 之间的参数绑定和结果映射PostgreSQL 负责数据存储、约束、索引和能下推的计算。所谓“能下推的计算”是我一直强调的原则能写进 SQL 的聚合、过滤、排序绝对不要在 Java 层用 Stream 再算一遍。曾经有一个统计接口最初版本是把几千条明细拉到内存里分组求和接口耗时 800ms改成一个带GROUP BY的 SQL 之后耗时降到 40ms而且数据库和内存的开销都小了一个量级。PostgreSQL 的查询规划器质量足够好把计算交给它比在应用层手工优化要可靠得多。2. 工程搭建依赖版本矩阵、JDBC 参数与连接池调优2.1 pom.xml 依赖清单与版本对应关系先看最关键的版本矩阵。Spring Boot 3.x 强制要求 Java 17 及以上对应的 MyBatis 官方启动器也升到了 3.0.x。如果团队还在 Java 8 的存量项目里就得老老实实停在 Spring Boot 2.7.x 和 mybatis-spring-boot-starter 2.3.x这套组合在维护项目里验证过两年多没有出现兼容性问题。Java 版本Spring Boot 版本mybatis-spring-boot-starter 版本适用场景173.3.x3.0.x新项目推荐82.7.x2.3.x存量项目维护82.4.x2.1.x更老的遗留系统pom.xml 里的依赖就这些三件套加一个 PG 驱动dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency一个建议PostgreSQL 驱动的版本最好由 Spring Boot 的 dependency management 统一托管不要手工固定一个旧版本。驱动升级里大部分都是查询性能和安全修复跟着父 POM 走最省心。2.2 application.yml 里决定成败的配置项配置是这套组合里最容易出问题的地方很多报错都是配置少一行引起的。下面是我现在项目里稳定运行的一份配置spring: datasource: url: jdbc:postgresql://localhost:5432/mydb?stringtypeunspecifiedreWriteBatchedInsertstrue username: app_user password: change_me driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl逐项解释容易踩坑的几处。JDBC URL 里的stringtypeunspecified是给 JSONB 字段用的。PostgreSQL 对参数类型校验很严格Java 传一个 String 过来SQL 里没写类型转换时如果是 jsonb 列就会报“expression is of type character varying”。加了stringtypeunspecified之后驱动会把字符串参数按列的目标类型解析插入 JSONB 就顺畅了。这个参数在开发环境不配置也能混过去因为很多时候数据恰好能被隐式转换一到生产某些特殊数据就直接报错最好一开始就写上。reWriteBatchedInsertstrue是 PG JDBC 驱动在 42.x 版本里提供的批量写优化参数把一坨独立的 INSERT 在驱动层重写成多 VALUES 的复合语句批量插入性能能提升好几倍配合后面要讲的 BATCH 执行器效果尤其明显。mybatis.configuration.map-underscore-to-camel-case这个开关几乎是必开的。PostgreSQL 的字段命名惯例是下划线created_atJava 成员变量是驼峰createdAt不开这个开关你就要在 resultMap 里把每个字段手动对齐一遍工作量瞬间翻倍。2.3 HikariCP 参数到底按什么标准调Spring Boot 2.x 之后默认的连接池就是 HikariCP理论上不用特意加依赖。但很多人对它的参数是“看心情调”我建议按一个大概公式先算一遍再结合监控微调。参数推荐值考虑因素maximum-pool-size20常规 Web 服务核心公式连接数 峰值 QPS × 单次查询平均耗时秒minimum-idle5和 maximum 之间留出伸缩空间即可connection-timeout30000默认就是 30 秒一般不用改idle-timeout600000注意必须小于 max-lifetimemax-lifetime1800000默认 30 分钟数据库或网络中间件有主动断连策略时一定要改小关于连接数的思考方式假设峰值 500 QPS单次查询平均 20ms那理论上只需要 500 × 0.02 10 条连接就能支撑再留 1.5 到 2 倍余量20 是个合理的数字。切忌盲目把连接池调到 100连接太多反而会把数据库的并发压力拉高锁等待变多整体吞吐不升反降。3. 从建表到接口跑通类型映射、Mapper XML 与自增主键3.1 建表时就要想清楚的类型选择先放一张我常用的建表语句覆盖大部分业务表的基础结构CREATE TABLE IF NOT EXISTS app_user ( id BIGSERIAL PRIMARY KEY, username VARCHAR(64) NOT NULL, email VARCHAR(128), profile JSONB, status SMALLINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_app_user_created_at ON app_user (created_at DESC);类型选择上下面这个对应表是踩过几次坑后固定下来的PostgreSQL 类型Java 类型说明BIGSERIALLong自增主键的标准选择VARCHAR / TEXTStringVARCHAR 限制长度TEXT 不限制按语义选BOOLEANBoolean别用 SMALLINT 代替布尔TIMESTAMPTZOffsetDateTime / LocalDateTime带时区的时间戳第 5 节专门讲坑JSONBString配合类型转换和 TypeHandler 使用NUMERICBigDecimal金额字段永远不要用 doubleBYTEAbyte[]文件二进制用真大量存储另说特别注意BIGSERIAL和GENERATED ALWAYS AS IDENTITY的区别。前者是 PostgreSQL 老牌自增方案基于序列后者是 SQL 标准里的 identityPG 10 以后支持语义上更严谨禁止手动指定 id。默认用 BIGSERIAL 就够了两者在 MyBatis 的useGeneratedKeys上都兼容。3.2 实体类、Mapper 接口与 XML 的命名铁律实体类就是一个普通的 POJOpublic class AppUser { private Long id; private String username; private String email; private String profile; private Integer status; private OffsetDateTime createdAt; private OffsetDateTime updatedAt; // getter / setter 略 }Mapper 接口只定义方法和参数具体 SQL 放 XMLMapper public interface AppUserMapper { int insertUser(AppUser user); AppUser selectById(Long id); ListAppUser searchUsers(Param(username) String username, Param(status) Integer status, Param(limit) int limit, Param(offset) int offset); }XML 的第一行 namespace 必须和 Mapper 接口全限定名完全一致这一点是无数人第一次写 MyBatis 时的翻车点mapper namespacecom.example.demo.mapper.AppUserMapper如果 namespace 对应不上运行时会直接抛Invalid bound statement (not found)。排查顺序很固定先看接口的全限定名再看 XML 文件是否在mapper-locations配置的路径下最后确认 XML 文件名和接口名一致。我见过最多的情况是 XML 文件放错目录starter 扫描不到白折腾半小时。3.3 动态 SQL 的正确姿势动态 SQL 的核心是where、if、foreach这几个标签的组合。where标签会自动处理前面多余的AND比手工写WHERE 11干净得多select idsearchUsers resultTypeAppUser SELECT * FROM app_user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset} /select一个细节模糊查询我写的是CONCAT(%, #{username}, %)而不是% #{username} %。原因是 PostgreSQL 对参数和字符串常量的拼接行为非常严格后者很容易产生类型或语法问题用 CONCAT 一劳永逸。另外一个容易被忽略的点if判断空字符串时很多参数默认是空串而不是 null所以username ! null and username ! 这种双判断是必要的否则前端传一个空串过来条件照样进 SQL等于没过滤。3.4 拿回自增主键的两种方法插入后拿自增主键是刚需。第一种是 MyBatis 的useGeneratedKeysinsert idinsertUser useGeneratedKeystrue keyPropertyid keyColumnid INSERT INTO app_user (username, email, profile) VALUES (#{username}, #{email}, #{profile}::jsonb) /insert这个写法依赖 JDBC 驱动的getGeneratedKeys()PG 驱动支持得不错插入成功后user.getId()就有值了。注意keyProperty写的是实体类的属性名idkeyColumn写的是数据库列名id别写反。第二种是利用 PostgreSQL 的RETURNING子句。比如你需要插入后不光拿 id还要拿数据库端生成的默认值insert idinsertUser INSERT INTO app_user (username, email) VALUES (#{username}, #{email}) RETURNING id, created_at /insert配合selectKey把返回结果映射回对象或者把它写成一个带返回结果的方法。使用RETURNING的优点是返回哪个字段完全由你决定缺点是 SQL 更复杂一点。经验是只拿 id 用第一种需要拿数据库默认值、或者插入逻辑里可能触发数据库端的函数生成字段时用第二种。4. 进阶功能分页、批量写入与事务别等上线才补4.1 offset 分页在深页码时的隐患与 keyset 替代方案分页最常见的写法是LIMIT #{limit} OFFSET #{offset}。数据量小的时候没问题一旦表里有几十万行、用户翻到第 50 页offset 大约是 2500数据库还是得把前 2500 行全部扫一遍再丢掉后面的页面越来越慢。如果确认使用场景就是“上一页/下一页”而不是任意跳页我更推荐 keyset 分页也叫“基于游标”的方式SELECT * FROM app_user WHERE created_at #{lastCreatedAt} ORDER BY created_at DESC LIMIT 20;第一页先按普通方式查把最后一条记录的created_at记住下一页把它作为边界条件传进来。这种写法无论翻到多深都永远只扫 20 行性能恒定。代价是不能直接跳页适合 App 端无限下拉、Feed 流这类场景。如果业务确实需要跳页并且数据量不大直接用 offset 分页也无可厚非不要为了“先进”把方案搞复杂。至于社区里的分页插件本质也是帮你生成一条 count 查询和 limit/offset SQL在 PG 上需要确认它生成的 count 语句在表数据量大时会不会做全表扫描必要时还是手工优化比较稳。4.2 批量插入的三种姿势与实测对比批量写入是 PostgreSQL 上性能和坑位都极其明显的一个点。三种常见姿势按实际效果排序。第一种XML 里用foreach拼多条 VALUESinsert idbatchInsertUsers INSERT INTO app_user (username, email, status) VALUES foreach collectionlist itemitem separator, (#{item.username}, #{item.email}, #{item.status}) /foreach /insert这是一条 SQL最直观。缺点是拼接的 SQL 会随着条数变长几百条以内问题不大超过千条要控制批次大小一般 500 条一批比较稳。第二种把 MyBatis 的默认执行器切成 BATCH。Spring Boot 里最快的改法是在配置里加一行mybatis: configuration: default-executor-type: BATCH这个配置会让所有的 Mapper 操作都走批量执行器JDBC 层面复用 PreparedStatement编译成本和网络往返大幅下降。注意两个副作用一是useGeneratedKeys在 BATCH 模式下的表现不稳定个别驱动版本会丢掉生成的主键二是批量执行器的语句是攒到 flush 才真正提交SQL 报错的位置和实际执行时间对不上排查时要心里有数。第三种靠 JDBC URL 里的reWriteBatchedInsertstrue把驱动收到的多条单行 INSERT 重写成多 VALUES。它和第二种是配合关系BATCH 执行器负责把多条语句交给驱动驱动再重写成复合 INSERT两步叠加才是 PostgreSQL 批量写的最终答案。方案实现成本单批 500 条耗时参考坑点foreach 拼 SQL低中等SQL 过长、占内存简单场景够用ExecutorType.BATCH低较快主键获取不稳定异常定位难BATCH reWriteBatchedInserts低最快提升数倍需要版本支撑和参数配置在一个实际导入场景里测过5000 条数据逐条 insert 大约 8 秒foreach 拼 500 条一批大约 1.2 秒打开 BATCH 加reWriteBatchedInserts之后最快能到 0.6 秒左右。导入类接口建议直接上第三种方案。4.3 事务注解的边界问题与常见事故Spring 的Transactional和 MyBatis 的整合是自动完成的Spring 把当前线程绑定的事务连接交给 SqlSession事务提交回滚由 Spring 统一管理业务代码里基本不需要感知底层细节。但这几年的使用体验里有三个边界问题反复出现。第一Transactional默认只在 RuntimeException 和 Error 时回滚。方法里 catch 住了一个受检异常然后继续执行等看到数据不对想回滚已经晚了。如果确实有“只要抛异常就回滚”的需求要在注解上明确写rollbackFor Exception.class。第二同一个类里方法自调用Transactional不会生效。因为 Spring 的代理机制只对从外部进入 Bean 的调用生效自己调自己走的是 this 引用绕过了代理。解决办法是把需要事务的方法拆到另一个 Bean或者用AopContext.currentProxy()这类手段。第三长事务是连接池耗尽的头号嫌疑人。一张报表接口开了事务后在事务里做了三次远程调用总共耗时 3 秒期间一直占着连接。接口 QPS 稍微上来连接池 20 条连接瞬间被打满其他接口全部超时。事务只应该包裹数据库操作本身远程调用、文件读写这类耗时操作一律放到事务外面。5. 上线前后的踩坑记录JSONB、schema、时区与连接池5.1 JSONB 字段读写时的两个经典报错第一个是插入报类型错误ERROR: column profile is of type jsonb but expression is of type character varying。根因是 Java 侧被当作 String 传入PG 要求 jsonb 列必须接收 jsonb 类型。处理方式两种SQL 里显式写#{profile}::jsonb或者 JDBC URL 加stringtypeunspecified。两种都留了配置里加参数兜底SQL 里显式 cast 保证明确性。第二个是查询返回时 MyBatis 处理 PG 的 PGobject 类型可能和环境不匹配结果集映射报类型转换错误。最省事的方案是查询语句里显式把 jsonb 列转成 textSELECT profile::text AS profile FROM app_user这样返回给 Java 的就是普通 String完全绕开类型处理器层面的不确定性。如果项目里 JSONB 字段特别多再去写一个通用的 TypeHandler 在 MyBatis 层注册收益更高。5.2 明明建了表却报 relation does not exist这个报错排查逻辑很直接先确认表真的建了然后十有八九是 schema 的问题。PostgreSQL 默认 schema 是public如果表建在另一个 schema例如app而 JDBC 连接的 search_path 里没包含它那SELECT * FROM app_user就会报 relation does not exist但你在数据库客户端里明明能看到表存在。解决办法有三个SQL 里全限定表名app.app_userJDBC URL 加?currentSchemaapp或者给连接角色设置默认 search_pathALTER ROLE app_user SET search_path TO app, public;另外还要确认连接数据库的账号对该 schema 有 USAGE 和建表权限不然表建不出来或者查询无权限报的错误五花八门。这个坑在开发环境不常见人人都是超级用户一上生产环境规范化的多 schema 架构就频繁暴露。5.3 timestamp 与 timestamptz 的时区灾难PostgreSQL 的timestamp不带时区存的就是你写入的字面值timestamptz带时区内部统一按 UTC 存储返回时再按会话时区换算。跨时区业务、日志系统、订单时间一律用timestamptz。如果实体字段用了LocalDateTime映射timestamptz很可能出现“数据库时间戳是对的应用里读出来少了 8 小时”或者反过来。固定做法是数据库列用timestamptzJava 字段用OffsetDateTimeJDBC URL 上明确指定时区参数老版本驱动用serverTimeZoneUTC新版本用connectionTimeZoneUTC应用服务器时区统一 UTC展示层再做本地化。这样从存储到传输都不会产生歧义。5.4 连接池耗尽的事故现场与排查链路连接池耗尽的现场是所有 PostgreSQL 应用最容易遇到的生产事故症状是大量接口突然大面积超时甚至有接口直接报Connection is not available, request timed out。排查链路我习惯这样走先看监控里活跃连接数是否打满 pool 上限打满之后用pg_stat_activity查当前哪些 SQL 占着连接最久SELECT pid, state, usename, query, now() - xact_start AS duration FROM pg_stat_activity WHERE state active ORDER BY duration DESC;定位到一条跑了很久的查询或者一个没关闭的事务再去代码里对应上下文。常见的根因不外乎三种慢查询本身把连接占住不放事务里夹带了远程调用把事务时间拉长连接池泄漏连接被借出后没有归还通常是异常分支里没有走 finally 释放资源。找到根因之后除了修代码我给每一个服务都加了连接池使用率告警超过 80% 提前介入把问题消灭在“接口还没超时”的阶段。最后说一个固定动作算是长期在这个技术栈上养成的习惯每个新工程第一次把所有 Mapper 跑通之后不要急着写业务先把每个复杂查询都用EXPLAIN (ANALYZE, BUFFERS)过一遍重点看有没有出现Seq Scan全表扫描、有没有估算行数和实际行数严重偏离。PostgreSQL 的统计信息是自动收集的但大批量数据灌入后统计信息会滞后必要时手动ANALYZE一下。这个习惯帮躲过了很多“测试环境跑得飞快、生产环境一上线就慢”的尴尬局面。如果你也在这套组合上折腾希望上面的内容能帮你少踩几个我已经踩进去过的坑。