1. 项目概述与背景
在现代Web应用开发中,数据分页和搜索功能几乎是每个项目的标配需求。当数据量达到数千甚至数万条时,一次性加载所有数据不仅会拖慢页面响应速度,还会给服务器和数据库带来不必要的压力。我在最近的一个电商后台管理系统项目中就遇到了这样的场景:用户表数据量超过10万条,管理员需要快速查找特定用户信息。
传统的全量查询方式在这个场景下完全不可行。页面加载时间超过15秒,用户体验极差。通过引入MyBatis分页和动态搜索功能,我们将查询响应时间控制在200毫秒以内,同时大幅降低了服务器资源消耗。
2. 技术栈选型与配置
2.1 后端技术栈解析
SpringBoot 3.x 作为基础框架,提供了自动配置、依赖管理等便利功能。选择最新版本3.x而非2.x,主要是为了利用其更好的性能和对Java 17的支持。
MyBatis 作为ORM框架,相比Hibernate提供了更灵活的SQL控制能力。对于需要复杂查询和性能优化的场景特别适合。
PageHelper 是MyBatis最流行的分页插件,其原理是通过拦截器在SQL执行前自动添加LIMIT语句。我们选择它的原因包括:
- 使用简单,只需几行代码即可实现分页
- 支持多种数据库(MySQL、Oracle等)
- 与MyBatis无缝集成
- 社区活跃,文档完善
2.2 前端技术栈解析
Vue 3 采用Composition API,相比Options API代码组织更灵活。特别是对于复杂组件,逻辑可以更好地复用和组合。
Element Plus 是Vue 3的UI组件库,提供了现成的表格和分页组件。其表格组件支持:
- 分页集成
- 排序
- 筛选
- 自定义列渲染
- 响应式布局
axios 作为HTTP客户端,相比原生fetch API提供了更丰富的功能:
- 请求/响应拦截器
- 自动转换JSON数据
- 客户端支持防御XSRF
- 请求取消
2.3 环境准备与依赖配置
后端Maven依赖配置关键点:
xml复制<!-- PageHelper分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.6</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</exclusion>
</exclusions>
</dependency>
这里排除了内置的MyBatis依赖是为了避免与项目中的其他MyBatis版本冲突。在实际项目中,我遇到过因为版本冲突导致的分页失效问题,所以这个排除配置很重要。
前端npm依赖安装:
bash复制npm install axios element-plus --save
3. 后端分页实现详解
3.1 PageHelper工作原理
PageHelper通过MyBatis的拦截器机制实现分页。当调用PageHelper.startPage()方法后,它会创建一个Page对象并存入ThreadLocal中。在执行SQL时,拦截器会检测到这个Page对象,并自动改写原始SQL,添加数据库特定的分页语句。
对于MySQL,它会将:
sql复制SELECT * FROM user
改写为:
sql复制SELECT * FROM user LIMIT 0,10
3.2 Service层实现
完整的Service层分页实现:
java复制@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public PageInfo<User> selectPage(Integer pageNum, Integer pageSize, User query) {
// 开启分页,必须放在查询方法前
PageHelper.startPage(pageNum, pageSize);
// 执行查询
List<User> users = userMapper.findAll(query);
// 包装为PageInfo对象
return PageInfo.of(users);
}
}
关键点:
PageHelper.startPage()必须放在查询方法前调用- 查询方法返回的是普通List,但实际已经是分页后的结果
PageInfo包含了丰富的分页信息(总页数、当前页、每页条数等)
3.3 Controller层设计
RESTful风格的Controller实现:
java复制@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public Result<PageInfo<User>> listUsers(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
UserQuery query) {
PageInfo<User> pageInfo = userService.selectPage(page, size, query);
return Result.success(pageInfo);
}
}
这里使用了@RequestParam的defaultValue属性为分页参数提供默认值,避免前端不传参数时的异常。
3.4 分页参数合理化
在实际项目中,我遇到过恶意用户传入超大pageSize(如10000)导致的内存溢出问题。解决方案是在拦截器中对参数进行校验:
java复制public PageInfo<User> selectPage(Integer pageNum, Integer pageSize, User query) {
// 参数校验
if(pageSize > 100) {
pageSize = 100;
}
PageHelper.startPage(pageNum, pageSize);
// ...
}
4. 动态搜索功能实现
4.1 MyBatis动态SQL
MyBatis提供了强大的动态SQL能力,通过XML配置可以实现条件查询:
xml复制<select id="findAll" resultType="User" parameterType="User">
SELECT * FROM user
<where>
<if test="username != null and username != ''">
AND username LIKE CONCAT('%', #{username}, '%')
</if>
<if test="address != null and address != ''">
AND address LIKE CONCAT('%', #{address}, '%')
</if>
<if test="startTime != null">
AND create_time >= #{startTime}
</if>
<if test="endTime != null">
AND create_time <= #{endTime}
</if>
</where>
ORDER BY id DESC
</select>
<where>标签会自动处理AND连接问题,避免SQL语法错误。我在项目中遇到过因为忘记处理第一个条件的AND导致SQL报错的情况,使用<where>标签可以有效避免这个问题。
4.2 前端查询表单设计
使用Element Plus的表单组件构建搜索条件:
vue复制<template>
<el-form :model="queryForm" @submit.prevent="handleSearch">
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="用户名">
<el-input v-model="queryForm.username" placeholder="请输入用户名"/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="地址">
<el-input v-model="queryForm.address" placeholder="请输入地址"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="创建时间">
<el-date-picker
v-model="queryForm.timeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"/>
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button type="primary" native-type="submit">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
const queryForm = reactive({
username: '',
address: '',
timeRange: []
})
const handleSearch = () => {
const params = {
...queryForm,
startTime: queryForm.timeRange[0],
endTime: queryForm.timeRange[1]
}
loadData(params)
}
const handleReset = () => {
queryForm.username = ''
queryForm.address = ''
queryForm.timeRange = []
loadData()
}
</script>
5. 前后端交互实现
5.1 跨域解决方案
前后端分离项目必须解决跨域问题。SpringBoot中通过@CrossOrigin注解或全局配置实现:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
生产环境建议:
- 不要使用
allowedOrigins("*") - 通过配置中心管理允许的域名列表
- 对于敏感接口,限制允许的HTTP方法
5.2 axios封装与拦截器
完整的axios封装示例:
javascript复制// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30000
})
// 请求拦截器
service.interceptors.request.use(config => {
const userStore = useUserStore()
// 添加token
if (userStore.token) {
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
// 序列化GET请求参数
if (config.method === 'get' && config.params) {
let url = config.url + '?'
for (const key in config.params) {
if (config.params[key] !== undefined && config.params[key] !== null) {
url += `${key}=${encodeURIComponent(config.params[key])}&`
}
}
config.url = url.slice(0, -1)
config.params = {}
}
return config
}, error => {
return Promise.reject(error)
})
// 响应拦截器
service.interceptors.response.use(response => {
const res = response.data
if (res.code !== 200) {
ElMessage.error(res.message || 'Error')
// 特殊状态码处理
if (res.code === 401) {
// 跳转登录
}
return Promise.reject(new Error(res.message || 'Error'))
} else {
return res
}
}, error => {
// 处理HTTP错误状态码
if (error.response) {
switch (error.response.status) {
case 400:
error.message = '请求错误'
break
case 401:
error.message = '未授权,请登录'
// 跳转登录页
break
case 403:
error.message = '拒绝访问'
break
case 404:
error.message = `请求地址出错: ${error.response.config.url}`
break
case 500:
error.message = '服务器内部错误'
break
default:
error.message = `连接错误 ${error.response.status}`
}
} else {
error.message = '连接到服务器失败'
}
ElMessage.error(error.message)
return Promise.reject(error)
})
export default service
6. 前端页面实现
6.1 表格与分页组件
完整的数据表格实现:
vue复制<template>
<div class="app-container">
<!-- 搜索表单 -->
<SearchForm @search="handleSearch" @reset="handleReset"/>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55"/>
<el-table-column prop="id" label="ID" width="80"/>
<el-table-column prop="username" label="用户名"/>
<el-table-column prop="email" label="邮箱"/>
<el-table-column prop="phone" label="手机号"/>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{row}">
{{ formatDate(row.createTime) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { formatDate } from '@/utils/date'
import request from '@/utils/request'
const loading = ref(false)
const tableData = ref([])
const selectedRows = ref([])
const pagination = reactive({
page: 1,
size: 10,
total: 0
})
const queryParams = reactive({})
const loadData = async (params = {}) => {
try {
loading.value = true
const res = await request.get('/api/users', {
params: {
...queryParams,
...pagination,
...params
}
})
tableData.value = res.data.list
pagination.total = res.data.total
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
const handleSearch = (params) => {
Object.assign(queryParams, params)
pagination.page = 1
loadData()
}
const handleReset = () => {
Object.keys(queryParams).forEach(key => {
queryParams[key] = undefined
})
loadData()
}
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
// 初始化加载数据
onMounted(() => {
loadData()
})
</script>
6.2 日期格式化处理
前端日期格式化工具函数:
javascript复制// src/utils/date.js
export function formatDate(dateString, format = 'YYYY-MM-DD HH:mm:ss') {
if (!dateString) return ''
const date = new Date(dateString)
const padZero = (num) => (num < 10 ? `0${num}` : num)
const replacements = {
'YYYY': date.getFullYear(),
'MM': padZero(date.getMonth() + 1),
'DD': padZero(date.getDate()),
'HH': padZero(date.getHours()),
'mm': padZero(date.getMinutes()),
'ss': padZero(date.getSeconds())
}
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, match => replacements[match])
}
7. 性能优化与常见问题
7.1 分页性能优化
-
索引优化:确保分页查询的ORDER BY字段和WHERE条件字段有合适的索引。我曾经遇到过一个分页查询慢的问题,最后发现是因为没有为排序字段创建索引。
-
避免大偏移量:当页码很大时(如page=1000),LIMIT的效率会很低。解决方案:
- 使用"上一页/下一页"模式代替直接跳页
- 记录上一页的最后一条记录的ID,使用WHERE id > ? LIMIT ?查询
-
COUNT优化:PageHelper默认会执行COUNT查询获取总数,对于大表这会很慢。可以考虑:
- 缓存总数
- 使用近似计数(如EXPLAIN获取估算值)
- 对于不需要总数的场景,使用
PageHelper.startPage(pageNum, pageSize, false)
7.2 常见问题排查
问题1:分页不生效,返回所有数据
- 检查
PageHelper.startPage()是否在查询方法前调用 - 检查是否有多个MyBatis版本冲突
- 检查是否在分页查询中使用了二级缓存
问题2:排序结果不符合预期
- 检查ORDER BY字段是否有索引
- 检查是否有多个排序条件冲突
- 对于多表关联查询,确保排序字段有表前缀
问题3:前端分页控件与数据不同步
- 确保将后端返回的total值赋给分页组件
- 检查current-page和page-size是否使用v-model双向绑定
- 确保分页事件(@size-change, @current-change)正确绑定
8. 项目扩展与进阶
8.1 导出分页数据
实际项目中经常需要导出查询结果,实现方案:
java复制@GetMapping("/export")
public void exportUsers(UserQuery query, HttpServletResponse response) {
// 设置响应头
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode("用户数据", "UTF-8");
response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx");
// 禁用分页,查询所有符合条件的数据
PageHelper.startPage(1, Integer.MAX_VALUE, false);
List<User> users = userMapper.findAll(query);
// 使用EasyExcel导出
EasyExcel.write(response.getOutputStream(), User.class)
.sheet("用户数据")
.doWrite(users);
}
8.2 多表关联分页
对于需要关联查询的场景,需要注意:
- 使用LEFT JOIN而不是多个单表查询
- 确保主表有索引
- 使用
PageHelper的countColumn属性指定计数列:
java复制PageHelper.startPage(pageNum, pageSize)
.setCountColumn("u.id"); // 指定用户表的主键作为计数列
List<UserDTO> users = userMapper.findUsersWithRoles();
8.3 前端缓存与防抖
对于频繁触发的搜索操作,建议:
- 使用防抖控制请求频率
- 缓存已加载的分页数据
javascript复制import { debounce } from 'lodash-es'
const search = debounce(() => {
loadData()
}, 500)
9. 安全注意事项
- SQL注入防护:
- 始终使用MyBatis的参数绑定(#{})
- 禁止拼接SQL(特别是前端传入的排序字段)
- 对于动态排序字段,使用白名单校验:
java复制// 安全的排序字段校验
private String validateSortField(String field) {
String[] allowedFields = {"id", "username", "create_time"};
if (Arrays.asList(allowedFields).contains(field)) {
return field;
}
return "id"; // 默认排序字段
}
-
分页参数校验:
- 限制最大pageSize
- 校验pageNum为正整数
-
接口防刷:
- 对分页接口添加限流
- 监控异常的分页请求(如频繁请求大页码)
10. 项目部署建议
-
前端部署:
- 生产环境使用Nginx部署
- 配置gzip压缩减少资源大小
- 设置合适的缓存策略
-
后端部署:
- 使用Tomcat或Undertow作为Servlet容器
- 配置连接池参数(如HikariCP)
- 启用MyBatis二级缓存(谨慎使用)
-
数据库优化:
- 配置合适的连接池大小
- 定期分析慢查询
- 对于超大表考虑分库分表
在最近的项目中,我们通过以上优化措施,将分页查询的响应时间从最初的2秒多降低到了200毫秒以内,效果非常显著。特别是在用户量大的后台管理系统中,合理的分页实现能大幅提升用户体验和系统稳定性。