バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

行ロック+トリガーで並列リクエスト時のユニークキー制約違反を解決した話

はじめに

はじめまして、バイセルテクノロジーズ(以降バイセル) テクノロジー戦略本部 開発 1 部買取 2 チームの長野です。

バイセルはリユース事業を行なっており Cosmos と呼ばれる買取から販売まで一気通貫にあらゆる機能を提供するシステムを開発しています。バイセルの買取事業は店舗での買取とお客様のご自宅へ伺う訪問買取に分かれています。私は 2025 年 4 月に新卒入社し、 Cosmos で店舗買取事業を支えるプロダクト(以降 Store )のフロントエンドとバックエンド開発を担当しています。

この記事では、私がチームで長らく課題となっていた「並列リクエスト時のユニークキー制約違反」問題を解決した取り組みについて紹介します。

チーム体制と今回のアラートの焦点

チームのアラート体制

私のチームでは、 Sentry を使ってエラー監視を行っています。エラーが発生すると Slack に通知が飛び、その日の担当者となったメンバーが対応する体制を取っています。

頻発していたアラート

以前から、以下のようなエラーが頻発していることにチームで問題意識を持っていました。

一見別のエラーに見えますが、根本原因は同じでした。契約書の文書番号を採番する際に、 PostgreSQL のユニーク制約に違反して発生していたのです。

※ 当システムはマルチテナント構成のため、テナントごとに文書番号の一意性を保証しています。

前者は 1 ヶ月に約 50 件、後者は 1 ヶ月に約 35 件のペースで発生しており、担当者は開発タスクとアラート調査のコンテキストスイッチが多く生産性が低下していました。

なぜこれらのアラートは長らく放置されていたか

  1. リトライで回避できていた: システムが既に実際の業務で使われており、エラーが発生しても、ユーザーが再度リクエストを送れば成功するため、クリティカルな障害にはなっていなかった。
  2. 影響範囲の把握が難しい: 根本原因はトランザクション分離レベルにあるが、契約書発行という重要な処理全体への影響が読めず、調査に時間がかかると見込まれていた。

これらのことから長らく放置されていました。しかし調査した結果、トランザクション分離レベルは変更しなくても、採番ロジックを変更するだけで解決できる可能性が見えてきました。

並列リクエスト時にユニークキー制約違反が発生するメカニズム

問題のあった採番ロジック

既存の実装では、以下のような SQL で文書番号を採番していました。( SQL は例です)

INSERT INTO contract_documents(
    contract_id,
    doc_number,
    tenant_id
)
VALUES (
    $1,
    (SELECT (coalesce(max(doc_number), 9999999) + 1) FROM contract_documents WHERE tenant_id = $2),
    $2
) RETURNING doc_number

このクエリは、同一テナントの doc_number の最大値を取得し、+1 した値を新しい文書番号として採番しています。

なぜ並列リクエストで問題が起きるのか

以下の図で、問題が発生するメカニズムを説明します。

PostgreSQL のデフォルトの分離レベル( READ COMMITTED )では、各トランザクションは他のトランザクションがコミットした最新のデータを読み取ります。しかし、 Transaction A が INSERT をコミットする前に Transaction B が SELECT max(doc_number) を実行すると、両方のトランザクションが同じ最大値を取得してしまいます。

その結果、両方のトランザクションが同じ doc_number (10000101)を INSERT しようとし、後から INSERT したトランザクションが UNIQUE 制約違反でエラーになります。

解決策の検討

この問題を解決するために、いくつかの方法を検討しました。

検討した選択肢

方法 概要 メリット デメリット
トランザクション分離レベルを上げる SERIALIZABLEに変更 確実に直列化される パフォーマンスが大幅に低下
勧告的ロック pg_advisory_xact_lockを使用 柔軟なロック制御 トランザクション全体がロックされる
行ロック + シーケンステーブル 採番専用テーブルで行ロック 採番部分のみ直列化 テーブル追加が必要

設計から実装:DB 設計の見直し

選択した解決策と理由

行ロック + シーケンステーブル方式を採用しました。

理由は以下の通りです。

  1. パフォーマンスへの影響が最小限: 採番処理のみを直列化し、トランザクション全体はロックしない。
  2. 既存パターンの活用: すでにトリガーを使っている箇所がありチームにも馴染みがあった。

シーケンス管理テーブルの作成

各テナントの最新の文書番号を管理するテーブルを作成しました。

