前言
去年公司要做一场线上作品评选活动,最初就是一整套单体应用:后端 SpringBoot、前端 Vue2,部署在一台 4C8G 的云服务器上。活动开始第一天,2 万多人同时在线投票,数据库连接池被打爆,紧接着出现死锁,页面直接白屏。那场面,我到现在都记得很清楚。
后来我们把整个系统用 SpringCloud 重新梳理了一遍,前端顺手升级到 Vue3,前后花了将近 40 天,终于把这套基于 SpringCloud 的微服务作品投票系统稳定跑了下来。这篇文章就围绕这个项目,把架构设计、核心代码、服务拆分、防刷方案,以及前后端联调时踩过的坑全部复盘一遍。无论你是想用 SpringCloud + Vue3 做投票类项目,还是单纯想入门微服务,都值得花几分钟看完,很多坑是我翻了半天文档才解决的。
1. 为什么一个投票系统要上 SpringCloud
1.1 单体阶段的瓶颈:一次活动就把系统打垮了
先说单体应用为什么撑不住。当时的投票接口逻辑并不复杂:用户点一下按钮,后端校验用户登录态,校验作品是否存在,然后往投票记录表里插入一条数据,再更新作品表的投票数字段。
看起来没毛病,但投票这个业务和普通 CRUD 不一样,它的特点是"短时间内的极端集中流量"。活动开始前半小时,大量用户同时涌进来,每个请求都要做若干次数据库读写:
- 查用户信息,1 次查询;
- 查作品信息,2 次查询;
- 插入投票记录,1 次写入;
- 更新作品票数,1 次写入。
一个请求就要产生 5 次数据库交互。假设单库连接池上限 100,QPS 稍微上来一点,线程就开始排队,连接迟迟不释放,死锁就会出现。我们当时的 MySQL 用的是默认的REPEATABLE READ隔离级别,投票记录表和作品表高频读写时,间隙锁冲突非常严重。
把架构拆成微服务,本质上不是为了"用微服务",而是为了把高频的投票操作和低频的作品管理、用户管理拆到不同的进程里,让各自独立扩容、独立扛压。这也是我做这次重构的核心出发点:哪个服务压力大,就单独给它加资源,而不是把整个单体应用一起放大。
1.2 服务边界怎么划分:我最终拆出来 5 个服务
服务拆分我纠结了很久,一开始拆了 8 个,后来发现过度设计了,比如"积分服务"和"用户服务"完全可以合并。最终沉淀下来的拆分逻辑是:按业务域拆分 + 按流量特征拆分。
我自己总结的边界划分标准有三个:
- 数据是否独立:如果一个模块的数据表只会被自己的业务访问,就可以拆出来。
- 流量是否独立:读写频率明显不同的模块,必须拆。
- 团队/职责是否独立:这一点在这个小项目里可以适当放宽。
最终的项目结构如下:
vote-system/ ├── auth-service # 认证鉴权服务 ├── user-service # 用户服务 ├── work-service # 作品服务 ├── vote-service # 投票服务(核心高并发服务) ├── stat-service # 统计服务 └── gateway-service # 网关服务这 5 个服务的边界如下:
- auth-service:登录、注册、Token 签发与刷新。虽然操作的是用户表,但它的调用频率极高,而且只涉及账号密码和 token,把它单独拆出来不会影响用户服务。
- user-service:用户资料的查询和编辑,包括头像、昵称、身份角色。用户在活动页查询自己的信息时主要走这个服务。
- work-service:作品上传、审核、上下架、作品详情。这个服务是低频写、中频读,和投票服务比压力小得多。
- vote-service:投票接口、取消投票、查询我的投票记录。这是全系统压力最大的服务,必须独立出来单独扩副本。
- stat-service:榜单聚合、实时排名、每日统计报表。榜单数据由投票服务通过消息异步通知,统计服务再聚合结果。
额外要提醒的是,不要把"文件上传"做成一个独立服务。头像和作品封面都属于低频上传,放在 work-service 里足够,独立拆出去反而要处理分布式文件存储的一致性问题,纯属自找麻烦。
1.3 技术栈清单与版本匹配
这个项目的技术栈是根据团队熟悉度和社区活跃度选出来的。很多人上来就问 SpringCloud 用什么版本、Nacos 怎么和 SpringBoot 匹配,确实,这一块最容易踩坑。
我用的版本组合表如下:
| 组件 | 版本 |
|---|---|
| JDK | 1.8(稳定,主流云厂商镜像兼容好) |
| Spring Boot | 2.7.14 |
| Spring Cloud | 2021.0.5 |
| Spring Cloud Alibaba | 2021.0.5.0 |
| Nacos | 2.2.1 |
| Spring Cloud Gateway | 3.1.4 |
| OpenFeign | 3.1.4 |
| Sentinel | 1.8.6 |
| MyBatis-Plus | 3.5.3 |
| Vue | 3.3.4 |
| Vite | 4.4.9 |
| Pinia | 2.1.6 |
| Element Plus | 2.4.2 |
关于版本匹配想多说两句:SpringCloud 和 SpringBoot 是强绑定关系,不能用2021.0.5去配 SpringBoot 3.0 以上的版本。SpringCloud Alibaba 也同理,必须先去官网查对应的version mapping。我见过很多新手项目启动不了,本质就是 nacos-client 和 micro-service 的版本号对不上,启动时报各种ClassNotFoundException。
2. 注册中心、网关与核心表设计
2.1 注册中心选 Nacos 而不是 Eureka
Eureka 2.0 已经停止开发,国内生态也更偏好 Nacos,原因很实用:
- Nacos 同时具备注册中心和配置中心两个功能,一个组件干两件事,省去额外部署 Config Server。
- Nacos 支持配置的动态刷新,网关路由、数据源配置等改动不需要重启服务。
- 控制台界面非常清晰,可以直观看到每个服务的在线实例数、健康状态。
我在项目里用的注册中心配置如下,新同学可以直接抄:
spring: application: name: vote-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: prod-001 group: VOTE_GROUP注意namespace这个参数。我会把开发、测试、生产环境用不同的 namespace 隔离,避免开发环境误连生产环境注册中心。很多人忽略这个,一上线就出现"服务路由到本地"这种玄学问题,其实就是 namespace 没隔离。
2.2 网关路由和统一鉴权
网关我用的是 Spring Cloud Gateway,它有两个选择:基于 Netty 的响应式版本,以及传统的 WebMVC。默认就是响应式的,如果误引入了spring-boot-starter-web,Gateway 启动时会直接报错。
路由配置里最关键的是StripPrefix参数:
spring: cloud: gateway: routes: - id: vote-service uri: lb://vote-service predicates: - Path=/api/vote/** filters: - StripPrefix=2StripPrefix=2的意思是删除请求路径前两段,也就是删除api和vote,最终转发到服务里的实际路径。如果你不配这个,服务内部 requestMapping 的路径始终匹配不上,所有请求都 404。
网关层做统一鉴权是比较合理的做法。我写了一个全局过滤器,从请求头里取 Token,调用 auth-service 验证,验证通过后把用户 ID 放进请求头转发给下游服务。
核心逻辑如下:
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } ServerHttpRequest mutateRequest = exchange.getRequest().mutate() .header("X-User-Id", parseUserId(token)) .build(); return chain.filter(exchange.mutate().request(mutateRequest).build()); } @Override public int getOrder() { return -100; } }一个非常重要的经验:网关不要做太重的逻辑。比如把用户信息查全量再放行,这会让网关变成瓶颈。网关只负责校验 Token 合法性和透传用户 ID,用户详细信息由下游服务自行查询 user-service 获取。
2.3 三张核心表的 DDL 与设计思路
数据库是整个系统最容易出问题的地方,表结构设计得好不好直接影响能否扛住高并发。投票系统最核心的三张表如下。
第一张,作品表work_info:
CREATE TABLE `work_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '作者ID', `title` varchar(255) NOT NULL COMMENT '作品标题', `cover_url` varchar(500) NOT NULL COMMENT '封面图', `video_url` varchar(500) DEFAULT NULL COMMENT '视频地址', `audit_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0草稿 1待审 2通过 3驳回', `total_vote` bigint(20) NOT NULL DEFAULT 0 COMMENT '冗余票数字段', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_audit_status` (`audit_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有一个设计技巧:total_vote字段。冗余票数到作品表,查询列表页时免去left join统计表,性能提升非常明显。但这个字段也是分布式事务的痛点,后面会详细讲。
第二张,投票记录表vote_record:
CREATE TABLE `vote_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `biz_id` varchar(64) NOT NULL COMMENT '幂等业务ID', `user_id` bigint(20) NOT NULL, `work_id` bigint(20) NOT NULL, `vote_date` date NOT NULL COMMENT '投票日期,用于按天统计', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_work_date` (`user_id`, `work_id`, `vote_date`), UNIQUE KEY `uk_biz_id` (`biz_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;biz_id是幂等关键,最前面加这个唯一键,是为了防止"用户点了一次投票按钮,前端超时后重试,导致插了两条记录"。这里后面会展开讲。
第三张,投票汇总表vote_stat:
CREATE TABLE `vote_stat` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `work_id` bigint(20) NOT NULL, `stat_date` date NOT NULL COMMENT '统计日期', `vote_count` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_work_date` (`work_id`, `stat_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;需要注意的是:作品表里的total_vote和统计表里的vote_count并不是实时强一致的。列表页展示的票数来自total_vote,历史趋势图来自vote_stat。二者天然有一个短暂的不一致窗口,我后面会给出最终一致性的处理方案,这里按下不表。
3. Vue3 前端:投票交互与实时榜单的实现
3.1 Composition API 还是 Options API
前端从 Vue2 迁到 Vue3 之后,团队内部刚开始争论一个问题:新代码到底用 Composition API 还是 Options API?
我的结论是:投票系统这种交互重、逻辑复用多的项目,统一用 Composition API +<script setup>语法。原因很简单:
- 投票页有"轮次信息""作品列表""当前选中作品""剩余票数""投票状态"这一大堆状态,它们分散在
data、computed、methods、watch里,用 Options API 写,业务逻辑会被打散,特别难维护。 <script setup>把逻辑全部写在顶层,Mixin 那套让人头大的隐性命名冲突问题也彻底消失了。- 一个页面的核心业务函数可以按功能拆分到
composables目录里,逻辑复用非常顺手。
看一个简单对比。投票按钮的禁用状态,用 Options API 你得同时维护data和watch;用 Composition API 只需要一个computed:
const props = defineProps({ workId: { type: Number, required: true } }); // 我投过哪些作品 const myVotes = ref([]); // 当前轮次还能投几票 const remainCount = computed(() => totalVoteLimit.value - myVotes.value.length); const canVote = computed(() => { if (!isLogin.value) return false; if (props.workId === selectedWorkId.value) return false; return remainCount.value > 0; });这样的代码读起来非常直接,canVote就是"没登录不能投,自己不能投自己,票数用完不能投"三个规则的集合。
3.2 投票提交与响应式状态管理
投票页最核心的交互就是点"投票"按钮。我强烈建议前端只做两件事:乐观更新 UI + 异步提交补偿。
所谓乐观更新,就是用户点了投票按钮,我先不等待后端返回,直接把按钮变成"已投票",同时把列表里的票数+1。等后端返回成功,就保持现状;后端返回失败,再回滚。
状态管理用的 Pinia,这是我个人非常推荐的方式——相比 Vuex 而言,Pinia 的setup风格写法天然适配 Composition API,而且没有 Mutation,直接actions里改数据。
// stores/vote.js import { defineStore } from 'pinia'; import { voteApi } from '@/api/vote'; export const useVoteStore = defineStore('vote', { state: () => ({ voteRecords: [], workList: [], loading: false }), actions: { async submitVote(workId, callbacks) { const rollbackData = { ...this.workList }; // 乐观更新 this.workList[workId].totalVote++; this.voteRecords.push(workId); try { const { data } = await voteApi.submit(workId); // 后端可能返回新的票数和一键防刷校验结果 this.workList[workId].totalVote = data.totalVote; callbacks?.onSuccess(data); } catch (error) { // 失败回滚 this.workList = rollbackData; this.voteRecords = this.voteRecords.filter(id => id !== workId); callbacks?.onFail(error); } } } });这个设计里有几个细节要注意:
- 乐观更新必须保存在一个临时快照里,失败时才能准确回滚。
- 后端返回的
totalVote才是权威值,UI 上的显示必须同步成后端返回的数字,不然并发投票时前端算出来的票数会漂移。 - 投票按钮点击后一定要禁用 2 秒左右,这不是后端限制,纯属防用户手抖,也能减轻接口压力。
3.3 实时榜单与 WebSocket 推送
榜单是本系统里最有视觉冲击力的模块,用户看了自己的作品排名会一直刷新。最开始我用了setInterval每 5 秒轮询一次榜单接口,后来发现两个问题:
- 每次轮询都要查最新票数,榜单接口 QPS 被刷得很高;
- 实时性不够,用户看到票数变化的延迟感明显。
后来改成 WebSocket 推送,后端在投票落库后向stat-service发消息,由stat-service聚合后推送到前端。
前端连接 WebSocket 并订阅轮次主题的代码如下:
import { ref, onMounted, onUnmounted } from 'vue'; import { useVoteStore } from '@/stores/vote'; const socket = ref(null); const store = useVoteStore(); function connectRankSocket() { const wsUrl = `ws://${location.host}/api/stat/ws/rank`; socket.value = new WebSocket(wsUrl); socket.value.onopen = () => { // 发送订阅消息,告诉后端我关心哪个轮次的榜单 socket.value.send(JSON.stringify({ type: 'SUBSCRIBE', roundId: currentRoundId })); }; socket.value.onmessage = ({ data }) => { const message = JSON.parse(data); if (message.type === 'RANK_UPDATE') { store.updateRank(message.payload); } }; socket.value.onclose = () => { // 断线重连 setTimeout(connectRankSocket, 3000); }; } onMounted(connectRankSocket); onUnmounted(() => socket.value?.close());这里要强调一个经验:WebSocket 的后端前面必须走网关,注意 URL 是/api/stat/ws/rank,这样网关可以统一做 Token 鉴权。Gateway 天然支持 WebSocket 转发,只要路由配置和普通 HTTP 一样即可。
还有一个易踩的坑:WebSocket 的onclose不等于连接成功断开,网络抖动也会触发。重连时要加退避策略,否则页面一多,服务端会被"重连风暴"打垮。
4. 高并发投票:防刷、幂等与最终一致性
4.1 投票接口的幂等设计
投票系统的核心痛点就是防重复投票。前面表设计里我预留了biz_id,这就是幂等键。前端在发起投票请求时,生成一个全局唯一的clientRequestId,后端在vote_record表里用这个字段做唯一约束。
整个投票接口的处理流程是:
- 前端生成
clientRequestId(UUID),携带 Token 发请求。 - 网关鉴权通过后,把用户 ID 透传到 vote-service。
- vote-service 先校验 Redis 里是否已有该
clientRequestId,如果有,直接返回之前的结果(幂等)。 - 没有则开启事务,插入
vote_record,更新作品的total_vote。 - 提交事务后,把
clientRequestId写入 Redis 并设置过期时间。
对应的 Service 伪代码如下:
@Override @Transactional(rollbackFor = Exception.class) public VoteResult submitVote(VoteRequest request, Long userId) { // 幂等校验 String key = "VOTE:IDEMPOTENT:" + request.getClientRequestId(); if (redisTemplate.hasKey(key)) { return new VoteResult(SUCCESS, "重复请求不作处理"); } // 业务校验:作品是否存在、是否在投票期、是否重复投 WorkInfo work = workClient.getById(request.getWorkId()); if (work == null) { throw new BizException("作品不存在"); } int rows = voteRecordMapper.insert( new VoteRecord() .setBizId(request.getClientRequestId()) .setUserId(userId) .setWorkId(request.getWorkId()) .setVoteDate(LocalDate.now()) ); if (rows > 0) { workService.incrTotalVote(request.getWorkId(), 1); // 记录这个请求已处理,有效期1小时 redisTemplate.opsForValue().set(key, "1", Duration.ofHours(1)); // 发送消息到统计服务 rocketMQTemplate.convertAndSend("VOTE_TOPIC", new VoteMsg(userId, request.getWorkId())); } return new VoteResult(SUCCESS, "投票成功"); }关于幂等,我最先踩过的坑是:只在应用层做判断,没在数据库层面加唯一键。后来发现分布式环境下,多个线程同时处理同一个clientRequestId时,应用层的hasKey可能同时返回 false,导致两条重复记录。加上unique key才能在数据库层面兜底。幂等方案必须是"数据库唯一键 + Redis 标记"双保险。
4.2 缓存 + 异步削峰策略
投票场景比普通社交场景更极端的一点是:票数需要即时展示,但数据库不可能撑住每一票的实时写入。这里我的方案是把写入流程分成了同步和异步两条链路。
同步链路处理的是"强实时"数据——也就是 Redis 里的缓存票数。用户投一票,主要操作是INCR voted:work:{workId},将票数加一。Redis 的INCR是原子操作,天然支持并发。
异步链路做的是"最终一致"的 DB 落库——用一个定时任务,每隔 2 秒把 Redis 中的增量票数同步到 MySQL 里。
具体实现思路是:
// 定时任务,每2秒执行一次 @Scheduled(fixedDelay = 2000) public void syncVoteCountToDb() { // 从Redis的SortedSet中取出有变动的作品 Set<String> keys = redisTemplate.keys("VOTE:COUNT:*"); for (String key : keys) { Long workId = parseWorkId(key); Integer increment = getAndResetIncrement(key); if (increment != null && increment > 0) { workInfoMapper.increaseTotalVote(workId, increment); statServiceMapper.increaseDayCount(workId, today, increment); } } }这里有一个重要的取舍:投票接口的实时性由 Redis 保证,榜单展示也从 Redis 读,但"用户是否已投过"这一类的强一致性判断仍然需要查数据库。如果这个判断也放到缓存里,用户投票后立刻取消,再投一次,缓存和数据库就可能不一致。
最终我的实现是:
- 用户查询"我投过哪些作品"时,读 Redis 缓存,缓存过期后再查库。
- 投票接口处理时,校验库里有没有投票记录,这是强一致。
这种做法的代价是投票接口多了一次数据库查询,收益是彻底避免了"明明投过还能再投"的严重逻辑错误。对于投票系统来说,投错比慢一点更致命。
4.3 分布式事务:千万不要追求强一致
这个系统里真正需要事务保障的只有一步:投票时插入vote_record和更新work_info.total_vote。在单体架构里这两步用数据库事务就搞定了,但拆成微服务后,work-service 和 vote-service 是不同的服务,跨服务事务就成了难题。
一开始我想用 Seata 做分布式事务,后来想了想还是放弃。原因很简单:
- 投票接口是超高 QPS 场景,强一致事务导致的锁等待会直接拖垮数据库。
- 业务上,用户投完票后,作品表里的
total_vote数据延迟几秒钟显示,完全可接受。 - 如果正要更新票数的那瞬间服务宕机,最多就是丢了这一次增量,下次从 Redis 的 INCR 增量里还能补偿。
所以我最终选择了"消息异步补偿 + 定时对齐任务"的方案:
- 投票事务提交成功后,发一条 MQ 消息到
VOTE_TOPIC。 - stat-service 消费消息,异步更新
vote_stat统计表和缓存榜单。 - 定时任务每晚跑一次,比对
vote_record表里当天的投票数和vote_stat表里的统计值,发现不一致就补齐。
这个方案绕开了分布式事务的复杂性和性能损失,换来的是最终一致性。我在项目文档里明确标注了:"榜单数据最多延迟 2 秒,票数统计次日凌晨对齐。"
结论是:小团队做微服务,不要轻易上分布式事务框架,那带来的复杂度比你省掉的代码多得多。
5. 前后端联调与部署:真正让人掉发的细节
5.1 跨域问题和网关的正确配置
前后端联调阶段,最经典的问题是跨域。很多人上来就在 Vue 的 devServer 里配一个代理,这在开发环境好使,但生产环境一旦前端是纯静态部署、后端在网关后面,跨域问题依然绕不过去。
我的做法是:所有跨域处理统一放在网关层。前端只用一个location.host相对路径拼接口地址,不写死 IP 和端口。开发环境用 Vite 的 proxy 把/api代理到网关,生产环境则由 Nginx 反向代理。
Gateway 里的跨域配置:
spring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowedOrigins: - "http://vote.example.com" allowedMethods: - GET - POST - PUT - DELETE - OPTIONS allowedHeaders: "*" allowCredentials: true maxAge: 3600千万要注意:allowCredentials: true时,allowedOrigins不能写*,必须写具体域名。这是浏览器的安全策略,踩过这个坑的人非常多。另外,前端如果自己又配了一层代理,就会造成双重重写路径,网关的路由规则怎么调都不对。
5.2 OpenFeign 超时、负载均衡和重试的坑
微服务之间调用我用的 OpenFeign,看起来简单,但默认配置坑很多。
我遇到最诡异的一个现象是:投票高峰期,服务间调用大量超时。排查了半天才发现,OpenFeign 默认的超时时间是 1 秒,而投票服务内部需要同步调用 work-service 做数据校验,这个校验有时候要查库,1 秒根本不够。
调大超时的配置如下:
ribbon: ReadTimeout: 5000 ConnectTimeout: 3000 MaxAutoRetries: 0 MaxAutoRetriesNextServer: 0这里有一个非常重要的经验:在投票这种写接口的场景,OpenFeign 的自动重试必须关闭,也就是MaxAutoRetries和MaxAutoRetriesNextServer都设为 0。为什么?因为一旦 vote-service 调用 work-service 超时后自动重试,而第一次请求其实已经成功执行了(只是响应超时),第二次重试就会导致重复扣减票数。我当初就是没关重试,上线后一堆人反馈票数不对。
如果业务确实需要重试,必须配合幂等。你可以在请求头里带一个requestId,在 work-service 里按这个 ID 去重。
5.3 Docker Compose 一键部署的经验总结
整个项目的部署我写成了 Docker Compose 编排,一个命令就能启动全部环境。先看完整文件:
version: "3.8" services: mysql: image: mysql:8.0.32 environment: MYSQL_ROOT_PASSWORD: vote_root_2024 MYSQL_DATABASE: vote_system ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql redis: image: redis:7.0.10 ports: - "6379:6379" command: redis-server --appendonly yes nacos: image: nacos/nacos-server:v2.2.1 environment: MODE: standalone NACOS_AUTH_ENABLE: "true" NACOS_AUTH_TOKEN: "secret-token" ports: - "8848:8848" - "9848:9848" rocketmq: image: apache/rocketmq:5.1.3 ... gateway-service: build: context: ./gateway-service dockerfile: Dockerfile ports: - "8080:8080" depends_on: - nacos - mysql - redis部署上的几个体会:
- Nacos 必须固定 IP 或者使用服务名注册。微服务容器里注册到 Nacos 的地址如果是容器 IP,客户端连不上就会调用失败。我最后给所有微服务配了
spring.cloud.nacos.discovery.ip,指定宿主机内网 IP 或者容器固定网络别名,才彻底解决这个问题。 - 前端 Dockerfile 用多阶段构建:第一阶段用 Node 构建 Vite 产物,第二阶段用 Nginx 跑静态文件,镜像体积从 1.2G 降到 80M 左右,部署速度快了很多。
- 数据库的
max_connections一定要调大,并且要在连接池层限制。我踩过一次最严重的坑就是 Nacos 的初始化连接和业务服务抢连接,最后数据库连接池直接达到上限。解决方案是所有微服务连接 MySQL 的最大连接数限制在 20,不要超过 50。
前端 Nginx 配置里,最需要注意的是静态资源缓存,以及 SPA 路由的回退:
location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://gateway-service:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }try_files这条指令是 Vue Router 的 history 模式必须配置的,少了它刷新页面就 404。
6. 复盘体会
这次用 SpringCloud + Vue3 重构作品投票系统,整个过程给我最深的一个感受是:微服务不是银弹,但当你遇到真实的并发瓶颈时,它提供的独立扩缩容能力确实很解决问题。投票服务高峰期可以快速从 2 个副本扩到 8 个,而无缝扩容正是单体应用做不到的。
另外一点:任何架构方案都要结合业务特性做取舍。比如这个项目里,我放弃了分布式事务、放弃了 OpenFeign 的重试、放弃了实时榜单的强一致,换来的是投票接口的高吞吐和整体系统的稳定。系统上线后,压测时单机 QPS 跑到 800 多,整个活动期间没有再出现一次白屏和死锁。
如果你准备抄这个项目,我的建议很直接:先把单体版跑通,再按文章里的拆分思路把服务切出来,最后再逐步加上网关、Nacos、OpenFeign。一步到位容易同时踩好几个坑,到时候都不知道问题到底出在哪一环。
最后补一个小技巧:整个系统调试时,记得在网关层加一个/actuator/health透传,这样前端、运维、测试三方共享一个健康检查接口,排查谁的机器挂了会快很多。祝你的投票系统能扛住下一次活动流量峰。