从单体到微服务:HSK在线考试平台SpringCloud完整实践
去年接了一个HSK汉语等级考试学习平台的项目技术栈定的是微服务分布式架构SpringBoot负责业务服务SpringCloud做服务治理前端交给Vue。说实话一开始团队内部是开过会的有人觉得一个考试系统单体就够用了非得上微服务纯属给自己找麻烦。但等项目做到第三个月我们遇到了几个绕不开的痛点才发现这个决定是对的。这套平台面向的是国际中文学习者他们要在线模拟HSK一级到六级考试看听力视频、做阅读题、提交口语录音、查成绩报告还有付费开通会员、参加模拟考活动等场景。业务虽然不算庞杂但用户量一上来高峰期的并发差异就很明显。这篇内容我会从架构选型、服务拆分、核心组件配置、分布式事务与锁的处理、音视频与分词落地、前端配合、部署踩坑这几个角度完整复盘适合正在做SpringCloud微服务项目、或者准备从单体往微服务迁移的朋友参考。1. 为什么一个HSK平台要拆成微服务上线前的三个真实痛点1.1 HSK平台的真实业务画像不只是在线做题如果只把HSK学习平台理解成一个可以做题的网站那确实用单体架构就够了。但实际业务拆开看远比这个复杂用户端注册登录、购买会员、参加模拟考试听力阅读书写、上传口语录音、查看成绩单和诊断报告、词汇学习计划。管理端题库维护题目、音频、视频素材、试卷配置、考试场次管理、用户审核、内容发布。师资/运营端查看学员学习数据、导出统计报表、人工批改口语题。这几个端看起来是同一套系统但对资源消耗的要求完全不同。听力考试期间大量学生对音频、视频资源发起并发请求运营在做报表导出时可能要吃满CPU和数据库IO。如果都塞进一个应用里互相干扰是必然的。1.2 单体版本走到第三个月开始暴露的瓶颈我们最开始跑通MVP时用的确实是单体SpringBoot上线第三个月赶上一次模拟考活动问题集中爆发了。第一个问题是考试提交接口大面积超时。考试结束那一刻上百个考生同时提交试卷成绩计算、题目归档、积分扣减全部在一个事务里完成数据库连接池直接被占满应用线程阻塞到连登录接口都响应不了。第二个问题是发布效率。修一个口语批改的bug必须整个应用重新打包上线。报表模块代码出了问题也会导致用户端考试服务不可用。第三个问题是资源没法独立扩容。报表统计占用大量内存但我们没法只给统计模块加资源只能给整个应用加成本高、收益低。1.3 我们决定拆分的三条底线基于这些问题我们决定转向微服务但定了三条底线不求一步到位先把核心链路拆出来非核心模块可以暂时留在单体里。拆分必须以业务域为边界不能为了拆而拆否则分布式带来的复杂度会直接吞掉收益。网关先行所有外部请求先走网关这样即使内部服务还在调整前端和外部系统的调用入口是稳定的。这三条底线在后面帮了大忙——尤其是第一条避免了我们陷入全量重构的泥潭。2. 服务划分的完整思路七个模块的边界与职责2.1 按业务域拆不按技术层拆微服务拆分最忌讳的是按技术层拆比如拆一个controller服务、一个service服务、一个dao服务。正确的做法是按业务域拆让每个服务拥有独立的数据边界和完整的业务闭环。HSK平台最终拆分成了七个服务服务名主要职责独立数据gateway-service路由转发、统一鉴权、跨域处理无auth-service登录认证、JWT签发、刷新令牌认证库user-service用户资料、会员套餐、积分账户用户库question-service题库管理、题目CRUD、素材关联题库库exam-service试卷配置、考试流程、成绩结算考试库study-service词汇学习、学习计划、生词本学习库file-service文件上传、MinIO对接、音视频转码对象存储每个服务都有自己的库或者至少自己的表空间不允许直接跨库查询。服务间需要数据时通过OpenFeign调用接口或者通过消息广播事件。2.2 服务之间的调用关系与数据边界实际的调用链是前端请求先到网关网关解析JWT校验登录态然后按路由规则把请求转发给具体服务。考试这条核心链路是这样走的exam-service发起考试前调用question-service获取题目列表考生提交试卷后exam-service需要调用user-service扣减考试次数、累计积分扣减成功后才落账成绩数据。整个过程涉及三个服务的数据变更后面讲事务时会详细说。study-service和exam-service之间还存在事件交互考试结束后发布考试完成事件学习服务监听到后根据考生的错题列表生成复习计划和生词本。这里用的是RabbitMQ解耦做得比较干净考试服务不需要关心学习服务怎么消费数据。2.3 拆分时最容易模糊的两个边界题库与考试、用户与认证这里必须单独提两个我们初期纠结过的边界。题库与考试的边界最开始我们想的是question-service直接生成试卷但后来发现试卷生成策略按题型比例抽题、按难度分层属于考试业务而不属于题库维护。最终调整为question-service只提供标准的题目查询接口试卷组装逻辑放在exam-service里。这样题库服务保持纯粹后续接其他考试项目时也能复用。用户与认证的边界auth-service负责登录和发令牌user-service负责用户资料和会员权益。两个服务的数据有重叠但职责不同认证库只存账号凭据用户库存业务资料。实际操作中要注意JWT里只放用户ID和基础角色信息不能放敏感数据用户资料变更后令牌里的信息可能过期需要合理的刷新机制。3. 核心组件选型与配置Nacos、Gateway和OpenFeign的落地细节3.1 为什么把Eureka换成Nacos以及版本对应的坑最早我们用Eureka做注册中心但用了一段时间发现两个问题一是Eureka Server本身就是个单点自己还要做高可用配置二是它只管服务注册发现配置管理还得单独搭SpringCloud Config。后来换成Nacos注册中心和配置中心二合一控制台界面也直观配置支持热更新改完配置不需要重启服务。提示版本对应关系是这里最大的坑。Nacos 2.x和1.x的协议差异很大客户端必须和服务器端大版本对齐。Spring Boot、Spring Cloud、Nacos客户端三者之间也要匹配我们最终用的是Spring Boot 2.7.18 Spring Cloud 2021.0.8 Nacos 2.2.3客户端跑得很稳。Spring Boot 3.x在命名空间上做了模块调整如果用的还是SpringCloud旧写法升级成本会高不少。3.2 网关只做两件事路由与鉴权过滤网关我们用了Sprping Cloud Gateway没有选Zuul。Gateway基于WebFlux性能更好配置路由也非常直观。一个核心的路由配置长这样spring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1 - id: exam-service uri: lb://exam-service predicates: - Path/api/exam/** filters: - StripPrefix1网关层的鉴权过滤器是必须的。我们在GlobalFilter里校验JWT白名单内的路径比如登录、注册直接放行其他请求解析令牌并校验过期时间同时把用户ID放进请求头转发给下游服务。这里有个实际经验跨域配置放在网关做一次搞定。网关需要同时配置CORS允许多个来源否则前端调用会出现跨域问题。3.3 OpenFeign调用时的一个隐形坑请求头丢失服务间调用我们统一用OpenFeign。第一个坑很快来了通过网关调用服务AA再通过Feign调用服务B时B拿不到登录用户信息。原因是Feign默认不会自动传递原请求的Header。我们用了一个RequestInterceptor解决Configuration public class FeignConfig { Bean public RequestInterceptor requestInterceptor() { return template - { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes ! null) { HttpServletRequest request attributes.getRequest(); template.header(Authorization, request.getHeader(Authorization)); template.header(X-User-Id, request.getHeader(X-User-Id)); } }; } }注意这个方案在异步线程里会失效因为RequestContextHolder拿不到请求上下文。如果Feign调用发生在异步任务里需要在发起异步任务前手动把令牌传给任务线程。4. HSK考试场景中的分布式事务与锁一致性问题实战4.1 提交试卷考试服务与用户服务的分布式事务到了分布式环境下最头疼的就是事务。以提交试卷为例涉及exam-service和user-service两个服务的数据变更exam-service写入成绩记录、生成成绩报告。user-service扣减本月剩余考试次数、增加积分。如果扣了积分但成绩落库失败或者成绩落了但次数没扣用户第二天可能白嫖一场考试。这里我们引入了Seata的AT模式在发起方加上GlobalTransactionalGlobalTransactional public Long submitExam(SubmitExamRequest request) { // 1. 调用user-service扣减考试次数 // 2. 保存成绩和考试明细 // 3. 发布考试完成事件 }AT模式对业务代码侵入小性能也能接受。但我要说清楚不是所有场景都需要强一致。比如生成生词本这个动作晚几秒甚至几分钟对用户来说无感这种就该用RabbitMQ做最终一致性而不是强行加分布式事务。4.2 用Redis分布式锁控制并发下的考试次数提交试卷还有一个隐藏问题用户快速双击提交按钮或者多个设备同时登录提交会导致同一个请求被并发执行两次。单体时代靠数据库唯一约束兜底分布式环境下需要分布式锁。我们用的是Redisson不是自己手写Redis SETNX。原因很简单Redisson的锁是自动续期的默认30秒有效期业务没跑完会自动续期避免了锁提前过期导致并发进入的问题。核心代码如下RLock lock redissonClient.getLock(exam:user: userId : examPaperId); boolean locked lock.tryLock(2, TimeUnit.SECONDS); if (!locked) { throw new BizException(正在提交中请勿重复操作); } try { // 执行业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }锁的粒度一定要控制好。锁加在用户试卷维度而不是整个服务或整个活动维度。我们之前犯过一个错误模拟考活动报名时直接锁了整个活动的Key结果几百个用户同时报名锁等待时间直接拖垮接口。后来改成按用户维度加锁并发问题瞬间消失。4.3 缓存一致性问题成绩发布与用户缓存缓存用得好不好直接决定微服务性能。Redis在这套平台里承担了大量缓存职责但带来的副作用是缓存一致性问题。我们的典型场景用户补考一次成绩更新了但user-service里存了一份用户最近成绩的缓存。如果不处理用户看到的还是旧成绩。采用的方案是Cache Aside模式更新数据库时直接删除缓存下次读时回源数据库。之所以不选择更新缓存是因为并发条件下更新缓存容易产生脏数据删除缓存则不会。考试结束后还要主动清理学习服务中的错题缓存保证生词本生成后不会复用旧数据。5. 视听资料与中文分词HSK特色场景的工程化实现5.1 MinIO听力音频与口语录音的统一存储HSK听力题有大量音频文件口语题需要考生上传录音这些都不能直接存在数据库里更不能塞进应用服务器磁盘。文件服务我们选了MinIO一个轻量级对象存储接口兼容S3协议私有化部署也方便。文件服务主要做三件事预签名URL上传前端向file-service申请一个临时上传地址然后直传MinIO避免文件流经过应用服务器压垮内存。桶策略管理听力素材放在私有桶中生成带签名的临时访问URL供播放器使用用户头像等公开资源放在公开桶中。类型校验上传时用contentType和文件扩展名双重校验防止上传非音频/视频文件。MinIO部署很简单一个Docker命令就能起docker run -d \ --name minio \ -p 9000:9000 -p 9001:9001 \ -e MINIO_ROOT_USERadmin \ -e MINIO_ROOT_PASSWORDadmin123456 \ minio/minio:latest \ server /data --console-address :90015.2 把MP3转为m3u8HLS播放链路搭建听力音频一开始直接放MP3PC端没问题但移动端Safari对大文件MP3的在线播放体验很差网络一旦波动就会卡顿。后来改成了HLS协议也就是m3u8分片播放。流程是管理员上传MP3/MP4到MinIO文件服务把它转成m3u8索引文件和若干ts分片重新放回MinIO的video桶然后前端播放器拿m3u8地址播放。转码用的ffmpeg核心命令ffmpeg -i input.mp3 -codec:a aac -hls_time 10 -hls_list_size 0 -f hls m3u8/output.m3u8m3u8播放链路里最容易踩的坑是CORS。分片文件播放时浏览器会发起跨域请求如果MinIO服务端没配好跨域规则会出现能加载第一片却加载不了后续分片的情况。需要在MinIO的bucket JSON里配置CORS策略允许来源为前端域名。5.3 HanLP分词在词汇难度标注中的应用HSK平台有一个词汇学习模块最核心的功能是从阅读材料中提取生词按HSK等级标注难度。这里我们用到了HanLP分词。做法是阅读文章经过HanLP分词后得到词性标注和词语列表再和标准HSK词汇等级表做匹配把一篇文章中出现的不同等级的词汇统计出来给用户生成生词本。实际开发中有两个需要注意的问题HanLP首次加载模型比较耗时大概需要几秒到十几秒因此不能在请求线程里现场加载。我们用SpringBoot的PostConstruct在启动时预热或者用定时任务预加载。分词不是100%准确尤其面对人名、地名、网络新词。需要维护一份自定义词典把HSK试题里出现的高频专有名词加进去分词效果才符合业务预期。6. 前端Vue3与后端网关的配合路由、播放器与打包部署6.1 前端动态路由根据权限生成菜单HSK平台的用户分学员、管理员、教师三种角色菜单和权限差异很大。前端不能把路由写死而是由后端根据角色返回可访问的权限菜单前端拿到后动态注册路由。具体实现思路// 路由守卫中拉取用户菜单 router.beforeEach(async (to) { const store useUserStore(); if (!store.menus.length) { const menus await getMenus(); // 后端返回权限列表 store.setMenus(menus); addDynamicRoutes(menus); // 动态新增路由 return { ...to, replace: true }; // 重新进入目标路由 } });动态路由的核心是router.addRoute方法注册出来的路由是运行时才生效的。这里要特别注意刷新页面的问题Vue是SPA路由表存在内存里刷新就没了必须在路由守卫里再次请求菜单并重新注册路由。6.2 m3u8播放的前端落地与Safari兼容前端播放m3u8我们用了hls.js。它把m3u8拉流下来通过Media Source Extensions喂给video标签播放兼容性比原生video好很多。import Hls from hls.js; if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(url); hls.attachMedia(videoEl); } else if (videoEl.canPlayType(application/vnd.apple.mpegurl)) { // Safari 原生支持 HLS videoEl.src url; }hls.js还需要配置withCredentials或CORS相关选项否则在需要鉴权的场景下ts分片拉取会失败。我们的听力资源都是私有桶签名URL签名URL本身带参数不需要额外处理鉴权头但CORS策略必须覆盖。6.3 Vue打包后放进SpringBoot静态资源路径的坑项目后期为了减少成本我们一度把Vue打包产物直接扔进SpringBoot的static目录运行省掉一台Nginx。这个操作踩了一个典型的坑Vue Router用的history模式页面刷新时SpringBoot不知道该把请求转发到index.html直接返回404。解决方式是在SpringBoot里加一个转发控制器Controller public class ViewController { RequestMapping(value {/, /{path:[^\\.]*}, /{path:^(?!api|actuator).*}/**/{path:[^\\.]*}}) public String forward() { return forward:/index.html; } }这里的原则是一切不带文件扩展名的路径都转发到index.html但带/api前缀的路径不能拦截否则会把后端请求也转进前端路由。如果条件允许还是建议前端单独用Nginx部署用try_files指令处理刷新问题后端的职责更纯粹。7. 部署与踩坑记录从IDEA多服务启动到Docker Compose上线7.1 IDEA同时启动多个服务端口与配置管理本地开发时后端六个服务需要同时在IDEA里启动端口规划必须清晰。我们统一约定服务端口gateway-service9000auth-service9010user-service9020question-service9030exam-service9040study-service9050file-service9060IDEA中每个SpringBoot应用的启动配置可以设置不同的VM options把-Dserver.port9040这样的参数传进去或者直接在application.yml里按profile区分。跑多个服务时内存开销大建议本地至少16G内存IDEA开启并行启动后再去喝杯咖啡。7.2 Nacos 2.x的端口和版本兼容问题Nacos 2.x和1.x最大的区别是引入了gRPC通道除了8848端口还需要开放9848端口给gRPC通信。如果你部署在云服务器上安全组只放了8848客户端会大概率连不上或连接不稳定报错却还是连接超时排查起来一点也不直观。客户端版本也是一样。Nacos Server版本和客户端版本最好保持一致我们用的是2.2.3的server配2.2.3的client稳定运行至今。7.3 上线前的性能检查线程池与连接池服务拆分完成后上线前做了一轮压测发现三个容易忽略的配置点数据库连接池默认值偏小。HikariCP默认最大连接数是10六个服务共享同一台MySQL实例时高峰期很容易打满。需要按服务实际并发量调大maximum-pool-size。OpenFeign的默认超时时间很短。连接超时默认10秒读超时默认60秒但考试题量大的场景下question-service返回题目列表可能超过这个时间需要手动调大超时并开启熔断降级。网关的线程池要预留余量。Gateway是反应式模型但业务API如果内部用的是阻塞式数据库访问线程池过小时会直接排队。这几个点我们都在压测后做了调整完成之后整体QPS和TP99明显改善。7.4 Docker Compose编排与前后端联调最后部署阶段中间件全部容器化。一个Docker Compose文件管起MySQL、Redis、RabbitMQ、Nacos、MinIO六个业务服务镜像独立构建。前端走Nginx容器把网关地址通过/api/反代过去实现前后端同域访问避免跨域问题。services: gateway: image: hsk/gateway:1.0.0 ports: - 9000:9000 depends_on: - nacos # 其他服务类似统一注册到nacos记住一个原则业务服务容器里不要写死IP全部通过服务名访问NacosNacos注册地址用容器网络内的服务名。我们第一次部署时就是因为业务容器里配置了宿主机IP导致容器内的服务无法注册到Nacos折腾了半天。这套平台从前期的单体到最后的微服务分布式形态踩过的坑比预期的多但收获也直接。我现在的习惯是先画清楚服务调用链和各自的数据边界再写代码——尤其是分布式事务、分布式锁、缓存一致性这三件事必须提前想清楚否则代码写完再改代价是成倍的。从单体往微服务迁移的话我的建议是第一次只拆一个服务把服务注册、远程调用、链路日志、配置中心这条链路完整跑一遍确认没问题后再拆下一个。地基稳了上面的房子才不会歪。