【Rails】バルクアップデート

# バルクアップデートとは
複数のレコードを一括で更新する操作のこと。

# どんな時にバルクアップデートをするのか
- <ins>パフォーマンスの向上・負荷削減</ins>
    - 大量のデータを一度に更新する為、レコードを1つずつ更新するよりも遥かに高速
    - DBへのアクセスは比較的高コストな為、一度のクエリでまとめて更新した方が効率的
    - DBへのアクセス回数を減少させるため、サーバーのリソースを節約
- <ins>トランザクションの最適化</ins>
    - 複数のレコードを1つのトランザクションで処理をすることが出来る
    - エラーが発生した場合にはトランザクション全体をロールバックすることが出来る
    - トランザクション全体を制御するため、データの整合性を保つことが出来る
      - 更新がすべて成功するか、すべて失敗するかのいずれか

:::note
- サーバーやDBへの負荷が低減され、処理が高速化される
- データの整合性を保つことが出来る
:::


# サンプルコード

## `#update_all`の場合
```rb
posts = Post.where(category_id: 1)
posts.update_all(updated_at: Time.zone.now, updated_system: 'system')
```

:::note
条件に合致する全てのレコードを一度に更新する。
:::

:::note
数百件程度の一括更新であれば問題なし。
:::

:::note warn
`updated_at`は明示的に記載してあげる必要がある。
:::

:::note warn
単一のテーブルのみ対象。
関連するモデルや関連するテーブルを同時に更新することは出来ない。
:::

:::note warn
`#update_all`は即座にDBを更新するため、一度実行すると変更が即時に反映される。<INS>※元のデータが失われる可能性があるため注意。</INS>
:::

:::note alert
コールバックやバリデーションが実行されない。
`#update_all`はActiveRecordオブジェクトのコールバックやバリデーションをスキップする。つまり、DBに直接クエリを送信して更新されるため、値がデータベースの制約を満たしているかどうかを事前に検証しない。
:::



## `#import!`の場合

https://github.com/zdennis/activerecord-import

