news 2026/10/1 19:43:47

Spring Boot + MyBatis + PostgreSQL 实战指南:从选型到上线避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot + MyBatis + PostgreSQL 实战指南:从选型到上线避坑

在 Java 后端这一摊子里,Spring Boot 早就成了事实上的标配,而说到持久层框架,MyBatis 和 JPA 两派之争从来没停过。我这两年做过的项目中,凡是要精细化控制 SQL、要针对复杂查询做深度优化的,最后几乎都落在了 Spring Boot + MyBatis + PostgreSQL 这个组合上。这套组合的好处很直接:Spring Boot 负责把工程骨架搭好,MyBatis 让你把 SQL 牢牢攥在自己手里,PostgreSQL 则在数据一致性和扩展性上给足底气。这篇实战指南,就是把我从零搭建、到上线维护这套组合的完整过程、关键决策和踩过的坑整理出来,给正要选型或者正在填坑的同学一个参考。

这篇文章适合谁?如果你是刚接触 Spring Boot 不久、想搞清楚 MyBatis 到底怎么跟数据库打交道的新手,或者你已经在用 MySQL 但被各种奇怪问题逼得想换 PostgreSQL 的老手,又或者你只是想看看别人在生产环境里怎么配置事务、怎么处理 JSON 字段、怎么做批量插入优化——那这篇内容基本就是为你准备的。我会从选型逻辑讲起,一路走到工程落地,把每一步为什么这么做的原因也一并交代清楚。

1. 技术选型:为什么是 MyBatis 遇上 PostgreSQL

1.1 选 MyBatis 而不是 JPA 的真实原因

很多人上来就问,Spring Boot 官方文档里推荐的持久层方案明明是 Spring Data JPA,为什么还要选 MyBatis?我的答案很简单:看 SQL 的掌控权在谁手里。

JPA 的思路是让你用面向对象的方式操作数据库,开发者写的是 Entity 和 Repository 接口,具体 SQL 由框架在运行时生成。这套东西在表结构简单、关联关系不复杂的业务里确实效率极高,但一旦遇到多表联查、动态排序、复杂聚合这类场景,JPA 的自动生成 SQL 要么性能拉胯,要么你得去写 @Query 原生 SQL——绕了一圈,还是回到了手写 SQL 的老路上。而 MyBatis 从一开始就把 SQL 当作一等公民,每一句 SQL 都是你自己写的,它的 XML 映射文件天然支持动态 SQL,if、where、foreach、choose 这些标签用起来极其顺手,逻辑一多优势就越明显。

还有一个非常现实的因素:团队里如果新老成员水平参差不齐,MyBatis 的 SQL 是显式的,任何一个人打开 Mapper XML 都能看懂这条查询在干什么,而 JPA 的派生查询方法名一长串,底层生成的 SQL 还得开日志去看,排查问题的门槛立刻高了一截。我个人的经验是,ERP、运营后台、报表系统这类重查询业务,MyBatis 的维护成本明显更低。

1.2 PostgreSQL 到底强在哪

PostgreSQL 这些年风头越来越盛,不是没有道理的。从我的使用感受来说,最直观的几点:

第一,它是真正的“关系型数据库六边形战士”,事务、约束、触发器、视图、存储过程这些传统能力一个不少,而且在复杂查询优化上表现非常稳定。第二,它的扩展能力极其恐怖,PostGIS 处理地理信息、jsonb 存半结构化数据、数组类型、全文检索,这些功能在 MySQL 里要么得靠外挂要么体验很差,在 PG 里都是原生支持。第三,它对 SQL 标准的遵循程度极高,很多在 MySQL 里写法别扭的查询,在 PG 里写起来就是一个标准 SQL,跨数据库迁移时心智负担小很多。

再说点实际的。我这边项目里经常要存一些动态属性,比如第三方接口返回的原始报文、用户前端自定义的表单数据,这种东西用传统关系型字段去建模会非常痛苦,但在 PG 里直接开一个 jsonb 字段就解决了,配合 GIN 索引还能做到对 JSON 内部属性的高效查询。这种灵活性和严谨性的平衡,是很多团队转向 PostgreSQL 的核心原因。另外 PostgreSQL 的 MVCC 并发控制机制做得相当成熟,高并发读写场景下锁竞争控制得比不少同类数据库要好,这也是我敢在偏核心的业务库上用它的一大底气。

1.3 整体项目结构与分层思路

