1. 项目概述与整体设计思路
失物招领这种业务,在大家的惯性思维里应该是一个“小系统”,无非就是发布丢失物品、登记捡到物品、双向匹配,最多再加个站内信通知。但把这个话题放到“微服务分布式”这个角度来讨论,情况就变得很有意思了。校园场景里,失物招领的并发量其实并不大,真正复杂的地方在于业务形态的多样性:学生丢东西、教职工捡到物品、食堂和图书馆这类地点有固定的失物收纳点、保卫处有监控凭证,甚至还有跨校区的物品调拨。如果只是按单体应用来做,后续每增加一种角色、每接入一类线下流程,代码就会在几个核心模块里越堆越乱。这次开发的校园失物招领系统,选择Spring Boot + Vue + Spring Cloud这套技术栈来做微服务拆分,本质上是把“失物招领”这一业务主线,拆成可独立演进、可独立部署的几个服务,倒不是为了追求技术上的炫技。
项目的前端使用Vue,后端基础框架用Spring Boot,微服务治理部分交给Spring Cloud全家桶(Nacos、Gateway、OpenFeign、Sentinel等),存储层面使用了MySQL作为业务库、Redis做缓存和分布式锁、MinIO做对象存储来保存失物照片和认领凭证。整套系统的核心需求可以概括为四个字:发、找、认、管。“发”是发布失物或拾物信息,“找”是按关键词和地点检索匹配,“认”是失主发起认领申请、管理员审核凭证,“管”是后台对全流程的追踪和处理。从纯技术角度看,这个项目真正的难点也恰好集中在这四个业务动作所衍生出的分布式问题上:跨服务的状态流转怎么保证一致性,并发认领同一件失物怎么避免超领,图片文件怎么存储和访问,以及前端页面怎么和后端网关对接。
如果你正准备做类似的学生项目、毕业设计或者个人技术作品集,这篇博文的参考价值在于:它完整覆盖了一条从服务拆分、环境搭建、编码实现,到部署压测的微服务落地路径。我也会把实际开发中踩过的坑、试过的方案、最后采用的取舍逻辑一并写出来,而不是只给你一个“能跑就行”的演示版本。
2. 微服务架构设计与技术选型解析
2.1 为什么校园失物招领也要上微服务
很多人会质疑:一个校园失物招领系统,日活撑死几千人,有必要拆微服务吗?纯从并发和性能角度讲,确实没必要。但从工程演进和教学实践角度讲,这个选择是合理的。首先,失物招领的业务角色天生多样:学生、教职工、管理员、后勤人员,每种角色的权限和操作范围完全不同,拆成服务后可以通过网关统一鉴权和路由,权限模型清晰很多。其次,失物照片、认领凭证这类非结构化数据如果直接写进业务库,数据库的压力会随着图片数量增长而快速上升,把文件存储独立成文件服务后,业务库只存访问路径,性能上更可控。最后,失物招领未来大概率会对接校内其他系统,比如一卡通中心、图书馆管理系统,通过微服务暴露接口的方式对接,比在单体应用里硬编码要灵活得多。
方案选型上,我对比过几种常见做法:
| 方案 | 优势 | 劣势 | 是否采用 |
|---|---|---|---|
| 单体Spring Boot + Thymeleaf | 开发快、调试简单 | 前后端耦合、难以独立扩展 | 未采用 |
| 单体Spring Boot + Vue前后端分离 | 结构清晰,适合中小项目 | 模块间耦合仍存在,多人协作易冲突 | 未采用 |
| 微服务Spring Cloud全家桶 + Vue | 服务独立部署、技术栈完整 | 架构复杂度高、运维成本大 | 采用 |
最终决定采用微服务方案,还有一个现实原因:这个项目需要体现分布式架构的核心技术要点,比如服务注册发现、配置中心、网关路由、分布式锁、分布式事务,这些都是微服务面试中的高频考点。做一个完整的微服务项目,远比自己背面试题要有说服力。
2.2 服务拆分与Spring Cloud核心组件选型
服务拆分遵循的原则是“按业务能力拆分,而非按代码层级拆分”。在失物招领系统里,我们拆出了以下几个服务:
- 用户服务(user-service):负责注册、登录、JWT签发、用户信息维护、角色权限管理。
- 失物服务(lost-service):负责丢失物品信息的发布、修改、下架、检索,以及失物与拾物的匹配逻辑。
- 认领服务(claim-service):负责认领申请的提交、审核、状态流转,是整个系统里业务规则最复杂的服务。
- 消息服务(message-service):负责站内信、邮件通知,当认领状态发生变化时通知相关人员。
- 文件服务(file-service):对接MinIO,负责图片上传、访问鉴权、文件路径管理。
- 网关服务(gateway-service):基于Spring Cloud Gateway实现统一入口、路由转发、限流和简单的JWT校验。
Spring Cloud组件选型上,我采用的是Spring Cloud Alibaba体系,主要原因有几点:第一,Nacos同时具备服务注册中心和配置中心两大功能,教育项目里不用额外部署Eureka和Spring Cloud Config两套东西;第二,Sentinel在流量控制、熔断降级上的配置比Hystrix更直观,控制台能实时看到调用链路和QPS;第三,Seata为分布式事务提供了AT模式,对业务代码的侵入小,非常适合这种以数据库操作为主的事务场景。OpenFeign用来做服务间的声明式HTTP调用,易用性比RestTemplate强很多,接口定义直观,维护成本低。
网关路由的配置有一个小经验值得说一下。初期我把所有路由规则都按服务名微调匹配,结果网关层经常出现路径冲突,比如/user/login和/user/info/login这种层级关系不好控制。后来统一约定:所有请求必须以/api/开头,网关层通过StripPrefix=1把/api去掉后再转发给下游服务。比如/api/lost/list会转发到失物服务的/lost/list,这样前后端联调时只需要记住一套前缀规则,逻辑清晰,也不容易出错。
2.3 项目模块结构与本地环境规划
项目采用Maven多模块结构,父工程只做依赖版本管理,子模块按服务划分。本地开发时,一台机器上跑全部微服务的方式是:后端六个服务模块在IDEA里分别启动,前端Vue项目在Node环境下通过npm run serve起在8080端口,Nacos、Redis、MinIO、MySQL用Docker Compose一键起。这里有一个非常实用的建议:不要手动去启动MySQL和Redis,写一个docker-compose.yml把基础设施统一管理起来,启动和清理都省心。
本地服务端口规划如下:
nacos-server: 8848 # 注册中心 + 配置中心 mysql-server: 3306 # 业务数据库 redis-server: 6379 # 缓存 + 分布式锁 minio-server: 9000 # 对象存储API端口,9001为控制台 gateway-service: 8000 # 网关统一入口 user-service: 8101 # 用户服务 lost-service: 8102 # 失物服务 claim-service: 8103 # 认领服务 message-service: 8104 # 消息服务 file-service: 8105 # 文件服务 vue-frontend: 8080 # 前端开发服务器端口规划看起来是小事,但在实际联调中作用很大。每个服务端口固定后,网关路由配置、前端环境变量、Nacos服务列表里的地址都会以这些端口为依据,排查问题时一眼就能看出是哪个服务没启动。另外,IDEA中服务较多时,建议给每个服务设置独立的VM参数,统一加上-Dserver.port的配置,避免多个服务实例共用端口导致启动冲突。
3. 后端微服务核心功能实现
3.1 Spring Boot项目搭建与Nacos集成
Spring Boot的版本选择直接影响后续依赖兼容性。我最初用的是Spring Boot 2.7.2 + Spring Cloud Alibaba 2021.0.5.0,整体运行稳定。后来尝试过升级到Spring Boot 3.0,发现很多早期版本的依赖都需要跟着调整,比如javax包要替换成jakarta,Spring Cloud Alibaba的适配版本也需要重建,考虑到项目稳定性和教程资源丰富度,最终锁定了Spring Boot 2.7.x。如果你的机器上Java版本较高,建议直接使用Java 8兼容模式,避免编译期出现意料之外的问题。
Nacos集成的核心步骤并不复杂。首先在父工程的dependencyManagement中引入Spring Cloud Alibaba的BOM,这样所有Alibaba组件的版本号可以统一定义,避免冲突。然后每个服务模块引入依赖:
<!-- Nacos注册中心 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <!-- Nacos配置中心 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency>bootstrap.yml中配置Nacos地址和文件扩展名,注意以下配置项:
spring: application: name: lost-service cloud: nacos: server-addr: 127.0.0.1:8848 discovery: namespace: campus-lost group: DEFAULT_GROUP config: namespace: campus-lost group: DEFAULT_GROUP file-extension: yaml enabled: true这里有一个细节值得特别注意:配置中心的namespace一定要在服务启动前规划好。如果所有服务都放在public命名空间,多人协作时很容易互相覆盖配置。我是在项目的初始阶段就创建了campus-lost这个命名空间,所有服务的配置都集中在那里,配置列表一眼就能看完。
3.2 服务间调用与OpenFeign接口设计
服务间调用的场景在系统里主要出现在两处:认领服务需要调用失物服务查询物品状态和归属信息;消息服务需要调用用户服务获取接收方的联系方式。OpenFeign的设计上,我把接口定义放在了一个独立的API公共模块中,这样调用方和服务提供方共享同一份接口契约,有效避免了接口路径和参数不一致的问题。
Feign接口的写法示例如下:
@FeignClient(name = "lost-service", path = "/lost") public interface LostFeignClient { @GetMapping("/detail/{id}") R<LostItemVO> getLostDetail(@PathVariable("id") Long id); @PutMapping("/status/{id}") R<Boolean> updateLostStatus(@PathVariable("id") Long id, @RequestParam("status") Integer status); }使用OpenFeign时有三个坑必须提前注意。第一,Feign默认的超时时间是1秒,服务间一旦有慢SQL或网络波动就很容易超时,需要在配置中单独调大超时时间,建议连接超时设为5秒,读取超时设为10秒。第二,Feign默认使用JDK自带的HttpURLConnection,性能不是很好,建议引入Apache HttpClient或OkHttp替换底层HTTP客户端,并发能力和连接复用能力都有明显提升。第三,传递复杂的查询条件时,建议统一使用POST加@RequestBody的方式而不是GET带多个@RequestParam,否则参数一多,URL会变得难以维护,而且容易触及Tomcat对请求行的长度限制。
3.3 网关统一路由与JWT鉴权
Gateway是整个系统的门面,所有前端请求都会先经过网关。网关除了做路由转发,还承担了JWT的解析和校验工作。我的设计思路是:前端登录成功后拿到JWT令牌,后续每次请求都在Header中携带;网关侧通过全局过滤器解析JWT,将解析出的用户ID和角色信息放入请求头,转发给下游服务;对于白名单中的接口,比如登录、注册、验证码获取,直接放行,其余接口一律校验令牌有效性。
网关路由配置示例:
spring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=1 - id: lost-service uri: lb://lost-service predicates: - Path=/api/lost/** filters: - StripPrefix=1 - id: claim-service uri: lb://claim-service predicates: - Path=/api/claim/** filters: - StripPrefix=1网关层做JWT校验的核心代码如下所示,这里面有两个处理细节:一是JWT解析失败时要区分是令牌过期还是非法令牌,返回不同的错误码;二是放行白名单和校验令牌的逻辑要分开写,避免在一次请求中重复解析。
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); if (whiteList.contains(path)) { return chain.filter(exchange); } String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (StringUtils.isBlank(token)) { return unauthorized(exchange, "未登录或令牌不存在"); } try { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() .header("X-User-Id", claims.get("userId").toString()) .header("X-User-Role", claims.get("role").toString()) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (ExpiredJwtException e) { return unauthorized(exchange, "登录状态已过期,请重新登录"); } catch (Exception e) { return unauthorized(exchange, "非法令牌"); } }3.4 并发认领场景下的分布式锁实现
失物招领业务里有一个非常典型的并发问题:同一件失物被多个失主同时发起认领申请。如果没有并发控制,两笔申请可能同时通过审核,导致一件失物被分配给两个人,这在真实场景中是严重的事故。这个问题的本质是数据库层面的“超卖”,解决思路和电商秒杀类似,核心是在物品状态更新的入口加分布式锁。
Redis分布式锁的实现,我一开始用RedisTemplate写了一套setIfAbsent加过期时间的逻辑:
Boolean success = redisTemplate.opsForValue() .setIfAbsent(LOCK_KEY_PREFIX + lostId, UUID.randomUUID().toString(), 30, TimeUnit.SECONDS); try { if (Boolean.TRUE.equals(success)) { // 业务逻辑 } } finally { if (持有锁标识) { redisTemplate.delete(LOCK_KEY_PREFIX + lostId); } }这套逻辑在低并发下没有问题,但存在两个隐患。第一,如果业务执行时间超过锁的过期时间,锁自动释放后,其他线程可能获取到同一把锁,造成重复执行。第二,删除锁时必须先判断值是否是自己的标识,否则可能误删其他线程的锁。为了彻底解决这些问题,后期切换到了Redisson框架,它提供的lock方法内部有看门狗机制,会自动续期,并且通过Lua脚本保证判断和删除的原子性:
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + lostId); try { // 尝试加锁,最多等待2秒,锁自动释放时间为30秒 if (lock.tryLock(2, 30, TimeUnit.SECONDS)) { // 查询失物状态并更新 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }使用Redisson之后,锁的可靠性明显提升,代码也简洁很多。这里需要特别强调一个易错点:不要为了省事直接使用synchronized关键字做锁,因为微服务架构下多个实例是部署在不同JVM进程中的,synchronized只能锁住本进程内的线程,分布式环境下完全起不到作用。这也是分布式锁区别于普通并发控制的核心差异。
3.5 分布式事务与数据一致性取舍
认领流程中有一个跨服务的数据一致性场景:失主提交认领申请后,认领服务要新增一条申请记录,同时还要更新失物服务的失物状态为“认领中”,如果第二步失败,就会出现“申请记录存在但失物状态未变”的脏数据。
针对这个场景,我考虑过Seata全局事务的AT模式,但最终没有采用。原因是这个操作链路短、并发量低,引入Seata需要额外部署Seata Server,还会把简单的业务操作包装成全局事务,开发和维护成本都偏大。最终采用的是本地消息表加定时任务的最终一致性方案:认领服务在本地数据库同时写入申请记录和状态变更消息表,然后通过定时任务轮询消息表,把未发送的状态变更消息通过Feign推送给失物服务;如果推送失败,则定期重试,直到成功。这个方案虽然带来了轻微的时间延迟,但保证了两边的数据最终是一致的,而且实现简单、可控性强。
这里补充一个面试中经常被问到的观点:并非所有业务都需要强分布式事务,消息队列加本地消息表就能解决大部分最终一致性需求。如果你的项目里确实有需要多服务同步提交的场景,比如下单同时扣库存,SEATA的AT模式或TCC模式会是更合适的选择。我们这种偏管理系统的场景,在架构上尽量追求简单可靠,不做过度设计。
4. Vue前端实现与联调实战
4.1 Vue环境安装与项目搭建
前端技术栈选择了Vue 2.7 + Vue Router 3 + Vuex 3 + Element UI的组合,这个组合在校园管理系统里非常常见,社区文档齐全、碰到问题容易搜到解决方案。Node版本建议使用14.x或16.x,不要直接上最新的Node 18以上,否则部分依赖在编译时会报OpenSSL相关的错误,这在Vue项目中是非常典型的坑。
Vue项目初始化通过vue-cli完成:
npm install -g @vue/cli vue create campus-lost-frontend创建过程中,建议勾选Router和Vuex,CSS预处理器选择SCSS,其他选项保持默认。依赖安装完成后,再用Element UI和Axios把基础架子搭起来:
npm install element-ui axios项目目录结构上,我按功能模块做了划分:
src/ ├── api/ # 接口请求封装 │ ├── lost.js │ ├── claim.js │ ├── user.js │ └── file.js ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── store/ # Vuex状态管理 ├── views/ # 页面视图 │ ├── LostList.vue # 失物广场 │ ├── LostPublish.vue # 发布失物 │ ├── ClaimApply.vue # 认领申请 │ ├── ClaimAudit.vue # 认领审核 │ └── UserCenter.vue # 个人中心 └── utils/ # 工具类4.2 Axios封装与网关对接
前端所有的请求都通过Axios发起,统一指向网关地址。这里有一个开发环境的配置技巧:本地开发时,Vue开发服务器默认跑在8080端口,而后端网关在8000端口,如果直接请求http://localhost:8000/api/xxx会存在跨域问题。解决方式有两种:一种是在网关层的GlobalCorsConfiguration中配置跨域规则,另一种是在Vue项目中配置devServer代理。我采用的是第二种,因为网关层配置跨域虽然可行,但会让网关代码变得不够干净,而且生产环境部署时网关和前端域名不同,还是要靠代理解决。
在vue.config.js中配置代理:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true, pathRewrite: { '^/api': '/api' } } } } }这么做之后,前端代码里只需要请求/api/lost/list这类相对路径,开发和生产的差异只在代理配置层面,业务代码完全不用动。Axios拦截器方面,我在请求拦截器里自动带上JWT令牌,在响应拦截器里统一处理401跳转到登录页,以及后端返回的R对象体中的业务错误码提示。
4.3 路由设计、状态管理与打包问题排查
前端路由设计围绕业务角色展开。未登录用户可以访问失物广场和失物详情页,登录用户可以发布失物、提交认领申请,管理员可以进入审核后台。路由守卫通过Vue Router的beforeEach钩子实现,每次跳转前检查Vuex中保存的用户登录状态和目标路由是否要求管理员权限。
关于Vuex的状态管理,我遇到的典型问题是刷新页面后登录态丢失,因为Vuex数据保存在内存中,刷新即清空。解决方案是一套组合拳:登录成功后把JWT令牌和用户基本信息同时保存到localStorage,页面刷新时在根组件重新读取localStorage并恢复Vuex状态,同时携带令牌去用户服务验证有效性。这样即使JWT过期,也只是跳转登录页,而不会出现页面上显示未登录但内容却是登录后的逻辑混乱。
打包这个问题在联调阶段尤其容易踩坑,先说一个现象:Vue项目打包后,把dist目录扔到Nginx上,刷新二级路由页面直接404。原因是Vue Router默认使用的hash模式虽然刷新不报错但URL中带#不好看,改成history模式后,刷新/claim/apply这个地址时,Nginx找不到对应的物理路径就返回404。解决办法是在Nginx配置中加入try_files回退规则:
location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; }另一个常见的打包问题是静态资源路径错误。如果前端项目部署在域名根路径下,publicPath设为'/'即可;如果部署在子路径下,比如通过Nginx的location /campus/来代理,那么publicPath必须设置成'/campus/',否则CSS和JS文件的引用路径就会错乱,页面样式全部丢失。项目里因为这个问题折腾了一个多小时,后来检查Nginx请求日志,才发现所有静态资源请求都返回404,改完publicPath后瞬间正常。这里建议所有部署在子路径下的前端项目,打包前先确认publicPath。
4.4 失物凭证图片与视频查看
系统里失物凭证和拾物现场照片需要支持图片查看,部分场景还涉及监控视频截图或短视频凭证。图片部分用Element UI的el-image组件就能完美支持预览,通过后台返回的MinIO访问链接直接加载。视频部分,考虑到大多浏览器对HTML5的mp4支持较好,但对m3u8直播流或分片视频格式支持不佳,需要引入hls.js这类库来处理。
import Hls from 'hls.js'; if (Hls.isSupported()) { const hls = new Hls(); hls.loadSource('http://minio-server:9000/campus-lost/video/xxxx.m3u8'); hls.attachMedia(videoElement); hls.on(Hls.Events.MANIFEST_PARSED, () => videoElement.play()); }这种场景在失物招领中其实不常见,但确实有用户上传过一段几十秒的监控视频用作认领凭证,所以在文件服务中增加了对视频格式的支持。这里要注意的是,MinIO在存储视频文件时需要设置正确的Content-Type,否则浏览器可能直接下载而不是播放。上传时通过文件名后缀判断文件类型,并显式指定content-type,能避免很多奇怪的问题。
5. 项目部署与压测验证
5.1 单节点K8s环境下的微服务部署
项目中后期,为了保证微服务各组件在生产环境配置的一致性,我在单节点的Kubernetes环境中搭建了整套微服务的运行环境。单节点K8s适合这种小规模项目,资源占用小,又能体验到ConfigMap、Secret、Deployment、Service、Ingress等容器编排的核心概念。
每个微服务在K8s中部署为一套Deployment + Service的组合。Deployment定义Pod副本数和资源限制,Service暴露集群内访问地址。由于服务间调用通过Nacos注册中心发现,K8s集群内的服务只需要保证所有Pod能访问到Nacos地址即可。数据库、Redis、MinIO这些基础组件,在同一台机器上继续用Docker跑,K8s Pod内通过NodePort或宿主机IP访问。
这里有一个部署时的经验教训:微服务在K8s中注册到Nacos时,IP地址会变成Pod内部的地址,比如10.244.x.x,如果Nacos配置了鉴权或者安全策略,必须确保Pod与Nacos网络互通。我当时遇到的情况是四个服务能正常注册,但文件服务一直显示不健康,排查半天发现是Pod的时区与宿主机不一致,导致健康检查失败。解决方法是在Deployment中配置:
spec: containers: - name: file-service env: - name: TZ value: Asia/Shanghai这个细节如果你完全用Docker Compose本地跑,是感知不到的,但一旦上K8s,时区、健康检查、资源限制这类问题就会集中爆发。
5.2 迁移到阿里云ECS过程中的数据一致性保障
项目在演示前需要从本地环境迁移到阿里云ECS单机部署,迁移时要求做到不停服、不丢数据。这个需求听起来很高大上,实际落地其实是通过一套有序的数据同步和流量切换策略完成的。
数据库迁移部分,采用的是MySQL主从复制的方式。先在ECS上搭建一个新的MySQL实例作为从库,连接到本地MySQL主库,配置binlog同步,等从库数据追平到与主库一致后,再将业务流量切换到ECS。这个过程需要在切换前确保主从延迟为零,操作命令如下:
-- 在主库执行 SHOW MASTER STATUS; -- 在从库执行 SHOW SLAVE STATUS\G; -- 关注Seconds_Behind_Master字段,为0时表示同步完成Redis数据的迁移相对简单,如果只是缓存数据,允许部分丢失的话,直接重启搞定。但项目里Redis中存有分布式锁的key和部分热点数据,不能完全丢弃。我的处理方式是采用Redis的持久化文件迁移:在本地执行SAVE命令生成rdb文件,将rdb文件上传到ECS后,替换目标Redis的数据目录,重启Redis即可。整个过程只有约几十秒的业务只读窗口,基本可以接受。
MinIO的文件迁移相对直白,因为文件数量不大,用mc命令行工具做两个桶之间的同步:
mc mirror --overwrite local/campus-lost remote/campus-lost迁移过程中的核心原则是:先迁移基础设施(MySQL、Redis、MinIO),再启动微服务应用,最后切换前端流量。每次只做一个组件的切换,出现问题可以快速回滚,不要试图一步到位。实际上,所谓“不停服”不可能百分百做到,更合理的说法是“尽量减少不可用时间”,我当时预留了一个时间窗口,在凌晨业务量最小的时候切换到新环境,切换后观察15分钟,确认日志无异常、接口无报错,再完成收尾。
5.3 JMeter压测脚本设计与结果分析
系统部署完成后,压测人员使用JMeter脚本进行高并发测试,验证云上环境的承载能力。压测的核心目标有两个:一是获取系统在指定并发量下的平均响应时间和错误率,二是定位系统的性能瓶颈在哪里。
JMeter脚本设计中,我按照业务场景设置了三个线程组:
- 登录与信息查询场景:模拟用户登录、查看失物广场列表、查看失物详情,核心接口以GET请求为主。
- 发布与认领场景:模拟发布失物、提交认领申请,核心接口以POST请求为主,会写入数据库。
- 文件上传场景:模拟用户上传失物照片,会请求文件服务并对接MinIO,压力集中在存储链路。
压测参数方面,线程数设置为50个并发用户,Ramp-Up时间为10秒,循环次数为50次。这一套参数跑下来,观察结果:
| 接口场景 | 平均响应时间 | 错误率 | 瓶颈分析 |
|---|---|---|---|
| 查询失物列表 | 45ms | 0% | 走了Redis缓存,性能最优 |
| 失物详情查询 | 120ms | 0% | 缓存未命中时回源MySQL,性能尚可 |
| 提交认领申请 | 380ms | 0% | 涉及分布式锁和跨服务调用,响应较慢 |
| 图片上传 | 950ms | 0.5% | 依赖带宽和MinIO磁盘IO,波动较大 |
压测暴露出的问题是:认领申请接口在并发升高时,响应时间从380ms上升到2秒以上,通过查看调用链日志发现是Feign调用失物服务更新状态时,出现了等待锁的超时。优化措施是调整Redisson锁的等待时间和MySQL连接池大小,同时将失物状态更新改为异步消息通知,认领确认后的失物下架操作不要求实时同步。这一改动后,接口响应时间稳定在300ms以内,错误率归零。
压测还有一个容易被忽略的点:压测机所在网络环境与被压服务的距离。如果压测机在本地,服务在云上,网络延迟本身就会占据大量时间,测出的数据不能真实反映服务性能。条件允许的话,最好在云环境的同一VPC内挑一台ECS作为压测机,这样能排除网络干扰。
6. 常见问题与排查技巧实录
问题一:Nacos上服务列表显示服务不健康
现象:服务启动正常,日志无报错,但Nacos控制台显示该服务健康检查不通过。排查思路:先确认服务实际端口是否正常响应,再查看服务启动日志中关于Nacos心跳的日志。常见原因包括:服务端口被防火墙拦截、服务的management端口与应用端口不一致导致健康检查失败、Nacos版本与Spring Cloud Alibaba版本不兼容。
问题二:Feign调用超时导致业务失败
现象:认领申请提交时偶发报错,错误信息为Read timed out。排查思路:查看Feign调用链中具体哪一步耗时较长,通过日志中打印的接口耗时定位慢接口。常见原因包括:下游服务数据库查询慢、下游服务线程池耗尽、OpenFeign默认超时时间过短。这个问题的优化路径通常要结合具体场景,不能简单粗暴地拉高超时时间,否则流量堆积会导致系统整体性宕机。
问题三:Vue打包后页面白屏
现象:本地npm run dev正常运行,npm run build后部署到Nginx,页面完全空白,控制台报错找不到JS文件。排查思路:查看Nginx错误日志,确认实际请求的JS路径与文件存放路径是否匹配。该问题绝大多数情况下是publicPath配置错误造成的,修改vue.config.js中的publicPath为'/'或实际部署子路径即可。另外一个坑是路由使用了history模式且Nginx没有配置try_files回退,刷新页面时报404,白屏只是结果,具体原因要区分开。
问题四:MinIO上传图片后无法访问
现象:文件服务返回上传成功,前端通过URL访问图片却显示403或404。排查思路:先检查MinIO控制台上文件是否存在,再检查Bucket的访问权限设置。MinIO的Bucket默认是私有权限,直接通过URL访问会被拒绝,解决方案是为Bucket设置下载策略,或者通过文件服务的接口读取文件并以流形式返回给前端。
问题五:IDEA中同时启动多个微服务但部分服务无法注册到Nacos
现象:本地开发时,先启动用户服务、失物服务,再启动网关服务,部分服务在Nacos上时有时无。排查思路:确认Nacos的namespace是否一致,不同namespace之间服务不可见;检查服务启动时是否读取到了正确的bootstrap.yml配置。另外一个常见原因是多服务同时启动时,本机网络端口短暂占用,导致Nacos心跳发送失败,稍等片刻即可恢复。
问题六:分布式定时任务重复执行
现象:定时任务用来处理过期的失物信息,但发现任务在多个服务实例上同时执行,导致重复处理。排查思路:如果服务部署了多个副本,原生Spring的@Scheduled注解必然会导致重复执行。解决方案有两种,一种是引入分布式调度框架如XXL-Job,另一种是使用Redis分布式锁来控制任务在同一时刻只能由一个实例执行。后者的实现思路是:任务执行前尝试获取一个全局锁,拿到锁的实例执行任务,没有拿到的实例直接跳过本次执行。
这些问题的排查过程,给我的整体感受是:微服务架构的问题排查链路比单体应用长很多,一个接口报错,可能是网关问题、服务发现问题、调用超时问题、数据一致性问题的某一环出了问题。如果你也刚开始做这类项目,建议先在本地把所有服务跑通一遍,再逐步扩展到分布式部署。把问题全部放到联调阶段再暴露出来,真的会非常痛苦。
我个人还建议在项目早期就把接口文档工具集成进来。我们使用的是knife4j,它在Nacos环境下支持跨服务聚合文档,网关层加一个聚合路由后,前端同事只需要访问一个地址就能看到所有服务的接口说明,这比翻代码看字段名要高效太多。当时手写接口文档的那几天,几乎每天都要回答“这个字段是什么意思”的问题,接入knife4j之后这类沟通成本直接降为零。