본문 바로가기
Dev/Go

Error 1205 (HY000): Lock wait timeout exceeded; try restarting transaction 해결 과정

by Sovereign 2024. 7. 31.

개선이 필요했던 이유

필터 처리된 지 3개월이 지난 경고 알림을 삭제하는 배치 작업을 수행할 때, 아래와 같은 오류가 발생했다.

Error 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

 

약 15,000개의 데이터를 한 번에 삭제하는 로직이었고, MariaDB 파라미터인 innodb_lock_wait_timeout 값은 기본값(50초)을 사용하고 있었다.

 

 

위와 같은 오류가 발생한 원인을 분석하기 위해 아래의 쿼리를 사용하여 현재 Lock 정보, Lock 대기 정보, 트랜잭션 상태를 조회했다.

# 현재 Lock 정보 조회
select *
from information_schema.INNODB_LOCKS;

# Lock 대기 정보 조회
select *
from information_schema.INNODB_LOCK_WAITS; 

# 트랜잭션 상태 조회
select *
from information_schema.INNODB_TRX;

 

위 쿼리를 통해 INNODB_LOCK_WAITS 테이블에서 Lock을 얻기 위해 대기 중인 트랜잭션들을 확인할 수 있었고, INNODB_TRX 테이블을 통해 Lock을 가지고 있거나 오류가 발생한 실행 중인 트랜잭션들을 찾을 수 있었다.

 

배치 시스템에서 Long Transaction 오류는 자주 발생할 수 있는 문제다. 초기 시스템 구축 당시에는 데이터가 적어서 문제가 발생하지 않았지만, 데이터가 적재됨에 따라 위와 같은 문제가 발생했다. 따라서, 배치 시스템의 경우 트랜잭션을 작은 단위로 나누고 적절한 청크 크기를 선택해 처리해야 합니다.

 

 

 

문제 해결을 위해 학습한 개념

 

 

배치 로직을 구현하기 앞서 타임아웃 컨텍스트 설정을 고려해야 한다.

 

타임아웃 컨텍스트 설정의 주요 목적은 프로세스 타임아웃을 설정하는 것이다. 이를 통해 작업이 일정 시간 내에 완료되지 않을 경우, 컨텍스트가 취소되어 모든 고루틴과 작업이 중단되도록 한다. 이로써 무한정 오래 걸리는 작업을 방지할 수 있다.

 

DB 타임아웃과는 다르며, 이는 작업을 수행하는 전체 프로세스에 대한 타임아웃을 설정하는 것이다

.

컨텍스트는 주로 context.Background() 메서드와 context.TODO() 메서드를 통해 생성한다.

대부분의 경우 context.Background()를 사용하여 생성하는 것이 일반적이며, 자주 사용하는 컨텍스트 생성 메서드는 다음과 같다.

 

context.WithValue()

현재 맥락에서 유지해야 할 값을 컨텍스트에 담아서 전달하고, 꺼내서 사용하는 방식이다. 값을 할당하고 전달할 수 있으며, 값이 존재하지 않을 경우 nil을 반환한다. 따라서 타입 체크를 추가적으로 진행해야 한다.

 

context.WithCancel()

특정 고루틴을 종료시킬 때 유용하고 여러 개의 고루틴을 사용할 때 더욱 유용하다. 여러 개의 고루틴 생명주기를 제어하는 것은 어렵기 때문에, 원하는 시기에 cancel()을 실행하여 여러 개의 루틴을 한 번에 종료시킬 수 있다.

 

context.WithTimeout()

일정 시간 이후에 자동으로 취소 신호를 전송할 수 있는 메서드다. 전체적인 동작 방식은 WithCancel과 동일하지만, 일정 시간을 설정하면 해당 시간이 소요되었을 때 자동으로 Done() 이벤트가 발생한다. 보통 WithDeadline과 WithTimeout 2개 중 하나를 선택해서 사용하지만 WithTimeout만 사용해도 문제는 없다.

 

 

다음은 전체 삭제 작업이 30분을 초과하지 않도록 보장하는 예제 코드다.


