最近在做一个面试题库的功能,需求听起来挺简单:根据标题搜索题目,还能按多个标签筛选,再加个分页。结果一上手就踩坑了,特别是那个深分页问题,简直让人头大。今天就跟大家分享一下我是怎么一步步解决这个问题的。
问题现场还原
先看看我们的查询长啥样:
List<InterviewQuestion> findQuestions(
@Param("title") String title,
@Param("tagIds") List<Integer> tagIds,
@Param("offset") int offset,
@Param("limit") int limit
);
需求是:
-
能按标题模糊搜索(比如搜"设计模式")
-
能选多个标签筛选(比如同时选"Java"和"Spring"标签)
-
要支持分页(特别是跳到第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>
看起来挺完美是吧?结果测试时发现两个致命问题:
-
深分页卡成狗:翻到第50页以后,页面加载要5秒以上
-
标签筛选结果不对:选了多个标签时,本该是"同时满足"的逻辑变成了"满足任意一个"
问题诊断
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范围。
最终选择
我们项目最终选择了游标分页+缓存的组合方案:
-
默认用游标分页,性能最好
-
对于必须用传统分页的页面,使用子查询优化方案
-
热门搜索条件的结果ID列表缓存10分钟
性能对比
优化前(第50页):
-
执行时间:4.8秒
-
SQL扫描行数:5020行
优化后(第50页):
-
执行时间:0.12秒
-
SQL扫描行数:20行
经验总结
-
深分页一定要避免OFFSET:数据量一大就现原形
-
多标签筛选要用HAVING COUNT:简单的IN查询结果不对
-
JOIN操作要小心:特别是多对多关系,很容易产生性能问题
-
根据业务场景选择方案:没有银弹,只有最适合的方案
希望这篇实战经验能帮到遇到类似问题的同学。如果你有更好的解决方案,欢迎在评论区交流!
解决分页与多标签搜索的深分页难题
908

被折叠的 条评论
为什么被折叠?



