我从去年开始就一直对技术人的真实收入水平很好奇。招聘网站上的薪资信息看似丰富,但岗位分散、格式混乱,想回答“同一座城市、同样五年经验的Java开发,市场价到底在哪”这种问题,靠人工翻页面根本不现实。所以我用大概三周业余时间,做了一套程序员薪资工资分析系统:爬虫负责采集公开招聘页面和公开薪资分享数据,数据清洗服务把“10K-20K·14薪”这种文本转成结构化字段,分析服务完成城市、岗位、经验纬度的聚合,最后在Vue大屏上用地图和柱状图把结果展示出来。整套系统跑在SpringBoot+Vue+SpringCloud微服务架构上,从采集到展示拆了六个服务,用Nacos做注册与配置中心,用RabbitMQ做异步解耦,部署起来也能分布式扩展。如果你正在学微服务拆分,或者想练手爬虫加数据可视化,这篇文章会把整体链路和最容易翻车的地方都讲清楚。
1. 项目需求拆解:先想清楚要采什么、分析什么
1.1 数据源调研与合规红线
做这类系统第一个问题不是技术,而是数据从哪来。我调研了几类来源:主流招聘平台的公开搜索接口、一些开源薪资分享项目、还有行业报告。最终选择了两类:招聘网站的公开职位列表(不需要登录态,页面robots.txt允许抓取的部分),以及社区用户主动公开的薪资爆料数据。这里有一条红线必须提前说清楚:只采集公开、非隐私、无需绕过验证码或登录门槛就能拿到的数据;采集频率要克制,不能对目标站点造成压力;数据只用于个人学习与统计分析,不对外提供原始数据或可定位到个人的信息。这个合规边界决定了后续技术方案,比如我们不设计验证码绕过模块,不维护付费接口的破解逻辑。
很多人一上来就纠结“爬虫不会被封吗”,我的经验是:只要把频率控制在人类浏览量级、做好随机延时、带上正常的UA,大多数公开页面是可以温和采集的。真正要防的是大数据量的暴力抓取,我们后面会用分布式采集加限流配合解决。
1.2 指标体系的定义:城市、岗位、经验与薪资口径
数据采下来之后要分析什么,必须先定义清楚。我最后确定的指标体系围绕五个维度:城市(北上广深杭成都等十个城市)、职位方向(后端、前端、算法、测试、运维、数据分析)、经验段(应届、1-3年、3-5年、5-10年、10年以上)、学历(大专、本科、硕士、博士)、企业类型(大厂、中型企业、创业公司、外企)。薪资字段则统一按月薪计算,原始文本里的“10K-20K”“15K·13薪”都要归一化。
为什么专门强调口径?因为很多页面写的是“月薪15K-25K·14薪”,如果直接用区间上限或平均值去聚合,误差会非常大。我们把每条记录拆成最低月薪、最高月薪、中位月薪、固定年终月数四个字段,分析时优先用中位月薪。后面清洗章节我会写完整的解析规则,这里先记住一点:口径不一致,图表做得再好看也是错的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 微服务架构设计:拆服务、选组件、定数据流
2.1 为什么不做单体:爬虫服务与Web服务的冲突
最开始我也想过用一个SpringBoot单体把爬虫、清洗、分析、展示全装进去,代码量估计也就两万多行,确实能跑。但实际跑起来会有几个尖锐的问题。第一,爬虫采集是典型的IO密集兼CPU消耗任务,JSoup解析一个大页面就能把内存打满,如果和用户查询薪资的接口放在同一个JVM,接口响应会不自觉变慢。第二,爬虫经常因为目标站点改版、代理失效需要重启或回滚,单体应用一重启,整个系统都断。第三,薪资分析需要定时重算大量聚合SQL,这种重任务如果和在线接口抢数据库连接池,会发生雪崩。所以我决定按业务域拆成微服务,每个服务独立JVM、独立数据库schema(物理上可以共用实例)、独立发布。
2.2 服务清单与调用关系
最终的拆分方案是六个服务:spider-service负责采集任务调度和HTTP抓取;clean-service消费原始数据做清洗;analysis-service提供统计查询接口;visual-service专门给Vue前端提供聚合展示接口(也可以并入analysis-service,但为了演示微服务调用链我单独拆了);auth-service做简单的JWT登录;gateway-service做统一入口。注册中心和配置中心用Nacos。数据异步链路用RabbitMQ:spider-service抓到原始文本后发到raw-data队列,clean-service消费后清洗,再发到clean-data队列,analysis-service消费后更新预聚合表。同步查询链路走OpenFeign:前端请求网关,网关转发到visual-service,visual-service需要调analysis-service拿统计结果。
这里有个设计心得:不要为了微服务而微服务。像auth-service如果只有几百行代码,拆出来纯属增加运维成本,但我拆它是因为后续想接入更多管理端接口,认证能力会独立演进。判断标准很简单——这个模块有没有独立的发布节奏和独立的扩展需求,有就拆,没有就留在原服务里。
2.3 SpringCloud组件选型:Nacos、Gateway、Feign、Sentinel
组件选型我基于近两年的稳定版本:注册中心和配置中心用Nacos而不是Eureka加Config,因为Nacos把两者合一,配置可以动态刷新,中文文档也好;网关用Spring Cloud Gateway而不是Zuul,因为Gateway基于WebFlux,性能和生态都比Zuul 1强;服务间调用用OpenFeign;限流熔断用Sentinel,它对Feign有很好的集成。如果项目要推到后面版本,注意版本匹配:SpringBoot 3.x必须搭配Spring Cloud 2022.0.0之后的版本,两者大版本不匹配会直接启动失败。具体版本匹配我放在踩坑章节讲。
yaml复制spring:
application:
name: analysis-service
cloud:
nacos:
discovery:
server-addr: nacos:8848
config:
server-addr: nacos:8848
file-extension: yaml
shared-configs:
- dataId: common-datasource.yaml
这段配置是所有服务共用的基础配置,Nacos里建一个common-datasource.yaml,所有服务动态引用,比每个服务各写一份数据库配置省心很多。
2.4 分布式爬虫任务怎么融入微服务体系
微服务框架搭好后,爬虫不是独立存在的。spider-service把自己注册到Nacos里,通过一个配置开关读取采集任务的启停状态。任务调度用定时任务框架(我用的XXL-Job,也可以直接用Spring Scheduling)把采集任务分片发送到Redis的task队列,多个spider节点(也就是同一个服务多实例部署)消费队列,实现分布式采集。每个节点处理完一批数据后,把原始文本批量发到RabbitMQ。
分布式并行带来的一个问题是重复抓取和计数不准确。我们的解决方案是:任务分片按数据源站点加页面ID取模分配,同一页面只会被一个节点负责;抓取完成后把结果的业务主键写入Redis Set做去重,DB层再加唯一索引兜底。两重保险下来,重复率基本能控制在千分之一以内。
3. 分布式爬虫采集层:并发调度、反爬策略与增量去重
3.1 Java技术栈做爬虫:HttpClient + JSoup的可行性
很多人提到爬虫第一反应是Python,但既然整个系统是SpringCloud技术栈,我不想再引入一套Python服务来增加运维复杂度。用Java写爬虫完全可行:HTTP请求用Apache HttpClient或OkHttp,页面解析用JSoup的CSS Selector和XPath,JSON接口直接用Jackson。对于招聘网站的公开搜索接口,很多返回的就是JSON,直接解析字段比解析HTML还省事。如果你的目标是复杂页面,比如需要执行JavaScript的SPA,那Java这边可以接Selenium或Playwright的Java版本,但会重很多,我的建议是优先找数据接口,不要在渲染页面上硬刚。
java复制// OkHttp异步抓取的简化示例
Request request = new Request.Builder()
.url(buildSearchUrl(city, pageNum))
.header("User-Agent", userAgentPool.random())
.header("Referer", "https://www.example.com/jobs")
.build();
try (Response response = httpClient.newCall(request).execute()) {
String json = response.body().string();
List<RawJob> rawJobs = parseJson(json);
MQProducer.sendRawJobs(rawJobs);
}
3.2 分布式调度与任务分片
采集调度我分成两层。上层是任务中心,定时生成采集任务清单,比如“采集北京后端岗位第1页到第50页”,每个任务带上站点代码、城市、岗位、页码范围。任务放进Redis的LIST结构(LPUSH),每个spider节点用BRPOP从队列取任务,天然实现负载均衡。关键点是任务要能断点续跑:每个任务的状态记录在MySQL里(pending/processing/done/failed),节点处理前先把状态改成processing,处理成功改成done,失败重试三次后改成failed。这样即使某个节点挂了,其他节点也能接管还没完成的任务。
这里有个需要注意的细节:BRPOP是阻塞式取的,如果队列空,线程会一直挂着。我们给BRPOP设置了超时时间,空闲线程等10秒没有新任务就回去干活,避免线程池被空等占满。
3.3 反爬对抗:UA池、代理池、频率控制
反爬不是对抗,是互相给面子。我做了四件事,效果稳定:第一,维护一个常见的浏览器UA池,请求时随机选一个,不要每次都用同一个UA;第二,请求间隔用随机数控制在2到5秒,并发逻辑里对同一域名做信号量限制,保证单域名下最多5个并发;第三,维护一个免费的代理IP池,从公开代理源定时抓取、验证、打分,当目标站点开始返回403或滑块提示时自动切换代理;第四,把采集节奏做成拟人化,比如每个会话的Cookie先访问首页再访问列表页,顺序固定。
Java这边实现代理池也不复杂:用定时任务每半小时抓取一批代理IP,用请求一个固定测试URL的方式验证是否可用,可用就放Redis ZSET里按响应时间排序,取的时候取最快的。当某个IP连续失败超过阈值,就从池里剔除并加入黑名单。
3.4 增量更新与布隆过滤器去重
增量更新的关键是业务主键。招聘职位一般有唯一ID,我们把“站点加职位ID”作为业务主键,每天增量采集时只处理当天新增或状态变化的职位。去重用两级:第一级是Redis的布隆过滤器,只有过滤器判断“可能不存在”的记录才进入第二级——Redis Set精确去重和数据库唯一索引。布隆过滤器有误判率,所以不能单独依赖它,但它的内存占用小,可以挡住99%的重复URL请求,大幅减少无效抓取。
还有一个经验:不要把布隆过滤器当精确工具,它只负责把明显重复的请求挡在最前面。真正的精确去重必须落到数据库的唯一索引上,否则并发情况下两个节点同时抓到同一条数据,很容易漏网。
4. 数据清洗与存储:从脏文本到标准薪资指标
4.1 薪资文本解析规则
清洗服务是整套系统的数据质量枢纽。我遇到的薪资文本五花八门,总结下来有六种典型格式:“10K-20K”、“10-20K·14薪”、“20-30K·15薪”、“面议”、“日薪300-500”、“年薪25-40万”。解析规则我写得比较细:优先提取月薪基础值,再读取年终月数。比如“20-30K·14薪”解析成minMonthly=20000、maxMonthly=30000、midMonthly=25000、months=14。“年薪25-40万”则先除以12,得到月均区间。日薪按22个工作日换算成月薪。“面议”这类无法量化的,直接过滤掉,不进入统计。
java复制public SalaryFields parseSalary(String raw) {
if (raw == null || raw.contains("面议")) return null;
Matcher m = Pattern.compile("(\\d+)-(\\d+)\\s*K").matcher(raw);
if (m.find()) {
int min = Integer.parseInt(m.group(1)) * 1000;
int max = Integer.parseInt(m.group(2)) * 1000;
int months = extractMonths(raw); // 检测14薪、15薪
return new SalaryFields(min, max, (min + max) / 2, months);
}
// 其他分支处理年薪、日薪
}
4.2 岗位、城市、经验字段的标准化
字段标准化比薪资解析更磨人,因为同一个意思有无数种写法。比如岗位:“Java开发工程师”和“Java后端研发”要归到“后端开发”;“Vue前端”“Web前端”归到“前端开发”。我做了一个词典映射表,放在MySQL里管理,清洗时先查词典,查不到再走规则。城市字段问题多是“工作地点:北京·中关村”这种带后缀的,统一用省份/城市关键词取前缀。经验字段统一成区间:“3-5年”直接取原文;“经验不限”归为“应届/不限”;“5年以上”归为“5-10年”。
这里的核心思路是:不要让分析服务去处理脏数据,所有脏活都在清洗层一次性解决。清洗结果写成标准宽表(city, job_category, experience, education, company_type, mid_salary, months),分析服务只查这张宽表,保证统计口径一致。
4.3 存储设计:MySQL、Redis、Elasticsearch的职责划分
存储选型我用了三套:MySQL存清洗后的结构化数据和任务状态,是最权威的数据源;Redis存量薪接口缓存、代理池、去重集合;Elasticsearch存原始文本,支持后续的全文检索和复杂聚合。如果你不想引入ES,可以先不装,MySQL的宽表在百万级数据下配合预聚合表也能撑住。
表结构设计上,我建议至少三张表:raw_job_snapshot(原始快照,保留全量原始字段和抓取时间)、salary_clean_record(清洗后的宽表,带业务主键和唯一索引)、salary_stat_pre_agg(预聚合结果表,按城市/岗位/经验维度存储中位数等指标)。稳定状态后,查询只走预聚合表,原始快照表只用来追溯和重跑清洗,这样在线查询永远很快。
5. 薪资分析服务:统计口径、聚合查询与缓存策略
5.1 平均薪资的陷阱:用中位数和分位数看分布
做薪资统计最容易犯的一个错就是直接算平均。程序员薪资分布是明显的右偏长尾,少数岗位可能拿到普通开发的三到五倍,一平均就把数据拉高了。所以我在分析服务里默认不提供平均薪资,而是计算P25、P50、P75三个分位数。举个例子:某城市后端岗位,平均值可能被拉到28K,但P50可能只有22K,P75是30K。对求职者来说,“一半的人低于22K”比“平均28K”更有参考价值。如果产品上非要展示平均薪资,我会同时展示样本量和分位数,避免误导。
MySQL 8.0以上支持窗口函数,可以直接用PERCENT_RANK或通过子查询算分位数。数据量小的时候一条SQL就能搞定,数据量大了就要走预聚合表。
sql复制SELECT city,
ROUND(AVG(mid_salary), 2) AS avg_salary,
ROUND(MAX(CASE WHEN p <= 0.5 THEN mid_salary END), 2) AS p50_salary
FROM (
SELECT city, mid_salary,
PERCENT_RANK() OVER (PARTITION BY city ORDER BY mid_salary) AS p
FROM salary_clean_record
WHERE job_category = 'backend'
) t
GROUP BY city;
5.2 聚合接口与预计算
分析服务提供三个核心接口:/salary/stat/city(按城市汇总)、/salary/stat/job(按岗位汇总)、/salary/distribution(按薪资区间分布)。这些接口的查询都打在预聚合表上。预聚合表由analysis-service在每天凌晨消费RabbitMQ的clean-data队列后重算一次,核心逻辑就是按城市、岗位、经验三维分组,算各分位数和样本量。为什么不用在线实时聚合?因为爬虫数据是增量进来的,实时聚合意味着每次都要全表扫描或维护复杂的增量统计,开发成本和出错风险都高;而预聚合的结果延迟最多一天,对行业薪资分析完全够用。
5.3 缓存一致性与数据新鲜度
为了降低数据库压力,预聚合表的查询结果会缓存到Redis,key设计成salary:stat:{dimension}:{city}:{job}:{exp},过期时间30分钟。因为预聚合表本身是一天重算一次,所以缓存过期时间设太短没有意义,设太长又可能导致后台更新后用户看不到变化。过期的策略用Redis的主动过期就够,不用搞复杂的分布式锁。还有一个细节:如果某个维度样本量小于30条,我会在结果里标记sampleCount,前端展示时直接过滤或灰显,防止小样本数据误导。“样本量”是薪资分析里容易被忽略但极其重要的字段,没有它,任何排名都可能是噪声。
6. Vue可视化大屏:ECharts图表设计、接口联调与优化
6.1 大屏整体布局与图表选择
可视化选择Vue 3 + Vite + ECharts。大屏整体布局是标准的三栏式:顶部显示系统标题和更新时间;中部左侧放全国城市薪资地图(用ECharts地图加散点效果),中间放核心指标卡(样本量、城市TOP1、岗位薪资中位数),右侧放岗位薪资对比柱状图;下部左侧放经验维度薪资折线图,右侧放企业类型薪资雷达图。颜色统一用深色科技风,因为薪资数据本身偏冷色调,深色背景上橙黄色高亮更容易抓住重点。
6.2 后端聚合接口设计
大屏最忌讳一次请求一个接口,十几个接口并发拉起,页面白屏时间很长。我的做法是设计一个/dashboard/overview聚合接口,一次性返回所有图表需要的数据,前端页面加载时只发一次请求。返回结构包含meta(数据更新时间、总样本量)、cityMap(城市薪资数据)、jobBar(岗位薪资)、expLine(经验薪资)、companyRadar(企业类型数据)。这样的缺点是后端返回的数据量会大一些,但对大屏这种一次性加载的场景来说完全可接受,换来的是前端逻辑简单、渲染可控。
接口字段命名我建议直接用驼峰,前端不做额外映射。配合Vue Router做动态路由,可以给大屏和管理后台共用同一套API权限控制。
6.3 前端实现细节与构建部署
ECharts在Vue3里的使用我是按需引入的,只注册Map、Bar、Line、Radar、Pie这些需要的图表,打包体积能小一半。图表容器用flex布局,每个图表组件在窗口大小变化时监听resize事件并调用chart.resize()。数据轮询方面,因为薪资数据一天只更新一次,我用setInterval每30分钟拉一次/dashboard/overview,不需要WebSocket。构建好的dist目录可以直接扔进SpringBoot的resources/static,也可以独立部署Nginx然后通过网关转发。如果后续你有视频演示的需求,比如播放操作录屏,Vue前端集成一个开源播放器就能解决,但那属于另一个话题了。
这一块的坑主要在ECharts的地图GeoJSON加载,国内地图数据需要额外引入,而且地图的坐标偏移要处理。我建议先用官方GeoJSON,后续再对接专业GIS数据。
7. 部署实践与踩坑整理:从开发机到线上环境
7.1 容器化部署与服务编排
我用Docker Compose把环境一键拉起来,包含Nacos、MySQL、Redis、RabbitMQ、六个服务实例。每个服务都打成Docker镜像,服务之间通过docker-compose的网络互相调用。网关只暴露宿主机端口,其他服务在内部网络。数据库和Redis不开放对外端口,只对内部服务可见。Nacos配置里把数据源地址写成服务名(比如mysql:3306),容器内可以直接解析。
yaml复制services:
gateway:
image: salary/gateway:1.0
ports:
- "8080:8080"
depends_on:
- nacos
- auth
spider-service:
image: salary/spider:1.0
deploy:
replicas: 2
depends_on:
- nacos
- rabbitmq
这个方案适合中小项目,如果节点规模上去了,可以迁移到K8s或者云厂商的容器服务。
7.2 我踩过的几个坑
第一个坑是SpringBoot版本太高和SpringCloud版本不匹配。我用SpringBoot 3.2的时候,如果SpringCloud还是2021版本的项目,启动直接报错,必须用对应的Spring Cloud 2023.0.x或者2022.0.x版本。建议直接用官方initializr初始化,再按官方版本对应关系锁版本。
第二个坑是Feign调用超时。爬虫服务调用清洗服务的接口,如果Feign超时配置不显式调大,线上数据量一大很容易超时。后来我在Feign配置里调大到连接5秒、读取10秒,并且开启服务间的日志,问题才稳定。
第三个坑是Nacos配置动态刷新不生效。配置类里的字段必须加@RefreshScope注解,否则Nacos那边改了配置不会重新加载。这个坑很隐蔽,代码不会报错,但配置就是不生效。
第四个坑是爬虫节点被目标站封IP。一开始并发设太高,10个线程抓同一个站点,跑了几分钟IP就被限制了。后来改成同一域名最多3个并发、请求间隔随机2到5秒,配合代理池回退,才稳定下来。做爬虫,越是分布式的架构,越要控制单位时间内的请求速率,分布式不是让你无脑并发。
第五个坑是大屏偶尔图表空白。原因是接口返回了空数组,ECharts对空数据直接不渲染。处理方式是把接口返回的meta和charts拆开,charts为空时前端展示“数据采集中”占位,而不是白屏。
7.3 后续可以扩展的方向
这套系统还有很多可以加强的地方:接入更多数据源,比如企业招聘官网和开源薪资报告;把数据分析层升级为更完整的BI模块,支持自定义维度下钻;爬虫采集引入更完善的动态渲染支持;给分析服务加上定时任务把结果推送到企业微信或邮件。后期如果条件允许,对接招聘平台的官方API替代爬虫,数据质量和稳定性都会有质的提升。
最后说点个人体会。这类项目最容易翻车的其实不是技术,而是数据质量和对口径的坚持。有一次我发现某个城市薪资中位数突然涨了三千,排查半天是清洗规则把“月薪30K以上”这种上限缺失的文本算成了上限30K,导致聚合偏高。所以做分析型系统,宁可少采数据也要保证清洗可追溯,每条统计结果都要能下钻到样本。这套系统从开发到现在跑了一个多月,每天稳定采集新增职位数据,大屏展示出来的趋势和我的直观感受基本吻合——应届生薪资这几年确实被卷上去了,但P50以下的岗位增长明显慢于头部。如果你也想做类似的东西,建议先从一个城市一个岗位开始跑通全链路,再慢慢加维度。
