본문 바로가기
엑셈 경쟁력/DB 인사이드

DB 인사이드 | PWI - LOCKS > Row-level Lock(2)

by exemtech 2024. 8. 5.

 

 

📢 PWI(PostgreSQL Wait Interface) - Lock

PostgreSQL의 Wait Event에 대하여 다루기 전에, 먼저 PostgreSQL에서 사용하는 Lock에 대한 전반적인 내용을 다룰 예정입니다.
PostgreSQL은 Relation과 같은 Object를 보호하는 Heavyweight Lock(HWLock), Relation의 구성 요소의 하나인 Row를 다루는 Row-level Lock, 그리고 일반적으로 공유 메모리의 데이터 구조에 접근할 때 사용하는 Lightweight Lock(LWLock) 등 다양한 유형의 Lock을 제공합니다.
앞으로 PWI - Locks에서는 PostgreSQL에서 사용하는 Lock의 종류와 특징, 동작 방식 등을 알아보고, 사용 예시를 통하여 Lock을 획득 및 해제하는 과정을 확인해 보도록 하겠습니다.

 

 

Row-level Lock

앞서 Row-level Lock의 특징인 Two-level 메커니즘과 함께 Row-level Lock에서 제공하는 Lock Mode에 대해서 살펴보았습니다.

이번 글에서는 Lock Mode 간 호환 여부에 따른 동작 과정을 예시를 통해 살펴보도록 하겠으며, Dead Lock에 대해서도 함께 알아보도록 하겠습니다.

 

Lock Contention

Row-level Lock에 대하여 상호 호환되지 않는 모드로 요청한 트랜잭션과 호환 가능한 모드로 요청한 트랜잭션이 있을 때로 나누어서 테스트하겠습니다.

 

[case 1] Row-level Lock 충돌이 발생

먼저, 아래 예시는 Relation-level Lock은 상호 호환 가능하지만 Row-level Lock에 대해서는 서로 호환이 되지 않는 Lock Mode로 요청한 두 트랜잭션이 존재하는 상황으로, Relation-level Lock은 해당 주제의 글에서 설명하였으므로 여기에서는 Row-level Lock에 초점을 맞춰 설명하겠습니다.

-- 트랜잭션 T1 시작
BEGIN;

-- PID, transactionID 확인
SELECT pg_backend_pid(), txid_current();

 pg_backend_pid | txid_current 
----------------+--------------
        2493574 |        1574 

-- UPDATE 수행 (Relation Lock Mode : Row Exclusive)       
UPDATE lock_test SET c3 = c3 + 100.00 WHERE c1 = 1;

트랜잭션 T1은 Row-level Lock에 대해서 ‘FOR NO KEY UPDATE’ 모드를 사용하도록 Update Command를 수행합니다. 그리고 pg_lockspgrowlocks를 사용하여 T1의 Lock 정보를 확인합니다.

pg_locks

 

pg_locks 조회 결과를 보면, Relation(lockid=lock_test)에 대하여 T1은 RowExclusiveLock 모드로 Relation-level Lock을 획득한 것을 볼 수 있습니다. pg_locks에서는 Row-level Lock 정보를 확인할 수 없으므로 pgrowlocks를 추가 조회하여 Row-level Lock을 확인합니다.

pgrowlocks('loct_test')

locked_row(0,1)에 해당하는 Row에 대하여 트랜잭션 ID가 1574인 T1이 No Key Update 모드로 Lock 상태임을 알 수 있습니다. heap_page 결과를 통해 Tuple Header 정보를 확인하면 pgrowlocks 결과와 일치하는 것을 알 수 있습니다.

heap_page('lock_test',0)

ctid(0,1)에 해당하는 Tuple의 xmax 또한 1574가 기록되었고, keys_updated, keyshr_lock, shr_lock 항목이 NULL인 것을 통해 ‘FOR NO KEY UPDATE’ 모드로 Row-level Lock 설정되었음을 알 수 있습니다. (추가로 hot_updated 값이 T라는 것은 Heap-Only Tuple Update 방식으로 처리되었음을 나타냅니다.)

-- 트랜잭션 T2 시작
BEGIN;

