こんにちは。KOUKIです。
Go言語でアプリケーション開発手法を紹介しています。
前回は、デッドロック処理について触れました。
今回は、デッドロックをどのように回避していくかについて学習していきましょう。
<目次>
デッドロックを発生させる
復習がてらデッドロックを発生させてみましょう。
最初にdocker-compose upでコンテナを起動しましょう。
1 |
docker-compose up |
次にターミナルを2つ立ち上げ、以下のコマンドを入力してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
# Terminal 1: コンテナに入る docker exec -it postgres psql -U postgres -d simplebank simplebank=# # Terminal 2: コンテナに入る docker exec -it postgres psql -U postgres -d simplebank simplebank=# # Terminal 1: トランザクション開始 BEGIN; # Terminal 2: トランザクション開始 BEGIN; # Terminal 1: データ更新 UPDATE accounts SET balance = balance - 10 WHERE id = 1 RETURNING *; id | owner | balance | currency | created_at ----+----------+---------+----------+------------------------------- 1 | selfnote | 90 | USD | 2020-12-25 22:16:53.824611+00 (1 row) # Terminal 2: データ更新 UPDATE accounts SET balance = balance - 10 WHERE id = 2 RETURNING *; id | owner | balance | currency | created_at ----+----------+---------+----------+------------------------------- 2 | selfnote | 90 | USD | 2020-12-25 22:21:54.034812+00 (1 row) UPDATE 1 # Terminal 1: データ更新(id2を更新するので、ここでブロックが発生する) UPDATE accounts SET balance = balance + 10 WHERE id = 2 RETURNING *; # Terminal 2: データ更新 |
復習になりますが、 Transactionを開始するにはBEGIN;を入力します。そして、トランザクションは、ROLLBACK;やCOMMIT;を入力することで完了させることができます。
UPDATE文でデータ更新をかけていくと、Terminal1でブロックが発生しました。これは、同じIDに対して更新をかけたからです。
ロックを示すレコードを確認したい場合、「http://localhost:9232/」にアクセスして、以下のSELECT文を実行してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
select a.application_name, l.relation::regclass, l.transactionid, l.mode, l.locktype, l.granted, a.usename, a.query, a.pid from pg_stat_activity a join pg_locks l on l.pid = a.pid where a.application_name = 'psql' order by a.pid; |

