扩展的瓶颈:文件描述符、内核内存与堆大小
任意输入/输出操作——无论是访问文件、处理 HTTP 请求,还是建立数据库连接——都建立在三个基础系统概念之上:文件描述符、内核内存和堆大小。本文将讨论现代语言如何在幕后帮助开发者处理文件描述符、内核内存与堆的管理;这三个概念正是系统扩展时最主要的瓶颈。
1. 文件描述符
文件描述符只是一个正整数,内核用它来标识任意已打开的输入/输出流或连接。它由内核为每个进程定义。进程默认拥有以下文件描述符:
- 0 —— 标准输入(stdin)
- 1 —— 标准输出(stdout)
- 2 —— 标准错误(stderr)
此后,每次 I/O 操作都会获得下一个可用的整数作为文件描述符。在 Linux 中,可以通过 ulimit -n 命令调整文件描述符上限。
无论是一个用 Java Spring Boot 编写的 Web 服务器、一个使用 net/http 和 gorilla-mux 的 Go API 服务器,还是一个 Python Flask 应用,它们本质上都是单个进程。默认情况下,每个进程只有 1024 个文件描述符,这意味着每个应用最多只能同时执行 1024 个 I/O 操作。当我们讨论应用或 API 服务器的扩展时,这显然是一个非常关键的限制。
我们经常会遇到这样的问题:如何让 API 服务器或 Web 应用扩展到每秒处理 10 万甚至 100 万个请求?这正是现代语言在后台发挥作用的地方,让开发者能够构建支持如此量级规模的应用。
2. 内核内存
在比文件描述符更底层的地方,当一个新的 TCP 连接到达网卡时,Linux 内核会先为该连接执行 TCP 三次握手。握手生命周期包括以下状态:SYN → SYN-ACK → ACK。
在文件描述符配额范围内的请求会被立即处理:分配文件描述符,并转发给应用继续处理。当文件描述符耗尽后,内核会为等待 FD 空闲的请求维护一个队列,以便应用稍后处理。同样,当请求处理完成、响应正准备返回客户端时,也会发生类似的过程。
这些队列由 Linux 在 RAM 中通过读缓冲区(rmem)和写缓冲区(wmem)维护。缓冲区大小由内核动态决定,取决于网络吞吐量、往返时间和内存压力。内核网络内存是“不可分页”的,也就是说不能换出到磁盘。这会直接依赖物理内存,因此是一个很大的瓶颈。
例如,如果有 100,000 个打开连接,每个连接平均占用 128KB 的 内核内存,那么总共就需要 12.8GB 物理内存。这显然是内核开销,它不会出现在 JVM 堆指标或 Go 运行时统计中。rmem 和 wmem 缓冲区由 /proc/sys/net/ipv4/ 下的内核参数控制。
3. 堆大小
当 TCP 连接被分配了文件描述符,并为内核内存预留资源后,请求便进入用户空间。用户空间内存由应用运行时管理——Java JVM、Node.js V8 引擎、Python 解释器、Go runtime 等。每个连接都会在堆中按三类存储对象:
- 连接元数据——Keep-alive 定时器、IP 状态、Socket 包装器等。
- 加密会话上下文——握手缓存、密码状态、TLS/SSL 密钥等。
- 序列化负载缓冲区——响应队列、JSON 字符串、ORM 实体映射等。
通过 TLS 加密的连接,其堆占用空间远大于普通连接。加密连接要求应用在堆中保存对称密钥、密码上下文、会话票据等。一个普通 TCP Socket 对象在堆中消耗 2KB 到 5KB;而一个 TLS 1.3 Socket 对象可能消耗 20KB 到 100KB 的堆空间。如果一个 API 保持 10,000 条空闲 TLS 连接,就会消耗 200MB 到 1GB 堆空间。
当应用运行时,随着对象不断创建,运行时会向内核申请内存。应用一直创建对象,内核不断为这些对象保留内存;这片区域就是堆。不同编程语言可以在运行时定义最大 堆大小。例如在 Java 中,-Xmx4g 表示预留 4GB 堆空间。操作系统承诺为应用提供这么大的堆空间,但不会一次性全部预留。随着应用创建对象,内核才会持续分配内存。当对象被标记为不再使用后,垃圾回收器会将它们从堆中移除。
当请求到达 API 服务器时,应用会使用堆空间将原始字节转换为应用特定的数据结构。当应用完成请求处理并返回响应后,堆中被创建的对象就变成不可达(unreachable)或死(dead)对象。当垃圾回收器清扫这些对象以回收内存时,它并不会立刻把内存归还给操作系统;相反,JVM 或 Go runtime 会把释放的内存保留在内部池中。如果 1 毫秒后又有新的 HTTP 请求到达,运行时就会从内部池的空闲内存中分配所需空间。
现在想象 10,000 个新请求同时到达,每个请求带 2MB 原始字节,而运行时正在尝试为这些对象分配堆空间——应用会瞬间消耗 20GB 内存。这就是所谓的 GC thrashing(GC 抖动):运行时在堆上快速创建所需对象的速度,超过了垃圾回收器清理它们的速度。
垃圾回收器本身也是运行时的一个执行线程;当堆使用率达到 80%~90% 时,GC 会进入紧急状态,占用 100% 的 CPU 核心扫描数百万个内存指针,以查找死对象。JVM 或 Node.js 等运行时中的垃圾回收器在重组内存时,可能会暂停其他代码的执行。
那么 Go 和 JVM 这类运行时是如何处理 GC thrashing 的呢?
Go 采用一个简单策略:尽量避免在堆上创建对象。最快的垃圾回收器,就是没有对象需要回收的垃圾回收器。Go 编译器会分析变量是否会存活超过其所在函数。如果某个 struct 只在函数内部使用,Go 会把它放到栈上而不是堆上;函数返回时栈指针直接下移,内存只需 1 个 CPU 周期即可回收,甚至不需要垃圾回收器介入。
如果 Go 确实需要清理堆,它的 GC 会与其他 goroutine 并发运行,并拆分成多次微暂停。Go 还提供了 sync.Pool,帮助开发者在创建对象时复用堆内存。例如,与其为每次请求的 JSON 解析创建大量 []byte,开发者可以这样使用 sync.Pool:
通过 sync.Pool 回收缓冲区,高并发 API 可以在几乎不产生新增堆分配的情况下,处理每秒 100,000 个请求。
Java 则采用了不同方法。由于 Java 应用历来会在堆上创建数以百万计的短生命周期对象,JVM 依赖分代假说(Generational Hypothesis)和分代收集器,如 G1GC、ZGC、Shenandoah。运行 Java 应用时,可以使用 java -XX:+UseG1GC 来启用 G1GC。
G1GC 将堆内存划分为多个物理区域:年轻代(Young Generation,包含 Eden 和 Survivor 区)与年老代(Old Generation)。它会将对象分门别类放到不同区域,从而不必扫描整个堆,只需清理大多数待回收对象所在的区域。还可以设置 -XX:MaxGCPauseMillis=200,告诉 G1 应用暂停时间尽量不超过 200ms,但这并不能得到保证。
较旧的 JVM 收集器如 Parallel GC,在堆满时通常会冻结整个应用来清理堆,导致数秒的延迟尖峰。现代 JVM 引入了 ZGC(Z Garbage Collector)和 Shenandoah。ZGC 使用专门的 CPU 指针引用实时跟踪被移动的对象。它可以在 API 请求持续运行的同时,并发地清理、移动和压缩 TB 级堆内存。ZGC 保证 GC 暂停时间在 1 毫秒以内,无论堆是 500MB 还是数 TB。
结论
要判断何时应该扩展,请持续关注这三个核心概念:文件描述符、内核内存和堆大小。
1. 文件描述符饱和信号
文件描述符代表系统持有的打开句柄。当应用达到 FD 阈值时,操作系统会停止接受连接。以下场景表示你可能需要扩展:
- 检查内核级统计
/proc/sys/fs/file-nr,以及每进程 fd/proc/<pid>/fd。如果 Prometheus 中暴露出的process_open_fds持续超过 80%~85% 阈值,就该扩容了。 - 你已经把
ulimit -n和LimitNOFILE调高到标准安全上限(例如 65,536 或 104,857),但进程 FD 数量仍持续向最大值攀升。 - 网络接口显示 SYN-to-LISTEN socket 数量和丢包持续增长,
netstat -s中的 listen 队列溢出指标出现明显升高。
2. 内核内存压力信号
由于 TCP 接收缓冲区(rmem)和发送缓冲区(wmem)不可分页,它们无法溢出到磁盘交换空间。当内核网络内存填满时,操作系统会开始丢包。以下场景与内核内存超限相关:
- 检查
/proc/net/sockstat中TCP: inuse的值,并对照/proc/sys/net/ipv4/tcp_mem的阈值。 netstat -s | grep -i retrans显示 TCP 重传率急剧上升(>1%~2%)。- 出现延迟尖峰,因为内核为避免物理 RAM 耗尽,会将 socket 缓冲区动态缩小到
tcp_rmem最小值(4KB),从而限制 TCP 窗口大小。
3. 堆大小与垃圾回收(GC)抖动信号
当用户空间堆分配速度超过垃圾回收器清理死对象(如已解析的 JSON 负载或会话状态)的能力时,应用性能会急剧下降。运行时(JVM 或 Go)会把超过总 CPU 时间 15%~20% 的部分用于 GC 清扫(go_gc_cpu_fraction 或 JVM GC CPU 利用率)。
在 Go 中,指标可能显示 pacer 触发 Mark Assist,占用 worker goroutine 的 CPU 时间来协助内存清理。你可以检查 runtime 包中的 /cpu/classes/gc/mark/assist:cpu-seconds 指标,判断 GC 是否正在向 CPU 请求更多帮助。
在 Spring Boot 中,可以使用 Actuator 和 Micrometer 暴露相关端点,以监控这些阈值。
DZone 投稿者所表达的观点仅代表其个人观点。