-- PID, transactionID 확인
SELECT pg_backend_pid(), txid_current();

 pg_backend_pid | txid_current 
----------------+--------------
        2493575 |        1575       

-- SELECT FOR 수행 (Relation Lock Mode : Row Share )
SELECT * FROM lock_test WHERE c1 = 1 FOR UPDATE;

이어서 트랜잭션 T2는 T1에서 사용한 ‘FOR NO KEY UPDATE’ 모드와 상호 호환되지 않는 ‘FOR UPDATE’ 모드를 선택하도록 명시적으로 작성한 Select for update Command를 수행합니다.

pg_locks

두 트랜잭션에 대한 pg_locks의 결과를 조회해 보면, ①동일한 Relation(lockid=lock_test)에 대하여 T1은 RowExclusiveLock 모드로, ②T2는 같은 Relation에 대하여 RowShareLock 모드로 Relation-level Lock 획득을 확인할 수 있습니다. 그리고 ③T2는 Tuple에 대한 HWLock을 AccessExclusiveLock 모드로 획득하였지만, ④T1의 트랜잭션 ID(lockid=1574)에 대한 Lock은 획득에 실패하였습니다. 이 결과를 통해 T1이 Tuple을 먼저 사용 중이고, 상호 호환 안 되는 모드로 Row-level Lock을 요청한 T2는 T1의 트랜잭션 종료 시까지 대기하고 있음을 알 수 있습니다.

heap_page('lock_test', 0)

T2로 인해 Tuple Header에는 어떤 변화가 있는지 Tuple Header 정보를 확인하고자 heap_page 결과를 보면, 이전 결과와 차이가 없음을 알 수 있습니다. 이는 T1과 T2 간의 Row-level Lock 충돌로 인하여 T2는 Tuple Header에 Row-level Lock 관련하여 상태를 변경할 수 없기 때문입니다.

[case 2] Row-level Lock 상호 호환 가능

이번에는 두 트랜잭션이 Row-level Lock에 대해 상호 호환 가능한 Lock Mode를 요청하였을 때, 즉, Tuple에 대한 동시 작업이 가능한 상황에 대해 살펴보도록 하겠습니다.

Relation-level에 이어 두 트랜잭션이 Row-level에서도 호환 가능하다는 것은, 동시성과 관련된 대기 없이 원하는 작업을 수행할 수 있다는 것을 의미합니다. 하지만 이 경우에도 각각의 트랜잭션은 반드시 동일한 Tuple Header의 xmax에 자신의 트랜잭션 ID를 기록해야만 합니다. (Level 1 메커니즘에 의해)

하지만 선행 트랜잭션에 의해 xmax가 비어있지 않다면, 이후의 트랜잭션은 자신의 트랜잭션 ID를 바로 기록할 수 없어 동시성 제어가 필요하리라 생각되는데, 어떻게 대기 없이 상호 호환이 가능한 걸까요?

그 이유는, PostgreSQL이 multitransactionID*라는 개념을 통해 두 개 이상의 트랜잭션 ID를 xmax에 기록하는 상황을 허용하고 있기 때문입니다.

📢 multitransactionID
Tuple Header는 Tuple에 대한 상태 정보를 저장하기 위해서 매우 제한된 공간을 제공하기 때문에, 하나의 트랜잭션 ID와 소수의 infomask bit를 위한 공간만 있습니다. 그렇기 때문에 두 개 이상의 트랜잭션에 의한 Lock을 설정해야 할 때는 multitransactionID를 부여합니다. 이처럼 상호 호환되는 Lock을 처리할 때, PostgreSQL은 Multi Transaction을 적용합니다. Multi Transaction은 별도로 ID가 할당된 트랜잭션의 그룹(배열)으로, 이러한 Multi Transaction에 부여되는 ID를 multitransactionID라고 합니다. multitransactionID는 일반적인 transactionID와 동일하게 xmax 기록에 사용됩니다. multitransactionID와 일반적인 transactionID는 독립적으로 생성되기 때문에 우연히 같은 값을 가질 수도 있습니다. 그래서 이를 구분하기 위해 Tuple Header에 is_multi라는 플래그를 infomask bit로 추가하였습니다.

 

