ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

学生荣誉证书管理系统微服务架构设计与SpringCloud实践

学生荣誉证书管理系统微服务架构设计与SpringCloud实践 在学校信息化建设这条路上我前后经手过七八个“管理系统”但“学生荣誉证书管理系统”是我第一次把项目从单体架构主动改造成微服务架构踩坑踩到怀疑人生也正因为如此这套沉淀下来的设计方案、代码结构和运维习惯后来成了团队做其他系统的地基。最初的版本需求其实并不复杂学生的荣誉申报、审批、证书生成、证书验真全部线上化教务处、学工办、辅导员、学生四个角色权限不同但多个二级学院要求能独立部署和扩展申报场景加上每学年申报高峰期接口经常卡死单体应用的瓶颈很快暴露这才决定引入SpringBoot SpringCloud Alibaba Vue的整体方案服务拆成多个前端全部重构。如果你是做高校信息系统的开发人员、毕设或者课设准备拿“微服务证书管理”做主题的学生或者你正想把单体项目往SpringCloud方向改造的团队这篇内容都可以参考。我会把项目从设计到部署的完整思路写出来包括为什么拆这些服务、每个核心功能怎么落地、分布式场景下的事务和锁怎么处理以及部署阶段踩过的那些坑。1. 项目整体设计与技术选型1.1 需求拆解证书管理系统背后的业务链路表面来看学生荣誉证书管理系统只有“证书管理”四个字但真要把业务跑起来至少要拆出五条链路。第一条是学生申报链路。学生登录系统后填写荣誉申报表单字段包含申请人学号、姓名、荣誉名称、荣誉级别、获奖时间、证明材料附件和学生个人说明。填完提交后记录进入审核状态不可再编辑。第二条是审核审批链路。学院管理员初审、学工处复核、主管校领导终审三级审核中每一级都可以驳回并填写驳回原因驳回之后学生可以重新编辑再提交。这个流程决定了申报表里必须有一个状态字段而且状态的流转方向是确定的不能出现“终审回到待审核”这种随意跳转。第三条是证书生成链路。终审通过后系统要自动生成证书证书内容包含学生姓名、荣誉名称、发证单位、证书编号、落款日期还要盖电子印章。证书以PDF形式落库同时生成一张展示用的证书图片用于前端预览。第四条是证书验真链路。证书上需要带一个二维码扫码进入验真页面输入证书编号和姓名做匹配查询。这个功能在真实场景里很重要用人单位和学生都会用。第五条是数据统计链路。学院、学工处需要按学院、年级、荣誉级别统计获奖人数输出报表用于工作总结和绩效展示。这五条链路里真正强依赖的只有审核和证书生成其他环节之间通过数据传递天然适合拆分。做设计的时候如果有类似系统建议先画一轮业务链路图再决定服务边界别一上来就按功能名称拆。1.2 为什么选择微服务架构而不是继续用单体应用一个校级荣誉证书系统的数据量撑死几万条从性能角度看完全用不上微服务。但我最终选择微服务架构是基于三个非性能原因。第一故障隔离。单体应用只要某个模块出现内存泄漏或者某个接口死循环整个Tomcat进程都可能没有响应所有业务一起挂。拆成多个服务之后证书生成服务因为模板渲染问题宕机学生的申报和审批流程仍然可以正常走故障影响面被限制在一个服务内。第二独立部署。二级学院希望学院端申报功能能够独立部署到院级机房学工处也提出数据要留在自己服务器上。单体应用在这种“按模块独立部署”的需求面前基本无能为力但微服务天然支持每个服务单独打包、单独运行、单独扩容。第三团队协作边界。学生端、审批端、证书端由不同人员维护拆开之后服务职责清晰、数据库边界清晰、代码冲突减少即使是小团队这种边界带来的效率提升也很明显。但这并不意味着微服务是唯一解。如果项目没有多级部署诉求、没有并发压力、没有团队分工需求单体加集群完全够用。微服务的代价非常具体——分布式事务、分布式锁、服务间调用超时、链路日志排查变难这些都会长期分散精力。我的建议是拆之前先确认有没有三条以上的“非用不可”理由别为了简历上写一行“微服务”就把简单问题复杂化。1.3 技术栈选型SpringBoot, SpringCloud Alibaba, Vue, MinIO技术选型表如下。模块选型选型理由后端主框架Spring Boot 2.7.18稳定版本配合Spring Cloud Alibaba兼容性最好团队迁移成本低微服务治理Spring Cloud Alibaba 2021.0.5.0覆盖注册中心、配置中心、熔断限流社区文档齐全注册/配置中心Nacos 2.2.3注册中心和配置中心一体少维护一套组件网关Spring Cloud Gateway非阻塞式响应网关路由、鉴权、跨域一站搞定服务间调用OpenFeign声明式调用配合负载均衡使用简单分布式事务Seata AT模式对业务代码侵入最小适合这个业务量级分布式锁Redisson 3.17基于Redis看门狗机制避免锁过期问题文件存储MinIO兼容S3协议部署轻量用于证明材料和证书PDF前端Vue 3 TypeScript Element Plus Vite组合式API开发效率高Element Plus组件全数据库MySQL 8.x每服务独立库数据隔离到服务边界避免跨服务直接查表选型上最大的注意点Spring Cloud Alibaba 2021.0.5.0 只能配合 Spring Boot 2.6到2.7使用如果用Spring Boot 3.x必须先确认Nacos客户端版本兼容。我在初期试验时用过Spring Boot 3.0.0结果Nacos客户端一直注册不上后来查版本矩阵才定位到问题团队因此直接定版Spring Boot 2.7.18。2. 微服务拆分与基础架构搭建2.1 服务拆分五个服务的边界和职责我最终把系统拆成了五个微服务。honor-gateway是网关服务所有外部请求都从网关进入负责路由分发、JWT统一认证、跨域处理以及把用户ID和角色信息写进请求头转发给下游服务。user-service是用户服务管理学生、辅导员、学院管理员、学工处管理员四类账号负责登录、注册、密码重置和角色权限的查询。apply-service是申报服务承担荣誉申报表单的提交、修改、列表查询以及三级审核流程是整个系统的业务核心。cert-service是证书服务管理证书模板、根据申报数据生成PDF证书、证书验真、证书下载和证书图片预览。file-service是文件服务对接MinIO实现证明材料和证书图片的上传下载提供统一的文件访问接口。数据库同样按服务边界拆分申请库、用户库、证书库、文件库各自独立。没有把公共字典表单独拆出去而是建立了一个honor_common库存数据字典供五个服务通过API调用读取虽然多一次网络开销但换来的是字典表统一管理。拆分时踩过的最大坑是过度拆分。最初把审核流程单独拆了一个review-service结果发现审核的每一步都要读取申报详情而申报详情在apply库导致apply-service和review-service之间调用极其频繁一次审核要跨服务查三次数据接口耗时从300毫秒涨到接近1秒。后来把审核状态和审核记录直接设计进申报表把审核逻辑合并回apply-service性能才恢复。这个教训很直观服务边界应该按业务聚合划分而不是按操作步骤划分。2.2 注册中心和配置中心落地Nacos搭建细节Nacos用的Docker方式部署单机模式命令如下。docker run -d --name nacos \ -p 8848:8848 -p 9848:9848 -p 9849:9849 \ -e MODEstandalone \ -e JVM_XMS256m -e JVM_XMX512m \ nacos/nacos-server:v2.2.3这里有一个很容易踩的坑Nacos 2.x从客户端到服务端的通信默认走gRPC用的是9848端口而管理界面用8848。如果服务器安全组只放行了8848端口服务可以启动、控制台也能看到服务注册列表但心跳和配置监听会一直失败。具体表现是服务列表里显示“健康”跨服务调用却全部超时。我项目上线时就在这个问题上花了一整晚最后用telnet测端口才发现9848没开。配置中心的用法也很直接。每个服务的bootstrap.yml只配置应用名、Nacos地址和命名空间数据源、Redis、MinIO这些连接信息全部放到Nacos配置中心用shared-configs统一共享命名空间区分环境。spring: application: name: apply-service cloud: nacos: server-addr: 172.16.10.11:8848 username: nacos password: nacos123456 namespace: prod config: file-extension: yml shared-configs: ->spring: cloud: gateway: routes: - id: apply-service uri: lb://apply-service predicates: - Path/api/apply/** filters: - StripPrefix1 - id: user-service uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1全局过滤器统一做JWT校验。白名单路径如登录接口、验真查询接口直接放行其他请求全部解析Authorization头解析成功的把用户ID和角色信息写入请求头X-User-Id、X-Role再传给下游服务。下游服务只需要通过拦截器读取这两个请求头就能拿到当前用户信息不需要重复解析Token。网关这里一个隐蔽的问题是跨域。前端在Vite开发环境走的代理端口和后端网关不一致如果不在网关层统一处理跨域会出现“接口通了浏览器却报CORS”的诡异现象。我用一个CORS全局配置把允许的来源、方法、请求头在白名单里列清楚开发环境和生产环境用同一个配置省掉了很多调试时间。3. 核心功能实现与关键代码拆解3.1 证书编号生成分布式场景下的唯一性方案证书编号是证书的核心标识必须全局唯一同时要具备一定业务可读性。最终方案是“日期前缀Redis自增序号”编号格式为XSHONOR-2025-000001。String datePrefix LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); Long sequence redisTemplate.opsForValue().increment(CERT_SEQ: datePrefix); String certNo XSHONOR- datePrefix - String.format(%06d, sequence);这里有个细节Redis的increment操作是原子性的天然满足并发唯一需求。但每天首次生成时计数器从0开始如果同一天内并发量很大可以用分段计数或者预生成一批编号方案不过在这个业务量级下直接increment就够了。需要注意的是证书编号在生成之后不能修改即使审核流程回退也要保证编号没有再次被使用。我实际处理方式是证书表里的编号字段建了唯一索引同时在生成编号后立刻写入证书表后续所有对证书的操作都依赖这个编号做关联。万一编号冲突唯一索引会直接兜底拦截。3.2 荣誉申报流程的状态机设计申报状态是最容易踩坑的业务点。我把状态定义为七种草稿、待初审、待复核、待终审、已通过、已驳回、已撤销。后端没有用简单的if-else判断而是维护了一个状态流转映射表。MapApplyStatus, ListApplyStatus transitions new HashMap(); transitions.put(DRAFT, Arrays.asList(SUBMITTED, CANCELED)); transitions.put(SUBMITTED, Arrays.asList(FIRST_REVIEWED, REJECT)); transitions.put(FIRST_REVIEWED, Arrays.asList(SECOND_REVIEWED, REJECT)); transitions.put(SECOND_REVIEWED, Arrays.asList(FINAL_REVIEWED, REJECT)); transitions.put(REJECT, Arrays.asList(SUBMITTED, CANCELED));每次审核时先判断当前状态和下一状态是否在映射表中不在就直接抛业务异常。这个设计带来的直接收益是无论前端怎么调用接口状态永远不会跳错。后来加了一个“批量驳回”功能也只需要在映射表里增加对应分支改起来很干净。3.3 证书PDF生成从模板到文件落地的实践证书PDF是最有价值的功能点。我选择的方案是Thymeleaf渲染HTML模板再用iText的html2pdf组件把HTML转成PDF。这样做的好处是证书排版可以完全用HTML和CSS控制遇到学校经常调整样式的情况改模板比修改代码快得多。核心代码大致如下。Context context new Context(); context.setVariable(studentName, certVO.getStudentName()); context.setVariable(honorName, certVO.getHonorName()); context.setVariable(certNo, certVO.getCertNo()); context.setVariable(date, certVO.getPublishDate()); String html templateEngine.process(cert_template, context); // html转PDF ConverterProperties converterProperties new ConverterProperties(); HtmlConverter.convertToPdf(html, outputStream, converterProperties);这里的一个坑是中文字体问题。直接用系统默认字体生成的PDF中文会乱码必须显式指定支持中文的TTF字体文件并在CSS里设置font-family。我把字体文件放到了resource目录下通过ClassPathResource加载。生成后的PDF文件保存到MinIO数据库里只存储文件路径。证书封面图片则用PDF转图片的方式在服务端生成前端列表展示时不需要每次都请求PDF解析。3.4 文件服务与MinIO对接文件服务拆出去以后上传逻辑统一放在file-service其他服务需要传文件时调用文件服务的接口即可。MinIO的上传代码封装在FileClient里。String filename UUID.randomUUID().toString() _ file.getOriginalFilename(); ossClient.putObject( PutObjectArgs.builder() .bucket(honor-bucket) .object(filename) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return filename;MinIO做文件存储有个明显优势是部署简单一个二进制文件就能跑起来还能通过Docker快速搭建测试环境。这里要注意bucket的访问权限不用设置成公共读写而是通过生成签名URL的方式临时授权给前端访问避免文件链接泄露。4. 分布式场景下的三大杀手事务、锁、会话4.1 Seata AT模式跨服务事务落地方案学生申报审核通过后要同时完成三件事更新申报状态为已通过、在证书服务生成一条证书记录、更新统计表里的获奖人数。这三个操作跨apply-service和cert-service任何一个失败都可能导致数据不一致于是引入了Seata AT模式。Seata的部署分三步第一步启动Seata Server并注册到Nacos第二步在业务库创建undo_log表第三步在业务方法上加GlobalTransactional注解。CREATE TABLE IF NOT EXISTS undo_log ( branch_id BIGINT NOT NULL, xid VARCHAR(128) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (branch_id), KEY xid (xid) ) ENGINEInnoDB DEFAULT CHARSETutf8;GlobalTransactional(name create-cert, rollbackFor Exception.class) public void createCert(ApplyDTO applyDTO) { applyMapper.updateStatus(applyDTO.getId(), PASSED); certService.insertCert(applyDTO); honorStatService.incrementCount(applyDTO); }AT模式之所以侵入小是因为它靠代理数据源自动记录undo_log业务代码基本不用改。但代价是数据源会被代理并且对数据库行记录加全局锁。关于全局锁有个典型问题当证书服务更新某一条证书记录时如果申请事务还没提交其他事务访问同一条记录会一直等待表现为大量Global lock wait timeout。最终我通过调整热点数据的更新频率、把统计表更新放到事务最后、并给特殊热点行加随机偏移字段来缓解。4.2 Redisson分布式锁防止重复申报和重复提交申报场景里最典型的并发问题是同一个学生快速点击两次“提交”结果生成两条申报记录。浏览器端按钮置灰不能根治问题后端必须兜住。我用Redisson实现分布式锁锁的key设计为学生ID加申报类型如下。RLock lock redissonClient.getLock(LOCK:APPLY: studentId : honorType); boolean acquired lock.tryLock(3, TimeUnit.SECONDS); if (!acquired) { throw new BizException(系统繁忙请勿重复提交); } try { applyService.createApply(applyDTO); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里特别注意tryLock的参数。Redisson的tryLock可以传leaseTime也可以不传不传时默认30秒持有锁并开启看门狗自动续期业务执行多久锁就续期多久传了leaseTime锁到点就释放如果业务还没有执行完锁会被其他请求拿到导致重复提交仍然可能发生。我在这块的实践是只传等待时间不传持有时间让看门狗兜底。证书编号生成里也用了锁锁的粒度是日期前缀保证同一天的编号生成串行虽然increment本身是原子的但锁可以避免“先查序列再拼接编号”这种非原子操作带来的问题。4.3 分布式会话JWT无状态认证的落地细节分布式服务之间不能靠Session共享来做登录态我用的是JWT加网关统一认证方案。用户登录成功后user-service签发JWT返回给前端前端在请求头里带Authorization: Bearer token网关解析出用户信息后把ID和角色写入内部请求头。这样一个服务之间调用时不需要判断“这个人是谁”只需要信任网关传递过来的X-User-Id和X-Role即可。下游拦截器校验这些内部请求头是否存在不存在就拒绝访问。这里有一个安全细节内部请求头不能被外部伪造。网关转发给下游服务时携带的X-User-Id和X-Role必须被下游服务信任但如果前端直接绕过网关请求某个服务的IP端口就可以伪造这两个头。解决办法是所有下游服务只监听容器内网IP不对公网开放同时网关层有一个服务间调用的认证Token下游服务会对这个Token做一次校验保证只有网关能调用。5. 前端Vue落地实践5.1 前端页面模块与组件拆分前端采用Vue 3组合式API开发项目管理上没拆成微前端就是一个标准单页应用按角色切页面。学生端的页面包括登录页、首页统计卡、荣誉申报表单页、申报记录列表、证书展示、证书验真扫码页。辅导员和学院管理员的页面包括审核列表、审核详情、批量驳回操作。学工处管理员的页面增加了证书模板管理、数据统计大屏。组件设计上把审核卡片抽成了公共组件ReviewCard申报列表抽成ApplyTable证书预览抽成CertPreview数据统计图表基于ECharts封装成StatChart。这套划分确保了后端新加一个字段时前端大多数情况下只需要调整一个组件的props。5.2 动态路由与按钮权限控制用户登录后前端通过/user/info接口拿到当前用户的角色和权限标识在路由守卫里根据角色动态添加路由。const roles store.state.user.roles const accessibleRoutes asyncRoutes.filter(route { return route.meta?.roles?.some(role roles.includes(role)) }) accessibleRoutes.forEach(route { router.addRoute(route) })按钮级别的权限用自定义指令v-permission实现只有拥有对应权限标识的按钮才会渲染。比如驳回按钮需要review:reject权限普通用户即使进入了审核页面也看不到这个按钮后端接口同样会拦截。5.3 前端打包部署与Vite配置前端开发环境通过Vite代理解决跨域生产环境把打包产物放到Nginx中Nginx配置反向代理转发到网关。注意history路由刷新404的问题需要配置try_files回退到index页面。server { listen 80; location /api/ { proxy_pass http://gateway:8080; } location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } }Vite打包的时候有一个坑是静态资源路径。如果前端部署在域名根路径base配置默认/没问题如果部署在子路径需要设置base为对应路径否则CSS和JS资源加载全部404。6. 部署、监控与常见问题排查实录6.1 本地开发联调环境搭建本地开发因为有五个服务同时跑端口规划尤其重要。我采用的规划是网关15001、用户服务15101、申报服务15102、证书服务15103、文件服务15104。每个服务启动时通过IDE配置对应的端口Nacos注册到同一个实例服务发现自动完成。联调时还有一个技巧本地启动的服务直接注册到测试环境的Nacos走测试环境的数据库这样不需要本地搭全套中间件就能完成跨服务调用测试。遇到服务启动失败时优先看Nacos控制台服务列表如果列表中显示多个实例注意区分哪个是本地哪个是测试环境避免请求被负载均衡分发到错误实例。6.2 Docker Compose编排服务生产环境部署用Docker Compose做了一键编排中间件和业务服务分两个Compose文件管理。中间件Compose负责MySQL、Redis、Nacos、MinIO业务Compose负责五个微服务和前端Nginx。version: 3.8 services: cert-service: build: ./cert-service environment: SPRING_PROFILES_ACTIVE: prod NACOS_ADDR: nacos:8848 ports: - 15103:15103 depends_on: - nacos - minio volumes: - /etc/localtime:/etc/localtime:ro这里要注意服务启动顺序。Nacos和Redis必须先启动否则业务服务启动时会连接失败部分Spring Cloud组件会直接抛出异常。单靠depends_on控制启动顺序并不可靠业务服务里需要对关键依赖做启动重试例如等待30秒再注册。6.3 典型问题排查表我把项目运行阶段遇到的高频问题整理成一个速查表方便遇到同样问题时快速定位。问题现象可能原因解决办法服务A调服务B超时OpenFeign读超时配得过短或服务B连接池耗尽调大readTimeout检查数据库连接池线程数合理设置负载均衡超时服务列表显示健康但调用失败Nacos的9848端口未开放gRPC通信异常检查安全组和防火墙确保8848/9848/9849全部放行证书生成偶尔出现重复记录锁粒度不合理或业务执行超过锁持有时间优化锁key设计使用Redisson看门狗自动续期分布式事务回滚不生效undo_log表缺失或代理数据源配置错误检查Seata客户端配置确认业务库存在undo_log表前端刷新404Nginx未配置try_files增加try_files $uri $uri/ /index.html配置中心修改后服务未生效未配置动态刷新注解或者监听不生效相关Bean加RefreshScope或在启动日志确认配置读取时间证书日期出现乱码系统字体缺失在Docker镜像内安装中文字体或者打包自定义TTF6.4 日志链路与监控微服务最让人头疼的就是排查问题时要翻好几个服务日志。我的做法是统一在每个日志配置文件里添加traceId字段在网关过滤器生成traceId并放入MDC服务间调用时通过RequestInterceptor把traceId传递下去。logging: pattern: console: %d{HH:mm:ss.SSS} %-5level [%X{traceId}] [%thread] %logger{36} - %msg%n这样一次用户请求从网关到申报服务再到证书服务所有日志都携带着同一个traceId在日志平台里按traceId搜索就能把所有关联日志串起来。如果有条件的话还可以接入SkyWalking展示完整链路拓扑但至少在日志层面保证traceId的贯通。这个项目我前后迭代了四个月最大的体会是“微服务本身不是目标目标是把系统的维护成本和故障影响降下来”。证书管理系统在技术上没有太多炫技的地方但分布式事务、分布式锁、服务间调用这些微服务典型问题全部真实遇到过一遍每一类问题都留下了可复用的解决方案。如果你们也要做类似系统我建议先花一天时间把部署形态和团队边界想清楚再决定要不要拆服务拆了之后一定要坚持数据库和服务边界对齐别让跨服务直连表悄悄出现。最后再分享一个小技巧遇到分布式环境下的诡异问题优先看Nacos服务列表、Redis连接状态、端口连通性这三件套大概率能定位80%的故障。
返回列表