在微服务架构里,一个服务的可用性往往依赖一堆外部依赖:数据库连接是否建立、下游接口是否可访问、消息队列是否就绪。启动阶段的就绪探针(readiness probe)如果实现得粗糙,每次被调用都真实地发起网络请求,就会造成明显的性能浪费,甚至在高频探测下把下游服务打出故障。用Ruby做一层就绪状态缓存,让重复的健康检查直接命中内存中的结果,是一个投入小、收益明显的优化手段。

为什么重复健康检查会成为性能隐患
Kubernetes默认的就绪探针周期是10秒,加上重试和多个副本的场景,一个服务实例每小时可能被探测数百次。假设每次探针都要串行地检查数据库连接、Redis连通性、某个下游HTTP接口,一次探测耗时几百毫秒并不稀奇。如果探针接口本身被业务代码或其他监控系统高频调用,问题会被进一步放大。
更麻烦的是TCP连接建立的开销。Ruby的Net::HTTP默认不复用连接,每次检查都完整走一遍DNS解析、TCP三次握手、TLS协商。当你同时部署几十个副本时,这些探测请求叠加起来就是一股不小的流量,下游服务的连接池可能被无意义的健康检查占满,真正承载业务的连接反而进不来。
另一个容易被忽视的坑是探针超时与部署平台的耦合。探针接口响应太慢,Kubernetes会认为实例未就绪,反复重启Pod形成恶性循环。而实际上服务本身早就准备好了,只是探针在反复做昂贵的检查。把就绪状态缓存下来,探针接口的响应时间可以从几百毫秒降到微秒级,彻底消除这类误判。
Ruby实现就绪探针缓存的核心代码
实现思路很直接:用单例对象保存就绪状态和过期时间,第一次探测真实执行,之后在缓存有效期内直接返回缓存结果。下面是一个完整的实现,包含了线程安全和失败退避两个关键细节。
require 'net/http'
require 'uri'
require 'time'
class ReadinessCache
include Singleton
attr_reader :checked_at
def initialize
@mutex = Mutex.new
@ready = false
@checked_at = nil
@ttl = 30 # 就绪状态缓存30秒
@failure_ttl = 5 # 失败状态只缓存5秒,尽快重试
end
def ready?
return true if cached_ready?
refresh!
end
def refresh!
@mutex.synchronize do
# 双重检查:等锁期间别的线程可能已经刷新过
return @ready if fresh?
@ready = perform_checks
@checked_at = Time.now
@ready
end
end
private
def cached_ready?
@mutex.synchronize { fresh? && @ready }
end
def fresh?
!@checked_at.nil? && (Time.now - @checked_at) < current_ttl
end
# 失败时用更短的TTL,实现退避重试
def current_ttl
@ready ? @ttl : @failure_ttl
end
def perform_checks
check_downstream_api('https://ipipp.com/api/ping') && check_tcp('redis.local', 6379)
end
def check_downstream_api(url)
uri = URI(url)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
open_timeout: 2, read_timeout: 3) do |http|
http.head(uri.request_uri)
end
response.is_a?(Net::HTTPSuccess)
rescue StandardError
false
end
def check_tcp(host, port)
Socket.tcp(host, port, connect_timeout: 2).close
true
rescue StandardError
false
end
end这份代码有几个值得展开的点。首先是双重检查锁:多线程环境下(Puma默认多线程),探针请求可能同时到达,如果不加锁,三个线程会并发执行三遍真实检查,缓存就失去了意义。@mutex.synchronize配合锁内二次校验fresh?,保证同一时刻只有一次真实探测。
其次是失败退避的设计。成功状态缓存30秒没问题,因为就绪状态通常是稳定的;但失败状态如果也缓存30秒,服务恢复后要等很久才能被探测到。所以代码里用current_ttl区分:成功长缓存、失败短缓存,这是实践中非常关键的一笔。
最后注意每个检查都设置了明确的超时。open_timeout和read_timeout必须配置,否则Ruby默认的Net::HTTP超时很长,一次网络抖动就可能让探针接口卡住,触发平台层面的重启。TCP检查用connect_timeout同理。
在Rails和微服务场景中的落地方式
在Rails项目中,把这个缓存接到健康检查路由上即可。需要注意的是Rails默认的开发环境代码热重载会让单例失效,建议只在生产环境启用缓存逻辑。
# config/routes.rb
Rails.application.routes.draw do
get '/health/ready', to: 'health#ready'
end
# app/controllers/health_controller.rb
class HealthController < ActionController::Base
def ready
if ReadinessCache.ready?
head :ok
else
render json: { status: 'not ready' }, status: :service_unavailable
end
end
end
# 生产环境启用缓存,开发环境每次都真实检查
ReadinessCache.configure(enabled: Rails.env.production?)在非Rails的微服务里,比如用Sinatra或纯Rack搭建的服务,同样可以在中间件层挂载这个探针。还有一种常见做法是把缓存对象注入到服务的启动流程:服务启动完成各项初始化后主动调用refresh!预热一次,之后平台探针几乎全部命中缓存,连第一次的探测延迟都省掉了。
落地时还有三点经验值得参考。第一,如果服务有多个依赖,可以让每个依赖独立缓存、独立TTL,数据库连接由连接池自己管理(不需要探针重复验证),只有不稳定的HTTP下游才值得做缓存探测。第二,提供手动失效入口,比如暴露一个内部的invalidate方法,在依赖切换、配置热更新时调用,避免旧状态粘住不放。第三,在监控指标里区分缓存命中和真实探测的次数,一旦发现真实探测频率异常升高,说明缓存可能因为持续失败在走短TTL路径,这本身就是下游故障的一个信号。
总体来说,就绪探针缓存的本质是把昂贵的网络检查与高频的状态查询解耦。Ruby侧用一个带锁的单例加差异化的过期策略就能覆盖绝大多数场景,代码量不到百行,却能显著降低探测开销、缩短部署就绪时间,也让健康检查对下游服务更加友好。