当分页遇上多标签搜索:我是如何搞定这个“深分页“难题的

解决分页与多标签搜索的深分页难题

最近在做一个面试题库的功能,需求听起来挺简单:根据标题搜索题目,还能按多个标签筛选,再加个分页。结果一上手就踩坑了,特别是那个深分页问题,简直让人头大。今天就跟大家分享一下我是怎么一步步解决这个问题的。

问题现场还原

先看看我们的查询长啥样:

List<InterviewQuestion> findQuestions(
    @Param("title") String title,
    @Param("tagIds") List<Integer> tagIds,
    @Param("offset") int offset,
    @Param("limit") int limit
);

需求是:

  1. 能按标题模糊搜索(比如搜"设计模式")

  2. 能选多个标签筛选(比如同时选"Java"和"Spring"标签)

  3. 要支持分页(特别是跳到第100页这种深分页)

第一版:天真无邪的实现

刚开始我觉得这不就是写个SQL的事吗?于是写出了这样的XML:

<select id="findQuestions" resultType="InterviewQuestion">
  SELECT * FROM interview_question q
  LEFT JOIN question_tag qt ON q.id = qt.question_id
  WHERE 
    q.title LIKE CONCAT('%', #{title}, '%')
    <if test="tagIds != null and tagIds.size() > 0">
      AND qt.tag_id IN 
      <foreach collection="tagIds" item="tagId" open="(" separator="," close=")">
        #{tagId}
      </foreach>
    </if>
  GROUP BY q.id
  ORDER BY q.create_time DESC
  LIMIT #{offset}, #{limit}
</select>

看起来挺完美是吧?结果测试时发现两个致命问题:

  1. 深分页卡成狗:翻到第50页以后,页面加载要5秒以上

  2. 标签筛选结果不对:选了多个标签时,本该是"同时满足"的逻辑变成了"满足任意一个"

问题诊断

1. 深分页为什么慢?

MySQL的LIMIT 10000, 20意思是:先扫描前10020条记录,然后扔掉前10000条,返回剩下的20条。越往后翻,要扫描的数据就越多,自然就越来越慢。

2. 标签筛选为什么不对?

我们的JOIN+IN查询实际上找的是"带有任意一个指定标签"的题目,而不是"同时带有所有指定标签"的题目。比如题目A有Java标签,题目B有Spring标签,我们想找同时有Java和Spring的题目,但用IN查询会把A和B都查出来。

解决方案

第一步:解决标签筛选问题

要找出"同时拥有所有指定标签"的题目,得用GROUP BY + HAVING计数:

<select id="findQuestions" resultType="InterviewQuestion">
  SELECT q.* FROM interview_question q
  LEFT JOIN question_tag qt ON q.id = qt.question_id
  WHERE 
    q.title LIKE CONCAT('%', #{title}, '%')
    <if test="tagIds != null and tagIds.size() > 0">
      AND qt.tag_id IN 
      <foreach collection="tagIds" item="tagId" open="(" separator="," close=")">
        #{tagId}
      </foreach>
    </if>
  GROUP BY q.id
  HAVING COUNT(qt.tag_id) = #{tagCount}
  ORDER BY q.create_time DESC
  LIMIT #{offset}, #{limit}
</select>

注意这里新增了HAVING COUNT(qt.tag_id) = #{tagCount},其中tagCount是传入的tagIds列表长度。这样就能确保题目拥有全部指定的标签。

第二步:解决深分页问题

这里我试了三种方案:

方案1:游标分页(推荐)

不用传统的LIMIT offset, size,而是记住上一页最后一条记录的ID:

List<InterviewQuestion> findQuestionsAfterId(
    @Param("title") String title,
    @Param("tagIds") List<Integer> tagIds,
    @Param("lastId") Long lastId,
    @Param("limit") int limit
);

SQL改为:

<select id="findQuestionsAfterId" resultType="InterviewQuestion">
  SELECT q.* FROM interview_question q
  LEFT JOIN question_tag qt ON q.id = qt.question_id
  WHERE 
    q.title LIKE CONCAT('%', #{title}, '%')
    AND q.id < #{lastId}
    <if test="tagIds != null and tagIds.size() > 0">
      AND qt.tag_id IN 
      <foreach collection="tagIds" item="tagId" open="(" separator="," close=")">
        #{tagId}
      </foreach>
    </if>
  GROUP BY q.id
  HAVING COUNT(qt.tag_id) = #{tagCount}
  ORDER BY q.id DESC
  LIMIT #{limit}
</select>

这种方案性能最好,但需要前端配合改成分页按钮变成"加载更多"的模式。

方案2:子查询优化

如果必须用传统分页,可以先用子查询找出符合条件的ID,再关联查详情:

<select id="findQuestions" resultType="InterviewQuestion">
  SELECT q.* FROM interview_question q
  WHERE q.id IN (
    SELECT q.id FROM interview_question q
    LEFT JOIN question_tag qt ON q.id = qt.question_id
    WHERE 
      q.title LIKE CONCAT('%', #{title}, '%')
      <if test="tagIds != null and tagIds.size() > 0">
        AND qt.tag_id IN 
        <foreach collection="tagIds" item="tagId" open="(" separator="," close=")">
          #{tagId}
        </foreach>
      </if>
    GROUP BY q.id
    HAVING COUNT(qt.tag_id) = #{tagCount}
    ORDER BY q.create_time DESC
    LIMIT #{offset}, #{limit}
  )
  ORDER BY q.create_time DESC
</select>
方案3:缓存总结果

对于数据变化不频繁的场景,可以把符合条件的ID列表缓存起来,分页时直接从缓存取ID范围。

最终选择

我们项目最终选择了游标分页+缓存的组合方案:

  1. 默认用游标分页,性能最好

  2. 对于必须用传统分页的页面,使用子查询优化方案

  3. 热门搜索条件的结果ID列表缓存10分钟

性能对比

优化前(第50页):

  • 执行时间:4.8秒

  • SQL扫描行数:5020行

优化后(第50页):

  • 执行时间:0.12秒

  • SQL扫描行数:20行

经验总结

  1. 深分页一定要避免OFFSET:数据量一大就现原形

  2. 多标签筛选要用HAVING COUNT:简单的IN查询结果不对

  3. JOIN操作要小心:特别是多对多关系,很容易产生性能问题

  4. 根据业务场景选择方案:没有银弹,只有最适合的方案

希望这篇实战经验能帮到遇到类似问题的同学。如果你有更好的解决方案,欢迎在评论区交流!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Leaton Lee

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值