在Ruby的并发应用里,频繁创建HTTP连接是一个容易被忽视的性能杀手。每次TCP三次握手加上TLS协商,可能带来几十到上百毫秒的额外延迟,而在Sidekiq任务或者Puma高并发请求下,这个开销会被成倍放大。Net::HTTP本身支持start方法维持持久连接,但直接在多线程里共享一个HTTP对象会引发各种诡异问题。这篇文章就来聊聊如何构建一个线程安全的Net::HTTP持久连接池,以及如何检测和避免连接泄漏。

为什么直接共享Net::HTTP对象会出问题
先看一段常见的错误代码。很多人会这样写一个全局HTTP对象供多个线程使用:
require 'net/http'
# 危险写法:多个线程共享同一个HTTP对象
$http = Net::HTTP.new('api.ipipp.com', 443)
$http.use_ssl = true
$http.start
threads = 10.times.map do
Thread.new do
response = $http.get('/api/data')
puts response.code
end
end
threads.each(&:join)</code>这段代码在低并发下可能碰巧能跑通,但本质上是错误的。Net::HTTP的实例并不是线程安全的:它的内部状态包括Socket对象、请求队列、上次响应的解析位置等,两个线程同时往同一个Socket写数据,响应内容会串包,轻则抛出EOFError,重则解析到别人请求的响应体,产生脏数据。
另一个隐蔽的问题是连接生命周期管理。当远端服务器或者中间的负载均衡器主动断开空闲连接时(Nginx默认keepalive_timeout是75秒),本地Socket并不会立刻感知。下一个线程拿到这个半死的连接去发请求,就会撞上Errno::ECONNRESET。所以一个合格的连接池必须同时解决两个问题:并发访问的互斥,以及连接健康度的维护。
线程安全连接池的设计与实现
最直接的思路是用互斥锁保护连接的借出和归还动作,池子里维护多个Net::HTTP实例,每个线程独占一条连接用完再还回来。这里给出一个可以落地的基础版本:
require 'net/http'
require 'thread'
class HttpConnectionPool
def initialize(host, port, use_ssl: true, size: 8)
@host = host
@port = port
@use_ssl = use_ssl
@size = size
@pool = []
@mutex = Mutex.new
@resource = ConditionVariable.new
@created = 0
end
# 从池中借出一条连接,池空则阻塞等待
def acquire
@mutex.synchronize do
loop do
if @pool.any?
conn = @pool.pop
return healthy?(conn) ? conn : replace_connection
elsif @created < @size
@created += 1
return create_connection
else
@resource.wait(@mutex)
end
end
end
end
# 归还连接,唤醒一个等待线程
def release(conn)
@mutex.synchronize do
if conn.started? && !conn.socket.nil?
@pool.push(conn)
else
@created -= 1
end
@resource.signal
end
end
private
def create_connection
http = Net::HTTP.new(@host, @port)
http.use_ssl = @use_ssl
http.open_timeout = 5
http.read_timeout = 10
http.keep_alive_timeout = 30
http.start
http
end
def healthy?(conn)
conn.started? && !conn.socket.nil?
rescue StandardError
false
end
def replace_connection
@created -= 1
create_connection
end
end使用时配合with_connection风格的包装可以避免调用方忘记归还:
pool = HttpConnectionPool.new('api.ipipp.com', 443)
def with_connection(pool)
conn = pool.acquire
begin
yield conn
ensure
pool.release(conn)
end
end
threads = 20.times.map do |i|
Thread.new do
with_connection(pool) do |http|
response = http.get("/api/items?page=#{i}")
puts "thread #{i}: #{response.code}"
end
end
end
threads.each(&:join)这里有几个实现细节值得展开。第一,ConditionVariable用来在池子耗尽时让线程排队等待,而不是自旋空转消耗CPU。第二,ensure块保证了即使请求过程抛异常,连接也会被归还或者从计数中剔除,这是防止泄漏的第一道防线。第三,healthy?检查只覆盖了最基础的场景,更健壮的做法是在借出前发一个轻量探测,或者干脆依赖retry机制:捕获Errno::ECONNRESET后丢弃旧连接、换新连接重试一次幂等请求。
还要注意keep_alive_timeout应该设置得比服务端的超时略短,比如服务端是75秒就设成60秒,这样客户端会主动在服务端断开之前回收连接,避免拿到已被对端关闭的Socket。
连接泄漏的常见成因与检测手段
连接池用久了如果出现内存持续上涨、fd数量不断攀升,多半是泄漏了。Net::HTTP场景下泄漏通常有三个来源。一是异常路径没有走ensure,连接对象被GC回收时底层Socket未正常关闭,文件描述符滞留。二是连接归还时已经断开但没有从@created计数中扣减,导致池子账实不符,新连接永远创建不出来。三是使用http.start块形式时块内又启动了长耗时操作,连接被占用时间过长,形成事实上的泄漏。
检测手段可以分为三层。第一层是进程内巡检,起一个后台线程定期检查@pool.size + 借出数量是否等于@created:
class LeakDetector
def initialize(pool, interval: 30)
@pool = pool
@interval = interval
Thread.new do
loop do
sleep @interval
stats = @pool.stats
# 借出后长时间未归还视为可疑
if stats[:checked_out] > 0 && stats[:avg_hold_time] > 15
warn "疑似连接泄漏: #{stats.inspect}"
end
# 打开fd数量交叉验证
fds = Dir.glob('/proc/self/fd/*').size rescue nil
warn "当前fd数量: #{fds}" if fds
end
end
end
end第二层是对象Finalizer追踪。给每个借出的连接打上唯一ID和时间戳,利用ObjectSpace.define_finalizer在连接对象被GC时记录日志,如果发现有连接没走release就被回收了,说明某处代码漏掉了归还逻辑。
第三层是系统层面的观察。用lsof -p查看进程持有的TCP连接数,用ss -tan统计TIME_WAIT状态的数量。如果TIME_WAIT持续增长且远超池子大小,说明连接没有真正被复用,每次都在重建;如果ESTABLISHED连接只增不减,那就是典型的归还逻辑缺失。在容器环境里还可以配合Prometheus暴露pool_size、checked_out、wait_queue三个指标,在Grafana上设置告警,比事后翻日志排查高效得多。
生产环境实践建议
如果不想自己维护连接池,可以直接考虑net-http-persistent这个成熟gem,它在内部为每个线程维护独立的连接,天然规避了互斥问题,也是Ruby生态里最主流的方案。自己造轮子的话,池子大小建议设置为CPU核数的2到4倍起步,再根据压测的P99延迟调整,盲目调大只会让服务端连接压力变大。
另外一个容易被忽略的点是进程模型。Puma的多进程模式下每个worker进程持有独立的池子,总连接数要乘以进程数计算,否则容易击穿下游服务的连接数上限。Sidekiq里则要小心Redis的长连接和HTTP连接共用线程的情况,确保池子大小不小于并发线程数,避免任务排队等连接。
总结一下核心要点:持久连接池的价值在于复用,复用的前提是线程安全和连接健康;泄漏检测要从代码层ensure、池内巡检、系统fd监控三个层面同时入手。把这些细节处理好,Net::HTTP在中等并发场景下完全够用,不必急着引入更重的HTTP客户端。
Ruby Net::HTTP连接池线程安全修改时间:2026-09-12 12:30:42