컨텍스트 타임아웃을 사용하여 전체 작업이 30분 내에 완료되지 않으면 작업을 중지하도록 설정한다. 각 워커는 전달된 컨텍스트의 취소 신호를 감지하고 작업을 중지한다. 이를 통해 예상치 못한 무한 작업을 방지할 수 있다.

 

 

 

 

 

청크 데이터를 채널을 통해 각 고루틴에 전송할 것이다.

 

채널은 고루틴 간의 데이터 공유를 가능하게 하는 통신 수단이다. 여러 고루틴이 동시에 실행 중일 때 고루틴이 서로 통신하는 방법은 채널을 통하는 것이다. 예를 들어 채널을 통해 동시에 실행되는 함수 간에 특정 요소 유형의 데이터를 전송하고 수신하는 데 사용할 수 있다.

 

채널은 make() 함수와 chan 키워드를 통해 생성할 수 있다. 채널에서 데이터를 읽고 쓰는 방법은 <- 연산자를 양방향으로 사용하면 된다.

 

채널은 사용 방식에 따라 여러 유형으로 분류할 수 있으며, 일반적으로 두 가지로 나눌 수 있다.

 

 

버퍼링 된 채널

버퍼링된 채널은 일정 크기의 버퍼를 가지고 있으며, 송신하는 고루틴은 버퍼가 가득 차지 않는 한 블록 되지 않는다. 수신하는 고루틴도 버퍼가 비어 있지 않으면 즉시 데이터를 받을 수 있다.

 

버퍼링 되지 않은 채널

버퍼링 되지 않은 채널은 버퍼가 없다. 값이 버퍼링 되지 않은 채널을 통해 전송되면, 송신 고루틴은 다른 고루틴이 값을 수신할 때까지 차단된다. 마찬가지로, 값이 버퍼링 되지 않은 채널에서 수신되면, 수신 고루틴은 다른 고루틴이 값을 전송할 때까지 차단된다.

 

 

 

닫힌 이동 채널

닫힌 채널은 내장 close() 함수를 사용하여 송신자가 닫은 채널이다. 채널이 닫히면 더 이상 값을 보낼 수 없지만, 수신자는 채널이 닫히기 전에 보낸 나머지 값을 계속 받을 수 있다.

 

수신자가 닫힌 채널에서 값을 수신하면, 채널의 요소 유형의 제로 값을 즉시 받는다. 예를 들어, 채널의 타입이 int인 경우, 수신자는 닫힌 채널에서 0을 받는다.

 

닫힌 채널은 작업 완료를 알리거나 수신자에게 채널에서 더 이상 값이 전송되지 않을 것임을 알리는 데 유용하다. 다만, 채널을 닫는 것은 송신자만 할 수 있는 일방적 작업이다. 수신자는 채널을 닫으려고 하면 안 된다.

 

 

 

runtime.NumCPU()를 사용하여 현재 시스템의 CPU 코어 수를 가져와 고루틴 수를 설정한다.

 

다음으로, sync.WaitGroup 객체를 생성하여 고루틴 간의 동기화를 관리한다. 이 객체를 통해 모든 고루틴이 작업을 완료할 때까지 기다릴 수 있다.

 

고루틴 간에 데이터를 주고받기 위해 청크 데이터를 저장할 수 있는 크기의 채널을 생성한다. 그 후, CPU 코어 수만큼의 고루틴을 생성하고 시작한다. 각 고루틴은 worker 함수를 실행하며, wg.Add(1)을 호출하여 작업 카운터를 증가시킨다. 이는 sync.WaitGroup이 모든 고루틴이 완료될 때까지 대기할 수 있게 한다.

 

청크 데이터는 채널을 통해 각 고루틴에 전송된다. 모든 청크 데이터를 전송한 후 채널을 닫아 더 이상 데이터를 보내지 않음을 알린다. 마지막으로, wg.Wait()를 호출하여 모든 고루틴이 작업을 완료할 때까지 대기한다. 각 고루틴이 작업을 완료하면 wg.Done()을 호출하여 작업 카운터를 감소시키고, 모든 작업이 완료되면 wg.Wait()가 반환되어 전체 작업이 종료된다.

 

 

 

 

