Spring Boot 4.0 整合 MyBatis:多表查询与分页最佳实践
前言
前 14 篇的订单保存在内存 Map 中。它适合验证 Controller、校验和实时消息,但服务一重启数据就消失,也无法展示真实 SQL。
从本章开始,课程增加独立的 demo-persistence 模块和 /api/v1/db/** 接口。内存接口继续保留,数据库接口负责新的持久化能力。
第一篇先解决一个常见页面需求:按客户查询订单列表,同时展示客户名称、订单项数量和商品总件数。
配套源码:dvsusan/spring-boot4-demo。
最终结果
调用:
curl "http://localhost:8080/api/v1/db/orders?customerNo=C1001&page=1&size=10"得到:
{
"code": "0",
"message": "success",
"data": {
"content": [
{
"orderNo": "PO8F3C1024A8D91F",
"customerNo": "C1001",
"customerName": "小明",
"amount": 398.00,
"status": "CREATED",
"itemCount": 2,
"totalQuantity": 3,
"createdAt": "2026-11-06T06:20:00Z"
}
],
"totalElements": 1,
"page": 1,
"size": 10
}
}
图 1:分页必须同时处理总数和当前页;只查页数据会让前端无法生成页码。
第 1 步:启动 PostgreSQL
项目根目录提供 docker-compose.yml:
services:
postgres:
# 本地教学数据库,生产账号和密码不能照搬。
image: postgres:17-alpine
environment:
POSTGRES_DB: spring_boot4
POSTGRES_USER: spring_boot4
POSTGRES_PASSWORD: spring_boot4
ports:
- "5432:5432"启动:
docker compose up -d
docker compose psSTATUS 显示 healthy 后再启动应用。首次拉取镜像可能需要几分钟。
第 2 步:添加持久化模块
根 POM 增加:
<!-- 持久化模块位于订单业务和 Web 适配层之间。 -->
<module>demo-persistence</module>demo-persistence/pom.xml 引入:
<dependency>
<!-- MyBatis 4.x 对应 Spring Boot 4。 -->
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis-spring-boot.version}</version>
</dependency>
<dependency>
<!-- PostgreSQL JDBC 驱动只在运行时需要。 -->
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>不要继续使用 MyBatis 3.x Spring Boot Starter。Spring Boot 4 的自动配置包和生命周期都发生了变化。
第 3 步:建立三张核心表
Flyway 的第一份迁移位于:
demo-persistence/src/main/resources/db/migration/V1__create_order_core_tables.sql关键表结构:
-- 客户编号只在租户内唯一。
CREATE TABLE customers (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id VARCHAR(32) NOT NULL,
customer_no VARCHAR(32) NOT NULL,
name VARCHAR(100) NOT NULL,
CONSTRAINT uk_customer_tenant_no UNIQUE (tenant_id, customer_no)
);
-- 订单保存客户外键和金额快照。
CREATE TABLE purchase_orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
tenant_id VARCHAR(32) NOT NULL,
customer_id BIGINT NOT NULL REFERENCES customers(id),
amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(32) NOT NULL,
CONSTRAINT uk_order_tenant_no UNIQUE (tenant_id, order_no)
);
-- 商品名和单价是下单时快照,不能随商品中心后续修改。
CREATE TABLE order_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES purchase_orders(id),
product_id VARCHAR(32) NOT NULL,
product_name VARCHAR(120) NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(12, 2) NOT NULL
);金额使用 NUMERIC(12,2),Java 使用 BigDecimal。数据库和程序层都不要用浮点数保存货币。
第 4 步:定义 MyBatis 返回 DTO
创建:
demo-persistence/src/main/java/com/susan/training/persistence/query/OrderSummaryRow.java/**
* MyBatis 多表查询返回的订单摘要。
*/
public record OrderSummaryRow(
String orderNo,
String customerNo,
String customerName,
BigDecimal amount,
String status,
long itemCount,
long totalQuantity,
Instant createdAt
) {
}不要直接返回 JPA Entity。联表结果不是完整实体,DTO 可以只包含页面需要的字段,也避免延迟加载和序列化问题。
分页外壳:
/**
* 带总数的分页结果。
*/
public record OrderPage(
List<OrderSummaryRow> content,
long totalElements,
int page,
int size
) {
}第 5 步:声明 Mapper
创建:
demo-persistence/src/main/java/com/susan/training/persistence/mapper/OrderQueryMapper.java/**
* 复杂联表查询独立使用 MyBatis,避免把报表 SQL 塞进 JPA。
*/
public interface OrderQueryMapper {
List<OrderSummaryRow> findPage(
@Param("tenantId") String tenantId,
@Param("customerNo") String customerNo,
@Param("offset") long offset,
@Param("size") int size
);
long count(
@Param("tenantId") String tenantId,
@Param("customerNo") String customerNo
);
}在配置类扫描 Mapper:
@Configuration
@MapperScan("com.susan.training.persistence.mapper")
public class PersistenceConfiguration {
// JPA Repository 仍由 Spring Data 自动扫描。
}第 6 步:编写多表分页 SQL
XML 位于:
demo-persistence/src/main/resources/mappers/OrderQueryMapper.xml当前页查询:
<!-- 当前页查询只返回页面需要的数据。 -->
<select id="findPage" resultMap="orderSummaryMap">
SELECT order_row.order_no,
customer_row.customer_no,
customer_row.name AS customer_name,
order_row.amount,
order_row.status,
COUNT(item_row.id) AS item_count,
COALESCE(SUM(item_row.quantity), 0) AS total_quantity,
order_row.created_at
FROM purchase_orders order_row
JOIN customers customer_row
ON customer_row.id = order_row.customer_id
LEFT JOIN order_items item_row
ON item_row.order_id = order_row.id
WHERE order_row.tenant_id = #{tenantId}
<if test="customerNo != null and customerNo != ''">
AND customer_row.customer_no = #{customerNo}
</if>
GROUP BY order_row.id,
order_row.order_no,
customer_row.customer_no,
customer_row.name,
order_row.amount,
order_row.status,
order_row.created_at
ORDER BY order_row.created_at DESC, order_row.order_no DESC
LIMIT #{size} OFFSET #{offset}
</select>这里使用 LEFT JOIN。订单即使没有明细也应该出现在列表中,COALESCE 把 NULL 总数转换为 0。
#{...} 使用预编译参数,不能改成 ${...}。后者会直接拼接字符串,产生 SQL 注入风险。
总数查询:
<!-- 总数查询复用相同过滤条件。 -->
<select id="count" resultType="long">
SELECT COUNT(*)
FROM purchase_orders order_row
JOIN customers customer_row
ON customer_row.id = order_row.customer_id
WHERE order_row.tenant_id = #{tenantId}
<if test="customerNo != null and customerNo != ''">
AND customer_row.customer_no = #{customerNo}
</if>
</select>总数 SQL 不应复制无关的聚合和排序,但仍必须使用完全相同的过滤条件。
第 7 步:应用层组装分页
@Transactional(
readOnly = true,
isolation = Isolation.REPEATABLE_READ
)
public OrderPage findPage(
String tenantId,
String customerNo,
int page,
int size
) {
// 页码从 1 开始,数据库 OFFSET 从 0 开始。
long offset = (long) (page - 1) * size;
return new OrderPage(
queryMapper.findPage(tenantId, customerNo, offset, size),
queryMapper.count(tenantId, customerNo),
page,
size
);
}固定 REPEATABLE_READ 让同一次查询中的 count 和 page 使用一致快照,避免第一条 SQL 后刚好插入新订单。
深分页时 OFFSET 很大,数据库仍需扫描并丢弃前面的行。海量数据应优先使用游标分页:
-- 下一页从上一页最后一条记录的 created_at/order_no 之后开始。
WHERE (created_at, order_no) < (:lastCreatedAt, :lastOrderNo)
ORDER BY created_at DESC, order_no DESC
LIMIT :size第 8 步:启动并调用接口
使用数据库 Profile:
mvn clean package -DskipTests
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=db设置库存:
curl -X PUT \
"http://localhost:8080/api/v1/db/inventory/P1001?quantity=20"创建订单:
curl -X POST "http://localhost:8080/api/v1/db/orders" \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: demo" \
-d '{
"customerNo":"C1001",
"customerName":"小明",
"items":[
{
"productId":"P1001",
"productName":"机械键盘",
"quantity":2,
"unitPrice":199.00
}
],
"metadata":{"channel":"web"}
}'分页查询:
curl "http://localhost:8080/api/v1/db/orders?customerNo=C1001&page=1&size=10" \
-H "X-Tenant-Id: demo"看到 totalElements=1、itemCount=1、totalQuantity=2,说明客户、订单和订单项三表已经正确聚合。
常见问题排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| Mapper Bean 不存在 | 没加 @MapperScan 或 XML 路径错误 | 检查扫描包和 mapper-locations |
| 查询结果字段全为 null | 列名与构造器参数映射错误 | 检查 XML resultMap |
| 总数正确但列表重复 | 一对多 JOIN 后又做错分组 | 按订单主键和所有非聚合列分组 |
| 翻页顺序不稳定 | 只按 created_at 排序 | 增加唯一订单号作为第二排序键 |
| 深分页越来越慢 | 大 OFFSET 扫描 | 改为游标分页并设计匹配索引 |
| 切到 PostgreSQL 后启动失败 | 仍连接 H2 默认配置 | 启动时激活 db Profile |
本章小结
MyBatis 适合表达明确的联表 SQL。一个可靠的分页接口至少要有:
- 稳定且唯一的排序。
- 与列表查询一致的 count 条件。
- 受上限保护的
size。 - 参数化 SQL。
- 对深分页的替代方案。
下一章会换一个视角,用 JPA 实体关系表达同一组客户、订单和订单项。
练习题
增加“按订单状态筛选”:
- Mapper 增加可选
status参数。 - count 和 page 共用同一段
<sql>条件。 - 为
(tenant_id, status, created_at)增加索引。 - 用
EXPLAIN ANALYZE比较加索引前后的执行计划。
上一节:SSE 与 WebSocket
下一节:Spring Data JPA 实体设计
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
