简介:基于Java搜索引擎的设计与实现毕设项目资料包,面向计算机相关专业学生和课程设计、毕业设计使用者,可作为项目初期立项演示或进阶学习参考。压缩包内含完整可运行的前后端Java源码、SQL数据库脚本、毕业论文文档、答辩PPT和操作演示视频,代码经过严格测试,配置好环境即可运行。资料共329个文件,覆盖Java类文件、XML配置、JSP/JS/JSX前端页面与脚本、Python辅助工具及数据库相关文件等多种类型,整体仅12.87MB,下载和部署都相当便捷。目前已有64人浏览学习,属于典型的完整型毕设资料。借助文档配套的视频演示、多个启动脚本及清晰目录,可快速还原搜索引擎的检索流程,理解索引构建、分词匹配、结果排序等核心机制,也方便在此基础上扩展新功能,直接用于毕设答辩、课设验收或项目初稿。
1. 毕设里的Java搜索引擎到底要做什么:先拆清“搜索”这件事
拿到“基于Java搜索引擎的设计与实现(源码+数据库+论文).zip”这个题目,很多人的第一反应是“搜索引擎不是百度那种级别吗?我一个毕设怎么做出来”。其实毕设级的Java搜索引擎,并不要求你造出一个能爬全网、扛亿级流量的系统,它考察的是你有没有完整理解搜索引擎的闭环:抓取、存储、索引、检索、排序。标题里的“源码+数据库+论文”也暗示了交付物形态——能跑的代码、合理设计的MySQL库、能用来答辩的论文。适合的人群很明确:JavaWeb方向、想拿数据库和算法综合练手的本科生,以及需要快速把题目落地成系统的应届生。我下面按自己做这类项目的习惯,把这个题拆成模块、数据库、检索、避坑和验证五条线讲清楚。
2. 搜索引擎整体架构拆解:从爬虫到检索器的模块划分与数据流
2.1 五个核心模块:爬虫、去重、索引、检索、管理后台
一个能拿去答辩的Java搜索引擎,不会去复刻搜索引擎大厂的爬虫集群,它要解决的是“查得到、查得快、能讲清楚”。我一般把系统拆成五个模块:爬虫模块负责抓网页,去重模块负责剔除重复内容,索引模块负责把网页文本转成倒排索引,检索模块负责接收关键词并打分排序,管理后台负责展示抓取情况和搜索日志。这五个模块串起来的数据流是:URL种子 → 下载网页 → 解析正文 → 去重 → 分词 → 建倒排索引 → 用户输入关键词 → 检索打分 → 返回结果。
爬虫模块在毕设里不需要做成分布式调度,单机多线程就够。常见的做法是用一个队列保存待抓取的URL,线程池消费队列,用HTTP请求下载网页,再用HTML解析器去掉标签和脚本。去重模块要单独做,因为很多网页内容相同但URL不同,比如带统计参数的链接,如果不加去重,索引表会被大量重复文档占满。去重可以用MD5对正文或标题做哈希,存一张去重表;进阶一点可以用SimHash做近似去重,但这个在毕设里属于加分项,不属于必须项。
索引模块是整个系统的核心。你要把每篇文档的标题、正文分词后,统计每个词在该文档中的出现次数(TF),以及每个词在多少篇文档里出现过(DF),然后写入倒排索引表。检索模块拿到用户输入的关键词后,先对关键词做同样的分词,再从倒排索引表中找出包含这些词的文档ID集合,最后用排序算法打分返回。管理后台在毕设里很容易被忽略,但它其实是答辩时的展示亮点,评委大概率会问“你抓了多少网页、搜了什么词、用了多长时间”,有后台截图和统计数字会好讲很多。
模块划分上我建议用一个表格把所有层看清楚:
| 模块 | 职责 | 关键类/组件 | 出力接口 |
|---|---|---|---|
| 爬虫 | URL调度、下载、解析 | UrlQueue、PageDownloader、HtmlParser | List |
| 去重 | 正文/标题Hash判重 | DuplicateFilter、SimHash | boolean |
| 索引 | 分词、倒排索引写入 | Analyzer、IndexWriter | 索引表记录 |
| 检索 | 查询合并、打分排序 | BooleanQuery、Scorer、TopKCollector | List |
| Web后台 | 搜索页、日志统计、抓取监控 | Controller、Mapper、Thymeleaf | 页面展示 |
2.2 用Java实现一个单机可跑的搜索Demo:模块接口与关键类设计
为了先跑通闭环,我不会一上来就写Spring Boot接口,而是先把核心模块用纯Java类串起来。下面这套代码是常见的最小可运行骨架,每个模块只暴露一个方法:爬虫只管抓,索引只管建,检索只管查。这样做的好处是边界清楚,后面替换任何模块都不影响其他部分。
public interface Crawler { // 从种子URL开始抓取,最多抓取maxPages个页面 List<CrawledDocument> crawl(String seedUrl, int maxPages, int threadCount); } public interface Indexer { // 对一篇文档分词并写入倒排索引 void indexDocument(CrawledDocument doc); // 全量构建完成后刷新索引文件/索引表 void flush(); } public interface Searcher { // 输入关键词、页码、每页条数,返回排序后的结果 SearchResultPage search(String keyword, int page, int pageSize); }接口定义好之后,爬虫实现里最容易踩坑的是解析。我用Jsoup做HTML解析,它能自动处理大部分坏标签,但正文抽取这块需要自己过滤。常见做法是先取meta关键字和description,再取document.title作为标题字段;正文则优先取article标签,取不到就取body的纯文本。下面是爬虫模块一个能跑的下载解析方法。
public CrawledDocument fetch(String url) { try (Connection.Response resp = Jsoup.connect(url) .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .timeout(5000) .ignoreContentType(true) .execute()) { String rawHtml = resp.body(); Document doc = Jsoup.parse(rawHtml, url); String title = doc.title(); // 优先用meta描述,页面没有时退化为body前200字 String description = doc.select("meta[name=description]").attr("content"); Element article = doc.selectFirst("article"); Element body = doc.body(); String text = article != null ? article.text() : body.text(); String charset = resp.charset() != null ? resp.charset().toString() : "UTF-8"; return new CrawledDocument(url, title, text, description, charset); } catch (IOException e) { // 单页失败不影响整体抓取,记录日志后返回null log.warn("fetch failed: {}", url, e); return null; } }这里的参数需要你按机器性能调:timeout设5秒,是因为商用的搜索对响应时间不敏感,但毕设爬虫如果超时太短,访问稍慢的站点就会大面积抓取失败;超时太长,线程池会被慢请求占满。threadCount在单机演示时建议不要超过8,我曾经用16线程去抓一个小型站点,结果对方服务器直接拒绝服务,自己被学院网关连坐禁了IP。ignoreContentType(true)是必要的,因为有些URL返回的不是HTML而是PDF或图片,解析前要先用Content-Type过滤掉非HTML资源。
去重模块我单独说,因为很多人会忘记它。抓下来两篇内容完全相同的文章,标题不同但正文一样,索引里如果都入库,检索同一个词时会出现两条几乎一样的结果,评委看到会觉得你没做数据清洗。常见做法是取正文前500个字符的MD5做唯一键,入库前先查一下。
public boolean isDuplicate(CrawledDocument doc) { String content = doc.getTitle() + "|" + doc.getText(); String md5 = DigestUtils.md5Hex(content.getBytes(StandardCharsets.UTF_8)); Integer count = duplicateMapper.countByMd5(md5); return count != null && count > 0; }参数说明:md5Hex要加上标题的前缀“|”,是为了避免标题相同但正文不同的误判;为什么只取正文前500字符而不是全文,是因为全文MD5在长文本场景下误判率极低,但计算成本高,而且少数文章正文中间有动态生成的内容,全文哈希会导致同一篇文章因为尾部日期不同而判定为不同文档,取前500字符既保证判重稳定性,又减少计算量。
3. 数据库表设计与数据落库:把网页和索引存进MySQL的正确姿势
3.1 三张核心表:网页表、分词表、索引表的字段设计与索引选择
毕设搜索引擎基本绕不开MySQL,因为评委最熟悉的就是它,而且MySQL能很好展示你“数据库设计”的能力。标题里带了“数据库”,意味着表结构设计是答辩时的高频提问点。我一般设计三张核心表:t_crawl_document存网页原文信息,t_word_dict存分词后的词条,t_inverted_index存倒排索引关系。外加一张t_search_log存搜索日志,用来做行为统计。
CREATE TABLE t_crawl_document ( id BIGINT PRIMARY KEY AUTO_INCREMENT, url VARCHAR(2048) NOT NULL COMMENT '页面URL', url_hash VARCHAR(64) NOT NULL COMMENT 'URL MD5,用于快速判重', title VARCHAR(512) NOT NULL DEFAULT '' COMMENT '页面标题', content MEDIUMTEXT NOT NULL COMMENT '正文纯文本', description VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'meta描述', charset VARCHAR(32) NOT NULL DEFAULT 'UTF-8' COMMENT '页面编码', crawl_time DATETIME NOT NULL COMMENT '抓取时间', UNIQUE KEY uk_url_hash (url_hash), KEY idx_crawl_time (crawl_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='爬虫文档表'; CREATE TABLE t_word_dict ( id BIGINT PRIMARY KEY AUTO_INCREMENT, word VARCHAR(128) NOT NULL COMMENT '词条', UNIQUE KEY uk_word (word) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='词条字典表'; CREATE TABLE t_inverted_index ( id BIGINT PRIMARY KEY AUTO_INCREMENT, word_id BIGINT NOT NULL COMMENT '词条ID', doc_id BIGINT NOT NULL COMMENT '文档ID', tf INT NOT NULL DEFAULT 0 COMMENT '词在当前文档出现次数', df INT NOT NULL DEFAULT 0 COMMENT '词在全部文档中出现文档数', positions VARCHAR(2048) NOT NULL DEFAULT '' COMMENT '词在正文中的位置列表,JSON数组', UNIQUE KEY uk_word_doc (word_id, doc_id), KEY idx_doc_id (doc_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='倒排索引表';建表时的几个关键选择:t_crawl_document的url字段用VARCHAR(2048)而不是TEXT,因为URL列经常作为查询条件,TEXT无法加普通索引;url_hash用MD5后转十六进制64字符,建唯一索引,判重直接走索引扫描。content用MEDIUMTEXT而不是LONGTEXT,因为单篇网页正文一般不会超过16MB,MEDIUMTEXT排序和DEBUG时的成本更低。t_inverted_index用word_id和doc_id联合唯一索引,天然保证同一个词在同一篇文档里只有一条记录,避免重复累加。
这里说一个容易被评委揪住的点:你把DF存到了每一行里,其实DF和doc_id是函数依赖关系——一个词在多少文档里出现,是全局统计值,存到每个倒排记录里数据冗余。冗余的代价是更新时只要改一行,代价是为换查询速度。如果你想更规范,可以把DF放到t_word_dict表里,查询时从字典表拿;但如果索引达到百万级别,每次查询都要回查字典表,开销不小。毕设场景下我倾向把冗余DF留在倒排表里,答辩时主动说“这是用存储空间换查询速度,并且用定时任务保证DF最终一致”。
3.2 数据入库的完整流程:从URL队列到倒排索引的代码实现
爬虫抓到的页面必须经过“清洗 → 分词 → 统计词频 → 批量入库”这条流水线,才能变成检索能用的倒排索引。我一般不用一条SQL插一行,因为做毕设的时候我试过一条条插,抓了5000个页面后用了近三小时,还经常卡死。改成批量插入后,速度快了快十倍。下面这段代码展示了把一篇文档写入倒排索引的完整逻辑。
@Transactional(rollbackFor = Exception.class) public void indexDocument(CrawledDocument doc) { Long docId = saveDocument(doc); // 先存文档表,拿到自增ID // 1. 分词:用IK分词器切分正文,得到词与词频的映射 Map<String, Integer> tfMap = new HashMap<>(); try (StringReader reader = new StringReader(doc.getText())) { Analyzer analyzer = new IKSegmenter(reader, true); Lexeme lexeme; while ((lexeme = analyzer.next()) != null) { String word = lexeme.getLexemeText(); if (word.length() < 2) { continue; // 过滤单字,减少索引噪声 } tfMap.merge(word, 1, Integer::sum); } } catch (IOException e) { log.error("分词失败,docId={}", docId, e); return; } // 2. 遍历词频Map,更新字典表和倒排索引表 List<InvertedIndex> batch = new ArrayList<>(tfMap.size()); for (Map.Entry<String, Integer> entry : tfMap.entrySet()) { String word = entry.getKey(); Long wordId = wordDictMapper.selectByWord(word); if (wordId == null) { WordDict dict = new WordDict(); dict.setWord(word); wordDictMapper.insert(dict); wordId = dict.getId(); } InvertedIndex idx = new InvertedIndex(); idx.setWordId(wordId); idx.setDocId(docId); idx.setTf(entry.getValue()); // DF先取当前索引表中的总文档数,后续再统一更新 idx.setDf(countDocNumByWord(wordId) + 1); batch.add(idx); } if (!batch.isEmpty()) { invertedIndexMapper.batchInsert(batch); } }参数说明:IK分词器里的true表示开启智能切分,它会合并部分相邻词,比细粒度切分更适合搜索场景。过滤单字word.length() < 2非常关键,搜索引擎的字典里如果塞满“的、了、我”这类单字,索引体量会大30%以上,而且检索时会返回大量无关结果。如果不想出现在代码里写死,可以把停用词表放到resources/stopwords.txt,用Set一次性加载,判断时直接contains。
还有一个很隐蔽的问题:分批插入时如果你用MyBatis的foreach批量insert,默认的executeBatch并不会真正批量提交,需要给JDBC连接加rewriteBatchedStatements=true参数。这个参数没设,批量插入的性能会退化到和单条插入差不多。我建议在application.yml里这样配置:
spring: datasource: url: jdbc:mysql://localhost:3306/search_engine?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000注意这里的rewriteBatchedStatements=true只对MySQL的驱动生效,PostgreSQL或其他数据库不认这个参数;如果你换库,记得去掉。HikariCP的maximum-pool-size设成10,是因为爬虫线程最多8个,加上后台搜索请求,10个连接足够;设太大反而浪费内存,设太小会导致爬虫线程和搜索线程互相抢连接。
4. 检索排序与搜索接口的实现:TF-IDF到BM25的调参与翻车记录
4.1 检索器核心算法:倒排索引合并、TF-IDF排序与BM25调参
检索模块是整个搜索引擎里最容易在答辩时代码被深挖的地方。评审老师不会关心你的前端页面用了什么框架,但一定会问“关键词来了,你怎么从索引里掏数据,怎么打分”。最基础的链路是:先把用户输入分词,得到一个词列表;然后拿每个词去倒排索引表查对应的docId集合;多词查询时做交集或并集;最后对候选文档算分。这里的排序算法,我建议直接实现TF-IDF作为基础版本,再实现BM25作为优化版本。
先看TF-IDF的Java实现:
public double scoreTfIdf(String word, int tf, int df, int totalDocCount) { // TF:词在当前文档中的出现频率 double tfScore = 1 + Math.log(tf); // IDF:逆文档频率,df越大说明这个词越不稀缺 double idf = Math.log( (totalDocCount + 1.0) / (df + 1.0) + 1.0 ); return tfScore * idf; }参数说明:tfScore为什么用1 + Math.log(tf)而不是直接用tf,是因为线性增长的TF会让高频词主导排序,比如一篇文档里出现50次的“Java”和出现5次的“数据库”,直接用tf算,Java会把数据库完全压下去;取对数后,50次和5次的差距被压缩到约1.4倍,更符合人感知的“出现多次但差别没那么大”。idf里的+1.0是为了防止分母为0和避免极端值,这也是很多搜索引擎实践里常见的平滑处理。
但是TF-IDF有个问题:它对短文档不公平。一篇只有100字的新闻摘要里出现一次“搜索引擎”,和一篇5000字的论文里出现三次“搜索引擎”,TF-IDF分数往往是长文档占优,但人会觉得短文档更相关。所以做到BM25更好。BM25内部有k1和b两个关键参数,我一般用k1=1.5、b=0.75,这是Lucene和Elasticsearch经过大量实验得到的默认经验值,适用于大多数场景。
public double scoreBm25(double tf, double df, double docLen, double avgDocLen, double totalDocCount, double k1, double b) { double idf = Math.log(1 + (totalDocCount - df + 0.5) / (df + 0.5)); double tfComponent = tf / (k1 * (1 - b + b * docLen / avgDocLen) + tf); return idf * tfComponent; }这里docLen是当前文档的长度,avgDocLen是全部文档的平均长度。很多新手会忘掉保存文档长度字段,导致BM25根本无法使用,所以在t_crawl_document表里务必加一个doc_len INT字段,在入库时计算正文分词后的词数并写入。k1控制词频饱和程度,k1越大,词频对分数的贡献衰减越慢;b控制文档长度惩罚力度,b越大,长文档越吃亏。如果你的数据集里文档长度差异很大,比如有的网页是短新闻、有的是长教程,b可以调到0.8;如果文档长度都比较均匀,b调到0.6反而更合适。这两个值改完需要在固定测试集上跑一遍排序质量,毕设里可以人工看前面20条搜索结果是否合理。
候选文档合并时还会遇到一个问题:多词查询怎么做集合运算。常见做法是用Java的HashSet做求交或求并,但数据量一大,HashSet的存储开销和GC压力会很痛。更稳妥的做法是先把每个词的docId集合从数据库查出时按docId排序,然后做类似归并的相交扫描;如果索引表的记录量超过百万,归并扫描比HashSet更省内存。下面是我常用的TopK打分代码,用小顶堆保留前K个高分结果,避免全排序。
public List<SearchResult> topK(List<CandidateDoc> candidates, int k) { // 小顶堆:堆顶是当前K个结果中分数最低的那个 PriorityQueue<CandidateDoc> minHeap = new PriorityQueue<>( Comparator.comparingDouble(CandidateDoc::getScore) ); for (CandidateDoc candidate : candidates) { if (minHeap.size() < k) { minHeap.offer(candidate); } else if (candidate.getScore() > minHeap.peek().getScore()) { minHeap.poll(); minHeap.offer(candidate); } } List<CandidateDoc> sorted = new ArrayList<>(minHeap); // 堆里元素顺序和分数无关,需要逆序排一下 sorted.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); return sorted.stream() .map(CandidateDoc::toSearchResult) .collect(Collectors.toList()); }这里的核心思想是:如果总共有10万篇文档包含“Java”,但你只需要返回前20条,用全排序要排序10万条,时间复杂度和空间占用都高;用PriorityQueue维护大小为20的小顶堆,时间复杂度只有O(n log 20)。注意堆排序出来的顺序是乱序的小顶堆积聚,最后一定要用sorted.sort逆序一下,否则前端拿到的结果顺序是乱的。这个毛病我踩过一次,排查了一个多小时才发现堆顶元素被当成第一条返回了。
4.2 搜索接口的Spring Boot实现:参数、分页、高亮、过滤
完成算法层之后,还要把搜索能力暴露给Web端或接口调用方。毕设里最稳妥的姿势是用Spring Boot + MyBatis搭一个REST接口,前端用简单页面或Postman调用。下面的Controller代码实现了核心搜索接口,涵盖分页、高亮、搜索日志写入。
@RestController @RequestMapping("/api/search") public class SearchController { @Autowired private SearchService searchService; @GetMapping public Result<SearchResultPage> search( @RequestParam("q") String query, @RequestParam(value = "page", defaultValue = "1") int page, @RequestParam(value = "size", defaultValue = "10") int size, @RequestParam(value = "highlight", defaultValue = "true") boolean highlight, @RequestParam(value = "source", required = false) String source) { // 参数校验:页码最小为1,每页最多100条 if (page < 1) page = 1; if (size > 100) size = 100; SearchResultPage resultPage = searchService.search(query, page, size, highlight, source); // 异步写入搜索日志,不阻塞结果返回 searchLogService.saveLog(query, page, size, resultPage.getTotal()); return Result.success(resultPage); } }Service层里要做的关键技术点是分页SQL。搜索引擎的分页最常用的是LIMIT offset, size,但offset太大时性能会急剧下降,因为数据库需要扫描并跳过offset行。对毕设量级的数据,直接LIMIT没问题;但如果你追求更好,可以改为“搜索时间后分页”或“基于游标分页”,即在SQL里带上lastDocId条件。这里给出一个兼顾性能和易读性的查询方式:
public List<SearchResult> queryDocs(List<Long> docIds, int page, int size) { if (docIds == null || docIds.isEmpty()) { return Collections.emptyList(); } // 按docId集合查详情;如果结果集太大,拆分查询防止SQL语句超长 List<List<Long>> partitions = Lists.partition(docIds, 200); List<DocumentInfo> docs = new ArrayList<>(); for (List<Long> part : partitions) { docs.addAll(documentMapper.selectByIds(part)); } // 按分数排序取本页 docs.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); int from = Math.min((page - 1) * size, docs.size()); int to = Math.min(from + size, docs.size()); return docs.subList(from, to).stream() .map(doc -> SearchResult.from(doc, highlight)) .collect(Collectors.toList()); }高亮实现这里有一个常见的翻车点:直接在正文中做字符串replace,把关键词替换成红字,但关键词如果出现在HTML标签属性里,替换后会破坏页面结构。常见做法是先把正文中的所有HTML标签过滤成纯文本再高亮,保留预设的前后缀标签。下面是我用的一个简化版高亮逻辑:
public String highlight(String text, String keyword) { if (text == null || keyword == null || keyword.isBlank()) { return text; } // 特殊字符做防注入处理,避免正则或HTML解析出错 String escaped = Pattern.quote(keyword); return Pattern.compile(escaped, Pattern.CASE_INSENSITIVE) .matcher(text) .replaceAll(m -> "<em>" + m.group() + "</em>"); }注意Pattern.quote是把关键词当字面量匹配,否则用户搜索“java.”时,.会被当成正则通配符,高亮会把整个段落都包上。filter参数这块,搜索接口里可以带source来源过滤,SQL里加一个source字段条件即可。搜索日志表本身也是一张表,增删改查的“增”在每次搜索时调用,“查”在后台管理页面里展示热门搜索词、搜索次数、空结果率。做毕设的时候,这个页面是评委看得最多的页面,别忽略它。
5. 毕设级搜索引擎避坑指南:5个高频踩坑点与排查思路
5.1 中文分词不生效:现象、原因、解决
现象:抓取了大量中文网页,索引也建了,但你搜“搜索引擎”,返回结果里匹配到的全是“搜索”和“引擎”的单独记录,有些文档明明有“搜索引擎”这个词却排得很靠后;更严重的,搜索“Java基础”返回0条。
原因:最常见的原因是没有加载扩展词典。IK分词器默认对“搜索引擎”这样的词如果不做词库补充,可能切分成“搜索”和“引擎”;还有一种情况是分词器版本与JDK版本冲突,导致Analyzer初始化时抛异常,但被代码吞了,索引建出来全是空词。
解决:检查根部加载的是IKAnalyzer还是IKAnalyzer的轻量封装;项目里resources目录下新增IKAnalyzer.cfg.xml,配置ext.dict指向扩展词典文件。扩展词典每行一个词,保存为UTF-8无BOM格式。修改后如果还是不生效,清理target目录重新编译,因为词典文件可能还留在旧的class输出目录里。我遇到过最玄学的一次,是mybatis的mapper XML里用了中文注释,导致XML解析异常,分词器加载被连带中断。
5.2 检索结果为空或不全:现象、原因、解决
现象:搜索一个词,明明数据库里有包含该词的文档,但接口返回的结果集为空;或者只返回了包含完整词组的文档,而包含同义词或近似词的文档没出来。
原因:过滤条件太严格。比如我在表结构里加了source字段后,搜索Service在拼SQL时用了AND条件,而某些文档source为空字符串,结果全被过滤了。另一个高频原因是分页越界:page传了很大的值,offset超过总记录数,查询结果自然为空。还有一个原因是词表大小写问题,“Java”和“java”在MySQL里默认排序规则下可能区分大小写,查询时匹配不上。
解决:排查顺序应该是“先查日志,再看SQL,最后看索引”。先拿到搜索接口打印的完整SQL,手工在MySQL里执行一遍,看是否有数据;有数据说明是ORM映射或参数类型问题,没数据说明条件过滤问题。在数据库设计阶段就把MySQL字段的collation设置成utf8mb4_general_ci(大小写不敏感);如果你要支持英文大小写混合检索,建议在写入索引时统一统一转小写,查询词也同步转小写,避免依赖数据库排序规则。
5.3 数据库连接池爆掉:现象、原因、解决
现象:爬虫刚启动时一切正常,跑了几分钟后控制台开始频繁报“Connection is not available, request timed out”,网页抓取速度越来越慢,最后线程卡死;重启后恢复正常,但只要爬虫大规模抓取又复现。
原因:最直接的原因是连接池配置与实际并发不匹配。我用过HikariCP默认配置(maximumPoolSize=10),但爬虫线程池开了16个线程,每个线程都调用IndexService保存页面,长事务和批量插入会把连接长期占用,连接池被抽干后请求排队。更深层的原因是数据库连接使用后没有及时释放,事务方法里嵌套了远程调用或高延迟操作,导致事务时间拉长。
解决:把爬虫线程数降到连接池的一半以下,这是最粗暴有效的办法;给事务方法加超时控制,比如@Transactional(timeout = 10);批量插入拆分成每500条一次,避免单次事务占用连接太久。最实用的是给连接池加上连接泄漏检测:HikariCP配置leakDetectionThreshold=60000,如果连接被占用超过60秒会主动记录错误日志,能很快定位到哪段代码一直不归还连接。我自己的经验是,毕设阶段不要过度追求并发,爬虫线程设4-6个,索引和爬虫共用一个连接池,速度完全够,而且各模块之间的耦合会小很多。
5.4 网页编码乱码:现象、原因、解决
现象:爬虫抓取的很多页面打开后标题正常,但正文是乱码;搜“数据库”时返回的片段里显示“鏁版嵁搴�”这类垃圾内容;把乱码网页存进索引后,关键词匹配完全失效。
原因:网页的编码声明在响应头或meta标签里,而代码里写死了UTF-8解码。国内有大量老网站还是GB2312或GBK编码,用UTF-8解码GBK字节流必然乱码。另一个原因是Jsoup的charset检测机制只认meta标签,有些页面meta标签缺失或声明错误,Jsoup会默认按UTF-8解析。
解决:在Joup连接时不要用body()字符串,而是先用execute()拿到Response对象,读取响应头Content-Type里的charset;如果响应头没有charset,再从HTML的head里解析meta charset;如果都没有,用ICU4J或juniversalchardet做编码探测。拿到最终编码后,把源码字节按这个编码转成UTF-8字符串再交给Jsoup解析。关键代码写法:
public String decodeHtml(byte[] htmlBytes, String headerCharset, String metaCharset) { String charset = headerCharset != null ? headerCharset : metaCharset != null ? metaCharset : detectCharset(htmlBytes); // 统一转成UTF-8,后续存储方保证一致 return new String(htmlBytes, Charset.forName(charset)); }这里有一个细节:如果你的数据库表排序规则是utf8mb4,MySQL本身能存GBK转好的UTF-8,但如果你直接把乱码文本存进去,后面想清洗就迟了。所以编码转换务必在入库之前完成,不要在查询时做补救。
5.5 关键词打分排序不稳定:现象、原因、解决
现象:同一个关键词,连续搜两次,结果顺序不一样;或者调整了某个网页的内容后,整个排序结果完全翻转,明显不符合直观判断。
原因:排序不稳定来自三条:一是查询结果集合里没有显式指定排序规则,MySQL的LIMIT在不同索引选择下可能返回不同顺序;二是打分公式里的IDF用的是“当前批次统计”而不是“全量统计”,每新增一篇文档,IDF变化后,已有文档的分数也变了;三是并发写入时DF字段更新时序不一致,倒排表里不同记录存了不同时间的DF值。
解决:给所有查询结果加上明确secondary sort,比如ORDER BY score DESC, doc_id DESC,保证同分时顺序稳定;把DF更新从批量写入时实时计算,改为定期全量重算——常见做法是每天凌晨或每抓完一轮后执行一次“UPDATE t_word_dict SET df = (SELECT COUNT(*) FROM t_inverted_index WHERE word_id = ...)”;在页面展示时,保留一次查询的快照分数,把“绝对分数”展示成相对排名,也能缓解这个问题。我在毕设里被这个问题教训得很惨,答辩前一天晚上发现排序乱了,一度以为是HashMap的遍历顺序问题,最后发现是DF更新滞后加上并发写导致的,把DF改成重算定时任务后,再没翻过车。
5.6 论文和代码不一致:现象、原因、解决
现象:论文里写的系统架构和数据流图和实际代码差异很大,比如论文里写了“本系统采用Redis做缓存”,代码里根本没有Redis依赖;论文里写“支持同义词扩展”,检索实现里只是简单like查询。答辩时评委照着论文提问,代码却对不上,直接导致“学术不端”的质疑。
原因:很多毕设是先写完论文再补代码,或者代码是照着网上模板改的,论文里很多设计是“理想态”,代码是“劳动成果”,两者脱节。搜索系统这种题目,论文里的技术名词又特别多:爬虫策略、倒排索引、TF-IDF、BM25、PageRank……很容易为了“显得专业”夸大实现。
解决:在动工前期就把论文的总体设计章节和代码模块一一对应起来,做一个对照表:论文里写“爬虫模块”,代码里一定要有Crawler类;论文里写“缓存优化”,代码里至少要有ConcurrentHashMap做热点词缓存。如果一个技术点实在不实现,论文里就不要写。答辩前把论文贴到代码旁边过一遍,看到任何“支持”、“采用了”字样,确认代码里找得到对应实现。这个动作比优化算法更能帮你通过答辩。
6. 从一个能跑的Demo到能答辩的毕设:验证方法、测试样例与优化方向
这个系统的验证不能靠“看起来能用”,要有可复现的测试样例。我会先造一份小规模的人工测试集:50个HTML文件,里面混了中文、英文、数字、代码段落、重复内容、超长文档和空文档,文件名按“doc_001.html”编号。然后跑一次完整流程:爬虫 → 去重 → 建索引 → 搜索,记录每一阶段的数据量。测试集必须包含对照组:比如两个标题不同正文相同的页面,用来验证去重;一个关键词在短文档中出现2次、在长文档中出现5次,用来验证排序是否偏向短文档。把这些样例和预期结果写进测试表里,每次改动后都跑一遍。
性能验证方面,我不建议直接看秒表,而是用JMeter或Postman对搜索接口做压测,记录QPS、平均响应时间、错误率,再和优化前对比。一个能说服评委的数据是:在5000篇文档规模下,搜索平均响应时间从120ms降到45ms,内存占用从320MB降到210MB。这比“我的系统很好”有说服力得多。优化方向我建议按性价比排序:第一优先加缓存,每个高频热搜词的结果集缓存到本地内存,可以显著降低数据库压力;第二优化倒排索引的查询SQL,为word_id加覆盖索引,避免回表;第三做搜索日志分析,找出空结果词和无结果词,补进同义词库;最后再考虑更复杂的排序策略,比如引入网页权重或时间衰减。
我自己的一个习惯是,在答辩演示时准备一个“后门词”:抓取阶段故意让某个独特词只出现在一篇文档里,答辩时搜这个词,能瞬间定位到唯一结果,侧面证明索引和检索是真正联动的,而不是写死的静态模板。最后说一句:别急着把爬虫规模做很大,搜索引擎项目的核心价值在“查得准、查得快”,把检索和排序打磨清楚,比堆一万篇垃圾网页更能让你的毕设站得住脚。希望帮到你。
本文还有配套的精品资源,点击获取