Skip to content
Go back

RestTemplate连接池优化——每次new都要三次握手?

RestTemplate 连接池优化:每次 new 都要三次握手?

一句话结论(30s)

默认的 new RestTemplate() 背后是 SimpleClientHttpRequestFactory,每次请求都新建 TCP 连接(三次握手 + 四次挥手),因为高 QPS 下这会每秒堆积几千个 TIME_WAIT 直到端口耗尽,所以生产必须换成 HttpComponentsClientHttpRequestFactory + 连接池,再配齐三种超时和 KeepAlive 策略,才能从”能用”跨到”生产可用”。

核心原理(2min)

底层深入(5-10min)

一个容易被忽略的性能黑洞

@RestController
public class OrderController {
    @Autowired
    private RestTemplate restTemplate;

    @GetMapping("/order/{id}")
    public OrderDTO getOrder(@PathVariable Long id) {
        // 调用用户服务获取用户信息
        UserDTO user = restTemplate.getForObject(
            "http://user-service/users/" + id, UserDTO.class);
        // 调用库存服务获取库存
        StockDTO stock = restTemplate.getForObject(
            "http://inventory-service/stock/" + id, StockDTO.class);
        return new OrderDTO(user, stock);
    }
}

这段代码表面看不出来问题——RestTemplate 是 Spring 的 Bean,默认单例,线程安全。但看底层:

RestTemplate 的默认构造器用的是 SimpleClientHttpRequestFactory,它每次 HTTP 请求都走 URLConnection.openConnection()——也就是说,每次请求都新建一个 TCP 连接,执行完整的三次握手,请求结束后立即关闭连接(四次挥手)。

如果一个微服务每秒处理 1000 个请求,每个请求又去调用 3 个外部服务 → 每秒新建 3000 个 TCP 连接 → 3000 次三次握手 → 3000 次四次挥手 → TIME_WAIT 状态堆积 → 端口耗尽。

想一想:为什么每次新建连接会导致端口耗尽? 因为主动关闭连接的一方会进入 TIME_WAIT 状态,端口要等 2MSL(约 60 秒)才能被复用。每秒 3000 次新建连接,TIME_WAIT 的端口会越积越多,一旦超过可用端口数(默认约 28232 个),新连接就建不起来,服务直接不可用。这就是”能用”和”生产可用”的差距——默认 RestTemplate 的每次 new 都在快速逼近这个上限。

换到连接池:HttpComponentsClientHttpRequestFactory

Spring 提供了替代实现——基于 Apache HttpClient 的连接池:

@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        CloseableHttpClient httpClient = HttpClients.custom()
            .setConnectionManager(poolingConnectionManager())
            .setKeepAliveStrategy(keepAliveStrategy())
            .evictExpiredConnections()  // 定期清理过期连接
            .build();

        HttpComponentsClientHttpRequestFactory factory =
            new HttpComponentsClientHttpRequestFactory(httpClient);

        // 不设 connectTimeout(在 PoolingHttpClientConnectionManager 设)
        return new RestTemplate(factory);
    }

    private PoolingHttpClientConnectionManager poolingConnectionManager() {
        PoolingHttpClientConnectionManager cm =
            new PoolingHttpClientConnectionManager();

        cm.setMaxTotal(200);              // 全局最大连接数
        cm.setDefaultMaxPerRoute(50);     // 每个路由(host)最大连接数

        // 连接超时配置
        cm.setDefaultConnectionConfig(
            ConnectionConfig.custom()
                .setConnectTimeout(3000)      // 建连接超时:3 秒
                .setSocketTimeout(5000)       // 读数据超时:5 秒
                .build()
        );

        return cm;
    }
}

maxTotal 和 maxPerRoute 怎么算?

maxTotal = 目标 QPS × 平均响应时间(秒) × (1 + 缓冲系数)

例子:被测服务 500 QPS,平均响应时间 200ms
maxTotal = 500 × 0.2 × 1.5 = 150
maxPerRoute = maxTotal / 下游服务数量

例子:你调用 5 个外部服务 → 200 / 5 = 40

