0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RDBの重複はどこで防ぐべきか、制約・アプリ・クエリの役割分担

0
Posted at

はじめに

この記事では、RDBにおける重複レコードの問題を整理し、DB制約・アプリケーション・クエリそれぞれのレイヤーが何を守るべきかを説明します。

テーブルに重複データが入り込む原因はいくつかあります。一意制約の不足、アプリ側のチェック漏れ、並行処理のタイミング問題が代表的です。これらを適切なレイヤーで防ぐことが、堅牢な設計につながります。

重複には2種類ある

まず、重複を2つに分けて考えます。

設計上許容しない重複:

  • 同じメールアドレスを持つユーザーが2件存在する
  • 同じ注文IDが2行存在する
  • 同じ社員が同じ権限ロールに2回登録されている

これらは業務ルール上、あってはならない重複です。

設計上あり得る重複:

  • 同じ商品を別の日付に2回注文している
  • 同じ顧客が複数の住所を持っている
  • 同じユーザーがログインを複数回記録している

これらはデータとして正しく、重複として防ぐ対象ではありません。

混乱の多くは、この2種類を区別せずに「重複を防ごうとする」ことから生まれます。防ぐべき重複と、そうでない重複を先に整理することが出発点です。

DB制約で防ぐべき重複

設計上許容しない重複は、DB制約で防ぐのが基本です。制約はアプリケーションの実装に関係なく常に機能するため、最後の防衛線として機能します。

単一カラムへのUNIQUE制約:

-- メールアドレスは一意でなければならない
CREATE TABLE users (
    id    INT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(255) NOT NULL,
    name  VARCHAR(100) NOT NULL,
    UNIQUE KEY uq_users_email (email)
);

複合ユニーク制約:

-- 同じユーザーに同じロールを重複して付与できないようにする
CREATE TABLE user_roles (
    user_id INT NOT NULL,
    role_id INT NOT NULL,
    PRIMARY KEY (user_id, role_id) -- 複合主キーで重複を防ぐ
);

-- または複合ユニーク制約で明示する
CREATE TABLE employee_projects (
    employee_id INT NOT NULL,
    project_id  INT NOT NULL,
    joined_at   DATETIME NOT NULL,
    UNIQUE KEY uq_employee_project (employee_id, project_id)
);

DB制約の限界

DB制約にはいくつかの注意点があります。

NULLの扱い:
MySQLのUNIQUE制約はNULLを重複とみなしません。NULLが複数存在してもエラーになりません。NULLを許容するカラムにUNIQUE制約を張る場合は、この挙動を把握しておく必要があります。

-- NULL を含むカラムへのUNIQUEの挙動(MySQL)
CREATE TABLE items (
    id   INT PRIMARY KEY AUTO_INCREMENT,
    code VARCHAR(50) UNIQUE -- NULLは重複として扱われないため、複数のNULLが入る可能性がある
);

並行処理のタイミング問題:
2つのトランザクションが同時に同じ値を挿入しようとした場合、どちらか一方がエラーになります。DB制約が機能していれば問題は防げますが、エラーハンドリングをアプリ側で適切に実装する必要があります。

アプリ側で扱うべき重複判定

重複そのものの最終防止はDB制約で行います。一方で、業務ルールに合わせた入力の解釈と、重複時の応答はアプリケーションレイヤーの責務です。

// 業務ルール:同じ月に同じ顧客から同じ商品への注文は1件まで
// 最終的な重複防止は複合UNIQUEに委ね、アプリ側は入力解釈と応答を担う
func (s *OrderService) PlaceOrder(ctx context.Context, customerID, productID int, month time.Time) error {
    // 月の解釈を業務ルールに合わせて正規化する
    normalizedMonth := normalizeToBusinessMonth(month, "Asia/Tokyo")

    err := s.orderRepo.Save(ctx, &Order{
        CustomerID: customerID,
        ProductID:  productID,
        Month:      normalizedMonth,
    })
    if err != nil {
        if isDuplicateOrderErr(err) {
            return ErrDuplicateOrder
        }
        return fmt.Errorf("注文登録に失敗しました: %w", err)
    }
    return nil
}

この例では、(customer_id, product_id, order_month) の複合UNIQUE制約を前提にしています。
アプリ側は「同じ月」の解釈(タイムゾーンや月初への正規化)をそろえ、重複エラーを業務エラーに変換して呼び出し元に返します。

アプリ側チェックの注意点:競合状態

アプリ側で「SELECT して存在確認してから INSERT する」パターンには、競合状態のリスクがあります。

時刻1: トランザクションAが「重複なし」を確認する
時刻2: トランザクションBが「重複なし」を確認する
時刻3: トランザクションAがINSERTする
時刻4: トランザクションBがINSERTする(重複が発生する)

