Skip to content
Charles Shao
Go back

连接与 I/O 模型:线程、池化、多路复用与背压

views

压测一个高并发服务,最先触发的告警往往不是 CPU 使用率,而是线程池满(RejectedExecutionException)、数据库连接池耗尽(Connection timeout waiting for connection from pool)、文件描述符超限(Too many open files)。CPU 还在 30% 的时候,连接和线程已经成了硬瓶颈。

理解 I/O 模型和连接池,是在不换机器的前提下把系统吞吐再翻一倍的关键——也是面试高并发系统设计时最容易被追问的细节层。

TL;DR

Table of contents

Open Table of contents

1. 为什么连接和线程先于 CPU 成为瓶颈

服务器处理一个请求的生命周期大致是:接受连接 → 读取请求 → 业务逻辑(内存计算)→ 调用下游(数据库/Redis/HTTP)→ 写回响应。

在这个生命周期中,业务逻辑往往只占几毫秒,而等待下游响应可能占 80%~90% 的时间。如果用阻塞 I/O 模型,线程在等待期间什么都不做,但仍然占用内存和操作系统资源。

以 Java 为例:

这就是为什么在 CPU 使用率还不高的时候,线程池和连接数就先耗尽了:等待时间让单位线程的实际有效利用率极低,要撑起高并发就需要大量线程,而大量线程本身就是资源消耗的瓶颈。

2. I/O 模型演进

2.1 BIO:一连接一线程

最直观的模型:每个客户端连接分配一个专用线程,线程同步地读写 Socket,等待数据时阻塞。

Client 1 ──→ Thread-1 (阻塞等待 I/O)
Client 2 ──→ Thread-2 (阻塞等待 I/O)
Client 3 ──→ Thread-3 (阻塞等待 I/O)
...
Client N ──→ Thread-N (阻塞等待 I/O)

问题:并发数 = 线程数,线程数受内存和 OS 限制,通常几百到几千;等待期间线程资源被完全锁定。适用于并发连接数少(< 数百)的场景,如内部工具或 CLI 服务。

2.2 线程池 + BIO

对 BIO 的改进:维护一个线程池,接受连接的主线程把连接分配给线程池中的工作线程,限制了最大并发数,避免无限创建线程。

Accept Thread ──→ [Thread Pool (固定大小)] ──→ 处理连接

               线程耗尽时请求排队或被拒绝

问题:仍然是”等待时线程被占用”;线程池满时新请求被拒绝或排队,队列无限增长会导致响应延迟飙升。这是大多数传统 Java Web 服务(Tomcat BIO 模式)的架构。

BIO vs NIO:一连接一线程 vs 单 Selector 多路复用管理大量连接 BIO 中并发数受线程数硬限制;NIO Reactor 用少量线程监控所有连接,线程仅在数据就绪时工作。

2.3 NIO + 多路复用(Reactor 模型)

核心思想:把”等待 I/O 就绪”和”处理 I/O 数据”分离。用极少量线程(可以是 1 个)通过 select / poll / epoll 系统调用同时监控多个连接,哪个连接有数据可读/可写就通知对应的处理逻辑,不就绪则继续监控其他连接。

              ┌─────────────────────────────────────┐
              │  Selector(epoll)                   │
连接 1 ───────┤  监控 N 个 Channel,等待任意一个就绪  │
连接 2 ───────┤                                     │
连接 3 ───────┤                                     │
  ...         │  就绪事件 → 分发给 Worker Thread      │
连接 N ───────┘

关键指标:单个 Reactor 线程可以管理数万甚至数十万个连接(epoll 在 Linux 上是 O(1) 就绪事件通知);Worker 线程只在有实际数据时才被使用,利用率接近 100%。

代价:编程模型从同步转为事件驱动/回调,代码复杂度显著上升;Worker 线程内不能执行阻塞操作(如阻塞式数据库调用),否则会阻塞整个 Reactor 线程的处理。

2.4 Netty:生产可用的 Reactor 实现

Netty 把 NIO + Reactor 封装成了高可用的框架,并在此基础上加入了:

在广告系统中,Netty 常用于实现高并发的 HTTP/2 或自定义二进制协议的竞价接入层(Bidder Gateway),单机处理数万 QPS 的同时保持 P99 延迟在毫秒级。

