DBのデッドロックとは何か?初心者にもわかりやすく解説

まずは結論から、データベースにおけるデッドロックとは、

  • 2つ以上のトランザクションが同時に実行されたとき、お互いに相手がアクセスしたいリソース(行やテーブル)をロックし合ってしまうことで、どちらも動けなくなってしまうこと
    ※ロックとは、リソースを占有する機能のことです
  • デッドロックは一度発生すると自然に解消することがないのでやっかい

です。

それではここから、もっと詳しくデッドロックについて解説していきます!

Q: そもそもロックって何?

前提として、データベースのロックという仕組みについて改めて理解しておきましょう。

一般的にデータベースは複数のトランザクションが同時にアクセスできるような仕組みになっています。
そのおかげでアクセス待ちが少なくなり効率よくトランザクションをさばくことができます。

しかし困るのが、複数トランザクションが同じデータにアクセスしたときです。
このとき何らかのケアをしないと、例えば更新途中の値を読み取ってしまうなど、データの整合性が崩れてしまうことがあります。

図書館の漫画を複数人で読むとき、みんなが違う巻を見ているなら問題はありませんが、同じ巻を見ようとするなら何らかの話し合いが必要なのと似ています。

そんな問題を解決するのが「ロック」です!

ロックとは、トランザクションが自身がアクセスするリソース(行やテーブル)に対して、他トランザクションからのアクセス制限を設ける仕組みです。
ロック(鍵)という名前の通り、あるデータに対して鍵をかけてほかの人が触れないようにするとイメージすれば分かりやすいでしょう。

Q: ロックがないと具体的にどんな問題が起こるの?

実際にロックがないとどんな問題が起き、ロックがそれをどう解決するのか例を挙げてみます。

ネット銀行でお金を送金するところ想像してください。
AさんがBさんに3000円送金したとします。

このシステムの処理の流れはこんな感じです。

手順1: 送金先の口座残高を取得
手順2: 残高+送金額の値で更新

この時、Aさんと全く同じタイミングでCさんもBさんに1000円送金したとします。
つまり2つトランザクションが同時に動くことになります。

仮にロックという仕組みがないとすると、次のように処理が入れ子になって実行されたとき問題が発生します。

  1. AさんのトランザクションがCさんの残高取得。10000円だった。
  2. BさんのトランザクションがCさんの残高取得。10000円だった。
  3. Aさんが残高に送金額3000円を足して【13000円】でCさんの残高を更新
  4. Bさんが残高に送金額1000円を足して【11000円】でCさんの残高を更新
  5. 処理終了

処理結果を見てみると、本来はCさんの残高は【14000円】なければいけないのに、【11000円】になってしまっています。
このように、複数トランザクションが同じデータにアクセスするときは、データの不整合が起こりやすくなります。

そこで、ロックの仕組み役立ちます。
ロックを使って他トランザクションからのアクセスを制限することで、先ほどのケースが次のように解決できます。

  1. Aさんが残高取得。この残高の値を持つ行データにロックをかける
  2. Bさんは残高を取得しようとするが、ロックがかかっているのでアクセスできない。ロックが解除されるまで待機する
  3. Aさんが残高に送金額3000円を足して【13000円】でCさんの残高を更新。処理が終わったのでロックが解除される
  4. Bさんが残高取得。13000円だった
  5. Bさんが残高に送金額1000円を足して【14000円】でCさんの残高を更新
  6. 処理終了

このようにロックがあるデータは他のトランザクションからのアクセスを制限できます。
この仕組みのおかげで、期待通りの金額でCさんの残高を更新できました。

Q: ロックにはどんな種類があるの?

ロックには「共有ロック」と「排他ロック」の2種類があります。
基本的にはデータを参照するときは共有ロックを、更新するときは排他ロックを利用します。

この違いは大切なので、それぞれ詳しく見てみましょう。

・共有ロック

共有ロックはデータを参照(SELECT)するときに使います。
共有ロックがあると、他のトランザクションはそのリソース(行やテーブルのこと)を更新できなくなります。

例えば、更新しようとしたリソースに共有ロックがかかっていた場合、そのリソースは現在ほかのトランザクションが参照中ということなので、更新できなくなります。
(参照中にもかかわらず値を更新してしまっては、不整合なデータを参照されてしまう可能性がありますからね)