これを緩和するには次のアプローチが有効です。

SELECT FOR UPDATEで排他ロックをかける:

前提として、orders(customer_id, product_id, order_month) には複合インデックス(可能なら複合UNIQUE)を設定しておきます。

// トランザクション内でSELECT FOR UPDATEを実行してからINSERTする
func (r *OrderRepository) CreateWithLock(ctx context.Context, tx *sql.Tx, order *Order) error {
    // 対象行を先にロックする
    var id int
    err := tx.QueryRowContext(ctx, `
        SELECT id FROM orders
        WHERE customer_id = ? AND product_id = ? AND order_month = ?
        FOR UPDATE
    `, order.CustomerID, order.ProductID, order.Month).Scan(&id)

    if err != nil && !errors.Is(err, sql.ErrNoRows) {
        return fmt.Errorf("ロック取得に失敗しました: %w", err)
    }
    if err == nil {
        // すでに存在する
        return ErrDuplicateOrder
    }

    // 存在しないのでINSERTする
    _, err = tx.ExecContext(ctx, `
        INSERT INTO orders (customer_id, product_id, order_month) VALUES (?, ?, ?)
    `, order.CustomerID, order.ProductID, order.Month)
    return err
}

アプリ側のチェックはDB制約と組み合わせて使うのが基本です。アプリ側だけに頼ると、並行処理やバッチ処理・直接のSQL実行による重複を防げません。
また、SELECT ... FOR UPDATE は有効な手段ですが、存在しないキーに対する競合を常に単独で防げるわけではありません(隔離レベルやインデックス条件に依存します)。
最終的な重複防止は、複合UNIQUE制約を置いたうえで重複エラーを正しく処理する設計が前提になります。

一意制約違反が問題ないケース

一意制約違反を「防ぐべき問題」として扱うのではなく、「発生してよい前提で設計する」ケースもあります。

キャンペーンへのエントリーが典型的な例です。

-- ユーザーとキャンペーンの組み合わせで一意にする
CREATE TABLE campaign_entries (
    user_id     INT NOT NULL,
    campaign_id INT NOT NULL,
    entered_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id, campaign_id)
);

このテーブルへの登録では、「すでにエントリー済みかどうか」を事前にSELECTで確認はします。
ただし、フロントエンド側でボタンの二重送信防止などの制御を入れておくことで、バックエンドは一意制約違反が起きても許容できる設計にできます。

// INSERT IGNORE で一意制約違反をエラーにしない
func (r *CampaignEntryRepository) Enter(ctx context.Context, userID, campaignID int) (bool, error) {
    result, err := r.db.ExecContext(ctx, `
        INSERT IGNORE INTO campaign_entries (user_id, campaign_id)
        VALUES (?, ?)
    `, userID, campaignID)
    if err != nil {
        return false, fmt.Errorf("エントリー登録に失敗しました: %w", err)
    }

    // RowsAffected が 0 なら重複(すでにエントリー済み)、1 なら新規登録
    // ただし sql_mode により一部エラーが警告化された場合も 0 になりうるため、厳密運用時は警告確認や重複エラー判定方式も検討する
    rows, err := result.RowsAffected()
    if err != nil {
        return false, fmt.Errorf("影響行数の取得に失敗しました: %w", err)
    }
    return rows == 1, nil
}
// ハンドラ側でレスポンスコードを分ける
func (h *CampaignHandler) Enter(w http.ResponseWriter, r *http.Request) {
    userID := getUserID(r)
    campaignID := getCampaignID(r)

    created, err := h.service.Enter(r.Context(), userID, campaignID)
    if err != nil {
        http.Error(w, "登録に失敗しました", http.StatusInternalServerError)
        return
    }

    if created {
        w.WriteHeader(http.StatusCreated) // 201: 新規エントリー
    } else {
        w.WriteHeader(http.StatusOK) // 200: すでにエントリー済み
    }
}

このパターンで重要な点は次のとおりです。

  • フロントエンドで二重送信防止の制御を入れておく
  • バックエンドは INSERT IGNORE などで一意制約違反をエラーにしない
  • 新規登録か既存かを RowsAffected で判定し、レスポンスコードで区別する(新規なら201、既存なら200)
  • アプリがエラーを握りつぶしているのではなく、「重複は正常な状態として扱う」という設計上の意図がある

一意制約が貼られているため、万が一フロント制御をすり抜けてもデータの整合性は保たれます。
事前SELECTで状態を確認しつつ、バックエンドは一意制約に委ねるという組み合わせです。

GORMの標準INSERTでエラーを判定する方法

INSERT IGNORE はMySQL固有の構文です。GORMの標準的なINSERTを使いつつ、返ってきたエラーが一意制約違反かどうかを判定して正常扱いにする方法もあります。