-- シーケンス管理テーブル(例)
CREATE TABLE document_sequences (
  tenant_id uuid NOT NULL,
  last_doc_number integer NOT NULL DEFAULT 10000000,
  updated_at timestamptz NOT NULL DEFAULT now(),
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id),
  FOREIGN KEY (tenant_id) REFERENCES tenants (id)
);

-- 既存テナントのデータ移行
INSERT INTO document_sequences (tenant_id, last_doc_number)
SELECT
    tenant_id,
    COALESCE(MAX(doc_number), 10000000) AS last_doc_number
FROM contract_documents
GROUP BY tenant_id
ON CONFLICT (tenant_id) DO NOTHING;

トリガー関数の作成

INSERT ... ON CONFLICT DO UPDATE を使い、行ロックで排他制御を行うトリガー関数を作成しました。

-- トリガー関数(例)
CREATE OR REPLACE FUNCTION set_document_number()
RETURNS TRIGGER AS $$
DECLARE
    v_next_number integer;
BEGIN
    IF NEW.doc_number IS NULL THEN
        -- 行レベルロックで排他制御をしてUPSERT
        INSERT INTO document_sequences (tenant_id, last_doc_number)
        VALUES (NEW.tenant_id, 10000000)
        ON CONFLICT (tenant_id) DO UPDATE
        SET last_doc_number = document_sequences.last_doc_number + 1,
            updated_at = now()
        RETURNING last_doc_number INTO v_next_number;

        NEW.doc_number := v_next_number;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Go コードの修正

トリガーが自動採番するため、アプリケーション側のコードはシンプルになりました。

// 修正後(トリガー方式、例)
// doc_number は指定しない(トリガーが自動設定)
q := `
INSERT INTO contract_documents(
    contract_id,
    tenant_id
)
VALUES ($1, $2)
RETURNING doc_number
`

並行処理の動作

この実装により、並列リクエストは以下のように処理されます。

同一テナントの場合(直列化)

同一テナントの場合のみ直列化されるため、異なるテナント間のパフォーマンスには影響しません。

実装後の検証:並列リクエストの検証方法とその結果

検証環境

  • 実行環境: ローカル開発環境( Docker Compose )
  • PostgreSQL: 13.22

検証方法の選定

最初は Cloud Run Jobs を使って DB を直接並列実行する方法を検討していました。しかしチームメンバーに相談したところ、「 DB を直接叩くのは実態と合っていない。本番環境での使われ方に近づけるなら、 API 側から叩いたほうがいい」等フィードバックをもらいました。

また、検証方法として curl での並列実行や Postman のパフォーマンステスト機能も提案してもらい、今回は curl でリクエストをバックグラウンドで同時起動するシェルスクリプトを作成する方法を採用しました。

curl による並列実行テストの実施

5 件並列実行:成功

結果:

  • 文書番号は連番(10000240〜10000244、重複なし)
  • 全リクエスト成功
  • ほぼ同時実行( created_at が同じ秒数)

10 件並列実行:タイムアウト

検証中9件までは成功したが、10 件の並列実行でタイムアウトが発生することを発見しました。

実行時間: ちょうど 60 秒(タイムアウト)

{"errors": [{
    "message": "HTTP exception occurred while sending the request",
    "extensions": {
        "internal": {
            "message": "Response timeout",
            "responseTimeout": "ResponseTimeoutMicro 60000000"
        }
    }
}]}

検証中に発見した既存バグ:トランザクション内のコネクション誤使用

調査の結果、タイムアウトの原因は PostgreSQL トリガーではなく、トランザクション内でグローバルな DB 接続を直接使用していたことが分かりました。

問題の詳細

APIの処理内で、トランザクション内にもかかわらずグローバルな DB 接続を直接使用している箇所がありました。また、私たちのプロダクトではDBの最大同時接続数を 10 に設定していました。これにより、以下の問題が発生していました。

  1. コネクションの二重使用: トランザクション用のコネクションとは別に、新しいコネクションをプールから要求。
  2. idle_in_transaction 状態の残存: 使用済みコネクションが idle_in_transaction 状態で残り続ける。
  3. Mutexのロック + コネクション枯渇: 並列リクエストが10件以上だと、コネクションプールが枯渇しアプリケーションロジックにあるMutexのロック処理と併発しタイムアウト。
