MyBatis 分页查询 + Feign 数据补充实战指南
一、概述
在微服务架构中,一个常见的列表查询模式是:先从本地数据库分页查询基础数据,再通过远程服务(Feign)补充关联信息。这种模式避免了跨库 JOIN,保持了服务间的解耦,但也带来了性能和一致性方面的挑战。
本文以一个"员工考勤记录查询"场景为例,系统介绍 MyBatis 动态 SQL 分页查询、Feign 远程数据补充、枚举翻译、N+1 问题优化等核心技术点。
二、示例场景
一个考勤管理系统,需要实现考勤记录分页查询:
- 本地表存储:员工ID、打卡时间、打卡状态
- 远程服务提供:员工姓名、部门名称、职位信息
- 需要支持:多条件筛选、分页、枚举翻译、操作人信息补充
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
三、分层架构
Controller(入参校验/默认值)
|
Service(业务编排:查询 + 数据补充 + 枚举翻译)
|
Mapper/DAO(MyBatis 动态 SQL 分页查询)
|
Feign(远程调用补充关联数据)
四、MyBatis 动态 SQL 分页查询
4.1 Mapper 接口
java
public interface AttendanceMapper {
List<AttendanceResultDto> listAttendancePager(
@Param("param") AttendanceQueryParamsDto param,
@Param("pageSize") Integer pageSize,
@Param("pageNum") Integer pageNum);
}
4.2 XML 动态 SQL
xml
<select id="listAttendancePager"
resultType="com.example.dto.AttendanceResultDto">
SELECT
a.id,
a.employee_id AS employeeId,
a.check_time AS checkTime,
a.check_type AS checkType,
a.status AS status,
a.create_user_id AS createUserId,
d.dept_code AS deptCode,
d.dept_name AS deptName
FROM attendance_record a
LEFT JOIN department d ON a.dept_id = d.id
<where>
<!-- 单字段模糊搜索 -->
<if test="param.employeeName != null and param.employeeName != ''">
AND a.employee_name LIKE CONCAT('%', #{param.employeeName}, '%')
</if>
<!-- 精确匹配 -->
<if test="param.status != null">
AND a.status = #{param.status}
</if>
<!-- 多选 IN 查询 -->
<if test="param.deptCodeList != null and !param.deptCodeList.isEmpty()">
AND d.dept_code IN
<foreach collection="param.deptCodeList" item="deptCode"
open="(" separator="," close=")">
#{deptCode}
</foreach>
</if>
<!-- 时间范围查询 -->
<if test="param.checkTimeStart != null and param.checkTimeStart != ''">
AND a.check_time >= #{param.checkTimeStart}
</if>
<if test="param.checkTimeEnd != null and param.checkTimeEnd != ''">
AND a.check_time <= #{param.checkTimeEnd}
</if>
</where>
ORDER BY a.id DESC
</select>
4.3 动态 SQL 核心标签
| 标签 | 用途 | 示例 |
|---|---|---|
<where> |
自动处理 WHERE 和多余的 AND/OR | 条件为空时不生成 WHERE |
<if> |
条件判断,参数不为空时拼接 SQL | 模糊搜索、精确匹配 |
<foreach> |
遍历集合,生成 IN 子句 | 多选筛选 |
<choose>/<when>/<otherwise> |
类似 switch-case | 互斥条件 |
<trim> |
自定义前缀/后缀处理 | 复杂条件拼接 |
<set> |
UPDATE 语句动态字段 | 部分字段更新 |
4.4 模糊搜索的安全写法
xml
<!-- 正确:使用 CONCAT + #{} 防止 SQL 注入 -->
AND a.employee_name LIKE CONCAT('%', #{param.employeeName}, '%')
<!-- 错误:使用 ${} 存在 SQL 注入风险 -->
AND a.employee_name LIKE '%${param.employeeName}%'
4.5 时间范围查询注意事项
xml
<!-- XML 中 > 和 < 需要转义 -->
AND a.check_time >= #{param.checkTimeStart}
AND a.check_time <= #{param.checkTimeEnd}
<!-- 或者使用 CDATA 包裹 -->
<![CDATA[ AND a.check_time >= #{param.checkTimeStart} ]]>
五、分页机制
5.1 PageHelper 插件方式
java
PageHelper.startPage(pageNum, pageSize);
List<AttendanceResultDto> list = attendanceMapper.listAttendance(param);
PageInfo<AttendanceResultDto> pageInfo = new PageInfo<>(list);
5.2 参数传递方式
java
List<AttendanceResultDto> list = attendanceMapper.listAttendancePager(
param, param.getPageSize(), param.getPageNum());
YlhPageInfo<AttendanceResultDto> pageInfo = new YlhPageInfo<>(list);
5.3 两种方式对比
| 维度 | PageHelper | 参数传递 |
|---|---|---|
| 侵入性 | 低(调用前设置) | 低(参数传入) |
| 灵活性 | 高(支持多种数据库) | 中(依赖拦截器实现) |
| 线程安全 | ThreadLocal 存储 | 无状态 |
| 适用场景 | 通用 | 项目有自定义分页拦截器 |
六、Feign 远程数据补充
6.1 基本模式
java
@Service
public class AttendanceServiceImpl implements AttendanceService {
@Resource
private AttendanceMapper attendanceMapper;
@Resource
private EmployeeFeign employeeFeign;
@Override
public YlhPageInfo<AttendanceResultDto> listAttendancePager(
AttendanceQueryParamsDto paramsDto) {
// 1. 分页查询本地表
List<AttendanceResultDto> resultList = attendanceMapper.listAttendancePager(
paramsDto, paramsDto.getPageSize(), paramsDto.getPageNum());
if (CheckEmptyUtil.isNotEmpty(resultList)) {
// 2. 遍历补充远程数据
resultList.forEach(dto -> {
enrichEmployeeInfo(dto);
translateEnums(dto);
});
}
return new YlhPageInfo<>(resultList);
}
}
6.2 N+1 问题与优化
问题:每条记录都单独调用一次 Feign,一页 20 条就要调用 20 次。
java
// 反例:N+1 调用
resultList.forEach(dto -> {
RestControllerResult<EmployeeDto> result =
employeeFeign.getEmployeeById(dto.getEmployeeId());
});
优化:批量查询 + Map 映射
java
// 正例:一次批量查询
List<Integer> employeeIds = resultList.stream()
.map(AttendanceResultDto::getEmployeeId)
.distinct()
.collect(Collectors.toList());
RestControllerResult<List<EmployeeDto>> batchResult =
employeeFeign.listEmployeeByIds(employeeIds);
Map<Integer, EmployeeDto> employeeMap = batchResult.getData().stream()
.collect(Collectors.toMap(EmployeeDto::getId, e -> e, (k1, k2) -> k1));
resultList.forEach(dto -> {
EmployeeDto employee = employeeMap.get(dto.getEmployeeId());
if (employee != null) {
dto.setEmployeeName(employee.getName());
dto.setPosition(employee.getPosition());
}
});
6.3 Feign 调用的防御性编程
java
private void enrichEmployeeInfo(AttendanceResultDto dto) {
if (CheckEmptyUtil.isEmpty(dto.getEmployeeId())) {
return;
}
try {
RestControllerResult<EmployeeDto> result =
employeeFeign.getEmployeeById(dto.getEmployeeId());
// 三重判空
if (CheckEmptyUtil.isNotEmpty(result)
&& Objects.equals(result.getSuccess(), Boolean.TRUE)
&& CheckEmptyUtil.isNotEmpty(result.getData())) {
EmployeeDto employee = result.getData();
dto.setEmployeeName(employee.getName());
dto.setDeptName(employee.getDeptName());
}
} catch (Exception e) {
log.warn("Feign调用失败, employeeId={}, error={}",
dto.getEmployeeId(), e.getMessage());
}
}
七、枚举翻译
7.1 枚举定义
java
public enum AttendanceStatusEnum {
NORMAL(1, "正常"),
LATE(2, "迟到"),
EARLY_LEAVE(3, "早退"),
ABSENT(4, "缺勤");
private Integer code;
private String name;
AttendanceStatusEnum(Integer code, String name) {
this.code = code;
this.name = name;
}
public static String getNameByCode(Integer code) {
if (code == null) return null;
for (AttendanceStatusEnum e : values()) {
if (e.code.equals(code)) return e.name;
}
return null;
}
}
7.2 设计原则
- 数据库存编码,不存中文名称
- Service 层负责翻译
- 枚举类提供静态方法
getNameByCode
八、操作人信息格式化
java
// 格式:工号 姓名(中间空格分隔)
private String formatOperatorName(String staffNo, String staffName) {
StringBuilder sb = new StringBuilder();
if (CheckEmptyUtil.isNotEmpty(staffNo)) {
sb.append(staffNo);
}
if (CheckEmptyUtil.isNotEmpty(staffName)) {
if (sb.length() > 0) {
sb.append(" ");
}
sb.append(staffName);
}
return sb.toString().trim();
}
九、性能优化建议
| 优化点 | 说明 |
|---|---|
| SQL 层尽量过滤 | 能在 SQL 中过滤的条件不要放到 Java 层 |
| 避免 N+1 | 优先使用批量查询接口 |
| 合理设置分页大小 | 每页 20~50 条 |
| Feign 调用加 try-catch | 远程调用失败不影响主流程 |
| 索引覆盖筛选字段 | WHERE 条件中的字段确保有索引 |
| LEFT JOIN 控制数量 | 避免过多表关联 |
| 缓存热点数据 | 部门、枚举等变化少的数据可以缓存 |
十、常见问题
10.1 LEFT JOIN 导致数据重复
sql
-- 如果关联表中一个 ID 对应多条记录,LEFT JOIN 会产生重复行
-- 解决:加 LIMIT 1 子查询,或在业务层去重,或确保关联字段唯一
10.2 模糊搜索性能
sql
-- LIKE '%xxx%' 无法使用索引(左模糊)
-- 数据量大时考虑:全文索引、Elasticsearch、限制最少输入字符数
10.3 时间范围查询索引
sql
-- 确保时间字段有索引
CREATE INDEX idx_check_time ON attendance_record (check_time);
十一、最佳实践清单
- SQL 层过滤优先:本地表有的字段在 SQL 中过滤,远程数据只做展示补充
- 分页参数设默认值:Controller 层处理,避免全表扫描
- Feign 调用三重判空:结果不为空 + success=true + data 不为空
- Feign 调用加 try-catch:单条失败不影响整体列表
- 批量优于逐条:有批量接口时优先使用,减少网络开销
- 枚举翻译在 Service 层:不在 SQL 中硬编码中文
- 使用 CONCAT + #{} 防注入:模糊搜索不用 ${}
- XML 中转义特殊字符 :
>用>,<用< - ORDER BY 配合索引:排序字段建议有索引
- 日志记录关键参数:方便排查分页查询问题