55
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Laravel で連番/シーケンスを作るテクニック (採番テーブルの作り方)

Last updated at Posted at 2019-10-17

img.jpg

TL;DR

  • 連番を作るファサードを作る Sequence::getNuewUserNumber()
  • 同時に大量のリクエストがあっても絶対に番号がかぶらない
  • 実際にプロジェクトに使ったものを 自分で再利用するためのコピペコード です。
  • MySQLの細かな挙動については、ワタクシ専門分野ではないので「ちょっと違うぜ!」ということがあるかもしれません。そのあたりは容赦なくマサカリ投げていただけると(嬉しくて)泣きながら勉強して書き直します。
  • 「プログラムってのはこんなことを考えなきゃいけないんだな」という読み物としてご利用いただければと。

なにがしたい?

受注番号とか会員番号とか、何かと「連番」が欲しくなることってありますよね。
もちろん、最もかんたんで最高のパフォーマンスを発揮するのが、ふつうの ID値=SERIAL値 ですが、これはこれでMySQLとかのデータベースにベッタリと依存して引き剥がしにくく、例えば「以前の会員番号はこんなふうだから引き継いでおいて」とか「紙の会員証も発行してるからそれとかぶらないようにね」とか「店舗やオーナーユーザーさんごとに連番は分けないとね」といった要件に対応しきれません。

なるほどなるほど。だったら、今の番号の最大値+1 したら良いんじゃない?

function getNewUserNumber()
{
    $lastuser = User::orderBy('user_no','desc')->first();
    return $lastuser->user_no + 1;
}

って、まさかこんなことしていませんよね? 😋

  • それを発番してから保存するまでの間に、複数のユーザーが同時に登録してきたら、番号かぶりません?
  • いやそもそも「最大の会員番号」を検索するのにどれだけのレコードを探すの?
  • インデックス張っているからいいって? いやいや、それだけのために? やたらレコードロックするのも良くないでしょ。
  • そう発番テーブル!いいね!! でもトランザクションで囲って……コケたらどうなるの?

という面倒くさいことツッコんでくる人を回避するためのちょっとしたテクニックです。

結論

専用のテーブルを用意して、専用のサービスを作ります。

テーブル(マイグレーション)

$ php artisan make:model Sequence -m
Model created successfully.
Created Migration: 2019_10_07_134100_create_sequences_table

中身はこんな感じ。
IDもタイムスタンプもなくて、キーと値だけ保持します。

database\migrations\2019_10_07_134100_create_sequences_table.php
Schema::create('sequences', function (Blueprint $table) {
    $table->string('key', 64);
    $table->integer('sequence')->unsigned();
    $table->primary('key');
});

適用しておきます。

$ php artisan migrate
Successfully Migrated: 2019_10_07_134100_create_sequences_table

モデル Sequence

ここで「このモデルに限り別のDBコネクションを張る」というEloquentのテクニックを使います。
さらにその「別のコネクション」は、ここで標準コネクションからコピーして作るという独自のテクニックを使っています。

app\Sequence.php
class Sequence extends Model
{
    // 独自コネクション名
    // ※ここでしか使わないからあえて config に書かない
    const DB_CONNECTION = 'mysql_sequence';

    // 独自コネクション
    protected $connection = self::DB_CONNECTION;

    // タイムスタンプなし
    public $timestamps = false;

    // キー変更
    protected $primaryKey = 'key';

    protected static function boot()
    {
        parent::boot();

        // デフォルトコネクションをコピーして独自コネクションを作る
        config(['database.connections.' . self::DB_CONNECTION =>
            config('database.connections.' . config('database.default')),
        ]);
    }
}

次のSequenceServiceのメソッド getNewOrderNo や getNewValueAndCommit は
ここにstaticメソッドとして書いてもいいけど、
Eloquentモデルには独自のメソッドを書かず別のサービスクラスに分けたほうが良いと思うので
今回はファサードを作ります。

サービスクラス SequenceService

app\Services\SequenceService.php
namespace App\Services;

use App\Sequence;

class SequenceService
{
    /**
     * 例)受注番号を取得する
     * @return mixed
     */
    public function getNewOrderNo(integer $store_id)
    {
        $value = $this->getNewValueAndCommit('orders:'.$store_id);

        return $value;
    }