リクエスト数 コネクション状況 結果
5 件 正常(トランザクション用 5 + 追加要求 1 = 10 未満) 4~700msで完了
9 件 ギリギリ(トランザクション用 9 + 追加要求 1 = 10 要求) 4~700msで完了
10 件 枯渇(11 コネクション以上を要求) 60 秒タイムアウト

この問題は、今回の調査がなければ発見されなかった既存のバグでした。店舗数の増加により利用者数が増えていく中で、このバグが発症するリスクが高まっていたため、一緒に修正しました。別の観点としてDBの最大接続数を引き上げるという判断もありますが、これはせっかく下げた実装の影響度が上がってしまう選択肢だったので除外しました。後日先輩との会話でCloud Runのインスタンス数も増加してしまうとのことで除外したのは正解でした。

修正内容

トランザクション内では、グローバルな DB 接続ではなく、トランザクション用のコネクションを再利用するように修正しました。

// 修正前: グローバルなDB接続を直接使用(例)
func (s *Service) createDocument(ctx context.Context, tx *sql.Tx, ...) error {
    result, err := s.db.Query(ctx, ...)  // 新しいコネクションを要求
    // ...
}

// 修正後: トランザクション用コネクションを再利用(例)
func (s *Service) createDocument(ctx context.Context, tx *sql.Tx, ...) error {
    exec := getContextExecutor(ctx, db)  // トランザクション用コネクションを取得
    result, err := exec.Query(ctx, ...)  // 既存のコネクションを使用
    // ...
}

getContextExecutor は、コンテキストにトランザクションが含まれている場合はそのトランザクション用のコネクションを返し、含まれていない場合は通常の DB 接続を返す自作のヘルパー関数です。これにより、トランザクション内で不要なコネクションを新規に要求することがなくなりました。

修正後の再検証

コネクション取得方法を修正した後、再度 curl による並列リクエストテストを実施しました。

15 件並列実行:成功

  • 全 15 件成功(修正前は 10 件でタイムアウト)
  • タイムアウトなし
  • 文書番号は連番(重複なし)

修正により、トランザクション用のコネクションを正しく再利用できるようになり 10 件以上の並列リクエストにも対応できることを確認しました。

今回の実装の成果

解決したこと

  1. ユニークキー制約違反アラートの解消: リリース後、該当のアラートは発生しなくなりました。
  2. 既存バグの発見と修正: 10 件以上の並列リクエストでシステムが停止する潜在的なバグを発見し、修正しました。

※ 2 つ目のエラーについては、リリース後も別の要因によるアラートが残っているため、完全な解消ではなく頻度の減少となっています。

チームへのインパクト

  • アラート対応によるコンテキストスイッチが減少
  • 将来的なスケーラビリティの懸念を解消

まとめ

本記事では、並列リクエスト時のユニークキー制約違反を解決した取り組みについて紹介しました。

学んだこと

  1. 並列処理の考慮は設計段階で重要: 単体では動作するコードでも、並列実行時に問題が発生することがある。
  2. 結果に満足せず原因を追求する姿勢: 最初は 5 件、 9 件と段階的に検証を進め、全て成功したため「これで OK 」と思った。しかし、チームメンバーから「なぜ10件以上では動かないのか?」と指摘をもらい、この潜在バグをサポートされながら改善することができた。
  3. PostgreSQL のロック機構の活用: 行ロックを使った排他制御は、パフォーマンスを維持しながら整合性を保証できる。

今後の展望

今回の経験を活かし、今後も以下のことに取り組んでいきたいと考えています。

  • トランザクション内のコネクション使用パターンの標準化: 今回作成したヘルパー関数を使った正しいパターンをチーム内で共有し、同様のバグを防ぐ。具体的にはCodeRabbitにこの観点を追加して、レビューの時不要なコネクションプールを使用していないかチェックする。
  • データベース設計における排他制御の深掘り: 今回学んだ行ロックに加え、楽観的ロックなど他の手法も習得したい。まずはPostgreSQLの公式ドキュメントや社内の既存実装を参考に学習を進める予定。また、追加した行ロックがAPIレイテンシに与える影響も、既存のモニタリングツールを活用して計測し、パフォーマンスへの理解を深めていきたい。

新卒 1 年目でチームの長年の課題を解決できたことは、大きな自信になりました。日頃からサポートしてくださったチームメンバーの皆さんに感謝しています。

最後に、バイセルでは新卒エンジニアを随時募集しております。興味のある方はぜひ以下の採用サイトをご覧ください。

herp.careers