Lock 에 대해 알아보자

김법우·2023년 12월 16일
0

Database

목록 보기
10/12
post-thumbnail

참고

13.3. Explicit Locking

[DB] Postgresql Lock 파헤치기

PostgreSQL 공식 한글 설명서들: 한국 포스트그레스큐엘 홈페이지


Postgres Lock

PostgreSQL provides various lock modes to control concurrent access to data in tables. These modes can be used for application-controlled locking in situations where MVCC does not give the desired behavior. Also, most PostgreSQL commands automatically acquire locks of appropriate modes to ensure that referenced tables are not dropped or modified in incompatible ways while the command executes.

(https://www.postgresql.org/docs/current/explicit-locking.html)

Lock 이란?


https://i0.wp.com/www.dbwatch.com/wp-content/uploads/2021/01/AcidProperties.jpg?fit=502%2C412&ssl=1

위는 Lock 에 대해 설명한 Postgres 공식 문서 내용 중 일부이다. 결국 데이터베이스 Lock 은 명령이 동시 실행되는 동안 참조 테이블이 올바르지 않은 방식으로 삭제 혹은 수정되어 테이블 상태가 바뀌는 것을 막기 위해 존재한다.

기본적으로 Postgres 에서 자동으로 참조 테이블에 대한 적절한 Lock 을 획득하게 되어 동시에 명령을 실행해도 올바르지 않은 방식으로 테이블 상태가 바뀌지 않도록한다.

Lock 은 말 그대로 잠금, 상태를 변경하고자 하는 대상을 다른 명령에서 읽거나 쓰는 등의 작업에 다양한 수준의 제한을 두어 동시에 실행되는 명령들간에 순서를 부여하는 형태로 동작한다. 데이터베이스에서 접근 가능한 자원은 데이터베이스 자체, 파일 (물리 저장소), 테이블, 페이지/블록, 컬럼, 행 이 있는데 이번에는 테이블에 대한 잠금에 대해서 집중적으로 다루어보자.


Lock 과 충돌

앞서 이야기한 내용을 다시 복기해보자. 만약 내 잔고에 10만원이 있는데 오늘이 월말이라 유튜브 프리미엄과 AWS 등 다양한 구독 서비스에서 계좌 출금을 시도하는 상황이다. “출금” 이라는 트랜잭션이 유튜브와 AWS 에 의해 동시에 실행 될 것이고 두 트랜잭션은 “내 계좌의 잔액”이라는 동일한 데이터에 접근해 수정하는 작업을 요청 할 것이다.
(트랜잭션에서 데이터베이스 쿼리를 실행하기 위해서는 적절한 리소스 범위에 대한 잠금을 얻어야만 한다.)

자, 이렇게 두 개 이상의 트랜잭션이 동시에 동일한 리소스, 데이터에 접근하고 수정하기 위해 Lock 을 요청하는 상황을 “충돌”이라고 한다. 충돌이 발생하면 일반적으로 Dead Lock 혹은 Race Condition(경쟁 조건) 중 하나의 상황으로 이어진다.


Dead Lock

https://velog.io/@rik963/Database-DeadLock

Dead Lock 은 두 개 이상의 트랜잭션이 각자 다른 잠금을 획득한 상태에서 각자가 획득한 잠금을 서로 대기하는 상황에서 발생한다. 두 명의 도둑이 금괴를 잡고 실랑이를 하며 “너 먼저 손 놓으면 놓을게!” 라고 하는 상황에 빗댈 수 있겠다. 심지어 도둑 두 명이 실랑이를 하는 와중에도 뒤에는 수 없이 많은 도둑들이 금괴를 가지기 위해 밀어 닥쳐온다.

이처럼 Dead Lock 이 발생하면 데이터베이스 시스템 일관성을 해칠 뿐 만 아니라 프로세스의 동작에도 심각한 비효율을 만들 수 있다. 따라서 대부분의 데이터베이스 시스템들이 Dead Lock 상태에서 벗어나기 위한 방법을 사용하는데 대표적인 방법은 아래와 같다.

  • “타임 아웃” - 특정 시간동안 잠금을 획득하지 못한 경우 트랜잭션이 해당 잠금을 포기하도록 강제한다.*
  • “Dead Lock detection” - 주기적으로 Dead Lock 을 탐지하고 발생시 하나 이상의 트랜잭션을 롤백.
  • “Dead Lock 예방” - 잠금을 어떤 순서로 확보할지 사전에 계획해 예방한다.*

Postgres 공식 문서에서도 교착 상태를 주기적으로 감지하고 있으며 감지할 경우 하나를 강제로 중단(누가 중단 될 지는 모름)해 문제를 해결한다고 명시 되어있다. 하지만 가장 좋은 방법은 당연히 잠금을 순차적으로 획득하고 나머지는 대기하도록 해 교착 상태를 방지하는 것이다.

백문이불여일견, 한번 교착 상태를 만들고 Postgres 에서 어떻게 처리하는지 확인해보자.

// session 1
begin;
select balance from account *where name = 'beobwoo' for update; // row* 

// session 2
begin;
select balance from account *where name = 'jawoon' for update;*

session 1 의 트랜잭션에서 beobwoo 계정의 잔액에 RowShareLock (select .. for update 구문으로 얻음) 을 획득했다. 그리고 이후에 바로 session 2 의 트랜잭션에서 jawoon 계정의 잔액에 마찬가지로 RowShareLock 을 획득했다.

RowShareLock 과 RowShareLock 은 서로 충돌하지 않으므로 동시에 여러 트랜잭션이 잠금 획득이 가능하다.

locktype     |relation                        |mode         |tid  |pid|granted|
-------------+--------------------------------+-------------+-----+---+-------+
transactionid|                                |ExclusiveLock|82226| 40|true   |
relation     |"PK_54115ee388cdb6d86bb4bf5b2ea"|RowShareLock |     | 40|true   |
relation     |account                         |RowShareLock |     | 40|true   |
virtualxid   |                                |ExclusiveLock|     | 40|true   |
transactionid|                                |ExclusiveLock|82225| 41|true   |
relation     |account                         |RowShareLock |     | 41|true   |
virtualxid   |                                |ExclusiveLock|     | 41|true   |
relation     |"PK_54115ee388cdb6d86bb4bf5b2ea"|RowShareLock |     | 41|true   |

이때 만약 session 1 에서 session 2 가 획득한 잠금을 요청하고, session 2 에서는 session 1 이 획득한 잠금을 요청하면 Dead Lock 의 발생 조건을 만족시키므로 Dead Lock 이 발생한다.

// session 1
begin;
select balance from account where name = 'beobwoo' for update;
update account set balance = balance + 1000 where name = 'jawoon';  // <-- session 2 에서 가지고 있는 리소스에 대한 잠금 요청

// session 2
begin;
select balance from account where name = 'jawoon' for update;
update account set balance = balance + 1000 where name = 'beobwoo';  // <-- session 1 에서 가지고 있는 리소스에 대한 잠금 요청

-> Dead Lock!!

Dead Lock 이 발생했다는 알림이 뜨고 두 트랜잭션 중 하나가 강제로 종료되는데 글을 쓰는 시점에서는 session 2의 트랜잭션이 종료되었다.


Race condition

경쟁 조건은 여러 프로세스 혹은 스레드가 공유된 자원에 동시에 접근하고 그 접근이 서로 겹치며 예측 할 수 없는 결과가 발생하는 상황을 말한다. “출금” 이라는 트랜잭션은 잔액을 먼저 조회하고 잔액을 차감하는 2개의 명령으로 이루어져 있다고 해보자.

이때 AWS 가 먼저 조회를 시작해 10만원이 있는 것을 확인했다. 그리고 AWS 가 10만원을 차감하기전 유튜브가 잔액을 조회해 마찬가지로 10만원이 있는 것을 확인한다. 그리고 AWS 가 10만원을 차감해 실제 잔액은 0원이지만 유튜브도 10만원을 차감해 최종적으로 내 잔액은 -10만원이 된다.

이렇게 동시에 실행되는 트랜잭션에 의해 예측 할 수 없는, 논리적인 데이터 일관성이 깨져 오류라고 판단할 상황이 발생하는 것을 Race Condition 이라고 한다. 즉 간단히 이야기하면 트랜잭션들이 동시에 실행되면서 실행 순서가 달라지면 결과 또한 달라지는 경우 경쟁 조건이 발생한다.

그런데 앞서 Postgres 에서 자동으로 적절한 Lock 을 획득한다고 하지 않았던가? 어째서 그럼에도 불구하고 이런 불일치가 발생하게 되는 것일까? 정답을 먼저 말하자면 select, update 만으로 이루어진 트랜잭션이 디폴트 고립 수준(Read Commited)에서 실행될 경우 AWS 와 유튜브가 동시에 select 를 위한 잠금을 얻어 해서는 안될 조회를 하기 때문이다. 즉, 별도로 명시하지 않은 경우 조회를 위한 잠금은 여러 트랜잭션에서 동시에 얻을 수 있기 때문에 이런 상황이 발생한다.

// AWS 세션
begin;
select balance from account where name = 'beobwoo';
=> 100,000

// 유튜브 세션
begin;
select balance from account where name = 'beobwoo';
=> 100,000
relationmode(잠금 모드, 수준)pidgranted (잠금 획득 여부)
accountAccessShareLock39 (AWS)true
accountAccessShareLock51 (유튜브)true

이번에는 조금 실행 쿼리를 수정해 select … for update 를 사용해서 두 세션에서 트랜잭션을 동시에 실행해 보겠다.

// AWS 세션
begin;
select balance from account where name = 'beobwoo' for update;
locktype     |relation                        |mode         |tid  |pid|granted|
-------------+--------------------------------+-------------+-----+---+-------+
relation     |"PK_54115ee388cdb6d86bb4bf5b2ea"|RowShareLock |     | 86|true   |
relation     |account                         |RowShareLock |     | 86|true   |
virtualxid   |                                |ExclusiveLock|     | 86|true   |
transactionid|                                |ExclusiveLock|82245| 86|true   |
// 유튜브 세션
begin;
select balance from account where name = 'beobwoo' for update;
locktype     |relation                        |mode               |tid  |pid|granted|
-------------+--------------------------------+-------------------+-----+---+-------+
relation     |"PK_54115ee388cdb6d86bb4bf5b2ea"|RowShareLock       |     | 86|true   |
relation     |account                         |RowShareLock       |     | 86|true   |
virtualxid   |                                |ExclusiveLock      |     | 86|true   |
transactionid|                                |ExclusiveLock      |82245| 86|true   |
relation     |account                         |RowShareLock       |     | 87|true   |
virtualxid   |                                |ExclusiveLock      |     | 87|true   |
transactionid|                                |ShareLock          |82245| 87|false  |
tuple        |account                         |AccessExclusiveLock|     | 87|true   |
relation     |"PK_54115ee388cdb6d86bb4bf5b2ea"|RowShareLock       |     | 87|true   |

이때 두번째 세션도 select … for update 를 사용해 잔액을 조회하기 위해 잠금을 요청하면 해당 튜플에 대해 AccessExclusiveLock 을 획득하고 동일한 리소스에 대한 잠금 획득을 위해 ShareLock 을 AWS 에게 요청하지만 충돌이 발생해 대기하게 된다. 따라서 AWS 세션에서 commit 혹은 rollback 이 발생하고 잠금을 반환하기전까지는 유튜브의 select 문이 실행되지 않으므로 트랜잭션이 동시에 실행되더라도 조회하고 수정하는 작업의 순서가 일련의 정상 흐름으로 항상 실행됨을 보장할 수 있게 된다.

이처럼 Race Condition 을 해결하기 위해서는 논리적으로 구성된 트랜잭션이 올바르게 처리되도록 명시적으로 적절한 수준의 잠금을 획득하도록 만들어야한다. “적절한 수준”의 잠금을 판단하기 위해서는 잠금의 수준이 어떤게 있고 각 수준별로 공유가 가능한지, 아니면 공유가 불가능해 충돌이나고 대기를 하는지를 알아야한다. 테이블 수준 잠금이 어떤게 있는지는 아래에서 하나씩 간단하게 다루어본다.


📌 요약
  • Postgres 의 경우 테이블 데이터에 대한 동시 엑세스를 제어하기 위해 다양한 잠금 모드를 제공
  • Postgres SQL 명령은 자동으로 적절한 모드의 잠금을 획득해 참조 테이블이 호환되지 않는 방식으로 삭제되거나 수정되지 않도록 한다.
  • 테이블 수준의 lock
    - 하나의 잠금 모드와 다른 잠금 모드의 차이는 충돌하는 잠금 모드 세트
    - 두 트랜잭션이 동시에 동일한 테이블에서 충돌하는 모드의 잠금을 보유 할 수 없음
    - 충돌하지 않는 잠금 모드는 많은 트랜잭션에 의해 동시 유지 가능
            *ex) Access Exclusive → 자체 충돌, Access Share → 여러 트랜잭션이 잠금 보유* 

