深入理解技术探索与实践:一个双休不加班国企程序员的 Spring Boot 面试题挑战实录

★张勇
2025-12-14 02:55
阅读 722

上周五下班前,我正戴着 AirPods 听着周杰伦的《七里香》,一边敲代码一边盘算着周末要不要去大梅沙冲浪(虽然大概率是躺在沙发上打原神)。突然企业微信弹出一条消息:“下周团队内部搞个 Spring Boot 面试题挑战赛,每人出一道题,其他人现场手撕。”

我第一反应是:又来?

在我们这个坐标深圳、紧挨腾讯滨海大厦的“准互联网”国企,技术氛围其实挺微妙的。说是国企,但项目节奏比某些创业公司还紧;说要双休不加班,可每次大促(比如去年双11)还是得盯着 Grafana 看到凌晨两点——当然,第二天会调休,这点倒是说到做到。

但这次“面试题挑战”,说实话,让我有点懵。不是因为不会,而是……太熟悉反而不知道从哪下手了。Spring Boot 用得跟呼吸一样自然,但真要深挖原理,还真得翻翻源码、跑跑实验。

于是,我决定借这个机会,把最近项目里踩的一个坑,包装成一道“看起来简单实则暗藏玄机”的面试题,顺便写篇博客记录下整个探索过程。毕竟,技术这东西,不动手,永远只是“好像懂了”


起因:一个诡异的内存泄漏

事情发生在上个月,我们团队在重构一个用户行为埋点服务。老系统用的是 Spring Boot 2.3 + Redis + Kafka,新系统升级到 2.7,加了响应式编程(Reactor),结果上线后监控报警:堆内存持续增长,GC 频繁,但回收不了多少

运维小哥在群里@我:“兄弟,你这服务是不是有内存泄漏?Prometheus 显示 Old Gen 快满了。”

我当时心里一咯噔——完了,周五刚提测,周一就要灰度,这锅背不起啊!

打开 Arthas,heapdump 一下,MAT 分析发现:大量 org.springframework.web.reactive.function.client.WebClient 的实例没被回收,每个都持有一个 reactor.netty.http.client.HttpClient,而它又引用了一堆连接池资源……

WebClient 是单例啊!怎么会创建这么多实例?

翻代码一看,罪魁祸首在这:

@Service
public class EventForwardService {

    public Mono<Void> forwardEvent(Event event) {
        // ❌ 千万别这么干!
        WebClient client = WebClient.builder()
                .baseUrl("https://downstream-api.example.com")
                .build();

        return client.post()
                .uri("/events")
                .bodyValue(event)
                .retrieve()
                .bodyToMono(Void.class);
    }
}

看到没?每次调用 forwardEvent,都 new 了一个新的 WebClient。虽然 WebClient 本身轻量,但它底层依赖 Netty 的 ConnectionProvider,而默认的 provider 是 全局共享、带连接池的。更坑的是,这个连接池不会随 WebClient 实例销毁而释放

结果就是:每秒几千次调用,每秒几千个 WebClient 实例 + 几千个隐式连接池引用 → 内存爆炸。

这就是典型的“看似无害,实则致命”的 API 误用。


探索:WebClient 到底该怎么用?

于是我开始翻 Spring 官方文档、GitHub Issues、Stack Overflow,甚至去扒了 Reactor Netty 的源码。

结论很明确:WebClient 必须作为 Bean 注入,且应复用。官方文档里其实写了:

WebClient is designed to be reused. It holds resources like connection pools that are expensive to create.

但问题来了:如果下游接口的 baseUrl 不固定怎么办? 比如我们的埋点服务要发给不同业务线的接收端,URL 动态变化。

这时候,有两种主流方案:

方案一:预定义多个 WebClient Bean(适合有限、固定的下游)

@Configuration
public class WebClientConfig {

    @Bean("marketingWebClient")
    public WebClient marketingWebClient() {
        return WebClient.builder()
                .baseUrl("https://marketing-api.example.com")
                .build();
    }

    @Bean("analyticsWebClient")
    public WebClient analyticsWebClient() {
        return WebClient.builder()
                .baseUrl("https://analytics-api.example.com")
                .build();
    }
}

优点:简单直接,连接池隔离。
缺点:URL 一多就爆炸,维护成本高。

方案二:动态构建 URI,复用同一个 WebClient(推荐!)

这才是正确姿势:

@Service
public class EventForwardService {

    // ✅ 单例注入,全局复用
    private final WebClient webClient;

    public EventForwardService(WebClient.Builder builder) {
        this.webClient = builder.build();
    }