拿到一个需求,别急着写代码,先把包结构想清楚。我习惯的分层是 Controller → Service → Mapper 三层,Controller 只做参数接收和结果封装,Service 负责业务逻辑和事务边界,Mapper 接口只声明方法,真正的 SQL 全部放在 resources/mapper/ 目录下的 XML 文件里。

src/main/java/com/example/demo ├── controller # HTTP 接口层 ├── service # 业务逻辑层 │ └── impl # 业务实现 ├── mapper # MyBatis Mapper 接口 ├── entity # 数据库实体映射 ├── dto # 数据传输对象 └── config # 配置类,如分页插件、JSON 转换 src/main/resources ├── mapper # Mapper XML 文件 └── application.yml # 全局配置

这个分层没有多新奇,但两个细节值得注意:一是 entity 和 dto 必须分开,千万别把数据库实体直接暴露给前端,不然哪天表结构加个字段,前端接口文档就得跟着遭殃;二是 Mapper 接口和 XML 文件的包路径要保持一致,这是 MyBatis 扫描的硬性约定,搞错了启动就直接报绑定异常。后面我会详细讲这个。

2. 环境准备与基础配置的细节

2.1 PostgreSQL 的安装与初始化

PostgreSQL 的安装本身不复杂,但有几个地方容易卡住。Windows 上装完以后,最容易遇到的问题就是服务启动失败。我在 Windows 上踩过的坑是安装过程中自动创建的 postgresql.conf 里监听地址默认是 localhost,如果你的机器开了防火墙或者端口被占,服务就会起不来。排查命令很简单:

# 查看服务状态 pg_ctl status # 查看端口占用 netstat -ano | findstr 5432

Linux 上用包管理器装就很省心,Ubuntu 系的命令是:

sudo apt update sudo apt install postgresql postgresql-contrib sudo systemctl enable --now postgresql

装完以后默认会创建一个名为 postgres 的超级用户,你要先切换到系统 postgres 用户才能登录:

sudo -i -u postgres psql

进到 psql 里,第一步就是把密码改了,创建业务库和业务用户:

ALTER USER postgres WITH PASSWORD 'your_strong_password'; CREATE USER app_user WITH PASSWORD 'app_pass_123'; CREATE DATABASE app_db OWNER app_user ENCODING 'UTF8'; GRANT ALL PRIVILEGES ON DATABASE app_db TO app_user;

这里必须提醒一句:生产环境绝对不要用 postgres 超级用户去连业务库。数据库用户权限应该收得越紧越好,业务账号只需要对自己库里的表有增删改查权限就行。很多线上事故都是因为图省事,业务代码拿超级用户跑,一个 SQL 写错就把整个实例的数据删了。

2.2 Maven 依赖到底该放哪些

Spring Boot 整合 MyBatis 的依赖配置,网上一搜一大把,但很多人复制粘贴完发现启动报错,多半是版本没对齐。我当前项目使用的是 Spring Boot 2.7.x,搭配的依赖组合供你参考:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <!-- Web 启动器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 与 Spring Boot 的整合包,注意这个不是 mybatis 官方出的 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <!-- PostgreSQL 驱动 --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- 分页插件,后面细说 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> <!-- 简化实体类代码的 Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

版本对齐的原理多说一句:mybatis-spring-boot-starter 2.3.x 内部依赖的是 MyBatis 3.5.x 和 Spring Boot 2.x 的兼容版本,所以 Spring Boot 3.x 项目就不能直接用这个坐标,得换成mybatis-spring-boot-starter的 3.x 版本。你只要记住一个原则:MyBatis 整合包的版本第一位,要和 Spring Boot 主版本保持一致。Spring Boot 2.x 对应 2.x 的整合包,Spring Boot 3.x 对应 3.x 的整合包,不然大概率会遇到组件扫描或者自动配置失效的问题。

2.3 application.yml 里的关键配置解读

配置这块是新手翻车重灾区。我给出一个能直接跑的配置,并且把每个关键项为什么这么配讲清楚:

spring: datasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://localhost:5432/app_db?useUnicode=true&characterEncoding=utf8&stringtype=unspecified username: app_user password: app_pass_123 hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 idle-timeout: 30000 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 pagehelper: helper-dialect: postgresql reasonable: true

重点看两个地方:

