一个全栈的代码人生:在资源夹缝中摸爬滚打的技术探索

刘浩宇
2025-12-29 00:18
阅读 800

上周五晚上十一点,我瘫在工位上,盯着屏幕上 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 飙升,可能拖垮主服务 ⚠️ 高
外包给第三方爬虫服务 稳定可靠 成本高(每月几千),数据隐私风险 💰 高
优化调度策略 + 缓存 成本低,可控 开发量稍大 ✅ 低

我选了第三个。毕竟,创业公司的代码人生,本质是一场资源精算游戏

具体做法:

  1. 按优先级调度:只对核心商品高频抓取(每小时),非核心商品保持每日一次
  2. 结果缓存:如果价格未变,跳过数据库写入
  3. 失败重试+退避:避免因临时网络抖动反复重试压垮自己

关键代码片段:

// 使用 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