最近、ほぼ 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
テーブルのid
がcreated_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
が定期的に混じっています。注目したのはid
が1000
と1001
のパターンのようにfirst_name_kana
とlast_name_kana
が埋まっていることです。これらのカラムはアカウントページで住所を新規登録・更新した時に値が入ります。
ではfirst_name_kana
とlast_name_kana
が空のパターンはどういった時に発生するかというと、購入時に住所を新規登録した時に該当します。SUZURI の購入フォームはfirst_name_kana
とlast_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_value
はnextval
で自動採番された直近の値を返します。正確な説明ではありませんが、ここではレコードを追加するときにlast_value + 1
の値が新しいレコードのid
となるとイメージしていただいて問題ありません。この例ではid
が1011
, 1012
, … という順でレコードが追加され続けたときに、id
が100600
のレコードを追加しようとしたタイミングでエラーになります。
正常な状態であればlast_value
はMAX(id)
以上の値を取っているはずです(例外もあり、例えばテーブルを作ったばかりでレコードが無い時にMAX(id)
はnull
になり、シーケンスのlast_value
はis_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_value
が 100000
くらいになる頃に id 重複エラーが起き始めます。
対応内容
今回は以下の理由から安全性を重視し、メンテナンスモードに入れて作業することにしました。
- 環境変数や Unleash でコード変更反映のタイミングを管理することで短いメンテナンス時間で済むこと
- サービスの歴史の中でシーケンスの
last_value
の変更を行ったことがなく、知見が少ないこと - シーケンスの
last_value
更新とリリース作業中にレコードが追加されると再度last_value
更新が必要になること。さらにその状態ではlast_value
更新が成功するまで購入処理が失敗してしまうこと。
購入時にPurchaserAddress
テーブルのid
が自動採番されるようにコードを修正しシーケンスのlast_value
を更新すれば解決しますが、実際には以下のように順序立てて丁寧に作業を行いました。
- 購入と住所更新が行えないようにメンテナンスモードとする
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))")
で問題ありません)
- 購入時に
PurchaserAddress
テーブルのid
が自動採番されるように本番環境の挙動を修正 - メンテナンスモードを解除
まとめ
今回は、レコード作成時にid
を指定していたことにより id 重複エラーとなっていた問題とその解決手順をご紹介しました。原因がわかってしまえば単純な問題でしたが、原因特定のためにログやデータを眺めながらいろいろと仮説を立てて 1 つ 1 つ確認していくという泥臭い作業を行う必要がありました。
id
カラムに DB の自動採番を使っている場合に、任意の数字をid
カラムに設定することは今回紹介したような問題を引き起こすので避けるべきでしょう。
最後までお読みいただきありがとうございました。