第一,JDBC URL 里的stringtype=unspecified这个参数对 PG 特别重要。PostgreSQL 的 JDBC 驱动在默认情况下,如果 PreparedStatement 绑定的参数类型和字段类型不一致,例如字段是 timestamp 但传入的是字符串,会直接报operator does not exist错误。加上这个参数后,驱动会把参数类型交给数据库去推断,救了我好多次。

第二,map-underscore-to-camel-case这个配置一定要打开。PostgreSQL 的命名习惯是下划线风格,比如 user_name、created_at,而 Java 实体类属性是驼峰风格 userName、createdAt。打开这个开关后,MyBatis 在做结果映射时自动把下划线转成驼峰,免去写大量 resultMap 映射的麻烦。

log-impl配成 StdOutImpl 只适合开发环境,SQL 会直接打印到控制台,方便调试。上线前记得把它改成org.apache.ibatis.logging.slf4j.Slf4jImpl或者直接删掉,不然在高并发下日志量能把磁盘炸满。

3. 从建表到跑通接口的完整实战

3.1 表结构设计与实体类映射

我这里拿一个典型的“用户订单”场景来演示。设计两张表:用户表 app_user 和订单表 app_order,订单表通过外键关联用户。为了充分展示 PG 的特性,订单表里我用了 jsonb 类型来存下单时的快照信息,这样哪怕商品信息后续变了,订单里保留的仍是用户下单那一刻的数据。

