多数据查询渲染耗时问题

34 阅读9分钟

问题描述:

下面代码在执行的时候,当数据库里的记录数量为16237的时候,后端查询需要的耗时为1003毫秒,也就是将近1秒

页面的渲染更久,会出现将近5~10秒的空白页面,因为前端没有任何的优化,主包本身也是后端仔,前端只会写屎山代码。

@Override
public BaseResponse<List<UploadDownloadFile>> selectList(String userId, Integer type, HttpServletRequest request) {
    // 先判断当前登录用户
    String loginUserId = userService.getLoginUser(request).getId().toString();
    if (!loginUserId.equals(userId)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "你无权查看");
    }
    long start = System.currentTimeMillis();
    LambdaQueryWrapper<UploadDownloadFile> queryWrapper =
    new LambdaQueryWrapper<UploadDownloadFile>()
    .eq(UploadDownloadFile::getUserId, userId)
    .eq(UploadDownloadFile::getType, type);
    List<UploadDownloadFile> res = this.list(queryWrapper);
    long end = System.currentTimeMillis();
    System.out.println("执行耗时" + (end - start));
    return ResultUtils.success(res);
}

原本代码

发现数据库的代码是:

create table upload_download_file
(
  Id          varchar(36)                        not null comment 'Id'
  primary key,
  userId      varchar(36)                        not null comment '所属用户ID',
  fileId      varchar(36)                        not null,
  name        varchar(255)                       not null comment '文件名',
  type        tinyint                            not null comment '上传或是下载(0-上传,1-下载)',
  size        bigint                             not null comment '文件大小(字节)',
  storagePath varchar(255)                       null comment '文件存储路径',
  status      tinyint                            not null comment '上传/下载状态(0-成功,1-失败)',
  createTime  datetime default CURRENT_TIMESTAMP not null comment '创建时间',
  updateTime  datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
  isDelete    tinyint  default 0                 not null comment '是否删除'
)
    comment '上传下载文件表' collate = utf8mb4_unicode_ci;

create index idx_type
    on upload_download_file (type);

create index idx_user
    on upload_download_file (userId);

create index userId_type__index
    on upload_download_file (userId, type);

但是发现原本就是有索引的,但是查看了查询的条件,发现是两个字段,而且有一个字段(type)存储的值只会是0和1,那么这个索引就是无效索引,所以肯定是不行的,必须使复合索引

也就是:userId_type__index(userId+type)

但是这个索引还是不行,执行的耗时还是很慢,如何就去查询了控制台的日志输出,结果发现我竟然忘记了一个很重要的东西,那就是我是MP框架,并且开了全局的逻辑删除,那么自动就会在查询语句后面添加上 isDelete = 0

JDBC Connection [HikariProxyConnection@972113608 wrapping com.mysql.cj.jdbc.ConnectionImpl@12b5bbb1] will not be managed by Spring
==>  Preparing: SELECT Id,userId,fileId,name,type,size,storagePath,status,createTime,updateTime,isDelete FROM upload_download_file WHERE isDelete=0 AND (userId = ? AND type = ?)
==> Parameters: 1913162264816992257(String), 0(Integer)
<==    Columns: Id, userId, fileId, name, type, size, storagePath, status, createTime, updateTime, isDelete
<==        Row: 1919013237811101697, 1913162264816992257, 1919013237685272578, 1.1_1_操作系统的概念、功能.pdf, 0, 4828633, null, 0, 2025-05-04 20:56:24, 2025-05-04 20:56:24, 0
<==      Total: 1
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@415f9324]

所以最终版的索引就加上了isDelete,也就是:

现在数据量不变的执行耗时是:1057ms

嗯?这怎么好像还是没有优化啊,和原来差不多,那到底是怎么回事呢?

原因就是因为要返回的字段不包含在索引中,那么就会导致回表操作,这样子就会导致执行的时间非常的长,索引也就起不到优化的效果。但是我要返回的数据几乎是整个表中的所有字段,如果直接对所有的字段建立索引的话,可以说非常的浪费空间,性价比太低了。