multitransactionID를 포함한 자세한 내용은 테스트를 통해 확인해 보겠습니다.

-- 트랜잭션 T1 시작
BEGIN;

-- PID, transactionID 확인
SELECT pg_backend_pid(), txid_current();

 pg_backend_pid | txid_current 
----------------+--------------
        2493574 |        1579 

 -- UPDATE 수행   
UPDATE lock_test SET c3 = c3 + 100.00 WHERE c1 = 1;

트랜잭션 T1은 이전 예시와 같이 Row-level Lock에 대해서 ‘FOR NO KEY UPDATE’ 모드를 사용하도록 Update Command를 수행하였으며, pg_lockspgrowlocks 조회 결과 또한 동일한 것을 알 수 있습니다.

pg_locks
pgrowlocks('loct_test')

 

heap_page를 통해 Tuple Header 정보를 확인하면, 다음과 같습니다.

heap_page('lock_test',0)

ctid(0,1)xmax에 T1의 트랜잭션 ID인 1579가 기록되었으며, keys_updated, keyshr_lock, shr_lock 항목은 모두 NULL인 것을 볼 수 있습니다. 그리고 ②T1의 Update 수행 결과로 ctid(0,6) Tuple이 새롭게 생성되었으며, ctid(0,6)xmin에는 T1의 트랜잭션 ID인 1579가 기록되어 있습니다. 또한 ctid(0,1)t_ctid에는 (0,6)를 저장하여, ctid(0,1)의 다음 버전이 ctid(0,6) 임을 가리킵니다.

이어서 트랜잭션 T2를 시작합니다. 이번에는 T1이 기록한 Row-level Lock의 Lock Mode인 ‘FOR NO KEY UPDATE’와 상호 호환이 가능한 Lock Mode인 ‘FOR KEY SHARE’를 선택하도록 다음과 같이 Select For key share Command를 수행하였습니다.

-- 트랜잭션 T2 시작
BEGIN;

-- PID, transactionID 확인
SELECT pg_backend_pid(), txid_current();

 pg_backend_pid | txid_current 
----------------+--------------
        2493575 |        1580    

-- SELECT FOR KEY SHARE 수행
SELECT * FROM lock_test WHERE c1 = 1 FOR KEY SHARE;

수행 후 pg_locks 조회 결과를 보면, granted 값이 false인 결과가 없습니다. 이는 대기 없이 필요한 Lock을 모두 획득하는 데 성공했음을 나타냅니다.

pg_locks

pg_locks에서는 제공하지 않는 Row-level Lock 정보를 확인하기 위해 pgrowlocks를 조회합니다.

pgrowlocks('lock_test')

locked_row(0,1)multi=true는 multitransactionID를 사용하고 있다는 것을 의미하며, 이는 즉 두 개 이상의 트랜잭션이 해당 Row에 Lock을 설정하였음을 의미합니다. locker에 표시된 6이 바로 multitransactionID입니다. 이 6이라는 multransactionID는 xids에 표시된 두 트랜잭션의 그룹 {1579, 1580} 배열에 부여된 ID입니다.

정리하면, xid=1579의 트랜잭션 T1은 NO KEY UPDATE 모드로, xid=1580에 해당하는 트랜잭션 T2는 KEY SHARE 모드로 locked_row=(0,1)에 Row-level Lock을 같이 설정하였음을 나타냅니다.

위 내용이 Tuple Header에는 어떻게 반영되었는지 heap_page를 통해 확인해 보겠습니다.

heap_page('lock_test', 0)

①Update가 수행된 Tuple에 Lock을 설정할 때는 Update Chain을 따라 다음 버전의 Tuple에도 Row-level Lock을 설정해야 하기 때문에, T2는 해당 Row에 대응하는 Tuple인 ctid(0,1) ctid(0,6)에 모두 Row-level Lock을 설정하였습니다.

📢 Update를 수행하면 변경 전 Tuple의 t_ctid에 새로 생성한 Tuple의 위치를 저장합니다. 이 값을 통해 Update 전/후 Tuple을 연결할 수 있으며, 이것을 Update Chain이라고 합니다. 위 예시에서는 “ctid(0,1) → ctid(0,6)” 로 이어지는 Update Chain을 알 수 있습니다.

