【Laravel×MySQL】複数同時アクセスでトランザクションを張るタイミングが被ると他方のcommitされた最新情報を認識できない問題をlockForUpdateで解決
シナリオ
在庫管理のシステムにおいて
前提
前提として、トランザクション分離レベルというものがあるらしい。
- デフォルトのMySQL分離レベルは「REPEATABLE READ」の状態
「REPEATABLE READ」の特性:
つまり、同時にトランザクションを開始した場合、リクエストA完了後すぐのリクエストBでは、トランザクション開始時点のスナップショットを見るため、Aの変更が反映されていない
トランザクション分離レベルを「READ COMMITTED」に変更すれば、他のトランザクションがコミットした変更が現在のトランザクション内から見えるようにすることはできるらしい。
でも、他の処理に影響でそうで怖いのでそれはやりたくない。
現状
REPEATABLE READの状態
- リクエストA: トランザクション開始
- リクエストB: トランザクション開始
- リクエストA: 商品Xの在庫数を確認(残り1個と判断)
- リクエストA: 商品Xの減算処理を実行
- リクエストA: トランザクションをコミット(在庫が0個に)
- リクエストB: 商品Xの在庫数を確認(まだ1個と判断)👈問題発生
- リクエストB: 商品Xの減算処理を実行(在庫不足なのに減算)
- リクエストB: トランザクションをコミット
リクエストA: トランザクションX開始 → 在庫確認(1個) → 商品X減算処理 → トランザクションX終了(在庫0個)
↓
リクエストB: トランザクションX開始 -------------------------→ 在庫確認(1個) → 商品X減算処理 → トランザクションX終了(在庫-1個) ← 問題発生!
問題点: リクエストBは自分のトランザクション開始時点のデータを参照するため、リクエストAがコミットした在庫減少を認識できない
解決方法
lockForUpdateを使う。
「lockForUpdate」の特性:
- 対象レコードに排他ロックを取得する
- 最新のコミット済みデータを読み取る(これが重要な違い)
- 他のトランザクションがロックを取得しようとすると待機させる
lockForUpdateを使用した場合
- リクエストA: トランザクション開始
- リクエストB: トランザクション開始
- リクエストA:
Parent::lockForUpdate()でロック取得 - リクエストA:
ChildX::lockForUpdate()で在庫を確認(残り1個と判断) - リクエストA: 商品Xの当選処理を実行
- リクエストA: トランザクションをコミット(ロック解放、在庫が0個に)
- リクエストB:
Parent::lockForUpdate()でロック取得(Aのコミット後なのでロック取得可能) - リクエストB:
ChildX::lockForUpdate()で在庫を確認(0個と判断)👈正しい判断 - リクエストB: 在庫不足のため別の景品を選択
- リクエストB: トランザクションをコミット
Parent::lockForUpdate()に関しては、lockForUpdateのそもそもの特徴として「対象レコードに排他ロックを取得する」というものがあるので、
デッドロックを防ぐために、商品Xの親の概念(Parent)に対して先にロックをかけている。
リクエストA: トランザクションX開始 → lockForUpdate取得 → 在庫確認(1個) → 商品X当選処理 → トランザクションX終了(在庫0個)
↓
リクエストB: トランザクションX開始 ---------------→ lockForUpdate待機 → lockForUpdate取得 → 在庫確認(0個) → トランザクションX終了
一言
便利そうだけどデッドロッグには細心の注意を!
参考
【データベース】ロック(排他制御)
複数ユーザーが同時にDB更新しても矛盾が起きないようにする仕組み
ロックの種類
- 共有ロック(READロック)
- 他ユーザーのSELECTだけ認める
- ユーザーのログイン処理とか
- 占有ロック(WRITEロック)
- 他ユーザーの一切の処理を認めない
- ECサイト等で在庫数で矛盾が起きないようにSELECTも認めたくないとき
ロックの範囲
- テーブルロック
- 行(=レコード)ロック
SELECT FOR UPDATE
- 行単位で共有ロックをかけるSQL文(他ユーザーのSELECTのみ許す)
- 必ずトランザクションを開始すること(開始しないとただのSELECT文になる)
- 他ユーザーが対象の行をSELECT FOR UPDATEしようとすると、待ち状態になる
- 一定時間が過ぎると
Lock wait timeout exceeded;エラーになる - トランザクションを開始した方を
commitすると、SELECTできるようになる
- 一定時間が過ぎると
注意点
- トランザクションの中で行うこと
- ユニークに行指定できる条件を使うこと
- 処理が終わったら
COMMITorROLLBACKでトランザクションを終了させてロック解除 beginしたらすぐにSELECT FOT UPDATE
どのロックが良いのか?
- ケースバイケース
- アドバイザリロックが便利
- 楽観的ロックはほぼ見かけない
- SELECT FOR UPDATEは使用注意(デッドロックしないように!)
参考
パーティションでDBの検索速度を速める
パーティションとは
DB検索を速くするために、テーブルを内部的に分割する技術のこと。
インデックスでも速くならない場合の検討材料
性能の向上
テーブルの分割により、アプリケーションからのSQLアクセスにおいて、検索性能の向上が見込まれます。性能向上を実現する要因には以下があります。
パーティションの種類
- リスト
- キーカラムを固定値で分割する方法
- キー値の取りうる値の種類があらかじめ判明していて、その数が少ない場合に有効
- レンジ
- キーカラムの値の範囲で分割する方法
- 日付や連続するIDなどで分割したいときに有効
- ハッシュ
- 均等に分割する方法
- 不特定多数、一意のキー値をもとに分散させたい場合に有効
MySQLの注意点
- パーティションキーカラムは、プライマリーキー or ユニークキーにするという制約がある
- 外部キー制約が使えない
参考
データベースの種類
復習
階層型
- ツリー構造:1本の幹から複数の枝に分かれながら作っていく
- 親データ:子データ = 1 対 複数
- 1つのデータまでのルートが必ず1つなため、ある特定のデータを検索する場合に高速
ネットワーク型
- ツリー構造
- 親データ:子データ = 複数 対 複数 なので柔軟に格納できる
リレーショナル型
- 表形式
- RDBMS(MySQL、PostgreSQL、OracleDatabase、SQLserver)
- 複雑なデータ向き
- データが正確
- 検索スピードが遅い
キーバリュー型(NoSQL)
- 単純な構造のデータに向いている
- キーとバリューを1つの組みにして格納
- NoSQL(Not Only SQL)(Redis、Riak、Hibari)
- 検索スピードが早い
- 少ない容量で保管できる
【SQL】LIKE検索は%を3つ以上含めてパターン検索できる
リクストされたパスを保存しているログから対象の商品IDを含むレコードを取得したいとする。
例:ログテーブル
| id | path |
|---|---|
| 1 | /item_ids=5,10,24&created_at=2023-04-01 |
| 2 | /item_ids=22,23,98&created_at=2023-04-12 |
| 3 | /item_ids=25,36&created_at=2023-04-30 |
| 4 | /item_ids=50,55,66,78&created_at=2022-10-01 |
| 5 | /item_ids=19&created_at=2022-07-05 |
困ってたこと
SQLのLIKE検索で、意図しない箇所で検索ヒットしてしまう。
item_idに10が含まれるレコード(id: 1)だけを取得したいのに、
下記の書き方だとcreated_atが10を含む箇所(id: 1 と id: 4)もヒットしてしまう。
例
SELECT * FROM Log WHERE path LIKE '%10%';
解決策
SELECT * FROM Log WHERE path LIKE '%item\_id=%10%created_at%';
こうすれば、item_idの中に10を含むもので、その後にcreate_atが来るように順番を指定することができる。
ありがとう、ChatGPT。
【PHP】cronで1億件のデータを移行
1億件のIoT計測データのデータ移行を行った話。
cronでやることになった背景
cronでデータ移行することになった背景はこんな感じ。

