Go言語とPostgreSQLで遊ぼう!~ デッドロック編~

こんにちは。KOUKIです。

Go言語でアプリケーション開発手法を紹介しています。

前回は、トランザクション処理の実装について触れました。

今日は、デッドロックについて学びたいと思います。

デッドロックについて

DB操作時にトランザクションを利用した更新があると「デッドロック」が発生する可能性があります。

DBのレコード更新時、プログラムは他のプログラムがデータを更新できないようにロックをかけます。そして更新が完了したあと、このロックを解除します。

他のプログラムはこのロックが解除されるまで、レコードにアクセスすることができません。

しかし、複数のプログラムがそれぞれのレコードに対してロックをかけ、ロックをかけたお互いのレコードも更新する必要があった場合、どうなるでしょうか?

答えは「お互いにロックが解除されるまで、処理をストップする」です。この状態をデッドロックと呼びます。

トランザクションの処理は、「複数の処理を一つにまとめる処理」です。各工程が全て完了した時にレコードのロックが解除されるのですが、複数のトランザクションが同時に走るとデッドロックが発生する確率は高くなります。

デッドロックを発生させる

説明を聞いても「?」という感じになると思うので、実際にデッドロックを発生させてみましょう。

アカウントの更新処理

前回学習したトランザクション処理に「アカウントの更新処理」を追加しましょう。

Aさんの口座からBさんの口座へ入金する処理(Update)を実装しました。

口座確認テストの追加

続いて、前回作成したテストコードに口座確認テストを追加します。

テストを実行します。※docker-compose upでコンテナを立ち上げてください

テストが失敗しました。入出金の調整に問題がありそうです。

sqlを更新する

レコードの金額を取得するSQL文は以下のようになっています。

他のプログラムのトランザクション中に、このSQL文でレコードを取得すると更新前のデータが取得されます。

そのため、更新済みのレコードに対してUpdateをかけることができないので、データの更新に失敗するようです。

account.sqlに次のSQL文を追加してください。

FOR UPDATE」を追加すると、トランザクションを検知してデータの取得を待つことができます。すごく便利です。

トランザクションが完了後にデータを取得するので、先ほどのテストエラーは回避できるはずです。

次に以下のコマンドを実行して、SQLからGo言語のプログラムを作成します。

sqlcの詳しい使い方については、この記事を参考にしてください。

上記のコマンドの実行により、account.sql.goには以下のメソッドが生成されました。

いよいよデッドロックへ

先ほど作成したGetAccountForUpdate関数を導入しましょう。store.goを修正します。

それでは、テストを実行します。

「pq: deadlock detected」と表示されました!

ついに出ましたね!デッドロックが!!!

デッドロックの可視化

まず、デッドロックが発生している状況を探りましょう。

store_test.goに次の処理を実装してください。

状況を整理する為、トランザクションの処理を5 -> 2に変更しました。そして、Contextを確認する為、contextパッケージのWithValueを使ってKeyの受け渡しを行なっています。

WithValueの使い方については、この記事を参考にしてください。

WithValueで生成したコンテキストをTransferTxメソッドに渡すことで、呼び出し先でKeyを取得できます。

store.goにKeyの取り出しとPrint文を追加しましょう。

テストを実行します。

「tx 1 update account 1」の出力後にデッドロックが発生していますね。

しかし、これだけでは何が起こっているかわかりません。Terminalを2つ開いて、Dockerコンテナ内に入ってみましょう。

「SELECT * FROM accounts WHERE id = 1 LIMIT 1 FOR UPDATE;」でブロックが入り、その後、デッドロックが発生しました。Postgresはデッドロックを検知してくれるので便利ですね。

「id = 1 」はリレーションを張っているので、これが関係しそうです。

Postgresは、pg_locksを参照するとロック状態を確認することができるようです。

このサイトを参照してください。

「http://localhost:9232/」にアクセスし、次のSQL文を実行します。

実行結果のレコードの通り、「SELECT * FROM accounts WHERE id = 1 LIMIT 1 FOR UPDATE;」によってブロックされていることがわかりますね。

そして、その理由は「INSERT INTO transfers ( from_account_id, to_account_id, amount) VALUES (1, 2, 3) RETURNING *;」が実行されているからとのこと。

これだけだとよくわからないので、次のSQL文も実行してみましょう。

ロックの一覧を出しました。

この中で、「ShareLock」が気になる存在です。

5レコード目の「INSERT INTO transfers ( from_account_id, to_account_id, amount) VALUES (1, 2, 3) RETURNING *;」を実行後、「SELECT * FROM accounts WHERE id = 1 LIMIT 1 FOR UPDATE;」の実行でShareLockが走っているので、やはりリレーションに問題がありそうです。

リレーションの設定は、migrationフォルダの000001_init_schema.up.sqlに記述しています。

この設定を見ると「id, from_account_id, to_account_id」でリレーションを張っていますね。Insert/Select両方ともidを必要とするので、これによりShareLockが発生したようです。

デッドロックを回避する

リレーションに問題があることがわかったので、以下のクエリを修正しましょう。

ロックを回避するために、「NO KEY」を追加しました。

公式サイトによると

「獲得するロックが弱い以外はFOR UPDATEと同じように振る舞います。このロックは同じ行のロックを獲得しようとするSELECT FOR KEY SHAREコマンドをブロックしません。 このロックモードはFOR UPDATEロックを獲得しないUPDATEによっても獲得されます。」

とあるので、これで行レベルでのロックを回避できるはずです。

以下のコマンドを実行しましょう。

テストを実行します。

OKですね。検証が完了したのでPrint文やコンテキストを削除しておきましょう。

リファクタリング – 口座残高計算クエリの作成

最後に、口座残高計算クエリを作成して終わりましょう。

このクエリにより、計算処理を実装する必要が無くなります。

次のコマンドを実行します。

このコマンドの実行によって、以下のGoプログラムが作成されます。

store.goのTransferTxメソッドを修正します。

だいぶコードがスッキリしましたね。

テストを実行します。

次回

今回の学習内容はすごく濃かったですが、ロックを発見する方法を学べたのはよかったですね^^

また、トランザクションを開始するにはBEGINを使い、ロールバックするにはROLLBACKを使うことも知ることができました(あまりPostgresを触らないんですよ)。

次回は、デットロックの回避方法について学びましょう。

Go言語まとめ

オススメ書籍

コメントを残す