想一想:为什么 maxTotal 太小和太大都会出问题? 因为连接数本质是”并发在途请求数”的上限——太小,请求排队等连接、响应时间飙升;太大,空闲连接占满文件描述符(Linux 默认 1024),触发 “Too many open files”。所以它必须按 目标 QPS × 平均响应时间 × (1+缓冲) 算,而不是拍脑袋填一个很大的数。

三种超时,一个都不能少

HTTP 调用有三个超时维度,漏配任何一个都会在特定场景下爆雷:

// 1. connectTimeout:TCP 连接建立超时
// 如果服务不可达(DOWN),客户端等多久才放弃?
ConnectionConfig.custom()
    .setConnectTimeout(3000)  // 3 秒,不宜设太长

// 2. socketTimeout(readTimeout):等数据返回超时
// 连接建立了,但服务端处理慢,等多久?
ConnectionConfig.custom()
    .setSocketTimeout(5000)   // 5 秒

// 3. connectionRequestTimeout:从连接池借连接的超时
// 所有连接都在忙,借不到连接,等多久?
PoolingHttpClientConnectionManager cm = ...;
// 3. connectionRequestTimeout 是通过 RequestConfig 配置的:
RequestConfig requestConfig = RequestConfig.custom()
    .setConnectionRequestTimeout(1000)  // 1 秒借不到就超时
    .build();

典型的事故链

  1. 下游服务 GC 停顿 5 秒 → socketTimeout = 无限制 → 所有调用者线程阻塞 → 连接池耗尽 → 新请求全超时 → 整个服务雪崩。

设了 socketTimeout = 5000ms:5 秒后超时返回异常 → 连接释放回池 → 降级逻辑兜底 → 服务存活。

想一想:为什么三种超时里 socketTimeout 最容易被忽略又最致命? 因为连接建立成功、请求也发出去了,很多人就以为”万事大吉”,忘了”等数据返回”这一环。可如果下游服务 GC 停顿 5 秒,没有 socketTimeout 的调用线程就会无限阻塞,连接不释放 → 池被耗尽 → 新请求全超时 → 整个服务雪崩。所以 socketTimeout 是防”被下游拖垮”的关键防线。

KeepAlive 策略:连接复用才是精髓

连接池的初衷不是”管理连接”——是复用连接。TCP 三次握手 + TLS 握手(如果是 HTTPS)耗时 50-200ms——如果每个请求都重新建连,这 200ms 就是纯浪费。

private ConnectionKeepAliveStrategy keepAliveStrategy() {
    return (response, context) -> {
        // 1. 优先使用服务端返回的 Keep-Alive header
        HeaderElementIterator it = new BasicHeaderElementIterator(
            response.headerIterator(HTTP.CONN_KEEP_ALIVE));
        while (it.hasNext()) {
            HeaderElement element = it.nextElement();
            String name = element.getName();
            String value = element.getValue();
            if (value != null && name.equalsIgnoreCase("timeout")) {
                return Long.parseLong(value) * 1000;  // 秒转毫秒
            }
        }

        // 2. 服务端没返回 Keep-Alive,用默认值
        //    服务A(高频轻量查询):保持 30 秒
        //    服务B(低频导出):保持 5 秒

        // 3. 兜底:30 秒后关闭空闲连接
        return 30_000;
    };
}

策略要点:

想一想:为什么 KeepAlive 绝对不要设 -1(永不过期)? 因为连接池复用的是”对端还活着的连接”,一旦服务端重启,旧的 TCP 连接全被关闭,而客户端还傻乎乎地复用这些死连接,所有请求都会失败。给连接一个有限的存活时间,才能让池子周期性地淘汰旧连接、建立新连接,避免”拿着过期地图导航”。

连接泄漏:最隐蔽的性能杀手

// ❌ 连接泄漏!ResponseEntity 的 body 没被消费 → 连接不回池
ResponseEntity<String> entity = restTemplate.getForEntity(url, String.class);
// 忘了 entity.getBody()
// → 连接一直被占用 → 连接池被耗尽

// ✅ 正确:用 execute 或确保消费 body
String result = restTemplate.getForObject(url, String.class);
// getForObject 内部自动消费了 InputStream,连接自动回池

