1. 这套系统到底是做什么的,别把搜索引擎想得太神秘
先拆解一下这个项目标题:“基于SpringBoot的网络爬虫搜索引擎,前端用Vue”。它不是让你从零去造一个百度或者Google,而是让你亲手搭建一套“数据采集 + 数据清洗 + 索引存储 + 关键词检索 + 前端展示”的完整闭环。说白了三件事:爬虫负责把网页抓下来,SpringBoot后端负责把数据处理好并对外提供搜索接口,Vue前端负责把搜索框和结果页呈现给用户。
核心价值在哪里?如果你是学生,这几乎是一个能同时打穿“Java后端”“前端框架”“网络协议”“信息检索”四个知识域的完整项目;如果你是准备面试的开发者,这套系统能让你讲清楚“从URL到搜索结果的完整链路”,比背一百道八股文都管用;哪怕你只是对搜索引擎的原理好奇,跟着这篇文章自己动手跑起来,你对“倒排索引”这四个字的理解也会比看书深刻十倍。
这篇博文不是纸上谈兵,我会直接把系统拆成几个模块来讲,每个模块都给出可以直接落地的方案和代码思路,同时会重点讲我们在实际开发中踩过的坑。整体架构我建议做成前后端分离:SpringBoot作为纯后端RESTful API服务,Vue作为纯前端单页应用,两者通过JSON交互。数据流向是这样的:
爬虫模块采集网页 → 数据清洗 → 存储到MySQL + Elasticsearch → SpringBoot提供搜索接口 → Vue调用接口渲染结果
这里有个非常关键的认知:网络爬虫搜索引擎和普通的企业CRUD系统完全是两种思维。CRUD系统是“我有数据,你来查”;爬虫搜索系统是“数据要从外部世界自己长出来,还要让人能查”。所以整个系统的设计核心不是前端长什么样,也不是接口怎么命名,而是——数据从哪来、怎么存、怎么让搜索变快。把这三个问题想透了,整个系统就立住了一半。
2. 爬虫模块的设计:别一上来就写代码,先想清楚这三个问题
爬虫是整个系统的数据入口,也是新手最容易翻车的地方。很多人拿到需求就开始写Jsoup解析HTML,结果爬到一半发现被封了IP,或者爬到一万条数据后发现全是重复内容。所以我强烈建议,动手写爬虫之前,先想清楚下面三个问题。
2.1 爬什么:目标站点和数据模型设计
爬虫一定要有明确的“数据模型”,没有模型你存下来的东西一定是一堆没法用的垃圾。对于通用搜索引擎,我们关心的核心字段是:标题(title)、正文内容(content)、URL、发布时间、来源站点。这是我常用的数据表结构:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 自增主键 |
| doc_id | varchar(64) | 内容唯一指纹(用于去重) |
| title | varchar(512) | 网页标题 |
| content | mediumtext | 清洗后的正文纯文本 |
| url | varchar(1024) | 原始链接 |
| source | varchar(128) | 站点名称(例如“知乎”“CSDN”) |
| publish_time | datetime | 页面发布时间 |
| crawl_time | datetime | 抓取时间 |
| status | tinyint | 0待处理 1已索引 |
这是我踩过的一个典型坑:一开始没设计doc_id字段,直接用URL去重,结果同一个页面的URL可能带不同的跟踪参数(?from=home、?utm_source=xxx),导致同一个文章被存了好几次。后来改成对“URL去掉参数”再做MD5作为doc_id,稍微好了点,但还是不彻底,因为有些网站同一篇文章会有多个入口URL。最终方案是用“标题 + 正文前100字 + URL域名”拼接后取Hash作为doc_id,这才稳定下来。这个指纹方案你可以直接用,它解决的是“内容级去重”而不是“URL级去重”。
2.2 怎么爬:HttpClient + Jsoup,还是WebMagic?
爬虫框架业界有一个很成熟的轮子叫WebMagic,它把“下载页面、解析内容、持久化存储、管理URL队列”这四件事都封装好了。但我个人建议,如果你是学习阶段,最好先用HttpClient + Jsoup自己手写一遍,哪怕写得糙一点。原因很简单:WebMagic帮你把活都干完了,你对HTTP状态码、重定向、请求头、Charset判断这些东西的感知是缺失的。不信你去问用过WebMagic的人,十个有九个说不清301和302对爬虫的影响。
这是用HttpClient + Jsoup写一个最基础下载器的方式:
public Page download(String url) { // 1. 构造请求,伪装浏览器 CloseableHttpClient httpClient = HttpClients.custom() .setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .setConnectionTimeToLive(10, TimeUnit.SECONDS) .build(); HttpGet request = new HttpGet(url); request.setHeader("Accept", "text/html,application/xhtml+xml"); request.setHeader("Accept-Language", "zh-CN,zh;q=0.9"); try (CloseableHttpResponse response = httpClient.execute(request)) { int status = response.getStatusLine().getStatusCode(); if (status == 200) { // 2. 拿到HTML字符串 String html = EntityUtils.toString(response.getEntity(), "UTF-8"); // 3. 用Jsoup解析成Document Document doc = Jsoup.parse(html, url); return buildPage(doc); } } return null; }再强调一下伪装请求头的重要性。很多站点对无脑爬虫的拦截第一道就是看User-Agent,其次是请求频率。所以两点必须做好:请求头里带上完整的浏览器UA,以及设置合理的抓取间隔,我一般控制在1~2秒一个请求,再多就容易触发反爬。对于频率控制,最简单的方式是搞一个线程池加延时:
// 信号量控制并发抓取数量 private Semaphore semaphore = new Semaphore(4); public void fetchTask(String url) { executor.execute(() -> { try { semaphore.acquire(); Page page = download(url); save(page); Thread.sleep(1200); // 控制请求频率 } catch (Exception e) { log.error("抓取失败: {}", url, e); } finally { semaphore.release(); } }); }2.3 页面解析的两种思路:CSS Selector和正则
页面解析就是把HTML里面的有意义内容抽出来。Jsoup最擅长的就是CSS Selector,比如你想抽CSDN博客的文章标题,看下页面结构就知道是:
String title = doc.selectFirst("h1.title-article").text(); String content = doc.selectFirst("#content_views").text(); String date = doc.selectFirst(".time").attr("title");但这里有一个很重要的经验:不同站点的页面结构完全不同,你要为每一个站点写一套解析规则。所以你最好在数据库里建一张“采集规则表”,把各个站点的选择器存进去,这样以后要加一个新站点,不用改代码,直接加一条配置就行。我大致是这样设计的:
{ "site": "csdn", "urlPattern": "blog.csdn.net/*", "titleSelector": "h1.title-article", "contentSelector": "#content_views", "timeSelector": ".time", "charset": "UTF-8" }正则表达式主要用来做URL过滤。比如只希望抓取文章详情页,不抓首页和分类页,你就可以在URL调度器里做一层正则匹配,比如^https?://blog\.csdn\.net/[a-zA-Z0-9_]+/article/details/\d+$这种。
3. 搜索引擎的基石:索引构建和数据存储策略
爬虫把数据抓回来了,接下来就到了搜索引擎和普通爬虫系统分道扬镳的地方。抓回来的数据如果不建立索引,那它只是一堆躺在数据库里的文本,搜索的时候全表扫描,几万条数据就把数据库拖死了。搜索引擎的核心是倒排索引,这个概念必须彻底搞懂。
3.1 为什么数据库LIKE查询救不了搜索引擎
很多第一次做这个项目的人会想:数据都有了,用户输入一个关键词,我用SELECT * FROM pages WHERE content LIKE '%关键词%'不就行了?数据量小的时候确实行,但你想想这个操作到底做了什么——它要把每一条记录的content字段都扫一遍,逐字匹配,内容越长扫得越慢,这种全表扫描的时间复杂度是O(n),n是记录数乘以平均内容长度。如果你爬了10万篇文章,每篇平均1万字符,那一次搜索就要扫描十亿级别的字符,用户等不起。
倒排索引的思路完全反过来。它不是去内容里找关键词,而是事先就把每个关键词对应了哪些文档记录下来。比如“SpringBoot”这个词,出现在第1、5、100、1024篇文档里,那索引里就存一条“SpringBoot → [1, 5, 100, 1024]”。用户搜“SpringBoot”的时候,直接从索引里取这个数组,O(1)就找到了所有包含这个词的文档ID。这就是为什么真正的搜索引擎都用倒排索引。
3.2 Elasticsearch:比你自己实现倒排索引靠谱得多
理解原理的同时,工程落地我不建议你从零实现倒排索引,因为细节太多了:分词、词项字典、倒排表压缩、跳表合并……每一个拉出来都是博士论文级别。业界成熟方案就是Elasticsearch,它底层是Lucene,Lucene已经帮你把倒排索引的所有细节都做到了极致。
在SpringBoot里集成ES非常简单,关键是先确定好索引映射(Mapping)。对于中文搜索,必须要装IK分词器插件,否则ES默认的标准分词器会把中文按单个字切分,搜“搜索引擎”会匹配不到“搜索”和“引擎”的短词匹配。这是索引的Mapping配置思路:
{ "settings": { "analysis": { "analyzer": { "ik_analyzer": { "type": "custom", "tokenizer": "ik_max_word" } } } }, "mappings": { "properties": { "docId": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "url": { "type": "keyword" }, "source": { "type": "keyword" }, "publishTime": { "type": "date" } } } }这里有个关键选型:ik_max_word和ik_smart怎么选?我的经验是,索引时用ik_max_word做最细粒度分词,把“中华人民共和国”切分成“中华人民共和国/中华人民/中华/华人/人民共和国/人民/共和国”等多个词项,这样搜索结果更丰富;搜索时用ik_smart做粗粒度分词,只切出最合理的词,比如搜“中华人民共和国”就只切成“中华人民共和国”一个词,搜出来的结果更精确。索引最大、查询最小,这是一条黄金法则。
3.3 数据的“双写”策略:MySQL和ES的同步问题
这里逃不掉一个问题:数据是同时存MySQL和ES的,怎么保证两边数据一致?最简单可靠的方案就是业务代码里先写MySQL,写成功后马上同步到ES。但爬虫是批量的,逐条同步太慢,我用的是批量提交:
@Service public class IndexService { @Autowired private RestHighLevelClient client; public void batchIndex(List<Page> pages) { BulkRequest request = new BulkRequest(); for (Page page : pages) { IndexRequest indexRequest = new IndexRequest("webpage") .id(page.getDocId()) .source(JSON.toJSONString(page), XContentType.JSON); request.add(indexRequest); } try { client.bulk(request, RequestOptions.DEFAULT); } catch (IOException e) { // 记录下来,后续通过定时任务补偿 log.error("批量索引失败", e); } } }批量提交比逐条提交性能高一个数量级,一般500条一批,效果最好。另外我强烈建议你加一个定时补偿任务,每隔几分钟扫描一下MySQL中status=0(未索引)的数据,补推到ES里面。否则爬虫某个时间点ES集群抖动了一下,那批数据就永久丢了,用户就永远搜不到那几篇文章了。
4. 搜索API的设计:排序、高亮、分页的实现方案
数据都索引好了,接下来的核心是搜索接口的设计。用户在前端输入一个关键词,后端要怎么处理这个请求,才能返回让用户满意的结果?
4.1 从简单到进阶:三种搜索策略
第一层:Match查询,最简单也最常用。用户搜“Java并发编程”,我们把“Java”“并发”“编程”拆成几个词项,去倒排索引里找包含这些词的文档,然后按相关度打分排序。这是全文检索的基本盘:
public SearchResponse search(String keyword, int page, int size) { NativeSearchQuery query = new NativeSearchQueryBuilder() .withQuery(QueryBuilders.multiMatchQuery(keyword, "title", "content")) .withPageable(PageRequest.of(page, size)) .withHighlightFields( new HighlightBuilder.Field("title"), new HighlightBuilder.Field("content") ) .build(); return elasticsearchRestTemplate.search(query, Page.class); }第二层:布尔查询控制权重。标题里出现关键词的内容,相关度一定比正文里出现的高,这是搜索引擎的基本逻辑。用bool查询把title和content分开,title的boost权重调高,content调低:
QueryBuilder query = QueryBuilders.boolQuery() .should(QueryBuilders.matchQuery("title", keyword).boost(5.0f)) .should(QueryBuilders.matchQuery("content", keyword).boost(1.0f));我不止一次在项目里见过直接用matchQuery一把梭的代码,再往上加权就是很多人没意识到的事。你们注意这个区别了吗?权重差5倍,搜索结果的相关性完全是两个档次。
第三层:过滤+聚合,让搜索更精准。用户可能指定只看某个来源站点的内容,或者只看某个时间段的。这个用filter加进去,filter不参与打分,性能比挨个算分的should快得多:
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "title", "content")); boolQuery.filter(QueryBuilders.termQuery("source", "csdn")); boolQuery.filter(QueryBuilders.rangeQuery("publishTime") .gte("2024-01-01").lte("2024-12-31"));4.2 搜索结果的高亮处理:前端直接可用的代码
ES搜索返回的是普通文本,但用户看到的搜索结果是带红色或者加粗高亮的。这块我建议用ES自己的高亮功能,前端拿到高亮字段直接渲染HTML即可:
HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("title").preTags("<em class='highlight'>").postTags("</em>"); highlightBuilder.field("content").preTags("<em class='highlight'>").postTags("</em>").fragmentSize(100);fragmentSize很关键,它控制正文高亮片段截取多长,100个字符左右是比较合理的,太长了前端展示起来很难看。高亮结果处理成这样的JSON结构返回给前端:
{ "code": 200, "data": { "total": 128, "pages": 13, "records": [ { "docId": "xxx", "title": "SpringBoot <em class='highlight'>整合</em> ElasticSearch 实战", "content": "本篇讲解SpringBoot如何<em class='highlight'>整合</em>ES...", "url": "https://xxx.com/post/1024", "source": "csdn", "publishTime": "2024-06-01 10:00:00" } ] } }4.3 搜索质量优化:相关度排名的实际调优经验
很多人做完第一版搜索接口,会沮丧地发现:搜一个关键词,返回结果好像跟关键词关系不大。问题多半出在以下三点:
第一,分词不合理。IK分词器虽然有词典,但你爬的内容里可能有很多专业词汇或者网络热词,比如“夹娃娃机”,IK可能把它切成“夹”“娃娃机”或者“夹娃”“娃娃”,百度的词典能切出来的“夹娃娃机”它就切不出来。ES自带了自定义词典功能,把你的专业词添加到IK的词典文件里,重启ES即可生效。
第二,没做同义词扩展。用户搜“计算机”,可能他想要的内容里全写的是“电脑”。IK插件同时支持同义词配置,把常见的同义词组配进去,搜索体验会提升一个大台阶。更复杂的方式是训练词向量做语义召回,但这已经不是这个项目能承载的范围了,作为进阶方向了解即可。
第三,没利用好“搜索点击反馈”。这也是搜索系统和普通系统的本质差别。真正的搜索引擎一定要记录用户的点击数据,“搜了哪个词,点了哪条结果”。数据积累多了以后,你会发现一个词被反复点击的文档,在后续搜索时可以给它一定的分数补偿,它可以记住哪些结果好、哪些结果沉底。这个功能在你的系统里可以先简单实现:给搜索结果表加一个click_count字段,定时把点击率作为加权因子同步到ES里。
5. Vue前端:搜索页、结果页、搜索建议的实现细节
后端的搜索接口再强大,最终都得落到用户能看到的界面上。Vue前端的核心工作有三大块:搜索框和搜索建议、结果列表展示、搜索状态管理。
5.1 前端项目初始化和路由设计
Vue3 + Vite + Element Plus是目前比较趁手的组合。没有特殊理由不要用Vue2了,新项目直接Vue3就好。项目结构:
src/ ├── api/ │ └── search.js # 封装搜索接口 ├── views/ │ ├── SearchView.vue # 搜索页 │ └── ResultView.vue # 结果页 ├── components/ │ ├── SearchBox.vue # 搜索框组件 │ ├── SearchSuggest.vue # 搜索建议下拉组件 │ └── ResultList.vue # 搜索结果列表组件 ├── router/ │ └── index.js └── store/ └── search.js # 搜索状态管理路由设计有个细节:搜索关键词应该放到URL参数里,而不是存在组件内部。这样用户刷新页面、收藏链接、分享搜索结果的场景都能直接还原搜索状态。我用的路由是:
const routes = [ { path: '/', component: SearchView }, { path: '/search', component: ResultView } ]搜索时通过router.push({ path: '/search', query: { q: keyword, page: 1 } })跳转,结果页从route.query.q里拿关键词。这里注意一个易错点:在搜索框里连续搜索两个不同的词时,/search?q=vue到/search?q=springboot其实是同一个路由,组件会被复用,不会重新执行created生命周期钩子。解决办法是watch路由变化:
watch( () => route.query.q, (newVal, oldVal) => { if (newVal !== oldVal) { fetchSearchResult(); } } );如果你忘了这一步,会看到用户第二次搜索页面毫无反应,这是Vue单页应用里一个经典坑。
5.2 搜索建议(下拉提示)的防抖实现
搜索建议就是用户在输入框里敲字,下面实时弹出一个下拉列表里展示“你可能想搜的”。这个功能有一个实现核心——防抖。用户每敲一个字母就发一次请求,后端会被打爆,所以一定要等到用户停下来了250~300毫秒再发请求。
import { ref, watch } from 'vue'; import { getSuggestions } from '@/api/search'; const keyword = ref(''); const suggestions = ref([]); let timer = null; watch(keyword, (newVal) => { clearTimeout(timer); if (!newVal) { suggestions.value = []; return; } timer = setTimeout(async () => { const res = await getSuggestions({ keyword: newVal }); suggestions.value = res.data; }, 300); });搜索建议的后端实现也很简单,ES的completion suggester或者前缀查询都可以。最简单的是用prefix查询:用户输入“sp”,你就去ES里面找关键词前缀匹配“sp”的热搜词返回给前端。前提是你得单独维护一个热搜词表,每次用户点击搜索,就把关键词记录到这个表里,count加一。冷启动的时候可以用爬虫抓下来的标题分词生成一批初始关键词。
5.3 结果页渲染性能:长列表不要用v-for直接搞
搜索结果一页十条,用户一口气翻了很多页,或者你的highlight内容里包含了复杂的HTML结构时,Vue的响应式渲染会出现明显卡顿。两个优化建议:
第一,用v-memo指令缓存列表项。Vue3.2以上版本内置了v-memo,它可以告诉Vue:只要docId没变,这一项就不需要重新渲染。
<div v-for="item in records" :key="item.docId" v-memo="[item.docId, item.title]"> <!-- 渲染搜索结果 --> </div>第二,高亮内容的HTML要防XSS。我们前面让ES返回了带<em class='highlight'>标签的HTML片段,前端用v-html直接渲染是可行的,但有一个严重安全风险——如果页面的正文原文里包含<script>标签,爬虫清洗的时候没洗干净,那这个脚本就会在你的页面上执行。解决方案是把高亮标签白名单化,给前端的不是原始过滤HTML,而是后端先清洗一遍,只保留em标签,其他标签全部剥离。
5.4 搜索接口API封装
前端API封装放在一个模块里统一管理,注意把loading状态也全局管理起来:
import axios from 'axios'; import { ElMessage } from 'element-plus'; const service = axios.create({ baseURL: '/api', timeout: 10000 }); // 搜索接口 export function search(keyword, page, size) { return service.get('/search', { params: { keyword, page, size } }); } // 搜索建议接口 export function getSuggestions(keyword) { return service.get('/suggest', { params: { keyword } }); } // 请求响应拦截:统一处理错误提示 service.interceptors.response.use( response => response.data, error => { ElMessage.error(error.response?.data?.message || '搜索失败,请稍后再试'); return Promise.reject(error); } );6. 整链路联调与性能优化:从能跑到好用的关键差别
系统各个模块单独都能跑了,联调的时候才会暴露出各种奇怪的问题。这一章节我重点讲几个项目真正上线跑数据时遇到的性能和稳定性问题,以及怎么解决的。
6.1 前后端联调时的跨域问题和代理配置
前端跑在localhost:5173,后端跑在localhost:8080,天然跨域。后端的处理方式是加一个WebMvcConfigurer统一配置跨域:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .maxAge(3600); } }这里有个坑:allowedOrigins("*")在带凭证(Cookies)的请求里是不生效的,必须用allowedOriginPatterns("*")。如果只是开放搜索接口,不带用户凭证的话,其实两种都行,但为了后续扩展登录功能,我建议直接用allowedOriginPatterns.
生产环境我更推荐用Nginx把前后端放在同一个域名下,前端静态文件由Nginx托管,/api开头的请求自动反向代理到后端服务。这样不仅解决了跨域问题,还能减少一次网络往返(没有OPTIONS预检请求了)。Nginx配置片段:
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # Vue history路由 } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }6.2 爬虫和生产环境的压力避让策略
生产环境跑起来以后,最烦的事情是爬虫被封IP。封了之后不是立刻失效,而是让你的爬虫突然抓不到任何东西,过一段时间才自动恢复。应对方案有三个级别:
级别一:降低请求频率 + 随机延时。从固定1.2秒改成1.2~3.5秒随机值,模仿人的行为。这会让爬虫的吞吐量降低30%左右,但稳定性能换长久的抓取量,值得。
级别二:维护一个代理IP池。网上有很多免费代理IP源,写一个定时任务每天去抓一批代理进来验证存活率,存到Redis或者数据库里。每次请求轮流使用代理池里的IP,被封的概率就迅速下降。这个方案有个前提——免费的代理IP大部分不稳定,你需要写一个自动剔除失效代理的机制。
级别三:针对具体站点做差异化处理。有些站点反爬策略很强,比如需要登录、需要处理滑块验证。这种站点不要硬碰,绕过去爬别的站点就好。通用搜索引擎的价值在于覆盖面广,不在于某一个网站的内容多全。
6.3 搜索接口的性能优化:缓存和查询优化
搜索接口的QPS一旦上来,每个请求都直接打到ES,响应时间和集群压力都会变大。两个优化手段:
第一,热门搜索词加Redis缓存。同样的关键词,用户会反复搜索很多次。在Redis里存一份“关键词→搜索结果JSON”的缓存,TTL设置为60秒,同一个词60秒内的重复请求直接走缓存,ES的压力瞬间减掉80%。伪代码如下:
public SearchResult search(String keyword, int page, int size) { String cacheKey = "search:" + keyword + ":" + page + ":" + size; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return JSON.parseObject(cached, SearchResult.class); } SearchResult result = doSearch(keyword, page, size); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), 60, TimeUnit.SECONDS); return result; }第二,SQL和ES查询都要注意数据类型。在ES里存日期一定要用date类型,存URL用keyword类型,不要什么都用text。text类型会被分词,导致URL里的小数点、斜杠全被拆了,term查询精确匹配就会失效。这个细节排查起来非常隐蔽,因为数据能存进去,你也感觉查询没报错,就是结果不对。
6.4 爬虫数据多了之后的自动清理策略
搜索引擎不是数据库,不需要把历史数据全部留着。旧数据有两个问题:一是占据ES存储空间,影响查询性能;二是内容老旧,用户搜索体验变差。我目前用的是一个很简单的策略:保留最近6个月的数据,定期删除更早的旧数据。
// 定时任务:每天凌晨3点执行 @Scheduled(cron = "0 0 3 * * ?") public void cleanOldData() { // 删除ES中6个月前的数据 QueryBuilder query = QueryBuilders.rangeQuery("publishTime") .lt(LocalDateTime.now().minusMonths(6)); DeleteByQueryRequest request = new DeleteByQueryRequest("webpage"); request.setQuery(query); // 同步删除MySQL中的记录 pageMapper.deleteByPublishTimeBefore(LocalDateTime.now().minusMonths(6)); }定时任务的cron表达式,注意避开搜索高峰时段,凌晨是最合适的。另外删除数据时务必检查是要逻辑删除还是物理删除,如果是搜索引擎,直接物理删除就好,因为你没有恢复旧数据的需求。
7. 分阶段开发路线:按这个顺序搭,每个阶段都能跑能看
给想动手做这个项目的朋友们一份直接的开发顺序建议。我踩过的坑是——很多初学者喜欢从爬虫开始,辛辛苦苦爬了一个星期数据,发现ES的索引映射设计错了,又得清洗数据重灌一遍,浪费一整个周末。正确的顺序应该是先把“搜索展示”跑通,再补数据。
阶段一:先搭ES和搜索后端。不用爬虫,手动往ES里插测试数据,把搜索接口调通。这一阶段只专注学习ES的核心概念、SpringBoot整合ES的方式。
阶段二:Vue前端做起来。把搜索框、结果页和数据交互跑通,前端能从后端搜到测试数据。到目前为止,这个系统是个“假搜索引擎”,但它已经能演示了。
阶段三:写爬虫。抓数据回来入库,用批处理同步到ES。注意先小批量测试,确保清洗后的数据在ES里搜索正常,再放开抓取规模。
阶段四:整体优化。加上搜索建议、Redis缓存、定时清理、权重调整这些进阶功能,把“能跑的搜索引擎”变成“好用的搜索引擎”。
这四步走完,你对整个系统的掌控感是完全不一样的。不要指望从第一个Commit就开始做完整的大系统,那只会让你卡在某一环,最后什么都没有。
另外一个建议是,项目不要只写代码,一定要配一个README说明你的架构图、数据表设计和技术选型原因。面试的时候面试官看看代码再看到这个README,对你项目理解度的评价和那些只有代码的人完全不在一个水平线上。
最后说一个运维层面的小经验:ES是Java写的,启动参数内存默认很小,爬了几千条数据可能就内存溢出了。启动ES前一定要去config/jvm.options把堆内存调大,建议至少2G。不然你会在半夜爬到数据入库的时候,突然报错连不上ES,然后一脸懵地查了半天才发现是ES挂了。这种坑看起来小,但能折腾你一晚上。