    public Mono<Void> forwardEvent(Event event, String targetUrl) {
        // 动态拼接完整 URL,WebClient 只负责发起请求
        return webClient.post()
                .uri(targetUrl) // ← 注意:这里是完整 URL
                .bodyValue(event)
                .retrieve()
                .bodyToMono(Void.class);
    }
}

关键点在于:不要用 baseUrl(),而是直接传完整 URL 到 uri()。这样,同一个 WebClient 实例可以安全地发往任意域名,底层 Netty 连接池会按 host+port 自动复用连接。

为了验证效果,我写了个 JMeter 脚本压测 10 分钟:

方案 内存峰值 (MB) Full GC 次数 平均响应时间 (ms)
每次新建 WebClient 1850 23 45
复用 WebClient(动态 URL) 620 2 32

内存直接降了 2/3,GC 压力骤减,响应还更快了。

搞定那一刻,我差点在工位上跳起来——终于不用背线上事故的锅了!


延伸:这道题能考什么?(面试题设计思路)

回到开头那个“面试题挑战”,我就打算拿这个场景出题:

题目:某 Spring Boot 应用使用 WebClient 发送 HTTP 请求,但发现内存持续增长,Full GC 频繁。请分析可能原因,并给出正确用法。

你以为这只是考 WebClient?其实背后能挖出至少五个层次的知识点:

  1. 基础用法:是否知道 WebClient 应该单例?
  2. 原理理解:是否了解其底层依赖 Reactor Netty 和连接池机制?
  3. 调试能力:能否通过 MAT、Arthas 定位内存泄漏对象?
  4. 设计权衡:面对动态 URL 场景,如何设计可复用的客户端?
  5. 最佳实践:是否知道 WebClient.Builder 应通过构造器注入,而非直接 new

更狠一点,还可以追问:

  • “如果下游服务 TLS 证书不一致,怎么处理?”
  • “如何为不同下游设置不同的超时策略?”
  • “连接池参数怎么调优?”

这些问题,光背八股文是答不好的,必须结合真实项目经验


安全意识:别让“便利”变成“漏洞”

说到安全,其实 WebClient 误用还有个隐藏风险:DNS Rebinding 攻击

假设你的服务允许前端传入任意 URL(比如 ?redirectUrl=https://evil.com),然后你直接用这个 URL 构造 WebClient 请求:

// ⚠️ 危险!
webClient.get().uri(userInputUrl).retrieve()...

攻击者就可以构造一个指向内网地址的域名(如 http://192.168.1.1/admin),让你的服务去探测内网接口,甚至 SSRF 攻击!

解决方案

  1. 严格校验 URL 域名白名单
  2. 禁用 WebClient 的自动重定向(默认是关闭的,但最好显式设置)
  3. 限制请求目标 IP 不能是私有地址段

示例加固代码:

private boolean isValidTarget(String url) {
    try {
        URI uri = new URI(url);
        InetAddress addr = InetAddress.getByName(uri.getHost());
        // 拒绝私有 IP、回环地址等
        return !addr.isAnyLocalAddress() 
            && !addr.isLoopbackAddress()
            && !addr.isLinkLocalAddress()
            && !addr.isSiteLocalAddress();
    } catch (Exception e) {
        return false;
    }
}

在我们国企,安全合规是红线。每次代码评审,安全部门都会扫这类问题。技术探索不能只追求性能,安全永远是底线。


总结:技术探索的本质是“动手 + 思考”

折腾完这一通,我对 Spring Boot 的理解又深了一层。以前觉得“会用就行”,现在明白:框架再封装,底层原理不清,迟早要踩坑

而且,这种探索带来的成就感,远比调完一个 UI 动画(虽然我也爱搞 Lottie)来得强烈。毕竟,解决一个线上内存泄漏,可是能写进年终述职报告的硬核战绩

最后,给正在看这篇博客的你几点建议:

  • 别迷信“开箱即用”,多看看官方文档的 Caveats 部分
  • 遇到性能问题,先 dump 再猜,数据比直觉可靠
  • 把每次故障当作学习机会——反正我们国企有双休,有的是时间复盘(手动狗头)

对了,下周的面试题挑战,我已经准备好答案了。不过嘛……我打算在题目里埋个新坑,比如故意用 block() 在 WebFlux 里,看看有没有人能 spot 出来 😏


P.S. 如果你在深圳,又刚好对前端动画和后端性能优化都感兴趣,欢迎来我们 team 玩(不加班是真的,团建去大鹏吃海鲜也是真的)。简历可发至:not.real.email@example.com(假的,别试了,我们招人走官网流程 😂)

评论 0

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