CREATE TABLE app_user ( id BIGSERIAL PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE app_order ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES app_user(id), order_no VARCHAR(32) NOT NULL UNIQUE, total_amount NUMERIC(10, 2) NOT NULL, snapshot JSONB, status SMALLINT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_app_order_user_id ON app_order(user_id); CREATE INDEX idx_app_order_order_no ON app_order(order_no); CREATE INDEX idx_app_order_snapshot_gin ON app_order USING GIN (snapshot);

几个设计决策值得说一下:

BIGSERIAL 是 PG 的自增主键,它本质上是一个 bigint 加一个隐式序列,效果等同于 MySQL 的 AUTO_INCREMENT,但有一个好处——你可以用currval()函数在插入后立刻拿到主键,这比 MySQL 的 LAST_INSERT_ID() 在某些场景下更直观。

NUMERIC(10, 2) 是定点数,存金额一定要用它,千万别用 float8 或 double precision,否则浮点精度问题会在对账的时候给你一个巨大的惊喜。jsonb 字段上的 GIN 索引是为了后续做snapshot -> 'goodsName'这类快速查询准备的。

实体类这边,用 Lombok 可以减少大量样板代码,但有一点要提醒:因为开启了下划线转驼峰,实体类的属性名必须严格使用驼峰,否则映射会直接失败:

@Data public class AppUser { private Long id; private String username; private String email; private LocalDateTime createdAt; private LocalDateTime updatedAt; } @Data public class AppOrder { private Long id; private Long userId; private String orderNo; private BigDecimal totalAmount; private String snapshot; // jsonb 在 Java 侧用 String 接收,配合 TypeHandler 转换 private Short status; private LocalDateTime createdAt; }

3.2 Mapper 接口与 XML 映射文件的编写

Mapper 接口这边很简单,只定义方法签名,不写任何 SQL:

public interface AppOrderMapper { int insertOrder(AppOrder order); AppOrder selectById(Long id); List<AppOrder> selectPageByUserId(@Param("userId") Long userId, @Param("offset") int offset, @Param("limit") int limit); int updateStatus(@Param("id") Long id, @Param("status") Short status); }

对应的 XML 文件写在 resources/mapper/AppOrderMapper.xml 里。这里要重点讲讲 insert 语句中如何拿回自增主键,以及动态 SQL 的使用技巧:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.AppOrderMapper"> <insert id="insertOrder" parameterType="com.example.demo.entity.AppOrder" useGeneratedKeys="true" keyProperty="id" keyColumn="id"> INSERT INTO app_order (user_id, order_no, total_amount, snapshot, status) VALUES ( #{userId}, #{orderNo}, #{totalAmount}, #{snapshot}::jsonb, #{status} ) </insert> <select id="selectById" resultType="com.example.demo.entity.AppOrder"> SELECT id, user_id, order_no, total_amount, snapshot, status, created_at FROM app_order WHERE id = #{id} </select> <select id="selectPageByUserId" resultType="com.example.demo.entity.AppOrder"> SELECT id, user_id, order_no, total_amount, snapshot, status, created_at FROM app_order <where> <if test="userId != null"> AND user_id = #{userId} </if> </where> ORDER BY id DESC LIMIT #{limit} OFFSET #{offset} </select> <update id="updateStatus"> UPDATE app_order SET status = #{status} WHERE id = #{id} </update> </mapper>

几个容易翻车的细节:

  • XML 文件的 namespace 必须和 Mapper 接口的全限定名完全一致,少一个包名都不行。不一致时启动报错是org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。

  • 插入时的#{snapshot}::jsonb写法是我故意演示的类型转换问题。如果 Java 侧 snapshot 是 String,PG 的 jsonb 字段不会自动把字符串转成 JSON 类型,必须显式加::jsonb告诉数据库去转换。更优雅的做法是自定义 TypeHandler,这个放在后面讲。

  • 分页用了 LIMIT 和 OFFSET,这是 PG 的原生分页语法,和 MySQL 完全一致,所以这段 SQL 在两个数据库上都能跑。

3.3 Service 层事务控制的正确姿势

Service 层的事务管理,Spring 用的是声明式事务,核心就一个注解@Transactional。但很多人不知道的是,这个注解有几个隐性陷阱。

第一个陷阱是事务只对 RuntimeException 和 Error 回滚,而对受检异常(checked exception)默认不回滚。比如你在 Service 方法里调用了一个声明抛出 IOException 的第三方接口,而业务要求在 IO 失败时回滚数据库操作,就必须显式指定:

@Transactional(rollbackFor = Exception.class) public void createOrder(AppOrder order) { // 插入订单 appOrderMapper.insertOrder(order); // 扣减库存等其它操作 inventoryMapper.decreaseStock(order.getUserId(), order.getOrderNo()); // 如果这里抛了受检异常,没有 rollbackFor 的话,前面插入的订单不会被回滚 }

第二个陷阱是自调用失效。同一个类里,方法 A 调方法 B,如果 B 上面标了 @Transactional,事务是不生效的。原因是 Spring 的事务是通过 AOP 代理实现的,自调用走的是 this 的引用,而没有经过代理对象。解决方案要么把 B 拆到另一个 Service 类里,要么自己注入自己,但更推荐前者,职责也更清晰。

第三个陷阱是事务的粒度。把大量耗时的网络调用、文件处理塞进一个事务里,会让数据库连接被长时间占用,连接池很快耗尽。我的经验是事务只包裹纯粹的数据库操作,外部调用放到事务外面,或者用异步方式解耦。

3.4 Controller 接口与统一返回结构

Controller 层没什么高深的技术,但统一的返回结构能省掉前后端大量的沟通成本。我通常会定义一个 Result 泛型类:

@Data public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 0; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(int code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }

接口层写起来就很清爽:

@RestController @RequestMapping("/api/order") public class AppOrderController { private final AppOrderService appOrderService; public AppOrderController(AppOrderService appOrderService) { this.appOrderService = appOrderService; } @PostMapping public Result<Long> createOrder(@RequestBody AppOrder order) { appOrderService.createOrder(order); return Result.success(order.getId()); } @GetMapping("/{id}") public Result<AppOrder> getOrder(@PathVariable Long id) { return Result.success(appOrderService.getOrderById(id)); } @GetMapping("/page") public Result<PageInfo<AppOrder>> page(@RequestParam Long userId, @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize) { return Result.success(appOrderService.pageByUserId(userId, pageNum, pageSize)); } }

这里有个容易被忽略的点:Controller 的参数接收尽量用 @RequestParam 显式声明默认值,避免空指针。分页参数 pageNum 和 pageSize 一定要做上限校验,不然恶意传一个 pageSize=1000000,一次查询能把数据库拖死。

4. 进阶功能与性能优化实战

4.1 分页查询:手写 LIMIT 还是用 PageHelper

前面 XML 里我演示了手写 LIMIT/OFFSET 的分页,这种方式在数据量小、分页逻辑简单的场景下完全够用。但项目一复杂,比如分页之外还要返回总条数,手写就变得很啰嗦。你总得先发一条 count 语句,再发一条数据语句,而且两条语句的参数过滤条件要维持一致,维护起来心累。

所以实际项目中我基本都用 PageHelper。它的原理很巧妙:在 MyBatis 执行查询前,通过拦截器拦截即将执行的 SQL,自动改写成分页语句,同时自动生成一条 count 语句,把总数放到返回的 PageInfo 里。使用方式极其简单:

public PageInfo<AppOrder> pageByUserId(Long userId, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); List<AppOrder> list = appOrderMapper.selectPageByUserId(userId); return new PageInfo<>(list); }

用 PageHelper 有两条铁律必须记住:

第一,PageHelper.startPage方法后面必须紧跟着要分页的 Mapper 查询,中间不能有任何其它数据库操作,否则 ThreadLocal 里记的分页参数会被下一次查询消费掉,导致分页错乱。

第二,分页插件是线程安全的吗?不完全是。PageHelper 的分页参数存在 ThreadLocal 里,如果一次请求里有两组查询,第一组 startPage 之后执行了其它查询再去查目标表,分页就串了。所以规范就是 startPage 和 Mapper 调用写在同一行或紧挨着,不要隔开。

4.2 jsonb 字段的优雅处理:自定义 TypeHandler

前面表设计里 snapshot 字段是 jsonb,Java 对应 String,但每次 insert 都要手写::jsonb太丑了,查出来的时候 PG 驱动返回的是 PGobject,还得手动 tostring。一个一劳永逸的方案是自定义 TypeHandler。

TypeHandler 的工作流程可以这样理解:Java 程序往数据库写参数时,它负责把 Java 类型转换成 JDBC 类型;从数据库读结果时,它负责把 JDBC 类型转换成 Java 类型。MyBatis 内置了很多常用类型的 TypeHandler,但 jsonb 这种 PG 特有类型就得自己写了:

@MappedTypes(String.class) @MappedJdbcTypes(JdbcType.OTHER) 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); } }

