毕设论文系统实战:基于 Spring Boot 的高并发选题与查重架构设计
最近在帮学校实验室重构毕设论文管理系统,过程中遇到了不少典型的“校园级”高并发问题:选题时学生一拥而上导致“撞车”、查重服务在高峰期响应缓慢、数据库事务在并发下出现诡异的不一致。这让我意识到,一个看似简单的管理系统,在真实场景下对架构的考验一点也不小。今天,我就把这次基于 Spring Boot 的实战架构设计与核心实现细节梳理出来,希望能给有类似需求的同学一些参考。

1. 背景与核心痛点分析
在动手之前,我们先明确要解决什么问题。高校毕设管理系统有几个鲜明的特点:
- 瞬时高并发:选题系统开放的那几分钟,所有学生同时登录、刷新、提交,流量是平时的几十甚至上百倍。
- 强一致性要求:一个课题只能被一个学生选中,这是业务铁律,必须保证绝对正确,不能出现“超卖”。
- 计算密集型任务:论文查重需要比对海量文本,是典型的 CPU 和 I/O 密集型操作,直接同步处理会拖垮整个系统。
- 数据可靠性:论文数据是学生的心血,任何丢失或错乱都是不可接受的。
基于这些,我们的核心痛点就清晰了:如何在高并发下保证选题的幂等与一致性?如何异步化处理查重这类耗时操作以提升系统吞吐?如何确保整个流程中的数据安全与事务可靠?
2. 技术选型与权衡
针对上述痛点,我们进行了如下技术选型:
-
基础框架:Spring Boot 快速构建、约定大于配置、生态丰富,是快速迭代校园项目的不二之选。
-
分布式锁:Redis (Redisson) vs ZooKeeper
- Redis (Redisson):基于
SETNX和 Lua 脚本,性能极高,实现简单。Redisson 客户端提供了完善的看门狗(Watchdog)机制,解决了锁的自动续期和避免死锁的问题,非常适合我们这种读多写少、锁持有时间较短的场景(如选题)。 - ZooKeeper:基于临时顺序节点,强一致性保证,但性能相对较低,部署和维护更复杂。
- 权衡:考虑到校园服务器资源有限,且选题对性能要求更高,我们选择了 Redis + Redisson 的方案。对于锁的可靠性,我们通过多主 Redis 集群和合理的锁超时时间来保障。
- Redis (Redisson):基于
-
查重引擎:Elasticsearch vs SimHash 本地计算
- Elasticsearch:优势在于能进行复杂的全文检索和相似度分析(如
more_like_this查询),可以快速从海量历史论文库中找出相似段落,支持分布式扩展,适合作为核心查重比对库。 - SimHash:局部敏感哈希算法,计算出的指纹可用于快速比对文本相似度,适合在内存中进行初步去重或作为前置过滤。
- 权衡:我们采用混合方案。上传论文时,先用 SimHash 计算一个指纹,与已有论文指纹库(存储在 Redis)进行快速粗筛。如果相似度超过某个阈值,再触发 Elasticsearch 进行精细化的全文相似度分析,生成详细的重复率报告。这样既保证了速度,又提高了准确性。
- Elasticsearch:优势在于能进行复杂的全文检索和相似度分析(如
-
异步任务队列:RabbitMQ 用于解耦查重请求和处理。学生提交论文后,系统立即返回“查重中”,同时将查重任务(论文ID、路径)放入 RabbitMQ 队列。后台有多个消费者进程从队列拉取任务,执行耗时的 SimHash 和 Elasticsearch 比对操作,完成后更新数据库状态并通知用户。
3. 核心实现细节与代码
3.1 幂等选题接口与分布式锁
选题的核心是“一题一人”,必须防止超卖。我们采用 “数据库乐观锁 + 分布式锁 + 幂等令牌” 三重保障。
首先,在课题表中增加一个 version 字段(乐观锁)。
// 课题实体
@Data
@Entity
public class ThesisTopic {
@Id
private Long id;
private String title;
private Long selectedBy; // 选中学生的ID,null表示未被选
@Version // JPA 乐观锁注解
private Integer version;
// ... 其他字段
}
其次,在 Controller 层实现幂等和分布式锁逻辑。客户端在请求选题前,先申请一个唯一的幂等令牌(如 UUID)。
@RestController
@RequestMapping("/topic")
public class TopicSelectionController {
@Autowired
private RedissonClient redissonClient;
@Autowired
private ThesisTopicService topicService;
@PostMapping("/select")
public ApiResponse selectTopic(@RequestParam Long topicId,
@RequestParam String idempotentToken) {
// 1. 幂等性校验:利用Redis,同一个令牌只能成功一次
String tokenKey = "idempotent:select:" + idempotentToken;
Boolean tokenSet = redisTemplate.opsForValue()
.setIfAbsent(tokenKey, "PROCESSING", 10, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(tokenSet)) {
return ApiResponse.fail("请勿重复提交");
}
// 2. 获取课题级别的分布式锁,防止并发修改同一课题
String lockKey = "lock:topic:select:" + topicId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待3秒,锁持有时间10秒
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
redisTemplate.delete(tokenKey); // 清理令牌
return ApiResponse.fail("系统繁忙,请稍后重试");
}
// 3. 核心业务逻辑:在锁内执行
boolean success = topicService.selectTopic(topicId, getCurrentUserId());
if (success) {
redisTemplate.opsForValue().set(tokenKey, "SUCCESS", 10, TimeUnit.MINUTES);
return ApiResponse.ok("选题成功");
} else {
redisTemplate.delete(tokenKey);
return ApiResponse.fail("选题失败,可能已被他人选择");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return ApiResponse.fail("系统中断");
} finally {
// 无论如何,最终都要释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Service 层利用 JPA 的乐观锁进行原子更新:
@Service
@Transactional
public class ThesisTopicServiceImpl implements ThesisTopicService {
@PersistenceContext
private EntityManager entityManager;
public boolean selectTopic(Long topicId, Long studentId) {
// 使用原生查询或JPA的findById,这里为了清晰展示乐观锁更新
ThesisTopic topic = entityManager.find(ThesisTopic.class, topicId, LockModeType.OPTIMISTIC);
if (topic == null || topic.getSelectedBy() != null) {
return false; // 课题不存在或已被选
}
// 当前用户是否已选过其他课题?(业务规则,需另查)
if (hasSelectedOtherTopic(studentId)) {
return false;
}
topic.setSelectedBy(studentId);
// JPA的@Version会在提交事务时自动检查并递增,如果version不匹配则抛出OptimisticLockException
entityManager.merge(topic);
return true;
}
}
3.2 异步查重队列处理
查重是一个典型的生产者-消费者模型。
生产者(上传论文时):
@Service
public class PlagiarismCheckService {
@Autowired
private RabbitTemplate rabbitTemplate;
public void submitCheckRequest(Long paperId, String filePath) {
// 1. 更新论文状态为“待查重”
paperRepository.updateStatus(paperId, PaperStatus.CHECK_PENDING);
// 2. 构造消息
CheckTaskMessage message = new CheckTaskMessage(paperId, filePath);
// 3. 发送到查重队列
rabbitTemplate.convertAndSend("plagiarism.check.exchange",
"check.routing.key",
message,
msg -> {
// 设置消息持久化
msg.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
return msg;
});
log.info("查重任务已提交,论文ID: {}", paperId);
}
}
消费者:
@Component
@Slf4j
public class PlagiarismCheckConsumer {
@Autowired
private SimHashService simHashService;
@Autowired
private ElasticsearchService esService;
@Autowired
private PaperRepository paperRepository;
@RabbitListener(queues = "plagiarism.check.queue")
@Transactional
public void handleCheckTask(CheckTaskMessage message) {
Long paperId = message.getPaperId();
log.info("开始处理查重任务,论文ID: {}", paperId);
try {
// 1. 读取论文文本内容
String content = readFileContent(message.getFilePath());
// 2. SimHash 粗筛
String fingerprint = simHashService.calculate(content);
List<Long> candidatePaperIds = simHashService.findSimilar(fingerprint);
// 3. Elasticsearch 精筛
Double similarityRatio = 0.0;
if (!candidatePaperIds.isEmpty()) {
similarityRatio = esService.calculateSimilarity(paperId, content, candidatePaperIds);
}
// 4. 更新结果
paperRepository.updateCheckResult(paperId, similarityRatio, PaperStatus.CHECK_COMPLETED);
log.info("查重任务完成,论文ID: {}, 相似度: {}", paperId, similarityRatio);
} catch (Exception e) {
log.error("查重任务处理失败,论文ID: {}", paperId, e);
// 更新状态为失败,可设置重试机制
paperRepository.updateStatus(paperId, PaperStatus.CHECK_FAILED);
// 根据业务决定是否重新入队
throw new AmqpRejectAndDontRequeueException(e); // 不重新入队,避免死循环
}
}
}
4. 性能与安全考量
- 缓存击穿防护:热门课题信息使用 Redis 缓存。使用
setnx实现互斥锁重建缓存,或使用永不过期缓存+后台定时更新策略。 - 查重结果防篡改:生成查重报告后,计算报告内容的哈希值(如 SHA-256),并将其存储到数据库或区块链存证服务中。学生查看报告时,重新计算哈希进行比对,确保报告未被篡改。
- 接口限流:在网关层或使用
Resilience4j/Sentinel对/topic/select等核心接口进行限流,例如每秒 1000 次请求,防止恶意刷题或脚本攻击。 - 数据库连接池优化:针对高并发,合理配置 HikariCP 连接池参数(如
maximumPoolSize,connectionTimeout),并启用 Prepared Statement 缓存。
5. 生产环境避坑指南
- 数据库死锁排查:尽管用了乐观锁,但复杂事务仍可能死锁。开启 MySQL 的
innodb_print_all_deadlocks日志,监控死锁信息。优化事务粒度,避免在事务中执行过多不相关的更新操作。 - 消息积压处理:监控 RabbitMQ 队列长度。如果查重队列积压,可以:
- 动态增加消费者实例(弹性伸缩)。
- 检查消费者处理逻辑是否有性能瓶颈(如 ES 查询过慢)。
- 设置队列最大长度和溢出策略(如
drop-head)。
- 冷启动延迟优化:系统重启后,Redis 缓存是空的,大量请求直接穿透到数据库。解决方案:
- 缓存预热:在系统启动后,使用一个后台任务,将热点课题数据加载到 Redis。
- 使用 Bloom Filter:对于“是否存在”类查询(如学生是否已选题),可以先查 Bloom Filter,快速过滤掉大量无效请求。
- Elasticsearch 性能调优:为查重索引合理设置分片数和副本数。针对
more_like_this查询,可以限制检索的文档数量和字段,避免深度翻页。

6. 总结与思考
通过 Spring Boot 整合 Redis 分布式锁、RabbitMQ 异步消息和 Elasticsearch 搜索引擎,我们构建了一个能够应对高校高并发场景的毕设论文管理系统。这套架构的核心思想是:同步操作做减法(通过锁和幂等保证正确性),异步操作做加法(通过队列解耦提升吞吐)。
最后留一个思考题:在资源紧张的校园服务器上(可能只有一两台低配服务器),我们如何实现一定程度的弹性伸缩?我的思路是:
- 垂直拆分:将查重服务单独部署,即使查重服务压力大,也不影响选题等核心业务。
- 容器化与简易编排:使用 Docker 封装每个核心服务(Web应用、查重消费者、ES、Redis),利用 Docker Compose 管理。在选题高峰期,可以手动或通过简单脚本快速扩容多个查重消费者容器实例。
- 利用云服务:如果学校允许,可以将计算密集型的查重服务或 Elasticsearch 集群部署到云服务器上,实现按需付费的弹性伸缩。
架构没有银弹,最适合的才是最好的。希望这篇笔记能为你带来启发,也欢迎你动手复现其中的核心模块,在实践中加深理解。
更多推荐

所有评论(0)