所以应该从其他的方面进行优化,那我们能不能直接通过将接口改造成分页查询的方式,也就是说每次其实只查询100条数据,或是500条数据这样子,可行吗?

直接上代码试一下

@Override
public BaseResponse<List<UploadDownloadFile>> selectList(String userId, Integer type, String lastId, Integer pageSize, HttpServletRequest request) {
    // 先判断当前登录用户
    String loginUserId = userService.getLoginUser(request).getId().toString();
    if (!loginUserId.equals(userId)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "你无权查看");
    }
    pageSize = (pageSize == null || pageSize < 1 || pageSize > 100) ? 100 : pageSize;

    long start = System.currentTimeMillis();
    LambdaQueryWrapper<UploadDownloadFile> queryWrapper =
    new LambdaQueryWrapper<UploadDownloadFile>()
    .eq(UploadDownloadFile::getUserId, userId)
    .eq(UploadDownloadFile::getType, type)
    .orderByAsc(UploadDownloadFile::getId);  // 必须按 ID 升序排序

    // 首次查询:lastId 为空或为初始值(如 0)
    if (lastId != null && !lastId.equals("")) {
        queryWrapper.gt(UploadDownloadFile::getId, lastId);
    }

    queryWrapper.last("LIMIT " + pageSize);  // 限制页大小
    List<UploadDownloadFile> res = this.list(queryWrapper);
    long end = System.currentTimeMillis();
    System.out.println("执行耗时" + (end - start));
    return ResultUtils.success(res);
}

因为现在一次只获取100条数据,所以执行耗时只需要10毫秒以内,非常的快,并且小弟这里也是考虑到了如果是使用传统的 Limit pageNum, pageSize 这种方式可能会造成深分页问题,那么这里能怎么改呢?

其实答案很明显,就是使用大家都知道的游标分页。

这里也贴上另一位大佬的博客分享: https://zhuanlan.zhihu.com/p/9908424603

游标分页就能解决这种深分页的问题,所以直接使用了游标分页,但是要求就是需要知道上一次数据的最后一个Id,并且要求你的表的Id是增长的,好在我这里数据库的Id是使用雪花算法(一般只要机器的时间不要出现回播或是不同节点生成的Id可能存在局部不连续递增,应该是不会有什么太大问题),那么就直接使用了游标分页的方式。

然后这是我的项目,刚好是这种滚动的,没有支持直接跳转到某一页的功能,所以能直接使用游标分页

像一些网页如果是要实现直接跳转到某一页的话就没法使用游标分页的方式,以为这种分页方式需要前后端来配合,前端需要将上一页的最后一个Id(lastId)传递给后端,如果是跳转的话,就没办法记录这个 lastId

下面一并附上前端的代码

<template>
  <div class="upload-container">
    <!-- 顶部统计栏 -->
    <div class="header">
      <span class="total-progress"
        >共上传<t style="color: #3c80ff; margin-left: 5px; margin-right: 5px">{{
          length
        }}</t
        >个文件</span
      >
      <el-button type="primary" @click="handleCleanAll">清空全部记录</el-button>
    </div>

    <!-- 上传任务列表 -->
    <div class="task-list" @scroll="handleScroll" ref="listContainer">
      <div v-for="task in tasks" :key="task.id" class="task-item">
        <!-- 文件名列(占4份) -->
        <div class="filename">
          <span class="text">{{ truncateName(task.name) }}</span>
        </div>

        <!-- 大小列(占2份) -->
        <div class="size-column">
          <span class="size">{{ formatSize(task.size) }}</span>
        </div>

        <!-- 状态列(占2份) -->
        <div class="status-column">
          <span class="status">{{
            task.status === 0 ? "下载成功" : "下载失败"
          }}</span>
        </div>

        <!-- 操作列(占2份) -->
        <div class="action-column">
          <div class="actions">
            <el-icon class="action-icon"><Document /></el-icon>
            <el-icon class="action-icon"><FolderOpened /></el-icon>
            <el-icon class="action-icon" @click="handleCleanOne(task)"
              ><Delete
            /></el-icon>
          </div>
        </div>
      </div>
      <!-- 加载提示 -->
      <div v-if="loading" class="loading-text">加载中...</div>
      <div v-if="!hasMore" class="no-more-text">没有更多数据了</div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
