MySQL CATS (Contention-Aware Transaction Scheduling)

Sunguck Lee
당근 테크 블로그
9 min readNov 6, 2022

전통적으로 MySQL 서버는 트랜잭션의 잠금 허가(Grant)는 순수하게 대기(Waiting) 순서에 의존했는데, 이를 FCFS(First Come First Served, 또는 FIFO라고도 함) 스케쥴링이라고 해요. 하지만 이런 FCFS 기반의 트랜잭션 스케줄링에서는 잠금 대기중인 트랜잭션이 기존에 어떤 잠금을 가지고 있는지 전혀 고려하지 않기 때문에 많은 프로그램 코드에서 사용하는 잠금 업그레이드의 경우 심각한 데드락 현상을 유발했어요.

CATs

오늘은 MySQL 서버의 새로운 트랜잭션 스케쥴링인 CATS(Contention-Aware Transaction Scheduling)가 어떻게 잠금 업그레이드를 해결해줄 수 있는지 살펴보려고 해요.

잠금 업그레이드

아래 예제는 MySQL 서버의 예전 메뉴얼에 소개되고 있던 데드락 예제인데, 이 예제는 대표적인 잠금 업그레이드 시에 발생할 수 있는 데드락을 보여주고 있어요. (메뉴얼 버그 제보 이후, 이 내용은 MySQL 8.0 메뉴얼에서는 다른 예제로 변경되었고, MySQL 5.7 메뉴얼에서만 확인할 수 있어요.)

(예전) MySQL 메뉴얼의 데드락 예시

이 예제에서 “TRX-A”가 먼저 Shared-Lock을 획득한 상태에서, “TRX-B”가 레코드 삭제 쿼리를 실행하면서 동일 레코드에 대해서 Exclusive-Lock을 요청하면서 “TRX-B”는 대기 큐에서 기다려요. “TRX-A”에서 동일하게 삭제 쿼리를 실행하면, MySQL 8.0.28 이하 버전까지의 FCFS 트랜잭션 스케쥴링에서는 다음 그림과 같이 “TRX-A”의 Exclusive-Lock 요청도 대기 큐의 “TRX-B” 뒤에 추가 추가해요.

잠금 대기 큐(Wait Queue)

그런데 “TRX-A”가 이미 해당 레코드에 대해서 Shared-Lock을 가지고 있는 상태이기 때문에, (“TRX-A”가 가진 Shared-Lock을 중간에 포기하지 않는 이상) “TRX-B”는 절대 Exclusive-Lock을 가질 수 없게 되죠. 하지만 “TRX-A”는 Exclusive-Lock을 획득해서 처리가 완료되기 때문에 Exclusive-Lock을 획득하기 전에는 절대 레코드의 Shared-Lock을 풀지 않는 상황이 되면서 데드락 상황이 발생하게 된 거죠.

트랜잭션 우선순위

MySQL 서버의 CATS는 이렇게 잠금을 경합하는 여러 트랜잭션에 가중치(Weight)를 부여하고, 대기 큐의 순서에 관계없이 가중치가 높은 트랜잭션에게 먼저 잠금을 할당해주는 방식으로 데드락을 회피할 수 있도록 해줘요(각 트랜잭션의 가중치는 INFORMATION_SCHEMA.INNODB_TRX 테이블의 trx_schedule_weight 컬럼을 통해서 확인할 수 있어요). MySQL 서버의 CATS 스케쥴러는 각 트랜잭션에서 잠금 요청이 들어오면, 각 트랜잭션의 스케쥴링 가중치(trx_schedule_weight)를 갱신하고, 그 가중치를 기준으로 잠금 할당(Grant) 순서를 변경해서 처리해요.

여기에서 한가지 주의해야 할 것은, 각 트랜잭션으로부터 잠금 요청이 오면 그때 대기 큐의 트랜잭션들을 가중치에 맞게 순서를 바꾸는 것이지, 데드락이 발생하면 그 때 가중치에 맞게 잠금 할당 순서를 바꾸는 것은 아니에요. 즉 CATS 스케쥴러를 사용하는 버전에서도 일단 데드락이 발생하면, 관련된 트랜잭션중 일부는 “ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction” 에러를 보게 된다는 것을 의미해요.

MySQL 8.0.20 이전 버전까지는 FCFS와 CATS 트랜잭션 스케쥴링이 모두 사용되었었는데, 평상시에는 FCFS 스케쥴링만 사용되다가 잠금 경합이 심해지면 CATS 스케쥴링이 사용되는 형태로 구현되어 있었어요. 그런데 MySQL 8.0.20 버전부터는 항상 CATS 트랜잭션 스케쥴링이 사용되도록 변경되었어요. 그리고 MySQL 8.0.29 버전에서는 2006년 레포팅된 케케묵은 버그가 CATS 트랜잭션 스케쥴링의 도움으로 개선되면서, 위에서 살펴 본 잠금 업그레이드로 인한 데드락 문제는 더이상 MySQL 서버에서는 발생하지 않게 개선된 것이에요.

즉, MySQL 8.0.29 버전부터는 “TRX-A”는 가중치(trx_schedule_weight) 값이 1이 부여되고 “TRX-B”는 가중치 값이 0(NULL)이 부여되기 때문에, “TRX-A”가 늦게 잠금을 요청했음에도 불구하고 “TRX-B” 보다 먼저 Exclusive-Lock을 허가(Grant)받게 되면서 데드락 상황을 회피할 수 있게 되었어요.