一个最小可运行的 ServerBootstrap 骨架,展示 Boss / Worker 两个 EventLoopGroup 的分工:

// Boss:只负责 accept,1 个线程足够
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
// Worker:处理已建立连接的 I/O,默认 = CPU 核数 × 2,此处显式设定
EventLoopGroup workerGroup = new NioEventLoopGroup(
    Runtime.getRuntime().availableProcessors() * 2);

try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup)
     .channel(NioServerSocketChannel.class)
     .option(ChannelOption.SO_BACKLOG, 1024)          // accept 队列长度
     .childOption(ChannelOption.TCP_NODELAY, true)    // 禁用 Nagle,降低尾延迟
     .childOption(ChannelOption.SO_KEEPALIVE, true)
     .childHandler(new ChannelInitializer<SocketChannel>() {
         @Override
         protected void initChannel(SocketChannel ch) {
             ch.pipeline()
               .addLast(new HttpServerCodec())        // 编解码
               .addLast(new HttpObjectAggregator(65536))
               .addLast(new BidHandler());            // 业务 Handler(非阻塞)
         }
     });

    ChannelFuture f = b.bind(8080).sync();
    f.channel().closeFuture().sync();
} finally {
    bossGroup.shutdownGracefully();
    workerGroup.shutdownGracefully();
}

关键点BidHandler 运行在 Worker EventLoop 线程上,其中的任何阻塞操作都会卡住该 Loop 所绑定的全部连接。因此凡是可能阻塞的调用(JDBC、同步 HTTP、磁盘日志),都必须卸载到独立线程池。

Reactor 模式(Netty 架构):Acceptor 接受连接 → Selector 分发事件 → Worker 线程池 → Handler 责任链 Boss Group 只接受连接,Worker Group 处理 I/O 读写;Handler Pipeline 以责任链处理编解码与业务逻辑。

2.5 把阻塞调用卸载到独立线程池

当业务逻辑中不可避免地存在阻塞调用(例如遗留的同步 JDBC、第三方 SDK),正确做法是把它提交到独立的业务线程池执行,用 CompletableFuture 拿到结果后,切回原 EventLoop 线程写回响应——绝不在 I/O 线程里直接跑阻塞代码:

// 独立业务线程池:与 Netty EventLoop 完全隔离,专门承接阻塞调用
private static final ExecutorService businessGroup =
    new ThreadPoolExecutor(
        32, 64,                                       // core / max
        60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(2000),              // 有界队列,满则触发拒绝策略
        new ThreadFactoryBuilder().setNameFormat("biz-%d").build(),
        new ThreadPoolExecutor.CallerRunsPolicy());   // 背压:队列满时不再无限堆积

class BidHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
        final EventLoop loop = ctx.channel().eventLoop();  // 记住当前 I/O 线程
        // 阻塞的 JDBC 查询卸载到 businessGroup,绝不在 EventLoop 上执行
        CompletableFuture
            .supplyAsync(() -> queryUserProfileByJdbc(req), businessGroup)
            .orTimeout(20, TimeUnit.MILLISECONDS)          // 严格超时,保护尾延迟
            .whenComplete((profile, err) -> loop.execute(() -> {  // 切回 EventLoop
                FullHttpResponse resp = (err == null)
                    ? buildBidResponse(profile)
                    : buildNoBid();                        // 超时/异常 → 降级为 no-bid
                ctx.writeAndFlush(resp);
            }));
    }
}

要点businessGroup 使用有界队列 + CallerRunsPolicy,把背压反馈给调用方而非无限堆积;orTimeout 保证单次阻塞调用不会拖长尾延迟;loop.execute(...) 确保 writeAndFlush 回到线程安全的 EventLoop 上执行。

一句话:BIO 用线程换连接;NIO/Reactor 用事件换连接——前者是”为每个客户都派一个服务员等候”,后者是”一个服务员同时看管所有桌子,有需要时再过去”。

3. 连接池:消灭连接建立成本

即便用了 NIO,每次请求都新建一个数据库连接或 HTTP 连接,仍然开销极大:

连接池(Connection Pool)预先建立并维护一批连接,请求到来时直接从池中取一个已建立的连接,用完归还,避免了上述开销。连接的建立均摊到池的生命周期中,单次请求感知不到握手延迟。

3.1 核心参数