カラム名は仮です。
- 年ごとの計測テーブルBに保存されている、ある期間の数種類の製品の計測データを、四半期ごとの計測テーブルA-Xテーブルに移行したい(1〜3月の計測データならA-1、4〜6月ならA-2、7〜9月ならA-3、10〜12月ならA-4)。
- 移行時に気を付ける点は下記だった。
- Bテーブルの製品番号(
product_no)と計測日時(datetime)の組み合わせと、同じ組み合わせのレコード(つまり同じ計測データ)が既にAテーブルにも保存されている可能性があった。 - Aテーブル内では容量の関係で同じ計測データはなるべく重複しないようにする(事情により、
product_noとdatetimeの組み合わせで uniqueキーは貼られていない)。 - Cテーブルにも、Bテーブルの製品番号(
product_no)と計測日時(datetime)の組み合わせが同じレコードが存在する場合がある。BテーブルからAテーブルへ移行する際、Cテーブルにも同じ組み合わせのレコードが存在する場合は、そのデータもAテーブルへ一緒に移行する。
- Bテーブルの製品番号(
- 上記のように、ちょっと複雑な条件だったので、dumpファイルを作って一気に移行する作戦はできなかった。
- 結果、PHPでプログラムを書いて少しずつ移行するように確定。
- このとき、一気に全データを取得するようにするとメモリが尽きるし、タイムアウトで処理が途中で途切れてしまう懸念があったので、安全に処理が完了するように少しずつデータ移行することにした。流れは下記の通り。
- Bテーブルから条件に該当するデータをちょうどいい量SELECT
- aで取得したデータを1件ずつループ
product_noとdatetimeが同じ組み合わせのレコードが、既にAテーブルに存在するか確認。- あれば
continue - なければ、Cテーブルにも同じ組み合わせのレコードがないか確認して、あればSELECT
- BテーブルとCテーブルのデータをAテーブルへINSERT。
- 移行完了をログに吐き出す
- 繰り返し
- これを人の手で都度確認してると大変なので、cronを書いてやってしまおう!となった。
プログラムの実装
詳細は書かないが、頑張って実装。
レコード数が膨大なので、発行されるSQLが負荷のかからないSQLかどうかを、EXPLAINで確認しながら実装する。
- EXPLAINの確認項目
| 項目 | 確認事項 |
|---|---|
| type | indexかALLじゃないか |
| rows | 多すぎないか |
| Extra | Using index condition Using where Using index あたりか |
処理の流れはざっとこんな感じ。

cronの設定
phpの場所の確認
$ which php
/usr/bin/php
現在設定されているcronの確認
$ crontab -l
crontab: no crontab for name
cronタブを vi で開いて、1時間おきにPHPプログラムを実行したかったので、下記のように記載して、 :wq で保存。
$ crontab -e
* */1 * * * /usr/bin/php /var/www/cron/migration_test.php
保存したら設定完了と出てくる。
crontab: installing new crontab
cronが実行されているか確認。
$ sudo tail -f cron Apr 16 23:01:01 xxxx CROND[]: (xxxx) CMD (php /var/www/cron/migration.php)
ちゃんと実行されてるっぽい。
でもデータベースを確認すると、なぜか更新されていない。
ログを吐くようにしてみる。
再度 cronタブを開いて、ログを吐くように追記。
$ crontab -e * */1 * * * /usr/bin/php /var/www/cron/migration_test.php >> /var/www/cron/migration.log 2>&1
これでもう一度cronを実行してみて、吐き出されたログを確認してみると、 fopen() でファイルを開こうとしている箇所で怒られていた。
failed to open stream: No such file or directory in /var/www/.....
ググってみると、cronで実行するプログラムでは相対パスではなくて絶対パスで指定する必要があるらしい。
絶対パスに修正して、再度cron実行!
これでちゃんと少しずつデータの移行が行われ始めました。
1億件なので時間はかかりますが、少しずつ。
参考
【Laravel】リポジトリパターンでデータ周りの要求変更に負けない設計パターンを取り入れる
こちらの本で勉強中なので、学習記録として。
リポジトリパターンとは
リポジトリパターンとは、ビジネスロジックからデータの保存や復元を別レイヤ(リポジトリ層)へ移し分離・隠蔽することで、コードのメンテナンス性やテストの容易性を高める実装パターン。
ビジネスロジックからデータストアに対して直接操作する処理を切り離し、何らかのデータ保管庫(リポジトリ)に対して、データの保存や復元を行う処理を抽象的に扱うオブジェクトを用意する。

対象サンプル
出版社テーブル(publishers)へのデータ操作を例とする。
| Field | Type | Null | Key | Default | Extra |
|---|---|---|---|---|---|
| id | bigint unsigned | NO | PRI | NULL | auto_increment |
| name | varchar(100) | NO | NULL | ||
| address | text | NO | NULL | ||
| created_at | timestamp | YES | NULL | ||
| updated_at | timestamp | YES | NULL |
出版社を新規に追加するWebAPIを作成していく。
エンドポイントは /api/publishers とする。
作成していくのは下記の3つのファイル。
- データベースアクセスを受け持つEloquent(Publisher)
- ビジネスロジックを受け持つサービスクラス(PublisherService)
- リクエストを受けるコントローラクラス(PublisherAction)
実装
Publisherクラス
nameとaddressカラムのみ登録可能にする。
<?php // app/DataProvider/Eloquent namespace App\DataProvider\Eloquent; use Illuminance\Database\Eloquent\Model; class Publisher extends Model { proteced $fillable = [ 'name', 'address', ]; }
PublisherServiceクラス
existsメソッドで、引数nameで指定された名前と同じ出版社名がないかを確認し、もし同じ出版社名がすでに登録されていたら true を返す。
storeメソッドで、新たに登録して、シーケンス値(id)を返す。
<?php // app/Services namespace App\Services; use App\DataProvider\Eloquent\Publisher; class PublisherService { public function exists(string $name): bool { $count = Publisher::whereName($name)->count(); if ($count > 0) { return true; } return false; } public function store(string $name, string $address): int { $publisher = Publisher::create( [ 'name' => $name, 'address' => $address, ] ); return (int)$publisher->id; } }
PublisherActionコントローラクラス
ユーザーからリクエストを受けて、nameで指定された名前と同じ出版社名が存在しないか確認する。同一出版社がすでに登録済みならなにも行わず、HTTPステータス200で返す。
登録されていない場合は、新規で登録してHTTPステータス201で返す。
<?php // app/Http/Controllers namespace App\Http\Controllers; use App\Services\PublisherService; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; class PublisherAction { private $publisher; public function __construct(PublisherService $publisher) { $this->publisher = $publisher; } public function create(Request $request) { if ($this->publisher->exists($request->name)) { return response('', Response::HTTP_OK); } $id = $this->publisher->store($request->name, $request->address); return response('', Response::HTTP_CREATED) ->header('Location', '/api/publishers/' . $id); } }
最後に、エンドポンとを登録するために、 routes/api.php にルート追加。
Route::post('/publishers', [App\Http\Controllers\PublisherAction::class, 'create']);
リファクタリング
PublisherServiceクラスを確認すると、データの存在確認やデータ登録の処理は、EloquentであるPublisherクラスに依存してしまっている。
つまり、MySQLに接続できるEloquentを利用することが前提となっている。
データベースの代わりにモックを利用したり、Eloquent以外のデータ操作クラスを利用しようとすると、このサービスクラスを大幅に修正する必要が出てきてしまう。
そこで、ビジネスロジックから特定のデータベース操作を取り除いていく。
手順は下記のとおり。
- Repositoryを抽象化するインターフェースとEntityクラスを作成する
- データベース操作を担当するRepositoryクラスを作成する
- Serviceクラスはインターフェースを参照する
- インターフェースと具象クラスを紐づける
データ操作をServiceクラスから見た場合、Publisherオブジェクトは、同名出版社の存在確認と登録処理ができればいいため、この2つの処理を持つクラスを「リポジトリ」として新たに定義する。
同時に処理を抽象化し、これを表現したクラスをインターフェースとして作成する。
リポジトリインターフェース
インターフェースクラスなため、出版社名をキーにデータ取得を行うfindByNameと登録処理を行うstoreメソッド定義のみを行う。
<?php // app/DataProvider/PublisherRepositoryInterface.php namespace App\DataProvider; use App\Domain\Entity\Publisher; interface PublisherRepositoryInterface { public function findByName(string $name): ?Publisher; public function store(Publisher $publisher): int; }
PublisherのEntityクラス
<?php // app/Domain/Entity/Publisher.php namespace App\Domain\Entity; class Publisher { protected $id; protected $name; protected $address; public function __construct(?int $id, string $name, string $address) { $this->id = $id; $this->name = $name; $this->address = $address; } public function getName(): string { return $this->name; } public function getAddress(): string { return $this->address; } }
リポジトリインターフェースを実装した具象クラス
上記のインターフェースの実処理を行う具象クラス。
PublisherActionクラスで実行していたデータアクセス処理をこちらに移動。
<?php // app/Domain/Repository/PublisherRepository.php namespace App\Domain\Repository; use App\DataProvider\PublisherRepositoryInterface; use App\DataProvider\Eloquent\Publisher as EloquentPublisher; use App\Domain\Entity\Publisher; class PublisherRepository implements PublisherRepositoryInterface { private $eloquentPublisher; public function __construct(EloquentPublisher $eloquentPublisher) { $this->eloquentPublisher = $eloquentPublisher; } public function findByName(string $name): ?Publisher { $record = $this->eloquentPublisher->whereName($name)->first(); if ($record === null) { return null; } return new Publisher( $record->id, $record->name, $record->address, ); } public function store(Publisher $publisher): int { $eloquent = $this->eloquentPublisher->newInstance(); $eloquent->name = $publisher->getName(); $eloquent->address = $publisher->getAddress(); $eloquent->save(); return (int)$eloquent->id; } }
これで、データ操作の実処理はリポジトリクラスに移った。
PublisherServiceクラスでは、MySQLのデータアクセスクラスを直接利用していたが、抽象クラスであるPublisherRepositoryInterfaceをコンストラクタインジェクションで引数として渡す形式に置き換え可能なので、リファクタリングしていく。
<?php // app/Services namespace App\Services; use App\DataProvider\PublisherRepositoryInterface; use App\DataProvider\Eloquent\Publisher; class PublisherService { private $publisher; public function __construct(PublisherRepositoryInterface $publisher) { $this->publisher = $publisher; } public function exists(string $name): bool { if (!$this->publisher->findByName($name)) { return false; } return true; } public function store(string $name, string $address): int { return $this->publisher->store(new Publisher(null, $name, $address)); } }
こうすれば、
- このサービスクラスは同じPublisherRepositoryInterfaceインターフェースを持つクラスであれば何でも動作することになる。
- ユニットテストではモッククラスを利用可能。
- 他のデータストアを利用することになっても、サービスクラスには変更を加えることなく差し替えが可能。
- コントローラもこのサービスクラスを利用するので、データストア先の変更にも影響を受けない。
最後に、インターフェースと具象クラスの関連づけ(バインド)を行う。
サービスプロバイダクラスのregisterメソッドに記述する。
例ではデフォルトで用意されている App\Providers\AppServiceProvide クラスに登録するが、新たにサービスプロバイダを作成しても良い。
<?php namespace App\Providers; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { public function register() { $this->app->bind( \App\DataProvider\PublisherRepositoryInterface::class, \App\DataProvider\PublisherRepository::class, ); } }
もし、データストア先を変更する場合は、PublisherRepositoryInterfaceを持ったデータ操作クラスを新たに作成してバインド定義し直せば、ビジネスロジックを変更することなく、データ操作処理のみを差し替えることができる。
リポジトリパターンは各クラスを疎結合にできる反面、クラス数が増えるため、短期限定でしようするプログラムには不要かもしれない。
が、システムの要件や規模の拡張が見込まれるサービスでは、いいデザインパターン。

