做程序员这行的,最关心的一个问题就是薪资。前段时间有个猎头朋友问我:"你知道不同城市、不同技术栈、不同工作年限的程序员薪资中位数大概是多少吗?"我当时答不上来,就决定自己动手做一套程序员薪资分析系统。标题里那串词——SpringBoot、Vue、SpringCloud、微服务、分布式爬虫、可视化——其实就对应完整链路:爬虫采集公开招聘数据,微服务处理后端业务,前端Vue渲染统计结果。这篇文章就记录这套系统从设计到落地的全过程,给想搞数据分析和微服务实战的朋友一个可参考的方案。整个项目做下来大概花了两周业余时间,最难的地方不是某个框架的API,而是把数据采集、服务拆分和图表展示这三件事串成一条可靠的流水线。
1. 项目从需求到架构:为什么一上来就上微服务
1.1 核心需求拆解
拿到这个项目标题,我第一件事不是写代码,而是把需求拆成四块:
- 数据采集:从招聘网站抓取公开的职位列表和薪资字段。
- 数据标准化:处理"15K-25K""薪资面议""15-25K·14薪"这类乱七八糟的文本。
- 统计分析:按城市、工作年限、技术栈、学历等维度算平均薪资、中位数、分位数。
- 可视化展示:在网页上看到趋势图和对比图,最好是一张大屏。
这四块如果再细分,还会牵扯到任务调度、反爬策略、分布式部署、API网关、前端鉴权等等。但核心链路就一条:采集 → 清洗 → 入库 → 分析 → 展示。程序员薪资分析系统表面上是个"数据展示平台",本质上是个数据工程项目。
我在设计时遇到一个选择:是用单体应用把爬虫和分析写在一起,还是拆成微服务?我的结论是:爬虫和分析是两个负载特征完全不同的模块。爬虫是IO密集,需要不断抓取、解析;分析是CPU密集,会有聚合查询和算法计算。把它们塞进同一个进程,扩缩容时只能一起扩容,性能瓶颈没法隔离。所以我决定用SpringCloud微服务把爬虫服务、数据服务、分析服务、网关拆开。虽然项目规模不算大,但这种拆分让后续加新功能时不用动老代码。
1.2 系统整体架构与数据流转
数据流可以简单描述为:
- 调度中心定时触发爬虫任务,向招聘站点发送请求,获取HTML或JSON数据。
- 爬虫服务把原始数据发送到消息队列(RabbitMQ),让下游异步处理。
- 数据清洗服务消费消息,解析薪资文本、去重、清洗空值,写入MySQL。
- 分析服务周期性读取MySQL数据,计算结果缓存到Redis,同时写回统计表。
- API网关统一暴露查询接口,Vue前端调用接口渲染图表。
这个架构里最关键的一点是引入了消息队列,而不是爬虫服务直接调用清洗服务。原因有两点:一是保护下游,当招聘网站响应慢或反爬导致大量超时时,爬虫服务产生的数据量是波动的。直接同步调用,清洗服务容易被瞬时流量打挂;改成消息队列后,消费速度自己控制,天然就削峰填谷了。二是方便增量扩展,后续想加一个"校招薪资分析"爬虫,只要往队列再发消息就行,清洗和分析代码不用改。
技术栈我一开始就定了:SpringBoot做基础服务框架,SpringCloud做微服务治理,Vue做前端,Python Scrapy做爬虫,MySQL做持久化存储,Redis做缓存,RabbitMQ做消息队列。很多人会纠结"微服务一定要用SpringCloud吗",我的看法是:如果你已经有SpringBoot基础,SpringCloud的入门成本不算高,而且它提供的注册中心、网关、熔断都是生产环境里必须有的东西。与其等出问题了再补,不如一开始就按微服务规范来。
2. 技术选型解析:SpringBoot、SpringCloud、Vue与爬虫框架的取舍
2.1 SpringBoot与SpringCloud的定位
SpringBoot解决的是"单个服务怎么快速健壮地跑起来"的问题。它把Spring配置、Spring MVC、内嵌Tomcat、依赖管理全部自动配好了,我写一个薪资查询接口,只需要一个Controller加几行Service代码,不用再写繁琐的XML配置。
SpringCloud解决的是"一堆服务怎么协作"的问题。它提供的能力我分了四类:
- 注册与发现:我用Nacos做注册中心,服务之间通过服务名互相调用,而不是写死IP。
- 网关路由:用Spring Cloud Gateway统一接收前端请求,再转发到具体微服务,顺便做鉴权和限流。
- 远程调用:用OpenFeign写声明式接口,像调本地方法一样调其他服务。
- 熔断降级:用Sentinel保护服务,一个服务挂了不至于拖垮全网。
我在搭建时发现,SpringCloud组件版本之间兼容性是个大坑。SpringBoot 2.x对应SpringCloud 2021.0.x,SpringBoot 3.x对应SpringCloud 2022.0.x及以上,版本选错会出现莫名其妙的ClassNotFound。所以建议直接用一个官方对齐的版本清单,比如SpringBoot 2.7.18配上SpringCloud 2021.0.8。这个组合在社区里用得最多,问题也最少。
2.2 爬虫框架选型:Python还是Java
做爬虫,我第一反应是Python,因为生态太完善了。requests处理HTTP请求,Scrapy管理并发和调度,BeautifulSoup / lxml做HTML解析。但在这个项目里,爬虫只是上游数据源,服务的生命周期和后端是分开的。我最后是这么分的:用Python写独立爬虫服务,用Java写数据清洗分析微服务。爬虫服务与后端通过RabbitMQ通信,双方的语言可以完全不同。
如果你的团队只会Java,也可以用SpringBoot里的RestTemplate或WebClient来爬,配合Jsoup解析HTML。Java爬虫的好处是部署环境统一,不用多维护一套Python运行时;缺点是写复杂选择器时没Python方便,很多招聘网站还会用JavaScript渲染页面,Java处理起来要引入HtmlUnit或Playwright。我之所以选Python,还因为后面要处理反爬,Python生态里最容易找到代理池、cookie池、指纹浏览器相关的现成轮子。
爬虫框架我推荐Scrapy而不是纯requests手工写线程池。Scrapy自带去重、暂停恢复、请求调度,还能导出JSON数据。我建的分布式爬虫并不是用Scrapy的分布式插件,而是自己写一个简单的调度服务,把任务URL列表扔进RabbitMQ,多个Worker消费。这样既灵活,又能复用刚才的后端消息队列。
2.3 Vue与可视化组件选型
前端我用的Vue3加Vite。Vue3的组合式API写业务逻辑很顺手,Vite启动速度快,热更新比Webpack时代舒服太多。项目里用到Element Plus做后台管理界面,ECharts做图表。ECharts这个库很能打,常规折线图、柱状图、散点图、热力图、地图基本都有现成方案,而且通过option配置项就能改样式,不需要自己画Canvas。
我还做了动态路由:根据登录用户的角色,从后端拉取菜单权限,动态注册路由。虽然这个系统不必须要权限体系,但加上之后更接近真实微服务项目,后续接若依微服务Plus这类脚手架做二次开发时思路是通的。Vue这边最需要关注的是跨域。开发环境用Vite的proxy代理,把/api请求转发到网关;生产环境用Nginx配置location /api反代到网关服务。线下的跨域问题基本都出在Nginx配置上,后面我会单独讲。
3. 核心细节解析:爬虫设计与薪资数据标准化
3.1 爬虫模块关键技术点
写爬虫,第一步不是写请求,而是看目标站点的结构。招聘网站的职位列表页一般能通过地址参数控制城市、关键字和分页。我用的是招聘网站的公开移动端接口,返回JSON格式,比解析HTML容易得多。请求头需要带上常见的User-Agent、Referer。还有几个重要策略:
- 严格遵守站点Robots协议,只采集公开允许的路径。
- 控制请求频率,每抓一页休息3到5秒,不要并发拉满。
- 做限速和去重,防止任务重复执行。
- 对返回的页面做快照保存,方便后续调试清洗逻辑。
关于反爬,我用过Selenium应对过一些强依赖JS渲染的页面,但实际效果并不理想。Selenium启动浏览器太占内存,分布式的Worker一多,机器资源直接吃紧。更实用的做法是先用Requests直接请求接口,如果返回了带有验证码或频繁要求登录,就先停一下,换个代理IP再试。我踩过的坑是盲目堆代理池,结果代理质量太差,响应延迟高反而拖慢了整体爬取效率。后来改成"按域名做并发池,每个并发池最多两个线程",配合动态UA,效率反而稳定。
import time import requests from bs4 import BeautifulSoup def fetch_job_list(url, headers): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp.text elif resp.status_code == 403 or resp.status_code == 429: time.sleep(5) return None except requests.RequestException as e: print(f"request error: {e}") return None return None这个简单函数里藏着两个关键点:一是超时设置,网络请求没有超时控制,一个坏连接能卡住整个线程;二是状态码分类处理,403/429表示触发了反爬,不能一味重试,应该先退避。实际项目中我会把这些逻辑抽成一个重试装饰器,重试次数最多三次,超过就直接跳过本页。
3.2 薪资文本解析
爬虫拿到原始数据只是起点,真正让人头疼的是薪资字段。同一个职位,招聘网站可能返回"15K-25K""15-25K·14薪""20-35K·15薪""4-5千""薪资面议""8千-1.2万"等等。要把这些文本变成可计算的数字,我写了统一的薪资解析逻辑:
- 第一步,判断有没有"面议",有就直接标记为null,不参与统计。
- 第二步,根据单位区分是"K"、"万"还是"千"。K表示千,万表示10000,千表示1000。
- 第三步,用正则提取两个数字范围,比如
(\d+(\.\d+)?)\s*[-到]\s*(\d+(\.\d+)?),同时提取"月薪"还是"年薪"。 - 第四步,把范围的下限和上限都统一成"千/月",再算均值。
这里有个容易忽略的细节:有些岗位给的是"15薪"或"16薪",代表一年发多少个月工资。如果只统计月薪,不折算年薪,坑就大了。我在解析时会把"14薪"信息单独存一列,统计年薪时用月薪乘上总月份。下面是Java里的解析函数片段:
public SalaryRange parseSalary(String text) { if (text == null || text.contains("面议")) { return null; } String normalized = text.toLowerCase() .replace("k", "") .replace("万", "0000") .replace("千", "000"); Pattern p = Pattern.compile("(\\d+(\\.\\d+)?)\\s*[-,~到]\\s*(\\d+(\\.\\d+)?)"); Matcher m = p.matcher(normalized); if (m.find()) { double min = Double.parseDouble(m.group(1)); double max = Double.parseDouble(m.group(3)); return new SalaryRange(min * 1000, max * 1000); } return null; }这种解析方式对付80%的文本足够,但还有20%的边界值需要单独处理。例如"8千-1.2万",replace后变成"8000-1.20000",正则提取到的是8000和1.20000,明显不对。所以正确的顺序是先提取数字和单位,再做统一换算,不能简单replace。我在实际情况里是先根据"万"和"千"的位置,分别转换成K单位,再提取范围。这个坑必须写出来,免得你直接抄上面的代码后翻车。
3.3 数据清洗与字段设计
清洗是决定分析结果能不能信的核心。招聘网站的原始字段往往有空值、重复值、格式不一。我设计了一张job_salary表,主要字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 自增主键 |
| job_id | varchar | 招聘站点的职位唯一ID,用于去重 |
| title | varchar | 职位名称 |
| city | varchar | 工作城市 |
| company | varchar | 公司名称 |
| experience | varchar | 工作经验要求 |
| education | varchar | 学历要求 |
| salary_min | int | 月薪下限,单位K |
| salary_max | int | 月薪上限,单位K |
| salary_month | int | 一年发多少个月工资 |
| job_source | varchar | 数据来源站点名称 |
| crawled_at | datetime | 抓取时间 |
清洗逻辑我放在消息消费者里:先按job_id查库去重,如果已存在且数据更新时间在24小时内,就丢弃;否则更新。这个简单的增量策略,能避免每天重复插几十万条数据。另外,城市字段需要做归一化,比如"北京"和"北京市"要统一成"北京","上海浦东新区"这种街道粒度分析时先归到市级。归一化表我直接用代码库里维护一个Map,虽然不优雅,但胜在简单可控。
4. 后端微服务实现与联调
4.1 基础设施搭建
我用的SpringCloud组件包括:Nacos、Gateway、OpenFeign、Sentinel。搭建时最要紧的是把Nacos先跑起来,因为它既是注册中心,又是配置中心。
启动Nacos可以用官方提供的Docker镜像,一条命令就能拉起来。但要注意,Nacos 2.x默认开启gRPC端口9848,防火墙一定要放行,否则服务注册成功但心跳检测总超时,控制台里服务状态会不停闪红。
SpringBoot项目的依赖引入其实不复杂,核心就是几个starter:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>如果你不想从零搭注册中心,可以直接参考若依微服务Plus这类开源脚手架。它已经把Nacos、Gateway、权限模块整合好了,我在项目早期确实借鉴过它的模块分层方式。但要注意,脚手架带了很多业务代码,直接照搬反而会增加迁移成本。我的建议是:先理解,再裁剪,不要无脑复制。
4.2 业务微服务开发
业务上我拆了三个核心服务:
- salary-crawler-service:负责接收调度任务,把需要爬取的URL发送给Python爬虫Worker,同时接收Worker回调结果并写入消息队列。
- salary-data-service:负责清洗数据、维护字典表、提供薪资数据增删改查接口。
- salary-analysis-service:负责聚合统计、计算中位数和分位数、生成统计报表。
服务间调用用OpenFeign。例如数据服务需要查询分析服务生成的统计结果,不需要直接连数据库,而是调用分析服务的接口。Feign接口定义:
@FeignClient(name = "salary-analysis-service", path = "/api/analysis") public interface AnalysisClient { @GetMapping("/city/salary") List<CitySalaryVO> getCitySalaryList(@RequestParam("date") String date); }这看起来简单,但Feign的第一个坑就是超时设置。默认连接超时只有1秒,读超时也只有1秒,薪资分析服务做聚合查询时一旦超过1秒,就会直接抛ReadTimeoutException。我在配置中心里统一把Feign的超时调到了5秒,同时给查询接口加Redis缓存。第二个坑是Feign的日志默认不打印,排查问题像摸黑。所以开发环境我开启了Feign的Full日志,看完整请求和响应,生产环境再调成Basic。
4.3 分析算法与缓存设计
分析模块的核心不是堆SQL,而是定义指标。我的指标包括:各城市平均月薪、各城市薪资中位数、各经验段薪资分布、各技术栈平均薪资。整体架构是"定时任务计算 + 实时查询缓存":每天凌晨用定时任务跑一次全量聚合,把结果写入一张统计表;实时查询优先读Redis,Redis里没有就查统计表。
算薪资中位数时有个麻烦:MySQL直接算全局中位数性能一般,我的办法是先按城市过滤,取出薪资数组再在Java内存里排序取中位数。数据量控制在几千条内,速度反而比SQL快。技术栈的判断也很费劲,职位标题里有"Java开发工程师"和"Java开发专家",我按关键词词典来归类,比如标题包含Java且不包含Android,就归到Java后端;包含Vue、React、小程序关键词,归到前端。这种规则词典准确率不算高,但用于趋势分析足够了。
4.4 联调与网关配置
服务都写好后,联调阶段最头疼的是网关路由。Spring Cloud Gateway的配置我在application.yml里维护:
spring: cloud: gateway: routes: - id: salary-analysis uri: lb://salary-analysis-service predicates: - Path=/api/analysis/** filters: - StripPrefix=1lb://是LoadBalancer协议,网关会自动从Nacos找到服务实例并负载均衡。如果你发现网关无法路由,先确认服务名正确,再确认Nacos控制台里服务实例数不为0,最后看一下网关日志里的404原因。我在本地联调时,经常因为Nacos上还留着旧的实例信息导致路由到死服务,解决办法是开发模式下直接把Nacos的临时实例自动注销时间调短,比如10秒。
5. 可视化前端与部署
5.1 Vue工程搭建
前端我用的Vite创建Vue3工程:
npm create vite@latest salary-visual -- --template vue cd salary-visual npm install npm install vue-router@4 axios element-plus echarts组件结构按页面划分:home是大屏仪表盘,city是城市对比页,trend是行业趋势页。路由配置里有一个坑,Vue3项目的router/index.js导出要用createRouter,不是new Router(),变量名routes不能省略。很多刚入门的朋友会卡在这里:页面白屏,控制台报Cannot read properties of undefined (reading 'push'),十有八九是路由实例没有正确挂载。
我有一次遇到更隐蔽的问题:双击路由跳转同一个页面时,组件params数据没刷新。原因是我在组件里用onMounted拉一次数据,路由参数变了但组件没重新执行生命周期。解决办法是用watch监听route.params变化,再重新拉接口。这也是动态路由场景下必踩的坑。
5.2 可视化大屏实现
大屏我用了ECharts的四个核心图:
- 薪资城市Top15水平柱状图,展示每个城市的平均薪资,按值排序。
- 经验-薪资折线图,横轴是1-3年、3-5年、5-10年,纵轴是平均薪资。
- 薪资区间直方图,把薪资分成0-10K、10-15K、15-20K、20-30K、30K以上几档。
- 岗位技术栈薪资雷达图,比较Java、Python、前端、算法等方向。
ECharts的option配置比较机械,但有几个坑我印象很深刻。一是横轴数据太长时,类目文字会重叠,我通过axisLabel: { rotate: 30 }把标签旋转30度解决。二是柱状图需要显示数据标签时,用label: { show: true, position: 'top' },但数字太大的话会被截断,需要设置grid: { top: 40 }给标签留出空间。三是接口返回的数据结构和图表需要的结构不一致,我会先用一个转换函数把后端VO转成前端Series,不要在组件里铺一堆数据转换逻辑。
5.3 部署与域名访问
部署我分成三步走:前端打包成dist静态文件,放到Nginx的HTML目录;后端三个SpringBoot服务打进Docker镜像,用docker-compose编排;MySQL和Redis单独跑在宿主机。
Nginx配置里最关键的是静态资源路由和API反向代理:
server { listen 80; server_name salary.example.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://gateway:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意try_files那行,Vue是SPA单页应用,前端路由里没有实际文件,如果去掉这行,刷新非首页路由时会得到404。proxy_pass后面加不加/也有讲究,加/代表把匹配前缀路径去掉再转发,不加则原样转发。我这里的网关接口本身就带/api前缀,所以选择不加斜杠。
6. 常见问题与避坑实录
6.1 爬虫反爬:频率控制优先于代理堆量
我遇到的最典型反爬现象是:短时间请求过多后,请求返回验证码页面,或直接跳到登录页。解决思路不是换更强代理,而是降低抓取频率。我实测过,单个IP每分钟请求超过10次,招聘平台大概率触发风控;控制在每分钟8次左右,连续抓一周都没问题。如果任务量大,用多台机器不同出口IP分摊请求,同时保持每台机器的整体频率不要过高。
另一个容易被忽略的点是Cookie失效。登录态Cookie半小时内有效,爬虫脚本里要定时刷新Cookie,并把Cookie存储在Redis中。每次请求前先取Cookie,如果取不到就退回匿名请求。这个逻辑写起来不难,但能省掉很多无效请求。
6.2 微服务联调:Nacos服务列表不健康
我在一次联调时遇到A服务能注册到Nacos,但B服务Feign调用A服务一直报No instances available。查了半天,发现A服务用的Nacos命名空间和B服务不一致。两个服务如果namespace不同,彼此是感知不到对方的。这个坑在单机开发时最坑:因为Nacos默认public命名空间,但一旦有人显式配置了namespace,就会出问题。排查方法很直观,打开Nacos控制台,切到对应命名空间,看服务列表是否为空。
还有一次是网关转发报500,检查日志发现是Sentinel配置的熔断规则把接口熔断了。原因是Sentinel默认带有懒加载,第一次访问时QPS统计从零开始,如果瞬间QPS突然高过阈值,就会熔断。开发调试环境可以直接关闭流控规则,生产环境则要把阈值和熔断时间设得宽松些。
6.3 SpringBoot Jar反编译:救命技能
项目有一次被误删了代码仓库,只有运维那边的jar包还在。这时候"怎么将SpringBoot jar反编译成项目"就是救命技能。SpringBoot打包出的jar本质是标准zip,里面存放了依赖的第三方jar和项目自身的classes。可以用CFR或Procyon这类反编译工具,将classes文件夹反编译回Java文件。不过反编译结果只能做参考,代码里的注释、资源文件、配置文件的顺序都会丢失。
我用这个技能成功恢复了大部分Service和Controller,但发现一个小问题:反编译出来的泛型和Lambda表达式可读性很差。所以我的建议是,反编译只用来救急,平时一定要做好代码版本管理。这个项目也让我养成了每次改动都提交Git的习惯。
6.4 数据可信度:不要忽略样本偏差
爬虫能拿到几万条数据,但招聘网站覆盖不等于真实就业市场。我在统计中发现,招聘薪资普遍比实际入职谈薪要低,因为很多公司招聘时写的是预算范围下限。同时,Boss直聘、拉勾网、智联招聘这些平台的用户群体不同,同一个职位的薪资分布会有偏差。如果分析时不过滤数据来源,结果参考价值就会打折。
我的处理方式是把job_source作为一个维度保留在分析结果里。展示时如果只有一个城市的综合数据,就同时展示数据量和来源占比,让使用者自己判断可信度。这个思路放到数据产品里,比单纯追求"高薪排行榜"更务实。我也在清洗时引入了三级过滤:第一级滤掉薪资字段为空的职位;第二级滤掉薪资范围上限小于下限的异常数据;第三级滤掉月薪超过200K的极端值(这极可能是数据错误)。这样虽然减少了一些样本,但聚合结果更稳。
写在最后:这套项目值不值得再扩展
如果你也想做一套类似的系统,我的建议是先跑通单机版本,再谈微服务。不用一上来就拆N副星座,否则联调成本会让你怀疑人生。先把爬虫抓到的数据能落库、能出图表,再按负载特征把爬虫、数据、分析拆成三个服务,配上Nacos和Gateway,自然就能理解微服务分布式带来的好处。
我个人做下来最深的体会是:这套系统的技术栈不算新,但它把"采集、存储、计算、展示"四个环节完整串了一遍,给我后续做数据中台积累了不少经验。最后再分享一个小技巧:爬虫的数据清洗和薪资解析规则不要全写死在代码里,把它存成一张配置表,每次清洗前先查配置表加载规则。这样发现新网站的薪资格式时,不需要改代码重新打包,改数据库配置就能生效。这个习惯帮我省了无数次发布会流程,希望也能帮到你。