从标题就能看出,这是一个很典型的"微服务 + 前端工程化"实训型项目:基于 SpringCloud 的作品投票系统,前端用 Vue3 全家桶。这类项目在课程设计、毕业设计乃至企业内部的小型竞赛中非常常见,但说实话,百分之八十的人做出来的东西只是"能跑",离"能讲清楚、扛得住面试追问"还差得远。
这篇博文我想换个角度,不给你贴一堆无脑代码,而是从架构决策、核心业务实现、前后端协作、以及面试官最爱问的"为什么这样设计"入手,把整个项目的关键节点完整拆一遍。无论你是要拿它交作业,还是想写进简历,这篇都能直接当参考底稿用。
1. 内容整体设计与服务拆分思路
1.1 为什么一个投票系统也要上 SpringCloud?
很多人第一反应是:投票系统而已,单机 SpringBoot + Vue2/3 一把梭不就行了?非要搞微服务,不是过度设计吗?
这话一半对,一半不对。如果只是班级内部选个三好学生,单机确实足够。但如果这个投票系统要面向全校、甚至公开上线,那情况完全不同:
- 流量瞬时高峰:投票活动往往有明确的开赛和截止时间,开闸瞬间会有大量并发请求打进来;
- 业务边界清晰:用户体系、作品管理、投票计数、排行榜统计、后台审核,天然可以拆成独立模块;
- 团队协作需求:毕设或实训中通常多人分工,微服务结构能让每个成员独立负责一个服务,互不阻塞;
- 面试价值:SpringCloud 是 Java 后端岗位的高频考点,做过和没做过,面试聊起来完全两个深度。
所以这个项目的定位很明确:场景不大,但技术栈完整、边界清晰、痛点典型,是一个把微服务核心组件全部串起来的绝佳实践载体。与其做一个"大而全但讲不清"的项目,不如做一个"边界清楚、每个服务能说出设计理由"的项目。
1.2 服务划分的三种常见方案
我在带学生做这类项目时,见过三种服务拆法:
| 拆分方案 | 服务划分 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 保守方案 | 单服务 + 模块分包 | 开发快、调试简单 | 没有微服务核心组件,面试减分 | 时间极紧、纯交作业 |
| 标准方案 | user-service / work-service / vote-service / gateway / auth | 边界清晰、组件齐全、工作量适中 | 服务间调用需要设计好接口 | 实训、毕设、简历项目 |
| 激进方案 | 在上述基础上再拆 statistics-service / message-service / admin-service | 微服务元素更丰富、性能隔离好 | 开发量大、联调复杂、容易烂尾 | 团队人数多、周期长 |
我的建议是选标准方案,再根据自身情况决定是否加一个 statistics-service 专门做排行统计。投票系统的核心痛点——并发扣票、防刷、排行榜一致性,在这个结构下都能得到合理归置。
1.3 技术栈选型与版本搭配
这个标题同时提到了 SpringCloud 和 Vue3,那我直接把我这套验证过、能跑通的组合列出来:
| 层次 | 选型 | 版本建议 | 说明 |
|---|---|---|---|
| 后端基础 | Spring Boot | 2.7.x 或 3.x | 建议 2.7.x,兼容性最稳 |
| 微服务框架 | Spring Cloud | 2021.0.x / 2022.0.x | 与 Boot 版本严格对应,别乱配 |
| 注册/配置中心 | Nacos | 2.x | 比 Eureka 功能强,一个组件解决注册和配置两个问题 |
| 网关 | Spring Cloud Gateway | 跟随 Cloud 版本 | 替代 Zuul,响应式非阻塞 |
| 远程调用 | OpenFeign | 跟随 Cloud 版本 | 声明式 HTTP 客户端 |
| 数据库 | MySQL | 8.0 | 业务数据落库 |
| 缓存 | Redis | 6.x / 7.x | 热点数据、计数器、分布式锁 |
| 前端 | Vue3 + Vite + Pinia | Vue 3.4+ / Vite 5+ | 组合式 API + Element Plus |
| 图表 | ECharts | 5.x | 排行榜、投票趋势可视化 |
提示:Spring Cloud 的版本号是跟着伦敦地铁站名走的,比如 Camden、Hoxton、2020.0(Ilford)。从 2020.0 开始改用年份命名,选版本前一定要去官网确认 Spring Boot 和 Spring Cloud 的兼容矩阵,这一步踩坑的人特别多。
2. 后端 SpringCloud 核心组件落地详解
2.1 注册中心选 Nacos 而不是 Eureka,理由很实在
SpringCloud 入门教材里几乎都拿 Eureka 做例子,但你真去做项目,我建议直接用 Nacos。原因很简单:
Eureka 2.x 已经停止维护,而 Nacos 集成了注册中心 + 配置中心两大能力。配置中心在微服务架构里是刚需——十几个服务的配置散落在各个 jar 包里,改一个参数要重新打包上线,这谁顶得住?Nacos 把配置统一管理起来,还能动态刷新,不用重启服务。这个优势在项目里非常直观。
具体落地步骤:
- 下载 Nacos Server,单机模式启动:
sh startup.sh -m standalone(Windows 下是startup.cmd -m standalone)。 - 后端服务引入依赖,Spring Boot 2.7 对应
spring-cloud-starter-alibaba-nacos-discovery和spring-cloud-starter-alibaba-nacos-config。 - 配置 bootstrap.yml,指定 Nacos 地址和应用名:
spring: application: name: vote-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 file-extension: yaml启动后在 Nacos 控制台就能看到服务实例列表,服务之间通过服务名互相调用,IP 地址变化对调用方透明。
2.2 Spring Cloud Gateway:统一入口与跨域处理
网关是整个系统的"门卫",所有前端请求先到 Gateway,再由它转发到具体服务。我推荐 Gateway 而不是 Zuul,因为它是响应式编程模型,性能更好,而且和 Spring Cloud 生态集成更自然。
网关要做的核心事情有三件:
- 路由转发:根据路径前缀把请求转发到对应服务。比如
/api/user/**转发到 user-service,/api/vote/**转发到 vote-service。 - 统一鉴权:写一个全局过滤器,拦截所有请求,校验 JWT token,白名单路径直接放行。
- 跨域处理:前后端分离项目必须解决跨域。可以在网关层统一配置 CORS,不要让每个服务自己配,否则一旦漏配某个服务,前端就是接连不断的 CORS 报错。
下面这个路由配置是经过实际项目验证的:
spring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=1 - id: vote-service uri: lb://vote-service predicates: - Path=/api/vote/** filters: - StripPrefix=1 globalcors: cors-configurations: '[/**]': allowedOriginPatterns: "*" allowedMethods: "*" allowedHeaders: "*" allowCredentials: true注意:
allowedOriginPatterns而不是allowedOrigins,否则前端带 cookie 跨域请求时会被浏览器拦截。这个问题我当时查了半天,最后发现就是这一个配置词的区别。
2.3 网关层限流:用 RequestRateLimiter 实现双维度限流
热词里出现了"eureka+gateway 的 springcloud 如何限流",说明这是很多人关注的点。Gateway 内置了基于 Redis 的RequestRateLimiter过滤器,配合KeyResolver可以实现按 IP 限流、按用户限流。
限流配置模板:
filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 key-resolver: "#{@ipKeyResolver}"这里的replenishRate是每秒允许填充的令牌数,burstCapacity是桶容量。大白话讲:每秒最多处理 10 个请求,瞬时可以突增到 20 个,超过的直接返回 429。
KeyResolver 代码:
@Bean public KeyResolver ipKeyResolver() { return exchange -> Mono.just( Objects.requireNonNull(exchange.getRequest().getRemoteAddress()).getAddress().getHostAddress() ); }我个人实测下来的经验是:网关层限流保护的是后端服务不被冲垮,但真正防刷要结合业务层判断。比如一个用户一秒内不能重复投票、同一 IP 一小时最多投 20 票,这些规则放到网关做太重,放业务服务里更灵活。
2.4 OpenFeign 服务间调用与降级策略
服务拆开之后,服务之间需要通信。比如投票时要校验用户是否是正常状态、要同步更新作品的票数,vote-service 需要调用 user-service 和 work-service 的接口。
OpenFeign 的用法核心三步:
- 服务提供方写标准 REST 接口。
- 服务消费方定义 FeignClient 接口:
@FeignClient(name = "work-service", fallback = WorkClientFallback.class) public interface WorkClient { @GetMapping("/api/work/info/{id}") Result<WorkVO> getWorkInfo(@PathVariable("id") Long id); }- 主启动类加
@EnableFeignClients。
这里必须提一个坑:Fallback 降级类一定要加@Component注入 Spring 容器,并且application.yml里要把feign.circuitbreaker.enabled打开,否则 fallback 写了也白写,服务调用失败照样直接抛异常。
2.5 应对瞬时高并发的限流补强:Sentinel 熔断降级
如果你用了 Nacos,那配置中心问题解决了一半,但流量防护还差一环。SpringCloud 生态里与 Nacos 搭配最丝滑的是 Sentinel——它是面向分布式系统的流量防卫组件,能做到限流、熔断、系统保护三位一体。
为什么有 Gateway 限流还要用 Sentinel?因为 Gateway 限流粒度比较粗(按 IP、按路径),而 Sentinel 能做接口级 QPS 限制、线程数限制、慢调用比例熔断,还能在控制台动态调整规则。比如投票接口如果 QPS 超过 300,直接返回友好提示而不是让请求压到数据库,这种场景 Sentinel 比 Gateway 的全局过滤器更精准。
接入 Sentinel 的配置:
spring: cloud: sentinel: transport: dashboard: 127.0.0.1:8080然后启动 Sentinel 控制台(java -jar sentinel-dashboard.jar),在控制台给 vote-service 的投票接口配置 QPS 限流规则即可。控制台里能看到实时的调用链路和流量曲线,排查问题非常直观。
3. 投票核心业务与防刷设计
3.1 数据库设计三张核心表
投票系统的数据库表可以有很多,但核心就三张:用户表(user)、作品表(work)、投票记录表(vote_record)。另外可以加一张作品标签表或者投票活动配置表,具体看需求。
关键字段设计:
| 表 | 核心字段 | 设计说明 |
|---|---|---|
| user | id, openid/username, nickname, avatar, role | role 区分普通用户和管理员 |
| work | id, user_id, title, description, image_url, status | status 控制是否审核通过、是否可投票 |
| vote_record | id, work_id, user_id, create_time | 唯一索引 (work_id, user_id),保证一个用户对一个作品只投一票 |
唯一索引这一条非常关键。不加这个索引,代码里就算加了判断,并发下照样能插入重复投票记录。数据库的唯一约束是最后一道防线,必须要有。
3.2 Redis 在投票系统里的三个核心应用
投票系统是典型的读多写少 + 数据热热点场景,Redis 在这里面可以承担三个重要任务:
任务一:排行榜缓存
排行榜如果用 MySQL 的ORDER BY vote_count DESC LIMIT 10,数据量大了之后会越来越慢,而且每次刷新都要查一次库。用 Redis 的 ZSet(有序集合)实现排行榜是最标准的做法:
# 作品被投一票 ZINCRBY work:rank 1 10001 # 查询前10名 ZREVRANGE work:rank 0 9 WITHSCORESZINCRBY是原子操作,天然支持并发加票。排行榜从 Redis 里取,响应时间在毫秒级。
任务二:投票计数的原子累加
我在做这个项目时,第一版保存票数用的是 MySQL 的UPDATE work SET vote_count = vote_count + 1 WHERE id = ?。这个小项目数据量不大时挺好用的,但你想在面试里聊"并发扣减"这个话题,那还是要有点更深入的设计。
用 Redis 做计数,然后异步同步到数据库:
// 投票时先操作 Redis Long currentCount = redisTemplate.opsForValue().increment("work:vote:" + workId, 1);increment是原子操作,不会出现"两个请求同时读到旧值导致丢票"的问题。
任务三:防重复投票
结合数据库唯一索引,再在 Redis 里做一个短时间的重复标记,双保险:
Boolean firstVote = redisTemplate.opsForValue() .setIfAbsent("user:vote:" + userId + ":" + workId, "1", Duration.ofDays(1)); if (Boolean.FALSE.equals(firstVote)) { // 这个用户对这部作品已经投过票 }3.3 防刷策略组合拳
单靠拦截器限流并不能完全防住刷票行为。投票系统在实践中会面对几种常见的刷票手段,我按实际威胁程度排个序:
- 脚本批量投票:绕开前端界面,直接用 Postman/Apifox 循环调接口。对策是登录后拿到 JWT 才能投票,而 JWT 需要账号密码,批量注册成本较高。
- 多个代理 IP 切换投票:同一用户用拨号代理换 IP 投票。对策是结合用户账号维度限制——一个账号一天只能投几票、一个作品只能投一票。
- 并发打爆接口:利用并发请求试图绕过单线程检查。对策是 Redis 原子操作 + 数据库唯一索引双保险,让"同时请求"在数据库层被约束住。
业务层的投票请求处理流程:
public Result<Void> vote(VoteRequest request) { // 1. 校验用户登录状态 // 2. 校验作品状态(是否审核通过、是否在投票期间) Long userId = SecurityUtils.getCurrentUserId(); Long workId = request.getWorkId(); // 3. Redis 判断是否重复投票 Boolean firstVote = redisTemplate.opsForValue() .setIfAbsent("user:vote:" + userId + ":" + workId, "1", Duration.ofDays(1)); if (Boolean.FALSE.equals(firstVote)) { return Result.error("你已经为该作品投过票了"); } // 4. Redis 原子累计票数 redisTemplate.opsForValue().increment("work:vote:" + workId, 1); redisTemplate.opsForZSet().incrementScore("work:rank", workId, 1); // 5. 异步消息落库,最终一致 mqTemplate.convertAndSend("vote.topic", new VoteMessage(userId, workId)); return Result.success(); }注意第 5 步:投票的核心路径在 Redis 中完成,秒回成功;落库操作放进消息队列做异步处理。也就是这里存在一个"最终一致性"的权衡——Redis 是准实时的数据,MySQL 是最终落地的数据。这个方案在投票类高并发场景下是合理的,因为即使某个时刻 Redis 和 MySQL 存在短暂不一致,最终排名以 MySQL 为准做一次对账即可。
3.4 排行榜一致性对账方案
Redis 和 MySQL 双写的情况下,需要一套对账机制,保证最终一致性。我的做法是写一个定时任务,每小时做一次全量对账:
- 扫描 Redis 里所有
work:vote:*计数键; - 读取对应的 MySQL 票数字段;
- 如果 Redis 值 > MySQL 值,按差值补一次数据库更新,减少数据差异。
再配合一个每日凌晨的全量重算任务:清空 MySQL 的 vote_count 字段,然后从 vote_record 表中COUNT(*)重新统计,把准确值写回去,并同步刷新 Redis 排行榜。这套方案虽然不能保证实时一致,但能保证"最终正确",而且实现成本很低。
4. 前端 Vue3 项目搭建与核心实现
4.1 Vite 创建项目与目录结构规划
前端我强烈建议用 Vite 而不是 vue-cli 创建 Vue3 项目。Vite 冷启动速度极快,热更新也是毫秒级,开发体验比 Webpack 时代的 vue-cli 好一个数量级。
npm create vite@latest vote-web -- --template vue cd vote-web npm install npm install vue-router@4 pinia axios element-plus echarts然后在src下按功能划分目录:
src/ ├── api/ // 接口请求封装 │ ├── auth.js │ ├── work.js │ └── vote.js ├── assets/ ├── components/ // 公共组件 ├── router/ // 路由配置与守卫 ├── stores/ // Pinia 状态模块 ├── utils/ // 工具函数、axios 封装 ├── views/ // 页面组件 └── App.vue这个结构的好处是"按业务切分、各归其位",多人协作时几乎不会出现冲突。
4.2 axios 封装与 Token 刷新联动
前后端分离项目里,axios 封装是最基础的一层。接口请求前读 token 放进请求头,响应码是 401 时自动跳转登录页,这些大家都懂。我这里提两个容易漏的细节:
第一个是请求拦截器里不要只加 token,还要在遇到上传文件接口时设置Content-Type为multipart/form-data,否则文件上传会因为默认的 JSON 头报错。
第二个是401 响应的统一处理要避免死循环。如果你的代码里写的是"401 就跳转登录页",那需要判断当前请求的 URL 是不是登录接口本身,否则会陷入"登录请求失败 401 → 跳登录页 → 页面刷新 → 再发登录请求"的循环。
axios.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { const isLoginRequest = error.config.url.includes('/auth/login'); if (!isLoginRequest) { router.push('/login'); } } return Promise.reject(error); } );4.3 Pinia 状态管理:用户信息和投票状态
Vue3 官方推荐状态管理库从 Vuex 换成了 Pinia。Pinia 的 API 更简洁、对 TypeScript 支持更好、没有了 mutations 的概念,直接改 state 就行。
项目中我会用两个 store:
// stores/user.js export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: {} }), actions: { setToken(token) { this.token = token; }, logout() { this.token = ''; this.userInfo = {}; } } }); // stores/vote.js export const useVoteStore = defineStore('vote', { state: () => ({ votedMap: new Map(), rankList: [] }), actions: { async fetchRankList() { const res = await getRankListApi(); this.rankList = res.data; } } });这里有个小技巧:votedMap用Map结构存储"当前用户给哪些作品投过票",进入作品详情页时直接查本地 Map,不用每次都请求后端,体验非常流畅。
4.4 组件通信从父子到跨组件
Vue3 开发中组件通信是最高频的场景,热词里也专门提到了"父子组件 非父子组件 交互"。我按实际使用频率总结一下:
| 场景 | 方案 | 说明 |
|---|---|---|
| 父传子 | props | 父组件通过属性传值,子组件用defineProps声明 |
| 子传父 | emit | 子组件用defineEmits触发事件,父组件监听 |
| 跨层级、非父子 | provide/inject | 适合祖先给后代传值,不需要逐层传递 |
| 全局状态 | Pinia | 登录状态、用户信息、排行榜数据等全局共享数据 |
| 兄弟组件 | Pinia 或 mitt | 简单场景用 Pinia,调试更直观 |
一个典型的父子组件示例:
<!-- Parent.vue --> <script setup> import { ref } from 'vue'; import VoteCard from './VoteCard.vue'; const workList = ref([]); function handleVoteSuccess(workId) { // 更新本地状态,刷新排行榜 refreshRankList(); } </script> <template> <VoteCard v-for="work in workList" :key="work.id" :work="work" @vote-success="handleVoteSuccess" /> </template><!-- Child.vue --> <script setup> const props = defineProps({ work: { type: Object, required: true } }); const emit = defineEmits(['vote-success']); function vote() { voteApi(props.work.id).then(() => { emit('vote-success', props.work.id); }); } </script>4.5 投票页面前端交互设计
前端投票页面的核心交互有三块:
- 作品卡片:展示作品图、标题、作者、当前票数,点击"投票"按钮触发投票。
- 排行榜:实时显示 TOP10,票数变化时用 ECharts 的横向柱状图动画呈现,视觉效果很好。
- 投票弹窗:投票前弹窗确认,防止误触。
前端在调投票接口前可以先做一次本地校验:
async function handleVote(workId) { // 本地先判断是否已登录 if (!userStore.token) { ElMessage.warning('请先登录'); router.push('/login'); return; } // 本地判断是否已投过 if (voteStore.votedMap.has(workId)) { ElMessage.warning('你已经为该作品投过票了'); return; } await voteApi(workId); // 成功后更新本地状态和使用体验 voteStore.votedMap.set(workId, true); const work = workList.find(item => item.id === workId); if (work) work.voteCount++; ElMessage.success('投票成功'); }4.6 Vue3 性能优化:diff 算法与虚拟列表
热词里提到一个 Vue3 的经典面试题:Vue3 diff 相对 Vue2 的优化点。这个知识点不只是面试要背,项目里也真的用得上。
Vue2 的 diff 是"全量对比":数据变化时,虚拟 DOM 从头到尾比较新旧 vnode 树,即使只有一个节点变了,也会遍历整个树做对比。Vue3 做了三个关键优化:
- 静态提升:模板中不变的静态节点在编译阶段就提取出来,不在每次渲染时重新创建;
- patchFlag:编译时标记节点中哪些部分是动态的(属性、文本、事件等),diff 时只比对有标记的部分;
- 最长递增子序列:处理多节点移动时,用算法求出最少的节点移动次数,避免不必要的 DOM 操作。
在作品列表页面,如果作品数量比较大(比如几百上千条),"虚拟滚动"才是终极方案。社区推荐vue-virtual-scroller,它只渲染可视区域内的 DOM 节点,列表再长页面也不会卡。
5. 前后端联调与部署落地
5.1 本地联调环境配置
前后端联调最让人头疼的就是地址配置问题。前端开发时访问http://localhost:9527(Vite 默认 5173),后端接口在http://localhost:8080网关,如果前端代码里直接把接口地址写死,换环境就得改代码重新打包。
正确做法是在 Vite 的vite.config.js里配置代理:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端代码里请求/api/vote/list,开发环境下会自动转发到网关。生产环境打包后,再用 Nginx 做同样的反向代理,前端代码不用改一行。
5.2 Docker Compose 一键部署方案
实训或毕设答辩时,如果你说"我这个项目能 Docker 一键部署",老师或面试官的好感度会明显提升。这个项目的容器化方案不复杂,核心就四个容器:
version: '3' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: vote_db ports: ["3306:3306"] redis: image: redis:7 ports: ["6379:6379"] nacos: image: nacos/nacos-server:v2.2.3 environment: MODE: standalone ports: ["8848:8848", "9848:9848"] gateway: build: ./gateway ports: ["8080:8080"] depends_on: [nacos, mysql, redis]服务多起来以后,再追加 user-service、work-service、vote-service 的容器即可。注意一个坑:容器之间通信要用服务名而不是 localhost,application.yml里的 Nacos 地址就要写成nacos:8848。
5.3 前后端构建产物发布
前端构建命令是npm run build,产物在dist目录。后端每个服务用 Maven 打包成 jar,通过 Dockerfile 打成镜像。
前端 Nginx 配置里需要做两件事:一是把/路径指向dist目录;二是把/api路径反向代理到网关地址。第一个是静态资源托管,第二个是接口转发,两者不冲突。
6. 常见问题排查表与避坑技巧
6.1 排查表
这个项目跑起来之后,我整理了一些高频问题,按症状、原因和解决方案列表,方便你对照自查:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| Nacos 控制台看不到服务 | bootstrap.yml 没配置或 Nacos 地址不对 | 确认 spring.application.name 与配置文件一致,在 Nacos 服务列表页面点刷新 |
| 前端请求 404 | 网关路由没配对或者路径前缀没剥干净 | 检查 StripPrefix 配置,确认后端接口实际路径 |
| 跨域报错 CORS | 网关 globalcors 没配置或配置项写错 | 确认 allowedOriginPatterns 和 allowCredentials 同时配置 |
| 投票后票数没增加 | Redis 计数成功但异步落库失败 | 检查 MQ 消费者是否正常,比较 MySQL 与 Redis 中的票数差值 |
| Feign 调用报连接超时 | 服务的端口或服务名写错;Feign 默认超时时间太短 | 确认 target 服务已启动,调大 ribbon.ReadTimeout 和 ConnectTimeout |
| 数据库报唯一键冲突 | 并发投票同一作品同一用户 | 属于预期行为,在代码中捕获 DuplicateKeyException 并给用户友好提示 |
| 前端热更新失效 | Vite 的缓存问题或被代理转发影响 | 清空 node_modules/.vite 缓存,重启 dev server |
6.2 我踩过的三个比较有代表性的坑
第一个坑是Spring Cloud 版本不匹配。这个项目我最早用 Spring Boot 3.0 + Spring Cloud 2022.0.0,结果 OpenFeign 的依赖名变了,Gateway 的 CORS 配置也失效了,排查了很久才发现是版本兼容问题。如果你不是特别需要 Spring Boot 3 的新特性,建议直接上 Boot 2.7 + Cloud 2021.0.x,网上资料多,坑基本都被踩平了。
第二个坑是前端组件缓存导致的"假数据"。作品审核通过后,前端列表页不刷新还是显示旧状态,原因是<router-view>被 keep-alive 缓存了。解决办法是在路由离开前调用clearCache(),或者给转场组件添加动态 key,强制重新渲染。
第三个坑是异步落库后 Redis 数据在某个节点丢失。我用 Redis 做计数器时没有做持久化配置,结果服务重启后 Redis 数据清了,票数一下子回到 MySQL 的旧值。解决方案是修改redis.conf开启 AOF 持久化,并做好定时对账任务。生产环境里 Redis 绝对不能裸奔不配持久化,否则一个重启就是事故。
6.3 面试官视角:把项目经验变成得分点
因为热词里频繁出现 springcloud 面试题和 vue3 面试题,我顺带从面试官视角补充一些回答思路。这部分算是项目之外的增值内容。
面试官问"你在这个项目里遇到过什么难点",最忌讳的回答是"没有遇到过"或者"感觉都挺简单的"。正确的讲法是选一个真实问题,讲清楚结论、分析过程、排查路径、最终方案。比如:
"投票接口上线后出现并发丢票问题。我先排查了日志,发现两个请求同时读取到旧票数,然后各加一,导致实际投了两票但只加一票。我查了代码,发现原来的 update 语句是读到实体再 set 后 update,存在时间窗口。后来我改成了 MySQL 的原子自增 SQL,再叠加 Redis 的 increment 做前置计数,同时加了数据库唯一索引兜底,最终解决了这个问题。"
面试官问"为什么选 Nacos 不选 Eureka",你要能答出:Eureka 停更、Nacos 一套组件同时解决注册与配置、Nacos 支持服务优雅上下线和权重路由、国产社区活跃度高。这些问题每一条都要能展开讲几分钟。
Vue3 相关的面试题也能从项目里延伸。比如被问到"Vue3 diff 算法优化",你可以结合自己项目作品列表长列表渲染的经验来答:哪些节点是静态提升的、为什么动态绑定要加 key、虚拟滚动为什么会提升性能。把面试题揉进项目经验里,说服力远大于干背八股文。
7. 项目扩展方向建议
7.1 增加管理员端与数据统计
基本投票流程做完之后,把管理员端补上:作品审核、用户管理、投票数据导出。统计模块用 ECharts 做可视化大屏——每日投票趋势折线图、作品类型分布饼图、排行榜横向柱状图。这一块既好做又出效果,演示的时候非常加分。
7.2 引入消息队列做削峰填谷
投票活动开闸瞬间如果请求量太大,可以把投票请求先写进 RabbitMQ/RocketMQ,后端服务按自己消费速率慢慢处理。虽然这个项目体量用不到,但能在文档或者面试中讲清楚方案,本身就是加分项。
7.3 从 SSE 角度实现实时排行榜推送
热词里出现了 "sse 流式输出 vue3" 和 "sse springboot vue3 通用进度条",说明这两个关键词关注度很高。如果投票系统要做实时榜单刷新,长轮询或 SSE 是成本最低的方案。后端生成一个SseEmitter,当票数变化时推送给前端,前端用EventSource接收后更新榜单数字,效果比轮询好得多。这个属于"进阶扩展",感兴趣的话可以单独写一篇博客展开讲。
我在实际做这些扩展时最大的体会是:项目本身的价值不在于技术栈多高级,而在于每个技术选型都能讲出实际理由。一个投票系统,因为可能有流量峰值所以用 SpringCloud 做服务拆分,因为要防刷所以用 Redis 原子计数,因为榜单要实时所以设计了对账方案——这些"为什么"才是真正的项目质感。
如果你正在做这个项目,先把本篇第一到第四部分的核心链路搭通,跑通一个完整的"用户登录 → 浏览作品 → 投票 → 排行榜更新"流程,再逐步叠加高级特性。没有跑通的流程,什么高级设计都是空中楼阁;流程通了,后面的每个点都可以慢慢打磨。祝顺利。