import { Document, FolderOpened, Delete } from "@element-plus/icons-vue";
import { UploadDownloadFileControllerService } from "../../generated";
import { useStore } from "vuex";
import { ElMessage } from "element-plus";
import _ from "lodash";

const store = useStore();
const tasks = ref([]);
// eslint-disable-next-line no-undef
const emit = defineEmits(["refresh"]);
const listContainer = ref(null); // 滚动容器引用
const loading = ref(false); // 加载状态
const hasMore = ref(true); // 是否有更多数据
const lastId = ref(null); // 修改初始值为null

// 获取下载完成列表
const fetchDownloadedTasks = async () => {
  if (loading.value || !hasMore.value) return;

  try {
    loading.value = true;

    const res = await UploadDownloadFileControllerService.selectListUsingGet(
      0, // type=0
      store.state.user.loginUser.id, // 修正参数顺序:userId在前
      lastId.value, // 使用当前lastId
      100
    );

    if (res.code === 0 && res.data) {
      // 追加数据而不是替换
      tasks.value = [
        ...tasks.value,
        ...res.data.map((item) => ({
          ...item,
          size: Number(item.size),
        })),
      ];

      // 更新lastId
      if (res.data.length > 0) {
        lastId.value = res.data[res.data.length - 1].id;
      }

      // 判断是否还有更多数据
      hasMore.value = res.data.length === 100;
    }
  } catch (error) {
    console.error("获取数据失败:", error);
  } finally {
    loading.value = false;
  }
};

// 滚动处理(添加防抖)
const handleScroll = _.throttle((e) => {
  const container = e.target;
  const { scrollTop, scrollHeight, clientHeight } = container;
  // 滚动到底部(留50px缓冲)
  if (scrollHeight - scrollTop - clientHeight < 50) {
    fetchDownloadedTasks();
  }
}, 200);

// 检查初始加载是否需要补充数据
const checkInitialLoad = () => {
  const container = listContainer.value;
  if (container && container.clientHeight >= container.scrollHeight) {
    fetchDownloadedTasks();
  }
};

let length = ref(0);
// 获取总记录数
const getCount = async () => {
  try {
    const res = await UploadDownloadFileControllerService.getCountUsingGet(
      0,
      store.state.user.loginUser.id
    );
    if (res.code === 0) {
      console.log("总记录数量:", res.data);
      length.value = res.data;
    }
  } catch (error) {
    console.error("获取总记录数量失败:", error);
  }
};

// 清空全部记录
const handleCleanAll = async () => {
  const res = await UploadDownloadFileControllerService.deleteAllUsingGet(
    0,
    store.state.user.loginUser.id
  );
  if (res.code !== 0) {
    ElMessage.error(res.message || "清空上传完成列表失败");
    return;
  }
  ElMessage.success("清空上传完成列表成功");
  await fetchDownloadedTasks();
  emit("refresh");
  tasks.value = [];
  lastId.value = null;
  hasMore.value = true;
};

// 删除单条记录
const handleCleanOne = async (task) => {
  const res = await UploadDownloadFileControllerService.deleteOneUsingGet(
    task.id,
    0,
    store.state.user.loginUser.id
  );
  if (res.code !== 0) {
    ElMessage.error(res.message || "删除记录失败");
    return;
  }
  ElMessage.success("删除记录成功");
  await fetchDownloadedTasks();
  emit("refresh");
};

