
1. 项目概览与架构设计思路如果你还在纠结在线投票系统到底该用单体还是微服务我的建议是先别急着下结论把投票这个业务场景拆开看。最近我完整搭了一套基于SpringBoot SpringCloud Vue的微服务分布式在线投票系统从需求梳理、服务拆分、代码落地到部署压测整个过程踩了不少坑。这套系统的核心业务不复杂创建投票活动、用户登录、参与投票、实时看票数。但这些看起来简单的功能在“高并发投票”和“杜绝刷票”这两个要求面前细节会出乎意料地多。这篇文章就把我搭建这套系统的完整过程、选型理由、关键代码和填坑记录整理出来适合正在做相关毕设、准备微服务项目面试、或者刚接手类似需求的同学参考。1.1 投票系统到底要解决什么问题先说业务模型。一个在线投票系统通常有三个角色管理员负责发布投票活动设置投票标题、选项、起止时间和是否允许多选普通用户浏览活动、登录后参与投票、查看实时结果运营人员关注最终的票数分布和参与人数。这里的核心数据表其实只有三张活动表、选项表、投票记录表。但一旦加上用户体系、权限校验、统计报表工程结构就没那么简单了。真正难的其实是两个非功能性需求。第一是并发写。投票接口是典型的写密集场景一个热门活动上线短时间可能涌进几千上万个请求。如果后端每个请求都直接更新MySQL的某个选项计数行数据库锁竞争会非常严重极端情况下接口直接超时。第二是幂等防刷。用户没看到票数变化会狂点“投票”按钮脚本也可以写个循环来刷票如果后端不做幂等控制一张票可能被重复计十几次。这两个问题恰好是微服务架构里“分布式锁”和“缓存最终一致性”的最佳应用场景。所以这套系统虽然业务简单但技术覆盖面很宽。从SpringBoot的工程搭建到SpringCloud的服务注册发现、网关路由、声明式调用再到Redis分布式锁、MySQL唯一索引兜底、异步/定时统计同步几乎把后端分布式开发的核心知识点都串了一遍。对于想理解“微服务到底在解决什么问题”的人来说这是一个比“订单系统”“商品系统”更容易讲清楚的项目。1.2 微服务拆分方案与服务职责我最终把系统拆成了五个模块网关服务、认证服务、用户服务、投票服务、统计服务外加一个公共模块。模块划分不是随手切的核心逻辑是按“业务域”和“变化频率”来划。用户、认证、投票、统计这四个域相对独立后续哪一块流量大了可以单独扩容哪一块挂了也不会拖垮整体。模块职责关键依赖默认端口gateway-service统一入口、路由转发、跨域处理、简单鉴权SpringCloud Gateway, Nacos8080auth-service登录注册、JWT令牌签发与校验SpringBoot, Nacos, MySQL, Redis8081user-service用户信息查询、用户资料管理SpringBoot, Nacos, MySQL8082vote-service投票活动管理、投票提交、防重复控制SpringBoot, Nacos, MySQL, Redis8083stats-service票数统计、排行展示、定时同步任务SpringBoot, Nacos, MySQL, Redis8084common模块统一返回结构、异常码、工具类无-网关必须挂在最外层前端只认网关地址不直接调各个微服务。这样做的原因很实际认证、限流、跨域逻辑收敛在一层后续新增服务不需要前端配合改接口地址。vote-service承载投票提交的瞬间写入压力stats-service负责把Redis里的增量票数同步到MySQL并把榜单数据读出来展示两边可以分别扩容。auth-service只做登录和令牌避免用户密码体系被业务服务碰来碰去。1.3 为什么不用纯单体架构说实话如果只是给班级做一次小投票单体SpringBoot加一个MySQL五分钟就能写完。微服务在这里不是为了“显得厉害”而是为了训练一套分布式开发的思维方式。投票场景天然适合微服务vote-service是热点服务所有用户都在打这个接口stats-service是旁路统计服务完全可以慢慢算。把这两个拆开之后我们可以只扩容vote-service而统计服务继续跑在低配机器上资源分配就不会互相拖累。微服务的代价同样明显服务间调用延迟比本地方法调用要高不止一个数量级排查问题必须看多个服务的日志数据一致性也不再是一个数据库事务能兜住的。这也是为什么我在后面花了很大篇幅讲幂等和最终一致性——这些东西单体内根本不需要关心但一旦服务拆开、数据库分开它们就是必修课。如果你只是为了交一个课程设计我更建议先做单体能跑通功能再按本文这套路子拆微服务如果你正准备微服务相关面试或者想做个能放在简历上的项目那这套拆分方案值得完整跟一遍。2. 核心技术实现拆解这一章不按“功能点”讲按“分布式系统里真正麻烦的点”讲。很多同学写微服务项目代码跑通了但问到底层为什么这么写就答不上来。下面几个技术点就是最常见的面试追问区也是实际运行中最容易出事故的地方。2.1 SpringBoot工程结构与配置中心落地工程结构上我用一个父Maven工程管理所有模块。父pom里只做两件事锁定版本、声明模块。依赖管理用dependencyManagement子模块不需要再写版本号这能避免一个项目里出现好几套Spring Cloud版本的经典混乱。我用的组合是SpringBoot 2.7.x、SpringCloud 2021.0.x、SpringCloud Alibaba 2021.0.5.0、Nacos 2.2.x、JDK 1.8。如果你的JDK到了17以上注意SpringCloud Alibaba要选对应的2022.x版本版本对应的规则以官方Wiki为准。配置中心这块有个容易踩的坑SpringBoot 2.4之后默认不再通过bootstrap.yml加载Nacos配置必须额外引入spring-cloud-starter-bootstrap依赖bootstrap.yml才会生效。每个微服务模块的bootstrap.yml大致是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: yml group: DEFAULT_GROUPNacos配置中心的核心规则是dataId的拼接${spring.application.name}-${spring.profiles.active}.${file-extension}。比如vote-service在dev环境配置文件名就是vote-service-dev.yml。我习惯把数据源、Redis连接、RedisKey前缀这些环境相关配置全部放在Nacos里本地application.yml只保留应用名和端口。这样改配置不用重新打包部署Nacos会推送配置变更服务也能做配置热更新。2.2 Nacos、Gateway、Feign的组合用法Nacos同时承担了服务注册中心和配置中心两个角色。和Eureka相比Nacos自带控制台注册列表长什么样打开页面就能看到排查服务有没有注册上非常直观。它把临时实例的默认心跳调成5秒一次一个服务掉线正常几十秒内能从注册列表中摘除。我实际调试时经常用控制台判断是“服务没启动”还是“服务没注册进来”这会节省大量时间。Gateway是整个流量的入口路由配置简单来说就是把请求的Path匹配到某个服务上去。我在vote-service上配置了这样一条路由spring: cloud: gateway: routes: - id: vote-service uri: lb://vote-service predicates: - Path/api/vote/** filters: - StripPrefix1注意uri: lb://vote-service里的lb://前缀它表示走负载均衡方式去找Nacos里名为vote-service的服务实例。StripPrefix1表示把/api前缀剥掉这样vote-service内部Controller里写的是/vote/submit前端调用只需要按/api/vote/submit访问网关即可。CORS跨域也建议在网关统一配置不要在每一个微服务里都配一遍允许跨域否则每次新增服务都会忘。微服务之间的调用我用OpenFeign。它在接口上做声明式调用代码写起来最接近本地方法调用团队协作时接口签名也直观。示例FeignClient(name user-service, path /api/user) public interface UserFeignClient { GetMapping(/{userId}) ResultUserDTO getUserById(PathVariable(userId) Long userId); }使用时有三个细节容易忽略。第一启动类必须加EnableFeignClients不加会报找不到FeignClient的Bean。第二Feign内部的超时时间默认很短建议把连接超时和读取超时适当调大否则业务逻辑超过1秒就容易触发超时重试。第三服务间调用必须传用户身份时可以用RequestInterceptor把当前请求头里的token透传过去否则下游服务认为你是未登录用户。2.3 防重复投票与分布式锁这是整个项目里我最想讲透的部分。投票接口必须做幂等否则一次点击产生多条记录票数不准用户投诉是小事活动公平性都会被质疑。幂等控制分三层来做。第一层是前端防抖按钮点击后变成loading状态禁止连续提交。这层只能优化体验防不了脚本刷票。第二层是数据库约束投票记录表加唯一索引。如果系统限制一个用户在一个活动中只能投一次就建uk_user_activity(user_id, activity_id)如果允许一个用户给多个选项各投一次就用uk_user_option(user_id, option_id)。数据库唯一索引是最终的底牌不管并发多高、代码有没有bug重复数据在落库这一层会被挡掉。第三层是Redis分布式锁在请求进入业务逻辑前先抢锁减少后端无谓的数据库压力。分布式锁的代码网上版本很多但很多都是错的。最常见的问题是setnx和expire分开执行如果刚设置完key服务就崩了锁永远不会过期后面所有请求都卡死。正确做法是在一条原子命令里同时设置值和过期时间释放锁时也不能无脑删key必须用Lua脚本校验value是自己的再删。我当时的核心逻辑是这样String lockKey vote:lock: userId : activityId; String lockValue UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 1. 查活动是否在进行中 // 2. 检查是否已投过票Redis或MySQL // 3. 写入投票记录 // 4. 更新Redis计数 } finally { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), lockValue); } } else { throw new BizException(操作太频繁请稍后再试); }锁的粒度非常关键。如果锁key只写成vote:lock:activityId那整个活动所有用户都被串行化高并发下等于没做性能优化。锁一定要放到用户维度比如vote:lock:1001:2001意思是“活动1001下的用户2001只能同时有一个投票操作”不同用户的请求互不阻塞。2.4 计数、缓存与最终一致性投票计数如果直接往MySQL表的count字段上执行UPDATE vote_option SET count count 1 WHERE id ?一旦投票量大这个行就是热点行数据库会频繁锁行性能立刻掉下来。我把计数移到Redis里用INCR原子自增因为Redis单线程处理这种自增指令非常快撑住几千并发没有问题。考虑Redis毕竟有可能宕机MySQL必须作为最终持久层。我的做法是投票时同时写入vote_record流水表保证有据可查Redis里的计数器作为展示和排行的主要数据源stats-service每隔半分钟跑一次同步任务把Redis中这段时间的增量更新到MySQL的vote_stats表。同步任务的核心是“增量同步”不能每次都全量覆盖。我用一个简单的方案业务启动时从MySQL把各个选项的初始票数加载到Redis每次投票对Redis对应key执行INCR同步任务扫描所有选项的key时先读当前值再用GETSET把Redis里的key重置为0把读到的值累加到MySQL中。如果同步的过程中Redis key被再次INCR获取到的旧值加上新值也不会丢下次同步还能再补。一段时间后可以用投票流水表对一下总数发现偏差再全量重算。这里还有一个常见的争议点到底要不要用分布式事务。我的答案很直接投票不是转账不需要强一致。允许展示的票数和真实落库票数存在秒级延迟但最终必须一致。真正的强一致需求只出现在“投票后发积分”这类跨服务写场景这种场景我会单独用本地消息表加异步补偿处理而不是引入Seata全局事务代价太大且会把Redis操作也拉进事务里得不偿失。2.5 Vue前端与SpringCloud的集成方式前端我用的Vue3加ViteUI框架是Element Plus路由用的Vue Router 4。搭建命令很简单npm create vitelatest vote-admin -- --template vue cd vote-admin npm install vue-router4 axios element-plus前端最重要的两个集成点是请求封装和路由守卫。所有请求都走网关的同一个baseURL我用Vite的代理来解决开发环境的跨域// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产环境如果不想引入Nginx可以直接把Vue打包产物复制到某个后端模块的src/main/resources/static里让SpringBoot统一托管前端页面。此时Vue路由要用hash模式也就是URL里带#/这样刷新页面时不会请求不存在的后端路径。如果坚持用history模式则后端必须做静态资源页面回退把匹配不到接口的路径全部forward回index.html否则刷新就404。如果投票内容是视频类作品前端播放m3u8流的视频也能直接处理。Vue里集成video.js加hls.js就能播放不需要额外安装专门的播放器软件。前端拿到视频地址后初始化播放器时把m3u8地址交给hls.js解析剩下的交给浏览器原生能力。这算是视频类投票活动里比较实用的补充点。3. 从零到一完整搭建过程前面把原理讲清楚了这章给一个可以直接照着操作的落地路径。我按照“环境准备、后端搭建、前端搭建、联调压测”四个阶段来写每一步的操作意图都会说明避免复制完了还不知道在干什么。3.1 环境准备和版本选型我建议你先把版本对齐再动手下载否则后面全是版本兼容的坑。我本机的环境清单工具版本用途JDK1.8或11编译运行SpringBootMaven3.6构建父工程和子模块MySQL8.0业务数据落库Redis6.2分布式锁、计数、缓存Nacos2.2.x注册中心与配置中心Node.js16/18 LTS运行Vue前端工程SpringCloud Alibaba的版本必须和SpringBoot版本匹配这个不是你想用哪个就用哪个。我这次用的组合是Boot 2.7.x配Cloud 2021.0.x配Alibaba 2021.0.5.0跑起来没有明显兼容问题。你要是用SpringBoot 3的体系就要换到对应的新版依赖IDE里也要更新到JDK17不要混搭。确认好版本之后先启动NacosWindows下进到bin目录执行startup.cmd -m standalone然后打开http://localhost:8848/nacos默认账号密码都是nacos。之后创建数据库vote_platform把活动表、选项表、投票记录表、票数统计表建好。唯一索引必须建ALTER TABLE vote_record ADD UNIQUE KEY uk_user_activity (user_id, activity_id);3.2 后端微服务搭建步骤后端我按“父pom - common模块 - 各服务模块”的顺序创建。父pom用dependencyManagement统一管理版本子模块里直接引用依赖即可。每个服务模块的启动类按上面表格里规划的端口设置bootstrap.yml配置Nacos地址再在业务模块里把共同依赖加进去。vote-service里核心的投票提交接口我写得很短因为复杂逻辑都放在Service里RestController RequestMapping(/vote) public class VoteController { Autowired private VoteService voteService; PostMapping(/submit) public ResultBoolean submit(RequestBody VoteSubmitReq req, RequestHeader(userId) Long userId) { return Result.success(voteService.submit(req, userId)); } GetMapping(/list/{activityId}) public ResultListVoteOptionVO listVote(PathVariable Long activityId) { return Result.success(voteService.listOptions(activityId)); } }userId这里是从网关透传过来的请求头网关负责解析JWT并把这个字段塞进请求头。各服务之间不直接依赖对方的数据库vote-service需要用户昵称时通过Feign调用user-service这样服务边界清晰。Service层里先把活动状态查出来活动未开始或者已结束直接返回错误然后执行分布式锁逻辑锁拿到后先查是否已经有投票记录有就返回“请勿重复投票”最后写入流水、更新Redis计数。启动服务的顺序也值得说先把auth-service、user-service、vote-service、stats-service逐个启动等Nacos控制台里五个服务全部注册成功再启动gateway-service。如果网关先启动再启动其他服务在Nacos里找不到路由目标时会一直报503控制台日志会误导你去排查服务端代码。3.3 前端工程搭建与打包后端跑通后可以先把接口用Postman测一遍再开始写前端这样能把问题边界缩小到某一侧。前端工程结构上我分成了views、router、api、utils四个目录。utils里封装axios实例拦截器统一处理token注入和401跳转。路由守卫里判断需要登录的页面没有token就跳登录页router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })投票页面上选项列表由后端接口返回用户点击选项后调用提交接口。按钮置灰和loading是必须做的目的是前文说的第一层防抖。活动结束后按钮禁用显示“投票已结束”用户已投过票就换成“你已参与投票结果实时更新中”。这些交互逻辑虽然简单但直接影响用户体验也决定了是否会有人因为UI反馈不清晰而重复点击。构建部署时开发环境直接访问Vite的5173端口走proxy转发生产环境执行npm run build把dist目录的内容复制到vote-service的src/main/resources/static重新打包即可。这个方法适合演示和小型项目正式项目我更推荐Nginx托管前端把/api反向代理到网关前端和后端完全分离后续前端升级不用重新打后端包。3.4 启动联调与基础压测整个系统启动完成后一个完整的投票请求链路是这样的浏览器访问Vue页面页面请求http://localhost:8080/api/vote/submit请求进入gateway-service网关校验token、做限流、匹配路由转发到vote-servicevote-service通过Feign调用user-service拿到用户信息用Redis分布式锁控制并发写入MySQL流水更新Redis计数stats-service的定时任务把Redis增量同步到MySQL。我在联调完成之后用JMeter做了一轮基础压测。模拟500个并发线程同时投同一个活动观察三个指标Redis里票数计数是否精准、MySQL里投票记录有没有重复、接口平均响应时间是否在合理区间。结果是唯一的因为有Redis锁和数据库唯一索引的双重兜底MySQL里的投票流水没有任何重复记录Redis计数全部正确。如果去掉唯一索引只靠分布式锁在极端并发下可能出现锁过期导致的重复提交这也是我为什么反复强调“双保险比任何单层都要稳”。4. 实战中的常见问题与排错经验这一章整理的是我在搭建和运行过程中真正遇到的坑。很多问题表面上看是“代码报错”实际上都是微服务体系里的通用问题记下来可以少走很多弯路。4.1 问题速查表现象可能原因处理方式服务启动但Nacos控制台看不到实例没引入nacos-discovery依赖或bootstrap.yml没生效引依赖检查是否加了spring-cloud-starter-bootstrap网关转发到服务时报503目标服务没有启动成功或服务名拼写不一致看Nacos注册列表确认服务名完全一致Feign调用报找不到服务启动类缺EnableFeignClients或服务没注册补齐注解等待服务注册完成再测试前端请求跨域网关没配globalcors或Vite代理配置没生效在网关统一配置CORS开发环境检查proxy配置Redis锁一直失效接口被并发打穿setnx和expire分两步执行或锁key范围太宽用setIfAbsent带过期时间的单条命令锁到用户维度刷新Vue页面404前端用了history模式但后端没有页面回退改用hash模式或在后端配置forward到index.htmlMaven构建版本冲突SpringCloud Alibaba与SpringBoot版本不匹配统一用dependencyManagement管理查官方版本映射端口被占用本地有残留进程Windows用netstat -ano查pid结束对应进程4.2 分布式事务的一个理性建议我见过很多同学在简历里写“用了分布式事务”但问他哪里用了、为什么用答不上来。投票系统里真正跨服务写数据的场景只有少数几个比如投票成功之后要给用户加积分、发优惠券。这种场景如果严格要求两边都成功可以先在vote-service里开启本地事务写入投票记录和消息表然后返回成功同时投递一条消息到MQuser-service消费消息给用户加积分。消息表记录投递状态定时任务扫描状态为“未消费”或“消费失败”的消息重新投递。这个方案就是经典的本地消息表加最终一致性不引入Seata也能在业务上保证不丢数据。不要为了展示技术深度强行上Seata。Seata的AT模式需要额外维护事务协调器TC每个全局事务还会持有数据库锁在投票这种高频写场景里反而会成为性能瓶颈。你真正该做的是把业务里哪些操作允许最终一致、哪些必须强一致盘清楚。投票本身允许最终一致用户注册不允许最终一致那就在同一个服务里用一个本地事务解决。4.3 高并发兜底策略系统能扛住日常流量不算完活动上线那一刻流量突然涌进来才是最考验架构的。我给vote-service接入了Sentinel在网关和投票接口上配置流控规则TPS超过阈值直接返回“系统繁忙请稍后再试”的提示而不是让请求全量打穿到数据库。Redis计数方案已经把数据库写压力带走了剩下的潜在风险主要是活动配置缓存穿透某个恶意请求反复查一个不存在的活动ID每次都穿透到MySQL。解决办法是活动详情查询结果为空时也用一个短过期时间的空值缓存兜底。万一Redis宕机了怎么办我做了一个降级开关Sentinel监控到Redis不可用时投票接口暂时切到“直接写MySQL流水MySQL计数”的模式。这个模式性能差但至少保证业务不中断。等Redis恢复后再切回来之后用流水表对总数做一次校准。这种兜底逻辑不复杂但能不能想到、做不做就是普通项目和可上线项目之间的差距。4.4 关于这套系统我还想说的几句回到最开始的问题投票系统该不该用微服务做完这套项目我的答案是看目的。如果目标是快速上线一个临时投票活动单体加Redis加速就足够了如果目标是系统性学习分布式架构或者做一个面试拿得出手的项目微服务拆分的价值在于它逼着你去处理服务注册、配置中心、远程调用、分布式锁、最终一致性这些单体开发根本碰不到的问题。最后分享一个小技巧。调试微服务系统时一定先把日志体系打通。我每个服务都加了traceId从网关进来到最终落库一条请求的完整链路靠traceId串起来。线上排查问题的时候不可能还像单体项目一样CtrlF看报错有了traceId之后跨服务查日志的时间能缩短一大半。这个习惯建议你从第一天写代码就养成别看项目小就不做等真正出故障时你会发现它是唯一的救命稻草。