「ShareLock」が発生している行がありました。これはTerminal1のブロックの原因になっています。その証拠としてqueryに「UPDATE accounts SET balance = balance + 10 WHERE id = 2 RETURNING *;」とあります。
その上の行を見てみると「ExclusiveLock」とあり、queryに「UPDATE accounts SET balance = balance + 10 WHERE id = 2 RETURNING *;」の記載があります。これは、Terminal2で実行したqueryです。
ExclusiveLock(排他的ロック)をかけているレコードに対して、UPDATEをかけるとブロックされるようですね。
Terminal1でブロックになっている状態で、Terminal2で以下のコマンドを実行するとデッドロックが発生します。
1 2 3 4 5 6 7 8 |
# Terminal2: 更新処理を実行(デッドロック発生) UPDATE accounts SET balance = balance + 10 WHERE id = 1 RETURNING *; ERROR: deadlock detected DETAIL: Process 44 waits for ShareLock on transaction 1002; blocked by process 36. Process 36 waits for ShareLock on transaction 1003; blocked by process 44. HINT: See server log for query details. CONTEXT: while updating tuple (0,1) in relation "accounts" |
詳細にも「DETAIL: Process 44 waits for ShareLock on transaction 1002; blocked by process 36.」とありますね。
デッドロックの発生が確認できたので、後片付けをしてコンテナから抜けましょう。
1 2 3 4 5 6 7 8 9 10 11 |
# Terminal1: ロールバック ROLLBACK; # Terminal2: ロールバック ROLLBACK; # Terminal1: コンテナから抜ける exit # Terminal2: コンテナから抜ける exit |
実装
デッドロック確認コード
前回の記事ではリレーション(id)に問題があることがわかった為、UPDATE文に「NO KEY」を追加して、事象を回避しました。
しかし、完全に回避できたわけではないようです。
以下のテストコードをstore_test.goに追加して、テストしてみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
func TestTransferTxDeadlock(t *testing.T) { store := NewStore(testDB) account1 := createRandomAccount(t) account2 := createRandomAccount(t) fmt.Println(">> before:", account1.Balance, account2.Balance) // Transactionを10個 n := 10 amount := int64(10) errs := make(chan error) for i := 0; i < n; i++ { fromAccountID := account1.ID toAccountID := account2.ID // 特定のTransactionでは、IDを逆にする if i%2 == 1 { fromAccountID = account2.ID toAccountID = account1.ID } go func() { ctx := context.Background() _, err := store.TransferTx(ctx, TransferTxParams{ FromAccountID: fromAccountID, ToAccountID: toAccountID, Amount: amount, }) errs <- err }() } for i := 0; i < n; i++ { err := <-errs require.NoError(t, err) } updatedAccount1, err := testQueries.GetAccount(context.Background(), account1.ID) require.NoError(t, err) updatedAccount2, err := testQueries.GetAccount(context.Background(), account2.ID) require.NoError(t, err) fmt.Println(">> after:", updatedAccount1.Balance, updatedAccount2.Balance) require.Equal(t, account1.Balance, updatedAccount1.Balance) require.Equal(t, account2.Balance, updatedAccount2.Balance) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
make test ... === RUN TestTransferTxDeadlock >> before: 717 926 store_test.go:150: Error Trace: store_test.go:150 Error: Received unexpected error: pq: deadlock detected Test: TestTransferTxDeadlock --- FAIL: TestTransferTxDeadlock (1.04s) FAIL coverage: 61.2% of statements FAIL golang-with-postgres/db/sqlc 1.132s ? golang-with-postgres/util [no test files] FAIL make: *** [Makefile:29: test] Error 1 |
デッドロックが発生しましたね!
次の処理で、accountのIDを入れ替えてます。
1 2 3 4 5 6 7 8 |
fromAccountID := account1.ID toAccountID := account2.ID // 特定のTransactionでは、IDを逆にする if i%2 == 1 { fromAccountID = account2.ID toAccountID = account1.ID } |
これにより、複数のトランザクションが同時に走るとaccount1およびaccount2のIDが同時に参照されるようになります。
# 今まで
T1 -> account1.ID参照 -> account2.ID参照
T2 -> account1.ID参照(T1がaccount1.IDを解放するのを待ってから) -> account2.ID参照
# 現状
T1 -> account1.ID参照 -> account2.ID参照(T2が解放するまで待つ)
T2 -> account2.ID参照 -> account1.ID参照(T1が解放するまで待つ)
->デッドロック
デッドロック回避コード
store.goにトランザクションを引き起こしているコードがあります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) { ... // FromAccount result.FromAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{ ID: arg.FromAccountID, Amount: -arg.Amount, }) if err != nil { return err } // ToAccount result.ToAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{ ID: arg.ToAccountID, Amount: arg.Amount, }) if err != nil { return err } return nil }) return result, err } |
デッドロックを回避するには、ここに条件分岐を入れると良さそうです。
前述の通り、異なるトランザクションがリレーション関係にあるaccountIDをロックし合うため、デッドロックが発生しました。つまり、特定の条件下では処理の順番を逆にして、ID1 -> ID2のように一方向の流れを作れば回避できるはずです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) { ... // 引数のaccountIDをチェック if arg.FromAccountID < arg.ToAccountID { result.FromAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{ ID: arg.FromAccountID, Amount: -arg.Amount, }) if err != nil { return err } result.ToAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{ ID: arg.ToAccountID, Amount: arg.Amount, }) if err != nil { return err } } else { // 処理の流れを逆にする result.ToAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{ ID: arg.ToAccountID, Amount: arg.Amount, }) if err != nil { return err } result.FromAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{ ID: arg.FromAccountID, Amount: -arg.Amount, }) if err != nil { return err } } return nil }) ... } |
チェックを入れて、条件に反した場合は処理の流れを逆にしました。これで大丈夫なはずです。if arg.FromAccountID < arg.ToAccountID
1 2 3 4 5 6 7 8 9 10 |
make test ... === RUN TestTransferTxDeadlock >> before: 82 470 >> after: 82 470 --- PASS: TestTransferTxDeadlock (0.08s) PASS coverage: 59.3% of statements ok golang-with-postgres/db/sqlc 0.188s coverage: 59.3% of statements ? golang-with-postgres/util [no test files] |
OKですね。ただ、処理が冗長になっているので、リファクタリングしましょう。
リファクタリング
重複しているAddAccountBalanceの呼び出しをユーティリティコードにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
func addMoney( ctx context.Context, q *Queries, accountID1 int64, amount1 int64, accountID2 int64, amount2 int64, ) (account1 Account, account2 Account, err error) { account1, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{ ID: accountID1, Amount: amount1, }) if err != nil { return } account2, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{ ID: accountID2, Amount: amount2, }) if err != nil { return } return } |
これを先ほど実装した条件文内のコードと差し替えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) { ... if arg.FromAccountID < arg.ToAccountID { result.FromAccount, result.ToAccount, err = addMoney(ctx, q, arg.FromAccountID, -arg.Amount, arg.ToAccountID, arg.Amount) } else { result.ToAccount, result.FromAccount, err = addMoney(ctx, q, arg.ToAccountID, arg.Amount, arg.FromAccountID, -arg.Amount) } return nil }) return result, err } |
だいぶ簡潔になりましたね。テストを実行しましょう。
1 2 3 4 5 6 7 8 9 10 |
$ make test ... === RUN TestTransferTxDeadlock >> before: 891 277 >> after: 891 277 --- PASS: TestTransferTxDeadlock (0.08s) PASS coverage: 60.0% of statements ok golang-with-postgres/db/sqlc 0.186s coverage: 60.0% of statements ? golang-with-postgres/util [no test files] |
OKですね。
次回
次回も引き続き、PostgreSQL中心に学習しましょう。
コメントを残す
コメントを投稿するにはログインしてください。