翻译 使用 Ruby 处理大型 CSV 文件

xguox · 2016年10月26日 · 最后由 early 回复于 2018年05月29日 · 16825 次阅读
本帖已被管理员设置为精华贴

早些个月看到的一篇,翻译了以后好久其实也好像没啥机会正式用到,今天刚好要处理一个 100+ MB 的 CSV 文件,想起了这篇。跑的时候顺手打开监视器看看内存使用什么的,个别方法都跑到 6G + 内存了 Σ( ° △ °|||)︴

处理大文件是一项非常耗内存的操作,有时候甚至会跑光服务器上的物理内存和虚拟内存。下面来看看使用 Ruby 来处理大型 CSV 文件的几种方式,同时测试一下这几种方式的内存消耗以及性能。

准备测试用的 CSV 数据文件。

在开始之前,先准备一个拥有一百万行的 CSV 文件 data.csv(大约 75mb) 用于测试。

# generate_csv.rb
require 'csv'
require_relative './helpers'

headers = ['id', 'name', 'email', 'city', 'street', 'country']

name    = "Pink Panther"
email   = "[email protected]"
city    = "Pink City"
street  = "Pink Road"
country = "Pink Country"

print_memory_usage do
  print_time_spent do
    CSV.open('data.csv', 'w', write_headers: true, headers: headers) do |csv|
      1_000_000.times do |i|
        csv << [i, name, email, city, street, country]
      end
    end
  end
end

内存及时间的消耗

上面这个脚本需要引用到 helpers.rb 脚本,helpers.rb 定义了两个 helper 方法来测量并打印出内存以及时间的消耗情况。

# helpers.rb
require 'benchmark'

def print_memory_usage
  memory_before = `ps -o rss= -p #{Process.pid}`.to_i
  yield
  memory_after = `ps -o rss= -p #{Process.pid}`.to_i

  puts "Memory: #{((memory_after - memory_before) / 1024.0).round(2)} MB"
end

def print_time_spent
  time = Benchmark.realtime do
    yield
  end

  puts "Time: #{time.round(2)}"
end

执行并生成 CSV 文件:

$ ruby generate_csv.rb
Time: 7.14
Memory: 4.79 MB

不同的机器输出结果也不尽相同,不过,幸运的是,得益于 garbage collector (GC) 回收已使用过的内存,这个 Ruby 进程耗掉的内存 (4.79MB) 并不是很夸张。同时生成的数据文件如图为 74MB.

CSV.read

# parse1.rb
require_relative './helpers'
require 'csv'

print_memory_usage do
  print_time_spent do
    csv = CSV.read('data.csv', headers: true)
    sum = 0

    csv.each do |row|
      sum += row['id'].to_i
    end

    puts "Sum: #{sum}"
  end
end

执行结果:

$ ruby parse1.rb

Sum: 499999500000
Time: 21.3
Memory: 1277.07 MB

惊人的超过 1GB 内存消耗啊。

CSV.read 源码

 # File csv.rb, line 1750
def read
  rows = to_a
  if @use_headers
    Table.new(rows)
  else
    rows
  end
end

CSV.parse

# parse2.rb
require_relative './helpers'
require 'csv'

print_memory_usage do
  print_time_spent do
    content = File.read('data.csv')
    csv = CSV.parse(content, headers: true)
    sum = 0

    csv.each do |row|
      sum += row['id'].to_i
    end

    puts "Sum: #{sum}"
  end
end

执行结果:

$ ruby parse2.rb
Sum: 499999500000
Time: 21.88
Memory: 1362.89 MB

可以看到,内存消耗的比刚刚第一个脚本还略多,差距大概就是刚好 CSV 文件大小。

CSV.parse 源码

# File csv.rb, line 1293
def self.parse(*args, &block)
  csv = new(*args)
  if block.nil?  # slurp contents, if no block is given
    begin
      csv.read
    ensure
      csv.close
    end
  else           # or pass each row to a provided block
    csv.each(&block)
  end
end

CSV.new

# parse3.rb
require_relative './helpers'
require 'csv'

print_memory_usage do
  print_time_spent do
    content = File.read('data.csv')
    csv = CSV.new(content, headers: true)
    sum = 0

    while row = csv.shift
      sum += row['id'].to_i
    end

    puts "Sum: #{sum}"
  end
end

执行结果:

$ ruby parse3.rb
Sum: 499999500000
Time: 16.89
Memory: 76.72 MB

