ruby rails

Railsアプリケーションで発生していたid重複エラーを解決した

ruby rails

最近、ほぼ 10 年ぶりに自作 PC を組みました todacchi です。

元同僚の 3 人と秋葉原のツクモに行ってきたのですが、店員さんが丁寧に相談に乗ってくれて大満足でした。

NZXT H7 Elite の白い個体にうっとりしています。あと GPU でかくなりすぎ。

はじめに

PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "purchaser_addresses_pkey" (PG::UniqueViolation)というエラーが 2015 年ごろから本番環境で発生しており、単純ですが珍しめの発生原因だったのでご紹介しようと思います。

本記事では PostgreSQL の細かい挙動について正確さよりわかりやすさを優先する場合があります。トランザクションやキャッシュ等の説明が入ると本題からそれてしまうので、それらについて知りたい方は公式ドキュメントをご確認ください。

発生していた問題

SUZURI ではサーバーサイドのエラーが Sentry に通知されるようになっていて、エンジニアのミーティングで定期的に確認していました。その中に以下のようなエラーがかなり昔から頻発していました。

PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "purchaser_addresses_pkey" (PG::UniqueViolation)
DETAIL:  Key (id)=(XXXXX) already exists.

  app/controllers/account/purchaser_addresses_controller.rb:19:in `update'
    if @purchaser_address.update(purchaser_address_params)
省略
...
(180 additional frame(s) were not displayed)

ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "purchaser_addresses_pkey" (ActiveRecord::RecordNotUnique)
DETAIL:  Key (id)=(XXXXX) already exists.

エラー発生箇所はアカウントページで住所を更新する処理にあたります。

簡単に直せる問題であれば大抵手の空いた人が直しており長年放置されているということは少なくともぱっと見で原因がわからない塩漬けタスクを意味するのですが、ミーティングで議題に上がったのをきっかけに解決を試みました。

調査

おまけ的な話なので、早く原因が見たい方はこの章を飛ばしても大丈夫です。

エラーを読むとPurchaserAddressテーブルのレコードを作成または更新する時に発生していることがわかります(PurchaserAddressテーブルはユーザーごとに住所を保存するテーブル)。そこでその周囲の調査から取り掛かりました。しかし、クライアントサイドから送られているパラメータを確認するなどしましたが、全く原因となりそうなコードは見つかりませんでした。ここで少し雲行きが怪しくなりました。

他の原因を探して DB を眺めていたらPurchaserAddressテーブルのidcreated_atの順序と大きくずれていることに気づきました。以下の表に例を示します。

created_at id first_name_kana last_name_kana
2023-8-29 18:00 100300    
2023-8-29 17:00 1001 メイ セイ
2023-8-29 16:00 100200    
2023-8-29 15:00 1000 メイ セイ
2023-8-29 13:00 100100    
2023-8-29 12:00 100000    

created_atでソートされていますが、明らかに小さなidが定期的に混じっています。注目したのはid10001001のパターンのようにfirst_name_kanalast_name_kanaが埋まっていることです。これらのカラムはアカウントページで住所を新規登録・更新した時に値が入ります。

ではfirst_name_kanalast_name_kanaが空のパターンはどういった時に発生するかというと、購入時に住所を新規登録した時に該当します。SUZURI の購入フォームはfirst_name_kanalast_name_kanaの入力欄がありません。この時点で購入周りの処理に問題があることがわかりました。

原因

シーケンスの last_value が意図しない値になっている

PostgreSQL のシーケンス(purchaser_addresses_id_seq)を確認すると以下のようになっていました。

SELECT MAX(id) FROM purchaser_addresses
100600

SELECT * FROM purchaser_addresses_id_seq
last_value	log_cnt	is_called
1010	0	TRUE

last_valuenextvalで自動採番された直近の値を返します。正確な説明ではありませんが、ここではレコードを追加するときにlast_value + 1の値が新しいレコードのidとなるとイメージしていただいて問題ありません。この例ではid1011, 1012, … という順でレコードが追加され続けたときに、id100600のレコードを追加しようとしたタイミングでエラーになります。

id重複エラー

正常な状態であればlast_valueMAX(id)以上の値を取っているはずです(例外もあり、例えばテーブルを作ったばかりでレコードが無い時にMAX(id)nullになり、シーケンスのlast_valueis_calledによって処理が分岐する関係で 1 になります)。

したがって自動採番が狂っていることによりidの重複が発生していることがわかりましたので、さらにその原因を探っていきます。

購入機能の問題点

原因は購入時の処理にありました。購入時の処理で注文ごとに発送情報を保存するShippingAddressテーブルにレコードが追加されます。そこに以下のようなコードがありユーザーごとに住所を保存するPurchaserAddressテーブルのレコードを作成しているのですが、なぜかidを指定して作成・更新していました(コードは説明のために書いた例なので実際の処理とは異なります)。これが自動採番が狂った原因でした。

class ShippingAddress < ApplicationRecord
  belongs_to :order, inverse_of: :shipping_address

  after_create do
    purchaser_address = order.purchaser.purchaser_address
    purchaser_address.update(
      attributes.slice(
        'id', 'first_name', 'last_name', 'address'
      )
    )
  end

イメージしやすいように少し例を書いてみます。DB に以下の状態のデータがあるとします。

SELECT MAX(id) FROM purchaser_addresses SELECT last_value FROM purchaser_addresses_id_seq
1000 1000

ここで誰かが商品を初めて購入したとします。すると注文情報とともにShippingAddressテーブルのレコードが追加されます(id: 100000とします)。それによりafter_createブロック内のコードが実行され、PurchaserAddressテーブルにもid: 100000のレコードが追加されます。レコードが追加される時にidを指定すると自動採番に使われるlast_valueは更新されません。したがって DB は以下のような状態になります。

SELECT MAX(id) FROM purchaser_addresses SELECT last_value FROM purchaser_addresses_id_seq
100000 1000

今後レコードが増え続けてlast_value100000 くらいになる頃に id 重複エラーが起き始めます。

対応内容

今回は以下の理由から安全性を重視し、メンテナンスモードに入れて作業することにしました。

  • 環境変数や Unleash でコード変更反映のタイミングを管理することで短いメンテナンス時間で済むこと
  • サービスの歴史の中でシーケンスのlast_valueの変更を行ったことがなく、知見が少ないこと
  • シーケンスのlast_value更新とリリース作業中にレコードが追加されると再度last_value更新が必要になること。さらにその状態ではlast_value更新が成功するまで購入処理が失敗してしまうこと。

購入時にPurchaserAddressテーブルのidが自動採番されるようにコードを修正しシーケンスのlast_valueを更新すれば解決しますが、実際には以下のように順序立てて丁寧に作業を行いました。

  1. 購入と住所更新が行えないようにメンテナンスモードとする
  2. last_valueを更新
    • bundle exec rails runner "ActiveRecord::Base.connection.select_all(%Q[SELECT setval('purchaser_addresses_id_seq', (SELECT max(id) from purchaser_addresses))])"(ワンライナーにした関係でシングルクォートが 2 重の入れ子にならないようにこのような書き方をしています。Rails コンソールで実行するのであればActiveRecord::Base.connection.select_all("SELECT setval('purchaser_addresses_id_seq', (SELECT max(id) from purchaser_addresses))")で問題ありません)
  3. 購入時にPurchaserAddressテーブルのidが自動採番されるように本番環境の挙動を修正
  4. メンテナンスモードを解除

まとめ

今回は、レコード作成時にidを指定していたことにより id 重複エラーとなっていた問題とその解決手順をご紹介しました。原因がわかってしまえば単純な問題でしたが、原因特定のためにログやデータを眺めながらいろいろと仮説を立てて 1 つ 1 つ確認していくという泥臭い作業を行う必要がありました。

idカラムに DB の自動採番を使っている場合に、任意の数字をidカラムに設定することは今回紹介したような問題を引き起こすので避けるべきでしょう。

最後までお読みいただきありがとうございました。