关键就在setObject(i, parameter, Types.OTHER)这句话——PG 驱动要求 jsonb 类型的绑定参数必须以 Types.OTHER 类型传入,否则会报协议错误。然后在 Mapper XML 里显式使用这个 TypeHandler:

<insert id="insertOrder"> INSERT INTO app_order (user_id, order_no, total_amount, snapshot, status) VALUES ( #{userId}, #{orderNo}, #{totalAmount}, #{snapshot, typeHandler=com.example.demo.handler.JsonbTypeHandler}, #{status} ) </insert> <select id="selectById" resultType="com.example.demo.entity.AppOrder"> SELECT id, user_id, order_no, total_amount, snapshot, status, created_at FROM app_order WHERE id = #{id} </select>

MyBatis 在查询结果的映射阶段,会自动把 jsonb 列的值用 JsonbTypeHandler 转成 String,所以查询这边不用再指定 handler,只要表字段类型匹配就行。这种方案把类型转换逻辑收敛在一个类里,业务代码彻底和数据库方言解耦。

4.3 批量插入的三种方式与性能对比

批量插入是我每次做导入功能都绕不开的话题。基于 MyBatis + PG,方案大概有三种,性能差距极大。

第一种,循环单条 insert,这是新手最容易写出来的。每插一条数据,就要跟数据库做一次网络往返,1 万条数据就是 1 万次往返,性能惨不忍睹,实测下 1 万条可能要几十秒甚至几分钟。

第二种,用 MyBatis 的 foreach 拼一条大的批量 insert 语句。比如:

<insert id="batchInsert"> INSERT INTO app_order (user_id, order_no, total_amount, snapshot, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.userId}, #{item.orderNo}, #{item.totalAmount}, #{item.snapshot}::jsonb, #{item.status}) </foreach> </insert>

这种方式把多条 INSERT 合并成一条 SQL,网络往返次数从 N 次降到 1 次,性能提升是几何级的。但要注意 PostgreSQL 对单条 SQL 的绑定参数个数有限制,默认上限是 32767 个参数,如果每行数据有 5 个占位符,那一批最多插 6553 行左右,超出就会报PreparedStatement can have at most 32767 parameters错误。所以批量插入时必须分批,比如每批 1000 行:

public void batchInsert(List<AppOrder> orders) { int batchSize = 1000; for (int i = 0; i < orders.size(); i += batchSize) { int end = Math.min(i + batchSize, orders.size()); appOrderMapper.batchInsert(orders.subList(i, end)); } }

第三种,用 JDBC 的 rewriteBatchedInserts 参数配合 ExecutorType.BATCH。这个方案更高级:在 JDBC URL 上加?reWriteBatchedInserts=true,让 PG 驱动把 JDBC 层的批量操作自动重写为多 VALUES 的 insert 语句。然后代码里用 SqlSessionTemplate 手动切批处理模式执行,具体的实现代码量更大,而且对事务和连接的管理要求更高。我的建议是绝大多数场景用第二种方案+分批就足够了,第三种方案适合对性能有极致要求的场景。

4.4 缓存体系与 MyBatis 二级缓存注意事项

MyBatis 的缓存分两级。一级缓存是 SqlSession 级别的,默认开启,同一个 SqlSession 内两次相同的查询不会重复访问数据库。二级缓存是 Mapper 级别的,可以在多个 SqlSession 之间共享,但默认关闭,需要手动开启。

二级缓存在单机环境下确实能显著提升热点数据的查询性能,配置也不复杂,在 Mapper XML 里加一行:

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>

但我要泼一盆冷水:如果你的项目部署在多台服务器上,或者你的表会被多个应用同时读写,二级缓存就是一颗定时炸弹。MyBatis 的二级缓存是应用内缓存,没有分布式失效机制,一台机器上的数据更新了,另一台机器的缓存里还是老数据,就会出现数据不一致。我的原则很简单:能用 Redis 就用 Redis,MyBatis 的二级缓存只适合单机部署、读多写少、对数据一致性要求不高的场景。

一级缓存倒是可以放心用,但要注意它有个隐蔽的坑。在 Spring 管理下,SqlSession 的生命周期跟事务绑定,如果你在一个事务里先查一个对象,然后更新了它,再查一次,按道理一级缓存应该失效返回最新数据——但如果你的 MyBatis 版本较老或者配置不当,可能会命中缓存拿到旧值。好在 MyBatis 3.x 系列里,更新操作会自动清空一级缓存中相关的缓存项,这个问题在新版本里基本遇不到,但知道原理总没坏处。

4.5 SQL 性能排查与 PostgreSQL 执行计划

最后说一个我强烈建议每个后端开发者掌握的能力:看懂执行计划。当你发现一条 SQL 慢得像蜗牛,不要急着加索引,先用 EXPLAIN ANALYZE 看数据库到底是怎么执行这条 SQL 的:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM app_order WHERE user_id = 42 ORDER BY id DESC LIMIT 20;

执行计划输出里,重点看三样东西:Seq Scan(全表扫描)、Index Scan(索引扫描)、以及每步的actual time。如果看到该走索引的地方走了 Seq Scan,大概率是字段类型不匹配,比如 user_id 在 Java 侧传的是字符串,SQL 里隐式转换之后索引就失效了。这时候去查 Mapper 里的参数类型是不是和表字段类型对得上,比瞎加索引有用得多。

PostgreSQL 还有个利器叫 pg_stat_statements,可以统计所有 SQL 的执行次数、平均耗时、总耗时,是发现慢 SQL 源头的最佳工具。开启方式是修改 postgresql.conf 里的shared_preload_libraries = 'pg_stat_statements',然后重启数据库,再执行:

CREATE EXTENSION pg_stat_statements; SELECT query, calls, total_exec_time / calls AS avg_time_ms FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;

这个视图能直接告诉你,压测或者生产环境里哪些 SQL 累计消耗的时间最多,是性能调优的第一手情报来源。

5. 常见问题排查与避坑实录

5.1 Invalid bound statement (not found)

这个异常可以说是 MyBatis 新手必遇的错误,报错信息翻译过来就是“找不到绑定语句”。排查路径基本固定:

第一,检查 Mapper 接口的 namespace 和 XML 的 namespace 是否完全一致。第二,检查 XML 文件的位置是否在mybatis.mapper-locations指定路径下,也就是 resources 目录下的 mapper 文件夹里。第三,检查 Mapper 接口的包路径是否被 Spring Boot 的组件扫描覆盖到。我见过最隐蔽的一个坑是:XML 文件放在 src/main/java 目录下而没放到 resources 目录,构建时没有被复制到 target 目录,结果启动一直报绑定异常。解决方案很简单,XML 文件一律放 resources 下,不要放 java 包结构里。

另外 Maven 构建时还需要防止 XML 文件被过滤掉,在 pom.xml 里加上:

<build> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>

这一步很多人漏掉,配好之后重启就正常了。

5.2 数据库连接池连接被耗尽

应用运行一段时间后突然卡死,日志里频繁出现HikariPool-1 - Connection is not available, request timed out,这基本就是连接被耗尽的前兆。排查方向主要有三个:

  • 检查是不是有慢 SQL 长时间占用连接。连接池的最小连接数就那么多,一条查询跑 30 秒,池子很快被占满。
  • 检查连接是否泄露。最常见的原因是在代码里开启事务后,因为异常提前 return 了,事务没有正常结束,连接一直没有释放。
  • 检查连接池参数是否合理。我这里给一个保守的参考配置:minimum-idle 2,maximum-pool-size 10,connection-timeout 30000,max-lifetime 1800000。记住一个点,连接池不是越大越好,每一条物理连接背后都有一个线程和内存开销,设得过大反而拖垮数据库。

排查连接泄露时,可以在 application.yml 里临时开启 Hikari 的泄漏检测:

spring: datasource: hikari: leak-detection-threshold: 60000

如果连接被持有超过 60 秒,日志里会打印出获取连接的堆栈信息,直接指出是哪一行代码拿走了连接没还。

5.3 PostgreSQL 的时区问题

PostgreSQL 的 timestamp 类型分两种:带时区的 timestamptz 和不带时区的 timestamp。如果 Java 实体用的是 LocalDateTime,而数据库字段是 timestamptz,读写时会发生时区偏移,最常见的结果就是你存进去的是 10:00,查出来变成了 18:00 之类。

我的建议是统一规范:Java 侧用 LocalDateTime,数据库侧用 timestamp(不带时区),JDBC URL 上显式指定 serverTimezone,并用 Asia/Shanghai 固定。这样所有时间在 Java 和数据库之间传递时不经过时区转换,避免了很多玄学问题。如果你是面向海外用户、需要存储 UTC 时间的项目,则反过来全部用 timestamptz,任何地方都不要手写当前时间,统一走NOW()或者 Java 侧写死 UTC。

顺带一提,PostgreSQL 里timestamp默认精度是微秒,而 Java 的 LocalDateTime 精度是纳秒,读写时间时会有精度截断,但对于绝大多数业务场景,微秒级完全可以接受,不必纠结。

5.4 常见的 PostgreSQL 权限与连接问题速查

我再整理一个速查表,都是我实际项目中遇到过的典型问题:

现象可能原因解决方案
psql: FATAL: Peer authentication failedLinux 上使用密码登录时,pg_hba.conf 的认证方式限制为 peer修改 pg_hba.conf 为 scram-sha-256,重启服务
FATAL: password authentication failed数据库密码错误确认用户和密码,注意业务用户和 postgres 超级用户不要混用
连接被拒 Connection refused数据库服务未启动,或端口被防火墙拦截检查 pg_isready,开放 5432 端口
执行 SQL 报 permission denied业务用户缺少表权限GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
中文数据乱码数据库编码不是 UTF8建库时务必指定 ENCODING 'UTF8',用SHOW server_encoding;验证

这里再强调一次,PostgreSQL 的pg_hba.conf文件是访问控制的核心。生产环境配置完数据库后,一定要检查这个文件,不要默认全部认证方式都是 trust,那等于把你的数据库裸奔在公网上,被扫描到就等着被删库吧。至少要保证远程连接走密码认证,并且只允许应用服务器的 IP 段访问 5432 端口。

6. 项目上线前的一些收尾建议

主体功能都跑通了,最后再唠叨几句上线前容易被忽略的事。

第一件是数据库迁移脚本的管理。我一个项目里至少有五六个人在干活,最怕的就是有人直接在测试库上 ALTER TABLE 加列,回头生产环境完全不知道要执行什么。后来统一引入了 Flyway,把所有 DDL 脚本都按版本号提交到代码仓库里,应用启动时自动按顺序执行,从此再也没出现过环境之间的表结构漂移问题。Spring Boot 整合 Flyway 极其简单,引入依赖、配置一个数据源,它自己检测到还没跑过的脚本就自动执行。

第二件是监控与日志。PostgreSQL 自带的日志系统要打开——在 postgresql.conf 里把log_statement设置为'mod'或'ddl',把log_min_duration_statement设置成 1000(单位毫秒),超过 1 秒的慢 SQL 就会记到日志里。这比事后排查要省力得多。

第三件是压测。上面说的这些都是纸面经验,真要上线,还是得实际压过才知道扛不扛得住。我自己的习惯是用 JMeter 或者 wrk 对核心接口做一轮压力测试,重点观察两个指标:吞吐量和 P99 延迟。如果 P99 延迟抖动明显,优先去排查数据库连接池和慢 SQL,这两个通常是瓶颈的大头。

第四件是我踩过最狠的坑——连接池参数和数据库参数不匹配导致偶发断连。PostgreSQL 的服务器端tcp_keepalives_idle默认是 2 小时,而 HikariCP 的 max-lifetime 默认是 30 分钟,按理说应用侧的连接生命周期短于数据库侧的,不会出问题。但如果你手动调大了 max-lifetime 或者数据库侧配了较短的 idle 超时,就会出现连接被数据库静默关闭后,连接池还握着这个死连接的场景。解决方式是把 Hikari 的 max-lifetime 设得比数据库空闲超时稍微短一点,并开启connection-test-query,让连接池对每一个空闲连接做保活检查。

这套组合我用到现在,最大的感受就是“稳”。MyBatis 让你对 SQL 有绝对的掌控感,PostgreSQL 则像一个可靠的大后方,把数据一致性、类型灵活性、查询性能都托举得很好。如果你正在做技术选型或者刚迁移过来被配置折腾得头大,照着这篇文章里的步骤走一遍,应该能少掉不少头发。遇到没覆盖到的问题,也可以直接去翻 PostgreSQL 官方文档或者 MyBatis 的 GitHub wiki,这两个资源比任何二手资料都权威,也是我这一路走来最常用的两本“字典”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 19:43:20

从零构建AI工程能力:数据契约、Rust服务与TS调试闭环

1. 项目概述&#xff1a;从零构建AI工程能力&#xff0c;不是造轮子&#xff0c;是搭骨架“ai-engineering-from-scratch”这个标题乍看像一本技术书名&#xff0c;但实际它指向的是一条被严重低估的实践路径——不是用现成框架跑通一个LLM demo&#xff0c;而是亲手把AI工程的…

作者头像 李华
网站建设 2026/10/1 19:43:19

LLVM内存管理机制如何优化大模型推理框架

最近在调一个 GPU kernel 的性能&#xff0c;火焰图拍出来吓我一跳&#xff0c;热点根本不在计算逻辑上&#xff0c;而是大量时间耗在反复 malloc/free 临时数组上。项目里的老人给了一句提点&#xff1a;去看看 LLVM 怎么管内存的。我从 SmallVector 一路看到 BumpPtrAlloc…

作者头像 李华
网站建设 2026/10/1 19:43:11

Model-Optimizer实战:量化、剪枝与蒸馏,让模型又快又小

前阵子一个做工业质检的朋友跟我诉苦&#xff1a;缺陷检测模型在实验室跑mAP有96.4%&#xff0c;一上到现场那台老工控机&#xff0c;单张推理要900多毫秒&#xff0c;流水线早就停在那儿等它了。这类问题我太熟了——训练阶段大家比的是精度&#xff0c;部署阶段拼的是时延和体…

作者头像 李华
网站建设 2026/10/1 19:43:08

TensorFlow工程实践:从安装踩坑到生产部署的全链路指南

1. 这不是“装个库”那么简单&#xff1a;TensorFlow到底在解决什么问题&#xff1f; 很多人第一次听说TensorFlow&#xff0c;是在“Python深度学习环境配置失败”的深夜崩溃时刻。搜索框里敲下“tensorflow安装”&#xff0c;跳出来的不是教程&#xff0c;而是满屏的报错截图…

作者头像 李华
网站建设 2026/10/1 19:43:02

Java后端Markdown解析选型:CommonMark与Flexmark实战指南

1. 为什么在后端做Markdown解析&#xff1f;不是前端更“自然”吗&#xff1f;很多人第一反应是&#xff1a;Markdown不就是给前端用的吗&#xff1f;用户写完&#xff0c;浏览器实时渲染&#xff0c;加个marked.js或remark就能搞定。但我在电商后台系统里踩过三次坑&#xff0…

作者头像 李华
网站建设 2026/10/1 19:42:21

WebSocket前端实战:连接管理、生命周期与高可用降级

1. 为什么WebSocket不是“另一个AJAX”&#xff0c;而是一次通信范式的切换前端工程师第一次接触 WebSocket&#xff0c;常会下意识把它当成“升级版的 fetch”——不就是发个请求、收个响应嘛&#xff1f;我试过用 fetch 轮询每秒拉一次订单状态&#xff0c;代码写了三四十行&…

作者头像 李华