ロックの役割と、ロックがもたらす行き詰まりとは 〜レコードロック、デッドロック、楽観的ロック〜
プログラム自学案内の30回目です。前回の記事では、排他制御の必要性について紹介しました。今回の記事はその実践編ということで、PostgreSQLのレコードロックを紹介します。前回までの記事の一覧はこちらです。
ロックとは
まずは、ロックを簡単に説明します。
ロック(lock) とは、自分が読み書きしているデータが、他の処理から同時に読み書きされないよう、他の処理を待たせるための仕組みです。
そもそも 英語の lock は錠前の意味ですので、ロックは、個室の扉に取り付けられた、錠前や掛金に似た役割を持ちます。扉の掛金が他の人から部屋の中を守るように、ロックは、他の処理から、自分が読み書きしているデータを守るのです。
ロックには、取得と解放の二つの操作があります。この二つの操作は、錠前にたとえるなら錠をかけたり、外すことに相当します。二つの操作は、次のルールで許可されます。
複数の処理が同時に同じロックを取得することはできない
だれもロックを取得していなければ、処理はそのロックを取得することができる
処理を終えたら、取得したロックを解放しなければならない
また、これらから次のことが言えます。
他の処理によってロックが取得されている場合、そのロックを取得するには、他の処理がそのロックを解放するまで待たなければならない
ロックが、扉の内側の錠前や掛金、もしくは掛金と連動する「使用中」のサインに、似た役割をはたせるのが分かると思います。
PostgreSQLのレコードロック
レコードロックは、RDBMSで、おそらく最も良く使われるロック機能です。
この機能を持つRDBMSでは、レコードの1行1行にそれぞれロックがあります。トランザクションはそのロックを取得することができ、ロックを取得されたレコードは、他の接続のトランザクションから更新できなくなります。
PostgreSQLでレコードロックを取得する SQL文の例はこうです。この構文はよく「SELECT FOR UPDATE」と言われます。
BEGIN;
SELECT FROM items WHERE name='卓上カレンダー' FOR UPDATE;
これで、「WHERE name='卓上カレンダー'」の全てのレコードのレコードロックを、一度に取得することができます。
ただし、もし、レコードのロックが他の接続によりすでに取得されていた場合は、そのロックが解放され、自分がロックを取得できるようになるまで、ひたすら待たされる、というわけです。
レコードロックを解放するには、COMMITまたはROLLBACKでトランザクションを終了させます。
COMMIT;
実践:花子とパパのレコードロック
前々回の花子とパパの例に合わせてやってみましょう。
花子とパパは、SUM(quantity)が上限を超えないように気をつけながら、DBに行を追加します。同時に行を追加すると、上限を突破してしまうという課題がありましたので、この課題の解決にレコードロックを使います。
ただし、レコードロックの機能だけでは、行の追加を防ぐことはできないので、同時追加を防ぐための約束事を一つ作ります。
「DBに行を追加するまえには、かならずその品目のレコードをロックすること」を、パパと花子で約束します。そして、ロックに使うテーブルとレコードを作っておきます。
こうです。
-- 品目テーブル
CREATE TABLE items (
-- 品目名
name VARCHAR PRIMARY KEY
);
INSERT INTO items VALUES ('卓上カレンダー');
INSERT INTO items VALUES ('壁掛カレンダー');
SELECT * from items;
これで準備が整いました。
花子とパパの注文受付に、レコードロックという手順を追加するとこうなります。「SELECT FOR UPDATE」によるロック取得の操作が、他方の操作を待ち、待たせることにより、同時更新による上限超過を防ぐことができます。
花子
BEGIN;
-- レコードロックで パパの操作が終わるのをまち、パパの操作を待たせる
SELECT FROM items WHERE name='卓上カレンダー' FOR UPDATE;
INSERT INTO orders VALUES ('担任の先生', '卓上カレンダー', 6);
-- 上限チェック
SELECT item, SUM(quantity) AS "合計" FROM orders GROUP BY item;
COMMIT;
パパ
BEGIN;
-- レコードロックで 花子の操作が終わるのを待ち、花子の操作を待たせる
SELECT FROM items WHERE name='卓上カレンダー' FOR UPDATE;
INSERT INTO orders VALUES ('スナックのママ', '卓上カレンダー', 6);
-- 上限チェック
SELECT item, SUM(quantity) AS "合計" FROM orders GROUP BY item;
COMMIT;
実際に二つの接続を使い、様々なタイミングでのSQL文実行を試してみてください。そして、花子とパパの二人がどんなタイミングで操作しても、一方が他方を待たせるロックの仕組みにより、同時更新による上限超過が防がれることを確認してください。
デッドロック
ロックを使う人が知っておかなければいけないのが、デッドロック(deadlock) です。deadlock という英単語は、もともとは交渉の行き詰まりや 膠着状態を意味するそうです。ロックを不用意に使うことで、まさに膠着状態が起きるのです。
デッドロックの起き方と防ぎ方
デッドロックは簡単に起きます。二つ以上の処理が同時に、二つ以上のロックを、順不同に 取得しようとすると、デッドロックが起きます。
「人質を解放しなさい、そうしたら身代金を渡すから」
「いや、身代金が先だ、そうすれば人質を解放しよう」
これはドラマなどでよく見る膠着状態の典型です。警察と悪人が、人質と身代金という二つのロックを、逆順で取得しに行ったせいで起きています。似た話で思い出すのが、映画「ルパン三世カリオストロの城」です。「伯爵」と「ルパン」の二人が、「指輪」と「クラリス」の二つを、同時に、逆順で取得しようとした結果、足がすくむような高い時計塔の上で膠着状態がおきそうになります。
いったんデッドロックが起きると、両者が言い分を変えない限り、何億年経っても事態は膠着したままです。ドラマや映画ではどちらか片方が機転を利かせて言い分を変えるため、話が進みますが、機転の利かないコンピュータのプログラム同士でこれが起きるとヤッカイです。
デッドロックを防ぐためには、基本的には、二つ以上のロックを取得する場合には、かならず全ての処理で取得する順番を合わせましょうということになります。
実践:花子とパパのデッドロック
具体例でみてみましょう。これでデッドロックになりますね。
パパ
BEGIN;
SELECT FROM items WHERE name='卓上カレンダー' FOR UPDATE;
花子
BEGIN;
SELECT FROM items WHERE name='壁掛カレンダー' FOR UPDATE;
SELECT FROM items WHERE name='卓上カレンダー' FOR UPDATE;
パパ
SELECT FROM items WHERE name='壁掛カレンダー' FOR UPDATE;
PostgreSQLは優秀なので、「デッドロックに陥ってしまった」ことを検出して、エラーを出してくれたと思います。しかし、ロックのしくみを提供する機能や製品によっては、デッドロックのせいでひたすら待ち続けるはめになることも、あり得ます。
では、このデッドロックを防ぐためにはどうすればいいでしょうか。
さきほど述べた通り、原因はパパと花子で、レコードをロックする順番が逆だったことにあります。
なので、ロックをかける順番を、花子とパパで合わせれば良いのです。「一つのトランザクションで二つの品目レコードをロックするときは、必ず卓上→壁掛の順でロックすること」なんて感じに花子とパパが約束をしておけば、デッドロックを防ぐことができます。
楽観的ロック
楽観的ロック(optimistic locking) というやり方も紹介しておきましょう。
前回の記事でもお話しましたが、待ち時間というのは、待たされる側にとっては思いのほか長くなるものです。それを解決するための、待たせない排他制御のやりかたが、楽観的ロックというテクニックです。
このテクニックにはロックと言う名前がついているものの、ロックを取得しません(SELECT FOR UPDATEを使いません)。代わりに、処理を終わらせる前に、他の処理による更新が起きていないことを確かめ、もし他の処理により更新されていたらやり直す。という方法をとります。「ほとんどの場合、他の処理による更新なんか起きないだろう」という楽観的な考えかたに基づいた方式なので、楽観的ロックと言うわけです
ちなみに、さきに紹介したレコードロックのやりかたを、楽観的ロックと対比的に、悲観的ロック(pessimistic locking)と言います。「ロックを取得しておかないと、他の処理による更新が起きるかもしれない」という悲観的な考え方に基づいているわけですね。
実践:花子とパパの楽観的ロック
つぎの版(revision)つきの品目テーブルを準備します。
-- 品目テーブル(版つき)
CREATE TABLE items_with_rev (
-- 品目名
name VARCHAR PRIMARY KEY,
-- 版
revision NUMERIC
);
INSERT INTO items_with_rev VALUES ('卓上カレンダー', 0);
INSERT INTO items_with_rev VALUES ('壁掛カレンダー', 0);
SELECT * from items_with_rev;
楽観的ロックでは、品目レコードの「ロック」を取得する代わりに、「版の値」を取得します。そして、注文受付処理が終わったら、最後に版を+1します。
パパ
BEGIN;
--版(revision)をメモっておく
SELECT * FROM items_with_rev WHERE name='卓上カレンダー';
INSERT INTO orders VALUES ('スナックのママ', '卓上カレンダー', 6);
-- 上限チェック
SELECT item, SUM(quantity) AS "合計" FROM orders GROUP BY item;
-- メモっておいた版(revision)を XXX に入れて、revisionを更新する
-- WHERE句にある、AND revision = XXX がポイント
UPDATE items_with_rev
SET revision = XXX + 1
WHERE name = '卓上カレンダー' AND revision = XXX;
ふつうであれば、UPDATE文は成功し、psql は UPDATE 1 と出力します。この結果であれば、COMMITしてよいです。
しかし、花子に先を越されて注文受付されてしまった場合、psql は UPDATE 0 と出力します。花子により先に版を更新されてしまった結果、WHEREの条件を満たすレコードが無くなり、更新が空振りするのです。もし、こうなった場合、同時注文受付による上限超過が疑われるので、ROLLBACK してもう一度最初からやり直すことにするのです。
以上が楽観的ロックというテクニックです。ただでさえ手順としてもややこしいうえ、他の処理に更新されてしまった場合、ROLLBACKして最初からやり直しというのもちょっとウンザリするかもしれません。それでも、待たずに、待たせずに済むというメリットの大きさのために、使われることが多いのですね。
まとめと次回予告
今回の記事では、PostgreSQLによる排他制御のやり方を紹介しました。前回と今回は、入門記事にはふさわしくない口当たりの話題だったかもしれません(でも、いつかは通る道です)。これで、5回にわたるPostgreSQLによるRDB技術の紹介は終わりになります。
次回以降は、またWebアプリに戻ります。24回目の記事の続きとして、Express.jsアプリで、PostgreSQLの読み書きをします。これが出来るとWebアプリで出来ることの幅が、そうとうに広がります。あわせて、非同期、例外処理、あたりのトピックに触れることになるかもしれません。
余談
この記事を書くのをきっかけに、「デッドロック」という名前のバンドがドイツにあるのを知りました。(http://www.deadlock-official.com/) メロディック・デスメタル、略してメロデスというジャンルらしいです。私はそんなジャンルがあることすら知らなかったのですが、聞いてみたら面白く、たいそう気に入ってしまいました。
私の経験上、歳を取るにつれ、どうしても音楽の好みは保守的になるものです。Spotifyに提案されるまま、自分の青春時代に気に入ったジャンルの曲ばかり聞いてしまいがちになってしまいます。
これは、良くないしもったいないことだと思うので、こんなふうにこじつけで無理やりにでも、好みの幅は広げていきたいものだと思っております。
この記事が気に入ったらサポートをしてみませんか?