データベースの行ロックをちゃんと理解する(PostgreSQL, MySQL)
はじめに
先日データのロックに関わる実装の方針に携わりましたが、思ったよりもロックについて知らない自分に気づきました。
データベースを考えるうえで、ロックとは長く付き合っていかなければならないものなので、今回を機に改めて学び直してみました。
今回はデータベースにおけるロックの中でも、行ロックに焦点を当てています。
また、実際の Ruby on Rails の内部実装にも焦点を当て、Ruby on Rails がどのように行ロックを実現しているかもおまけ的に記述しました。
この記事は少々長いですが、以下の順番に記載しています。
- 基本の概念
- PostgreSQL, MySQL におけるロックの挙動
- (おまけ)Ruby on Rails における実装
ここだけ知りたいという方は、飛ばし飛ばしお読みいただいて構いません。
メインのテーマとしては、PostgreSQL と MySQL を題材に、行ロックの理解を深めることを中心にしています。
基本の概念
まずはロックの概念をおさらいしておきます。
この概念については失敗から学ぶ RDB の正しい歩き方に大変お世話になりました。
本記事で書いたものは(特にトランザクション分離レベルにおいてはほぼ)上記本の要約的側面が強いので、より詳しく知りたい方はぜひ上記をお読みください。
トランザクション分離レベル
トランザクションの分離とは、2 つのトランザクションがお互いに影響を与え合わない、という意味で利用されます。
データベース、特に RDBMS においては、データの整合性が合っており信頼性に富むことは大変重要です。商品の在庫を管理するテーブルが、2 つ同時に更新されたからといって 1 個しか減っていなかったり、逆に 3 個減ったりしては困りますから。その意味で、RDBMS においてはとても重要です。
完全なるトランザクションの分離を行いたい場合、出来うる施策としてはすべてを直列にして 1 つずつ処理を行うことです。
ただ、こんなことをしていたらデータベースの処理はびっくりするほど遅くなってしまいますよね。
ということで、データベースは基本的にデフォルトの挙動として、トランザクションの分離レベルの制限を緩めています。どのくらい緩めるかは、緩めるレベルに合わせて 4 つの定義で示されています。
ですが紹介をするその前に、まずはトランザクション分離レベルが緩いことによって発生しうる 4 つの現象を挙げてみました。
- ダーティーリード: 他のトランザクションから自分のコミットしていない内容が見える状態
- ファジーリード: 他のトランザクションがコミットしていない内容は見えないが、コミットした内容は見える状態(トランザクション中にタイミング問題で読み取り内容が変わる)
- ファントムリード: 他のトランザクションがコミットした内容は見えないが、追加・削除は見える状態
- ロストアップデート: 更新が複数トランザクションで行われた場合、後コミットの内容で上書きされる状態
これら 4 つをどれほど抑制できるか、という観点から、主に 4 つのトランザクション分離レベルが存在します。
- READ UNCOMMITTED: すべて抑制できない
- READ COMMITTED: ダーティーリードを抑制
- REPEATABLE READ: ダーティーリード, ファジーリードを抑制
- SERIALIZABLE: すべて抑制
上が最も分離レベルが緩く、下の SERIALIZABLE は直列処理にすることにより完全な分離レベルを実現しています。
後ほど PostgreSQL, MySQL におけるロックの挙動を確認する時に詳しくみていきますが、PostgreSQL のデフォルトの分離レベルは READ COMMITTED であり、MySQL は REPEATABLE READ です。先述した通り、SERIALIZABLE レベルでない限り、普通に利用するとトランザクションの完全な分離は困難であり、このままではデータ不整合が起きる可能性を秘めています。
そこで、データベースには全体にかかるデフォルトのトランザクション分離レベルの定義の他に、データを守るために個別に利用できるロック方法があります。
概念としては大きくは楽観ロック・悲観ロックと呼ばれる二種類の方法で、データ操作時の不整合を守ってくれます。
楽観ロックと悲観ロック
基本的にはデフォルトのトランザクション分離レベルで更新してもらって構わないけれど、保存するレコードの中には、データ不整合を起こしてしまいそうなものや、起こしてはいけないものが存在しますよね。
そんな時に行うのが行ロックですが、以下の 2 つの考え方のどちらで実装するかを検討する必要があります。
- 楽観ロック: 「基本的に衝突しない」というスタンスのロック
- 悲観ロック: 「衝突が起きないように回避する」というスタンスのロック
どちらのロックも正しく実装すれば、データ不整合は起こらないと言われています。
楽観ロックは衝突が起きたらエラーを出したり再検証し直したりすることが求められます。このロジックに漏れや抜けがあるとデータ不整合が起きます。悲観ロックは行のトランザクションレベルを一時的に SERIALIZABLE レベルまで強めるようなイメージです。処理を直列化し衝突を避けるので、スループットは低くなりますがデータ不整合が起きにくいです。ただし、お互いに譲りすぎてロック待ち状態(デッドロック)を起こすことがあります。
特に開発者観点でいうと、デッドロックが起こった場合の調査は困難を極めることが多く(私も調査してて PostgreSQL のdeadlock detected
に出会うと辛かった記憶があります)、悲観ロックは忌避される印象が強いです。また、悲観ロックの方がなんとなく実装が難しそうという印象も強いです。
しかし、ロックは悲観・楽観問わず基本どう頑張っても難しいので、まず難易度からロック手法を判定するべきではないと考えています。どちらのロックを選ぶかは、ロック対象がどのような性質を持つか(プロフィール更新くらいの簡単なものか、在庫や予約, 残高管理等シビアに管理するべき項目の更新なのか等)や、どれくらいロックする可能性があるのか(衝突メトリクスのような使われ方があるそう)によって決めるべきです。
PostgreSQL, MySQL におけるロックの動作確認方法について
基本のロック周辺の概念について掴んだところで、今度は PostgreSQL と MySQL におけるロックの挙動確認をしてみました。
概念としてはこれまで説明した通りですが、実際のロックの挙動はデータベースにより少しずつ異なりますので、そのあたりにフォーカスを当てて確認してみました。
おすすめは手を動かして確認することなので、実際の DB を利用した挙動の確認をやってみたことがない方は、サンプルで環境を立ち上げてみることをおすすめします。
後述する Ruby on Rails でも利用予定のため、そちらも含めた動作確認用のサンプルリポジトリをよろしければ利用してみてください。
PostgreSQL, MySQL の環境立ち上げ方法
上記のリポジトリをクローンし以下のように環境を構築してください。
PostgreSQL のために pgcli, MySQL のために mycli を brew 経由でインストールしておくと良さそうです。
PostgreSQL を利用したい時は以下で、PostgreSQL に入ることができます。
あとはコマンドを実行して動かせます。
$ docker compose up --build
# サンプルデータのマイグレーション
$ docker compose exec app bundle exec rails db:migrate
$ pgcli postgresql://postgres:postgres@localhost:5432/rails_lock_sample_development
MySQL を利用したい時は以下で、MySQL に入ることができます。
あとはコマンドを実行して動かせます。
$ DB_ADAPTER=mysql2 DB_HOST=mysql DB_PORT=3306 DB_USERNAME=root DB_PASSWORD=password docker compose up --build
# サンプルデータのマイグレーション
$ docker compose exec app bundle exec rails db:migrate
$ mycli -h 127.0.0.1 -P 3306 -u root -ppassword rails_lock_sample_development
PostgreSQL と MySQL のトランザクション分離レベルの違い
先述した通り、PostgreSQL のデフォルトのトランザクション分離レベルは READ COMMITTED、MySQL の場合は REPEATABLE READ です。
トランザクションを貼った時の様子から、どのように違うのか、確認してみます。
まずは PostgreSQL の様子をみてみます。
# トランザクション A
rails_lock_sample_development> begin;
rails_lock_sample_development> select * from users where id = 3
+----+----------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+----------------------------|
| 3 | たろう | 20 | 0 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:30:13.177932 |
+----+----------+-----+--------------+----------------------------+----------------------------+
# トランザクション B
rails_lock_sample_development> begin;
rails_lock_sample_development> select * from users where id = 3
+----+----------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+----------------------------|
| 3 | たろう | 20 | 0 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:30:13.177932 |
+----+----------+-----+--------------+----------------------------+----------------------------+
どちらも同じカラムをとっていますが、まだ変化はありません。ここで、トランザクション A が更新をかけてコミットします。
トランザクション B はトランザクション A がコミットをかける前とかけた後にデータを取得してみます。
# トランザクション A
rails_lock_sample_development> update users SET name = 'たろう 2' where id = 3
You're about to run a destructive command.
Do you want to proceed? [y/N]: y
Your call!
UPDATE 1
Time: 0.012s
# トランザクション B
# コミット前で更新した時は A の更新前データが取れない(ダーティーリードが発生していない)
rails_lock_sample_development> select * from users where id = 3
+----+----------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+----------------------------|
| 3 | たろう | 20 | 0 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:30:13.177932 |
+----+----------+-----+--------------+----------------------------+----------------------------+
# トランザクション A
rails_lock_sample_development> commit;
# トランザクション B
# トランザクション Aコミット後のデータが取れてしまう
rails_lock_sample_development> select * from users where id = 3
+----+----------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+----------------------------|
| 3 | たろう 2 | 20 | 0 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:30:13.177932 |
+----+----------+-----+--------------+----------------------------+----------------------------+
これが、トランザクション分離レベル READ COMMITTED の挙動です。トランザクション B の間でトランザクション A の更新結果により、SELECT 文の結果が変わっていることがわかります。これが、ファジーリードの挙動です。
MySQL も同じようにみてみます。
# トランザクション A
(MySQL):rails_lock_sample_development>begin;
(MySQL):rails_lock_sample_development>select * from users where id = 3
+----+--------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+--------+-----+--------------+----------------------------+----------------------------+
| 3 | たろう | 20 | 0 | 2025-10-10 11:55:14.706411 | 2025-10-10 11:55:14.706411 |
+----+--------+-----+--------------+----------------------------+----------------------------+
# トランザクション B
(MySQL):rails_lock_sample_development>begin;
(MySQL):rails_lock_sample_development>select * from users where id = 3;
+----+--------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+--------+-----+--------------+----------------------------+----------------------------+
| 3 | たろう | 20 | 0 | 2025-10-10 11:55:14.706411 | 2025-10-10 11:55:14.706411 |
+----+--------+-----+--------------+----------------------------+----------------------------+
トランザクション A が更新をかけてコミットします。
トランザクション B はトランザクション A がコミットをかける前とかけた後にデータを取得してみます。
# トランザクション A
(MySQL):rails_lock_sample_development>update users SET name = 'たろう 2' where id = 3
# トランザクション B
(MySQL):rails_lock_sample_development>select * from users where id = 3;
+----+--------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+--------+-----+--------------+----------------------------+----------------------------+
| 3 | たろう | 20 | 0 | 2025-10-10 11:55:14.706411 | 2025-10-10 11:55:14.706411 |
+----+--------+-----+--------------+----------------------------+----------------------------+
# トランザクション A
(MySQL):rails_lock_sample_development>commit
# トランザクション B
# トランザクション A コミット後のデータは取れない
(MySQL):rails_lock_sample_development>select * from users where id = 3;
+----+--------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+--------+-----+--------------+----------------------------+----------------------------+
| 3 | たろう | 20 | 0 | 2025-10-10 11:55:14.706411 | 2025-10-10 11:55:14.706411 |
+----+--------+-----+--------------+----------------------------+----------------------------+
# トランザクション B がコミットした後は
(MySQL):rails_lock_sample_development>commit;
# 変わっていることがわかる
(MySQL):rails_lock_sample_development>select * from users where id = 3;
+----+--------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+--------+-----+--------------+----------------------------+----------------------------+
| 3 | たろう 2| 20 | 0 | 2025-10-10 11:55:14.706411 | 2025-10-10 11:55:14.706411 |
+----+--------+-----+--------------+----------------------------+----------------------------+
MySQL はファジーリードを発生させなかったことがわかります。これが REPEATABLE READ レベルの挙動です。
PostgreSQL と MySQL にはこのような挙動の違いがあります。
FOR UPDATE / FOR SHARE
MySQL も PostgreSQL も基本のロックは該当カラムの動作すべてをロックする FOR UPDATE(排他ロック, X ロック)と、閲覧については許可をする FOR SHARE(共有ロック, S ロック)の 2 つです。
MySQL の行ロックはこの 2 つのみで、PostgreSQL は後述しますがもう少し細かくあります。
FOR SHARE は共有ロックを取るため、他トランザクションと競合しても問題ありません。
しかし、ここから更新をりようとすると、共有ロックだったものが排他ロックに昇格します。
# トランザクション A
(MySQL):rails_lock_sample_development>begin;
(MySQL):rails_lock_sample_development>select * from users where id = 3 for share;
+----+-----------------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+-----------------+-----+--------------+----------------------------+----------------------------+
| 3 | たろう | 20 | 2 | 2025-10-10 11:55:14.706411 | 2025-10-10 12:06:41.580946 |
+----+-----------------+-----+--------------+----------------------------+----------------------------+
# トランザクション B
(MySQL):rails_lock_sample_development>begin
(MySQL):rails_lock_sample_development>select * from users where id = 3 for share;
+----+-----------------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+-----------------+-----+--------------+----------------------------+----------------------------+
| 3 | たろう | 20 | 2 | 2025-10-10 11:55:14.706411 | 2025-10-10 12:06:41.580946 |
+----+-----------------+-----+--------------+----------------------------+----------------------------+
# トランザクション A
# update すると排他ロックへ昇格する
(MySQL):rails_lock_sample_development>update users SET name = 'たろう update A' where id = 3
Query OK, 1 row affected
# トランザクション B
# update しようとするとデッドロックが発生する
(MySQL):rails_lock_sample_development>update users SET name = 'たろう update B' where id = 3
(1213, 'Deadlock found when trying to get lock; try restarting transaction')
この通り、共有ロックから排他ロックに昇格させるのは、不具合の温床になります。
閲覧だけでなく更新の可能性がある場合は、あらかじめ排他ロックをかけておくことが大切です。
FOR UPDATE がロックした行はその後ロールバックかコミットがされるまで、他のトランザクションからは閲覧も更新も不可能になります。
# トランザクション A
(MySQL):rails_lock_sample_development>begin
(MySQL):rails_lock_sample_development>select * from users where id = 3 for update;
+----+-----------------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+-----------------+-----+--------------+----------------------------+----------------------------+
| 3 | たろう | 20 | 2 | 2025-10-10 11:55:14.706411 | 2025-10-10 12:06:41.580946 |
+----+-----------------+-----+--------------+----------------------------+----------------------------+
# トランザクション B
# トランザクション A が先にロックしているためロックが取れない
(MySQL):rails_lock_sample_development>begin
(MySQL):rails_lock_sample_development>select * from users where id = 3 for update;
(3572, 'Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.')
検証では 1 つの行をロックすることで試してみましたが、たとえば複数トランザクションがほぼ同時にFOR UPDATE
付きで発行した SELECT 文が、順不同だったとしたら、ロックはどうなるでしょう?
トランザクション A は 1,2 のユーザーを、トランザクション B は 2,1 という感じです。
A が 1 のユーザーをロックしている間に、B は 2 をロックしにかかります。次に A が 2 を、B が 1 を更新しようとしますが、これらはすでにロック済みです。これでデッドロックが完成するというわけですね。
MySQL のギャップロック
次に MySQL 特有のギャップロックについて見ていきます。
ギャップロックはSELECT FOR UPDATE
で複数行をとった際に、他トランザクションがそのレコードの間に新しい行を追加できないようにするためのロックです。文字面だとわかりにくいため、実際に検証してみます。
# トランザクション A
(MySQL):rails_lock_sample_development>begin;
(MySQL):rails_lock_sample_development>select * from users where id >= 4 for update
+----+--------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+--------+-----+--------------+----------------------------+----------------------------+
| 4 | じろう | 19 | 0 | 2025-10-10 11:55:36.570783 | 2025-10-10 11:55:36.570783 |
+----+--------+-----+--------------+----------------------------+----------------------------+
id が 4 よりも大きいデータを取ってきます。
この時、トランザクション B は新しい 4 以上の ID でユーザーを追加しようとします。
# トランザクション B
(MySQL):rails_lock_sample_development>begin
(MySQL):rails_lock_sample_development>insert into users (id, name, age, created_at,updated_at) values (10, '大人の人3', 19, now(), now());
(1205, 'Lock wait timeout exceeded; try restarting transaction')
ロックがかかっていることがわかります。
万が一このレコードの挿入が許可された場合、トランザクション A の select 結果の個数が変わってしまいますよね。これを回避するために、ギャップロックの仕組みがあります。
トランザクション A がもし以下のように id が 4 以下を取っていた場合は、トランザクションが ID 10 のレコードを挿入しても結果が変わらないので、この場合は特に問題なくレコードの追加が可能です。
# トランザクション A がこのようなロックだった場合は動きます
(MySQL):rails_lock_sample_development>select * from users where id <= 4 for update
一見よくわからない挙動に思えるので注意が必要です。
が、よくよく思い出してみるとこの挙動は他コミットの追加・削除の状態を見えなくするファントムリードの抑制に一役買っていることが見て取れます。
REPEATABLE READ というトランザクション分離レベルを叶えている MySQL ならではの仕組みなのだろうという理解ができそうです。
これはすごい技術じゃないですか!と思ったので、もう少し実験してみました。
# トランザクション A
(MySQL):rails_lock_sample_development>select * from users where age >= 20 for update
+----+-----------------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
+----+-----------------+-----+--------------+----------------------------+----------------------------+
| 1 | いちろう | 29 | 0 | 2025-10-10 11:39:34.691243 | 2025-10-10 11:39:34.691243 |
| 2 | はなこ | 34 | 0 | 2025-10-10 11:39:34.694882 | 2025-10-10 11:39:34.694882 |
| 3 | たろう | 20 | 2 | 2025-10-10 11:55:14.706411 | 2025-10-10 12:06:41.580946 |
+----+-----------------+-----+--------------+----------------------------+----------------------------+
# トランザクション B
# ふむふむロックするな、正常
(MySQL):rails_lock_sample_development>insert into users (name, age, created_at,updated_at) values ('じろう', 21, now(), now());
(1205, 'Lock wait timeout exceeded; try restarting transaction')
# あれ、これもロックされているではないか!
(MySQL):rails_lock_sample_development>insert into users (name, age, created_at,updated_at) values ('さぶろう', 19, now(), now());
(1205, 'Lock wait timeout exceeded; try restarting transaction')
20 歳以上の年齢でロックを取ったのだから、19 歳のさぶろうが挿入できないことには納得いきません。
ということで調べてみたのですが、ギャップロックがうまく動くのはインデックスを貼っているカラムに限った話、というのが落とし穴ポイントでした。
インデックスを貼っていない場合、システムの方で正しい範囲(ギャップ)を取れなくなってしまいます。結果どうなるかというと「危ないのでどこもかしこもロックしよう」となり、上記のような挙動になります。
この挙動はやや注意が必要ですね、1 つの悲観ロックによりそのテーブルのすべての挿入が待ち状態になるのは困ることがありそうです。
PostgreSQL の KEY ロック
PostgreSQL にはFOR SHARE
およびFOR UPDATE
の 2 つ以外にも、FOR NO KEY UPDATE
およびFOR KEY SHARE
というロック方式があります。
公式によると
FOR NO KEY UPDATE
獲得するロックが弱い以外は FOR UPDATE と同じように振る舞います。このロックは同じ行のロックを獲得しようとする SELECT FOR KEY SHARE コマンドをブロックしません。 このロックモードは FOR UPDATE ロックを獲得しない UPDATE によっても獲得されます。
FOR KEY SHARE
獲得するロックが弱い以外は FOR SHARE と同じように振る舞います。SELECT FOR UPDATE はブロックされますが、SELECT FOR NO KEY UPDATE はブロックされません。 キー共有ロックは、他のトランザクションが DELETE やキー値を変更する UPDATE を実行するのをブロックしますが、それ以外の UPDATE や、SELECT FOR NO KEY UPDATE、SELECT FOR SHARE、SELECT FOR KEY SHARE を阻害しません。
とのことですが、正直この説明は 1 ミリもわからなかったので、挙動を確認してみました。
# トランザクション A
rails_lock_sample_development> select * from users where id = 3 for no key update;
+----+----------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+----------------------------|
| 3 | たろう | 20 | 0 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:30:13.177932 |
+----+----------+-----+--------------+----------------------------+----------------------------+
# トランザクション B
# 共有ロックが取れない
rails_lock_sample_development> select * from users where id = 3 for share
could not obtain lock on row in relation "users"
# 共有ロックが取れる
rails_lock_sample_development> select * from users where id = 3 for key share
+----+----------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+----------------------------|
| 3 | たろう | 20 | 0 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:30:13.177932 |
+----+----------+-----+--------------+----------------------------+----------------------------+
基本的にSELECT ... FOR UPDATE
は排他ロックなのですべての操作をロックしてしまいます。
しかし、FOR NO KEY UPDATE
は「キー(主キーや外部キーなど)の値は変えない排他ロックです」という意味であり、これが弱いロックという言葉の意図となります。FOR UPDATE
よりもロックのレベルを下げたバージョンと言えそうです。
FOR KEY SHARE
は「キーは変わらないのね、ではロックしないでね、読みます」ということで、FOR NO KEY UPDATE
のロックを読むことができます。
他にも PostgreSQL にはFOR UPDATE OF XXX[テーブル名]
のように JOIN 時に特定のキーだけ排他ロックの対象にすることなどが可能になっています。
NOWAIT / FOR SKIP
最後に、PostgreSQL と MySQL のどちらにも備わっているNOWAIT
およびFOR SKIP
の機能についてです。
-
NOWAIT
: ロックが取れなかった場合に待たずにエラーを返す -
FOR SKIP
: ロックが取れなかった場合にそのロックはスキップして次へ行く
ロックは相手が解除するのを待つイメージが強いですが、上記のような機能を利用することにより、ロックができない場合は早々に諦めるという処理が可能になっています。
先に PostgreSQL が機能を出したみたいですが、今は MySQL でも制御可能になっています。
# PostgreSQL
rails_lock_sample_development> select * from users where id = 3 for share
could not obtain lock on row in relation "users"
# MySQL
(MySQL):rails_lock_sample_development>select * from users where id = 3 for update nowait
(3572, 'Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.')
ここまでデータベース二代巨頭のロックについてみてきました。
こうしてみてみると、PostgreSQL と MySQL の特色がほんのりわかってくるような気がします。
MySQL は REPEATABLE READ のトランザクションレベルを守り、不整合のない堅牢なデータにするための努力がされていることがわかります。
PostgreSQL は並列化することの早さに重きをおいているのでしょうか、ロックを避けたり最小限にするための細やかな工夫が見て取れました。
このあたりはデータベースの技術選定の判断材料になる、重要な思想の違いのように思います。
Ruby on Rails における実装
データベースに関することが色々とわかってきたところで、最後に Ruby on Rails における実装を見てみたいと思います。
Rails Console での動作確認方法
docker compose up
で Docker を立ち上げた状態で、以下のコマンドを実行してください。
$ docker compose exec app bundle exec rails console
lock_version を利用した楽観ロック
lock_version
を利用した楽観ロックをみてみます。
rails_lock_sample_development> select * from users where id = 3
+----+----------+-----+--------------+----------------------------+---------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+---------------------------|
| 3 | たろう | 20 | 0 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:27:29.13336 |
+----+----------+-----+--------------+----------------------------+---------------------------+
# 1回目の更新
irb(main):014> user = User.find(3)
irb(main):016> user.name = "たろう 2"
=> "たろう 2"
irb(main):017> user.save
TRANSACTION (12.4ms) BEGIN
User Exists? (28.4ms) SELECT 1 AS one FROM "users" WHERE "users"."name" = $1 AND "users"."id" != $2 LIMIT $3 [["name", "たろう 2"], ["id", 3], ["LIMIT", 1]]
User Update (0.8ms) UPDATE "users" SET "name" = $1, "updated_at" = $2, "lock_version" = $3 WHERE "users"."id" = $4 AND "users"."lock_version" = $5 [["name", "たろう 2"], ["updated_at", "2025-10-10 11:27:09.657264"], ["lock_version", 1], ["id", 3], ["lock_version", 0]]
TRANSACTION (6.7ms) COMMIT
=> true
# 2回目の更新
irb(main):018> user.name = "たろう 3"
=> "たろう 3"
irb(main):019> user.save
TRANSACTION (0.5ms) BEGIN
User Exists? (2.3ms) SELECT 1 AS one FROM "users" WHERE "users"."name" = $1 AND "users"."id" != $2 LIMIT $3 [["name", "たろう 3"], ["id", 3], ["LIMIT", 1]]
User Update (0.7ms) UPDATE "users" SET "name" = $1, "updated_at" = $2, "lock_version" = $3 WHERE "users"."id" = $4 AND "users"."lock_version" = $5 [["name", "たろう 3"], ["updated_at", "2025-10-10 11:27:29.133360"], ["lock_version", 2], ["id", 3], ["lock_version", 1]]
TRANSACTION (8.0ms) COMMIT
=> true
更新が終わったところで、データベースの様子をみてみます。
# lock_version がインクリメントされている
rails_lock_sample_development> select * from users where id = 3
+----+----------+-----+--------------+----------------------------+---------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+---------------------------|
| 3 | たろう 3 | 20 | 2 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:27:29.13336 |
+----+----------+-----+--------------+----------------------------+---------------------------+
Ruby on Rails の楽観ロックは、lock_version
カラムがあり、かつ楽観ロックがオフになっていなければ、更新の際にlock_version
が変わっていないか見て(=楽観ロック)更新をかけてくれる仕組みです。
update だけではなく、updated_at
カラムだけを更新するtouch
でもこのような動きをすることがわかります。
# updated_at 更新
irb(main):020> user.touch
TRANSACTION (11.8ms) BEGIN
User Update (15.4ms) UPDATE "users" SET "updated_at" = $1, "lock_version" = $2 WHERE "users"."id" = $3 AND "users"."lock_version" = $4 [["updated_at", "2025-10-10 11:30:13.177932"], ["lock_version", 3], ["id", 3], ["lock_version", 2]]
TRANSACTION (7.3ms) COMMIT
=> true
irb(main):021>
# lock_version が増えている
rails_lock_sample_development> select * from users where id = 3
+----+----------+-----+--------------+----------------------------+----------------------------+
| id | name | age | lock_version | created_at | updated_at |
|----+----------+-----+--------------+----------------------------+----------------------------|
| 3 | たろう 3 | 20 | 3 | 2025-10-10 11:20:02.162811 | 2025-10-10 11:30:13.177932 |
+----+----------+-----+--------------+----------------------------+----------------------------+
ロックを失敗させてみましょう。
irb(main):005* ActiveRecord::Base.transaction do
irb(main):006* u = User.find(3)
irb(main):007* puts "A: before lock_version=#{u.lock_version}, name=#{u.name.inspect}"
irb(main):008* sleep 10
irb(main):009* u.update!(name: "たろう Aトランザクション")
irb(main):010> end
TRANSACTION (20.6ms) BEGIN
User Load (2.8ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1
A: before lock_version=1, name="たろう"
User Exists? (1.5ms) SELECT 1 AS one FROM `users` WHERE `users`.`name` = 'たろう Aトランザクション' AND `users`.`id` != 3 LIMIT 1
User Update (3.0ms) UPDATE `users` SET `users`.`name` = 'たろう Aトランザクション', `users`.`updated_at` = '2025-10-10 12:06:41.580946', `users`.`lock_version` = 2 WHERE `users`.`id` = 3 AND `users`.`lock_version` = 1
TRANSACTION (13.9ms) COMMIT
=> true
irb(main):001* ActiveRecord::Base.transaction do
irb(main):002* u = User.find(3)
irb(main):003* puts "A: before lock_version=#{u.lock_version}, name=#{u.name.inspect}"
irb(main):004* sleep 10
irb(main):005* u.update!(name: "たろう Bトランザクション")
irb(main):006> end
TRANSACTION (0.6ms) BEGIN
User Load (1.2ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1
A: before lock_version=1, name="たろう"
User Exists? (0.5ms) SELECT 1 AS one FROM `users` WHERE `users`.`name` = 'たろう Bトランザクション' AND `users`.`id` != 3 LIMIT 1
User Update (0.4ms) UPDATE `users` SET `users`.`name` = 'たろう Bトランザクション', `users`.`updated_at` = '2025-10-10 12:06:45.640715', `users`.`lock_version` = 2 WHERE `users`.`id` = 3 AND `users`.`lock_version` = 1
TRANSACTION (0.3ms) ROLLBACK
(irb):5:in `block in <main>': Attempted to update a stale object: User. (ActiveRecord::StaleObjectError)
from (irb):1:in `<main>'
トランザクション A とトランザクション B が被ってしまいましたが、トランザクション A がコミットされ、トランザクション B はロールバックし、ActiveRecord::StaleObjectError
が出ました。
なぜこのようなエラーになるのか、詳しく見てみましょう。
以下は Ruby on Rails のレコードが更新された際の処理です。
affected_rows
は更新の結果、何レコードが更新されたかの件数を持っており、この更新が 1 件でない場合にはエラーを出しロールバックするようになっています。
affected_rows = self.class._update_record(
attributes_with_values(attribute_names),
update_constraints
)
if affected_rows != 1
raise ActiveRecord::StaleObjectError.new(self, attempted_action)
end
先ほどのモデルで、この更新部分をよくみてみると、取得したlock_version
をwhere
にくっつけていることがわかります。
つまり、諸々簡単に書くとupdate users set name = 'たろう Bトランザクション' where id = 3
がupdate users set name = 'たろう Bトランザクション' where id = 3 and lock_version = 1
になっているんです。この結果が 1 件であれば、lock_version
が変わっていないので更新ができたが、0 件の場合は誰か(今回の場合トランザクション A)がインクリメントしてしまっていることになります。なので、if affected_rows != 1
の条件でエラーを出すような設計になっているという流れですね。
User Update (0.4ms) UPDATE `users` SET `users`.`name` = 'たろう Bトランザクション', `users`.`updated_at` = '2025-10-10 12:06:45.640715', `users`.`lock_version` = 2 WHERE `users`.`id` = 3 AND `users`.`lock_version` = 1
アップデート文に条件分岐を追加することにより、TOCTOU (Time of Check to Time of Use: チェックと利用の間で状況が変わってしまうことによる不整合)と呼ばれる事象やロストアップデート(後のトランザクションが前のトランザクションを上書きしてしまうこと)の対策ができます。
.lock を利用した悲観ロック
Ruby on Rails のもう 1 つのロック手段として.lock
を利用した悲観ロックがあります。失敗する様子をみてみます。
以下のトランザクション A と B で、10 秒の遅延処理を使ってロックさせてみました。
lock メソッドは何も引数がない場合はFOR UPDATE
になりますが、代わりに引数を入れるとそのまま SELECT 文の最後にくっつけてくれるようになります。
失敗させたい方は、NOWAIT
で待たずにエラーを起こしてみます。
# トランザクション A
irb(main):001* ActiveRecord::Base.transaction do
irb(main):002* u = User.lock.find(3)
irb(main):003* sleep 10
irb(main):004* u.update!(name: "たろう 更新 A")
irb(main):005> end
TRANSACTION (0.3ms) BEGIN
User Load (5.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1 FOR UPDATE
User Exists? (1.4ms) SELECT 1 AS one FROM `users` WHERE `users`.`name` = 'たろう 更新 A' AND `users`.`id` != 3 LIMIT 1
User Update (2.1ms) UPDATE `users` SET `users`.`name` = 'たろう 更新 A', `users`.`updated_at` = '2025-10-10 14:53:23.492662' WHERE `users`.`id` = 3
TRANSACTION (12.8ms) COMMIT
=> true
irb(main):006>
# トランザクション B
irb(main):001* ActiveRecord::Base.transaction do
irb(main):002* u = User.where(id: 3).lock("FOR UPDATE NOWAIT").first!
irb(main):003* u.update!(name: "fast path")
irb(main):004> end
TRANSACTION (0.1ms) BEGIN
User Load (0.2ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 ORDER BY `users`.`id` ASC LIMIT 1 FOR UPDATE NOWAIT
TRANSACTION (0.0ms) ROLLBACK
(irb):2:in `block in <main>': Mysql2::Error: Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set. (ActiveRecord::StatementInvalid)
from (irb):1:in `<main>'
/bundle/ruby/3.2.0/gems/mysql2-0.5.7/lib/mysql2/client.rb:146:in `_query': Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set. (Mysql2::Error)
.......
うまくロックできそうです。
では、MySQL と繋いだ状態でFOR NO KEY UPDATE
(存在しないロック方法を指定)するとどうなるでしょうか?
irb(main):006* ActiveRecord::Base.transaction do
irb(main):007* u = User.lock("for no key update").find(3)
irb(main):008* sleep 10
irb(main):009* u.update!(name: "たろう 更新 ないロック方法")
irb(main):010> end
TRANSACTION (15.7ms) BEGIN
User Load (5.7ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1 for no key update
TRANSACTION (0.5ms) ROLLBACK
(irb):7:in `block in <main>': Mysql2::Error: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'no key update' at line 1 (ActiveRecord::StatementInvalid)
from (irb):6:in `<main>'
/bundle/ruby/3.2.0/gems/mysql2-0.5.7/lib/mysql2/client.rb:146:in `_query': You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'no key update' at line 1 (Mysql2::Error)
from /bundle/ruby/3.2.0/gems/mysql2-0.5.7/lib/mysql2/client.rb:146:in `block in query'
...
irb(main):011>
シンタックスエラーになりました。
どうやら、lock
メソッドの引数はただ SELECT 文の最後にそのままくっつけるだけみたいなので、特にそれ以上の考慮はないみたいですね。
Ruby on Rails はデータベースエンジンごとの微細な違いを隠蔽してくれることがよくありますが、lock
メソッドに関してはそういった配慮はないようです。
PostgreSQL と MySQL を見ただけでもロックのかけ方が違うので、このあたりを隠蔽するのが難しかったのかもしれません。
楽観ロックはどちらかというと Ruby on Rails がよしなにやってくれる印象でしたが、悲観ロックに関しては自分たちが接続しているデータベースのロックを知った上で利用するのが良さそうですね。
おわりに
長くなりましたが、データベースの行ロックについて理解は深まりましたでしょうか?
私はデータベースを考える上で、ロックの概念はとても大切と感じる一方で、どこかとっつきにくく知らないゆえの怖さがありました。
こうして調べながらじっくり検証すると、正しく考え実装することの重要性がとても実感できました。また、最後に Ruby on Rails の実装をみるというおまけを追加してみましたが、この「このフレームワークはどういう書き方をした時にどの行ロックを取る動きをするのか」を理解することも、重大な認識齟齬を起こさないために忘れないようにしたいと思いました。
ここまでお読みいただきありがとうございました。
Discussion