본문 바로가기

생각

동시성과 gap-lock

동시성 관련해서 이런 이슈를 겪은 적이 있습니다. 해당 포스팅에서 해결 방법을 제시했고 실제로 그렇게 해결을 하였지만, 다른 해결 방법은 또 없을지 고민하는 시간이 길었습니다. 일단 해결 방법을 찾기 위해 동시성과 Lock 에 대해 정리해보려고 합니다. 다음과 같은 목록을 하나씩 짚어나가보겠습니다.

  • 2PL
  • recode-lock 과 gap-lock
  • gap-lock 의 존재이유
  • 데드락을 해결하기 위한 방법
  • gap-lock 최소화하면서 동시성 향상시키기

 

2PL

2단계 잠금 프로토콜은 하나의 데이터에 대한 동시 접근을 차단하여 직렬화를 보장하는 DBMS 의 동시성 제어 방법입니다. 트랜잭션 도중에 락을 걸어서 데이터 접근 권한을 선점하는 방식인데요. 각 트랜잭션이 락 획득과 락 해제, 두 단계로 진행해서 2 Phase Lock 이라고 불립니다. 그림으로는 다음과 같습니다.

 

 

  • acquisition Phase : 잠금을 획득할 수 있고, 해제할 수는 없는 단계
  • release Phase : 획득한 잠금을 해제할 수 있고, 획득할 수는 없는 단계 

이 때, 2PL에서 사용되는 잠금 메커니즘은 gap-lock이나 recode-lock과 같은 세밀한 잠금 전략을 포함할 수 있습니다.

 

 


 

gap-lock 과 recode-lock

아래 테이블을 이용해서 gap-lock 과 recode-lock 을 테스트 해보았습니다.

 

| user_id 컬럼에 unique 인덱스가 존재합니다.

 

 

 

lock 을 획득하기 위해서는 select for update (x-lock) 을 사용하면 되는데요, 

결과 값이 존재하냐에 따라 gap-lock 또는 recode-lock으로 락 종류가 달라지게 됩니다. 차례대로 살펴보겠습니다.

 

 

 

 

📍 unique 인덱스에서 결과가 존재할 때

 

| unique 인덱스나 pk 로 쿼리하는 경우는 해당 값이 1개인 것을 보장합니다. 따라서 where 절에 해당하는 값이 존재하면 gap-lock 을 걸지 않고 recode-lock 을 걸어둡니다. 
  

 

data_locks 정보를 확인해보겠습니다.

 


해당 테이블 결과에서 첫번째는 테이블 잠금입니다. LOCK_MODE 컬럼의 "IX" 는 트랜잭션이 어떤 데이터를 변경하려고 할 때 그 데이터의 부모 레벨에 설정되는 값이며, 해당 데이터 블록에 대한 독점적 접근을 예고하는 잠금입니다. 데이터 베이스의 동시성을 제어하려할 때 이용해볼 수 있습니다. 

두번째 row 의 테이블 잠금 타입은 RECODE 이며 레코드 수준의 잠금을 의미합니다. 레코드 자체의 잠금으로 해석해서는 안된다고 합니다. 암튼 여기서 제가 직접 확인하고 싶었던 것은 조회 쿼리 결과값이 존재할 때 recode-lock이 걸리느냐였습니다. "X,REC_NOT_GAP", gap-lock 이 아닌 recode-lock 이 걸려있네요. 

그럼 gap-lock 은 어떤 경우에 발생할까요? 무식하게 생각하면, recode-lock 의 반대입니다. where 절에 해당하는 값이 존재하지 않을 때 gap-lock 이 발생합니다.

 

 

 

📍where 절에 해당 하는 값이 존재하지 않을 때 

 


gap-lock 입니다. 현재 존재하는 인덱스 사이에 값이 없는 경우 검색한 값의 이전 인덱스 ~ 다음 인덱스 값까지 lock 을 거는 것을 gap-lock 이라고 합니다. 해당 예제에서는 user_id 를 varchar 로 지정했지만, 숫자 또는 시간 같은 타입에서는 사이값이 무한하다는 점에 주의해야할 것 같습니다. 

 

 

존재하는 인덱스의 범위를 벗어나는 경우도 확인해보았습니다.

 

 

이 경우에는 해당 값을 기준으로 마지막 지점(supremum pseudo-record) 까지 next-key gap-lock 이 걸어져있는 모습을 볼 수 있습니다. 특히 이 경우는 레코드가 작을수록 테이블 전체에 lock 범위가 넓어지므로 이 점도 주의해야할 것 같다는 생각이 듭니다. 

정리하면, gap-lock 은 데이터베이스 테이블의 특정 범위에 대한 잠금입니다. 이 잠금은 특히 격리 수준이 높은 환경에서 동시성을 제어하기 위해 사용됩니다. gap-lock 은 주로 트랜잭션이 범위 쿼리를 실행할 때 필요하며, 팬텀 리딩과 같은 문제를 방지하는데 도움이 됩니다.


팬텀 리딩(Phantom Read) 이란?

더보기

팬텀 리딩(Phantom Read)은 데이터베이스 관리 시스템에서 발생할 수 있는 특정 유형의 읽기 일관성 문제입니다. 이 현상은 한 트랜잭션이 같은 쿼리를 두 번 이상 실행했을 때, 첫 번째 쿼리와 두 번째 쿼리 사이에 다른 트랜잭션이 새로운 레코드를 삽입하거나 삭제함으로써 결과 집합이 변경되는 경우에 발생합니다. 즉, 첫 번째 조회에서는 보이지 않았던 "유령(Phantom)" 레코드들이 두 번째 조회에서 나타나게 됩니다.

 