参数含义常见错误
maximumPoolSize / maxPoolSize池中最大连接数设太大:DB 端连接数超限;设太小:高并发时频繁等待
minimumIdle / minIdle池中最小空闲连接数设为 0 时低流量期间连接被关闭,流量来时需重新建立,造成延迟尖刺
connectionTimeout从池中获取连接的最大等待时间设太长:请求堆积;设太短:正常峰值下误触超时
idleTimeout空闲连接在池中的最大保留时间设太长:数据库端超时断开但池不知道(产生”幽灵连接”)
maxLifetime连接的最大存活时间必须小于数据库的 wait_timeout,否则池以为连接有效但实际已被 DB 关闭
keepaliveTime定期发送 keepalive 包的间隔未设置时,防火墙/NAT 设备可能静默关闭长期空闲连接

推荐起点(HikariCP)

maximumPoolSize = CPU 核数 × 2 + 磁盘数(对于 I/O 密集型)
minimumIdle = maximumPoolSize / 2(避免完全空闲时关闭连接)
connectionTimeout = 3000ms(3 秒,业务可接受的最长等待)
maxLifetime = 1800000ms(30 分钟,比 MySQL wait_timeout 8 小时短很多)

3.2 数据库连接池(JDBC Pool)

Java 生态中最常用的是 HikariCP(Spring Boot 2.x+ 默认)和 Druid(Alibaba,功能更丰富,有监控 Dashboard)。

HikariCP 的设计哲学是”极简快速”:通过字节码注入避免反射、使用 ConcurrentBag 替代锁密集的集合类,在微基准测试中通常是最快的 JDBC 连接池。

HikariCP 的关键参数配置(数值应基于 Little’s Law 反推 + 压测校准,而非默认放大):

HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://db-host:3306/adx");
config.setUsername("adx");
config.setPassword(secret);

// 池大小:由 Little's Law 反推(QPS × 平均延迟),而非拍脑袋设 100
config.setMaximumPoolSize(12);
config.setMinimumIdle(12);                      // 与 max 相等,避免低峰期缩池、峰值重建造成尖刺

// 获取连接的最长等待:宁可 fail fast 也不让请求无限排队
config.setConnectionTimeout(2000);              // 2s

// 连接最大存活:必须显著小于 MySQL wait_timeout(默认 8h),避免用到被 DB 单方关闭的连接
config.setMaxLifetime(1_800_000);               // 30min
config.setIdleTimeout(600_000);                 // 10min(minIdle=max 时此项实际不生效)
config.setKeepaliveTime(120_000);               // 2min,穿透 NAT/防火墙的空闲回收

// 连接泄漏检测:超过阈值未归还就打印堆栈,定位忘记 close 的代码
config.setLeakDetectionThreshold(5000);         // 5s

config.setConnectionTestQuery("SELECT 1");
HikariDataSource ds = new HikariDataSource(config);

Druid 在国内使用更广泛,原因在于:内置 SQL 监控(慢 SQL 统计)、连接泄漏检测、防 SQL 注入过滤,适合对可观测性要求较高的生产环境。

3.3 HTTP 客户端连接池

服务间 HTTP 调用同样需要连接池。以 OkHttp(Java 最常用的 HTTP 客户端之一)为例:

OkHttpClient client = new OkHttpClient.Builder()
    .connectionPool(new ConnectionPool(
        50,          // maxIdleConnections:最大空闲连接数
        5,           // keepAliveDuration
        TimeUnit.MINUTES
    ))
    .connectTimeout(2, TimeUnit.SECONDS)
    .readTimeout(5, TimeUnit.SECONDS)
    .build();

HTTP 连接池的关键点与 JDBC 类似,但还需注意:

4. Little’s Law:给池大小一个理论基础

Little’s Law(利特尔法则):在一个稳定的系统中,队列中的平均请求数(L)= 到达率(λ)× 平均处理时间(W)。

对连接池的直接应用:

所需并发连接数(L)= 目标 QPS(λ)× 平均响应时间(W)

示例计算

很多团队习惯把连接池设到 100、200,认为”大一点安全”。但实际上:过大的连接池会让数据库端连接数超限(MySQL 默认 max_connections = 151,每个连接消耗约 1 MB 内存);过多的并发连接反而会增加数据库的 context switch 压力,降低整体吞吐。

