이게 왜 데드락? – 중복키 insert & 갭락
인생사 새옹지마. 다가올 미래를 함부로 예측하기란 어려운 일입니다.
이건 컴퓨터를 다룰 때에도 동일한 것 같습니다.
늘 문제가 발생하면 기계는 잘못이 없다곤 하지만,, Lock을 명시적으로 걸어준 적도 없는데 데드락이 발생할 줄은 몰랐습니다.
이번 글에서 명시적으로 Lock을 걸지도 않았는데 데드락을 실제로 만난 두가지 케이스를 공유하고자 합니다.
중복 키 동시 insert로 인한 데드락
주기적으로 db 로그를 살펴봐주시는 그렉은 아래의 슬랙 메세지로 하나의 데드락 케이스를 공유해주셨습니다.
유니크 인덱스 때문에 데드락이 발생한다니,, 잘 와닿지는 않지만 일단 sentry에 찍힌 에러 로그 등을 확인해 문제가 발생한 코드를 찾을 수 있었습니다.
fun createHighlight(...) {
transaction {
val newHighlight = Highlight(
saveId = ...,
styleId = ...,
...
)
HighlightRepository.saveHighlight(newHighlight)
...
}
}
(위 코드는 실제 코드를 일부 각색했으며 언어 Kotlin과 exposed라는 orm 라이브러리를 통해 작성되었습니다.)
highlight를 insert하는 HighlightRepository.saveHighlight에서 문제가 발생하고 있었습니다.
좀 더 자세히 살펴보면 highlight 테이블에는 saveId와 styleId를 쌍으로해 unique key를 가지고 있습니다.
UNI_save_id_N_style_id (save_id,style_id)
또한 INSERT INTO save_highlights (save_id, style_id, ...) VALUES (...)
이런 insert문이 날라가고 있었습니다.
사실 이것만 보면 매우 일반적인 상황으로 문제가 없어 보였습니다. 적절하게 필요한 unique 제약 조건이 걸려있었고 insert문 또한 평범 했습니다.
그렇게 구글링을 이어가다 mysql의 공식 문서(Locks Set by Different SQL Statements in InnoDB)에서 답을 찾을 수 있었습니다.
If a duplicate-key error occurs, a shared lock on the duplicate index record is set. This use of a shared lock can result in deadlock should there be multiple sessions trying to insert the same row if another session already has an exclusive lock.
만약 중복 키 에러가 발생한 경우, shared lock(s-lock)이 중복된 인덱스 레코드에 걸립니다. 이러한 s-lock은 여러 세션에서 같은 로우를 insert하려하고, 다른 세션이 이미 exclusive lock(x-lock)을 가지고 있다면 데드락이 생길 수 있습니다.
즉 예를 들면, transaction t1, t2, t3가 있고 t1, t2, t3 모두 같은 데이터를 insert하려고 할때 아래의 시나리오로 데드락이 발생할 수 있습니다.
- t1이 insert로 x-lock 획득
- t2가 insert 시도, duplicate error로 s-lock을 걸기 위해 대기
- t3가 insert 시도, duplicate error로 s-lock을 걸기 위해 대기
- t1 커밋, t2, t3 s-lock 획득
- t2, t3가 x-lock 걸기 위해 대기
- 하지만 t2는 t3가, t3는 t2가 s-lock을 가지고 있어 데드락 발생
실제로 저희가 만난 문제인 highlight 생성도 여러 세션에서 동시에 같은 highlight 쓰기 작업을 하고 있었습니다. 결국 이를 해결하기 위해 highlight를 생성하는 트랜잭션들은 시작할 때 해당하는 user의 row에 명시적으로 x-lock을 걸어 다른 트랜잭션이 끝나기 전까지 대기하도록 변경했습니다.
갭 락으로 인한 데드락
하이라이트를 다른 유저들이 볼 수 있는지 없는지 공개범위를 설정할 수 있는데, 이번 이슈는 유저가 마지막으로 공개범위를 어떻게 설정했는지를 저장하는 코드에서 발생했습니다.
구현 로직은 유저의 마지막 공개범위 row에 업데이트를 시도하고 업데이트된 row가 0개라면 insert로 새로 데이터를 만들어주는 형식으로 진행되었습니다.
즉 아래와 같은 sql로 동작했습니다.
start transaction;
...
update last_highlight_open_state set open_state = ... where user_id = ...;
// upate된 row가 0개인 경우 insert문 실행
insert into last_highlight_open_state (user_id, open_state) values (...);
...
commit;
여기서 insert를 하게되는 경우에서만 데드락이 발생하고 있었습니다.
이번에도 열심히 구글링한 결과 갭 락에 의한 문제라는 것을 알 수 있었습니다.
여기에 mysql 갭 락에 대한 공식 문서가 있습니다
갭 락은 index record에 걸리는 락입니다.
즉 last_highlight_open_state 테이블에는 IDX_user_id라는 인덱스가 있는데 위의 update 문에 의해 user_id가 특정한 값인 index record에 락이 걸립니다.
여기서 index record에 락이 걸리는 것 때문에 갭 락의 독특한 특징이 하나 생깁니다.
바로 실제로 존재하지 않는 row에 대해서도 락을 걸 수 있다는 것 입니다.
예를 들어 last_highlight_open_state 테이블에서 user_id가 10인 index record에 락을 걸면 만약 user_id가 10인 로우가 1개라도 다른 트랜잭션에서 user_id가 10인 row를 insert할 수 없게 됩니다.
user_id가 10인 로우, 특히 user_id가 10이 될 존재하지 않는 row들에도 락이 걸렸기 때문입니다.
갭 락의 또다른 특징 중 하나는 갭 락은 s-lock이라는 것 입니다. 따라서 하나의 트랜잭션에서 갭 락을 걸었을때 다른 트랜잭션이 같은 갭 락을 걸 수 있습니다.
이러한 특징 때문에 다음과 같은 시나리오에서 데드락을 만날 수 있습니다.
-- transaction 1
1. start transaction;
3. update last_highlight_open_state set open_state = "public" where user_id = 10;
5. insert into last_highlight_open_state (user_id, open_state) values (0, 'only_me');
commit;
-- transaction 2
2. start transaction;
4. update last_highlight_open_state set open_state = "public" where user_id = 10;
6. insert into last_highlight_open_state (user_id, open_state) values (0, 'only_me');
commit;
두 트랜잭션이 있습니다.
1, 2. transaction 1, 2가 각기 다른 트랜잭션 시작
- transaction 1이 갭 락을 획득
- transaction 2가 갭 락을 획득
- transaction 1은 insert시 transaction 2의 갭 락에 의해 대기
- transaction 2는 insert시 transaction 1의 갭 락에 의해 대기
- 데드락 발생
중복 키 동시 insert로 인한 데드락의 상황과 비슷하게 실제로도 session 1과 2처럼 여러 세션에서 동시에 갭 락을 걸고 insert를 하고 있어 데드락이 발생했습니다. 해결 방법 또한 비슷하게 해당하는 user에 명시적으로 x-lock을 걸어 해결하게 되었습니다.
마무리
명시적으로 락을 걸지 않았는데도 데드락을 만나니 당황스러웠지만 덕분에 이것 저것 많이 찾아보게 된 것 같습니다. 또 오히려 락을 적절하게 걸어주고 관리해주어야 문제가 없다는 것도 다시 깨달았습니다.
두 케이스 모두 공식 문서에 잘 설명되어 있어 역시 공부는 늘 꾸준히 해야한다는 생각이 드네요. 이 글을 읽는 분들에게도 도움이 되었길 바라며 마치겠습니다. 감사합니다.