启用HTTPX的ResponseCache插件以后,内存缓存并不会无限增长。它的默认存储实现HTTPX::Plugins::ResponseCache::Store::Memory::LRU会在写入新响应时判断当前条目数是否达到容量上限,如果达到,就调用evict方法清理一条旧记录。这个清理过程就是常说的缓存淘汰策略,核心目标是尽量保留近期可能再次访问的响应,同时把内存使用控制在一个可预测范围内。

LRU淘汰在HTTPX缓存中的角色
缓存的价值取决于命中率,而命中率与淘汰策略直接相关。如果简单地删除最早写入的响应(FIFO),就可能把仍然热门的资源清掉;如果每次写入都全局扫描最久未使用的键(严格LRU),在高并发场景下会引入明显的CPU和锁开销。HTTPX的ResponseCache插件选择了内存LRU存储,但它的名字里的LRU并不是严格意义上的全量排序,而是通过采样来实现近似效果。这样设计是为了让缓存读写保持在O(1)到O(sample_size)的复杂度之间,避免因为维护双向链表或时间戳堆而拖慢请求处理。
在实际使用中,Store::Memory::LRU把每个缓存项存储为一个包含响应体和最后访问时间戳的数组。读取缓存时会更新时间戳,写入新缓存时则先检查容量并触发淘汰。这个过程中,evict方法不会阻塞整个请求链路太久,因为它只操作少量采样键。对于大多数HTTP客户端场景,响应体可能是几KB到几百KB的JSON或HTML,缓存容量通常设置为几十到几百条,近似的LRU效果已经足够优秀。
需要注意的是,LRU淘汰只作用于内存缓存。如果ResponseCache插件配置了磁盘存储或自定义存储,淘汰逻辑将由对应实现负责。理解内置LRU的实现细节,有助于在遇到缓存命中率下降或内存占用异常时快速定位问题。
Evict方法的近似LRU算法与代码拆解
下面这段代码展示了Store::Memory::LRU的核心结构。为了便于说明,省略了线程锁和TTL处理,只保留与淘汰直接相关的部分。
module HTTPX
module Plugins
module ResponseCache
module Store
class Memory
class LRU
def initialize(capacity: 100, sample_size: 5)
@capacity = capacity
@sample_size = sample_size
@data = {}
end
def set(key, response)
evict if @data.size >= @capacity
@data[key] = [response, Time.now.to_f]
end
def get(key)
entry = @data[key]
return nil unless entry
entry[1] = Time.now.to_f
entry[0]
end
private
def evict
return if @data.empty?
keys = @data.keys.sample(@sample_size)
oldest = keys.min_by { |k| @data[k][1] }
@data.delete(oldest)
end
end
end
end
end
end
end
代码中的evict方法首先调用sample从所有键里随机取出sample_size个候选,然后用min_by找出时间戳最小的那个键,也就是这组样本中最久未被访问的条目,最后删除它。由于每次淘汰只比较少量候选,即使缓存里有几千条记录,淘汰耗时也可以控制在几十微秒以内。这种算法来自Redis早期版本的近似LRU实现,在工程实践中被证明非常有效。
如果sample_size越大,淘汰结果越接近真实LRU,但单次淘汰的遍历成本也越高。默认值5是一个折中选择,适合绝大多数容量在100到1000条之间的场景。当缓存特别小,例如容量只有10条,采样5个键已经覆盖一半数据;当缓存特别大,例如10000条,采样5个键仍能保证较快速度,只是偶尔会误删还比较新的条目。这个参数可以通过初始化选项传入,后面会具体说明。
还有一点值得注意:evict在set的开头执行,而不是事后。这意味着写入新响应之前就会腾出空间,保证@data的大小永远不会超过capacity。如果此时缓存中已经存在相同键,旧条目不会先被覆盖,而是先触发一次可能的淘汰,再写入新值。这种行为在键冲突时可能多删一条数据,但对于缓存来说影响可以忽略。
配置缓存容量与采样数对命中率的影响
在使用ResponseCache插件时,可以通过插件初始化选项把存储替换为带自定义参数的LRU实例。例如:
HTTPX.plugin(:response_cache,
store: HTTPX::Plugins::ResponseCache::Store::Memory::LRU.new(
capacity: 200,
sample_size: 10
))
.get("https://api.ipipp.com/users")
上面配置了容量为200条、采样数为10的LRU存储。容量越大,能保留的响应越多,命中率理论上越高,但内存占用也随之上升。采样数从5提高到10以后,淘汰时更接近真实LRU,适合缓存条目生命周期差异较大的情况,例如同时缓存了静态配置和实时接口响应。不过采样数并不是越大越好,当它接近容量时,淘汰几乎退化为全量扫描,写入性能会明显下降。
除了容量和采样数,HTTPX的ResponseCache插件还支持TTL过期时间。TTL与LRU淘汰是两层机制:TTL负责删除逻辑上已过期的响应,LRU负责在容量压力下删除物理上最不活跃的响应。两者可以同时启用。如果缓存里的响应大多带有较短的TTL,那么LRU淘汰的机会相对较少;如果关闭TTL或设置很长,容量限制就会成为主要淘汰触发条件。
对于大多数REST API调用场景,建议容量设置在200到500之间,采样数保持默认5即可。如果发现某些高频请求总是需要重新访问上游,说明这些键可能被错误淘汰,可以尝试提高采样数到10或15。若内存占用仍然偏高,则适当降低容量,让淘汰更积极一些。
线程安全与自定义存储的避坑建议
HTTPX的缓存存储在并发请求下需要保证线程安全。内置的Store::Memory::LRU通常在方法内部使用Mutex同步对@data哈希的读写。因此,调用get和set时虽然会有短暂锁竞争,但不会出现数据损坏。如果你自己实现了一个继承或替代的存储类,必须记得在读写共享哈希时加锁,否则多个纤程或线程同时触发evict可能删除同一个键,或者导致@data.size判断和删除操作之间出现竞争。
另一个容易忽略的点是时间戳精度。Time.now.to_f在大多数系统上可以达到微秒级,但在高频写入下仍可能出现相同时间戳。如果两个候选键的时间戳相等,min_by会返回第一个满足条件的键,此时淘汰结果是不确定的。为了减少这种不确定性,可以在时间戳后追加一个自增计数器,或者使用单调时钟Process.clock_gettime(Process::CLOCK_MONOTONIC)。不过对于HTTP客户端缓存来说,相同时间戳导致误删的概率很低,不必过度设计。
最后,如果你想完全替换LRU为LFU或其他策略,可以实现自己的存储类,只要对外暴露get、set和delete等方法即可。HTTPX的ResponseCache插件通过依赖注入接收存储实例,因此替换过程并不复杂。但要注意,淘汰策略的改变会影响缓存命中率,建议在替换前记录当前LRU的命中率基线,做A/B对比后再决定是否切换。
Ruby HTTPXResponseCacheLRU淘汰策略修改时间:2026-10-07 03:59:46