共有ロックの特徴として、「共有」という名の通り、一つのリソースに対して複数の共有ロックをかけることができます。
例えば、共有ロックをかけようとするリソースにすでに他のトランザクションが共有ロックをかけていたとしても、かまわずロックをかけることができます。
参照するだけであれば何人が同時に見ても問題が起こるわけではないので、複数のロックが共存できるというわけです。

・排他ロック

排他ロックはデータを更新(UPDATE, INSERT, DELETE)または参照(SELECT)するときに使います。
排他ロックがあると、他のトランザクションはそのリソースを参照も更新もできなくなります。

さっきの共有ロックは他から「更新」を防ぐだけでしたが、排他ロックは「参照」すらもさせません。

排他ロックは「排他」という名の通り、一つのリソースに対して一つのトランザクションだけがロックをかけることができます。
データを更新するときは、ほかから参照・更新されると不整合が起こるため、排他ロックを使ってリソースを独占します。

また、SELECTでも排他ロックをしたいケースが時にありますが、そんな時は専用の構文を使うことでSELECTでも排他ロックをかけることができます。

Q: ロックはどうやって取得するの?いつ開放されるの?

・ロックを取得する

ロックは多くの場合、データベースが自動的に取得します。
そのため、基本的にはロックを取得するために特別にやらなければいけないことはありません。

どの操作でどんなロックが取得されるかは下記の通りです。

  • データを参照(SELECT)するとき → 「共有ロック」を取得する
  • データを更新(UPDATE, INSERT, DELETE)するとき → 「排他ロック」を取得する

ただし、SELECTするときに排他ロックを取得したい場合は明示的にSQLに記載する必要があります。
書き方はデータベースの種類によって異なりますが、例えばmysqlの場合は下記のようにFOR UPDATEを付けます。

SELECT * FROM ResourceTable WHERE ResourceID = 1 FOR UPDATE;

・ロックを開放する

ロックが解放されるタイミングは、基本的にはトランザクションが終了したら、つまりコミットかロールバックをされたらです。

Q: ロックは何に対して取得できるの?

ロックするリソースの単位には、主に「行」と「テーブル」があります。
データベースによってはこの中間に「ページ」という単位があることもあります。

行ロックはアクセスする行データだけに対するロックです。
その他の関係ない行については何もしないので他から自由にアクセス可能です。

テーブルロックはテーブル全体をロックするので、すべての行がロック対象になります。

ロックはなるべく小さい単位に対して行うことが推奨されています。
なぜなら複数のトランザクションが同じデータにアクセスしてロックを掛け合うと、ロックの解除待ちが発生してパフォーマンスが落ちてしまうからです。また、後述するデッドロックの発生原因にもなりえます。

とはいえリソースをどんな単位でロックするかはデータベースが決めます。
データベースはどの範囲でロックすれば最適かを計算してロックの取得を行います。

基本的に少ないデータに対する参照や更新を実行する場合は「行ロック」が行われます。
逆にテーブルロックが起こる例としては下記などのケースがあります。

  • DDL(データ定義言語)操作
  • インデックスの作成や再構築
  • 大規模なデータアクセス

Q: デッドロックってなに?

さて、本題です。

デッドロックとは2つのトランザクションが互いにロックの開放待ちをして動けなくなってしまう現象です。
デッドロックの問題は、一度起こるともう自然に解消することはないので、それ以降の処理が継続できなくなってしまうところです。

このような特徴があるため、データベースによってはデッドロックを検知すると詰まっている一方のトランザクションをエラーにすることで処理が続行できるようにします。

実際にデッドロックが起こる例を見てみましょう。
次の例では2つのテーブル(Color、Fruit)を作成し、それぞれ1レコードだけ追加しています。

drop table if exists Fruit;
create table Fruit (
    Id int primary key,
    Name varchar(255)
);
drop table if exists Color;
create table Color (
    Id int primary key,
    Name varchar(255)
);

insert into Fruit values (1, "Apple");
insert into Color values (1, "Red");

-- Colorテーブル
-- # Id, Name
-- 1, 'Red'

-- Fruitテーブル
-- # Id, Name
-- 1, 'Apple'

このテーブルに対して2つのトランザクションで更新をかけます。

トランザクション1

トランザクション1はFruitテーブル→Colorテーブルの順番で値を更新します。
トランザクション同士が競合しやすいように途中に5秒の待機を入れています。

-- トランザクション1
START TRANSACTION;
UPDATE Fruit SET Name ='Pineapple' WHERE Id=1;
SELECT SLEEP(5);
UPDATE Color SET Name ='Blue' WHERE Id=1;
COMMIT;

