HTTPX 是 Ruby 社区中一个设计现代、插件体系完善的 HTTP 客户端库。它的 response_cache 插件可以把服务端响应缓存下来,避免重复请求相同资源。而这个插件的默认内存存储后端,正是通过一个 LRU 结构来管理缓存容量的。理解这个 LRU 的实现细节,不仅有助于正确使用缓存插件,也能学到不少 Ruby 语言层面构建高效数据结构的技巧。

LRU 缓存的核心思想与 Ruby 实现选型
LRU(Least Recently Used,最近最少使用)是一种经典的缓存淘汰策略:当缓存满了之后,优先淘汰最长时间没有被访问过的条目。它的理论基础是局部性原理——最近被访问过的数据,未来再次被访问的概率通常更高。
在很多语言里实现 LRU 需要哈希表加双向链表的组合:哈希表负责 O(1) 查找,双向链表负责维护访问顺序,每次访问把节点移动到链表头部,淘汰时直接删除尾部节点。但 Ruby 给了我们一个更简洁的选择。从 Ruby 2.9(发布版本为 3.0)开始,Hash 的插入顺序在所有场景下都得到保证,即使删除再重新插入键,其位置也会更新到末尾。利用这一特性,可以用一个普通 Hash 配合「先删后插」的手法实现 LRU,代码量大幅减少。
HTTPX 中的 ResponseCache::Store::Memory::LRU 正是采用了这种思路。它把每个缓存条目存储在 Hash 中,读取命中时先删除该键再重新写入,让条目始终排在 Hash 的最后;而最旧的条目则自然停留在 Hash 的开头。淘汰时只需从头部开始删除,直到数量回到上限以内。
源码结构解析:写入、读取与淘汰逻辑
我们来看一个基于 HTTPX 思想还原的简化实现,它保留了原版的核心逻辑:
class ResponseCache
module Store
class Memory
class LRU
def initialize(max_entries = 100)
@max_entries = max_entries
@store = {}
end
# 写入缓存条目,同时触发容量检查
def set(key, value)
@store.delete(key)
@store[key] = value
evict_while_full
value
end
# 读取命中时刷新热度:删除后重插,条目移到末尾
def get(key)
value = @store.delete(key)
return nil unless value
@store[key] = value
value
end
def delete(key)
@store.delete(key)
end
def clear
@store.clear
end
private
# Hash 首部是最久未使用的条目,从头部开始淘汰
def evict_while_full
@store.shift while @store.size > @max_entries
end
end
end
end
end
这段代码的关键点有三个。第一,set 方法里先调用 delete 再赋值,保证即使键已存在,也会被移动到 Hash 的末尾,视为刚被访问过。第二,get 方法同样采用删除重插的策略,一次读操作相当于一次热度刷新,这正是 LRU 语义中「使用」的定义。第三,evict_while_full 使用 Hash#shift 删除首个键值对,由于 Hash 保持插入顺序,首部就是最久未访问的条目,整个淘汰过程无需遍历和排序。
值得注意的是,这里的 shift 操作是 O(1) 的,配合 O(1) 的 Hash 查删插,整个结构的所有操作都维持常数时间复杂度。相比传统双向链表实现,这种写法更简洁,也不容易出现链表指针操作带来的边界 bug。当然,它的代价是每次读操作都要执行一次删除加一次插入,常数因子略大,但对于 HTTP 响应缓存这种低频读写场景完全够用。
实际使用中的容量调优与常见误区
在 HTTPX 中使用响应缓存时,LRU 的容量参数直接决定内存占用与命中率的平衡。如果容量设置过小,缓存会频繁淘汰,命中率下降,插件几乎形同虚设;如果设置过大,大量响应对象(包括头部、正文缓冲区)常驻内存,可能引发内存膨胀。建议根据实际请求的响应体大小做粗略估算:假设平均响应 50KB,容量 1000 意味着最多约 50MB 的内存占用上限。
一个常见误区是认为 LRU 能自动防止内存泄漏。实际上 LRU 只保证条目数量不超过上限,但单个条目如果持有了大对象引用(例如未关闭的 IO 流或巨型字符串),内存压力依然存在。因此如果基于这个类做二次开发,可以考虑在 evict_while_full 淘汰条目时增加回调钩子,及时释放条目内部的非托管资源。
另一个需要留意的点是线程安全。上述实现没有加锁,而 HTTPX 本身支持并发请求(通过 session 的 request 批量接口并发执行),多线程并发读写同一个 LRU 实例时可能出现竞态条件。如果需要在自定义场景中并发使用,可以简单地用 MonitorMixin 包一层:
require "monitor"
class ThreadSafeLRU < SimpleDelegator
include MonitorMixin
def initialize(max_entries)
super(ResponseCache::Store::Memory::LRU.new(max_entries))
end
def set(key, value)
synchronize { __getobj__.set(key, value) }
end
def get(key)
synchronize { __getobj__.get(key) }
end
end
最后总结一下这个实现的教学价值:它展示了如何利用 Ruby Hash 的有序性这一语言特性,用二十行左右的代码完成一个功能完备的 LRU。理解了删除重插刷新热度、shift 淘汰首部这两个核心手法后,你也可以很轻松地把同样的模式迁移到自己的项目中,比如连接池管理、接口结果缓存等场景,都是这类轻量级 LRU 的用武之地。