테이블 수준의 잠금

각 요소에 충돌 여부가 표시되어있다. 충돌한다는 의미는 두 트랜잭션이 동시에 요소에 표기된 잠금을 가질 수 없다는 의미이다. 각 잠금이 어떨때 자동으로 얻어지고 어떤 목적으로 사용 되는지에 대해 알아보자!


Access Share [AccessShareLock]

  • Access Exclusive 를 제외하고 다른 모든 잠금과는 충돌하지 않음
  • 일반적으로 테이블을 읽기만 하고 수정하지 않는 쿼리는 이 잠금 모드를 획득
  • 성능에 지장을 주는 잠금이 아니며 조회 진행 중에 DDL 로 테이블 구조를 바꾸지 못하도록 한다.
begin; select * from item;
select locktype, relation::regclass, mode, transactionid tid, pid, granted from pg_catalog.pg_locks where not pid=pg_backend_pid() order by pid;
locktyperelationmodetidpidgranted
relationitem_pkAccessShareLock57TRUETRUE
relationitemAccessShareLock57TRUETRUE
virtualxidExclusiveLock57TRUE

위 상황에서 조회로 인해 item relation 에 대한 AccessShareLock 을 획득하였다.

alter table item drop column selected;
select locktype, relation::regclass, mode, transactionid tid, pid, granted from pg_catalog.pg_locks where not pid=pg_backend_pid() order by pid;
locktyperelationmodetidpidgranted
relationitem_pkAccessShareLock57TRUE
virtualxidExclusiveLock57TRUE
relationitemAccessShareLock57TRUE
virtualxidExclusiveLock76TRUE
relationitemAccessExclusiveLock76FALSE
transactionidExclusiveLock3174576TRUE
SELECT query,state,pid FROM pg_catalog.pg_stat_activity;
querystatepid
alter table item drop column selectedactive76

