本番環境でデータを削除、気をつけたいこと5選


はじめに

本番環境のデータ削除は通常の開発プロセスとは似ているようで非なる作業です。
最初から別物として捉えて作業を始める必要があります。
この記事では、本番環境のデータ削除をするときに気をつけたいこと5つを紹介していきます。

1. 工数を過小評価しない

データ削除を行う場合、調査なしで正確な見積もりをすることはほぼ不可能です。
どんなにかんたんに思えても、作業を完了するために少なくとも2週間はかかると考えたほうが無難です。
なぜなら、次の項で述べるような質問に答えるための調査をする必要がありますし、コレでいけると思ったコードではパフォーマンスが出ず、書き直す必要がある。といった事態も起こりうるからです。
チームとしてある程度時間がかかるものという共通認識を持つことが大切です。

2. 事前調査をする

作業に取り掛かる前に、以下の質問に全て答えるための調査をしましょう。
すべての質問に答えることができたら、コーディングに取り掛かりましょう。

  • 削除することになるレコード数はどのくらいか?
  • どうやって対象のデータが削除されたこと、対象外のデータが削除されていないことを確認することができるか?
  • 削除対象のデータは何か?
  • 削除対象のデータにどのくらい関連データがあるか?
  • 本番環境でのデータ削除にかかる時間は?
  • すべての関連データを削除するのが現実的ではない場合、代替案や落とし所はあるか?
  • DB事態のバックアップを作ることは可能か?

3. パフォーマンスに配慮したコードを書く

さて、事前調査が終わったら実際にコードを書いていきましょう。
その際には、パフォーマンスに配慮することが重要です。今回はRuby on Railsで例を説明しますが、大枠の注意点はどの言語においても変わらないはずです。

Eコマースサイトを運営していて、そこには注文データであるordersと、注文に含まれる商品、order_itemsがあると仮定します。

イテレーションをネストしない

関連したデータをもれなく削除したいと思ったときに、陥りやすい罠です。
ネストが深くなればなるほど、発行されるSQLは掛け算で増えていき、パフォーマンスは飛躍的に悪くなります。工夫によりN+1問題は解決できたとしても、DELETE文はレコード数分呼ばれてしまいます。

Order.where(user_id: 10).all.each do |order|
  order.order_items.each do |order_item|
     order_items.something.each do ||
       ...
     end

     order_item.delete
  end
  order.delete
end

バッチ処理を行う

もし数千行を超える量のレコードを削除する必要があるとたら、データベースのリソースをうまく扱うことを考えなければいけません。バッチを使うことを考えましょう。
Railsであれば、デフォルトAPIとして提供されている find_in_batches か、delete_in_batches gemというgemを使うといいでしょう。

Order.find_in_batches(batch_size: 5000).each do |orders|
  orders.delete_all
  sleep(1) # throttle down DB
end

destroy_allではなくdelete_allを使う

Ruby on Railsには、delete と destroyという2つの削除に関するメソッドがあります。
destroyメソッドは削除を実行する前に, before_destroy, after_destroyの2つのコールバックを実行します。delete メソッドは、単純にDELETEのSQLを実行する分高速で、上記例のように, delete_allを使用したほうがパフォーマンスは上がります。
注意すべき点としては、当然ですが、コールバックで行われていた処理を手動でケアする必要があります。

sleep処理を入れる

上記コードサンプルのように、DBリソースを有効活用するため、sleep処理をすることがRails公式でも推奨されているようです。

4. インデックスを確認をする

限られたサンプルデータでテストしていると、開発段階では見逃す可能性のある問題です。
よく知られているように、検索対象のデータにインデックスがなければ、フルテーブルスキャンを行うので、パフォーマンスが著しく低下します。
以下のようなテーブルがあると仮定します。

                  Table "addresses"

   Column  | Type |     Modifiers
-----------+------+------------------------------
 id        | int  | not null default nextval(...
 street_no | int  | not null
.
.
.
Indexes:
    "idx_primary" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "users" CONSTRAINT "fk_users_addresses" FOREIGN KEY (address_id) REFERENCES addresses(id) NOT VALID

                  Table "users"
    Column   |  Type  |         Modifiers
-------------+--------+--------------------------------
 id          | bigint | not null default nextval(...
 address_id  | int    |
.
.
.
Indexes:
    "idx_primary" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "fk_users_addresses" FOREIGN KEY (address_id) REFERENCES addresses(id) NOT VALID

Case 1: Whereで指定しているカラムにインデックスがない

DELETE users where email = 'something@localhost';

emailを指定してusersからデータを削除しようとしているが、emailにindexが貼ってないというパターンです。おそらく一回は目にしたことがあるのではないかと思います。

Case 2: 制約チェックをしている別のテーブルのカラムにインデックスがない

DELETE addresses;

addressesを削除する際、addressesはusersから参照されており、usersはaddress_idに対してforegn_key制約を持っています。しかし、usersテーブルのaddress_idにインデックスがないので制約チェックのためにフルテーブルスキャンを行っているというパターンです。addressesのほうに気を取られていると、意外に気が付かない罠です。

5: 一部データのみを削除する

もし、削除対象のデータに関連するデータも多数あり、指定された期間内に全てをやりきるのが不可能に思える場合は、一時的に要件を満たす一部のデータを削除することを考えましょう。
例えば、orderを削除したいが、関連するデータがいくつも(例: order_items, purchesed_users, addresses, items...)あるような場合です。
ただしこの場合、親がないデータが大量に発生することになります。技術的負債として扱わなければならなくなるので、関係者と共通認識を持つことが重要です。

to_be_deleted テーブルを作成する

対象のデータのみを削除し、近い将来関連データを削除できるようにするために、to_be_deletedテーブルを作成しましょう。このテーブルには、削除対象となるデータのidを保持します。以下に例を示します。

-- orders_to_be_deleted
Table "orders_to_be_deleted"
  Column  |  Type   | Modifiers
-----+---------+-----------
 order_id | integer | not null
Indexes:
    "index_orders_to_be_deleted_on_order_id" btree (order_id)

このidテーブルを作り、削除対象のidを保持することで、データを削除後も、idを参照することができます。

リファレンス

まとめ

以上、本番環境でデータ削除するときに気をつけたいことを5つ紹介してみました。
データ削除をする際は参考にしてみてください!