Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

一意のIDを持たないデータベースでActiveRecordを扱う——重複ID問題への対処と教訓

時には、特殊な状況や制御できない事情によって、型破りな要件に直面することがあります。最近、私はデータベースのIDに一切頼らずにActiveRecordを使わなければならないという経験をしました。同じことを検討している方がいれば、別の方法を見つけることを強くお勧めします!それでは、話の続きに進みましょう。

決定はすでに下されていました。構造は同一ですがデータが異なる複数の小規模データベースを統合する必要があったのです。私がプロジェクトに参加したのは、チームがあるデータベースから別のデータベースへレコードをコピー&ペーストするスクリプトの仕上げを行っている、まさにそのタイミングでした。このスクリプトはIDも含めて、すべてをそのままの形でコピーしていました。

データベースA

idfruituser_id
.........
123orange456
.........

データベースB

idfruituser_id
.........
123banana74
.........

マージ後のデータベースA

idfruituser_id
.........
123orange456
123banana74
.........

IDを持つ根本的な理由である「一意な識別」が壊れてしまいました。詳細まではわかりませんでしたが、重複したIDがシステムに入り込めば、さまざまな問題が発生すると直感しました。何か言おうとはしましたが、私はプロジェクトに参加したばかりで、他のメンバーはこれが最善の道だと確信している様子でした。数日後にはコードをデプロイし、重複IDを持つデータを扱い始める予定でした。もはや問いは「これをすべきか?」ではなく、「どうやって実現するか?」「あとどれくらい時間がかかるか?」へと変わっていたのです。

重複IDとの付き合い方

では、重複したIDを持つデータをどう扱えばよいのでしょうか?解決策は、複数のフィールドを組み合わせた複合IDを作ることでした。当時のDBフェッチはほぼ次のような形でした。

# これは動作しません。id: 123 のユーザーが2人いる可能性があります
FavoriteFruit.find(123)

# 複数のIDでクエリを正しいレコードに絞り込む
FavoriteFruit.find_by(id: 123, user_id: 456)

すべてのActiveRecord呼び出しがこのように更新され、コードをざっと見た限りでは理にかなっているように思えました。デプロイするまでは。

地獄の始まり

コードをデプロイして間もなく、電話が鳴り始めました。顧客は合計が合わない数字を目にし、自分のレコードを更新できず、さまざまな機能が壊れていきました。

どうすればよいのでしょうか?私たちはコードをデプロイしただけでなく、あるデータベースから別のデータベースへデータを移動させていました(さらにデプロイ後に新しいデータが作成・更新されています)。単純なロールバックで済む状況ではありません。迅速に修正する必要がありました。

Railsは内部で何をしているのか?

デバッグの第一歩は、現在の挙動を把握し、エラーを再現する方法を見つけることでした。本番データのクローンを取得し、Railsコンソールを起動しました。環境設定によっては、ActiveRecordクエリを実行した際にRailsが発行するSQLクエリが自動的には表示されないことがあります。コンソールでSQLステートメントを確実に表示するには、次のようにします。

ActiveRecord::Base.logger = Logger.new(STDOUT)

その後、いくつかの一般的なRailsクエリを試しました。

$ FavoriteFruit.find_by(id: 123, user_id: 456)

FavoriteFruit Load (0.6ms)
SELECT  "favorite_fruits".*
FROM "favorite_fruits"
WHERE "favorite_fruits"."id" = $1
AND "favorite_fruits"."user_id" = $2
[["id", "123"], ["user_id", "456"]]

find_byは正常に動作しているように見えました。しかしその後、次のようなコードを目にしました。

fruit = FavoriteFruit.find_by(id: 123, user_id: 456)
...
...
fruit.reload

このreloadが気になったので、こちらも試してみました。

$ fruit.reload

FavoriteFruit Load (0.3ms)
SELECT  "favorite_fruits".*
FROM "favorite_fruits"
WHERE "favorite_fruits"."id" = $1
LIMIT $2
[["id", 123], ["LIMIT", 1]]

これはまずい。find_byで最初に正しいレコードを取得できていても、reloadを呼び出すたびに、レコードのIDだけを使ったシンプルなfind-by-idクエリが実行されるのです。当然ながら、重複IDがあるため、誤ったデータが返されることが頻繁に起こりました。

なぜそんな動作をするのでしょうか?手がかりを求めてRailsのソースコードを調べました。Ruby on Railsで開発することの素晴らしい点の一つは、ソースコードがプレーンなRubyで書かれ、自由に参照できることです。「ActiveRecord reload」で検索すると、すぐに次のコードが見つかりました。

# File activerecord/lib/active_record/persistence.rb, line 602
def reload(options = nil)
  self.class.connection.clear_query_cache

  fresh_object =
    if options && options[:lock]
      self.class.unscoped { self.class.lock(options[:lock]).find(id) }
    else
      self.class.unscoped { self.class.find(id) }
    end

  @attributes = fresh_object.instance_variable_get("@attributes")
  @new_record = false
  self
end

このコードが示すように、reloadは要するにself.class.find(id)のラッパーです。IDのみによるクエリがこのメソッドに組み込まれていました。重複IDを扱うには、Railsのコアメソッドをオーバーライドするか(決してお勧めできません)、reloadの使用を完全にやめる必要がありました。

私たちの解決策

そこで、コード内のすべてのreloadを洗い出し、複数のキーによるDBフェッチを実現するためにfind_byへ置き換えることにしました。

しかし、これで解決したのは一部のバグだけでした。さらに調査を進めた結果、update呼び出しもテストすることにしました。

