news 2026/10/6 3:24:11

公考学习平台微服务架构实战:从选型到上线全记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
公考学习平台微服务架构实战:从选型到上线全记录

做公考知识学习平台,技术选型这件事我纠结了很久。项目立项时需求方给出的预期是“覆盖刷题、模考、视频课、资讯、错题本,能扛住省考和国考前的刷题高峰”,我评估了两天,最后还是放弃了一上来就用单体一把梭的想法,选择了SpringBoot + SpringCloud这套微服务体系,前端用Vue做前后端彻底分离。整个项目从拆架构到完成上线,再到扛过两次模考峰值,中间踩的坑比我预想的多,但收获也足够大。这篇就把我从选型原因、服务拆分、基础设施搭建,到分布式事务、缓存、前端协作、上线运维的完整过程写下来,尽量把当时的取舍逻辑和踩坑细节都还原出来,给正在做类似学习类平台的读者一个参考。

1. 先回答一个问题:公考学习平台为什么非拆微服务不可

1.1 万人模考的入口雪崩风险

公考学习平台有一个很特殊的业务特征:流量在模考时段极其集中。以我们的数据为例,一次大型模考活动开放报名后,一小时内涌进来几万用户,到了交卷和查分的尖峰,请求量直接翻几番。单体架构下,用户请求、题库检索、试卷存储、成绩计算全部复用同一个应用进程和数据库连接池,一旦考试服务扛不住,整个系统都会被拖垮。我在旧项目里经历过一次真实事故,报名高峰时数据库连接池被打满,连管理员后台想登录进去调整配置都进不去——这其实就是典型的雪崩前兆。

拆成微服务之后,最直接的好处是隔离和独立伸缩。考试服务可以单独扩容到多实例,题库服务可以按读写比例调配,就算模拟测试的流量再大,受影响最大的也只是考试链路,用户基本信息、课程播放这些服务不会跟着遭殃。

1.2 题库、课程、资讯三种内容的增长节奏完全不同

公考学习平台的内容类型非常杂:题库是结构化数据,题目、选项、答案解析、知识点标签,需要支持复杂条件检索;课程是视频文件加播放记录,体积大、流量大;资讯和公告则是典型的读多写少。这三类内容放在同一个单体工程里,本质上是在互相干扰。

题库更新有一个明显的高峰期——每年考纲发布、真题出来后,运营团队集中录入和校对题目,这时候后台频繁写库;课程端则主要在晚间和周末有大量视频访问;资讯端平时流量平稳,一旦有新的招考公告发布,瞬间关注度上升。三者对基础设施的要求也不一样:题库要强一致性和复杂检索,课程要带宽和对象存储,资讯要缓存和CDN。把它们拆成不同的服务,才能按各自的节奏独立演进。

1.3 多端接入与团队协作的现实约束

平台要做PC网页、H5、小程序三个端,三个前端小组并行开发。如果后端是单体,所有接口都堆在一个工程里,改一个字段就可能影响所有端;发布节奏也被迫绑定在一起,前端联调等待时间长。拆成微服务后,每个端都通过API网关接入,前端只需要面对一套稳定的网关域名,后端各服务独立发版互不阻塞,两个服务之间要改接口,也只是局部改动。这个协作层面的收益,在我做团队规划时是仅次于性能的考虑因素。

2. 领域边界的划分:我的微服务拆分思路与教训

2.1 按技术分层是拆不出微服务的

第一次设计微服务拆分方案时,我踩了第一个坑:一开始想当然地按“Controller层、Service层、Dao层”来做模块划分,结果拆出来一堆本质上还耦合在一起的服务,改一个业务要跨三四个服务联调,等于把单体强行切成碎片,成本和收益完全不成正比。

后来换了思路,用领域建模的方式重新梳理整个公考学习平台的业务上下文,拆分原则变成了:一个业务域一个服务,服务之间通过API通信,禁止跨服务直接操作对方的数据库表。这样拆出来的服务,内部是高内聚的,外部是低耦合的。

2.2 八个服务模块的职责边界

最终确定的微服务模块如下:

服务名端口核心职责关键数据
gateway-server9090API网关、路由转发、统一鉴权路由规则、白名单
auth-server9100登录认证、JWT签发、令牌刷新用户凭证、令牌状态
user-service9200用户信息、会员等级、积分用户表、会员表、积分流水
question-service9300题库管理、知识点、组卷题目表、知识点表、标签表
exam-service9400模考创建、答题、自动判分试卷表、答卷表、成绩表
course-service9500视频课程、直播回放、播放记录课程表、章节表、播放进度
study-service9600学习记录、错题本、收藏、打卡学习记录表、错题表、打卡表
content-service9700资讯公告、招考信息、政策解读资讯表、公告表
search-service9800Elasticsearch全文检索、搜索推荐ES索引、热搜词表