池大小应该通过压测结果和 Little’s Law 反推,而不是靠直觉拍脑袋。

连接池 + 利特尔法则:L = λ × W 推导池大小,请求 → 池(有限连接)→ 后端 示例:目标 1000 QPS × 平均 10ms = 10 个并发连接,加余量后池设 12~15,而非拍脑袋设 100。

一句话:Little’s Law 告诉你”理论上需要多大的池”,压测告诉你”实际需要多大的池”——两者结合,才能在不浪费资源的前提下保证并发能力。

5. 池耗尽时的背压策略

连接池耗尽(所有连接都在使用中,且没有空闲连接)时,新的请求无法立即获得连接,有三种处理策略:

5.1 等待超时(Blocking with Timeout)

新请求进入等待队列,超过 connectionTimeout 后抛出异常。

适用:短时流量峰值,峰值过后连接会快速释放。用户体验是”稍有延迟”。

风险:等待队列无限增长时,请求堆积,延迟线性上升,可能触发上游超时,形成雪崩。需要限制等待队列长度。

5.2 快速失败(Fail Fast)

连接池耗尽时立即返回错误,不等待。配合上游的熔断器,可以快速隔离故障,防止资源堆积。

适用:下游服务本身已经出现问题(而不只是流量峰值);用户端可以处理重试或降级响应。

与熔断的关系:连接池持续耗尽是下游服务过载的信号,此时应触发熔断器,停止新的请求发往该下游,直接走 fallback——而不是让所有请求都排队等待连接。详见依赖韧性中的熔断策略。

5.3 限流降级(Rate Limit at Ingress)

在连接池之外、更上层的入口处加限流,确保进入系统的请求量不超过连接池的实际处理能力。超出限流阈值的请求在入口就被拒绝,而不是进入系统后等待连接。

适用:持续性的高流量场景,需要保护整个系统的稳定性。

实现:在网关层(Nginx、API Gateway)或应用层(Sentinel、Resilience4j)加令牌桶或滑动窗口限流。限流阈值的设定应基于连接池大小和下游平均响应时间(即 Little’s Law 的反推值)。

6. 常见陷阱

6.1 连接池太大

症状:数据库连接数超限(MySQL 报 Too many connections);数据库 CPU 使用率高但吞吐没有提升(大量线程在 context switch)。

根因:误以为”连接池越大越能支持更多并发”——实际上数据库服务器的处理能力有上限,超过后增加连接只会增加 context switch 开销。

处理:用 Little’s Law 重新计算合理的连接池大小,压测验证。

6.2 超时配置不分场景

把所有下游调用的超时设成同一个值(如全部 30 秒),导致:慢查询长期占用连接不释放;连接池实际上的并发容量大幅低于设计值。

正确做法:每个下游调用独立配置超时,基于该下游的 P99 延迟基准 + 合理余量来设定。连接超时(Connect Timeout)通常设 1~2 秒;读超时(Read Timeout)根据下游操作特性单独设定。详见超时与重试的相关设计原则

6.3 连接泄漏

症状:连接池慢慢被耗尽,重启后短时恢复,随后再次耗尽(呈周期性)。

根因:代码中获取连接后未在 finally 块中归还(异常路径未覆盖),或长事务持有连接不释放。

处理:

6.4 池之间互相影响

同一个服务进程中,访问不同下游的连接池共享 JVM 内存;若一个下游响应变慢,其连接池被大量请求长期占用,可能间接影响 JVM 线程调度,进而影响其他下游的连接池请求处理速度。

解决:为不同下游设置独立的、大小合理的连接池,而不是共用一个大池。核心下游(如主数据库)和非核心下游(如日志服务)应完全隔离,防止非核心下游的问题拖垮核心链路。这也是舱壁隔离(Bulkhead Pattern)的一种实践。

7. 小结

连接与 I/O 模型的核心是在有限的线程和连接资源下,最大化有效吞吐并保持稳定的尾延迟

结合异步与削峰(前一篇)和性能测试(下一篇),连接与 I/O 模型构成了高性能系统的底层基础设施:削峰负责控制流入的速率,连接池负责高效利用出向资源,压测负责验证整体容量。


views
Share this post on:

Previous Post
批处理与请求合并:攒批、削频与吞吐优化
Next Post
异步与削峰:用消息队列把洪峰挡在核心路径外