暇つぶしに Rails アプリでテーブル全行フェッチする実装で遊んでみる

はじめに
----

`find_each` はメモリに優しいけど、 `ActiveRecord` のインスタンス化は時間に優しくない。これは、そんな些細な事が気になって暇つぶしがてらに遊んでみた記録。

### 制限

- PostgreSQL の事しか考えない
- フェッチしたデータを処理する際にモデルの機能が不要(インスタンス化不要)な場合の話
- 遊びなので全体的に雑


環境
----

- MacBookAir7,1
- Intel Core i7 2.2 GHz
- DDR3 4GBx2
- APPLE SSD SD0256F
- Mac OS X 10.11.4
- Ruby 2.3.0p0 (rbenv)
    - Rails 4.2.6
    - pg 0.18.4
    - postgres-copy 1.0.0
    - factory_girl 4.5.0
    - ridgepole 0.6.4
- PostgreSQL 9.5.2


準備
----

### テーブル

```ruby:Schemafile
create_table 'ten_columns', force: :cascade do |t|
  10.times do |n|
    t.string "column_#{n}"
  end
end

create_table 'hundred_columns', force: :cascade do |t|
  100.times do |n|
    t.string "column_#{n}"
  end
end
```

### モデル

```
$ rails g model TenColumn
$ rails g model HundredColumn
```

```ruby:app/models/ten_column.rb
class TenColumn < ActiveRecord::Base
  acts_as_copy_target
end
```

```ruby:app/models/hundred_column.rb
class HundredColumn < ActiveRecord::Base
  acts_as_copy_target
end
```

### データ

1行あたりのデータ量は同じになる様に調整。

```ruby:spec/factories/ten_columns.rb
FactoryGirl.define do
  factory :ten_column do
    10.times do |n|
      sequence("column_#{n}") { 'a' * 100 }
    end
  end
end
```

```ruby:spec/factories/hundred_columns.rb
FactoryGirl.define do
  factory :hundred_column do
    100.times do |n|
      sequence("column_#{n}") { 'a' * 10 }
    end
  end
end
```

```
$ rails r 'FactoryGirl.create_list(:ten_column, 100000)'
$ rails r 'FactoryGirl.create_list(:hundred_column, 100000)'
```


計測
----