2.3 拆分后的依赖关系与通信约定

服务拆完之后,通信的约束必须同步定下来。同步调用统一走OpenFeign,每个服务的对外接口有明确的DTO定义,不直接暴露数据库实体;异步调用统一走RocketMQ,比如用户提交试卷后,exam-service只负责保存答卷和判分,然后发送一条“提交完成”的消息,由study-service消费后生成错题本和学习进度。这样即使错题本生成失败,也不会影响考试主链路。

我还定了几条硬性约定:服务之间禁止共享数据库表;禁止内网直接连其他服务的库查数据;所有跨服务调用必须带traceId,方便追踪。这些约定在后面排查问题时真的帮了大忙。

3. SpringCloud基础设施选型与版本落地实录

3.1 注册中心、配置中心、网关都选了谁

基础设施这一层,我一开始对比过Eureka、Consul、Nacos,最后选的是Nacos,原因是它同时解决了服务注册与配置管理两个问题,少维护一套组件。配置中心用Nacos Config,应用启动时从Nacos拉配置,本地保留一份兜底配置防止配置中心短暂不可用时服务起不来;网关用Spring Cloud Gateway,不用Zuul,Gateway基于WebFlux响应式模型,性能好,而且和SpringCloud的适配更自然;服务间负载均衡用Spring Cloud LoadBalancer替代了老的Ribbon。

3.2 版本对应关系与踩坑记录

微服务项目里版本对应关系是最大的隐形坑。我这里直接给出我们验证过的一套组合:

组件版本
Spring Boot2.6.13
Spring Cloud2021.0.5
Spring Cloud Alibaba2021.0.5.0
Nacos Server2.2.1
Redisson3.20.1
RocketMQ4.9.4
Seata1.5.2

这套组合的坑点在于:Spring Boot 2.4之后Ribbon被移除,如果代码里还引用Ribbon依赖,服务启动会直接报错;Spring Cloud 2021.0.x对应的Spring Boot版本必须在2.6.x或2.7.x,如果盲目升级到Spring Boot 3.x,很多依赖会因javax到jakarta的包名变更而崩溃;Nacos的客户端版本与服务端版本不一致时,会出现服务列表频繁刷新、心跳超时误剔除的诡异现象,所以client和server版本尽量保持一致。

3.3 网关、认证、路由的配置细节

网关配置是所有前端请求的入口。我的核心路由配置长这样:

spring: cloud: gateway: routes: - id: exam-route uri: lb://exam-service predicates: - Path=/api/exam/** filters: - StripPrefix=1 - id: question-route uri: lb://question-service predicates: - Path=/api/question/** filters: - StripPrefix=1

这里有两个细节容易踩坑。第一,StripPrefix=1表示去掉第一层路径前缀,也就是说前端请求/api/exam/start,网关转发到服务端时会变成/exam/start,如果服务端接口路径是/api/exam/start,就会404,必须统一约定好哪一层由网关剥掉;第二,uri: lb://exam-service里的lb是必须的,它告诉网关走LoadBalancer去Nacos里找实例,如果写成http://exam-service就变成直连,服务多实例化时负载均衡就失效了。

认证方面,auth-server签发JWT,网关层写了一个全局过滤器,放行登录、注册、验证码等白名单地址,其余请求校验Authorization头。token过期后,前端拿refreshToken去auth-server刷新,鉴权逻辑收在网关这一层,下游服务默认信任网关传过来的用户标识,避免每个服务都去做一遍token解析。

@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); if (whiteList.contains(path)) { return chain.filter(exchange); } String token = request.getHeaders().getFirst("Authorization"); // 校验token并解析用户ID,写入请求头 X-User-Id return chain.filter(exchange.mutate() .request(r -> r.header("X-User-Id", userId)) .build()); } }

4. 分布式硬仗:事务、锁、幂等在刷题与模考场景的实践

4.1 提交试卷时的分布式事务处理

模考提交答卷这个操作,业务上要同时更新好几块数据:exam-service保存答卷并计算成绩,study-service同步生成错题本,user-service累加积分和经验值。如果走同步Feign调用,任何一个环节失败都会导致数据不一致。我在这个场景用的是Seata的AT模式,全局事务注解直接加在提交入口方法上:

@GlobalTransactional(rollbackFor = Exception.class) public SubmitResult submitExam(SubmitRequest request) { // 1. 保存答卷并判分 examRecordService.saveAndScore(request); // 2. 调用study-service生成错题本 studyFeignClient.buildWrongBook(request); // 3. 调用user-service累计积分 userFeignClient.addPoints(request.getUserId(), 10); return SubmitResult.success(); }

AT模式对代码侵入很小,Seata通过解析SQL自动生成undo_log,提交前锁定全局事务,提交后异步删除日志。不过要提醒的是,AT模式默认对性能有损耗,所以只建议用在真正需要强一致的场景。像浏览题目、增加播放次数这类最终一致性就够了的数据,走消息队列异步处理更划算。

4.2 签到与排行榜场景的Redis分布式锁

公考学习平台里的“每日打卡”“连续签到”“模考排行榜”这类功能,天然有并发竞争问题。比如打卡签到,同一个用户连续快速点两次,如果没有锁,就可能产生两条签到记录;再比如排行榜更新,多个实例同时写同一个用户的积分,容易把数据覆盖掉。

我用的Redisson实现分布式锁,核心代码大概是这样的:

RLock lock = redissonClient.getLock("lock:sign:" + userId); boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(500, "操作太频繁,请稍后再试"); } try { signService.doSign(userId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }

这里有两个细节是从线上事故换来的教训。锁的key一定要把业务维度放进锁名里,比如签到锁和答题锁必须分开,否则会发生完全不相关的请求互相阻塞;释放锁之前要判断isHeldByCurrentThread(),防止因为锁超时自动过期后,当前线程又把其他线程创建的锁释放掉。Redisson底层是Lua脚本保证加锁和释放的原子性,比直接用SETNX要安全得多。

4.3 题库热点数据的二级缓存与一致性

公考刷题场景下,热点题目非常集中。比如“最新时政”“行测常识”里的某些题,一天可能被刷几十万次。如果每次都打到MySQL,数据库根本撑不住;如果全部依赖Redis,热点请求会让Redis的单次网络IO成为瓶颈。

我的方案是Caffeine本地缓存加Redis的二级缓存。Caffeine放在每个服务实例内存里,命中率极高且零网络开销,Redis作为跨实例的共享缓存层,MySQL在最底层兜底。读取顺序是:本地缓存、Redis、MySQL,写入顺序是:更新MySQL、删除Redis、短时间容忍本地缓存不一致。本地缓存设置5分钟过期,既保证性能又不会和数据差太远。

对于“刚发布的高热题目”,运营端还有一个主动预热接口,发布题目时直接把题目数据写入Redis,防止上线瞬间的缓存穿透打爆数据库。

4.4 自动交卷任务的幂等与重试设计

模考时间到,系统要自动为未交卷的用户交卷。这个任务放在分布式环境下有两个难点:一是不能让多个服务实例同时执行同一个考试的交卷任务,否则会重复扣分或重复生成成绩;二是自动交卷这个动作天然要求幂等,任务重试时不能影响已交卷的考生。

调度方案我用了ShedLock,它通过数据库表记录锁记录,保证同一时刻只有一个实例在执行任务。自动交卷的任务执行前会先把考试状态改成“判分中”,然后对每个未交卷考生逐个处理。处理时先在Redis里用SETNX写入一个submit:考生ID的标记,标记创建成功才执行交卷逻辑,处理完成后写入最终成绩。这样即使ShedLock失效触发了重试,重复执行也只是发现标记已存在,直接跳过,不会重复扣分。

boolean firstAttempt = redisTemplate.opsForValue() .setIfAbsent("submit:" + examId + ":" + userId, "1", 5, TimeUnit.MINUTES); if (!firstAttempt) { log.warn("重复交卷请求,已跳过. examId={}, userId={}", examId, userId); return; }

5. Vue前端与微服务后端的协同细节

5.1 Vue3 + Vite项目的基础设施搭建

前端选型是Vue3加Vite,状态管理用Pinia,UI组件库用Element Plus。创建工程后,第一步就是配置环境变量,区分开发、测试、生产三个环境。开发环境下所有请求走Vite代理,转发到网关的9090端口,绕开浏览器跨域限制;生产环境直接请求网关域名。

// .env.production VITE_API_BASE_URL = https://exam-platform.example.com/api VITE_VIDEO_BASE_URL = https://media.example.com

环境变量这个事看似简单,却是我见过太多前端项目翻车的地方。有人把网关地址直接写死在代码里,换环境就要改源码重新打包,效率极低。正确做法是让网关统一暴露/api前缀,前端所有接口调用都基于VITE_API_BASE_URL拼路径,这样换环境只改环境变量,代码完全不动。

5.2 权限路由与Token刷新机制

平台里有学员、运营、管理员三类角色,登录后能看到的菜单和页面完全不同。我的实现是:登录成功后,前端把用户信息和token存进Pinia,然后调用auth-server的权限查询接口拿到该用户可见的路由配置,再通过Vue Router的addRoute方法动态注册。

const { data: permissions } = await getPermissions() permissions.forEach(route => { router.addRoute({ path: route.path, name: route.name, component: () => import(`@/views/${route.component}`), meta: { title: route.title, icon: route.icon } }) })

这里有个动态导入的坑:component字段如果写成字符串后用模板字符串动态加载,Vite打包时不会自动解析这些动态导入的模块,生产环境下会404。解决办法是要让路由配置里的组件指向一个静态映射表,或者把视图组件放到固定的views目录下,用import.meta.glob批量注册。

axios拦截器方面,我在请求拦截器里统一加上Authorization头,在响应拦截器里处理401。刷新token用了一个简单的队列机制:第一个请求触发刷新后,把其他并发请求暂存到队列里,等token刷新完再统一重放,避免同一时间发出多个刷新请求把refreshToken刷新失效。

5.3 答题页的计时、切屏监测与m3u8视频播放

答题页是前端最复杂的页面之一,考场上要显示倒计时、题目列表、答题卡、提交按钮。我的做法是倒计时用本地setInterval驱动,同时每5秒把当前答题进度和剩余时间写入localStorage,防止用户误刷新页面后答题数据丢失。

切屏监测是模考防作弊的一个实用小功能。利用visibilitychange事件,页面隐藏时记录一次离开次数,交卷时把离开次数随答卷一起提交,作为监考参考。这个实现非常轻量,但需要和服务端同步约定字段。

视频课程模块,公考平台大量使用m3u8切片视频。前端直接用hls.js来播放,不依赖video.js全家桶,按需引入裸播,体积小,且能方便地拼接鉴权参数:

if (Hls.isSupported()) { const hls = new Hls({ maxBufferLength: 30 }) hls.loadSource(`${videoUrl}?token=${accessToken}`) hls.attachMedia(videoEl) }

视频地址如果直接暴露,别人很容易盗链。我采用的方式是播放前调用course-service返回一个带签名和过期时间的播放地址,签名过期后前端重新获取。这个细节很关键,否则视频服务会成为免费资源站。

5.4 前端性能优化与网关的跨域配合

打包角度,我做了三件事:路由懒加载、第三方库CDN化、图片懒加载。把答题页、视频播放页这些重页面单独分包,首屏只加载首页需要的代码。Element Plus按需引入,避免整个组件库打进bundle里。

跨域问题上,如果在开发环境用Vite代理,生产环境走Nginx反代到网关,通常不需要网关再做CORS。但如果前端直接跨域访问网关,网关侧要配置跨域过滤器,允许携带Authorization头并放行OPTIONS预检请求。这里要特别注意:网关GlobalFilter里如果把带token的OPTIONS请求也拦截鉴权,前端预检请求永远过不了,表现形式就是浏览器里看到CORS错误但后端日志干干净净,我在这个坑上浪费了大半天。

6. 上线后我观察到的几个稳定性问题与处理

6.1 缓存穿透、击穿在题库场景的真实表现

上线稳定运行两周后,第一次遇到缓存穿透。原因是运营发布了一道新题目后,题库详情接口被刷,大量请求同时打到MySQL,单库连接数瞬间飙升。这种问题单靠增加Redis缓存是解决不了的,因为请求的是“不存在的数据”,Redis里根本没有对应记录。

我做了两层防护。第一层,布隆过滤器在题库服务里维护所有合法的题目ID集合,查询前先判断ID是否存在,不存在直接返回空,不落到数据库;第二层,对空结果也做短时间的Redis缓存,key加上empty标记,过期时间设60秒。这两层结合之后,穿透问题基本消失。另外运营端发布了新题之后,原来“主动预热”的接口要把题目ID同步更新到布隆过滤器的集合里,否则新题会被过滤器挡掉。

6.2 消息积压与异步消费失败的处理

错题本生成、学习进度同步、积分流水写入,这些异步消息在模考高峰期出现了明显的积压。查了消费端日志,发现两个原因:一是默认消费线程数是20,但单条消息处理要调用ES和MySQL,平均耗时要300毫秒以上,吞吐不够;二是某个下游接口偶尔抖动,消费组无限重试,把正常消费的线程也拖住了。

处理方案分三步:把消费线程数调到50,并开启批量消费模式,每次拉取200条消息后分批处理;为失败的消息设置最大重试次数为3次,超过后进入死信队列,由人工或者定时任务补偿;关键消费链路里增加Redis幂等标记,同一消息重复投递时不重复执行。调整之后,模考高峰期的积压从分钟级降到了秒级。

6.3 关于监控和链路追踪的一点建议

微服务拆分之后,最大的代价就是排查问题变难。一次请求跨三个服务,任何一个环节耗时长都会导致整体超时。我上线前就把SkyWalking接进来了,所有服务通过agent接入全链路追踪,每次请求都能看到完整的调用链和每个节点的耗时,排障效率提升非常明显。

Prometheus加Grafana监控了每台服务实例的CPU、内存、GC、接口QPS、RT和线程池状态。我特别建议一定要监控两个容易被忽略的指标:Feign线程池的活跃线程数和Tomcat线程池的拒绝次数,这两个指标能提前暴露服务过载风险,等CPU飙起来再处理往往已经晚了。


最后再分享一个运维层面的小技巧。我们每台服务器的Nacos配置中心里都放了一个全局开关——“考试模块熔断开关”,平时关闭,一旦模考高峰期发现考试服务RT异常,运维可以直接改Nacos配置把开关打开,网关层立即短路到提示页,避免把后端资源拖死。这个开关救了我们好几次,我觉得比任何自动熔断策略都可靠。经历过整套从0到1的过程,我的感受是微服务不是银弹,拆分之前一定要想清楚业务边界在哪,把基础设施的版本对应关系锁死,把事务、幂等、缓存的方案提前定好,剩下的才是真正的业务开发。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 3:24:10

通义灵码如何利用上下文生成高质量Git提交信息

你有没有遇到过这种时刻&#xff1a;代码辛辛苦苦改完了&#xff0c;鼠标移到 Git 的 Commit Message 输入框&#xff0c;大脑突然一片空白&#xff0c;最后要么敲一个update&#xff0c;要么写句fix bug就草草推上去。等哪天线上出问题要回滚&#xff0c;看着那一排“update”…

作者头像 李华
网站建设 2026/10/6 3:22:30

外卖订单需求预测实战:KNN与随机森林在Swiggy Hackathon的应用

简介&#xff1a;面向 2018 年 Swiggy Hackathon 的订单需求预测实战项目&#xff0c;围绕历史订单数据构建机器学习回归模型&#xff0c;适合想学习需求预测、Kaggle/黑客松实战的 Python 开发者。资源完整收录了从数据预处理、特征工程到 K 近邻回归与随机森林回归的训练评估…

作者头像 李华
网站建设 2026/10/6 3:21:40

弹性伸缩定时任务与报警任务谁说了算?阿里云冲突逻辑详解

做渠道商这些年&#xff0c;我接过不少客户的阿里云账号&#xff0c;其中一多半的弹性伸缩组配得让人捏把汗。大部分人的困惑集中在同一个点上&#xff1a;定时任务到点扩容&#xff0c;报警任务看CPU飙了也扩容&#xff0c;两边要是同时撞上&#xff0c;到底谁说了算&#xff…

作者头像 李华
网站建设 2026/10/6 3:21:10

AI辅助PHP开发实战:从编码提效到智能集成全指南

做PHP开发这些年&#xff0c;我经历了从手写每一行代码到IDE自动补全的转变。最近这一年多&#xff0c;AI工具的介入&#xff0c;把“写PHP”这件事又往前推了一大步。不是那种“AI要取代程序员”的焦虑叙事&#xff0c;而是很实际的、每天都能摸到的效率提升&#xff1a;以前要…

作者头像 李华
网站建设 2026/10/6 3:18:48

Spring Boot+Vue网上手机销售系统毕设实战:从数据库设计到订单状态机

简介&#xff1a;完整的网上手机销售系统毕业设计资源包&#xff0c;整合了项目源码、辅助视频、毕业论文、答辩演示文稿和任务书&#xff0c;适合计算机专业毕业生以及正在从事Java Web开发的技术人员。系统基于B/S架构&#xff0c;采用Java、JSP、CSS与SSH框架&#xff0c;数…

作者头像 李华
网站建设 2026/10/6 3:18:32

用Win32 API从零开发中国象棋:窗口、绘制与规则全解析

简介&#xff1a;基于Windows SDK的象棋程序开发源码资源&#xff0c;面向学习Windows原生API编程与游戏逻辑实现的开发者&#xff0c;提供可直接编译的示例工程。资源共38个文件&#xff0c;压缩后仅24KB&#xff0c;包含头文件&#xff08;h&#xff09;、源文件&#xff08;…

作者头像 李华