一个全栈的代码人生:在资源夹缝中摸爬滚打的技术探索
上周五晚上十一点,我瘫在工位上,盯着屏幕上 Spring Boot 应用疯狂打印的 OutOfMemoryError 日志,心里只有一句:这破项目怎么又崩了?
产品经理明天早上就要看数据报表,而我刚写完的爬虫模块把整个服务拖垮了。那一刻,我真的想拔电源跑路。
但作为一个在上海租房、离公司步行十分钟、平时靠参加 Meetup 续命的创业公司全栈开发,跑是不可能跑的——房租还没交呢。
今天这篇文章,就聊聊我在“资源极其有限”的现实夹缝中,如何一边写业务代码,一边搞技术探索的血泪史。关键词不多,就四个:资源、Spring Boot、代码人生、爬虫。听起来很普通?但正是这些“普通”的东西,构成了我们大多数人的日常。
起因:老板说“我们要做竞品监控”
事情得从去年底说起。公司是个典型的小微创业团队,12 个人,前端 2 个,后端 3 个(包括我),产品 2 个,剩下是运营和销售。我们的核心产品是一个 SaaS 工具,主打中小企业的营销自动化。
某天晨会,老板突然拍板:“我们要监控竞品价格动态!下周上线!”
我内心 OS:你当我是神吗?竞品网站有反爬、有验证码、还用了 Cloudflare,连公开 API 都没有!
但没办法,创业公司嘛,需求永远比资源来得快。而且老板补了一句:“别买新服务器,就用现有的那台 4C8G 的云主机。”
资源,永远是我们技术决策的第一约束条件。
技术选型:为什么还是选了 Spring Boot?
很多人一听到“爬虫”,第一反应是 Python + Scrapy。确实,Python 写爬虫快、生态好、社区活跃。但问题来了:
- 我们的主系统是 Java 技术栈,用的是 Spring Boot
- 团队里没人专职维护 Python 服务
- 新起一个 Python 服务意味着要配 CI/CD、日志收集、监控告警……运维成本直接翻倍
作为那个“啥都干”的全栈,我得考虑长期可维护性。于是咬牙决定:用 Spring Boot 写爬虫。
我知道这听起来有点反直觉。Java 启动慢、内存大,写网络请求不如 Python 优雅。但在资源受限的环境下,减少技术栈数量 = 减少故障点 = 活得更久。
而且 Spring Boot 其实没那么笨重——只要你不乱加 starter,合理配置 JVM 参数,一个轻量级爬虫服务完全可以跑在 512MB 堆内存里。
实战:一个能扛住 OOM 的 Spring Boot 爬虫长啥样?
先说目标:每天定时抓取 5 个竞品网站的商品价格页,解析 HTML 提取价格,存入数据库。
第一版:天真地用 RestTemplate + Jsoup
@Service
public class PriceCrawler {
private final RestTemplate restTemplate = new RestTemplate();
public void crawl(String url) {
String html = restTemplate.getForObject(url, String.class);
Document doc = Jsoup.parse(html);
String price = doc.select(".price").first().text();
// 存 DB
}
}
看起来没问题?上线三天后,OOM 了。
原因:RestTemplate 默认使用 SimpleClientHttpRequestFactory,底层是 HttpURLConnection,不支持连接池。每次请求都会新建 TCP 连接,不及时释放。加上 Jsoup 解析大 HTML 时占用大量堆内存,很快就把 8G 内存吃光。
第二版:引入 HttpClient 连接池 + 异步处理
@Configuration
public class HttpClientConfig {
@Bean
public CloseableHttpClient httpClient() {
return HttpClients.custom()
.setMaxConnTotal(20)
.setMaxConnPerRoute(10)
.evictIdleConnections(30, TimeUnit.SECONDS)
.build();
}
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory(httpClient());
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
return new RestTemplate(factory);
}
}
同时,把爬虫任务改造成异步:
@Async
public CompletableFuture<Void> crawlAsync(String url) {
try {
// 爬取 + 解析
saveToDb(price);
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
log.error("Crawl failed for {}", url, e);
return CompletableFuture.failedFuture(e);
}
}
再配合合理的线程池配置:
spring:
task:
execution:
pool:
core-size: 4
max-size: 8
queue-capacity: 16
这一波优化后,内存使用从峰值 7.5G 降到稳定在 2.1G,CPU 也平稳了。
资源博弈:爬虫频率 vs 服务器负载
但问题没完。产品经理又提需求:“能不能每小时抓一次?现在一天一次太慢了。”
我看了看服务器监控面板,心一沉:现在的 4C8G 已经跑了 Web 服务、MySQL、Redis、Nginx,再提高爬虫频率,怕是要雪崩。
怎么办?三个方案摆在面前:
| 方案 | 优点 | 缺点 | 资源消耗 |
|---|---|---|---|
| 增加爬虫并发数 | 快速响应需求 | 内存/CPU 飙升,可能拖垮主服务 | ⚠️ 高 |
| 外包给第三方爬虫服务 | 稳定可靠 | 成本高(每月几千),数据隐私风险 | 💰 高 |
| 优化调度策略 + 缓存 | 成本低,可控 | 开发量稍大 | ✅ 低 |
我选了第三个。毕竟,创业公司的代码人生,本质是一场资源精算游戏。
具体做法:
- 按优先级调度:只对核心商品高频抓取(每小时),非核心商品保持每日一次
- 结果缓存:如果价格未变,跳过数据库写入
- 失败重试+退避:避免因临时网络抖动反复重试压垮自己
关键代码片段:
// 使用 Guava Cache 做本地缓存,避免重复存储相同价格
private final LoadingCache<String, String> priceCache =
CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build(new CacheLoader<>() {
@Override
public String load(String key) {
return fetchFromDb(key); // 从 DB 查历史价格
}
});
public void processPrice(String productId, String newPrice) {
String cached = priceCache.getIfPresent(productId);
if (cached != null && cached.equals(newPrice)) {
log.info("Price unchanged, skip saving: {}", productId);
return;
}
saveToDb(productId, newPrice);
priceCache.put(productId, newPrice);
}
这招一出,数据库写入量减少了 68%,CPU 负载下降明显。
踩坑记录:那些让我半夜惊醒的 Bug
当然,过程不可能一帆风顺。分享两个经典翻车现场:
1. User-Agent 被识别为机器人
某天爬虫突然全部返回 403。查了半天,发现对方更新了风控策略,检测到固定 User-Agent 就封 IP。
解决方案:动态轮换 UA
private static final List<String> USER_AGENTS = Arrays.asList(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36...",
// ... 更多真实 UA
);
public HttpHeaders buildHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.set("User-Agent",
USER_AGENTS.get(new Random().nextInt(USER_AGENTS.size())));
return headers;
}
2. DNS 缓存导致切换 CDN 失败
有一次竞品网站切换了 CDN,IP 变了,但我的服务还在连旧 IP,持续超时。
原因是 JVM 默认会永久缓存 DNS 记录(除非配置 -Dsun.net.inetaddr.ttl=60)。
在生产环境不能随便改 JVM 参数,于是我改用 OkHttp 替代 HttpClient,因为 OkHttp 默认会定期刷新 DNS。
// OkHttp 自带 DNS 刷新机制,比 Apache HttpClient 更现代
OkHttpClient client = new OkHttpClient.Builder()
.dns(Dns.SYSTEM) // 使用系统 DNS,带 TTL
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
后来我把整个 HTTP 客户端从 RestTemplate + HttpClient 切到了 WebClient + OkHttp,性能和稳定性又上了一个台阶。
代码人生的反思:技术探索不是炫技
写到这里,可能有人会问:为什么不用现成的爬虫框架?比如 WebMagic 或 Crawler4j?
答案很简单:在资源极度受限的创业公司,最小可行方案 > 完美架构。
我花三天时间手撸一个轻量爬虫,比集成一个重型框架更可控。而且,亲自下场写每一行代码,才能真正理解底层发生了什么——这也是我热衷研究原理的原因。
上周参加上海一个 Spring Boot 分享会,有个大厂同学说他们用 Kafka + Flink 做实时爬虫管道。听起来很酷,但对我们来说,Kafka 本身就要至少 3 台机器集群,光运维成本就劝退。
技术没有高低,只有适不适合。
总结:在夹缝中开出花
回看这段经历,我其实挺感谢那个“不讲武德”的需求。它逼我深入理解了:
- Spring Boot 如何控制资源消耗
- HTTP 客户端底层连接管理机制
- JVM 内存与 GC 调优的实际场景
- 如何在有限资源下做技术权衡
更重要的是,它让我意识到:所谓“全栈”,不是什么都会,而是在任何限制条件下,都能找到一条可行的路。
现在的爬虫模块已经稳定运行三个月,每天处理 2000+ 页面,内存占用 <2.5G,错误率 <0.5%。虽然代码不够“高级”,但足够健壮。
昨天产品经理又来找我:“能不能再加个功能,抓评论?”
我笑了笑:“行啊,不过得加钱买服务器。”
他愣了一下,然后说:“算了,先这样吧。”
你看,有时候,技术人的价值,不只是写代码,更是帮业务看清现实。
这就是我的代码人生——在资源的钢丝上跳舞,偶尔摔跤,但从不认输。
如果你也在创业公司摸爬滚打,欢迎留言交流。或者,哪天在上海 Meetup 见,我请你喝杯瑞幸(反正公司报销)。

评论 0