[※ Rails6では`#insert_all` `#insert_all!` `#upsert_all`が標準機能としてActiveRecordに追加された](https://railsguides.jp/v6.0/6_0_release_notes.html#active-record-%E4%B8%BB%E3%81%AA%E5%A4%89%E6%9B%B4)（よって`activerecord-import`はそれ以前のバージョン向け）


```Gemfile:Gemfile
gem 'activerecord-import'
```

```terminal:terminal
bundle install
```

```rb:bulk_update_example.rb
class BulkUpdateExample
  def execute
    # カテゴリーとジャンルのIDを取得(サンプルコード用の適当なID)
    @category_id = 1
    @genre_id = 1
    @system = 'sample_system'

    # 1. 10万件のデータを取得
    posts = Post.where(category_id: @category_id).limit(100_000)

    # 2. 配列に直した時にメモリを圧迫しないよう、1000件ずつ分割して実行
    posts.each_slice(1_000) { |target_posts| bulk_update_posts(target_posts) }
  end

  def bulk_update_posts(posts)
    ActiveRecord::Base.transaction do
      # 各レコードに対して同値を設定
      posts.each do |post|
        post.assign_attributes(genre_id: @genre_id, updated_system: @system, updated_at: Time.now)
      end

      # バルクアップデート実行
      Post.import!(posts, on_duplicate_key_update: [:genre_id, :updated_system])

      puts "バルクアップデート成功: #{posts.size}件のレコードが更新されました"
    end
  rescue => e
    # バルクアップデートが失敗した場合のエラーハンドリング
    puts "エラーが発生しました: #{e.message}"
    puts e.backtrace.join("\n")

    # トランザクションをロールバック
    raise ActiveRecord::Rollback
  end
end

BulkUpdateExample.new.execute
```

:::note
`#import!`は、大量のデータを一括で登録または更新する場合に適している。
:::

:::note
`on_duplicate_key_update`オプションは、データをDBに追加または更新しようとした際、一意制約（主キーやユニークキー制約）に違反した場合に、特定のカラムを更新するために使用される。それにより、指定したカラムが更新されるようになる。

①Insert実行
②既にDBに存在する場合、一意制約に引っかかる
③Update実行
```sql:実行されるSQLのイメージ
INSERT INTO "posts" (
  "id", "genre_id", "updated_system", "updated_at"
) 
VALUES 
  (
    1, 1, 'sample_system', '2024-07-10 15:41:07.727030'
  ) 
ON CONFLICT (id) DO 
UPDATE 
SET 
  "genre_id" = EXCLUDED."genre_id", 
  "updated_system" = EXCLUDED."updated_system", 
  "updated_at" = EXCLUDED."updated_at"
RETURNING "id"
```
:::

### UPSERT（ON CONFLICT句）
[UPSERT（ON CONFLICT句）の実装](https://thinkit.co.jp/article/9586)
>ON CONFLICT句にDO UPDATE句を指定すると、テーブルに挿入したい行がまだ存在しない（制約に違反する行が存在しない）場合はDO NOTHING句と同様に通常の挿入処理となります。ただし、テーブルに挿入したい行がすでに存在する（制約に違反する行がすでに存在する）場合は対象行への挿入処理が更新処理として実行されるようになります。


### オプションをもっと細かく設定することも可能
```rb:sample.rb
posts = [
  { id: 1, title: 'Update Title 1', contents: 'Update Contents 1', delete_flg: false, created_at: Time.now, updated_at: Time.now },
  { id: 2, title: 'Update Title 2', contents: 'Update Contents 2', delete_flg: false, created_at: Time.now, updated_at: Time.now }
]

options = {
  on_duplicate_key_update: {
    columns: [:title, :contents],
    conflict_target: [:id],
    condition: "posts.delete_flg = FALSE"
  }
}

Post.import! posts, options
```

- **columns**オプションには、更新するカラムが指定されている
- **conflict_target**オプションには、重複の判定に使うカラムを指定
    - 上の例では、既に同じ`id`がデータベース上に存在する場合に更新を行う
- **condition**オプションには、更新の条件を指定
    - ここでは、`delete_flg`が`false`の場合に更新を行う条件を指定
- **set**オプションを使用すれば、特定の値を設定できる（上記では不使用）
    - 例）`set: { title: "Updated Title", contents: "Updated Contents" },`
- 最後に、`#import`を呼び出し、postsの中身をDBへインポートし、重複の際に指定されたカラムを更新


:::note
**要約**

postsに含まれるデータをDBに追加しようとしている。
もしも既に同じ`id`が存在し、かつ`delete_flg`が**false**の場合にのみ、
`title`と`contents`を更新する。
※例えば`id: 2`がDBに存在しない場合は、新しいレコードとして登録する。
:::


### batch_sizeオプションで制御することも可能
以下はバルクインサートの例（バルクアップデートも同様）
```rb
# Gemfileに以下を追加し、bundle installを実行する
# gem 'activerecord-import'

class BulkInsertExample
  def execute
    # 1万件のデータを生成
    data = []
    current_time = Time.zone.now

    10_000.times do |i|
      # created_at と updated_at を明示的に指定する(通常のActiveRecordのコールバックやデフォルト値の機能が働かないため)
      data << { item_name: "Item #{i}", created_at: current_time, updated_at: current_time }
    end

    # バルクインサートを実行
    Item.import! data, batch_size: 1_000
  end
end

BulkInsertExample.new.execute
```

:::note
**batch_size**は1000に設定されているため、1000件ずつデータが分割されて処理される。
これにより、メモリの消費を抑えつつ効率的にバルクインサートが実行される。
最終的には1万件のレコードがデータベースに追加される。
:::


# バルクアップデートの注意点

:::note warn
- 一意制約を確認する
    - 重複した値がある場合、更新が失敗する可能性があるため
- 大量のデータを操作するため、適切なタイミングで実行する
    - DBへの負荷が低い時間帯を選ぶ等
      - （例）アクセスが少ない深夜帯に実行
- トランザクションの範囲を検討する
    - トランザクションが長時間続くと、DBのロックやパフォーマンス低下といった影響を与える可能性があるため
- 適切なインデックスが設定されていることを確認する
    - 適切なインデックスが存在しない場合、処理が遅くなる可能性がある
:::