MySQL 서버의 CATS 스케쥴러에 대한 자세한 내용은 아래 메뉴얼과 워크로그에서 살펴 볼 수 있어요.

이제 더 이상 데드락은 없는 것인가 ?

CATS라는 이름을 보면 뭔가 대단한 느낌이 들고, MySQL 서버의 모든 데드락 문제를 해결해줄 것처럼 보여요. 하지만 (서비스의 특성에 따라서) 아직 그 효과는 매우 크진 않을 수도 있어요.

서비스 환경에서 우리가 자주 경험하는 데드락은 동일 프로그램 코드가 여러 쓰레드에 의해서 동시에 실행될 때 발생하는 것들이 대부분이에요. 데드락의 원인이 되는 트랜잭션들이 모두 동일한 SQL 문장들을 실행하면서 발생하기 때문에, (“동일 코드의 멀티 쓰레드 실행으로 인한 데드락” 예시와 같이) 경합하는 트랜잭션들이 동시에 Shared-Lock(Gap 또는 Record Lock)을 가진 상태에서 Exclusive-Lock이나 Insert-Intention-Lock을 요청하면서 데드락이 발생해요. 하지만 MySQL 서버의 CATS 스케쥴러가 이미 각 트랜잭션에 할당된 잠금을 회수하는 것은 아니기 때문에, 이런 경우 데드락을 회피할 수 없는거죠.

동일 코드의 멀티 쓰레드 실행으로 인한 데드락

다음 예제는 “TRX-A”와 “TRX-B”가 똑같이 deadlock 테이블에 INSERT를 실행하는데, 제 3의 트랜잭션인 “TRX-X”가 잠금 경합을 유발하는 방아쇠 역할을 하게 되면 데드락이 발생하는 케이스에요.

동일 프로그램 코드가 서로 경합하면서 데드락이 발생하는 케이스

“TRX-X”가 먼저 deadlock 테이블의 pk=2 인 레코드를 DELETE하고 아직 COMMIT을 하지 않은 상태에서, “TRX-A”와 “TRX-B”가 deadlock 테이블에 pk=2 인 레코드를 INSERT하려고 해요. 이때 “TRX-X”는 pk=2 레코드에 대해서 Exclusive-Lock을 점유(Grant)한 상태이고, “TRX-A”와 “TRX-B”는 deadlock 테이블에 이미 pk=2 레코드가 존재하는 것을 확인하고, 먼저 Shared-Lock을 동시에 획득(Grant)한 이후 pk=2 레코드에 대해서 Exclusive-Lock을 대기(Wait)해요. 이 상태에서 “TRX-X”의 DELETECOMMIT 되면 “TRX-A”와 “TRX-B”는 동시에 pk=2 레코드에 대해서 Exlusive-Lock을 획득할 수 있는 상태가 되지만, 이미 “TRX-A”와 “TRX-B”가 각자 Shared-Lock을 획득하고 있는 상태이기 때문에 누구에게도 Exclusive-Lock을 허가(Grant)해주지 못하는 데드락 상황이 발생하게 되는 거에요.

MySQL 서버에서는 INSERT 문장이 UNIQUE INDEX의 중복 레코드를 만나게 되면 Duplicate key error가 발생하게 되는데, 이렇게 중복 레코드를 만나면 Exclusive-Lock이 아니라 Shared-Lock을 먼저 걸어요. 이는 트랜잭션내에서 여러번 문장이 실행되어도 동일한 결과를 낼 수 있도록 설계하고 구현했기 때문이에요.

이 예제의 경우, “TRX-A”와 “TRX-B”는 Shared-Lock을 가진 상태에서 Exclusive-Lock을 요청하면서 동일하게 trx_schedule_weight=1 을 가지게 되요. 그런데 이렇게 동일한 trx_schedule_weight 값을 가지는 트랜잭션이 경합하는 경우, MySQL CATS는 더 먼저 시작한 트랜잭션(TRX-A)에 우선권을 부여해요. 하지만 이 예제에서는 먼저 실행된 “TRX-A” 트랜잭션에 우선권을 부여한다 하더라도, “TRX-B”에게 이미 허가(Grant)된 Shared-Lock을 회수(Revoke)할 수 없기 때문에 “TRX-A”에게 Exclusive-Lock을 허가해줄 수가 없어요. 그래서 결국 데드락 상황이 발생하게 되는거죠.

여기에서 설명하지 않은 InnoDB 스토리지 엔진의 레코드 잠금 관련된 중요한 비밀이 하나 있어요. 제일 먼저 이 비밀을 찾아서 Real MySQL 오픈챗으로 오시는 분께 커피 쿠폰을 선물로 드릴게요.

추가로

이런 형태의 데드락은 UNIQUE INDEX를 많이 가진 테이블에서 자주 발생해요. 그래서 불필요한 UNIQUE INDEX는 최소화해서 테이블을 설계하는 것이 좋아요.

당근마켓에서 함께 고민을 나누고 싶다면 여기를 눌러 당근마켓 채용 공고를 확인해보세요!

--

--