全栈独立产品搜索体验复盘:从 LIKE 到 Elasticsearch 的升级路径

📅 2026/7/24 16:19:32
全栈独立产品搜索体验复盘:从 LIKE 到 Elasticsearch 的升级路径
全栈独立产品搜索体验复盘从 LIKE 到 Elasticsearch 的升级路径一、搜索体验的阶段性困境LIKE 不等于搜索独立产品最早期的搜索通常都用 SQLLIKE实现SELECT * FROM articles WHERE title LIKE %关键词% OR content LIKE %关键词%。在产品初期数据量 1 万条、日搜索量 100 次LIKE查询完全够用——MySQL 在title列建立全文索引FULLTEXT INDEX后1 万条数据内的查询延迟在 50ms 以内。没有人会因为搜索慢而流失。但数据量和搜索量在跨过临界点后LIKE方案的体验会快速恶化性能临界点10 万条数据以上单次LIKE %keyword%的查询耗时 500ms~2s。这是因为前缀通配符%keyword%无法使用 B-Tree 索引MySQL 必须做全表扫描。相关性临界点LIKE返回的结果是包含关键词的所有记录按数据写入时间排序。用户搜索React 性能优化返回的第一条是 2019 年的React 入门教程因为文中恰好提到了性能优化一词而真正相关的 2024 年React 18 并发特性下的性能优化策略排在第 17 条。功能临界点用户期望的分词搜索搜索前端能匹配前端开发、拼音搜索输入react能匹配React、模糊匹配prefomance纠错为performance、高亮显示——这些在LIKE方案下全部不可实现。二、阶段一LIKE 的快速起步与死亡螺旋2.1 LIKE 方案的实现第一阶段用LIKE实现搜索的核心代码非常简短——这也是为什么它总是被选择作为起点-- 基础搜索 SELECT id, title, content, created_at FROM articles WHERE title LIKE CONCAT(%, ?, %) OR content LIKE CONCAT(%, ?, %) ORDER BY created_at DESC LIMIT 20;2.2 LIKE 方案的三大硬伤随着数据量和搜索频率的增长三个硬伤逐一显现全表扫描的不可扩展性LIKE %keyword%的第一个%是最致命的。它告诉 MySQL这个关键词可能出现在字段的任何位置MySQL 无法使用 B-Tree 索引只能逐行扫描。10 万条数据 ≈ 10 万次字符串比较这是不可扩展的。结果排序的无意义性按创建时间排序意味着一个 3 年前的文章如果恰好包含关键词会排在 3 天前的高质量文章前面。《MySQL 入门》里提到了一句数据安全就能排在专讲数据安全最佳实践的文章前面。这是糟糕的相关性。无分词的语义断裂搜索前端开发时LIKE只会匹配包含连续字符串前端开发的记录不会匹配前端开发两个独立词分别出现在不同位置的情况。中分词的缺失让搜索精度极低。三、阶段二MySQL FULLTEXT 的过渡期3.1 FULLTEXT INDEX 与 ngram 分词MySQL 5.7 支持 FULLTEXT INDEX 和 ngram 分词器可以解决LIKE的性能问题-- 添加全文索引 ALTER TABLE articles ADD FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram; -- 全文搜索BOOLEAN MODE SELECT id, title, content, created_at, MATCH(title, content) AGAINST(? IN BOOLEAN MODE) AS relevance FROM articles WHERE MATCH(title, content) AGAINST(? IN BOOLEAN MODE) ORDER BY relevance DESC LIMIT 20;FULLTEXT INDEX 将文本切分为 n-gram2字词组并建立倒排索引。查询时不需要全表扫描而是走索引查找。10 万条数据的全文搜索从 LIKE 的 500ms 降到 50ms 以内。3.2 FULLTEXT 方案的局限但 FULLTEXT INDEX 在以下场景中仍然不够用相关性打分粗糙MATCH...AGAINST的评分算法基于词频TF和逆文档频率IDF但不考虑字段权重标题匹配应该比正文匹配更相关、词位置关键词出现在开头比出现在末尾更相关。缺少高亮用户期望在搜索结果中看到关键词被标黄高亮FULLTEXT 本身不提供这个能力。无聚合统计搜索结果无法按分类、年份、作者聚合统计。无拼音/纠错不支持拼音搜索和拼写纠错。这些是搜索引擎Elasticsearch才能解决的需求。四、阶段三Elasticsearch 的全面升级4.1 索引结构的迁移从 MySQL 迁移到 ES 的关键是数据同步策略。对于独立产品来说不需要 Canal Kafka 的 CDCChange Data Capture方案。更务实的做法是应用层双写/** * 搜索服务应用层双写 Elasticsearch 查询 * 写操作同时写入 MySQL 和 ES读操作走 ES */ interface SearchDocument { id: string; title: string; content: string; summary: string; category: string; tags: string[]; author: string; createdAt: number; updatedAt: number; viewCount: number; } class SearchService { private esHost: string; private esIndex: string; constructor(host: string, index: string) { this.esHost host; this.esIndex index; } /** * 创建/更新文档 * 在应用层同时写入 MySQL 和 ES */ async indexDocument(doc: SearchDocument): Promiseboolean { try { // 1. 先写 MySQL主数据源 await this.saveToMySQL(doc); // 2. 再同步到 ES允许失败后续补同步 const response await fetch( ${this.esHost}/${this.esIndex}/_doc/${doc.id}, { method: PUT, headers: { Content-Type: application/json }, body: JSON.stringify(doc), } ); if (!response.ok) { console.error([ES] 索引文档失败: ${response.status}); // 标记为待同步 await this.markForResync(doc.id); // ES 写入失败不影响主流程 } return true; } catch (err) { console.error([ES] 索引文档异常:, err); await this.markForResync(doc.id); return true; // 主流程MySQL已成功 } } /** * 搜索文档 * 使用 ES 的多字段匹配 高亮 聚合 */ async search(query: string, options: SearchOptions): PromiseSearchResult { try { const esQuery this.buildESQuery(query, options); const response await fetch(${this.esHost}/${this.esIndex}/_search, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(esQuery), }); if (!response.ok) { throw new Error(ES search error: ${response.status}); } const data await response.json(); return this.parseESResponse(data); } catch (err) { console.error([ES] 搜索失败降级到 MySQL 搜索:, err); // 降级走 MySQL FULLTEXT 或 LIKE 兜底 return this.fallbackSearch(query, options); } } /** * 构建 ES 查询 DSL */ private buildESQuery(query: string, options: SearchOptions) { return { from: (options.page - 1) * options.pageSize, size: options.pageSize, query: { bool: { should: [ // 标题匹配权重最高 { match: { title: { query, boost: 3.0, fuzziness: AUTO, operator: and, }, }, }, // 摘要匹配权重中 { match: { summary: { query, boost: 1.5, }, }, }, // 正文匹配权重低 { match: { content: { query, boost: 1.0, }, }, }, // 标签精确匹配 { terms: { tags: [query], }, }, ], minimum_should_match: 1, filter: this.buildFilters(options), }, }, highlight: { fields: { title: { number_of_fragments: 0 }, summary: { fragment_size: 150, number_of_fragments: 1, }, content: { fragment_size: 200, number_of_fragments: 1, }, }, pre_tags: [mark], post_tags: [/mark], }, aggs: { categories: { terms: { field: category, size: 20 }, }, }, sort: options.sortBy relevance ? [{ _score: desc }] : [{ createdAt: desc }], }; } /** * 构建过滤条件 */ private buildFilters(options: SearchOptions): Recordstring, unknown[] { const filters: Recordstring, unknown[] []; if (options.category) { filters.push({ term: { category: options.category } }); } if (options.tags options.tags.length 0) { filters.push({ terms: { tags: options.tags } }); } return filters; } /** * 解析 ES 返回结果 */ private parseESResponse(data: any): SearchResult { return { total: data.hits.total.value, items: data.hits.hits.map((hit: any) ({ id: hit._id, score: hit._score, source: hit._source, highlights: { title: hit.highlight?.title?.[0], summary: hit.highlight?.summary?.[0], content: hit.highlight?.content?.[0], }, })), aggregations: { categories: data.aggregations?.categories?.buckets?.map( (b: any) ({ key: b.key, count: b.doc_count }) ) ?? [], }, }; } /** * ES 不可用时的降级搜索 */ private async fallbackSearch( query: string, options: SearchOptions ): PromiseSearchResult { // 降级走 MySQL 全文搜索 const sql SELECT id, title, content, category, tags, created_at FROM articles WHERE MATCH(title, content) AGAINST(? IN BOOLEAN MODE) ORDER BY created_at DESC LIMIT ?, ? ; // 返回简化版结果无高亮、无聚合 return { total: 0, items: [], aggregations: { categories: [] } }; } /** * 补同步将标记为待同步的文档重新索引 */ async resyncStaleDocuments(): Promisevoid { const staleIds await this.getStaleDocumentIds(); for (const id of staleIds) { const doc await this.getDocumentFromMySQL(id); if (doc) { await this.indexDocument(doc); } } } // Stub 方法 private async saveToMySQL(_doc: SearchDocument): Promisevoid {} private async markForResync(_id: string): Promisevoid {} private async getStaleDocumentIds(): Promisestring[] { return []; } private async getDocumentFromMySQL(_id: string): PromiseSearchDocument | null { return null; } } interface SearchOptions { query: string; page: number; pageSize: number; category?: string; tags?: string[]; sortBy: relevance | date; } interface SearchResult { total: number; items: SearchResultItem[]; aggregations: { categories: { key: string; count: number }[]; }; } interface SearchResultItem { id: string; score: number; source: SearchDocument; highlights: { title?: string; summary?: string; content?: string; }; }4.2 前端搜索 UI 的配套升级ES 的搜索结果包含了前端可以消费的丰富数据高亮片段mark关键词/mark直接渲染在搜索结果中。聚合数据按分类聚合的结果数在搜索框下方以标签形式展示前端开发(42)、后端开发(18)。相关性分数用于决定搜索结果是否展示无相关结果的提示score低于阈值时。4.3 ES 方案的成本控制独立产品引入 ES 最常见的顾虑是太重了——ES 本身需要 512MB~2GB 内存对于一个小服务器来说是巨大的开销。折中方案使用 ES 云服务如 Elastic Cloud、阿里云 ES按量付费最低配置每月约 100~200 元。使用 Meilisearch 替代Meilisearch 是一个轻量级的开源搜索引擎Rust 编写内存占用 100~200MB内置了中文分词、容错、高亮等功能部署成本远低于 ES。对于独立产品来说Meilisearch 往往是比 ES 更务实的选择。五、总结搜索体验的升级是一个三段跳的过程第一阶段LIKE适用于数据量 1 万条的场景。实现简单一个 SQL 语句搞定。超出临界点后性能和相关性迅速恶化。第二阶段MySQL FULLTEXT用 ngram 分词 倒排索引解决 LIKE 的全表扫描性能问题。10 万条数据的查询从 500ms 降到 50ms。但相关性打分粗糙缺少高亮和聚合。第三阶段Elasticsearch/Meilisearch多字段权重打分标题 3x、摘要 1.5x、正文 1x、IK 中文分词、高亮、聚合、拼音搜索、拼写纠错。搜索结果的相关性和丰富度质变。落地建议如果数据量还没到 5 万条先用 MySQL FULLTEXT 兜底。当搜索成为核心功能时日搜索量 500 次优先考虑 Meilisearch部署成本低、内置中文支持而不是直接上 ES。