Skip to main content

08. [BackEnd] golang db transaction deadlock

TDD​


TDD는 Test Driven Development로 테스트를 먼저하고 그 다음에 테스트가 통과되면 코드를 개선하는 방식입니다.

account 테스트 코드 작성​


    existed := make(map[int]bool)
{
.
.
.

fromAccount := result.FromAccount
require.NotEmpty(t, fromAccount)
require.Equal(t, account1.ID, fromAccount.ID)

toAccount := result.ToAccount
require.NotEmpty(t, toAccount)
require.Equal(t, account2.ID, toAccount.ID)

// check different balance

diff1 := account1.Balance - fromAccount.Balance
diff2 := toAccount.Balance - account2.Balance
require.Equal(t, diff1, diff2)
require.True(t, diff1 > 0)
require.True(t, diff1%amount == 0)

k := int(diff1 / amount)
require.True(t, k >= 1 && k <= n)
require.NotContains(t, existed, k)
existed[k] = true
}

updatedAccount1, err := testQueries.GetAccount(context.Background(), account1.ID)
require.NoError(t, err)

updatedAccount2, err := testQueries.GetAccount(context.Background(), account2.ID)
require.NoError(t, err)

require.Equal(t, account1.Balance-int64(n)*amount, updatedAccount1.Balance)
require.Equal(t, account2.Balance+int64(n)*amount, updatedAccount2.Balance)

deadlock 해결​


지난 게시글에서 만들어본 fromAccount와 toAccount가 잘못됐다. 왜냐하면 동시에 transaction begin을 하게 되면 동시에 같은 계정을 가리키기 때문에 업데이트가 제대로 되지 않는다.

이를 해결하기 위해 For Update를 사용해줘야 한다.

For Update 쿼리​

SELECT * FROM accounts
WHERE id = $1 LIMIT 1
FOR UPDATE;

FOR UPDATE만 넣어주고 다시 sqlc해준다.

하지만 이렇게 해도 교착상태에 빠집니다.

deadlock 디버깅​


디버깅하기 위해서 context에 transaction 이름을 넣어야 합니다.

var txKey = struct{}{}

txKey라는 구조체를 만듭니다. struct{}{} 이렇게 하는 것은 빈 구조체를 만든다는 뜻입니다.

txName := fmt.Sprintf("tx %d", i+1)
ctx := context.WithValue(context.Background(), txKey, txName)

위와 같은 방식으로 context를 만들면 context에 txName이 포함됩니다.

txName은 context.Value(txKey) 함수를 통해서 얻을 수 있습니다.

테스트를 실행해보고 나온 결과를 실제 sql로 따라해본다.

BEGIN;

INSERT INTO transfers (from_accounts_id, to_accounts_id, amount) VALUES (1,2,10) RETURNING *;

INSERT INTO entries (accounts_id, amount) VALUES (1,-10) RETURNING *;
INSERT INTO entries (accounts_id, amount) VALUES (1,10) RETURNING *;

SELECT * FROM accounts WHERE id=1 FOR UPDATE
UPDATE accounts SET balance = 190 WHERE id=1 RETURNING;

SELECT * FROM accounts WHERE id=2 FOR UPDATE
UPDATE accounts SET balance = 210 WHERE id=1 RETURNING;

ROLLBACK;

하다보면 get account가 안되는 것을 알 수 있다.

더 자세히 보려면 아래 sql을 입력하면 된다.

  SELECT blocked_locks.pid     AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS current_statement_in_blocking_process
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid

JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

아래 sql도 같은 역할을 합니다.

SELECT a.datname,
a.application_name
l.relation::regclass,
l.transactionid,
l.mode,
l.GRANTED,
a.usename,
a.query,
a.query_start,
age(now(), a.query_start) AS "age",
a.pid
FROM pg_stat_activity a
JOIN pg_locks l ON l.pid = a.pid
ORDER BY a.query_start;
속성 의미

a.application_name : 잠금이 발생한 어플리케이션

l.relation::regclass : 테이블 이름

l.transactionid : 트랜젝션 아이디

l.mode : 잠금 모드

l.locktype: 잠금 유형

l.GRANTED : 잠금 부여 여부

a.usename : 쿼리 실행 사용자 이름

a.query : 잠금 보유하거나 획득하려는 쿼리

a.pid : pid

이렇게 해서 알아낸 것이 키 제약조건으로 인해서 교착 상태가 발생한다는 것입니다.

가장 간단한 방법은 키 제약조건을 제거하는 것입니다. 하지만 이는 좋은 방법이 아닌 것이 일관성 유지를 해야하기 때문입니다.

해결법​

account 업데이트를 할 때 account_id를 변경하는 것이 아니기 때문에

FOR UPDATE 대신에 FOR NO KEY UPDATE를 사용할 수 있습니다.

SELECT * FROM accounts
WHERE id = $1 LIMIT 1
FOR NO KEY UPDATE;

계정 잔액 변경 sql 개선​

-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + sqlc.arg(amount)
WHERE id = sqlc.arg(id)
RETURNING *;

위처럼 개선해서 진행할 수 있습니다.

sqlc.arg(변수명) 으로 설정하고 sqlc generate하면 해당 변수명으로 parameter가 생깁니다.