    /**
     * 単純に新しい番号を取得する
     *
     * @param  string    $key      同じキー名を与えると前回の続きの値を返す
     * @return int|float $sequence 基本はintだがPHPの限界値を超えるとfloatになる
     */
    protected function getNewValueAndCommit(string $key)
    {
        // config/sequence.php という設定ファイルを作って初期値を用意しておける。
        // なければ 1 からスタート
        $default = config("sequence.default.$key", 1);

        $sequence = Sequence::lockForUpdate()->find($key);
        if( !$sequence ){
            $sequence = new Sequence;
            $sequence->key = $key;
        }

        if (($sequence->sequence ?? 0) < $default) {
            $sequence->sequence = $default;
        } else {
            $sequence->sequence = ($sequence->sequence??0) + 1;
        }
        $sequence->save();

        return $sequence->sequence;
    }

}

まずはサービスが動くかテストしてみましょう。

$php artisan tinker
>>> app('App\Services\SequenceService')->getNewOrderNo(1)
=> 1
>>> app('App\Services\SequenceService')->getNewOrderNo(1)
=> 2
>>> app('App\Services\SequenceService')->getNewOrderNo(1)
=> 3
>>> app('App\Services\SequenceService')->getNewOrderNo(2) // key を変更
=> 1
>>> app('App\Services\SequenceService')->getNewOrderNo(2)
=> 2
>>> app('App\Services\SequenceService')->getNewOrderNo(1) // さっきの key を使うと
=> 4

ファサードにする

詳しくは たった3行 世界一カンタンにLaravelのファサードを作る方法 をご参照ください。

app\Facades\Sequence.php
<?php
namespace App\Facades;

use Illuminate\Support\Facades\Facade;

class Sequence extends Facade 
{
    protected static function getFacadeAccessor()
    {
        return \App\Services\SequenceService::class;
    }
}

エイリアスを追記します。