// 按创建时间降序排序
const sortedTasks = computed(() => {
  return [...tasks.value].sort((a, b) =>
    b.createTime.localeCompare(a.createTime)
  );
});

// 文件大小格式化(示例:7327511 → 7.33MB)
const formatSize = (bytes) => {
  const units = ["B", "KB", "MB", "GB"];
  if (bytes === 0) return "0.00B";
  const exp = Math.floor(Math.log(bytes) / Math.log(1024));
  return `${(bytes / Math.pow(1024, exp)).toFixed(2)}${units[exp]}`;
};

// 文件名截断
const truncateName = (name) => {
  const maxLength = 24;
  return name.length > maxLength ? `${name.slice(0, maxLength - 3)}...` : name;
};

onMounted(() => {
  fetchDownloadedTasks();
  setTimeout(checkInitialLoad, 500);
  getCount();
});
</script>

<style scoped>
.upload-container {
  padding: 20px;
}

.header {
  display: flex;
  align-items: center;
  padding: 12px 0;
  margin-bottom: 8px;
}

.total-progress {
  font-size: 16px;
  color: #606266;
  margin-left: 1%;
  margin-right: auto;
}

.task-list {
  background: white;
  border-radius: 4px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
  max-height: 85vh; /* 根据实际高度调整 */
  overflow-y: auto;
  padding: 0 8px;
}

.loading-text,
.no-more-text {
  text-align: center;
  padding: 16px;
  color: #909399;
  font-size: 14px;
}

.task-item {
  display: grid;
  grid-template-columns: 4fr 2fr 2fr 2fr; /* 4:2:2:2 比例 */
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid #ebeef5;
}

.filename {
  justify-self: start; /* 强制左对齐 */
  width: 100%; /* 确保占满网格列宽度 */
}

.filename .text {
  font-size: 14px;
  color: #303133;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-align: left;
  display: block;
  max-width: 100%;
}

.size-column {
  font-size: 12px;
  color: #909399;
  text-align: left;
}

.status-column {
  font-size: 12px;
  color: #909399;
  text-align: left;
}

.action-column {
  display: flex;
  justify-content: flex-end;
}

.actions {
  display: flex;
  gap: 16px;
}

.action-icon {
  color: #909399;
  cursor: pointer;
  transition: color 0.3s;
  font-size: 18px;
}

.action-icon:hover {
  color: #409eff;
}

.task-item:last-child {
  border-bottom: none;
}
</style>

后端除了对接口进做出以上改变之外还需要再新增一个接口,通过上面的页面的图可以发现,其实还需要知道总共的记录数量,所以后端还需要提供一个接口来查询总的记录数量,原本是直接一次性获取所有的数据,直接用这些数据的length属性来表示数量。

@Override
public BaseResponse<Long> getCount(String userId, Integer type, HttpServletRequest request) {
    String loginUserId = userService.getLoginUser(request).getId().toString();
    if (!loginUserId.equals(userId)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "你无权操作");
    }
    long start = System.currentTimeMillis();
    LambdaQueryWrapper<UploadDownloadFile> queryWrapper =
            new LambdaQueryWrapper<UploadDownloadFile>()
                    .eq(UploadDownloadFile::getUserId, userId)
                    .eq(UploadDownloadFile::getType, type);
    long count = this.count(queryWrapper);
    long end = System.currentTimeMillis();
    System.out.println("执行查询耗时:" + (end - start));
    return ResultUtils.success(count);
}

这个时候前面建立的索引就能发挥作用了

总结:对于响应慢以及长时间空白页的解决一共是几步:

(1)直接游标分页查询

解决了单次查询数据量大导致耗时的问题,同时也避免了深分页问题

(2)新增一个查询所有记录数量的接口

弥补了接口改动导致无法直接通过length属性获取总的记录数的问题

(3)前端通过一个滚动监听,当前面的100条记录数被翻过之后就再去查询接下去的100条进行拼接到后面

谢谢观看🌹🌹🌹