청크 지향 처리

 

청크 단위로 트랜잭션을 묶어서 데이터 처리를 관리할 것이다.

각 청크를 독립적인 트랜잭션으로 처리하므로 전체 데이터 세트가 아닌 개별 청크에 대해서만 commit, rollback을 수행하자.

 

 

worker 함수는 각 고루틴이 실행할 작업을 정의한다. 컨텍스트가 종료되었는지 확인하고, 청크 데이터를 수신하여 이를 트랜잭션으로 처리한다. 고루틴이 종료되면 wg.Done()을 호출하여 WaitGroup 카운터를 감소시킨다.

 

 

 

processChunkWithTx 함수는 청크 데이터를 트랜잭션 내에서 처리한다. 새 트랜잭션을 시작하고, 청크 데이터를 처리하며, 오류가 발생하면 롤백한다. 모든 처리가 성공적으로 완료되면 트랜잭션을 커밋한다.

 

 

 

어떻게 개선했나

우선 기존 코드를 간략하게 살펴보자. 전반적인 코드의 흐름과 동작 방식 위주로 살펴보자.

 

 

기존 코드에서는 db.Where("update_dtm < ?", oneWeekAgo).Find(&alerts) 호출 후 for 루프 내에서 각 데이터를 삭제한다. 이 방식으로 인해 대량의 데이터를 한 번에 처리할 때 데이터베이스에 큰 부하를 줄 수 있으며, 잠금 대기 시간 초과 문제를 발생시킬 수 있다.

 

트랜잭션 내에서 많은 데이터를 삭제하는 작업은 트랜잭션 시간을 길게 만들고, 이는 다른 트랜잭션이 동일한 자원에 접근하는 것을 차단할 수 있다.

 

 

위와 같은 문제를 개선하기 위해 다음과 같이 같이 Go 병행 처리 및 청크 지향 처리 기법을 적용했다.

 

데이터를 한 번에 처리하는 대신 작은 청크로 나누어 처리한다. chunkData 함수는 데이터를 일정 크기(chunkSize)로 나누어 각 청크를 개별적으로 삭제하도록 한다. 이를 통해 데이터베이스에 가해지는 부하를 줄이고, 트랜잭션 처리 시간을 단축하여 잠금 대기 시간 초과 문제를 예방한다.

 

여러 고루틴을 사용하여 청크를 병렬로 처리한다. worker 함수는 여러 고루틴을 실행하여 각 청크를 동시에 처리하므로, 전체 처리 시간을 단축한다. 이를 통해 단일 트랜잭션의 길이를 줄이고, 경합을 줄여 잠금 대기 시간을 초과하는 문제를 방지한다.

 

각 청크를 개별 트랜잭션으로 처리하여, 한 번에 모든 데이터를 처리하는 대신 여러 트랜잭션으로 나누어 처리한다. processChunkWithTx 함수는 각 청크를 개별 트랜잭션으로 처리하고, 문제가 발생하면 해당 청크에 대한 트랜잭션을 롤백한다. 이를 통해 트랜잭션 시간을 짧게 유지하고, 하나의 트랜잭션 실패가 전체 작업에 영향을 미치지 않도록 한다.

 

전체적인 플로우는 다음과 같다.

 

 

 

개선된 코드의 예시 버전을 아래와 같이 간략하게 제공한다. 전반적인 코드의 흐름과 동작 방식 위주로 살펴보자.

 

 


출처

A deep dive into Go's Context Package - DEV Community

 

A deep dive into Go's Context Package

All you need to know about Go's Context Package

dev.to

https://www.atatus.com/blog/go-channels-overview/

 

Understanding Go Channels: An Overview for Beginners

Learn Go channels basics with our beginner-friendly guide. Discover how it simplify concurrent programming by allowing safe communication between functions.

www.atatus.com