Deadlock 유발할 수 있는 Django ORM (feat. InnoDB)
안녕하세요! 개발자 Jay 입니다 😄
이번 포스팅에서는 데드락(Deadlock)을 유발할 수 있는 Django ORM과 InnoDB 엔진을 알아보려고 합니다.
예전에 update_or_create()하는데 갑자기 Dead Lock 너는 왜나오냐?! (feat. transaction)라는 포스팅을 업로드 했던적이 있는데, 시간이 좀 지나니까 기억이 좀 안나기도 하고 이번에 다시한번 InnoDB 부터 알아 보려고 합니다. (내용도 조금 빈약했던 것 같습니다 ㅎㅎ)
1. InnoDB란?
이노DB(InnoDB)는 MySQL을 위한 데이터베이스 엔진이며, MySQL AB가 배포하는 모든 바이너리에 내장되어 있다. MySQL과 사용할 수 있는 다른 데이터베이스 엔진에 대한 개선 사항으로 PostgreSQL을 닮은 ACID 호환 트랜잭션에 대응하고 있는 것이 있다. 또한 외래 키(FK)도 지원하고 있다. (이것을 선언적 참조 무결성이라 한다.) (출처: 위키백과)
네 그렇다고합니다😄 InnoDB는 저와 여러분이 자주 사용하는 MySQL의 엔진입니다. 뭐 InnoDB의 모든 것! 을 이야기하지는 않고 Transaction, Isolation level, Lock의 종류를 알아보겠습니다!
2. 트랜잭션 (Transaction)
데이터베이스 관리 시스템 또는 유사한 시스템에서 상호작용의 단위이다. 여기서 유사한 시스템이란 트랜잭션이 성공과 실패가 분명하고 상호 독립적이며, 일관되고 믿을 수 있는 시스템을 의미한다. 이론적으로 데이터베이스 시스템은 각각의 트랜잭션에 대해 원자성(Atomicity), 일관성(Consistency), 독립성(Isolation), 영구성(Durability)을 보장한다. 이 성질을 첫 글자를 따 ACID라 부른다. 그러나, 실제로는 성능향상을 위해 이런 특성들이 종종 완화되곤 한다. (출처: 위키백과)
요약하자면 데이터베이스의 상태를 변경하기 위한 작업단위입니다. 다만 이런 작업 단위가 주먹구구식이 아닌 몇 가지 성질(?), 규칙(?)이 있습니다.
- 원자성 (Atomicity)
트랜잭션의 작업들이 모두 성공(commit)하거나 실패(rollback)하거나 둘 중 하나를 보장해야 합니다. 즉, 부분적으로 작업이 성공하거나 실패하면 안 됩니다. 예를 들어 은행 송금 과정에서 내 계좌에서 돈 빼는 건 성공하고 다른 사람한테 보내는 것만 실패하면... 돈이 증발하는 신기한 마술ㅋㅋㅋ
- 일관성 (Consistency)
모든 트랜잭션은 일관성있는 상태를 보장해야 합니다. 데이터베이서에서 정한 무결성 조건을 항상 만족해야 합니다.
- 독립성, 격리성 (Isolation)
각각의 트랜잭션들이 서로에게 영향을 주면 안 되고 서로 격리되어야 합니다. 예를 들면 동시에 같은 데이터를 수정하면 안 되며 동시성 이슈에 따라 격리 수준(Isolation level)을 정할 수 있습니다.
- 영구성, 지속성 (Durability)
트랜잭션이 끝나면 그 결과가 영구적 기록을 보장하는 것을 말합니다. 데이터베이스 시스템에 장애가 나더라도 트랜잭션, 데이터베이스 로그를 보고 복구가 가능해야합니다.
트랜잭션의 원자성, 일관성, 영구성을 보장하는 게 격리성입니다. 이 격리성(Isolation)은 상황에 맞게 level을 다르게 적용해서 사용할 수 있습니다. 격리 수준(Isolation level)에 따라서 lock도 달라지고 lock을 거는 상황도 달라집니다.
격리성은 크게 동시성과도 크게 연관이 있습니다. 우리가 만나는 데드락(Deadlock)도 Isolation level과 동시성에서 맞닥뜨릴 수 있는 문제입니다.
3. 격리수준 (Isolation level)
- READ UNCOMMITTED (커밋되지 않은 읽기)
- READ COMMITTED (커밋된 읽기)
- REPEATABLE READ (반복 가능한 읽기)
- SERIALIZABLE (직렬화 가능)
다음은 격리수준에 따른 유형별 표입니다
InnoDB의 Isolation level은 위와 같이 4가지로 되어 있습니다! MySQL은 default로 REPEATABLE READ를 사용합니다! 특이한 점은 REPEATABLE READ를 사용하면 InnoDB에서는 Phantom Read가 불가능합니다. REPEATABLE READ에서는 특정 시점의 sanpshot을 가지고 있어서 Phantom Read가 발생하지 않고 매번 같은 select query 결과를 확인할 수 있습니다.
이번 포스팅하면서 느낀 점인데 데드락 하나에도 굉장히 많은 개념들이 들어가 있어서 하나의 포스팅으로 다루기에는 힘드네요;;
4. 데드락(deadlock) 이란?
데드락, 한국말로 교착상태라고 합니다. 두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상황을 말합니다.
위 이미지처럼 두 개의 스레드가 각각 A, B 리소스를 먼저 lock을 건 상태에서 그다음 B, A 리소스로 서로 접근하려고 할 때 lock이 해제되기를 기다리는 상태가 데드락 상태입니다. 둘 중 먼저 하나라도 lock이 해제가 돼야 하는데 서로 기다리고 있으니까 lock이 해제되지 못하고 결국 오류가 나는 거죠!
5. 락(lock)의 종류
락의 종류까지 포스팅 하기에는 시간이 부족하므로, 아래 블로그들을 참고하시길 바랍니다ㅠㅠ
X, S락, Record Lock, Gap Lock 위주로 먼저 훑고 오시면 좋을 것 같습니다!
MySQL InnoDB lock & deadlock 이해하기
6.. 데드락이 걸릴 수 있는 상황
데드락이 걸릴수 있는 상황에 대해 몇 가지 알아보겠습니다.
첫 번째로는 동시성 이슈입니다. 두 개의 트랜잭션이 같은 데이터에 대해서 select for update 하려고 할 때 동일한 레코드(record)에 락(lock)을 걸게 되어 서로 대기하는 상황입니다.
두 번째로는 A, B라는 레코드에 대해 하나의 트랜잭션은 A->B 또 다른 트랜잭션은 B->A 순으로 락을 잡아서 서로 락을 해제하기만 기다리는 상황이 되었을 때입니다.
일단 제가 겪어본 상황은 이 두 가지이며 찾아보면 더 있을 것 같네요?! (댓글로 부탁드립니다)
첫 번째 상황은 격리 수준(Isolation level)에 따라서 상황마다 다르게 동작하기 때문에 적절한 격리수준을 써서 해결할 수 있는 부분도 있고 애초에 동시성에 대한 보장을 클라이언트 혹은 서버에서 적절히 처리하는 방법도 있을 것 같습니다. 예를 들자면 서버에서 request에 대한 부분을 캐싱하여 쓰로틀링(throttling) 시킬 수도 있고요.
두 번째 상황은 트랜잭션에서 작업의 순서를 보장하는 방식입니다. 어떤 트랜잭션이든 A->B 순으로 처리되게 하면 해결할 수 있습니다.
7. Django ORM에서 데드락 걸릴 수 있는 상황
READ COMMITED, REPEATABLE READ 두 가지 격리수준(Isolation level)으로 테스트를 해보려고 합니다. 이 두 가지를 선택한 이유는 일반적으로 제일 많이 사용하는 격리수준이라고 알고 있고 동시성과 격리성을 타협한 중간 level이라고 생각해서입니다. (모두 테스트하기 벅차서 이기도 ㅋㅋ)
추가로 django에서는 isolation level을 기본적으로 READ COMMITED을 사용하고 있었습니다. 몰랐는데 mysql.cnf에서 transaction-isolation옵션을 변경해도 query log를 보면 항상 SESSION TRANSACTION ISOLATION LEVEL READ COMMITED로 실행이 되더라고요!
그래서 테스트를 할 때마다 아래와 같이 데이터베이스 세팅을 변경해야 합니다. 몰랐는데 database 세팅에서 isolation level을 변경 가능하더라고요!
자 그럼, 이제부터 본격적으로 예제를 보면서 테스트해보겠습니다.
select_for_update()는 트랜잭션이 끝날 때까지 row lock을 잡는 ORM입니다. 위 예제를 보면 Account 모델에 userid, nickname에 매칭 되는 row가 있는 경우 가져오고 없으면 생성합니다.
언뜻 보기에는 크게 문제가 없어 보입니다. 하지만 정말 그럴까요??
데드락이 발생했습니다!! READ COMMITED, REPEATABLE READ 두 가지 격리수준 모두 동일하게 데드락이 발생합니다! 그럼 왜 데드락이 발생하는지 알아보겠습니다!
데드락에 대한 정보는 아래 명령어를 통해 확인할 수 있습니다.
show engine innodb status
먼저 READ COMMITED일때 먼저 확인해보겠습니다.
- transaction : 트랜잭션에 대한 정보를 보여줌.
- holds the lock(s) : 현재 잡고 있는 lock에 대한 정보를 보여줌
- waiting for this lock to be granted : 트랜잭션이 실행하기 위해 lock을 걸어야 하는 데이터에 대한 정보, 즉 row에 대한 정보를 보여줌.
select_for_update()는 row lock을 잡는다고 했습니다. 위 상황을 해석하면 트랜잭션 A와 트랜잭션 B가 동일한 space id에 레코드 락(record lock)을 걸었고 데이터를 쓰기 위해 서로의 락 해제를 기다리고 있는 상태입니다.
데드락이 된 상태에서 InnoDB는 트랜잭션 B를 롤백(roll back)시키고 트랜잭션 A만 성공시켰습니다.
로그를 해석해보면 레코드에 Shared lock(S)을 잡았습니다.
해당 레코드는 Exclusive lock(X)을 잡기위해 대기하고 있네요. 그리고 not gap 즉, gap lock은 사용하지 않는다는 의미인 것 같습니다!
다음은 REPEATABLE READ일 때 트랜잭션 상태를 확인하겠습니다.
전반적을 데드락이 걸린 맥락은 READ COMMITED과 유사합니다. 트랜잭션 A와 트랜잭션 B가 동일한 space id에 레코드 락(record lock)을 걸었고 데이터를 쓰기 위해 서로의 락 해제를 기다리고 있는 상태!!
다만 다른 점은 REPEATABLE READ는 X lock으로 gap lock을 걸고 있습니다. (rec 전에)
그리고 해당 레코드는 X lock으로 gap lock을 걸고 있고 insert intention 대기중입니다. insert intention waiting이 insert intension lock을 잡고있는 상태를 말하는것 같습니다. (이부분은 확실하지 않아서 좀 더 스터디후 첨언하도록 하겠습니다!)
확실히 READ COMMITED과는 락의 종류가 다릅니다! 그리고 위 예제는 동시성 이슈로 인한 데드락이라 락의 차이 때문에 이슈가 생긴 건 아닙니다. 다만 다르게 락을 잡는다는 건 확인할 수 있죠!
어쨌든 정리하자면 READ COMMITED, REPEATABLE READ 둘 다 동일한 위치에 레코드 락(recored lock)을 걸고 있어서 데드락에 빠졌다는 걸 알 수 있습니다!
8. 그럼 동시성 이슈로 인한 데드락에 걸리지 않으려면?
락을 걸지 않으면 됩니다!! 뭐 이게 말이야 방구야 할 수 있겠지만 사실입니다 ㅋㅋ 락을 애초에 걸지 않으면 데드락이 걸릴 이유도 없겠죠?! 다만 READ COMMITED, REPEATABLE READ 각각 결과의 차이는 있습니다.
로그를 한번 보겠습니다. (로그가 보이지 않는다면 아래 명령어로 확인!)
로그관련 상태 보기: show variables like 'general%';
로그 쌓기: set global general_log='on';
로그 쌓지 않기: set global general_log='off';
READ COMMITED일 때는 데드락도 걸리지 않고, 데이터도 잘 들어갑니다! 별다른 에러도 없고요!
반면에 REPEATABLE READ에서는 Duplicate entry ~ key 오류가 났습니다. 다만 데드락은 걸리지 않았죠! 만약 유니크 키가 없다면 중복으로 생성되었을 겁니다!
결과가 다른 이유가 있습니다. 먼저 REPEATABLE READ는 락을 걸지 않았기에 데이터의 추가(insert)가 둘 다 실행되지만 userid 칼럼이 유니크로 걸려있기 때문에 Duplicate 오류가 난 것입니다!
READ COMMITED는 첫 번째 트랜잭션이 진행 중일 때 두 번째 트랜잭션에서 동일한 row에 값을 변경하려고 할 때는 그 변화는 무시한다고 합니다. 다만 위 내용에 대해서 문서 내용을 찾을 수 없어서... 정확히 설명을 못 드리겠네요. 쿼리의 결과가 위처럼 나오기 때문에 추측을 할 수 있었고, 참고로 요 블로그 내용을 참고하였습니다.
추측을 한 바로는 READ COMMITED은 쿼리 로그를 보면 두 트랜잭션 모두 커밋(commit)을 했지만 Duplicate 오류가 나지 않았고, REPEATABLE READ는 첫 번째 트랜잭션만 커밋되고 두 번째 트랜잭션은 롤백(roll back)한 걸 볼 수 있습니다.
9. 정리
- select_for_update()를 사용할 때는 동시성 이슈를 잘 고려하면서 사용해야 한다.
- 격리수준(Isolation level)에 따라 사용되는 락(lock)이 다르다.
- 트랜잭션이 걸리는 작업들은 순차적으로 락이 걸리도록 순서를 보장하자(ex. A->B->C)
정리하면 이 정도가 될 것 같습니다. 지금 이 포스팅을 2-3일 정도 작성하면서 InnoDB에 대해 보고 있는데도 아직 완벽히 이해가 되진 않은 것 같습니다. 제가 경험해보고 테스트를 해보지 못한 부분들도 있어서 시간이 날 때마다 SQL로 테스트를 해보거나 관련 문서들을 좀 더 봐야 하지 않나 싶습니다.
(이 포스팅을 적고 바로 Real MySQL을 구입하였습니다... 역시 책이 짱이여!)
관련해서 제가 설명이 미흡하거나 잘못된 부분이 있었다면 댓글로 남겨주시길 바랍니다! MySQL... 파면 팔수록 정말 어려운 것 같습니다!
별개로 데드락 관련해서 재밌게 읽은 아티클을 소개합니다!
https://helloworld.kurly.com/blog/vsms-performance-experiment/
참고자료
https://zzang9ha.tistory.com/381 (설명이 정말 잘 되어있음)
https://dev.mysql.com/doc/refman/8.0/en/set-transaction.html (격리수준 세팅 방법)
https://hleee.medium.com/%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80-3287d4bcc64d
https://choelhee.tistory.com/35
https://medium.com/daangn/mysql-gap-lock-%EB%8B%A4%EC%8B%9C%EB%B3%B4%EA%B8%B0-7f47ea3f68bc
https://youngminz.netlify.app/posts/get-or-create-deadlock#mysql-internal
https://suhwan.dev/2019/06/09/transaction-isolation-level-and-lock/
https://blog.actorsfit.com/a?ID=01050-db08f4cb-71b7-4e30-91d7-e81821a1f52f