이 문제는 주로 범위 쿼리에서 나타나며, 한 트랜잭션이 처리 중인 데이터의 범위 안에 새로운 데이터가 추가되거나 제거될 때 관찰됩니다. 예를 들어, 어떤 트랜잭션이 특정 조건을 만족하는 모든 레코드의 수를 세는 동안 다른 트랜잭션이 해당 조건을 만족하는 새로운 레코드를 추가하면, 첫 번째 트랜잭션의 결과는 더 이상 정확하지 않게 됩니다.
이러한 팬텀 리딩은 격리 수준(Isolation Level)에 따라 다르게 처리될 수 있습니다.

 

recode-lock 은 특정 레코드 수준 잠금으로, 다른 트랜잭션이 해당 레코드를 동시에 수정하지 못하도록 방지합니다. 일반적으로 레코드를 읽고 수정할 때 해당 락이 사용됩니다.

그렇다면 secondary index 같은 경우, 그러니까 쿼리의 결괏값이 1개가 아니라 N 개인 경우는 어떨까요?
항상 recode-lock + gap-lock 이 동시에 사용됩니다. 이 모든 상황을 고려했을 때 2PL 방식을 사용하면 deadlock 이 매우 자주 발생하게 되는데요. 왜 그럴까요?

 

 


gap x-lock은 실제 gap에 값이 삽입되는 것을 막기 위한 기능이고 같은 범위의 gap-lock 획득에는 제한을 두지 않습니다. 서로 각기 다른 트랜잭션에서 gap-lock 을 획득할 수 있는 것을 확인해보실 수 있습니다. 따라서 gap x-lock은 lock 획득에 대해서 gap s-lock과 차이가 없습니다.

 

따라서 서로 다른 트랜잭션이 같은 gap x-lock을 획득하고 이후에 각각 insert를 시작하게 되면 서로 gap x-lock이 풀리길 기다리게 되어 데드락이 발생합니다.

 

 


결국 2PL 은 직렬화는 보장하지만 잦은 교착 상태가 발생할 수 있습니다. 특히 MySql 서버에서 잠금 관련 문제의 대부분은 이 gap-lock 으로 인한 것일 가능성이 매우 높습니다. 그럼에도 불구하고 왜 gap-lock 은 존재해야하는 걸까요?

 


 

gap-lock 의 존재이유 


Repeatable Read 격리 수준 보장
Repeatable Read 격리 수준에서 Gap Lock은 트랜잭션이 데이터를 여러 번 읽어도 동일한 결과를 얻도록 보장합니다. 이는 다른 트랜잭션이 데이터 범위를 수정하는 것을 방지함으로써 일관된 읽기를 가능하게 하여 중요한 역할을 합니다.


Replication 일관성 보장
MySQL의 Replication에서는 바이너리 로그를 통해 SQL 문장을 STATEMENT 또는 MIXED 포맷으로 기록하며, 이는 복제 서버 간 실행 결과의 일관성을 보장하는 데 중요합니다. 주 서버와 복제 서버 사이의 미묘한 실행 시간 차이, 네트워크 지연으로 인한 변경 사항의 전파 지연 등으로 복제 서버와의 일관성이 깨지게 됩니다. 동시에 여러 트랜잭션이 실행하면서 발생하는 동시성 문제도 그 원인 중 하나입니다. Gap Lock은 트랜잭션 중 데이터 갭에 락을 설정하여 다른 트랜잭션의 변경을 방지함으로써, 복제 과정에서의 데이터 불일치를 예방합니다.


결국엔 gap-lock 의 존재 이유가 해당 서비스와 맞지 않는다면, gap-lock 을 최소화하면서 동시성을 향상시키는 방법을 찾아야합니다. 격리수준과 바이너리 로깅으로 돌파구를 찾을 수 있을 것 같습니다.

 

 

gap-lock 최소화하면서 동시성 향상시키기

READ-COMMITTED 격리 수준
이 격리 수준에서는 트랜잭션이 커밋된 데이터만을 읽을 수 있습니다. 다른 트랜잭션이 데이터를 변경하고 아직 커밋하지 않은 경우, 그 변경 사항은 읽히지 않습니다. 따라서 각 트랜잭션은 독립적으로 동시에 데이터에 접근하고 작업을 수행할 수 있습니다. 트랜잭션이 독립적으로 데이터에 접근할 수 있으면, 락 소유 시간이 짧아짐에 따라 Gap Lock에 의한 락 경합이나 데드락 발생 가능성이 줄어듭니다. 

ROW 기반의 바이너리 로깅
바이너리 로그에 데이터 변경을 행 변경 사항으로 기록합니다. 그러니까, 실행된 SQL 문장 자체가 아닌 실제 데이터가 변경된 내용을 기록합니다. 변경이 일어난 구체적인 데이터를 대상으로만 복제가 이루어진다는 것을 의미하며, 변경된 데이터만을 전송하므로 복제에 필요한 데이터양이 줄어들고 네트워크 부하 및 처리 시간이 감소합니다. 따라서, 다른 트랜잭션에서 같은 데이터를 동시에 변경하려할 때 충돌의 가능성이 줄어들고, 복제된 환경에서도 데이터 일관성을 유지하며, 더 정확하게 동기화할 수 있습니다. 

 

 

 

 

해당 포스팅은 해당 블로그를 참조하여 공부했습니다. 더 자세한 사항을 확인하세요.