先交代一下背景。这个项目完整做下来前前后后花了将近两个月时间从最开始的一堆需求点子到最终跑通“求职者投简历、企业筛简历、平台做推荐”的完整闭环。名字就叫“个人简历求职招聘系统”技术栈是SpringBoot Vue SpringCloud微服务分布式。如果你正准备学微服务、想找个实战项目加深理解或者面试前想有一套能讲清楚原理的分布式系统这篇内容应该能省你不少折腾的时间。我尽量把设计思路、技术选型、实操过程、踩过的坑都写出来全程用大白话不整虚的。1. 项目整体设计与服务拆分思路1.1 为什么上微服务从业务场景反推架构很多人做招聘系统上来就SpringBoot单机一把梭这没问题系统小的时候完全够用。但我一开始就把这个项目定位成“能抗住一定并发、能展示分布式能力”的系统所以必须考虑几件事。第一简历解析和职位推荐是CPU密集型操作。一份PDF简历可能要经过格式转换、文本抽取、关键词打分异步处理还好如果同步塞进普通的业务接口里压力一大Tomcat线程池很容易被打满连累登录、投递这些核心接口。第二平台类系统天然有多个业务域用户、简历、职位、投递、消息、推荐它们访问频率和增长节奏完全不同。把用户表和职位表放在同一个库里后期做分库分表、读写分离都会互相牵制。第三团队协作上多个开发同时改一个单体应用合并冲突是小事更麻烦的是互相踩坏对方的接口契约。所以我采用了SpringCloud微服务分布式架构本质上是把“按功能拆分代码”升级成“按业务域拆分服务”。每个服务独立部署、独立扩容、独立故障互不拖累。代价也很明显服务间通信、数据一致性、链路排查都比单体复杂这也是后面几章我详细展开的重点。1.2 服务边界怎么划按业务域拆分而不是按层拆分这是微服务设计里最容易犯错的点。新手容易把Controller、Service、Mapper拆成三个微服务这属于“按技术层拆分”结果就是一次用户操作要穿透好几个服务数据还得来回拷贝纯属给自己找麻烦。正确的拆分方式是“按业务域”一个服务包揽某个业务域的完整闭环。我最终把系统拆成了这些服务给你一份可以直接抄的清单服务名职责说明核心数据auth-service登录注册、JWT签发、token刷新、权限认证用户凭证、令牌user-service用户基本信息、企业信息、简历基础档案用户表、企业表resume-service简历上传、解析、在线编辑、简历检索简历表、简历内容索引job-service职位CRUD、职位审核、职位上下架职位表、公司表application-service投递记录、投递状态、收藏夹、沟通记录投递表、收藏表message-service站内信、邮件、微信模板消息、面试邀约消息记录表recommend-service职位与人才匹配、热度统计、榜单推荐推荐结果缓存gatewayAPI网关路由转发、统一鉴权、限流无注意user-service和auth-service我刻意分开了。有人会觉得用户信息为什么要拆两个服务因为认证是纯技术性能力涉及密钥、令牌、加密策略而用户资料是业务性能力涉及大量业务表关联。分开之后auth-service可以做独立安全加固user-service可以专注于资料组装。后期即使换掉整个认证方案用户模块不用动。1.3 数据模型设计从单库到分库跨服务数据怎么组装数据是微服务里最容易拖垮项目的地方。最初我在一张业务库里建了8张表爽快无比后来服务一拆发现两个问题一是跨库JOIN完全不能用二是同一份用户数据会在多个服务里重复存储怎么保证同步。我的做法是“每个服务只碰自己的库”服务和库一一对应。比如投递记录在application-service的库职位表在job-service的库。那查询一个“我投递过的职位列表”怎么办不查JOIN查两次application-service查投递记录拿到职位ID列表再调job-service接口批量查职位详情最后在调用方组装。为了让这套方案跑得顺数据库设计有几个原则核心表必须有全局唯一业务ID我用的是雪花算法Snowflake生成的Long类型ID不用数据库自增主键避免分库后主键冲突。跨服务需要展示的冗余字段只保留ID和名称这类稳定信息比如投递表里冗余一个job_title不要冗余整个职位对象。每个表都加create_time、update_time、deleted字段做逻辑删除。分布式环境物理删除一旦误操作数据恢复难度相当大。这里还有个小坑Spring Data JPA的物理删除如果关联了其他服务的数据会留下脏数据所以逻辑删除几乎是微服务项目的标配。如果你用MyBatis-Plus可以直接用它的TableLogic注解省事很多。2. 核心技术选型与SpringCloud组件落地2.1 注册与配置中心我选了Nacos而不是EurekaSpringCloud第一代常用Eureka做服务注册中心但Eureka 2.x早就停止维护了而且它只解决“服务发现”配置管理还得另配Spring Cloud Config整个链路太重。我选了Nacos理由很实际注册中心和配置中心二合一少维护一个组件。配置支持动态刷新改完配置不用重启服务这对灰度发布和线上应急特别重要。自带命名空间和分组可以隔离dev、test、prod环境避免不同环境互相注册导致调用错乱。Nacos部署我用的是Docker方式一条命令起来docker run -d --name nacos -p 8848:8848 -p 9848:9848 \ -e MODEstandalone \ -e NACOS_AUTH_ENABLEtrue \ nacos/nacos-server:v2.3.0注意8848是HTTP端口9848是gRPC端口客户端连接时会自动用9848做长连接。防火墙只开8848的话服务注册会一直超时这是我第一次部署栽过的坑先给你提个醒。服务接入很简单引入依赖后在bootstrap.yml里指定注册地址spring: application: name: user-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: ${NACOS_NAMESPACE:dev} config: server-addr: 127.0.0.1:8848 file-extension: yaml namespace: ${NACOS_NAMESPACE:dev}配置中心的共享配置比如Redis地址、数据库连接、MQ地址可以放到一个共享配置DataID里用shared-configs引入避免每个服务重复写一堆相同配置。2.2 服务网关与认证鉴权统一拦在门口网关我用的Spring Cloud Gateway它基于WebFlux性能比Zuul 1.x好不少。网关只做三件事路由转发、统一鉴权、限流。路由配置大概是这个样子spring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1 - id: job-service uri: lb://job-service predicates: - Path/api/job/** filters: - StripPrefix1鉴权这块我用的是JWT Redis双Token方案。accessToken有效期短比如30分钟refreshToken有效期长比如7天存在Redis里。网关用GlobalFilter统一校验Authorization请求头解析JWT并把用户ID塞进请求头后续服务从请求头拿当前用户。关键代码是这样的Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Autowired private StringRedisTemplate redisTemplate; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 白名单直接放行 String path exchange.getRequest().getPath().toString(); if (PathMatcher.matches(/api/auth/login, path)) { return chain.filter(exchange); } String token exchange.getRequest().getHeaders().getFirst(Authorization); if (token null || !token.startsWith(Bearer )) { return unauthorized(exchange); } try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); // 将用户ID写入请求头传给下游服务 ServerHttpRequest mutated exchange.getRequest().mutate() .header(X-User-Id, claims.get(userId).toString()) .build(); return chain.filter(exchange.mutate().request(mutated).build()); } catch (Exception e) { return unauthorized(exchange); } } }这里有个容易忽略的坑Gateway用的是WebFluxRequestHeader你定义成HttpServletRequest是拿不到的必须用ServerHttpRequest千万别把Servlet那套思维搬过来。2.3 服务间调用OpenFeign的配置与避坑服务之间互相调用我统一用OpenFeign。相比RestTemplateFeign自带负载均衡、接口声明式定义代码可读性好太多。比如application-service要调job-service查职位详情只需要定义接口FeignClient(name job-service, path /api/job) public interface JobClient { GetMapping(/detail/{id}) ResultJobDetailVO getJobDetail(PathVariable(id) Long id); }有几个坑必须记一下超时时间默认只有1秒业务接口稍微慢一点就报Read timed out。我一般统一设置连接超时2秒、读取超时5秒在bootstrap配置里调。Feign默认单次请求不重试但turn-on-advanced-circuit-breaker开启后要注意重试的幂等性。投递接口这种写操作千万不能开重试否则重复建单。请求头不会自动透传。用户登录态从网关传到下游后如果需要继续传token要写一个RequestInterceptor把Header从ThreadLocal复制过去。Feign调用失败时的降级处理我用的Sentinel比Hystrix更轻量、控制台功能更多。每个Feign接口单独配置降级方法返回兜底数据避免一个服务抖动把整个调用链拖垮。2.4 分布式事务与分布式锁最硬核的两块骨头这个系统里有一个非常典型的分布式事务场景用户点击投递application-service要创建投递记录job-service要给职位投递数加一message-service还要发一条“投递成功”的通知。这三个操作分属三个服务本地事务管不着别人必须引入分布式事务方案。我对比过两种主流方案。Seata的AT模式侵入性低但需要部署TC服务器而且对性能有影响业务量大了之后全局锁竞争会很明显最终我选了“本地消息表 事务消息”的方式思路是这样的application-service在本地事务里创建投递记录同时写入一条message_task表初始状态是pending。定时任务扫pending消息投递到RabbitMQ消息里包含投递ID、职位ID、用户ID。job-service和message-service各自监听队列处理自己的本地事务处理成功后回调确认。如果某个环节失败消息重试超过最大重试次数进入dead-letter队列人工介入。这种方案的难点在于“消息不丢、不重复”。我用RabbitMQ的publisher-confirm机制保证消息一定到达Broker消费者端做幂等表投递记录加上唯一业务ID重复消息直接丢弃。虽然比Seata要多写不少代码但胜在可控性强踩坑了也容易排查。分布式锁我用了Redisson场景是“企业审核职位时防止重复审核”和“同一个用户对同一职位防止重复投递”。普通Redis的SETNXlua脚本自己写容易出问题Redisson直接封装好了还有看门狗机制自动续期RLock lock redissonClient.getLock(apply:lock: userId : jobId); boolean locked lock.tryLock(0, 10, TimeUnit.SECONDS); if (!locked) { return Result.error(操作太频繁请稍后再试); } try { // 业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }Redisson的锁默认30秒过期看门狗每10秒自动续期业务执行多久锁就续多久基本不会出现“业务还没跑完锁就过期”的尴尬。3. 前后端实现细节与业务模块实操3.1 Vue3前端搭建与动态路由前端这块我用的Vue3 Vite Vue Router Pinia Element Plus没有上重型脚手架自己手动初始化项目npm create vitelatest recruit-web -- --template vue cd recruit-web npm install npm install vue-router4 pinia element-plus axiosVite的好处是开发环境冷启动快HMR也流畅相比Webpack体感提升明显。动态路由是我比较满意的一个设计。菜单和权限不是写死在代码里而是登录后从后端拉取再通过router.addRoute动态加入。用户Service返回当前用户可见的菜单前端把菜单结构转换成路由配置。核心逻辑如下// 登录成功后 const menus await getUserMenus() const asyncRoutes generateRoutes(menus) // 把菜单转成路由对象 asyncRoutes.forEach(route router.addRoute(route))导航守卫里判断路由是否已经注册避免刷新页面后白屏。这里有个经典的坑刷新后Pinia里的菜单数据丢了路由为空。我的处理是把路由状态持久化到localStorage一份刷新时先用缓存恢复再拉最新数据覆盖。axios封装也是老生常谈但必须做对。请求拦截器加token响应拦截器统一处理报错。遇到401说明accessToken过期这里不要直接跳登录先尝试用refreshToken换新token换成功就重放原请求实在失败才清空登录态跳转登录页service.interceptors.response.use( (response) response.data, async (error) { const { response, config } error if (response response.status 401 !config._retry) { config._retry true const ok await refreshToken() if (ok) { config.headers.Authorization Bearer ${getAccessToken()} return service(config) } router.push(/login) } return Promise.reject(error) } )这个双token刷新机制在“用户正在填写简历长表单突然token过期”的场景里体验很好不会把用户一脚踢回登录页。3.2 简历上传与MinIO文件存储简历文件不能存数据库也不能扔到应用本地目录。本地目录一旦服务重启或多实例部署文件就丢了或者访问不到。我用了MinIO兼容S3协议支持私有化部署社区活跃度也高。SpringBoot集成MinIO很简单先建一个配置类ConfigurationProperties(prefix minio) public class MinioProperties { private String endpoint; private String accessKey; private String secretKey; private String bucket; }上传接口的核心代码public String upload(MultipartFile file) { String originalName file.getOriginalFilename(); String suffix originalName.substring(originalName.lastIndexOf(.)); String objectName resume/ DateUtil.format(new Date(), yyyyMMdd) / IdUtil.getSnowflakeNextId() suffix; minioClient.putObject( PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); return objectName; }文件对象名我直接用雪花ID生成避免中文文件名乱码和被恶意路径穿越。简历解析是另一个重头戏。我用的Apache Tika抽取PDF和Word里的纯文本抽取到的内容写入Elasticsearch投递搜索时按内容全文检索Parser parser new AutoDetectParser(); BodyContentHandler handler new BodyContentHandler(1024 * 1024); Metadata metadata new Metadata(); try (InputStream inputStream file.getInputStream()) { parser.parse(inputStream, handler, metadata, new ParseContext()); String resumeText handler.toString(); // 保存到ES并同步生成技能标签 }解析耗时通常几十到几百毫秒不能放在上传请求里同步等。我上传完简历文件后直接返回成功然后发一条MQ消息给简历解析消费者解析完更新简历状态前端轮询状态展示“解析中→已解析”。用户体验好也避免上传接口超时。3.3 职位搜索与匹配推荐ES 简单打分职位搜索我用的Elasticsearch IK分词。IK分词对中文支持好技能像“Java开发”“Vue前端”都能正确切分。索引结构里重点字段包括职位名称、技能要求、薪资范围、城市、工作经验创建索引时指定IK分析器。匹配推荐一开始我想上协同过滤后来发现冷启动问题太严重新用户没有任何行为数据时协同过滤直接哑火。所以我先用基于标签的召回打分策略简单但不弱用户注册或编辑简历时让用户选择技能标签比如Java、Spring Boot、MySQL也支持自然语言解析简历生成标签。职位发布时HR填写技能要求同样打标签。推荐计算时计算用户标签集合和职位标签集合的Jaccard相似度权重上给核心技能更高分再叠加薪资匹配分、城市匹配分、热度分。打分公式我放在recommend-service里用了简单的加权和score 0.5 * sameTagsCount 0.25 * cityMatchFlag 0.25 * salaryMatchFlag min(heatScore, 10)候选集从ES里按标签召回N条再打分排序Top30写回Redis接口直接读缓存。实践下来这个方案的推荐准确率不算顶尖但胜在逻辑透明、好维护面试时也容易讲清楚“为什么不用协同过滤”。3.4 消息通知与面试流程状态机投递简历、面试邀约、职位审核结果这些都需要通知用户。我的消息服务统一封装了三种渠道站内信、邮件、微信公众号测试号模板消息。微信公众号测试号可以用来练手感申请门槛低调试也方便。模板消息接口需要先设置模板ID再向用户openId推送。站内信做的简单存消息表前端右上角小铃铛轮询未读数。这个系统里最需要理清的是面试流程状态。我设计成了一个状态机面试申请的状态流转如下待沟通 - 已邀约 - 待面试 - 已完成通过/不通过待沟通 - 已拒绝已邀约 - 已取消每个状态变更都会发消息通知对方。这里必须避免HR和求职者同时操作导致状态错乱我用状态机校验加上分布式锁双重保障状态推进前先拿锁校验当前状态是否允许跳转到目标状态不允许直接拒绝。状态机的核心代码不多但能挡住绝大多数并发脏数据。4. 本地搭建、版本兼容与部署实录4.1 SpringBoot版本这么选千万别盲追新版这一节特别写给刚接触SpringCloud的新手因为我自己就在版本上栽过大跟头。SpringBoot和SpringCloud有严格的版本对应关系不能随便配。SpringBoot 3.x要求JDK17同时javax.servlet全改成了jakarta.servlet很多老代码直接跑不起来。SpringBoot 2.x时代用得好好的Feign组件升级到3.x后包名都变了。所以“SpringBoot版本太高”不是你一个人遇到的问题是生态迁移的必经之路。我最终用的是一套经过验证的稳定组合SpringBoot版本SpringCloud版本对应SpringCloud AlibabaJDK2.7.182021.0.82021.0.5.01.8 / 113.2.x2023.0.x2023.0.1.0173.3.x2023.0.x2023.0.3.017这个项目我用的是第一套组合稳定性最高踩坑资料也最多。如果你不是非要体验SpringBoot 3新特性老老实实用2.7.18把精力放在业务实现上价值更高。Maven多模块结构也给你参考recruit-system ├── recruit-gateway ├── recruit-auth ├── recruit-user ├── recruit-resume ├── recruit-job ├── recruit-application ├── recruit-message ├── recruit-recommend ├── recruit-common └── recruit-apirecruit-api专门放Feign接口和DTO服务依赖它recruit-common放工具类、统一返回体、全局异常处理器。这样避免了服务之间互相依赖对方内部类把依赖方向理得很干净。4.2 启动顺序与本地联调要点本地调试微服务最烦的就是“服务启动了但报找不到实例”。经验有三条中间件先启动Nacos、MySQL、Redis、RabbitMQ、Elasticsearch、MinIO顺序无所谓但必须确保端口都被占用。服务启动观察Nacos控制台每个服务起来后去Nacos服务列表看是否注册成功。注册失败先查namespace对不对再查服务名是否带了下划线Nacos对服务名大小写和下划线都敏感。服务间联调看日志Feign报错先看调用方日志再看被调方日志。我习惯在Feign配置里打开日志级别为FULL打印完整请求响应排错效率翻倍。联调时我还发现一个困扰很久的问题三台服务都连同一个Nacos但服务之间就是找不到。最后定位到是Nacos的namespace配置不一致不同的namespace会把自己隔离成Nacos的“独立小世界”。排查思路是先确认两个服务拿到的是同一个namespace ID再确认环境变量没被覆盖。4.3 用Docker Compose一键拉起基础环境整个系统的基础中间件我写了一个docker-compose.yml统一管理开发机一键spawn、一键清理非常方便。只贴几个最关键的services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: recruit ports: - 3306:3306 volumes: - ./mysql/conf:/etc/mysql/conf.d - ./mysql/data:/var/lib/mysql redis: image: redis:7.0 command: redis-server --appendonly yes ports: - 6379:6379 rabbitmq: image: rabbitmq:3.12-management environment: RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_PASS: guest ports: - 5672:5672 - 15672:15672 elasticsearch: image: elasticsearch:7.17.10 environment: discovery.type: single-node ES_JAVA_OPTS: -Xms512m -Xmx512m ports: - 9200:9200 minio: image: minio/minio command: server /data --console-address :9001 environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin ports: - 9000:9000 - 9001:9001注意Elasticsearch和MinIO的容器内存占用不小开发机建议分配16G内存起步否则经常出现服务无响应。数据库密码、Redis密码这些敏感信息我推荐用环境变量注入不要直接写死在compose文件里提交到仓库这个习惯要养成。4.4 日志链路追踪线上排查靠这个救命服务拆了之后一次请求可能横跨四个服务日志分散在多个容器里。没有链路ID排查问题就像大海捞针。我的做法比较简单网关生成一个traceId放进请求头每个服务在日志输出时带上这个traceId用MDC实现。Logger的Pattern里配置%X{traceId}业务异常时把traceId返回给前端用户报障只要报一串ID我就能在日志系统里一个词把所有相关日志拉出来。如果需要更完整的链路可视化可以接SkyWalking或Pinpoint。我有段时间想给这个项目加SkyWalking后来发现排查需求用traceId基本够了而且SkyWalking对部署和资源有一定要求先按需选择就好。5. 面试中关于微服务系统的高频问题与复盘既然做完了这个项目难免会被问到微服务相关的原理。这一章我把面试里被问烂的几个问题用自己的理解重新整理一遍你可以当复习提纲用。5.1 微服务拆分怎么答才不虚面试官问微服务拆分原则千万不要背“高内聚低耦合”就完了。要结合自己的项目说我是按照业务域拆分的把变化频率不同的模块分开拆分时关注数据域是否独立如果一个服务要频繁访问另一个服务的数据库表这个边界就有问题。还要说清楚拆分之后带来了什么比如简历解析可以单独扩容、职位搜索的流量不会影响登录接口。5.2 分布式事务和分布式锁怎么答这个问题基本必问。我先说明自己项目里的分布式事务场景再讲为什么选“本地消息表消息队列”而不是Seata最后画一条消息流转链路并强调幂等处理。分布式锁这边我从“SETNX为什么不能直接用”讲起引到Redisson的看门狗、可重入、公平锁再补充一下锁粒度设计的经验。5.3 服务治理与稳定性设计这里我会提到Sentinel的限流降级怎么配的网关层对每个路由配置了QPS阈值核心写接口的并发线程数做了隔离每个Feign接口配置降级方法Redis缓存设置了合理的过期时间防止缓存穿透。还要强调排查故障的手段traceId日志链路、Nacos的上下线观察、Prometheus监控关键指标。这些内容比单纯堆术语更能让面试官信服你真的做过。6. 一些实际体会这个项目做完我最大的感受是分布式架构最难的不是技术本身而是“什么时候该拆、拆到什么程度”的判断。如果业务只有几千个用户单体应用加缓存加消息队列完全够用不必为了微服务而微服务。但如果你想通过一个完整项目去理解注册中心、配置中心、网关、分布式事务、分布式锁之间是如何配合的那这个求职招聘系统是非常合适的练手场地。最后再分享一个小技巧做这类全栈微服务项目一定先把服务间调用链路的日志和异常规范定好比如统一Result返回体、全局异常处理器、Feign降级格式这些看似不起眼的工程化细节后期会帮你省下海量排查时间。技术会更新版本会升级但清晰的工程习惯永远不会过时。