SpringBoot电影评论网站系统设计:从表结构到缓存与全文检索实战
简介面向计算机专业毕业设计及Spring Boot实战学习者提供一套完整的电影评论网站项目源码系统涵盖用户注册登录、电影信息展示、评论发表与管理、评分系统、用户权限管理等核心功能后端基于Spring Boot与MySQL借助JPA简化数据持久层前端采用Vue/HTML等实现页面交互。压缩包共779个文件约20.73MB以java、vue、js等前后端代码文件为主同时包含svg、jpg、gif等界面素材、sql数据库脚本、jar可运行包及构建运行脚本bat、mvnw等目录结构清晰便于直接导入与二次开发。资源另附详细设计论文完整记录了需求分析、系统设计、数据库设计、实现与测试等环节并提供了带初始数据的数据库表可直接运行体验完整功能。目前已有75人学习下载适合需要完成毕业设计或想通过真实项目巩固Spring Boot开发技能的学习者。1. 一个 SpringBoot 电影评论网站难点不在评论在数据组织一个 SpringBoot 电影评论网站的功能清单初看只有注册、登录、看详情、写评论这几件事。若把“系统设计与实现”当成普通的后台增删改查通常会漏掉两处真正决定工程质量的点一是评论与电影评分如何保持一致二是详情页的读压力怎么扛。前者做不好评论区里全是“我打完分页面没有变化”后者做不好演示时一刷新就卡顿。这篇文章把这个项目拆成一套能落地的方案从表结构、核心接口到防刷与检索最后给一套不用外网服务也能演示的部署方式。适合正在做毕业设计或数据库课程设计的同学也适合想把 SpringBoot 评论类项目快速做成型的工程师。2. 系统设计与数据库表结构六张表撑起评论核心2.1 先从使用路径切模块再谈建表合理的顺序是先列用户故事不是先画架构图。这个系统里主要角色有两个普通用户在首页浏览电影、搜索电影、看详情、写评论、打分、收藏管理员维护电影信息、分类和轮播图。按动作落点拆模块得到五块用户模块、电影模块、评论模块、收藏模块、内容管理模块。每个模块对应的核心问题分别是登录态怎么维持、电影怎么展示、评论怎么与评分联动、收藏去重怎么保证、后台操作怎么审计。模块涉及表关键动作设计要点用户模块user注册、登录、个人信息role 字段区分普通用户/管理员电影模块category、movie分页列表、详情、搜索分类一对多评分字段冗余评论模块comment写评、打分、点赞、列表评分由应用层聚合回写收藏模块favorite收藏/取消、我的收藏(user_id, movie_id) 唯一索引内容管理banner、movie轮播图推荐、上下架status 软删除不物理删除这张模块表的作用是给后面的 DDL 定边界。很多 springboot 项目翻车是因为把“评论”和“打分”做成了两张表导致删除评论时评分难以回滚。这里建议合并成一张 comment 表打分作为可空字段 score。这样一次操作只有一条记录评分统计可以随时重算逻辑最简单。2.2 六张核心表的 DDL 与字段说明下面这版 DDL 去掉了外键约束用普通索引加应用层校验代替。常见做法是保留逻辑外键评论表里存 movie_id但不建 FOREIGN KEY原因有二一是数据量小外键的级联策略反而干扰手工测试二是系统设计文档里“删除策略”要写半天不如用 status 字段做软删除更省事。CREATE TABLE movie ( id bigint NOT NULL AUTO_INCREMENT, title varchar(128) NOT NULL COMMENT 电影标题, category_id bigint DEFAULT NULL COMMENT 分类id关联category表, director varchar(64) DEFAULT NULL COMMENT 导演, actors varchar(255) DEFAULT NULL COMMENT 主演多个用逗号分隔, release_date date DEFAULT NULL COMMENT 上映日期, poster_url varchar(255) DEFAULT NULL COMMENT 海报地址, description text COMMENT 剧情简介, avg_score decimal(2,1) NOT NULL DEFAULT 0.0 COMMENT 平均分冗余统计字段, rating_count int NOT NULL DEFAULT 0 COMMENT 评分人数冗余统计字段, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_title (title) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电影表; CREATE TABLE comment ( id bigint NOT NULL AUTO_INCREMENT, movie_id bigint NOT NULL COMMENT 电影id, user_id bigint NOT NULL COMMENT 用户id, content varchar(1000) NOT NULL COMMENT 评论内容, score tinyint DEFAULT NULL COMMENT 评分1-10允许只看不评, like_count int NOT NULL DEFAULT 0 COMMENT 点赞数, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0屏蔽, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_movie_time (movie_id, create_time), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电影评论表;movie 表里avg_score和rating_count是典型的反范式设计来源是 comment 表的聚合。这样列表页和详情页显示评分时不需要每次 JOIN 评论表。comment表的复合索引idx_movie_time覆盖了“查某部电影的评论并按时间倒序”这个最高频查询单查movie_id也能命中前缀不需要额外建单列索引。title上的普通索引留给按片名精确匹配的查询模糊搜索在第 4 章讲全文索引时替换掉。2.3 评分聚合不依赖事务锁的统计回写插入评论后最怕的就是评分不准。最常见做法不是“先查平均值再加权平均”而是删除缓存后整体重算统计。因为“读取当前平均值→计算新平均值→写回”在并发写时会发生丢失更新而重算天然幂等不管执行多少次结果都一样。UPDATE movie m SET avg_score ( SELECT ROUND(IFNULL(AVG(score), 0), 1) FROM comment WHERE movie_id #{movieId} AND score IS NOT NULL ), rating_count ( SELECT COUNT(*) FROM comment WHERE movie_id #{movieId} AND score IS NOT NULL ) WHERE m.id #{movieId};这条 UPDATE 的子查询会读取 comment 表而更新动作锁的是 movie 表的对应行。在评论量万级以内整个操作在几毫秒内完成等量级再涨再把它挪到异步任务里重算应用层不需要改接口逻辑。到这一步“发完评论页面立刻更新平均分”的核心闭环就打通了第 3 章会把它接进 SpringBoot 的事务方法里。3. SpringBoot 核心实现认证、评论与缓存链路3.1 JWT 登录态与拦截器不引入 Security 的轻量方案认证方案选型上Spring Security 对这个项目偏重配置和过滤器链的调试成本高。常见做法是 JWT 加拦截器登录成功后签发 token前端每次请求带上 Authorization 头后端用拦截器统一校验。JWT 本身只做身份凭证不放敏感信息。登录成功后的 token 签发逻辑public String issueToken(Long userId, String username) { long now System.currentTimeMillis(); long expire now 24 * 60 * 60 * 1000L; return Jwts.builder() .setSubject(username) .claim(uid, userId) .setIssuedAt(new Date(now)) .setExpiration(new Date(expire)) .signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256) .compact(); }secret来自application.yml里的jwt.secret配置项演示环境不要用代码写死的固定值用随机生成的临时密钥即可。过期时间 24 小时是常规设置如果演示时不想中途重新登录可以拉到 7 天。.claim(uid, userId)把用户 ID 放进 token后续接口从请求里取不需要再查一次数据库。拦截器的校验逻辑Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } try { Claims claims Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes())) .build() .parseClaimsJws(token.replace(Bearer , )) .getBody(); request.setAttribute(userId, claims.get(uid)); return true; } catch (JwtException e) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } }这里单独处理OPTIONS请求是因为前后端分离项目在浏览器里跨域时会先发预检请求预检不带 Authorization 头直接放行。校验通过后把userId放进 request attributecontroller 里用RequestAttribute(userId) Long userId接收每个接口不用自己解析 token也不需要在业务代码里重复判断登录态。3.2 发布评论接口事务方法与评分刷新发布评论这个动作分成两步插入评论记录刷新 movie 表的评分统计。这两步必须在一个事务里。如果先插入评论再异步刷新用户刷新页面会看到“评论出现在列表里但评分还是旧的”演示时非常明显。Transactional(rollbackFor Exception.class) public Long postComment(CommentDTO dto, Long userId) { Comment comment new Comment(); comment.setMovieId(dto.getMovieId()); comment.setUserId(userId); comment.setContent(dto.getContent()); comment.setScore(dto.getScore()); commentMapper.insert(comment); movieMapper.refreshRating(dto.getMovieId()); return comment.getId(); }Transactional默认只回滚运行时异常rollbackFor Exception.class是为了让受检异常也触发回滚保证插入和统计刷新要么都成功要么都失败。dto.getScore()允许为空对应“只看不评”的场景评分非空时前端做 1 到 10 的整数校验后端的 DTO 校验注解再兜一道。comment.setStatus((byte) 1)这类默认值字段在实体里初始化不会因为前端漏传导致评论落库就是屏蔽状态。refreshRating对应的就是 2.3 节那条 UPDATE 语句MyBatis 的 mapper 里直接写即可。注意评分更新没有必要在 Java 层先SELECT再UPDATE那样反而扩大了事务里锁的持有时间。3.3 详情页缓存Cache Aside 的 30 分钟策略电影详情页是典型的读多写少一部电影的详情被看几百次评论可能一天就几条。这里把“电影信息”和“评论列表”拆成两套缓存互不干扰。电影信息用 Cache Aside 模式查询先读 Redis没命中再查库、回填评论写入成功后删除缓存而不是更新缓存。删除比更新好写也不容易出现并发写脏。public MovieVO detail(Long id) { String key movie:detail: id; Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseObject(cached.toString(), MovieVO.class); } MovieVO vo movieMapper.selectVOById(id); if (vo null) { redisTemplate.opsForValue().set(key, , 60, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), 30, TimeUnit.MINUTES); return vo; }注意cached为空的判断。这里对“数据库里不存在的电影 ID”也做了缓存值是空字符串TTL 只有 60 秒。这个空值缓存是防穿透最廉价的方案有人用不存在的 ID 连续请求时Redis 直接挡住不会每次都打到 MySQL。TTL 参数可以按业务调电影信息两小时不更新就调 30 分钟演示时要保证“改完电影数据页面 30 分钟内能看到”的预期这也是我一般推荐的默认值。序列化用 String 配合 JSON不用 JDK 序列化。一方面压测时能从 Redis 里直接看到缓存内容排查问题不用 decode另一方面 JSON 体积小省内存带宽。4. 防刷、检索与排序评论类项目的三个加分项4.1 Redis 限流一个 IP 对同一部电影一分钟最多 5 条评论评论接口是写接口里最容易被打的刷量成本极低。最简单有效的方案是“IP 电影 ID”维度限流用 Redis 的 INCR 和 EXPIRE 组合天然支持原子自增和过期清理不用引入 Sentinel 这类重组件。String redisKey rate:comment: ip : movieId; Long count redisTemplate.opsForValue().increment(redisKey); if (count ! null count 1) { redisTemplate.expire(redisKey, 60, TimeUnit.SECONDS); } if (count ! null count limitPerMinute) { throw new BizException(评论太频繁请稍后再试); }INCR是原子的第一次自增后单独设置过期时间避免每个请求都重置 TTL。limitPerMinute建议做成配置项而不是写死在代码里演示环境和正式环境可以给不同阈值。这里限的是写接口读接口不要限否则会误伤详情页和搜索的正常访问。真要更严格可以再加上用户 ID 维度把 key 换成rate:comment:uid:加上 userId两个维度同时限制。4.2 中文全文检索用 MySQL FULLTEXT 替换 LIKE标题搜索如果用LIKE %关键词%前导百分号会导致索引失效这个点面试必问论文里的“系统性能优化”章节也能写上一笔。中文检索的关键是 ngram parser它能把连续中文按两个字符切词不需要额外安装插件MySQL 5.7.6 起内置。ALTER TABLE movie ADD FULLTEXT INDEX ft_title_desc (title, description) WITH PARSER ngram;SELECT id, title FROM movie WHERE MATCH(title, description) AGAINST(流浪地球 IN NATURAL LANGUAGE MODE);ngram_token_size默认值是 2按“流浪地球”会切成“流浪 / 地球 / 球”的粒度对电影名这种短文本够用。如果切词粒度为 2 导致短词匹配过多可以在 MySQL 配置里调成 3但调整后必须重建全文索引。IN NATURAL LANGUAGE MODE适合自然语言查询返回结果按相关度排序需要精确控制词频权重时改用IN BOOLEAN MODE它支持、-操作符。对于毕设项目自然语言模式就够省去自己写 Lucene 的接入成本。4.3 热评排序点赞权重与时间衰减评论区直接按create_time DESC排会变成“新评论永远置顶”一部老电影的热门评论被最新的一条“打卡”压在下面观感很差。这里用一个简单的时间衰减模型SELECT id, content, like_count, create_time, (like_count * 2 TIMESTAMPDIFF(HOUR, create_time, NOW())) AS hot_score FROM comment WHERE movie_id #{movieId} AND status 1 ORDER BY hot_score DESC LIMIT #{offset}, #{size};like_count * 2的意思是 1 个赞等于 2 小时的新鲜度TIMESTAMPDIFF(HOUR, create_time, NOW())让老评论的自然衰减成线性下降。这个模型的好处是两个权重都能肉眼理解想让“点赞”的影响更大把系数从 2 调到 3想让“时间”更重要把小时数换成分钟粒度。用户手动顶上去的“置顶评论”不需要靠调公式实现给 comment 表加一个top_flag字段排序时top_flag DESC排最前即可。提示热评排序的 SQL 走了idx_movie_time索引但排序字段不是索引列执行计划里大概率出现 filesort。数据量在十万条以内问题不大量上去了再把 hot_score 拆成“点赞数 创建时间”两列分别建索引或用冗余表。5. 论文素材与快速演示把工程变成设计与实现的证据5.1 论文“系统设计”章节的素材组织做毕业设计时最常见的尴尬是代码写完但论文“设计与实现”章节没东西写。其实前面这四章的产物可以直接搬模块表对应“需求分析”的用例划分六张表的 DDL 对应“数据库设计”的 E-R 图认证时序和评论事务方法对应“系统实现”的核心流程。素材整理成一张表写作时按表索引不会漏论文章节工程里现成的素材需求分析用例表、角色权限清单、功能模块划分系统设计E-R 图、六张表 DDL、核心接口签名系统实现JWT 时序图、评论接口时序、Redis 缓存读写路径系统测试接口测试用例表、限流验证结果、全文检索效果截图5.2 用 H2 内存库做演示的配置技巧答辩环境经常没有安装 MySQL或者版本不兼容导致项目启动失败。常见做法是准备一套 H2 内存数据库配置演示时用demoprofile 启动不需要额外装服务重启后数据自动重置适合反复演练。spring: datasource: url: jdbc:h2:mem:cinema;MODEMySQL;DATABASE_TO_LOWERTRUE;DB_CLOSE_DELAY-1 driver-class-name: org.h2.Driver username: sa password: sql: init: mode: always schema-locations: classpath:db/schema.sql >curl -i http://localhost:8080/api/movie/1看到响应里的avg_score和页面显示一致这套链路就通了。本文还有配套的精品资源点击获取