Ruby 的 Hash 用起来省心,但内存回收不一定按你预期的路径走。这次我们来看一个非常常见、又容易被忽视的问题:一个大 Hash 删掉了大部分键,进程 RSS 却几乎不下降。如果这个 Hash 是长驻服务里的全局缓存、会话表,或者批量任务里的中间结果,多占的内存就会一直挂在进程上,直到进程重启。
这类问题的核心,是 Hash 底层那张桶表(bucket table)的容量“只增不减”。删除键只是把槽位清空,底层的存储结构并不会立刻缩小。只要你还持有这个大 Hash,那份大容量表就会继续占用内存。本文会用一段可复现的 Ruby 脚本演示这个问题,然后给出几种收缩 Hash 的方案,包括重建 Hash、select、rehash,以及批量任务里的内存观测思路。
这次内容不需要 GPU、不需要安装模型,只需要一个 Ruby 环境。适合写 Ruby 服务、做数据处理脚本,或者对 CRuby 内存行为好奇的读者。你可以直接复制脚本到本机跑一遍,观察自己的 Ruby 版本在“大 Hash 删除键之后”到底会不会释放内存。
1. 核心问题与方案速览
| 项目 | 说明 |
|---|---|
| 核心问题 | Ruby Hash 底层桶表容量只增不减,删除大量键后内存不能及时释放 |
| 关键技术点 | Hash 内部桶表、load factor、重建 Hash、select、rehash |
| 验证工具 | ObjectSpace.memsize_of、GC.start、进程 RSS |
| 适用版本 | Ruby 2.x / 3.x;不同版本内部实现有差异,以本机实测为准 |
| 适用场景 | 长驻服务中的缓存、大数据量 Hash、批量数据处理、内存受限环境 |
| 使用边界 | 小 Hash 重建开销可能大于收益,应该先测量再优化 |
| 最直接的收缩方式 | 遍历原 Hash,构造一个新的 Hash 并替换原引用 |
这里有一个需要强调的认知:Hash#size返回的是键值对数量,不是 Hash 占用的底层内存。一个删到只剩 10 万条数据的 Hash,底层桶表可能还是按 100 万条数据扩容后的规格。这是因为 Hash 在做插入时,会在负载因子达到阈值后把桶数组扩大一倍;而删除操作往往不会触发等比例的收缩。
2. 适用场景与使用边界
先说什么时候应该关注 Hash 收缩。
第一类场景是长驻服务。Web 服务、API 网关、消息处理程序启动后常驻内存,如果把大量数据装进 Hash 做缓存或去重,之后又删掉大部分键,内存不会因为你删了键就立刻归还给操作系统。时间一长,进程占用持续偏高,GC 压力也跟着上来。
第二类场景是批量数据处理脚本。比如一次导入几百万行数据,先分组放进 Hash,再逐步消费。中间如果保留了大量“已经用不上的空桶”,脚本峰值内存会明显高于数据量本身。这类脚本跑在容器或 CI 环境里时,内存上限很敏感,能省一点是一点。
第三类场景是内存受限环境。云函数、Sidecar、嵌入式环境里,Ruby 进程的内存配额固定,Hash 底层表不收缩会让本来够用的配额变得紧张。
再说边界。
小 Hash 不需要折腾。一个只有几百条键值对的 Hash,底层桶表也就几千字节,重建一个新 Hash 反而多一次遍历和分配。对性能敏感的服务,这种开销是纯浪费。
不要为了省内存牺牲可读性。如果业务代码里到处是hash.each_with_object({}) { ... },会让维护者困惑。更合适的做法是把“收缩 Hash”封装成一个方法,或者直接用它来替代“删除后的残留大 Hash”这种极端情况。
先测量再优化。不要凭感觉说“我的 Hash 占了很多内存”。先用 ObjectSpace 和 RSS 把数字测出来,确认问题存在,再动手改代码。
3. 环境准备与验证方法
这部分只需要一个可运行的 Ruby 环境。建议用 Ruby 3.x 做验证,因为新版 GC 和 Hash 实现相对更成熟,但这篇文章给出的思路在 Ruby 2.6 以上基本通用。
先确认 Ruby 版本:
ruby -v然后创建一个工作目录,用来放测试脚本:
mkdir -p ruby-hash-shrink cd ruby-hash-shrink在 Linux 环境里,可以通过/proc/self/status读取当前进程的 VmRSS,用来观察脚本自身的内存占用。macOS 没有这个文件,可以用ps -o rss= -p $$代替。Windows 则可以用任务管理器或 WMI 观察,不过接下来的示例脚本以 Linux 为主。
写脚本前,先说明一个观察工具:ObjectSpace.memsize_of。它返回某个对象在 GC 眼中的内存大小,对 Hash 来说能给出一个参考值,但不是绝对精确的进程内存统计。实际判断内存释放,还是要看 RSS 变化。
4. 先复现问题:删除大量键之后内存不下降
我们先构造一个 100 万条数据的 Hash,再删掉其中的 90 万条,观察删除前后 RSS 和对象内存大小。
require 'objspace' def rss_kb File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i end hash = {} 1_000_000.times do |i| hash[i] = "value_#{i}" end GC.start puts "inserted: size=#{hash.size} rss=#{rss_kb}KB obj=#{ObjectSpace.memsize_of(hash)}B" hash.delete_if { |k, _| k < 900_000 } GC.start puts "deleted : size=#{hash.size} rss=#{rss_kb}KB obj=#{ObjectSpace.memsize_of(hash)}B"运行方式:
ruby memory_check.rb在多数 CRuby 版本里,你会看到类似下面的现象:
size从 1000000 降到 100000;rss可能只有轻微下降;ObjectSpace.memsize_of(hash)仍然是一个比较大的数值。
原因在于:delete_if是原地修改,它清空了 90 万个键值对,但 Hash 内部那张桶表还保留着按 100 万条数据扩容后的容量。桶表里的空槽位只是标记为“已删除”,并没有归还给内存分配器。
这里有一个容易混淆的点:如果删除键后你不再持有这个大 Hash,Ruby GC 会回收整张表,内存自然会降下来。但问题恰恰是,业务代码往往还需要保留剩下的那一小部分数据。于是,一个只装 10 万条数据的 Hash,带着 100 万条数据的桶表继续活着。
判断是否复现成功,可以看两点:
size明显变小;rss或ObjectSpace.memsize_of(hash)没有按比例下降。
如果你的 Ruby 版本在删除后自动收缩了桶表,那说明当前版本的实现已经优化得比较好,下面的收缩方案可以根据实际情况选用。
5. 收缩 Hash 的几种方法
5.1 遍历重建:最直接可靠
既然旧 Hash 的桶表太大,最简单的思路就是创建一个新 Hash,把剩余键值对依次放进去。
def shrink_hash(original) result = {} original.each { |k, v| result[k] = v } result end hash = shrink_hash(hash) GC.start puts "shrunk : size=#{hash.size} rss=#{rss_kb}KB obj=#{ObjectSpace.memsize_of(hash)}B"新 Hash 在插入过程中会按照实际条目数重新分配桶表,通常不会再按照旧表的规模扩容。旧 Hash 在失去引用后变成垃圾对象,下一次 GC 就会回收它的底层大表。
这个方法有几个优点:
- 逻辑简单,不依赖某个 Ruby 版本内部细节;
- 不改变键值对内容;
- 可以顺便打断原有的超大桶结构。
缺点是会多一次遍历和分配,所以只适合“数据量大、需要长期持有”的场景。
5.2 用 select 替代 delete_if
很多时候,我们不是想“删掉键”,而是想“保留满足条件的数据”。这时直接使用select会返回一个新 Hash,从写法上就避开了原地删除的问题。
hash = hash.select { |k, _| k >= 900_000 }这行代码等价于“新建一个 Hash,把符合条件的键值对放进去”。比起delete_if,它更符合“我要一个小 Hash”的语义,也更容易让代码评审的人看出意图。
如果是既要修改原 Hash,又要释放内存,可以先select出小 Hash,再把原变量指向新对象。注意,如果原 Hash 还被其他变量引用,那个引用仍然会保持大桶表,所以要把所有引用都清理干净。
5.3 rehash 不是收缩内存的万能药
Hash#rehash的作用是根据键当前的hash和eql?结果重建内部索引。它最典型的用途是:键对象在放入 Hash 后被修改了,导致原来的哈希值失效。
key = [1] h = { key => :hello } key << 2 # 此时通过 h 查找原始 key 可能失败 h.rehash puts h[key] # :hello从内部实现看,rehash会重新整理底层表结构,但它主要解决的是“键的哈希值已变化”的正确性问题,不能保证把容量收缩到与当前条目数匹配。如果你的目标是压缩内存,还是优先用遍历重建。
5.4 Hash[] 复制是否可行
Hash[original]也会生成一个新 Hash 对象。新对象在构造时会按新条目数分配桶表吗?这在不同的 Ruby 版本里行为不完全一致。有的版本会沿用原实现的一些容量信息,有的版本会重新分配。
从稳妥角度考虑,不建议把它当作标准收缩方案。需要可预测行为时,用 5.1 的遍历重建最保险。
6. 键类型与值对象的选择
收缩 Hash 解决的是“已有 Hash 太大”的问题。更进一步的优化,是在源头减少 Hash 的膨胀。
键类型影响很大。一段反复执行的代码如果每次都用字符串字面量当键,比如hash["user_1"] = 1,每次都会创建一个新的字符串对象。改成 Symbol 作为固定业务键,可以减少大量临时对象:
HASH_KEY = :user_1当然,动态数据不太适合转 Symbol,因为 Symbol 本身也会占用内存。判断标准很简单:键的集合是不是固定的。固定的用 Symbol,动态的用 String 反而更合适。
值对象也要注意。如果 Hash 里存放的是大量只读字符串,考虑freeze或复用同一个对象。字符串在没有冻结时,即使内容相同,也是一个个独立对象。冻结后可以在一定程度上共享,从而减少重复分配。
极端情况下换数据结构。当你需要保存几百万条扁平记录,每条记录都是一个 5 字段 Hash 时,Hash 本身的开销会非常大。这时可以把字段改成定长数组或 Struct,内存下降往往比收缩 Hash 更明显。
Record = Struct.new(:id, :name, :status) records = [] records << Record.new(1, "alice", :active)Struct 的对象开销比 Hash 小,适合字段固定、数量巨大的场景。如果还需要快速按 id 查找,可以只在外层维护一个 “id -> index” 的 Hash,把数据本体放进数组。
7. 批量任务与内存观测
在实际项目中,最常见的情况是:脚本从文件或数据库读入大量数据,放进 Hash 做处理,处理完一批再读下一批。如果整个流程只有一张大 Hash,内存在高峰时就会很难看。
一个通用的思路是按批次处理,每批做完后主动清理引用,再继续下一批。
def process_in_batches(enum, batch_size: 10_000) batch = [] enum.each do |item| batch << item if batch.size >= batch_size yield batch batch = [] end end yield batch unless batch.empty? end users = (1..1_000_000).lazy.map { |i| { id: i, name: "user_#{i}" } } process_in_batches(users, batch_size: 20_000) do |batch| # 处理当前批次,不要把 batch 缓存到外层 Hash puts "batch size=#{batch.size}" end这里的要点是“不要让中间结果粘连在一个全局 Hash 上”。如果每批数据都要汇总,可以把汇总值放在一个只存计数的小 Hash 里,而不是把所有原始对象都堆进去。
批量任务的内存观测也很重要,建议在脚本里打印关键节点的 RSS:
GC.start rss = File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i puts "after batch #{batch_index}: rss=#{rss}KB"如果 RSS 持续上涨,优先怀疑三个地方:
- 某个 Hash 还在接收新数据,旧数据没有被清理;
- 所有批次结果都累积在数组或 Hash 里;
- 删除键后只改了
size,底层桶表没收缩。
写批量任务之前,先在内存里确定“哪些对象是要跨批保留的”。只保留聚合结果和必要的索引,不要让原始数据一直留在堆里。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 删除大量键后 RSS 不下降 | 底层桶表未收缩,旧 Hash 仍被持有 | 观察 size 与 RSS 变化;检查全局变量引用 | 遍历重建 Hash;释放所有引用 |
| 重建后内存反而升高 | 旧 Hash 还没有被 GC | 在重建后调用 GC.start 再测 | 确认旧 Hash 无引用,等待 GC |
| select 之后原 Hash 还被占用 | 其他变量仍持有原 Hash 引用 | 搜索变量引用关系 | 将旧引用置为 nil |
| rehash 后数据查找异常 | 键对象的 hash/eql? 行为不稳定 | 检查自定义键类的 hash 方法 | 避免使用可变对象做键 |
| Hash 内存降低但 CPU 上升 | 频繁重建大 Hash 导致 CPU 开销 | 用 benchmark 对比重建频率 | 低频重建;换数据结构 |
| 批量任务 RSS 持续上涨 | 中间结果累积在 Hash/数组 | 每批打印 RSS,定位增长点 | 分批处理,清理引用 |
这里把几个关键点拆开说明。
为什么删了键 RSS 还不降?因为删除和内存归还不是同一件事。Hash 底层表保留了大量空槽,这些空槽属于 Hash 对象本身。除非 Hash 被回收,或者桶表被重新分配,否则内存不会释放。
为什么重建后内存反而更高?重建时,旧 Hash 和新 Hash 会同时存在一瞬间。如果旧 Hash 还有 10 万条数据,两者叠加,峰值内存会短暂上升。解决办法是重建完成后立刻把旧引用清掉,并调用一次 GC。
rehash 和收缩的区别。rehash解决键哈希值变化后的正确性问题;收缩解决容量过大的内存问题。两者不是一个东西,不要混用。
自定义键类要格外小心。如果一个类重写了hash方法,但返回值不稳定,放进 Hash 后又会变化,rehash也无法保证所有键都能找回来。更安全的做法是避免用会变化的对象做键。
9. 最佳实践与使用建议
综合前面的分析,下面是一套比较实用的工程化建议。
第一,先建立内存基线。在一个脚本里测量 Hash 插入、删除、重建三个阶段的 size、RSS、ObjectSpace 数值。只有拿到基线,才能判断“需不需要优化”“优化后有没有效果”。
第二,删除大 Hash 后优先重建。如果需要长期保留剩余数据,建议用each遍历重建,而不是delete_if。如果只是取子集,用select拿到新 Hash 更自然。
第三,固定键集合用 Symbol。这个优化虽然小,但在大量小 Hash 组成的数组里效果明显。注意:不要为了追求 Symbol 而给动态数据硬转 Symbol,那会制造出不可控的 Symbol 池。
第四,批量任务按批次处理。不要让所有数据同时堆积在 Hash 或数组里,设置批次大小,每批处理完就把局部变量释放。配合 GC.start 观察,能比较清楚地看到内存涨落。
第五,对 Hash 生命周期做好规划。一个 Hash 如果注定只会被短期使用,就不要把它长期挂在全局变量里。到期请主动设成nil,让 GC 有机会回收。
第六,不要过度优化。如果 Hash 只有几百条数据,或者删除频率很低,重建的额外 CPU 开销不值得。先让代码保持可读,遇到真实内存问题时再做收缩。
第七,替换后跑一遍完整测试。收缩 Hash 不会改变键值对内容,但如果你的 Hash 存储了带重复键的对象,或者依赖default_proc,重建后要确认行为一致。
def rebuild_with_default(original, default_proc) result = Hash.new(&default_proc) original.each { |k, v| result[k] = v } result end10. 总结
这次围绕 “Shrinking Ruby Hashes” 展开的核心结论就一句话:Ruby Hash 的 size 变小,不代表底层内存变小;想要真正释放内存,最可靠的做法是遍历重建 Hash,或者用 select 生成新 Hash。
值得做的三件事:
- 先写一个脚本测量自己 Ruby 版本下“删除键后内存是否下降”;
- 在长驻服务或批量任务里,把大 Hash 的删除改成重建;
- 把 RSS 和 ObjectSpace 的观测加入日常开发调试流程。
最容易踩的坑是以为delete_if之后内存会自动回来。实际上,底层桶表可能还是原来那么大。另一个坑是rehash被误当成内存收缩工具使用,它真正解决的是键哈希值变化后的索引重建。
后续可以继续探索的方向包括:用 Struct 代替大批量小 Hash、用compare_by_identity降低某些场景的哈希计算成本、以及在 Ruby 3.x 上对比不同 GC 配置下的内存表现。建议先跑完上面的脚本,把你当前版本的行为摸清楚,再做后续优化。