config\app.php
    'aliases' => [
        'App' => Illuminate\Support\Facades\App::class,
        // ...
        'View' => Illuminate\Support\Facades\View::class,
        'Sequence' => App\Facades\Sequence::class, // 追加

テストします。

$ php artisan tinker
>>> Sequence::getNewOrderNo(1)
=> 5
>>> Sequence::getNewOrderNo(1)
=> 6

できた!

解説

こんな「何の変哲もない連番」を作るのに、なんでこんな込み入ったコードがいるか?
というお話ですが
「同時に複数の人からアクセスされたときに被る」という問題を避けるためです。
例えば…

Aさん
「今の最後の会員番号は1200だな? じゃあ1201を作ろう」

Bさん
(Aさんがなんかやってるけどよくわからんな)
「えーと、最後の会員番号は1200だな? じゃあ1201を作ろう」

こんなことがWEBシステムでは頻繁に、カンタンに起こります。

ロック

こんな衝突を防ぐための仕組みが「ロック」です。

Aさん
「今の最後の会員番号は1200だな? じゃあ1201を作ろう。はいロック!」

Bさん(Aさんがなんかやってるけどよくわからんな)
「えーと、最後の会員番号は…… 何だこれロックされてる! 見れない!!」

というわけで、Bさんは諦めるか、ロックが解除されるまで待たなければいけません。

ロック単位

次の問題は、ロックが、ロックする単位です。
ちょっと前まではテーブル全体がロックされる「テーブルロック」が主流でしたが、最近は「行ロック」が主流です。

Aさん
「今の最後の会員番号は1200だな? じゃあ1201を作ろう。はいロック!」

Bさん(Aさんがなんかやってるけどよくわからんな)
「えーと、最後の会員番号は…… 1199か。じゃあ1200を作ろう。」
※会員番号1200番だけ行ロックで見えなくなっていて、最後の1199番にしれっとアクセスできる現象

ということは実際には起こりませんが、
そもそもロックしたい目的と、ロックする行がズレているので
ちゃんと目的を果たしていません。

Cさん(AさんとBさんがなんかやってるけどよくわからんな)
「はいはい。会員番号 350番さん、住所変更ですね…… ってなにこれ全部ロックされてる!」
※会員テーブルが全部「テーブルロック」されていて全員の更新ができない現象

かと言って、テーブル単位のロックをすると、Cさんのような他の利用者にとても迷惑です。

発番テーブル

そこで、番号だけを管理するテーブルを作っちゃいましょう。
イメージはこんな感じです。

目的 発番した最後の番号
会員番号 1200
受注番号 35400

番号はいくつも持てます。

目的 発番した最後の番号
横浜店の会員番号 540
横浜店の2019年10月の受注番号 320
横浜店の2019年11月の受注番号 54
川崎店の会員番号 339
川崎店の2019年10月の受注番号 184
川崎店の2019年11月の受注番号 35

こうすると、ロックが確実に目的を果たしてくれます。

Aさん
「今の横浜店の最後の会員番号は 540 だな? じゃあ 541 を作ろう。はいロック!」

Bさん(Aさんがなんかやってるけどよくわからんな)
「えーと、横浜店の最後の会員番号は…… 何だこれロックされてる! 見れない!!」

Dさん(AさんとBさんがイチャついてるなぁ)
「さてと、川崎店の最後の会員番号は 339 だな? じゃあ 340 を作ろう。はいロック!」

発番したらすかさず保存する

Bさんがなるべく待たなくていいように、Aさんは541を作ったらすぐにそれを保存しましょう。
ユーザー情報の入力完了を待つ必要はありません。

Aさん
「今の横浜店の最後の会員番号は 540 だな? じゃあ 541 を作ろう。はいロック!」
「で、541 を保存。ロック解除!」

Bさん(Aさんがなんかやってるけどよくわからんな)
「えーと、横浜店の最後の会員番号は…… 541 か。じゃあ 542 を作ろう。」

DBコネクションを分ける

問題は、トランザクションを張ったケースです。

try{
  DB::beginTransaction();
  $member = new Member;
  $member->member_no = Sequence::getNewMemberNo($store_id); // 番号をもらう
  $member->fill( $request->all() );
  $member->save(); // ここで例外が起こるかもしれない
  DB::commit();
} catch ( \RuntimeException $e ) {
  DB::rollback(); // 番号も巻き戻る
}

トランザクションを張った中で採番テーブルから値をもらうと、それが失敗した場合、採番テーブルも巻き戻ってしまいます。

Aさん
「今の横浜店の最後の会員番号は 540 だな?じゃあ 541 を作ろう。はいロック!」
「で、541 を保存……」
「ぎゃー失敗した!ロールバック!」

Bさん(Aさんがなんかやってるけどよくわからんな)
「えーと、横浜店の最後の会員番号は…… 540 か。じゃあ 541 を作ろう。」

Aさん
「あれ?なんか復帰した。541を保存 ♬っと」

これを避けるためには、Laravelでは「トランザクションを分ける」というテクニックを使います。これはLaravelのトランザクションがコネクション単位のためです。トランザクションを分けると、会員をロールバックしても、採番テーブルはロールバックされません。

コネクションを分けるのにいちばんカンタンなのは、モデルにオプションを加えること。

app\Sequence.php
class Sequence extends Model
{
    // 独自コネクション
    protected $connection = 'mysql_for_sequence';

DBコネクションをデフォルトからコピーする

ただ、これだけのために、config/database.php にコネクション情報を追加するのはいまいちカッコ悪いです。いや、書いたほうが後々わかりやすいかもしれないけど。今回は標準のコネクション情報をコピーして再利用するテクニックを採用しました。

app\Sequence.php
class Sequence extends Model
{
    // 独自コネクション名
    // ※ここでしか使わないからあえて config に書かない
    const DB_CONNECTION = 'mysql_sequence';

    // 独自コネクション
    protected $connection = self::DB_CONNECTION;

    protected static function boot()
    {
        parent::boot();

        // デフォルトコネクションをコピーして独自コネクションを作る
        config(['database.connections.' . self::DB_CONNECTION =>
            config('database.connections.' . config('database.default')),
        ]);
    }
}

まとめ

というわけで、ネタとしてはあまり目新しさはありませんが、
「Laravelでやるとこうなる」という例を、
Laravelらしい小技をいくつか使って作ってみました。

「config は後から追加変更できる」というテクニックは
なかなか知られていないというか迂闊に使うとカオスになって危険なので
お取り扱いにはご注意ください……。

55
61
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
55
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?