이때 만약 DDL 을 통해 item 테이블 구조를 변경할려고 하면 AccessExclusiveLock 이 얻어지고 granted 가 False 인 것을 알 수 있다. 아직 AccessShareLock 을 얻은 첫번째 트랜잭션이 종료되지 않아 잠금이 반환되지 않았으므로 아래 DDL 쿼리는 대기한다.


Row Share [RowShareLock]

  • Access Exclusive , Exclusive 를 제외하고 다른 모든 잠금과는 충돌하지 않음
  • select 명령과 for update, for no key update, for share, for key share 옵션 중 하나가 지정된 경우 혹은 명시적인 for … <<옵션>> 인 경우 해당 모드의 잠금을 획득.
begin; 
select * from item for update;
select locktype, relation::regclass, mode, transactionid tid, pid, granted from pg_catalog.pg_locks where not pid=pg_backend_pid() order by pid;
locktyperelationmodetidpidgranted
relationitem_pkRowShareLock57TRUE
relationitemRowShareLock57TRUE
virtualxidExclusiveLock57TRUE
transactionidExclusiveLock3175057TRUE

공식 문서에 나온대로 select * from item for update; 쿼리를 실행하는 경우 item relation 에 대해 RowShareLock 을 획득한다.

begin; 
select * from item;
update item set selected=selected+1 where id=2;