这次结果可以看到,因为只是在内存中加载了整个文件内容,所以内存的消耗大概就是文件的大小 (74MB), 而处理时间也快了不少。当我们只是想逐行逐行的操作而不是读取要一次过读取一整个文件时候,这种方法非常奏效。

通过 IO 对象一行一行解析

虽然有了很大的进步,但是,使用 IO 文件对象还可以做得更好。

# parse4.rb
require_relative './helpers'
require 'csv'

print_memory_usage do
  print_time_spent do
    File.open('data.csv', 'r') do |file|
      csv = CSV.new(file, headers: true)
      sum = 0

      while row = csv.shift
        sum += row['id'].to_i
      end

      puts "Sum: #{sum}"
    end
  end
end

执行结果:

$ ruby parse4.rb
Sum: 499999500000
Time: 13.78
Memory: 2.64 MB

仅仅用了 2.64MB, 速度也稍稍又快了一些。

CSV.foreach

# parse5.rb
require_relative './helpers'
require 'csv'

print_memory_usage do
  print_time_spent do
    sum = 0

    CSV.foreach('data.csv', headers: true) do |row|
      sum += row['id'].to_i
    end

    puts "Sum: #{sum}"
  end
end

执行结果跟上一个差不多:

$ ruby parse5.rb
Sum: 499999500000
Time: 15.54
Memory: 2.62 MB

CSV.foreach 源码

# File csv.rb, line 1118
def self.foreach(path, options = Hash.new, &block)
  return to_enum(__method__, path, options) unless block
  open(path, options) do |csv|
    csv.each(&block)
  end
end
huacnlee 将本帖设为了精华贴。 10月27日 13:55

这个好,👍 💪

只是 parse CSV 还好。如果处理的内容比较重的话,还是牺牲内存追求时间了。

大点的文件使用 IO 对象应该是最好的做法了,不用操心内存占用。具体场景中 csv 读取后一般就是数据库操作,比如插入数据。这时可以采取一些并发方式写入数据库,进一步优化整个过程,不过那是另一个问题了。

早些个月用了 1G 的 csv 文件做的测试。。。

看完之后马上去检查自己写的 rake 果然是 CSV.parse 😂

#12 楼 @IChou ++++++++++1 😂 😂 😂 😂 😂 😂 😂 😂

def self.parsed_csv
  csv_text = File.read(Rails.root.join('lib/data', 'initial_xxxxxxx_items.csv'))
  CSV.parse(csv_text, headers: true)
end

你好,请问为什么我的 CSV.read 反而比 CSV.parse 这种方式要少一个 CSV 文件左右呢?

貌似直接写循环数组读取也不会有多慢啊,30 万行,100M 左右,我还要转格式生成大约 3 倍以上的 output,用时也就 10 多分钟

我觉得,处理大文件时基本原则有两个:

  1. 尽量不要一次性把所有内容都读取到内存中。按需读取,用完就释放。
  2. 尽量使用简单的数据结构。csv 文件的每一行解析为一个 hash 还是 array,在内存占用方面肯定差别很大。

#15 楼 @qunlee 还是贴一下主要代码吧,这么凭空说不准哪儿的问题

easonlovewan Ruby 中读取大文件 提及了此话题。 11月09日 17:00
flemon1986 Ruby 中读取大文件 提及了此话题。 11月09日 19:18

正在处理一个 1G CSV 文件的导入。。正好看到此贴

请问下,如何处理多个维度的 csv 表格,比如有多行多列 的那种表格

judi0713 数据库已经有了,如何重写成 Rails 应用 提及了此话题。 03月21日 09:12

最近使用 ps -o rss= -p #{Process.pid} 来检测代码内存消耗,但一直输出是 0, GC.disable 也是一样。搜索到这里,我把楼主的代码 copy 了一份,执行后,内存消耗依然输出是 0。

wwjdeMac-Pro-3:$ ruby parse1.rb
Sum: 499999500000
Time: 14.17
Memory: 0.0 MB

我的系统及 ruby 信息是:

wwjdeMac-Pro-3:$ ruby -v
ruby 2.3.6p384 (2017-12-14 revision 61254) [x86_64-darwin16]

wwjdeMac-Pro-3:$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29

大神们,请问这是什么情况?

tmr Rails 上传文件,编码问题 提及了此话题。 06月26日 17:07
需要 登录 后方可回复, 如果你还没有账号请 注册新账号