시작하며;
아무 소리가 없던 채널에서 연달아 로그가 쏟아졌다. 심쿵했다. 로그 내용은 아래와 같다. 내용은 MySQL 에서 트랜잭션을 실행하는 도중, 두 개 이상의 트랜잭션이 서로의 리소스를 기다리며 교착 상태가 (Deadlock) 발생했을 때 MySQL 이 이를 해결하기 위해 한 트랜잭션을 강제로 롤백하면서 발생하는 에러라고 한다.
Deadlock found when trying to get lock; try restarting transaction
Deadlock 이란 무엇인가;
MySQL 은 데이터를 변경하거나 조회할 때, 다른 작업과 충돌하지 않도록 잠금을 건다. 예를 들어, 테이블 A의 데이터를 변경 중이라면, MySQL 은 해당 데이터를 다른 트랜잭션이 접근하지 못하도록 잠금을 걸게 된다. 하지만 작업 순서가 뒤섞이거나 서로 기다리는 상황이 생기면 Deadlock (교착 상태) 이 발생한다.
예를 들어보자:
- 작업 1: ExampleEntity 테이블 잠금 (유저 레벨을 업데이트하려고 잠금)
- 작업 2: ExampleEntity2 테이블 잠금 (경험치를 업데이트하려고 잠금)
- 작업 3: ExampleEntity3 테이블 잠금 (새 경험치 기록을 삽입하려고 잠금)
이 상황에서 병렬로 실행되며 작업 순서가 꼬이면 다음과 같은 Deadlock이 발생할 수 있다.
- 작업 2가 ExampleEntity2 잠금을 잡은 상태에서 ExampleEntity 잠금을 요청
- 작업 1이 이미 ExampleEntity 잠금을 잡고 있어서 대기
- 작업 1이 ExampleEntity2 잠금을 요청
- 작업 2가 이미 ExampleEntity3 잠금을 잡고 있어서 대기
결과적으로 두 작업 모두 끝나지 않고 무한히 기다리는 상태(Deadlock) 가 발생한다.
문제 상황: 기존 코드;
기존 코드는 다음과 같이 구성되어 있다. 이 코드는 한 트랜잭션 안에서 병렬적으로 처리되며 Deadlock 이 발생할 가능성을 키운다. 왜냐하면 Promise.all 을 사용하여 여러 작업을 동시에 실행했기 때문이다.
await this.dataSource.transaction(async (transactionalEntityManager) => {
const userTransaction = () => {
if (currentLevel !== userExpInfo.currentLevel) {
return transactionalEntityManager
.createQueryBuilder()
.update(ExampleEntity)
.set({
example: example,
})
.where('seq = :userSeq', { userSeq })
.execute();
}
};
const [example, example2] = await Promise.all([
transactionalEntityManager
.createQueryBuilder()
.insert()
.into(ExampleEntity2)
.values(example)
.execute(),
transactionalEntityManager
.createQueryBuilder()
.update(ExampleEntity3)
.set({
example: example,
})
.where('userSeq = :userSeq', { userSeq })
.execute(),
userTransaction(),
]);
});
줄을 서시오;
Deadlock 을 피하기 위해 트랜잭션 내 병렬 처리를 순차 처리로 변경했다. 한 번에 하나의 작업만 실행하여 MySQL 이 잠금을 걸고 해제하는 과정을 명확히 순서대로 진행하도록 만드는 것이다.
await this.dataSource.transaction(async (transactionalEntityManager) => {
if (currentLevel !== userExpInfo.currentLevel) {
await transactionalEntityManager
.createQueryBuilder()
.update(ExampleEntity)
.set({
example: example,
})
.where('seq = :userSeq', { userSeq })
.execute();
}
await transactionalEntityManager
.createQueryBuilder()
.insert()
.into(ExampleEntity2)
.values(example)
.execute();
await transactionalEntityManager
.createQueryBuilder()
.update(ExampleEntity3)
.set({
example: example,
})
.where('userSeq = :userSeq', { userSeq })
.execute();
});
최적화와 예방;
Deadlock 은 시스템에서 완전히 피하기 어렵지만, 몇 가지 전략을 통해 발생 확률을 효과적으로 줄일 수 있었다. 먼저, 잠금 순서를 통일하는 것이 중요하다. 여러 트랜잭션이 동일한 리소스를 잠글 때, 항상 동일한 순서로 잠금을 요청하도록 설계하면 교착 상태의 가능성을 크게 줄일 수 있다. 그리고 큰 트랜잭션을 작은 단위로 분리하여 한 번에 처리하는 데이터를 최소화하는 것도 중요하다. 잠금 유지 시간을 단축할 수 있기 때문이다. 마지막으로, 조회나 업데이트 쿼리에 적절한 인덱스를 추가하여 불필요한 잠금을 방지하고, 쿼리 성능을 최적화할 수 있다. 이와 같은 접근 방식을 통해 문제를 해결했고, 특히 잠금 순서를 통일하여 긴급 패치를 배포한 이후로 동일한 에러는 더 이상 발생하지 않았다.
return;
'MySQL' 카테고리의 다른 글
| [MySql] 의존성 서브 쿼리를 겪어보니.. (0) | 2021.08.26 |
|---|