
先说个场景求职者在周五晚上集中投简历HR周一早上集中筛选这两个时间段里服务器要扛住的是几百人同时写投递记录、上传简历附件、刷新职位浏览量的瞬时流量。如果还是单体架构加一台MySQL硬撑大概率会出现投递成功但记录丢失、简历上传超时、职位页白屏的情况。我做的这套个人简历求职招聘系统技术栈是SpringBoot Vue SpringCloud微服务分布式核心思路就是针对这种“短时间高并发”的业务场景来设计避免把鸡蛋全放在一个单体应用里。这篇文章我会把项目的业务边界、技术选型逻辑、服务拆分方案、核心链路实现以及上线后的排查笔记全部拆开讲。内容适合三类人正在做毕业设计或个人项目需要完整技术栈的开发者、从单体切微服务但不知道从哪下手的同学、以及面试前想搞懂分布式锁和分布式事务到底用在哪儿的朋友。1. 简历求职招聘系统的业务边界先想清楚哪些模块必须拆很多人拿到“求职招聘系统”这个题目第一反应是画页面登录页、职位列表、简历编辑页、投递记录页。这样想问题不大但到了微服务阶段就行不通了。微服务拆分的第一原则是“数据边界决定服务边界”不是“页面边界决定服务边界”。所以动工之前我花了两天时间梳理业务对象和它们之间的归属关系。1.1 用户身份与角色模型的设计起点这套系统里面有三种角色求职者、企业HR、平台管理员。求职者要维护简历、搜索职位、投递、查看面试邀请HR要发布职位、筛选简历、发起面试管理员要审核企业资质、处理举报、看运营数据。这三种角色的权限差异非常大所以我在设计用户服务的时候没有把角色字段简单塞进一张 user 表而是拆成了用户基础信息表、角色表、用户角色关联表用RBAC模型来支撑。这样做的原因是求职者和HR虽然都能登录但他们登录后能看到的菜单、能调用的接口完全不同。比如“发布职位”这个操作只有HR角色能执行如果用户表里就一个字符串字段存角色后期加一个“超级HR”或者“企业子账号”的时候改动就得波及整张表。拆开以后加角色、加权限都只是加数据的事不动表结构。1.2 职位、简历、投递三者的数据关系核心业务链路其实只有一条HR发布职位求职者投递简历HR筛选后发起面试。围绕这条链路衍生出来的是职位收藏、简历下载、面试日程、消息通知这些次生功能。职位表归属于 HR 和职位域核心字段是职位名称、工作城市、薪资范围、岗位要求、职位状态招聘中/已下线。简历表归属于求职者域和用户服务是一对一关系包含基本信息、教育经历、工作经历、项目经历、技能标签。投递表是独立的关联域它不归属于任何一方记录的是“谁在什么时间投递了哪个职位”状态从“已投递”流转到“被查看”“被邀约”“已被拒”。这里有一个关键经验投递关系表绝不能和简历表放在同一个服务里。因为投递是高频写入操作而简历是低频更新操作。如果简历服务挂了投递服务不应该跟着不可用。这在单体应用里看不出来问题但一旦微服务化服务间的依赖边界如果没划清楚后面排障会非常痛苦。2. 技术选型逻辑SpringBoot打底、SpringCloud做骨架、Vue做门面技术选型不是把热门框架堆在一起就完事而是要回答“每个组件在这个项目里到底解决什么问题”。这块我吃过亏最初想用纯SpringBoot做单体快速迭代结果职位搜索、简历全文检索、文件存储这些能力全挤在一个应用里代码倒是能跑但启动越来越慢改一个模块就要全量重启。2.1 SpringBoot在服务内的“去重”意义SpringBoot在这个项目里的角色是“每个微服务的地基”。用户服务、职位服务、投递服务全部基于SpringBoot 2.7.x构建它负责的东西包括自动配置DataSource、集成MyBatis-Plus操作数据库、用Validation注解做参数校验、通过Spring Security OAuth2做资源服务器校验Token。关于版本这里要多说一句我看到很多人上来就选SpringBoot 3.x结果和SpringCloud Alibaba的版本对不上Nacos客户端起不来。我自己用的版本组合是SpringBoot 2.7.12 SpringCloud 2021.0.3 SpringCloud Alibaba 2021.0.5.0这三个版本经过大量项目验证兼容性最稳。如果你真想上SpringBoot 3.x那SpringCloud Alibaba必须用2022.x以上的版本不然各种ClassNotFound会教做人。2.2 SpringCloud核心组件的组合方式SpringCloud全家桶我没有全用只挑了四个最核心的组件Nacos同时承担服务注册中心和配置中心。服务注册解决的是“服务之间怎么找到对方”的问题配置中心解决的是“改配置不用重新打包”的问题。OpenFeign服务内部调用用声明式HTTP客户端比如投递服务要查询职位是否存在直接Feign调用职位服务不用自己写RestTemplate模板代码。Spring Cloud Gateway所有前端请求统一走网关做路由转发、Token校验、跨域处理。Sentinel做接口限流和熔断降级保护核心接口不被瞬间流量打垮。这套组合是微服务项目里最常用的“骨架套餐”。网关负责守门Nacos负责登记服务地址Feign负责服务间通话Sentinel负责在流量过大时兜底。2.3 Vue端为什么比传统JSP方案更适合简历编辑前端选Vue而不是JSP或Thymeleaf核心原因是简历编辑这个场景太适合SPA了。简历编辑涉及动态增删教育经历、工作经历、项目经历这些操作本质上是前端数组的增删改Vue的双向绑定配合v-for渲染开发效率比JSP高出一个量级。我选的是Vue 3 Vite Vue Router Pinia Element Plus没有用Vue 2因为Vue 3组合式API的代码组织方式更适合团队协作。Vite的冷启动速度也比Webpack快很多。前端路由用了两种模式常规模块用history模式URL更干净比如 /job/detail/123。需要根据角色动态生成菜单的部分用动态路由前端先调用 /user/permissions 接口拿到菜单数据后通过router.addRoute动态注册。这里有个坑history模式在刷新时会404必须让网关把不匹配API的请求都转发到index.html后面第五章我会专门讲。3. 服务拆分与数据边界设计简历投递的链路不能拧成麻花服务拆分是最容易走极端的一步。有人拆了十几个服务结果一个简单的查询要跨五六个服务才能拿到完整数据性能和复杂度双双爆炸。也有人不敢拆把所有业务揉在一个服务里根本没有微服务的样子。我的做法是“按核心业务链路走一个服务只有一份核心数据”。3.1 按业务域划分的三个核心服务整个系统我拆成了四个服务持续集成时按服务独立部署服务名核心数据主要职责auth-service用户、角色、权限登录、Token签发、权限校验user-service求职者档案、企业信息简历CRUD、企业资质管理job-service职位、投递关系职位发布、职位搜索、投递申请message-service面试邀请、站内信投递状态通知、面试日程管理这四个服务的划分逻辑完全是跟着数据走的用户数据归auth-service求职者和企业档案数据归user-service职位和投递关系归job-service面试和消息归message-service。简历文件不落在数据库走MinIO对象存储后面单独讲。3.2 跨服务数据一致性分布式事务的取舍分布式事务是微服务绕不开的话题。我的系统里最典型的一个场景是投递简历成功后要同时做三件事——在job-service插入一条投递记录、在user-service更新简历投递次数、在message-service发一条站内信。三个服务各有一份数据要更新任何一个失败都可能造成数据不一致。第一版我直接上了Seata AT模式结果个人项目部署Seata Server又是建表又是配置成本太高而且AT模式本身有全局锁的性能开销。后来我换成了本地消息表方案投递请求先在job-service本地事务里插入投递记录和一条待发送消息然后通过Spring的事件机制把这条消息发给MQmessage-service监听MQ消费消息写站内信。业务上能容忍几十毫秒的延迟但绝不会出现投递成功但通知丢失的情况。提示个人项目或中小型业务优先考虑本地消息表 消息队列不要一上来就上Seata。Seata适合那种对账规则复杂的金融级场景招聘系统没到那个量级。3.3 分布式锁解决投递冲突Redis实现要点“分布式锁到底用在哪”这个问题我困惑了很久直到我自己压测时才真正遇到。场景是这样的同一个HR短时间内批量发起面试多个线程同时对同一个候选人记录做状态流转或者求职者重复点击投递按钮前端做了防重复提交但后端接口层还是可能收到重复请求。我的解法是在投递接口和面试邀请接口上用Redis分布式锁。直接用Spring生态里的Redisson用法很简单Resource private RedissonClient redissonClient; public Boolean applyJob(ApplyRequest request) { String lockKey lock:apply: request.getUserId() : request.getJobId(); RLock lock redissonClient.getLock(lockKey); boolean isLocked false; try { // 尝试加锁等待时间3秒锁自动释放30秒 isLocked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!isLocked) { return Boolean.FALSE; } // 核心投递逻辑 return applyService.doApply(request); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Boolean.FALSE; } finally { if (isLocked lock.isHeldByCurrentThread()) { lock.unlock(); } } }Redisson的锁不是简单的SET NX EX它底层是Lua脚本支持看门狗自动续期。锁粒度是“用户职位”维度同一个求职者投同一个职位会被串行化不同求职者投不同职位完全不受影响这样既保证不重复投递又不降低并发度。4. 核心业务链路实战从简历上传到面试邀请的完整实现框架搭好、服务拆好之后真正花时间的是把业务链路打通。我挑几条最核心的链路拆开讲每一条都踩过坑直接抄作业能少走很多弯路。4.1 简历文件存储方案MinIO而不是本地磁盘求职者上传简历附件最常见的是PDF或Word。一开始我直接存在服务本地磁盘结果两个问题很快出现一是重启服务文件丢失二是网关转发大文件时经常超时。后来我把文件存储换成MinIO它兼容S3协议个人项目完全免费。核心逻辑是前端上传简历时先请求后端拿一个预签名URL拿到之后前端直接PUT到MinIO文件不经过业务服务和网关。这招非常关键因为简历文件动辄几MB如果走应用服务器中转既占带宽又容易造成服务阻塞。预签名URL的生成逻辑很简单GetMapping(/presign) public RString getPresignUrl(RequestParam String fileName) { // 生成一个有效期为5分钟的预签名上传URL String url minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.PUT) .bucket(resume) .object(UUID.randomUUID() - fileName) .expiry(5 * 60) .build()); return R.ok(url); }文件上传成功后前端拿到文件名再把元信息文件名、大小、类型提交给user-service入库。下载简历时同理生成一个预签名下载链接有效期5分钟避免简历文件长期暴露。4.2 职位搜索与简历匹配的思路演进职位搜索第一版就是SQL里的LIKE模糊查询WHERE job_name LIKE CONCAT(%, #{keyword}, %)这个写法在数据量几千条的时候没问题但职位数据量到了十万级LIKE查询就开始明显变慢。我的优化方案是在job-service里集成OpenSearch把职位名称、技能标签、职位描述都倒排索引进去搜索结果按匹配度打分排序。在此基础上还做了一版“简历匹配度计算”把求职者简历里的技能标签提取出来和职位要求的标签做Jaccard相似度计算。比如求职者简历里有Java、SpringBoot、Redis三个技能职位要求Java、Spring、MySQL重合度是2/4两个交集除以两个集合并集匹配度50%再加上学历、工作年限等硬性条件加权最终给出一个匹配分。这个功能做出来后演示效果非常好面试官视角能看到一份“为什么推荐这个人”的完整逻辑。4.3 消息通知链路的异步化改造面试邀请、投递状态变更、简历被查看这些都要实时通知用户。最初是同步调用投递成功后直接调message-service发邮件结果是投递接口响应时间从200ms飙升到1.5秒。后来我把通知改成异步投递成功只写业务数据然后发一条消息到RabbitMQmessage-service在消费者里统一处理站内信和邮件通知。这里还要注意消息的幂等性。消费者拿到消息后先查一下消息表如果这条消息的msgId已经被处理过就直接确认消费不再重复发通知。否则MQ重试机制会导致用户收到重复邮件。5. 上线部署与排查笔记这些坑我帮你提前踩了系统开发完只是开始真正考验人的是部署和联调阶段。我这一版项目从开发到上线前前后后处理了不少问题挑三个比较典型的写成排查笔记都是不看代码光看现象完全定位不到的那种。5.1 网关超时与简历上传的“慢请求”陷阱现象是上传简历时前端报504但后端日志显示接口其实处理完了。排查后发现是网关默认的responseTimeout只有60秒而简历附件走应用中转时Nginx层还有一层read timeout 60秒两层叠加导致大文件上传必超时。根本解决方案就是我前面说的预签名URL直传MinIO文件不经过网关和应用服务器。如果你实在要经过网关转发务必在Gateway的配置里调大超时时间spring: cloud: gateway: httpclient: connect-timeout: 5000 response-timeout: 5m但直传才是最干净的方案网关只负责传小体积的JSON数据大文件一律走对象存储。5.2 前端路由刷新404与网关配置Vue Router用history模式后直接访问 /job/detail/123 这个地址并刷新请求会打到网关网关一看没有匹配的API路径就直接返回404。这不是Vue的问题是服务端没有做SPA回退。HTTP服务层面Nginx只需要加一条location / { try_files $uri $uri/ /index.html; }但如果前端请求也是走网关分发网关里需要加一个转发规则对非 /api 开头的路径统一转发到前端的index.html。我的做法是前端部署在独立的Nginx容器网关不做静态资源转发只转发 /api 开头的请求刷新404问题在Nginx层直接解决。5.3 热点职位缓存击穿与限流有个职位被头部企业放出来后一瞬间涌入大量求职者查看详情。职位详情接口做了Redis缓存正常情况下缓存命中就没有压力但缓存过期的那一瞬间大量请求同时发现缓存为空全部打到数据库这就是缓存击穿。我的处理方案是“互斥锁 缓存空值”双管齐下。缓存过期后不是所有请求都去查数据库而是先让一个请求获取分布式锁去查库回填缓存其他请求短暂等待后重新查缓存。同时缓存里存一个“空值占位”防止恶意请求轰炸一个根本不存在的职位ID时每次都穿透到数据库。Sentinel那边我对职位搜索和投递接口做了QPS限流兜底超过设定阈值直接返回“系统繁忙请稍后再试”的提示保证核心链路不因异常流量全面瘫痪。6. 这套架构后续还能扩展什么方向系统上线稳定之后我复盘了一下这套架构的扩展空间。如果这个项目要往真实产品方向迭代有几个方向是比较自然的延伸。第一个是面试全流程管理。目前系统只做到面试邀请这一步面试后的评价、offer审批、入职办理都可以挂到message-service下面扩展这本质上就是企业招聘后台的闭环能力。第二个是简历解析的自动化。现在简历录入还是靠求职者手动填写如果引入OCR识别PDF简历自动解析出教育经历和工作经历填写简历的门槛会大幅降低。这个方向在技术上是可行的Python那边有很多成熟的解析库把解析结果以JSON格式落到消息队列里user-service消费即可。第三个是数据报表可视化。管理员端需要看到平台每天的投递量、职位发布量、热门技能标签分布、行业薪资分布。这些数据在四个服务的业务表里都有用定时任务把统计结果同步到独立的报表库前端用ECharts展示就能形成一个轻量数据看板。我自己实际操作中的体会是这个项目最有价值的不是“用了什么技术”而是让微服务的那套概念真正落到了一条业务链路上。分布式锁不在面试题里而是在那个防重复投递的接口里消息队列不在文档里而是在那个异步发通知的投递流程里。如果有时间建议你从单体版开始跑起来再把这套微服务架构映射上去前后对比的感觉会很不一样。