トランザクション2は、1とは逆の順番でテーブルを更新します。

-- トランザクション2
START TRANSACTION;
UPDATE Color SET Name ='Yellow' WHERE Id=1;
SELECT SLEEP(5);
UPDATE Fruit SET Name ='Mango' WHERE Id=1;
COMMIT;

この2つのスクリプトを同時に流すと、デッドロックが起こります。
今回は次のようにトランザクション1がエラーになりました。

01:16:00	UPDATE Color SET Name ='Blue' WHERE Id=1	Error Code: 1213. Deadlock found when trying to get lock; try restarting transaction	0.015 sec

ではデッドロックが発生した仕組みを見てみましょう。

1.まずトランザクション1が下記のクエリを実行し、FruitテーブルのId=1のレコードを更新します。このときトランザクション1がこのレコードに対して排他ロックをかけます。

UPDATE Fruit SET Name ='Pineapple' WHERE Id=1;

2.続いてトランザクション2が下記のクエリを実行し、ColorテーブルのId=1のレコードを更新します。同じようにトランザクション2がこのレコードに対して排他ロックをかけます。

UPDATE Color SET Name ='Yellow' WHERE Id=1;

3.トランザクション1が下記のクエリを実行しますが、ColorテーブルのId=1のレコードはトランザクション2が排他ロックをかけているので、更新できません。そのためロックの開放を待って待機します。

UPDATE Color SET Name ='Blue' WHERE Id=1;

4.トランザクション2が下記のクエリを実行しますが、FruitテーブルのId=1はトランザクション2が排他ロックをかけているので更新できません。そのため、こちらもロック開放を待って待機します。

UPDATE Fruit SET Name ='Mango' WHERE Id=1;

5.結果的に二つのトランザクションがお互いに欲しいレコードを相手がロックしている状態になり、どちらも処理が進まなくなります。これでデッドロックが完成です。

このように、複数トランザクションが同一データを取り合うときにデッドロックが発生してしまうことがあります。
これは単純な例ですが、実際のシステムやアプリでは同時にいくつものトランザクションが実行されるのは日常茶飯事なので、デッドロックが発生してしまうケースも珍しくはありません。
とはいえ、同一データアクセス時にいつでもデッドロックが起こるわけではありません。

この後、どんな時にデッドロックが起こるのか、その条件について見ていきます。

Q: デッドロックはどんな時に起こるの?

デッドロックが起こる主な条件は下記があります。

  • 複数トランザクションが同時に実行される
  • 異なるトランザクションが同じリソースをロックする
  • トランザクション内で2つ以上のリソースをロックする
  • 排他ロックが使われている
  • ロック待ちの循環が発生している

それではこの後一つ一つ詳しく見ていきましょう。

・複数トランザクションが同時に実行される

デッドロックは複数のトランザクションが互いに必要なリソースをロックし合うことで発生します。
そのため、発生条件として複数トランザクションが同時に実施されていることが挙げられます。

逆に1トランザクションだけの実行では、ロックを取り合う相手がいないのでデッドロックは発生しません。

・異なるトランザクションが同じリソースをロックする

デッドロックは同じリソースの取り合いで発生します。
そのため複数のトランザクションが同じリソースをロックすることが、デッドロックの発生条件になります。

複数のトランザクションが同時に実行されていても、まったく違うデータをロックするのであればデッドロックは発生しません。

・1トランザクションで2つ以上のリソースをロックする

1トランザクションで2つ以上のリソースをロックするということは、あるリソースのロックを保持しつつ、他のリソースのロックも取得しようとする、ということです。
このようなトランザクションが複数実行されると、「アクセスしたいリソースはすでにロックが取られてる…(けど相手が欲しいリソースは自分がロックしている)」という状況が起こるとデッドロックの原因になります。

読み終わった漫画を返さず保持したままどんどん新しい漫画を借りるようにみんながなってしまったら、他の人とバッティングしてしまうのと一緒です。

逆に1トランザクションで1つのリソースしかロックしないのであればデッドロックは起こりません。

・排他ロックが使われている

排他ロックはリソースを独占し、他トランザクションから一切のアクセスができなくなる種類のロックでした。

排他ロックはリソースを独占するため、他のいかなるロックと共存することができません。
そのため他のロックがすでにあった場合、その開放待ちをすることになりそれがデッドロックの原因となりえます。

逆に共有ロックだけであればデッドロックは起こりません。なぜなら共有ロックは同じリソースに対し複数のロックが共存できるので、ロックを取得するために待つことがないからです。

