先交代一下背景。这个项目完整做下来,前前后后花了将近两个月时间,从最开始的一堆需求点子,到最终跑通“求职者投简历、企业筛简历、平台做推荐”的完整闭环。名字就叫“个人简历求职招聘系统”,技术栈是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 | 职位与人才匹配、热度统计、榜单推荐 | 推荐结果缓存 |
| gateway | API网关:路由转发、统一鉴权、限流 | 无 |
注意,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而不是Eureka
SpringCloud第一代常用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 MODE=standalone \ -e NACOS_AUTH_ENABLE=true \ 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: - StripPrefix=1 - id: job-service uri: lb://job-service predicates: - Path=/api/job/** filters: - StripPrefix=1鉴权这块,我用的是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 Mono<Void> 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用的是WebFlux,RequestHeader你定义成HttpServletRequest是拿不到的,必须用ServerHttpRequest,千万别把Servlet那套思维搬过来。
2.3 服务间调用:OpenFeign的配置与避坑
服务之间互相调用,我统一用OpenFeign。相比RestTemplate,Feign自带负载均衡、接口声明式定义,代码可读性好太多。比如application-service要调job-service查职位详情,只需要定义接口:
@FeignClient(name = "job-service", path = "/api/job") public interface JobClient { @GetMapping("/detail/{id}") Result<JobDetailVO> 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的SETNX+lua脚本自己写容易出问题,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 vite@latest recruit-web -- --template vue cd recruit-web npm install npm install vue-router@4 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 Alibaba | JDK |
|---|---|---|---|
| 2.7.18 | 2021.0.8 | 2021.0.5.0 | 1.8 / 11 |
| 3.2.x | 2023.0.x | 2023.0.1.0 | 17 |
| 3.3.x | 2023.0.x | 2023.0.3.0 | 17 |
这个项目我用的是第一套组合,稳定性最高,踩坑资料也最多。如果你不是非要体验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降级格式,这些看似不起眼的工程化细节,后期会帮你省下海量排查时间。技术会更新,版本会升级,但清晰的工程习惯永远不会过时。