MySQLを活用したグローバル注文ID生成システムの実装

従来のID生成手法と課題

大規模なシステムにおいて、一意な注文ID(オーダー番号)を生成する一般的な手法として、主に以下の2つのアプローチが採用されてきました。

1. タイムスタンプと乱数の組み合わせ
日時やマイクロ秒に乱数を付与してIDを生成する方法です。実装は簡単ですが、高并发な環境(同時アクセスが多い状況)では重複が発生するリスクがあります。重複を防ぐために一意制約(Unique Index)を設け、挿入時に照会を行う必要がありますが、これは処理速度を低下させる要因となります。

2. 現在のレコード数を基準とした連番
日付+「現在のレコード総数+1」で生成する方法です。一見すると合理的ですが、過去の注文データが削除された場合(論理削除を含む)、削除された番号が再利用される可能性があります。業務によっては「欠番」が生じること自体は許容されても、番号の巻き戻りや再利用はデータ整合性の観点から望ましくない場合が多いです。

これらの課題を解決するため、独立したグローバルカウンターテーブルを利用したアプローチが推奨されます。この方法では、注文データの削除の有無に関わらず、常に一意で連続性が保証されたIDを生成できます。

データベース設計

注文番号の管理専用テーブルを設計します。ここでは、IDの競合を防ぐための排他制御と、十分な桁数を確保するためのデータ型選択が重要です。注文番号のプレフィックス(接頭辞)として「年月」を使用し、月ごとの集計処理を容易にします。

CREATE TABLE `sequence_counter` (
  `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
  `counter_key` VARCHAR(100) NOT NULL COMMENT 'カウンタを識別するキー (例: 202310_SALES)',
  `current_value` BIGINT(20) UNSIGNED NOT NULL DEFAULT '0' COMMENT '現在のシーケンス値',
  `category_type` TINYINT(3) UNSIGNED NOT NULL DEFAULT '1' COMMENT '種別 (1:販売, 2:購入, 3:物流など)',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_counter_key` (`counter_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='グローバルシーケンスカウンター';

current_valueBIGINT 型を使用することで、将来的な拡張や大量の注文にも対応できるようにします。また、counter_key に一意制約を設定することでデータの一貫性を保証します。

実装ロジック(PHP/Laravel例)

以下に、トランザクションと排他ロック(SELECT FOR UPDATE)を用いたID生成の実装例を示します。このコードは、同時実行制御(Concurrency Control)を考慮し、重複のない連番を生成します。

また、単純な「+1」ではなく、特定の条件下では「+3」などの増分ステップを設けることで、手動での緊急挿入や特殊注文のための「番号予備(ホール)」を確保する設計にしています。これにより、本来の自動採番と手動採番が衝突するリスクを低減できます。

use Illuminate\Support\Facades\DB;
use App\Models\SequenceCounter;

class OrderIdGenerator
{
    // 種別定数
    const TYPE_SALES = 10;
    const TYPE_PURCHASE = 20;
    const TYPE_LOGISTICS = 30;
    const TYPE_INBOUND = 40;

    /**
     * 一意な注文IDを生成する
     *
     * @param int $type 注文種別
     * @return string 生成された注文ID
     * @throws \Exception
     */
    public static function generate(int $type): string
    {
        $now = time();
        $prefix = date('Ym', $now); // プレフィックスは年月(例: 202310)
        
        // 種別に応じたサフィックスキーを取得
        $suffix = self::getSuffixByType($type);
        $key = $prefix . '_' . $suffix;

        DB::beginTransaction();
        try {
            // 排他ロックをかけて現在のレコードを取得
            // これにより、他のトランザクションが同時にこの行を読み書きすることを防止します
            $counter = SequenceCounter::where('counter_key', $key)
                ->lockForUpdate()
                ->first();

            // レコードが存在しない場合は初期化
            if (!$counter) {
                $counter = SequenceCounter::create([
                    'counter_key' => $key,
                    'current_value' => 0,
                    'category_type' => $type
                ]);
            }

            // 増分ロジックの決定
            // 通常は3ずつ増やし、予備番号を確保する(例外的な挿入用)
            // 特定の種別(TYPE_INBOUNDなど)では1ずつ増やす
            $step = ($type === self::TYPE_INBOUND) ? 1 : 3;
            
            $nextValue = (int) $counter->current_value + $step;

            // データベースを更新
            $counter->update(['current_value' => $nextValue]);

            // フォーマット処理
            // 種別によってパディング桁数を調整
            $paddingLength = ($type === self::TYPE_INBOUND) ? 6 : 12;
            $sequencePart = str_pad((string) $nextValue, $paddingLength, '0', STR_PAD_LEFT);
            
            $orderId = $prefix . $sequencePart;

            DB::commit();
            
            return $orderId;

        } catch (\Exception $e) {
            DB::rollBack();
            throw new \Exception('注文ID生成に失敗しました: ' . $e->getMessage());
        }
    }

    /**
     * 種別からキー用サフィックスを取得
     */
    private static function getSuffixByType(int $type): string
    {
        $map = [
            self::TYPE_SALES => 'sales',
            self::TYPE_PURCHASE => 'purchase',
            self::TYPE_LOGISTICS => 'logistics',
            self::TYPE_INBOUND => 'inbound',
        ];

        if (!isset($map[$type])) {
            throw new \InvalidArgumentException('無効な種別が指定されました');
        }

        return $map[$type];
    }
}

重要な技術的ポイント

1. 排他ロック(FOR UPDATE)の重要性
トランザクション内で lockForUpdate() を使用することが不可欠です。トランザクション分離レベルに関わらず、読み取り時点から更新時点までの間に他のプロセスが同じデータを読み込んでしまう(ダーティリードや更新の消失)のを防ぐため、必ず行ロックを取得する必要があります。

2. インクリメントステップ(+1 vs +3)
単純な+1のインクリメントでもID生成は可能ですが、システム運用上、エンジニアによる手動注文の挿入や、バッチ処理による過去日付への注文登録などが発生することがあります。カウンターを大きく飛ばす(例:+3)ことで、これらのレアケースでも番号が衝突しない「予備領域」を動的に確保できます。

3. パフォーマンスについて
「単一テーブルへの書き込みはボトルネックになるのでは?」という懸念が持たれることがありますが、RDBMS(特にInnoDB)の行ロック処理は非常に高速です。通常のECサイトや業務システムの注文生成程度の負荷であれば、MySQLは十分にパフォーマンスを発揮します。ボトルネックが発生するほどのトラフィックがある場合は、シャーディングや別のID生成スキーマ(Snowflakeなど)の検討が必要ですが、多くのケースではこの方式で十分対応可能です。

4. 日付フォーマットの選択
IDのプレフィックスには「年月(YYYYMM)」を使用することを推奨します。「年月日(YYYYMMDD)」を使用すると、日付変更時にカウンタのリセット処理やキーの切り替えが頻繁になり、統計処理が煩雑になる場合があります。月単位で管理することで、集計クエリの記述が簡素化され、データのライフサイクル管理も容易になります。

タグ: MySQL Transaction laravel concurrency DatabaseDesign

5月26日 17:43 投稿