ARTICLE DETAIL

资讯详情

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

从单体到微服务:基于Spring Cloud的招聘系统分布式架构实践

从单体到微服务:基于Spring Cloud的招聘系统分布式架构实践 1. 重构起因单体招聘系统在多角色、高并发场景下的真实瓶颈说实话第一次看到这个需求时我是有点疑惑的——一个企业人才招聘求职系统为什么要上微服务分布式这套组合但等我把业务角色梳理完就发现问题没这么简单。系统要同时支撑求职者端、企业端、平台管理后台三端招聘季流量是平时的十倍以上简历解析、职位搜索、投递通知这些环节既吃CPU又吃I/O企业用户和求职用户的数据模型差异很大硬塞进同一张用户表只会越来越乱。这时候用SpringBoot做服务开发底座、SpringCloud做分布式治理、Vue做三端前端反而成了最自然的选择。我会把整个项目从服务拆分、三端前端、SpringCloud组件、核心业务实现到部署上线的完整过程写一遍重点讲那些文档里不会写清楚的坑。1.1 单体版本到底卡在哪里这个项目刚接手时是个典型的单体SpringBoot应用所有模块塞在同一个进程里。用户、职位、简历、投递、消息、报表全部共用一个MySQL实例前端只有一个用户中心企业端和管理后台都是后加的权限区分代码里大量出现if (role COMPANY)这种分支。业务还能跑但问题已经很明显了。最痛的是简历解析和职位搜索。简历上传后后端要解析PDF、提取文本、匹配技能标签这过程偶尔要几秒钟在单体应用里直接把请求线程阻塞住用户前台上传简历转圈后台服务所有接口都在排队。职位搜索用的是数据库LIKE %关键字%数据量到几十万条就明显变慢招聘季一来数据库CPU先飙起来。另一个问题是角色模型混乱。求职者的属性是期望薪资、技能栈、工作年限企业用户是公司全称、信用评级、招聘配额。这些字段在单表里就是一堆nullable列查询时要么全查出来内存过滤要么写一堆动态SQL。后来团队决定重构我第一反应就是按业务域拆服务而不是继续在单体上打补丁。1.2 服务拆分清单九个小服务的边界是怎么定的微服务拆分最怕一步到位拆成几十个服务治理成本比业务成本还高。我们按能独立迭代、独立扩容、数据库访问不跨服务的原则最终拆了以下九个服务服务名核心职责关键依赖开发端口auth-service登录、注册、JWT签发与刷新Nacos、Redis9001user-service求职者/企业/管理员资料管理Nacos、MySQL9002job-service职位发布、上下架、审核、搜索Nacos、MySQL、Elasticsearch9003resume-service简历上传、解析、模板管理、预览Nacos、MySQL、MinIO9004delivery-service投递记录、投递状态流转、批量投递Nacos、MySQL、Redis、RabbitMQ9005message-service站内信、邮件、短信通知Nacos、MySQL、RabbitMQ9006statistics-service招聘报表、企业数据看板Nacos、MySQL9007file-service图片/附件统一上传下载Nacos、MinIO9008gateway-service统一路由、鉴权、限流、跨域Nacos、Redis8888这个拆分逻辑是跟着业务闭环走的用户来平台先注册企业发布职位求职者搜到职位后投递简历投递后产生通知和状态流转管理员在后台审核所有内容运营要看数据报表。每个环节独立成服务谁挂了不影响其他环节比如message-service挂了投递主流程还能继续消息等消费者恢复后再补发。1.3 为什么最终选SpringBootSpringCloudVue这套组合选型不是追求新技术是看团队熟悉度和生态成熟度。SpringBoot在服务端开发上几乎没有上手阻力Starter机制把消息队列、缓存、数据源这些依赖统一管理写业务的效率很高。SpringCloud提供注册发现、配置管理、网关、熔断限流的全家桶不用自己拼轮子虽然学习曲线有但比起自研治理框架划算得多。前端三端都选了Vue 3 TypeScript Vite。原因很直接三端界面大量复用组件Vue的单文件组件结构适合团队协作Vite构建速度快很多开发体验比Webpack时代强不少。求职者端做成PC Web为主体企业端也是PC Web但组件风格完全不同管理后台以表格和表单为主三套应用共用一个组件库省掉大量重复劳动。2. 三端应用的工程拆分求职者、企业、运营后台怎么共用一套Vue生态2.1 三端的业务边界与页面结构先说清楚三端到底分别给谁用。求职者端是C端产品页面讲究简洁流畅核心操作是搜索职位、浏览职位详情、投递简历、查看投递进度、维护个人简历。企业端是B端工作台核心操作是发布职位、管理在招职位、筛选简历、安排面试、查看招聘数据。管理后台则是平台方的运营工具负责用户管理、职位内容审核、简历违规处理、公告配置、系统参数设置。这三个端如果做成一个前端工程路由、权限、样式会互相污染。我们采用monorepo结构放在一个Git仓库里统一版本管理frontend/ ├── packages/ │ ├── web-jobseeker/ # 求职者端 │ ├── web-company/ # 企业端 │ ├── web-admin/ # 运营管理后台 │ ├── shared-ui/ # 公共组件库 │ └── shared-utils/ # 公共工具函数 └── pnpm-workspace.yaml用pnpm workspace而不是创建三个独立仓库好处是公共组件改动后三个应用立即生效不需要发版本到npm私服。CI构建时也可以并行打包。2.2 动态路由和菜单权限是怎么落地的三端最深的坑是权限模型。求职者端几乎没有路由级权限企业端和管理后台需要严格的角色权限控制而且不同角色看到的菜单不一样。我们没把菜单写死在前端而是由后端根据用户角色返回菜单树前端用router.addRoute动态注册。const modules import.meta.glob(../../views/**/*.vue) function buildRoutes(menus) { return menus.map((menu) ({ path: menu.path, name: menu.name, component: modules[../../views${menu.component}.vue], meta: { title: menu.title, icon: menu.icon }, children: menu.children ? buildRoutes(menu.children) : [] })) } const dynamicRoutes buildRoutes(menuTree) dynamicRoutes.forEach((route) router.addRoute(route))这里必须注意一个细节刷新页面时Vue Router会重新初始化动态路由会全部丢失。所以我们把路由获取逻辑放到应用初始化时先请求用户信息接口拿到菜单树再addRoute最后router.replace(当前路径)。否则用户一刷新就跳到404这是最典型的页面刷新白屏问题。2.3 接口请求层封装与PDF简历预览三个前端应用都要对接网关我统一封装了一个基于axios的请求模块。请求拦截器里读取pinia中的token放在Authorization头响应拦截器里统一处理401跳登录、403无权限提示、业务错误码toast。这个封装本身不复杂但一定要在联调前就定好错误码规范我们后来踩的很多沟通成本都是因为后端返回错误字段不统一。简历预览是求职者端的高频功能。有同事问Vue的img标签能不能直接显示PDF答案是img只能显示图片不能渲染PDF。我们最终用的是pdf.js通过pdfjs-dist把PDF文件解析成Canvas逐页渲染这样可以在页面上嵌入预览也支持转图片放大查看。对纯图片型简历直接用img加上object-fit: contain即可。3. SpringCloud落地细节注册中心、网关、远程调用和分布式锁的真实配置3.1 Nacos注册中心与配置中心使用细节服务注册用的是Nacos版本2.2.3每个服务启动时自动注册通过spring.application.name作为服务名。这里要提醒的是环境隔离我们在一套Nacos里用namespace区分dev、test、prod三个环境每个环境下再按service创建配置文件比如job-service.yaml、delivery-service.yaml。配置中心最值得注意的坑是配置刷新。Nacos支持配置变更后动态推送但Spring Boot的Bean默认不会自动重新绑定。比如数据源参数变了除非加上RefreshScope否则只有重启才生效。我们只对非关键配置如开关、阈值类加RefreshScope数据源、Redis连接池这类底层配置不敢动态刷新避免连接泄露。3.2 Gateway网关统一鉴权与跨域配置所有前端请求统一走网关网关做JWT校验、路由转发、基础限流。路由配置长这样spring: cloud: gateway: routes: - id: auth-service uri: lb://auth-service predicates: - Path/api/auth/** filters: - StripPrefix1 - id: job-service uri: lb://job-service predicates: - Path/api/job/** filters: - StripPrefix1 globalcors: cors-configurations: [/**]: allowed-origins: http://localhost:5173,http://localhost:5174,http://localhost:5175 allowed-methods: * allowed-headers: * allow-credentials: true网关里写了一个全局过滤器白名单路径登录、注册、图形验证码直接放行其余请求解析JWT并校验角色。要注意的是跨域配置必须放在Gateway层不要在每个微服务里单独配否则本地联调时前端会碰到来自不同端口的CORS错误排查起来非常烦。3.3 OpenFeign调用超时与异常处理的教训服务间调用我们用了OpenFeign最开始把所有接口都按默认配置跑结果线上频繁出现超时。Feign默认连接超时和读取超时都非常短一旦目标服务GC停顿或慢SQL调用方直接抛异常。后来在配置中心统一加了超时参数feign: client: config: default: connectTimeout: 3000 readTimeout: 5000比调大超时更重要的是搞清楚哪些调用必须同步、哪些可以异步。投递简历后要校验用户状态、企业状态这两个可以同步调但发送站内信、统计计数这类完全没必要同步放进MQ异步处理。如果每个远程调用都同步等待接口响应时间会被最慢的服务拖垮。3.4 Sentinel限流熔断先保护投递和搜索接口网关后面直接接Sentinel我们只对两类接口做了重点保护职位搜索和简历投递。职位搜索在招聘季一定会上热搜瞬时QPS能冲得很高如果ES扛不住整个job-service会雪崩。投递接口关系到数据准确性不能因为流量大就丢弃所以策略是搜索可降级、投递必保。Sentinel规则我们可以通过Dashboard推送到Nacos持久化。搜索接口阈值设置成常态峰值的1.5倍超过后快速返回热门职位兜底投递接口阈值给得宽一些配合后面的分布式锁做并发控制。降级不是为了砍功能而是牺牲一部分非关键体验保住核心链路。3.5 Redis分布式锁批量投递和面试时间段的幂等控制企业招聘有一个高频操作叫批量投递求职者可以一键投递多个职位但同一个用户同一个职位不能重复投递。单机时代用数据库唯一索引就能挡拆成微服务后投递请求会落在不同实例上必须用分布式锁。我们直接用Redisson的RLock比手写setnx方便且安全。核心逻辑String lockKey delivery: userId : jobId; RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(5, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(操作太频繁请稍后再试); } try { // 再次查询投递记录确认未投递 // 写投递表发MQ消息 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里有三个容易被面试官追问的点。第一是为什么不能简单setnx后设置过期时间要保证加锁和设置过期时间原子执行防止进程崩溃后锁永远不释放。第二是为什么释放锁前要判断isHeldByCurrentThread防止锁过期后当前线程误删其他线程持有的锁。第三是Redisson的看门狗机制默认锁超时30秒如果业务没执行完看门狗会自动续期避免业务中途锁失效。面试时间段抢约也是类似思路。企业安排面试官开放几个时间段求职者抢约同一个时间段只能被一个人约到。我们用tryLock拿锁拿到后直接更新数据库时段状态更新成功才算真正约上锁key粒度是interview-slot:{slotId}。3.6 分布式事务本地消息表保证投递链路最终一致投递简历这个动作会跨多个服务delivery-service写投递记录、resume-service更新简历投递次数、message-service发通知。如果每个服务各自本地事务不引入分布式事务方案必然会出现投递成功了通知没发出去或者投递次数错乱的情况。我们没有引入Seata这类较重框架用的是本地消息表 RabbitMQ的最终一致方案。投递服务在同一事务里写投递记录和一条本地消息表记录事务提交后定时任务把未发送的消息发给MQ消费者处理后写回调状态。如果投递记录成功了但MQ发送失败定时任务会重试消息消费端做好幂等根据deliveryId去重。这套方案的好处是不侵入业务表能保证最终一致代价是引入了消息表和重试逻辑但招聘系统的投递链路对实时性要求没那么高这样最稳。4. 招聘业务闭环的实现职位、简历、投递状态机与ES搜索4.1 职位模块审核流、上下架与到期自动关闭职位发布不是企业填完就立即上线需要走平台审核。job-service里设计了status字段草稿、待审核、审核通过、已驳回、已下架、已关闭。企业端编辑职位后提交审核管理后台审核通过后职位才可见。职位还有有效期很多企业发布时设置30天或60天有效。我们用XXL-Job做定时任务每分钟扫描一次到期职位把状态从招聘中改成已关闭。这里要注意的是不要用UPDATE job SET status 0 WHERE expire_time NOW()在业务代码里循环执行大批量更新会造成主从延迟我们按每次取200个ID分批做状态变更并同步删除ES中的过期文档。4.2 简历模块上传、解析、技能提取与PDF预览简历模块我花了最多时间。文件上传走file-service统一存到MinIO返回对象存储的key简历表只存元数据和文件地址。解析PDF用了PDFBox提取文本Word文档用poi-tl读取提取出来的文本再交给HanLP做分词和技能词匹配。HanLP在SpringBoot里的集成并不复杂引入依赖后加载自定义词典把Java、Spring Boot、MySQL这类常见技能词加到词典里然后对简历文本做分词匹配到技能词就记录到简历的技能标签字段。这套规则不可能100%准确但有7成覆盖率已经能支撑搜索和推荐。PDF预览的坑上文已经提到这里再说一个细节MinIO存储桶如果设成私有前端iframe预览时会因为签名过期导致加载失败所以预览场景专门走file-service的临时下载链接接口生成带签名且有效期10分钟的URL。4.3 投递状态机从已投递到已入职的状态设计投递状态是招聘系统的核心流程。最早我们只用一个整数状态字段前端根据数字判断按钮和文案结果需求一改就乱套。后来改成明确的状态机和流转表把状态流转统一收敛到delivery-service处理当前状态允许操作目标状态已投递企业初筛通过初筛通过已投递企业初筛不通过已淘汰初筛通过安排面试面试中面试中通过面试待offer面试中面试未通过已淘汰待offer发送offer已发offer已发offer候选人确认入职已入职状态更新用乐观锁控制UPDATE delivery SET status ?, version version 1 WHERE id ? AND version ?防止企业和求职者同时操作导致状态覆盖。4.4 职位搜索与简历检索ES索引设计和数据同步职位搜索从MySQL的LIKE迁到了Elasticsearch。索引mapping大致如下{ mappings: { properties: { title: { type: text, analyzer: ik_max_word }, companyName: { type: keyword }, salaryMin: { type: integer }, salaryMax: { type: integer }, city: { type: keyword }, skillTags: { type: keyword }, status: { type: integer }, publishTime: { type: date } } } }索引和数据同步用的是MQ异步方案job-service在职位创建、更新、审核通过、关闭时发送MQ消息search消费者把对应文档写入或删除ES。这里有个常见问题刚发布的职位搜不到是因为投递到ES有几百毫秒延迟。我们给搜索建议接口做了允许数据延迟1秒的容忍同时在搜索结果中把刚发布且仍处于审核中的职位排除避免运营在后台刚审核完就搜索立即看到但前台却看不到导致投诉。5. 服务拆分后的数据难题缓存、跨服务查询与统计报表5.1 按服务拆库后跨服务数据怎么拿拆服务容易拆库难。user-service管用户表job-service管职位表投递记录需要展示职位名称和企业名称不能直接去job-service查数据库只能走远程接口。第一个版本我们写了一个投递列表接口循环100条记录调一次job-service查明细结果耗时爆炸。后来改成批量接口传ID列表返回MapLong, JobBriefDTO一次远程调用解决。对于简历投递历史我们还做了数据冗余投递记录里直接冗余保存职位名称、企业名称、企业Logo等快照字段。职位信息后续改了也没关系投递历史里保留的是投递那一刻的快照这在业务上反而是正确的。冗余字段虽然违背第三范式但在这个场景下合理。5.2 职位详情缓存的穿透、击穿和雪崩处理职位详情是被读最多的数据我们加了Redis缓存。缓存key是job:detail:{id}value是JSON字符串。最简单的缓存方案容易出三个问题。缓存穿透可以用空值缓存解决查询不存在的职位ID时往Redis写一个空对象并设置短过期时间防止恶意请求打到数据库。缓存击穿是指一个热点职位过期瞬间大量请求同时回源数据库解决办法是加锁回源只让一个线程查库并重建缓存。缓存雪崩是指大量key同一时间过期解决办法是给过期时间加随机值比如5分钟 Random.nextInt(60)秒。另外职位上下架操作必须主动删除缓存不能等它自然过期。我们用Spring的CacheManager统一管理在职位状态变更的服务里调用evict方法确保前台状态即时正确。5.3 招聘统计报表异步聚合而不是实时查询企业端有一个招聘数据看板要统计每个职位的投递量、简历筛选通过率、面试到场率、offer接受率。一开始我们直接让statistics-service去各服务远程调数据再内存聚合数据一大就超时。后来改成异步聚合模式投递状态每发生一次流转都会发一条MQ事件给statistics-service统计服务维护一张每日汇总表按小时增量更新。这样报表展示的数据最多延迟几分钟但它不阻塞业务主流程。统计口径也更容易统一——所有指标从事件表重算而不是各个服务自己吐数字避免出现同一指标在三个页面显示三个值的尴尬。6. 部署上线与生产环境的那些坑6.1 三端前端构建与Nginx反向代理三套前端应用各自构建成dist目录部署在同一台Nginx服务器上通过不同域名或路径区分。我的建议是生产环境用不同域名比如job.xxx.com、company.xxx.com、admin.xxx.com这样Cookie作用域和CORS都更清晰。Nginx里核心配置是把/api/请求反向代理到网关server { listen 80; server_name job.xxx.com; location / { root /data/www/jobseeker; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8888/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html这一行必须写否则Vue路由的history模式在新页面刷新时会返回404。这个坑几乎所有用Vue Router的人都会踩一遍。6.2 服务容器化与最小资源分配所有服务都做了Docker镜像基础镜像我选了eclipse-temurin:17-jre比完整JDK镜像小很多。docker-compose启动时我踩了一个大坑服务太多内存不够导致Nacos自己都被挤到OOM。后来给每个服务设置了关键内存参数services: job-service: image: job-service:latest environment: JAVA_OPTS: -Xms256m -Xmx512m微服务拆分后每个服务都不应该占用巨大内存关键是做好堆内存上限和GC参数。简历解析服务因为要处理PDF文件内存给到1G其余常规服务512M足够。6.3 链路追踪一次投递请求到底卡在哪个环节没有链路追踪前排查问题靠逐台服务器翻日志效率低到崩溃。我们引入Spring Cloud Sleuth Zipkin所有服务在调用时自动生成traceId和spanId日志里带上这些信息Zipkin负责汇总展示调用链。加了这个之后有一次用户反馈投递简历一直转圈我打开Zipkin一看请求卡在delivery-service调用resume-service的接口上resume-service又卡在MinIO的临时下载地址生成上最终定位是MinIO的一个连接池配置太小。没有链路追踪这种问题光靠猜绝对猜不出来。6.4 上线后最值得警惕的几个生产问题把生产环境遇到的高频问题汇总一下给后面做同类系统的朋友避雷现象根因处理方法Nacos配置改了不生效业务Bean没加RefreshScope对开关类配置加RefreshScope底层连接池不动Feign偶发超时默认超时太短统一调大并区分同步/异步调用用户刷新页面404Vue Router history模式没配置try_filesNginx补try_files配置职位搜索后刚发布的职位搜不到ES同步有延迟接受1秒延迟审核后再同步索引Redis缓存穿透恶意请求大量查不存在的ID空值缓存布隆过滤器简历PDF预览偶发失败MinIO签名URL过期动态生成10分钟有效临时链接不缓存地址6.5 如果重做一次我会改选什么回顾整个项目有几件事如果再给我一次机会我会在架构阶段就改掉。第一角色权限模型一开始就该做成中央化的权限服务而不是每个服务各自判断角色虽然现在能用但权限点分散维护成本高。第二简历解析应该从一开始就设计成独立异步任务而不是先做成同步接口再重构异步化之后用户体验和系统稳定性完全是两个档次。第三ES索引应该在项目初期就引入晚了之后数据回填、双写校验都很痛苦。最后说一点体会微服务分布式不是目的是手段。这套系统如果只在本地跑个Demo单体确实更省事但只要要上生产、要扛招聘季流量、要支持三端独立迭代微服务拆分的收益远远大于成本。关键是拆分边界要想清楚不要为了技术而技术。
返回列表