// MySQLの一意制約違反エラーを判定するヘルパー
func isDuplicateEntry(err error) bool {
    var mysqlErr *mysql.MySQLError
    // エラー番号 1062 は Duplicate entry
    return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}

func (r *CampaignEntryRepository) Enter(ctx context.Context, userID, campaignID int) (bool, error) {
    entry := &CampaignEntry{
        UserID:     userID,
        CampaignID: campaignID,
    }
    err := r.db.WithContext(ctx).Create(entry).Error
    if err != nil {
        if isDuplicateEntry(err) {
            // 一意制約違反は重複エントリー済みとして正常扱い
            return false, nil
        }
        return false, fmt.Errorf("エントリー登録に失敗しました: %w", err)
    }
    return true, nil
}

INSERT IGNORE との違いは次のとおりです。

方法 特徴
INSERT IGNORE MySQL固有。DBエラーが発生しないため RowsAffected で新規/既存を判定する
エラー判定 ORM標準のINSERTを使える。エラーコードを見て重複だけを正常扱いにする

エラー判定の方法はGORMに限らず database/sql でも同様に使えます。また INSERT IGNORE は一部のエラーが警告化されることがあり、意図しないデータ品質低下を見逃すリスクがあります。エラー判定はエラーコードを確認するため、重複だけを選択的に正常扱いしやすく、他のエラーを見分けやすくなります。

なお、mysql.MySQLError を使った判定はMySQLドライバ前提です。PostgreSQLなど他DBでは対応するドライバのエラー型とコードで判定します。

どちらを選ぶかはチームの規約やDBへの依存度の方針によりますが、一意制約違反だけを選択的に正常扱いにしたい場合はエラー判定の方が安全です。

クエリ側の重複

クエリを書くときに意図せず重複が発生するケースがあります。これは設計の問題ではなくクエリの問題ですが、集計の誤りに直結します。

意図しないJOIN重複:

-- 1人の顧客が複数の注文を持つ場合、JOINすると顧客行が重複する
SELECT c.name, c.email
FROM customers c
JOIN orders o ON o.customer_id = c.id
-- この書き方では、注文が3件ある顧客は3行返ってくる
WHERE o.ordered_at >= '2025-01-01';

-- DISTINCT で重複を排除する
SELECT DISTINCT c.name, c.email
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.ordered_at >= '2025-01-01';

-- またはサブクエリで分離する
SELECT c.name, c.email
FROM customers c
WHERE c.id IN (
    SELECT customer_id FROM orders WHERE ordered_at >= '2025-01-01'
);

集計の二重カウント:

-- 注文テーブルと注文明細テーブルをJOINして合計金額を集計する例
-- 1件の注文に複数の明細がある場合、order.total_amount が重複して合計される
SELECT SUM(o.total_amount) AS 合計  -- 二重カウントが発生する
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.ordered_at >= '2025-01-01';

-- 正しい集計:明細の金額を集計する
SELECT SUM(oi.unit_price * oi.quantity) AS 合計
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.ordered_at >= '2025-01-01';

役割分担の整理

各レイヤーが担う責任をまとめます。

レイヤー 担う責任 主な手段
DB制約 設計上許容しない構造的な重複を防ぐ UNIQUE KEY、複合主キー
アプリケーション 業務ルール由来の重複チェック SELECT FOR UPDATE、エラーハンドリング
INSERT IGNORE 一意制約違反が正常な状態として許容されるケース RowsAffected で新規/既存を判定
クエリ 集計・取得時の意図しない重複の排除 DISTINCT、GROUP BY、サブクエリ分離

基本的な考え方は次のとおりです。

  • DB制約は最後の防衛線として必ず設定する
  • アプリ側のチェックはDB制約と組み合わせる
  • 並行処理が起きる箇所ではロックや排他制御を意識する
  • 一意制約違反が「正常な状態」として扱えるケースは INSERT IGNORERowsAffected で対応する
  • クエリの重複はJOINの構造を見直して原因から対処する

DBとアプリケーションの責務分担についてはCHECK制約はどこまで書くべきか?ビジネスロジックを書かない理由でも整理しています。

まとめ

テーブルに重複が入り込む原因は、制約の不足・アプリのチェック漏れ・並行処理の3つに大別されます。

「設計上許容しない重複」はDB制約で防ぐのが基本です。業務ルール由来の重複チェックはアプリケーション側で担い、DB制約と組み合わせて機能させます。

一方、キャンペーンエントリーのように「重複が起きても正常」なケースでは、フロントエンドで制御を入れつつ、バックエンドは INSERT IGNORE で一意制約違反を許容する設計も選択肢になります。新規か既存かは RowsAffected で判定し、レスポンスコードで区別します。

どのレイヤーで何を守るかを整理しておくと、重複の原因が発生したときに素早く特定できます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?