$ fruit = FavoriteFruit.find_by(id: 123, user_id: 456)
$ fruit.update(last_eaten: Time.now)

FavoriteFruit Update (43.3ms)
UPDATE "favorite_fruits"
SET "last_eaten" = $1
WHERE "favorite_fruits"."id" = $2
[["updated_at", "2020-04-16 06:24:57.989195"], ["id", 123]]

またしてもまずい結果です。find_byで特定のフィールドによってレコードを絞り込んでいても、Railsレコードに対してupdateを呼び出すと、シンプルなWHERE id = xクエリが生成されます。これも重複IDがあると壊れてしまいます。どう回避したのでしょうか?

独自のupdateメソッドupdate_uniqueを作成しました。次のようなものです。

class FavoriteFruit
  def update_unique(attributes)
    run_callbacks :save do
      self.class
        .where(id: id, user_id: user_id)
        .update_all(attributes)
    end
    self.class.find_by(id: id, user_id: user_id)
  end
end

これにより、ID以外の条件でもレコードを絞り込んで更新できるようになりました。

$ fruit.update_unique(last_eaten: Time.now)

FavoriteFruit Update All (3.2ms)
UPDATE "favorite_fruits"
SET "last_eaten" = '2020-04-16 06:24:57.989195'
WHERE "favorite_fruits"."id" = $1
AND "favorite_fruits"."user_id" = $2
[["id", "123"], ["user_id", "456"]]

このコードにより、レコード更新の適用範囲を狭く限定できます。ただし、クラスのupdate_allメソッドを呼び出すことで、通常レコード更新時に伴うコールバックが失われる点に注意が必要です。そのため、コールバックを手動で実行し、update_allが更新後のレコードを返さないため、もう一度DB呼び出しを行って更新済みレコードを取得する必要がありました。最終的なコードはあまり汚くはありませんが、fruit.updateよりは明らかに読みにくくなっています。

本当の解決策

サンクコスト(埋没費用)、経営層の判断、時間的制約により、私たちの解決策は、すべてのDB呼び出しで複数キーを使うようRailsにモンキーパッチを当てることでした。顧客が製品を購入し使い続けられるという意味では「機能」しましたが、いくつかの理由から悪いアイデアでした。

  • 今後の開発で、一般的なRailsメソッドを使うことで意図せずバグが再導入される可能性があります。新しい開発者には、reloadメソッドの使用など、隠れたバグを避けるために厳格なトレーニングが必要になります。
  • コードがより複雑になり、明確さと保守性が低下します。これは技術的負債であり、プロジェクトが進むにつれて開発速度をどんどん遅くしていきます。
  • テストが大幅に遅くなります。関数が動作することだけでなく、さまざまなオブジェクトが重複IDを持つ場合にも動作することをテストする必要があります。テストの作成に時間がかかり、テストスイートを実行するたびに余分なテストを通す時間も増えます。さらに、各開発者がすべての可能なシナリオを慎重にテストしない限り、バグを見逃しやすくなります。

この問題の真の解決策は、そもそも重複IDを作らないことです。あるデータベースから別のデータベースへデータを移す必要があるなら、移行スクリプトはIDを除外した状態でデータを収集・挿入し、受け側のデータベースが標準化された自動インクリメントカウンターによって各レコードに固有のIDを割り当てるようにすべきです。

もう一つの解決策は、すべてのレコードにUUIDを採用することです。この種のIDは、整数IDのように順番に数え上げるのではなく、ランダムに生成された長い文字列です。そのため、他のデータベースへデータを移動しても競合や問題が発生しません。

結論として、Railsは「IDがレコードごとに一意であり、データベース内の特定データを操作する迅速で簡単な手段である」という前提のもとに設計されています。Railsはopinionated(強い設計思想を持つ)フレームワークであり、その美しさは、Rails流のやり方に従う限り、すべてが滑らかに動作する点にあります。これはRailsだけでなく、プログラミング全般の多くの側面にも当てはまります。物事が複雑になったときには問題を特定する力が重要ですが、明確で保守しやすい、慣習に沿ったコードを書けば、こうした複雑さの多くをそもそも回避できるのです。

  1. 読書好き必見!Microsoft Edgeの「閲覧モード」と「内蔵辞書」の使い方

    Microsoft Edgeは、2015年に登場したWindows標準搭載のWebブラウザです。登場以来、Microsoftは継続的に機能を追加・改善しており、圧倒的な人気を誇るGoogle Chromeに対抗しうるブラウザへと進化を遂げています。 本記事では、Edgeに搭載されている「閲覧モード(Reading View)」と「内蔵辞書」の使い方をわかりやすく解説します。 これらの機能は以前から存在しますが、その存在を知らずに活用できていない方も多いのではないでしょうか。私たちは日々、ネット上で良質な記事を探して読んでいます。だからこそ、快適な読書環境はとても重要です。 読書がお好きな方なら

  2. Windows 7 を Windows 11 に無料でアップグレードする方法(データ消失なし)

    まだWindows 7を使用しているなら、最新のWindows 11へのアップグレードを検討しましょう。MicrosoftはすでにWindows 7のサポートを終了しており、セキュリティ更新プログラムやバグ修正が定期的に提供されなくなっています。そこで多くのユーザーが抱く疑問が「Windows 7からWindows 11へ無料でアップグレードできるのか?」というもの。答えは「はい」ですが、そのためにはまずWindows 10にアップグレードし、その後Windows 11へ移行する必要があります。MicrosoftがWindows 11の無料アップグレードを提供しているのは、互換性のあるWind