이때 만약 다른 세션에서 잠금이 걸린 item relation 에 대해 수정을 요청하면 ShareLock 을 얻고 RowShareLock 을 얻은 트랜잭션이 종료되기를 대기한다.


Row Exclusive [RowExclusiveLock]

  • Update, Delete, Insert, Merge 명령은 대상 테이블에서 이 잠금 모드를 획득.
  • 일반적으로 테이블의 데이터를 수정하는 모든 명령에 의해 획득됨
begin;
update item set selected=selected+1 where id=1;
locktyperelationmodetidpidgranted
relationitem_pkRowExclusiveLock40TRUE
relationitemRowExclusiveLock40TRUE
virtualxidExclusiveLock40TRUE
transactionidExclusiveLock3175940TRUE

update 쿼리에 의해 RowExclusiveLock 을 획득했다. 이 시점에 다른 트랜잭션이 또다시 RowExclusiveLock 을 획득하고자 하면 ShareLock 을 얻고 대기한다.

begin; 
select * from item;
update item set selected=selected+1 where id=1;
locktyperelationmodetidpidgranted
transactionidExclusiveLock3176740TRUE
relationitem_pkAccessShareLock40TRUE
relationitem_pkRowExclusiveLock40TRUE
relationitemAccessShareLock40TRUE
relationitemRowExclusiveLock40TRUE
virtualxidExclusiveLock40TRUE
tupleitemExclusiveLock41TRUE
transactionidExclusiveLock3176841TRUE
transactionidShareLock3176741FALSE
relationitem_pkRowExclusiveLock41TRUE
relationitemAccessShareLock41TRUE
relationitemRowExclusiveLock41TRUE
virtualxidExclusiveLock41TRUE
relationitem_pkAccessShareLock41TRUE