ctid(0,1)xmax에 기록된 6is_multi=T를 통해 multitransactionID라는 것을 알 수 있으며, 우리는 이전에 pgrowlocks를 조회하여 multitransactionID 6이 트랜잭션 ID 15791580을 포함하는 배열이라는 것을 확인했습니다. 이 중 1580이 T2의 트랜잭션 ID에 해당합니다. 따라서 ctid(0,1)은 T1과 T2, 두 트랜잭션에 의해 사용 중인 Tuple이라는 것을 알 수 있습니다.

ctid(0,6)xmax에는 T2의 트랜잭션 ID인 1580이 기록되었습니다. 그리고 infomask bit에는 T2가 수행한 Select For key share Command 작업 성격에 따라 lock_onlykeyshr_lock이 설정된 것을 확인할 수 있습니다.

📢 Tuple ctid(0,1)은 T1과 T2, 두 트랜잭션에 의해 Row-level Lock이 설정되었으므로, xmax에 두 트랜잭션의 ID 모두를 가리키는 multitransactionID를 기록하였습니다. 하지만 infomask bit에는 multitransactionID에 속한 트랜잭션들의 Lock Mode 중에서 가장 강한 모드, 즉 상호 호환성이 낮은 모드로 설정합니다. 따라서 T1이 요청한 Lock Mode인 ‘NO KEY UPDATE’ 모드만 기록된 것을 볼 수 있습니다.

 

 

다음은 일반적인 상황에서 Row-level Lock을 획득하는 과정을 도식화 한 그림입니다.

Row는 논리적으로 Relation에 포함되는 개념이므로 Relation-level Lock 경합이 선행됩니다. 일반적으로 트랜잭션 간 Relation-level Lock 경합 시 충돌이 없다면(상호 호환가능한 경우), Row-level Lock 충돌 여부에 따라 Two Level 메커니즘에 의해 대기 여부가 결정됩니다.

 

 

Dead Lock

PostgreSQL에서 Dead Lock은 Row-level Lock 경합 상황에서 비교적 빈번하게 발생합니다. Dead Lock이 발생하면, 원인이 되는 트랜잭션 종료 전까지 문제가 지속되기 때문에, 이러한 Dead Lock 문제를 해결하기 위하여 PostgreSQL은 Dead Lock Detection 기능을 제공합니다.

 

Dead Lock Detection

PostgreSQL의 해당 기능은 주기적으로 Dead Lock을 모니터링하고, Dead Lock이 감지되면 트랜잭션 하나를 강제 종료시킴으로써 문제를 해결합니다. Dead Lock 발생 예시를 통해 어떻게 동작하는지 알아보겠습니다.

Dead Lock Detection 기능은 deadlock_timeout이라는 파라미터에 설정된 시간보다 긴 시간 동안 리소스 획득을 대기하는 트랜잭션이 있을 때 수행됩니다. 사용자는 먼저 다음과 같이 파라미터 설정을 확인 후 변경할 수 있습니다.

show deadlock_timeout;

 deadlock_timeout
------------------
 1s

Dead Lock 발생을 위해서 다음과 같이 두 트랜잭션을 시작하고 Row-level Lock에 대한 충돌이 발생하도록 SQL Command를 작성합니다. (아래 예시는 Relation-level Lock은 상호 호환되도록 작성, Row-level Lock에 초점을 맞춰 설명합니다.)

-- T1

begin;

SELECT pg_backend_pid(), txid_current();
 pg_backend_pid | txid_current
----------------+--------------
        4102438 |         2118

select *
from lock_test
where c2='row1' for update;

update lock_test
set c1 = c1 + 1000
where c2 = 'row2';

 

-- T2

begin;

SELECT pg_backend_pid(), txid_current();
 pg_backend_pid | txid_current
----------------+--------------
        4102595 |         2119

select *
from lock_test
where c2='row2' for update;