・ロック待ちの循環が発生している

複数のトランザクションが実行されているときに、「AはBのロック解除待ちをしている、BはAのロック解除待ちをしている」のように、ロック待ちの循環が発生するとデッドロックになります。

なお、デッドロックは2つ以上のトランザクションで発生するので、3つや4つでも循環が起こればデッドロックになります。

・リソースのアクセス順が交差している

各トランザクション同士が同じリソースにアクセスするとき、そのアクセス順が交差するとデッドロックの原因になります。

例えば、先ほど紹介したFruitテーブルとColorテーブルのデッドロックもリソースアクセスが交差しています。

再度SQLを見てみましょう。
トランザクション1はColorテーブル、Fruitテーブルの順で更新しています。
一方トランザクション2はその逆の順で更新しています。

-- トランザクション1
START TRANSACTION;
UPDATE Fruit SET Name ='Pineapple' WHERE Id=1;
SELECT SLEEP(5);
UPDATE Color SET Name ='Blue' WHERE Id=1;
COMMIT;

-- トランザクション2
START TRANSACTION;
UPDATE Color SET Name ='Yellow' WHERE Id=1;
SELECT SLEEP(5);
UPDATE Fruit SET Name ='Mango' WHERE Id=1;
COMMIT;


これを図にすると下記のようになります。(①~④はリソースへのアクセス順を表します)

このように、リソースのアクセス順が交差していることが分かると思います。
ではなぜ交差するとデッドロックになりやすいのでしょうか?

試しに、アクセス順が同じになるようにトランザクション1を2つ平行して実行してみましょう。

この場合、②のアクセス時にトランザクション1-2がロック開放待ちになりますが、待っていればトランザクション1-1が終了してロックが解放されるので、いずれすべての処理が完了します。

一方、先ほどのアクセス順が交差した例を再度見てみましょう。

この場合、最初にロック開放待ちが起こるのはトランザクション1が③のアクセスをするときです。
しかし、トランザクション2も④のアクセスでロック待ちが起こります。
そのためお互いに必要なリソースをロックし合う形になり、デッドロックが起こります。

このように、リソースのアクセス順が交差するとお互い必要なリソースを取り合ってしまいます。
結果的にお互いにロック開放待ちが起こりデッドロックの原因になりえます。

補足:実はデッドロックが発生する条件として、コフマンという方が提唱した4つの有名な?条件があります。
https://en.wikipedia.org/wiki/Deadlock
このページの「Individually necessary and jointly sufficient conditions for deadlock」という欄にその記載がありますので興味があればご覧ください

Q: デッドロックを防ぐには?

デッドロックを防ぐには先に挙げた発生条件を避ければよいのですが、中には現実的に避けるのが無理なケースも多いです。(「複数トランザクションの同時実行」をなくすなんて多くのソフトウェアでは難しいですよね)

そんな中、一般的にできるデッドロック対策には下記のようなものがあります。

  • リソースのアクセス順を統一する
  • 1トランザクションを短くする

また、デッドロック自体の発生は防げなくても

  • デッドロックが起きたらリトライする

ことで処理を正常に行う手段もあります。

詳しく見てみましょう。

・リソースのアクセス順を統一する

先の例で紹介したように、各トランザクションのリソースへのアクセス順を統一することでデッドロックを防ぐことができます。

リソースのアクセス順を確認するためには、同時に実行されるトランザクションを洗い出し、各SQLでアクセスするリソースを整理することが一つの手段です。

・1トランザクションを短くする

ロックはトランザクションが続く限り保持され続けるため、できるだけトランザクションを短くすることでデッドロックの発生率を下げることができます。

1トランザクションの中でいくつものクエリを実行している場合、別のトランザクションに分けることができないか検討することが大切です。

・デッドロックが起きたらリトライする

特にソフトウェアの規模が多い場合など、いくら気を付けてもデッドロックが発生する機会をゼロにできないことがあります。

その場合は、デッドロックが起きたらリトライするというのも一つの手段です。
デッドロックは一度エラーが発生したら他トランザクションの処理は流れるので、リトライすれば問題なく実行できることも多いです。

終わりに

今回はデータベースのデッドロックについて解説しました。
記事の中に誤りがあればぜひご指摘いただけると幸いです。

もしこれが少しでも読まれるようであれば、今回は記載できなかったこと(トランザクション分離レベルなど)についてもう少し付け加えたいと思います。

では、ありがとうございました!

返信を残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です