Share Update Exclusive [ShareUpdateExclusiveLock]

  • SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVEACCESS EXCLUSIVE 잠금 모드와 충돌
  • 이 잠금 모드는 동시 스키마 변경 및 VACUUM 실행으로부터 테이블을 보호합니다. VACUUM(FULL 제외), ANALYZE, CREATE INDEX CONCURRENTLY, CREATE STATISTICS, COMMENT ON, REINDEX CONCURRENTLY 및 특정 ALTER INDEXALTER TABLE 변형에 의해 획득

Share [ShareLock]

  • ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVEACCESS EXCLUSIVE 잠금 모드와 충돌
  • 이 잠금 모드는 동시 데이터 변경으로부터 테이블을 보호
// user 1
begin; 
update item set selected=selected+1 where id=1;
locktyperelationmodetidpidgranted
relationidx_item_nameRowExclusiveLock40TRUE
relationitem_pkRowExclusiveLock40TRUE
virtualxidExclusiveLock40TRUE
relationitemRowExclusiveLock40TRUE
transactionidExclusiveLock3178940TRUE

유저 1이 테이블 상태를 변경시키는 작업을 실행해 transaction_id 가 부여되고 item relation 에 RowExclusiveLock 이 획득된 상황이다.

// user 2
begin; 
update item set selected=selected+1 where id=1;