```ruby
require 'ten_column'
require 'hundred_column'
require 'csv'

Benchmark.bm(16) do |x|
  x.report('AR/10:10') do
    TenColumn.find_each(batch_size: 500) { |_| }
  end

  10.step(100, 10) do |n|
    columns = ['id']
    n.times { |i| columns << "column_#{i}" }
    x.report("AR/100:#{n}") do
      HundredColumn.select(*columns).find_each(batch_size: 500) { |_| }
    end
  end

  x.report('CSV/COPY/10:10') do
    Tempfile.create('csv-10col') do |tmp|
      TenColumn.copy_to tmp.path
      CSV.foreach(tmp.path) { |_| }
    end
  end

  10.step(100, 10) do |n|
    columns = ['id']
    n.times { |i| columns << "column_#{i}" }
    x.report("CSV/COPY/100:#{n}") do
      Tempfile.create("csv-100col-#{n}select") do |tmp|
        HundredColumn.select(*columns).copy_to tmp.path
        CSV.foreach(tmp.path) { |_| }
      end
    end
  end

  Tempfile.create('csv-10col') do |tmp|
    TenColumn.copy_to tmp.path
    x.report('CSV/10:10') do
      CSV.foreach(tmp.path) { |_| }
    end
  end

  10.step(100, 10) do |n|
    columns = ['id']
    n.times { |i| columns << "column_#{i}" }
    Tempfile.create("csv-100col-#{n}select") do |tmp|
      HundredColumn.select(*columns).copy_to tmp.path
      x.report("CSV/100:#{n}") do
        CSV.foreach(tmp.path) { |_| }
      end
    end
  end

  sql = TenColumn.all.to_sql
  conn = TenColumn.connection.raw_connection
  deco = PG::TextDecoder::CopyRow.new
  x.report('COPY/10') do
    conn.copy_data "COPY (#{sql}) TO STDOUT", deco do
      while row = conn.get_copy_data
      end
    end
  end

  10.step(100, 10) do |n|
    columns = ['id']
    n.times { |i| columns << "column_#{i}" }
    sql = HundredColumn.select(*columns).all.to_sql
    conn = TenColumn.connection.raw_connection
    deco = PG::TextDecoder::CopyRow.new
    x.report("COPY/100:#{n}") do
      conn.copy_data "COPY (#{sql}) TO STDOUT", deco do
        while row = conn.get_copy_data
        end
      end
    end
  end
end

__END__
                       user     system      total        real
AR/10:10           1.680000   0.190000   1.870000 (  2.156565)
AR/100:10          1.450000   0.040000   1.490000 (  1.672375)
AR/100:20          1.830000   0.050000   1.880000 (  2.062489)
AR/100:30          2.390000   0.080000   2.470000 (  2.695000)
AR/100:40          2.910000   0.080000   2.990000 (  3.275444)
AR/100:50          3.370000   0.130000   3.500000 (  3.822395)
AR/100:60          3.910000   0.120000   4.030000 (  4.406621)
AR/100:70          4.550000   0.150000   4.700000 (  5.203402)
AR/100:80          4.970000   0.150000   5.120000 (  5.575259)
AR/100:90          5.290000   0.150000   5.440000 (  5.904406)
AR/100:100         6.150000   0.180000   6.330000 (  6.853067)
CSV/COPY/10:10     2.260000   0.070000   2.330000 (  2.956577)
CSV/COPY/100:10    1.220000   0.020000   1.240000 (  1.433307)
CSV/COPY/100:20    2.080000   0.020000   2.100000 (  2.389060)
CSV/COPY/100:30    2.990000   0.030000   3.020000 (  3.444904)
CSV/COPY/100:40    3.830000   0.040000   3.870000 (  4.393019)
CSV/COPY/100:50    4.520000   0.050000   4.570000 (  5.199091)
CSV/COPY/100:60    5.560000   0.070000   5.630000 (  6.419458)
CSV/COPY/100:70    6.280000   0.110000   6.390000 (  7.337055)
CSV/COPY/100:80    7.040000   0.120000   7.160000 (  8.266461)
CSV/COPY/100:90    7.860000   0.150000   8.010000 (  9.561194)
CSV/COPY/100:100   8.680000   0.180000   8.860000 ( 10.270413)
CSV/10:10          2.130000   0.070000   2.200000 (  2.238094)
CSV/100:10         1.290000   0.030000   1.320000 (  1.332233)
CSV/100:20         2.160000   0.040000   2.200000 (  2.247108)
CSV/100:30         2.760000   0.040000   2.800000 (  2.848656)
CSV/100:40         3.810000   0.060000   3.870000 (  3.957855)
CSV/100:50         4.430000   0.060000   4.490000 (  4.549781)
CSV/100:60         5.350000   0.100000   5.450000 (  5.615255)
CSV/100:70         5.880000   0.080000   5.960000 (  6.024053)
CSV/100:80         7.160000   0.160000   7.320000 (  7.745105)
CSV/100:90         7.990000   0.170000   8.160000 (  8.405227)
CSV/100:100        8.590000   0.140000   8.730000 (  8.945959)
COPY/10            1.210000   0.130000   1.340000 (  1.352775)
COPY/100:10        0.200000   0.040000   0.240000 (  0.233236)
COPY/100:20        0.400000   0.050000   0.450000 (  0.485219)
COPY/100:30        0.430000   0.050000   0.480000 (  0.505082)
COPY/100:40        0.570000   0.070000   0.640000 (  0.646616)
COPY/100:50        0.670000   0.070000   0.740000 (  0.742535)
COPY/100:60        0.760000   0.070000   0.830000 (  0.842084)
COPY/100:70        0.990000   0.080000   1.070000 (  1.112195)
COPY/100:80        0.950000   0.080000   1.030000 (  1.039029)
COPY/100:90        1.100000   0.100000   1.200000 (  1.201436)
COPY/100:100       1.240000   0.100000   1.340000 (  1.354664)
```


まとめ
----

- `ActiveRecord` のインスタンス化はカラム数に比例して遅くなる
- `find_each` 相当の事を `ActiveRecord` のインスタンス化なしに実現する目的で PostgreSQL の `COPY` を使ってみた
    - 地道に `LIMIT` と `select_rows` でフェッチしても良い気はする
    - 最初、CSV ファイルに書き出してからパースという無駄な実装をしてしまい、しかも相当遅い処理になった
    - `pg` の `copy_data` のデコーダーを使って `Array` で受け取る方法を知った
- `COPY` で1行ずつ `Array` で受け取る方法はそれなりに速い
- 探せば一般的でより良い方法がある気がしてる

以上、暇つぶしおわり。