在博客系统里,搜索通常被视为一个很简单的功能:用户输入关键词,后端执行查询,再把结果展示出来。
但“能搜”与“搜得可靠”并不是一回事。
在这个项目中,我遇到过一个很有代表性的现象:博客首页明明存在多篇标题包含“AI”的文章,搜索 AI 后却进入了空结果页。更容易误导用户的是,页面显示的还是“页面不见啦”,看起来像文章被删除或者地址错误,而不是一次正常但无匹配项的搜索。
![]()
这个问题表面上只是两字母关键词失效,背后却同时涉及:
- MySQL 全文检索的分词规则
- 两套检索条件错误叠加
- URL 路径与特殊字符编码
- 查询总数和分页列表的一致性
- 缓存键隔离
- 搜索空状态的产品语义
- 旧地址兼容与回归测试
本文以项目中的真实重构为例,说明如何把一个“偶尔搜不到”的搜索框,改造成一条行为明确、结果稳定、可以被自动化验证的搜索链路。
一、问题现象:HTTP 200 不代表搜索正常
最初排查时,请求 /search/AI 返回的是 HTTP 200,控制器和模板也都正常执行,没有 404,也没有数据库异常。
然而页面结果为空。
这类问题比直接报错更难发现,因为监控系统通常只会看到“请求成功”。如果测试只验证状态码,也会得到一个错误的结论:搜索功能正常。
真正需要验证的是用户可见结果:
- 标题、摘要或正文包含关键词的已发布文章是否被返回;
- 大小写不同是否影响结果;
%、_等字符是否会意外变成 SQL 通配符;- 翻页后是否仍然保持同一个关键词;
- 无结果时是否显示正确的搜索提示,而不是 404 文案。
这说明搜索测试不能停留在“接口可访问”,而要覆盖从输入到最终 HTML 的完整链路。
二、根因:把全文检索和模糊匹配做成了交集
原来的查询思路可以简化为:
WHERE MATCH(article_title, article_content)
AGAINST (#{keywords} IN NATURAL LANGUAGE MODE)
AND (
article_title LIKE CONCAT('%', #{keywords}, '%')
OR article_summary LIKE CONCAT('%', #{keywords}, '%')
OR article_content LIKE CONCAT('%', #{keywords}, '%')
)
它看起来像是“双保险”:先使用全文索引,再用 LIKE 确认文章中确实包含关键词。
实际上,这里得到的是两个条件的交集,而不是兜底关系。
flowchart LR
A[用户输入 AI] --> B[全文检索 MATCH]
A --> C[可见文本包含判断]
B --> D{两者同时成立}
C --> D
D -->|是| E[返回文章]
D -->|否| F[空结果]
全文检索并不是简单的字符串包含判断。它会受到存储引擎、分词器、停用词、最小 token 长度和相关度计算等因素影响。像 AI 这样的短关键词,有可能没有进入有效的全文检索 token 集合。
一旦 MATCH 返回不匹配,即使标题里的确包含 AI,后面的 LIKE 条件也没有机会“救回”这篇文章,因为 SQL 要求两组条件同时为真。
因此,真正的问题不是 LIKE 失效,而是系统把一种可能丢弃短词的检索算法放在了强制过滤位置。
这次重构最重要的设计判断是:
用户能否搜到文章,应由可解释的文本包含规则决定;全文检索只负责辅助排序,不能决定文章是否有资格进入结果集。
三、先定义搜索契约,而不是先改 SQL
如果没有明确契约,修复很容易退化为针对 AI 写一个特殊分支,例如:关键词长度小于 3 时用 LIKE,其他情况继续全文检索。
这种方案本质上仍然依赖数据库当前的分词配置,也会制造新的边界:为什么是 3?中文、英文、数字和符号是否采用同一规则?配置变化后代码是否仍然成立?
因此,本次改造没有为 AI 设置任何硬编码特例,而是先统一搜索行为:
- 搜索范围:文章标题、摘要和正文可见文本;
- 数据范围:只返回已发布的正式文章;
- 匹配方式:不区分英文字母大小写;
- 特殊字符:按用户输入的普通字符处理;
- 排序优先级:标题完全匹配、标题包含、摘要包含、正文包含;
- 次级排序:全文相关度和发布时间;
- 空白输入:不访问数据库,直接返回空分页;
- 越界页码:自动归一到有效范围;
- 旧搜索地址:继续可用,避免历史链接失效。
契约明确后,控制器、服务层、Mapper、模板和测试才能围绕同一个结果工作。
四、入口治理:关键词应该放在查询参数里
旧地址采用路径变量:
/search/AI
/search/AI/2
路径变量适合资源标识,但搜索词是用户输入,可能包含空格、斜杠、问号、百分号、中文和其他特殊字符。把它直接拼进路径会增加路由歧义,也更容易出现编码不一致。
新的主入口改成了标准查询参数:
/search?q=AI&page=2
控制器同时保留旧路由,并统一委托给同一个渲染方法:
@GetMapping("search")
public String searchQuery(
Model model,
@RequestParam(name = "q", defaultValue = "") String keywords,
@RequestParam(name = "page", defaultValue = "1") Integer page,
HttpServletRequest request) {
return renderSearch(model, keywords, page, request);
}
@GetMapping("search/{keywords}")
public String search(Model model,
@PathVariable String keywords,
HttpServletRequest request) {
return renderSearch(model, keywords, 1, request);
}
浏览器端不能直接拼接原始输入,而要统一编码:
window.location.href = '/search?q=' + encodeURIComponent(keyword);
这不是单纯的 URL 美化。统一入口可以避免页头搜索、页脚搜索、移动端搜索和分页分别实现不同的编码规则。
五、服务层治理:归一化、分页和缓存必须协同
用户输入往往带有不可见差异,例如:
AI 评论
AI 评论
AI\t评论
从搜索意图看,它们应当是同一个关键词。因此服务层会先去除首尾空白,再把连续空白压缩为一个空格:
private String normalizeSearchKeyword(String keywords) {
if (keywords == null) {
return "";
}
return WHITESPACE_PATTERN
.matcher(keywords.trim())
.replaceAll(" ");
}
归一化后再处理分页边界:
int safePage = page == null || page < 1 ? 1 : page;
int safeLimit = limit == null || limit < 1 ? 10 : limit;
if (keyword.length() == 0) {
return buildArticleSearchPage(
Collections.emptyList(), safePage, safeLimit, 0);
}
空关键词直接返回,既避免一次没有意义的全表扫描,也防止空字符串被解释为“匹配全部文章”。
查询时先获取总数。总数为零,就不再执行列表查询:
Long totalResult = articleMapperCustom.countSearchByVisibleText(
keyword,
PostType.POST_TYPE_POST.getValue(),
ArticleStatus.PUBLISH.getStatus()
);
long total = totalResult == null ? 0 : totalResult;
if (total == 0) {
return buildArticleSearchPage(
Collections.emptyList(), 1, safeLimit, 0);
}
如果请求页码超过最后一页,则落到最后一个有效页:
int pages = (int) ((total + safeLimit - 1) / safeLimit);
int pageNum = Math.min(safePage, pages);
int offset = (pageNum - 1) * safeLimit;
缓存键也需要包含关键词、页码和每页数量:
@Cacheable(
value = "articles",
key = "'visibleSearch:'+#keywords+':'+#page+':'+#limit"
)
否则,不同关键词或不同页码可能错误复用同一份结果。更换缓存键版本还能隔离修复前已经写入的错误缓存,避免代码上线后仍短暂展示旧结果。
六、SQL 重构:字面匹配负责召回,全文检索负责排序
新的查询使用 LOCATE 判断关键词是否出现在标题、摘要或正文中:
WHERE (
LOCATE(
LOWER(#{keywords}),
LOWER(COALESCE(article_title, ''))
) > 0
OR LOCATE(
LOWER(#{keywords}),
LOWER(COALESCE(article_summary, ''))
) > 0
OR LOCATE(
LOWER(#{keywords}),
LOWER(REGEXP_REPLACE(
COALESCE(article_content, ''),
'<[^>]+>',
''
))
) > 0
)
AND article_post = #{post}
AND article_status = #{status}
这里有四个细节。
1. LOWER 统一大小写
搜索 AI 和 ai 会得到一致结果,不依赖数据库当前列的排序规则是否区分大小写。
2. COALESCE 处理空字段
标题、摘要或正文为 NULL 时,将其转为空字符串,避免整个表达式变成 NULL。
3. 正文先去除 HTML 标签
文章正文存储的是 HTML。搜索面向用户可见内容,因此先使用 REGEXP_REPLACE 去除标签,再执行包含判断,避免标签名本身干扰结果。
4. 使用 LOCATE 而不是动态 LIKE
如果使用:
LIKE CONCAT('%', #{keywords}, '%')
用户输入中的 % 和 _ 会天然拥有 SQL 通配语义。除非额外编写转义规则,否则用户搜索的就不再是原始字面值。
LOCATE(needle, haystack) 直接查找字符串位置,% 和 _ 都只是普通字符,搜索契约更容易解释,也更容易测试。
七、排序不能只看“匹配了没有”
可靠搜索不仅要召回正确结果,也要把更相关的文章放在前面。
本次重构使用分层排序:
ORDER BY
CASE
WHEN LOWER(COALESCE(article_title, ''))
= LOWER(#{keywords}) THEN 0
WHEN LOCATE(
LOWER(#{keywords}),
LOWER(COALESCE(article_title, ''))
) > 0 THEN 1
WHEN LOCATE(
LOWER(#{keywords}),
LOWER(COALESCE(article_summary, ''))
) > 0 THEN 2
ELSE 3
END,
MATCH(article_title, article_content)
AGAINST (#{keywords} IN NATURAL LANGUAGE MODE) DESC,
article_newstime DESC
这里保留了全文检索,但它只出现在 ORDER BY 中。
即使全文检索无法识别短词,它也只会失去一项相关度分值,不会把本应命中的文章从结果集中删除。最终的发布时间排序还能保证相关度相同时结果稳定。
这就是“全文检索辅助排序”和“全文检索强制过滤”的本质区别。
八、总数查询与列表查询必须使用同一套条件
分页经常出现一种隐蔽错误:列表查询已经改了,COUNT(*) 仍沿用旧条件。
这会造成:
- 页面显示有 10 页,但后面几页没有内容;
- 列表有结果,总数却是 0;
- 上一页、下一页状态错误;
- 请求越界后 offset 计算不稳定。
因此,计数查询同样使用标题、摘要和正文的 LOCATE 条件,并使用完全相同的文章类型和发布状态过滤。
一个实用原则是:
除了
SELECT、ORDER BY和LIMIT,分页列表与计数查询的过滤语义必须一致。
九、空结果不是 404,页面文案也是搜索契约的一部分
搜索不到文章是一种正常业务状态,而 404 表示目标资源不存在。两者不能共用同一套提示。
旧页面显示:
哎呀,页面不见啦!
我们找不到您要访问的内容
这会让用户误以为链接错误或文章被删除。
新的搜索空状态改为:
没有找到相关文章
当前关键词没有匹配到已发布文章
同时提供可执行建议:
- 检查关键词是否输入错误;
- 尝试标题、摘要或正文中的其他表达。
分类页仍然保留原来的内容不存在提示,通过页面类型进行语义区分,而不是复制两套模板。
分页链接也针对搜索使用查询参数:
<a th:href="${type == '搜索'}
? @{/search(q=${keywords},page=${i})}
: @{/{url}/{page}(url=${url},page=${i})}">
</a>
这样既保持搜索词的正确编码,也不影响分类和标签页面原有路由。
十、测试策略:从方法正确到用户真的能搜到
这类问题不能只写一条 Mapper 测试。最终采用了四层验证。
第一层:服务层单元测试
使用 Mockito 验证:
- 连续空白会被归一化;
- 空关键词不会访问数据库;
%_会原样传给字面匹配查询;- 超出范围的页码会落到最后一页;
- Mapper 返回空总数时不会继续查询列表;
- 手工构造的
PageInfo总数、页数和前后页状态正确。
第二层:SQL 与模板契约测试
契约测试直接检查 Mapper XML 和模板资源,防止未来维护时重新引入旧问题:
WHERE中不能再次出现强制MATCH;- 计数查询不能使用不同的匹配逻辑;
- 搜索入口必须使用
encodeURIComponent; - 新旧搜索路由必须同时存在;
- 空结果页必须使用搜索文案;
- 搜索分页必须继续携带
q参数。
第三层:Blog 全量回归测试
搜索页面复用了文章卡片、分类模板、公共页头和页脚,因此不能只跑新增测试。最终 Blog 模块执行了 168 项测试,评论、后台管理、文章 Mermaid、移动端交互和其他页面契约全部通过。
第四层:真实应用与数据库验证
在 8091 启动真实应用后,验证最终 HTML,而不是只看 Java 方法返回值:
| 场景 | 预期结果 |
|---|---|
AI |
返回标题或正文包含 AI 的文章 |
ai |
与 AI 结果一致 |
AI 评论 |
归一化后正常搜索 |
%_ |
按普通字符处理,不发生 SQL 通配 |
| 不存在的长关键词 | HTTP 200,显示搜索空状态 |
/search/AI |
旧地址继续可用 |
page=0 |
自动归一到第一页 |
page=2 |
返回第二页并保留关键词 |
这一步非常关键,因为只有真实数据库才能证明短关键词问题确实被解决,只有最终 HTML 才能证明 Thymeleaf 表达式和用户文案确实生效。
十一、性能取舍:可靠性优先,但要知道下一步往哪里走
LOCATE + LOWER + REGEXP_REPLACE 的优势是行为稳定、容易解释,并且不依赖全文分词器是否收录短词。代价是数据库较难直接利用普通索引,文章规模很大时会出现扫描成本。
对于个人博客或中小规模内容库,这是可以接受的正确性优先方案。如果数据继续增长,可以按以下方向演进:
- 在写入文章时生成纯文本正文,避免每次查询执行 HTML 正则清理;
- 增加规范化标题、摘要和正文搜索列;
- 根据实际语言选择 MySQL ngram 全文解析器;
- 为搜索建立独立倒排索引,而不是在业务表上实时计算;
- 数据量和查询复杂度进一步增加时,再评估 Elasticsearch 或 OpenSearch;
- 使用真实查询日志建立关键词回放集,持续检查召回率和排序质量。
需要注意的是,升级搜索引擎不能替代搜索契约。无论底层使用 MySQL 还是专用搜索服务,都必须先回答:什么算匹配、什么内容可见、特殊字符如何处理、结果如何排序、无结果如何反馈。
十二、总结
这次故障最有价值的地方,不是修好了 AI 两个字,而是暴露了一个常见工程误区:把多个看似合理的搜索条件叠加,并不一定更准确,有时只会让召回范围越来越窄。
最终方案没有针对某个关键词设置特殊分支,而是重新划分职责:
- 查询参数负责安全传递用户输入;
- 服务层负责归一化、缓存和分页边界;
- 字面包含规则负责稳定召回;
- 全文检索负责辅助排序;
- 模板负责区分搜索空状态和资源不存在;
- 自动化测试与真实运行验证共同证明结果。
一个可靠的搜索功能,不只是“一条能运行的 SQL”。它是一份从输入、召回、排序、分页到页面反馈都保持一致的系统契约。
当用户再次搜索 AI 时,他不需要知道全文索引、分词器或分页对象的存在。他只需要看到本来就应该被找到的文章——这正是搜索功能最基本,也最重要的工程目标。

评论区 0