或者用 RestTemplate.execute() + ResponseExtractor,保证在 finally 中消费掉 InputStream。

完整生产级配置

@Bean
public RestTemplate restTemplate() {
    // 1. Socket 工厂(连接配置)
    ConnectionSocketFactory socketFactory =
        new PlainConnectionSocketFactory();

    // 2. 连接管理器
    PoolingHttpClientConnectionManager cm =
        new PoolingHttpClientConnectionManager(
            RegistryBuilder.<ConnectionSocketFactory>create()
                .register("http", socketFactory)
                .build()
        );
    cm.setMaxTotal(200);
    cm.setDefaultMaxPerRoute(50);
    cm.setDefaultConnectionConfig(ConnectionConfig.custom()
        .setConnectTimeout(3000)
        .setSocketTimeout(5000)
        .build());
    cm.setValidateAfterInactivity(10000);  // 10 秒空闲后验证连接可用性

    // 3. HTTP 客户端
    CloseableHttpClient httpClient = HttpClients.custom()
        .setConnectionManager(cm)
        .setKeepAliveStrategy(keepAliveStrategy())
        .evictExpiredConnections()
        .evictIdleConnections(30, TimeUnit.SECONDS)
        .setRetryHandler(new DefaultHttpRequestRetryHandler(2, true))
        .build();

    // 4. RequestFactory + RestTemplate
    HttpComponentsClientHttpRequestFactory factory =
        new HttpComponentsClientHttpRequestFactory(httpClient);
    // 不设 factory 级别的 timeout(cm 里已经设了)

    return new RestTemplate(factory);
}

总结

RestTemplate 连接池优化的核心只有四行配置——但每一行背后都是线上事故的教训:

配置解决什么问题不配的后果
connectTimeout=3000服务不可达时快速失败请求线程无限阻塞
socketTimeout=5000服务端慢响应连接不释放 → 池耗尽 → 雪崩
maxTotal=200限制总连接数文件描述符耗尽
keepAliveStrategy复用连接每次请求都三次握手

不要用默认的 new RestTemplate()——它的背后是 SimpleClientHttpRequestFactory,一个在生产环境绝对不够用的玩具实现。至少换成 HttpComponentsClientHttpRequestFactory + 连接池——这是从”能用”到”生产可用”的最低门槛。

章末提问

Q1:为什么默认 new RestTemplate() 是性能黑洞? 结论先行:因为它的默认构造器用 SimpleClientHttpRequestFactory,每次请求都 URLConnection.openConnection() 新建 TCP 连接、用完即关。 因为新建连接意味着完整的三次握手 + 四次挥手,高 QPS 下每秒几千次握手挥手会产生大量 TIME_WAIT,端口很快耗尽;而且 TCP/TLS 握手本身要 50-200ms,纯属浪费。所以生产必须换连接池实现,复用连接。

Q2:三种超时分别解决什么问题?漏配 socketTimeout 会怎样? 结论先行:connectTimeout 防”服务不可达时无限等”,socketTimeout 防”服务端慢响应拖垮自己”,connectionRequestTimeout 防”借不到连接时无限等”;漏配 socketTimeout 会连锁雪崩。 因为下游一旦 GC 停顿或变慢,没有 socketTimeout 的线程会一直阻塞等数据、连接不释放,池被耗尽后新请求也全超时,最后整个服务被一个慢下游拖垮。所以 socketTimeout 是三条防线里最不能省的一条。

Q3:RestTemplate 的连接泄漏是怎么发生的?怎么避免? 结论先行:getForEntity 拿到 ResponseEntity 后如果不消费 body,底层 InputStream 没被关闭,连接就一直占着不回池,池被耗尽。 因为连接池复用连接的前提是”响应体被读干净并关闭”;body 没消费,连接就处于占用状态。避免方式是用 getForObject(内部自动消费)或用 execute + ResponseExtractor 在 finally 里保证消费掉 InputStream。


Share this post on:

Previous Post
Segmented分段锁——从单锁串行到10倍并发的演进
Next Post
Nginx vs Tomcat——为什么有了Nginx还要用Tomcat?