이어서 유저 2가 동일한 작업을 요청한다.

locktyperelationmodetidpidgranted
virtualxidExclusiveLock40TRUE
transactionidExclusiveLock3178940TRUE
relationitemRowExclusiveLock40TRUE
relationidx_item_nameRowExclusiveLock40TRUE
relationitem_pkRowExclusiveLock40TRUE
relationidx_item_nameRowExclusiveLock41TRUE
tupleitemExclusiveLock41TRUE
transactionidShareLock3178941FALSE
relationitemRowExclusiveLock41TRUE
relationitem_pkRowExclusiveLock41TRUE
virtualxidExclusiveLock41TRUE
transactionidExclusiveLock3179041TRUE

이때 유저 2가 요청한 작업은 실행되지 않고 대기하게 되는데 lock 정보를 확인하면 유저 2(pid 41)이 트랜잭션(31789) 대해 share lock 을 얻은 것을 알 수 있다.
share lock 은 이처럼 동시에 데이터를 변경할때 생기는 문제로부터 보호하기 위해 먼저 Exclusive Lock 을 잡고있는 트랜잭션에게 공유를 요청하는 lock 이다.

유저 1의 작업이 commit 되어 트랜잭션(31789)이 종료되면 share lock 을 얻었던 유저 2가 RowExclusiveLock 를 바탕으로 item 정보를 수정한다.


Exclusive [ExclusiveLock]

  • ROW SHAREROW EXCLUSIVESHARE UPDATE EXCLUSIVESHARESHARE ROW EXCLUSIVEEXCLUSIVEACCESS EXCLUSIVE 잠금 모드와 충돌
  • 동시에 Access Share 잠금만 허용하므로 테이블에서 이 트랜잭션과 병렬로 처리될 수 있는 작업은 읽기가 유일

테이블 상태를 변경하는 트랜잭션은 충돌이 없는 한 Exclusive Lock 을 자동으로 획득한다고 했다. Exclusive Lock 은 읽기 잠금 Access Share Lock 과는 충돌이 나지 않으므로 병렬 실행 중인 다른 트랜잭션에서 데이터를 읽어오는 것이 가능하다. 만약 특정 트랜잭션이 테이블 상태를 변경 중일때 읽기 조차도 하지 못하게 하려면 아래의 Access Exclusive 을 사용해야한다.


Access Exclusive [AccessExclusiveLock]

  • 모든 다른 잠금과 충돌
  • 이 모드는 해당 잠금을 보유한 트랜잭션이 어떤 방식으로든 테이블에 접근하는 유일한 트랜잭션임을 보장
  • LOCK TABLE 명령어에 명시적으로 모드를 지정하지 않는 경우 자동으로 얻는 기본 잠금
// user 1
begin; 
lock table item;
locktyperelationmodetidpidgranted
virtualxidExclusiveLock40TRUE
transactionidExclusiveLock3180540TRUE
relationitemAccessExclusiveLock40TRUE

잠금 모드를 명시하지 않아 lock table 명령어에 의해 자동으로 item relationAccessExclusiveLock 을 획득했다.

// user 2
begin; 
select * from item;
locktyperelationmodetidpidgranted
virtualxidExclusiveLock40TRUE
transactionidExclusiveLock3180540TRUE
relationitemAccessExclusiveLock40TRUE
virtualxidExclusiveLock41TRUE
relationitemAccessShareLock41FALSE

유저 2(pid 41)이 조회를 위해 AccessShareLock 을 얻으려 했지만 AccessExclusiveLock 과 충돌이 발생하고 트랜잭션(31805)가 종료될때까지 대기하게 된다.

profile
개발을 사랑하는 개발자. 끝없이 꼬리를 물며 답하고 찾는 과정에서 공부하는 개발자 입니다. 잘못된 내용 혹은 더해주시고 싶은 이야기가 있다면 부디 가르침을 주세요!

0개의 댓글