update lock_test
set c1 = c1 + 1000
where c2 = 'row1';
  1. T1에서 Select for update Command를 수행하였으므로 c2=’row1’에 해당하는 Row에 대하여 Row-level Lock을 ‘FOR UPDATE’ 모드로 설정합니다.
  2. 이후, T2에서도 Select for update Command를 수행합니다. T2c2=’row2’에 해당하는 Row에 대하여 Row-level Lock을 ‘FOR UPDATE’ 모드로 설정합니다.
  3. 이어서 T1에서 c2=’row2’에 해당하는 Row에 Update Command를 수행합니다. 하지만 해당 Row는 이미 T2에 의해 사용 중이므로 Tuple HWLock을 획득 후 T2 트랜잭션 종료 시까지 대기합니다.
  4. 이번에는 T2에서 c2=’row1’에 해당하는 Row에 대한 Update Command를 수행합니다. 이 Row는 T1이 먼저 수정 중이기 때문에 T2는 Tuple HWLock을 획득하고 T1 종료 시까지 대기합니다.

결과적으로 T1T2 트랜잭션 종료를 기다리고, T2T1 트랜잭션이 종료되기를 기다리고 있습니다. 두 트랜잭션이 모두 상대 트랜잭션의 종료를 기다리고 있는 Dead Lock 상황이 발생했습니다. deadlock_timeout 설정만큼의 시간이 흐른 후, Dead Lock Detection 기능이 동작합니다. Dead Lock 발생이 감지되면 다음과 같은 에러 메시지를 표시하며 임의의 하나의 트랜잭션, 예시에서는 T2 트랜잭션이 강제 종료됩니다. 그리고 나머지 트랜잭션인 T1이 요청한 작업은 정상적으로 수행됩니다.

-- T2
ERROR:  deadlock detected
DETAIL:  Process 4102595 waits for ShareLock on transaction 2118; blocked by process 4102438.
Process 4102438 waits for ShareLock on transaction 2119; blocked by process 4102595.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,1) in relation "lock_test"

이처럼 Dead Lock Detection 기능을 통해서 Dead Lock 현상을 해결할 수는 있지만, 이 기능을 사용하는 과정은 리소스가 소모될 뿐만 아니라 강제 종료로 작업을 실패하는 트랜잭션이 생기게 됩니다. 따라서 사용자는 주기적으로 Dead Lock을 모니터링하여 Dead Lock 현상을 파악 및 조치해야 합니다. PostgreSQL은 Dead Lock 발생 시 서버 로그에 기록하고, Dead Lock 발생량을 시스템 카탈로그 뷰(pg_stat_database.deadlocks)에도 저장하므로 이를 추적하여 Dead Lock 발생을 감지할 수 있습니다.

select datid, datname, deadlocks from pg_stat_database;

datid|datname  |deadlocks|
-----+---------+---------+
    0|         |        0|
    5|postgres |        2|
    1|template1|        0|
    4|template0|        0|

 

 

 

마무리

이번 글에서는 PostgreSQL의 Row-level Lock에 대한 경합 상황과 Dead Lock 발생 예시를 살펴보았습니다. 주요 내용을 정리하면 다음과 같습니다.

  • Relation-level Lock에 대해서는 상호 호환 가능한 상황에서 Row-level Lock 경합이 발생할 수 있다.
  • 두 트랜잭션이 상호 호환되지 않는 모드로 Row-level Lock을 요청하면, Level 2 메커니즘에 따라 Tuple에 대한 HWLock을 사용하여 Wait Queue를 구현하여 Tuple 사용을 위한 대기 순서를 관리한다.
  • 반면에, 두 트랜잭션이 Row-level Lock에 대하여 상호 호환되는 모드로 요청한다면 Level 1 메커니즘에 따라 Tuple Header의 xmax에 두 트랜잭션 ID를 모두 기록하고자 한다. 이처럼 xmax에 두 개 이상의 트랜잭션 ID를 기록해야 하는 경우에는 트랜잭션 그룹에 대하여 multitransactionid를 할당해서 사용한다.
  • Row-level Lock 경합 상황에서 Dead Lock은 빈번하게 발생할 수 있으며, PostgreSQL은 Dead Lock 문제를 해결하기 위하여 Dead Lock Detection 기능을 제공한다.

 

 

 

댓글