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

系统架构示意图

1. 背景与核心痛点分析

在动手之前,我们先明确要解决什么问题。高校毕设管理系统有几个鲜明的特点:

  • 瞬时高并发:选题系统开放的那几分钟,所有学生同时登录、刷新、提交,流量是平时的几十甚至上百倍。
  • 强一致性要求:一个课题只能被一个学生选中,这是业务铁律,必须保证绝对正确,不能出现“超卖”。
  • 计算密集型任务:论文查重需要比对海量文本,是典型的 CPU 和 I/O 密集型操作,直接同步处理会拖垮整个系统。
  • 数据可靠性:论文数据是学生的心血,任何丢失或错乱都是不可接受的。

基于这些,我们的核心痛点就清晰了:如何在高并发下保证选题的幂等与一致性?如何异步化处理查重这类耗时操作以提升系统吞吐?如何确保整个流程中的数据安全与事务可靠?

2. 技术选型与权衡

针对上述痛点,我们进行了如下技术选型:

  1. 基础框架:Spring Boot 快速构建、约定大于配置、生态丰富,是快速迭代校园项目的不二之选。

  2. 分布式锁:Redis (Redisson) vs ZooKeeper

    • Redis (Redisson):基于 SETNX 和 Lua 脚本,性能极高,实现简单。Redisson 客户端提供了完善的看门狗(Watchdog)机制,解决了锁的自动续期和避免死锁的问题,非常适合我们这种读多写少、锁持有时间较短的场景(如选题)。
    • ZooKeeper:基于临时顺序节点,强一致性保证,但性能相对较低,部署和维护更复杂。
    • 权衡:考虑到校园服务器资源有限,且选题对性能要求更高,我们选择了 Redis + Redisson 的方案。对于锁的可靠性,我们通过多主 Redis 集群和合理的锁超时时间来保障。
  3. 查重引擎:Elasticsearch vs SimHash 本地计算

    • Elasticsearch:优势在于能进行复杂的全文检索和相似度分析(如 more_like_this 查询),可以快速从海量历史论文库中找出相似段落,支持分布式扩展,适合作为核心查重比对库。
    • SimHash:局部敏感哈希算法,计算出的指纹可用于快速比对文本相似度,适合在内存中进行初步去重或作为前置过滤。
    • 权衡:我们采用混合方案。上传论文时,先用 SimHash 计算一个指纹,与已有论文指纹库(存储在 Redis)进行快速粗筛。如果相似度超过某个阈值,再触发 Elasticsearch 进行精细化的全文相似度分析,生成详细的重复率报告。这样既保证了速度,又提高了准确性。
  4. 异步任务队列: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. 性能与安全考量

  1. 缓存击穿防护:热门课题信息使用 Redis 缓存。使用 setnx 实现互斥锁重建缓存,或使用永不过期缓存+后台定时更新策略。
  2. 查重结果防篡改:生成查重报告后,计算报告内容的哈希值(如 SHA-256),并将其存储到数据库或区块链存证服务中。学生查看报告时,重新计算哈希进行比对,确保报告未被篡改。
  3. 接口限流:在网关层或使用 Resilience4j/Sentinel/topic/select 等核心接口进行限流,例如每秒 1000 次请求,防止恶意刷题或脚本攻击。
  4. 数据库连接池优化:针对高并发,合理配置 HikariCP 连接池参数(如 maximumPoolSize, connectionTimeout),并启用 Prepared Statement 缓存。

5. 生产环境避坑指南

  1. 数据库死锁排查:尽管用了乐观锁,但复杂事务仍可能死锁。开启 MySQL 的 innodb_print_all_deadlocks 日志,监控死锁信息。优化事务粒度,避免在事务中执行过多不相关的更新操作。
  2. 消息积压处理:监控 RabbitMQ 队列长度。如果查重队列积压,可以:
    • 动态增加消费者实例(弹性伸缩)。
    • 检查消费者处理逻辑是否有性能瓶颈(如 ES 查询过慢)。
    • 设置队列最大长度和溢出策略(如 drop-head)。
  3. 冷启动延迟优化:系统重启后,Redis 缓存是空的,大量请求直接穿透到数据库。解决方案:
    • 缓存预热:在系统启动后,使用一个后台任务,将热点课题数据加载到 Redis。
    • 使用 Bloom Filter:对于“是否存在”类查询(如学生是否已选题),可以先查 Bloom Filter,快速过滤掉大量无效请求。
  4. Elasticsearch 性能调优:为查重索引合理设置分片数和副本数。针对 more_like_this 查询,可以限制检索的文档数量和字段,避免深度翻页。

性能监控面板

6. 总结与思考

通过 Spring Boot 整合 Redis 分布式锁、RabbitMQ 异步消息和 Elasticsearch 搜索引擎,我们构建了一个能够应对高校高并发场景的毕设论文管理系统。这套架构的核心思想是:同步操作做减法(通过锁和幂等保证正确性),异步操作做加法(通过队列解耦提升吞吐)

最后留一个思考题:在资源紧张的校园服务器上(可能只有一两台低配服务器),我们如何实现一定程度的弹性伸缩?我的思路是:

  • 垂直拆分:将查重服务单独部署,即使查重服务压力大,也不影响选题等核心业务。
  • 容器化与简易编排:使用 Docker 封装每个核心服务(Web应用、查重消费者、ES、Redis),利用 Docker Compose 管理。在选题高峰期,可以手动或通过简单脚本快速扩容多个查重消费者容器实例。
  • 利用云服务:如果学校允许,可以将计算密集型的查重服务或 Elasticsearch 集群部署到云服务器上,实现按需付费的弹性伸缩。

架构没有银弹,最适合的才是最好的。希望这篇笔记能为你带来启发,也欢迎你动手复现其中的核心模块,在实践中加深理解。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