💻 프로그래밍/Django

update_or_create()하는데 갑자기 Dead Lock 너는 왜나오냐?! (feat. transaction)

피트웨어 제이 (FitwareJay) 2020. 10. 26. 20:01

안녕하세요! 운동하는 개발자 제이입니다!

오늘은 Dead LockTransaction에 대해서 알아보려고 합니다! 

 

회사 슬랙에 올라온 센트리 오류

회사에서 종종 위 이미지와 같은 센트리 리포팅을 볼 수 있었는데, Deadlock (교착상태)에 대해서 개념은 알고 있었지만 실제로 코드상에서 왜 Deadlock이 나는지 정확한 이유를 알진 못했습니다.

 

시니어 개발자분께서 transacion log를 보여주시면서 설명은 해주셨는데, 그냥 그렇구나 하고 넘어갔던 것 같아요(이러면 안 됩니다 여러분 ㅎㅎ) 최근에 DB에 대해서 좀 더 관심이 생겨서 이 Deadlock에 대해서 실제 django orm을 보면서 알아보려고 해요.

 

 

1. Dead-lock 그리고 Transaction


일단 간단히 Deadlock이 뭔지 transaction이 뭔지 알아보겠습니다.

Deadlock 개념

📌 교착 상태 (膠着狀態, 영어: deadlock)
두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태를 가리킨다.
(출처 : 위키백과) 간단히 말하자면 두개의 작업에 동일한 리소스가 할당되어, 어느 한쪽의 작업이 끝날 때까지 무한정 기다리게 되는 것을 말합니다.

 

📌 데이터베이스 트랜잭션 (Database Transaction)
데이터베이스 관리 시스템 또는 유사한 시스템에서 상호작용의 단위이다. 여기서 유사한 시스템이란 트랜잭션이 성공과 실패가 분명하고 상호 독립적이며, 일관되고 믿을 수 있는 시스템을 의미합니다.
이론적으로 데이터베이스 시스템은 각각의 트랜잭션에 대해 원자성(Atomicity), 일관성(Consistency), 독립성(Isolation), 영구성(Durability)을 보장한다. 이 성질을 첫 글자를 따 ACID라 부른다. 그러나, 실제로는 성능향상을 위해 이런 특성들이 종종 완화되곤 한다.(출처 : 위키백과) 

어떤 시스템들에서는 트랜잭션들은 논리적 작업 단위(LUW, Logical Units of Work)로 불립니다.

 

Data Base에서는 이런 ACID 원칙을 엄격하게 지키면 동시성이 떨어지기 때문에 이걸 조절(?)할 수 있는 transaction isolation level이 있습니다. 이 level에 따라서 lock을 거는 범위가 달라집니다.

 

isolation level에는 UNCOMMITTED, READ COMMITTED, REPEATABLE READ, and SERIALIZABLE. 이 있습니다.

각각에 대해 자세하게 설명하고 싶지만, 다른 분이 잘 정리해주신 내용이 있어서 이 글을 공유하겠습니다.

(참고: https://suhwan.dev/2019/06/09/transaction-isolation-level-and-lock/)

 

Lock으로 이해하는 Transaction의 Isolation Level

개요 내게 transaction의 isolation level은 개발할 때 항상 큰 찝찝함을 남기게 하는 요소였다. row를 읽기만 할 때는 REPEATABLE READ로, row를 삽입 / 수정 / 삭제할 때는 SERIALIZABLE로 isolation level을 지정했지

suhwan.dev

 

본문에서는 MySQL innoDB 기본 설정인 REPEATABLE READ에 대해서 간단하게 설명하고 예제를 실행하겠습니다.

 

InnoDB Isolation Level 확인

📌 REPEATTABLE READ

1. InnoDB의 기본 default isolation level입니다.

2. lock을 사용하는 select나 update, delete 쿼리를 실행할 때 REPEATTABLE READ transaction은 gap lock을 사용한다. 현재 내가 조작하려는 row를 다른 transaction이 조작 못하게 합니다.

3. gap locks와 next-key locks도 사용합니다. next-key locks은 “supremum”라 불리는 임시의 레코드 다음에 있는 갭을 잠급니다. 

"supremum" 레코드 레코드는 인덱스에서 실제 존재하는 값들 보다 더 큰 값을 가집니다. “supremum” 은 진짜 인덱스 레코드가 아닙니다. 그래서 이 next-key locks는 가장 큰 인덱스 값 다음에 오는 Gap만 잠급니다.

(참고: https://singun.github.io/2019/03/10/mysql-innodb-locking/)

 

(번역) InnoDB Locking - Programming Singun

MySQL 공식 문서 중 14.7.1 InnoDB Locking 을 번역한 것입니다. 영어가 익숙하지 않아 의미가 잘 전달되지 않는 부분이 있을 수 있습니다. 틀렸거나 잘못 번역된 부분이 있다면 피드백 부탁드리겠습니

singun.github.io

Deadlock과 transaction에 대해서 더 설명하려면 분량이 너무 많아지기 때문에 위 블로그들을 참고하길 바랍니다 ㅎㅎ 저희는 여기서 중요한 포인트만 가져와서 django ORM 동작에서 확인해보겠습니다.

 

 

2. update_or_create()를 해보자!


먼저 예제를 한번 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# -*- coding: utf-8 -*-
from multiprocessing import Process
from django.db import transaction
from apps.users.models import User
 
TEST_DATA = {
    'password''12345676',
    'name''테스트 유저',
    'phone''01012345678',
}
 
 
def test_function(username=None):
    if username is None:
        return
 
    with transaction.atomic(): # 트랜젝션을 건다 (중간에 잘못되면 롤백하기 위해)
        User.objects.select_for_update().update_or_create(
            username=username,
            defaults=TEST_DATA
        )
 
 
def run():
    # 동시 실행 테스트를 위해 멀티 프로세싱 사용
    th1 = Process(target=test_function, args=('fitware_jay1',))
    th2 = Process(target=test_function, args=('fitware_jay2',))
 
    th1.start()
    th2.start()
    th1.join()
    th2.join()
 

test_function()의 내용을 보면 transaction을 걸고 User 모델에 username인 데이터가 있으면 select_for_update()에 의해 해당 rowlock을 걸고 update, 모델이 없는 경우 create를 하게 되며, 실제로 SELECT .... WHERE `users_user`.`username` = 'fitware_jay1' FOR UPDATE; args=('fitware_jay1',) 이런 식의 쿼리가 나가게 됩니다.

 

언뜻 보면 크게 문제가 되지 않을 수도 있지만, test_funstion()가 동시에 여러 번 실행하는 경우 문제가 생깁니다.

 

📝 시나리오: fitware_jay1, fitware_jay2 username을 가진 User가 존재하지 않을 때 동시에 두 함수가 호출되는 경우 (테스트 스크립트에서는 멀티 프로세스로 진행했습니다.)

 

시나리오대로 동작하는 경우 과연 어떻게 결과가 나올까요?

 

Deadlock이 발생됬다는 내용

두둥 탁!! Deadlock이 발생했습니다. 이번 글에서 설명하고자 하는 포인트입니다!!! 과연 Deadlock이 왜 발생했을까요?

mysql에 접속해서 SHOW ENGINE INNODB STATUS 명령어로 InnoDB의 상태(output)를 확인해 보겠습니다.

같은 row에 lock을 잡고 있는 서로다른 트랜잭션

명령어를 치면 여러 정보들이 나오는데 그중 LATEST DETECTED DEADLOCK 정보를 확인합니다. 첨부된 이미지에서 보다시피 서로 다른 transaction이 같은 row에 lock을 걸고 있는 걸 확인할 수 있습니다. 그래서 Deadlock이 걸렸던 것입니다. 그리고 실제로 둘 중 하나의 transaction 만 정상적으로 처리되었습니다. 

 

위 동작에 대해서 포인트를 설명하자면, 

1. InnoDB에서는 Predicate Locking을 지원하지 않아 Supremum record(존재하지 않는 record)에 lock을 겁니다. 
(* row = record)

2. Supremum record는 Gap Lock을 사용하는데 Gap Lock은 같은 record에 여러 transaction이 걸 수 있습니다. (Insert 만 안됩니다)

3. 2번의 내용 때문에 서로 Lock이 해제되기를 기다리다가 Dead Lock에 빠지고 MySQL은 이것을 감지하고 하나의 transaction만 남기고 나머지를 강제로 Roll Back 시킵니다.

(참고: https://youngminz.netlify.app/posts/get-or-create-deadlock#mysql-internal)

 

Django get_or_create() 함수에서 발생한 MySQL Deadlock 이슈 해결하기 - 구영민의 개발 블로그

Django ORM의 get_or_create() 함수 Django ORM의 get_or_create() 함수는 데이터베이스에 객체가 있으면 가져오고, 없으면 객체를 만드는 함수입니다. 아래의 코드를 한 줄로 줄여서 쓸 수 있는 편의성 함수입

youngminz.netlify.app

이게 결론은 두 개의 요청이 update_or_create() 하다가 User가 없어서 둘 다 새로 User를 생성하려고 동일한 Supremum record에 lock을 걸어 Dead Lock이 발생한 것입니다.

 

그럼 어떻게 해결해야 할까요?

Lock을 걸지 않으면 됩니다. -> update_or_create() 쓰지 마세요!! ㅋㅋㅋ <- 오늘 진짜 진짜 말하고 싶었던 내용!!! update_or_create()는 Dead Lock을 발생시킬 수 있습니다!!

(정정 2022.04.25)
update_or_create()를 사용해도 됩니다. 다만 select_for_update()를 함께 사용하면 안됩니다. select_for_update()는 gap lock이 걸려 dead lock을 유발할 수 있습니다.

 

 

3. Dead Lock 없이 update_or_create() 대체하기


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# -*- coding: utf-8 -*-
from multiprocessing import Process
 
from django.db import transaction
 
from apps.users.models import User
 
TEST_DATA = {
    'password''12345676',
    'name''테스트 유저',
    'phone''01012345678',
}
 
 
def test_function(username=None):
    if username is None:
        return
 
    with transaction.atomic(): # 트랜젝션을 건다 (중간에 잘못되면 롤백하기 위해)
        try:
            user = User.objects.filter(username=username).get() # 먼저 select로 User를 가져온다.
            user.__dict__.update(TEST_DATA) # 인스턴스의 네임스페이스들을 업데이트
            user.save() # 변경된 내용 저장
        except User.DoesNotExist: # User가 존재하지 않는 경우
            TEST_DATA['username'= username
            User.objects.create(**TEST_DATA) # 새로운 User 
 
 
def run():
    th1 = Process(target=test_function, args=('fitware_jay1'))
    th2 = Process(target=test_function, args=('fitware_jay2'))
 
    th1.start()
    th2.start()
    th1.join()
    th2.join()
 
 

변경된 코드는 이렇습니다!!! 코드를 보면 lock을 거는 게 아니라 get()으로 User가 존재하는지 체크를 하고 존재하면 save(), 존재하지 않는 경우 create() 해줍니다!! 이렇게 하면 Dead Lock 없이 update_or_create() 기능을 구현할 수 있습니다.

 

Dead Lock 없이 실행된 쿼리

 

 

3. 마치며


오늘 update_or_create()를 보셨다시피 ORM을 너무 믿어서는 안 됩니다! 실제로 우리가 생각하는 데로 동작이 되지 않을 수가 있어요 ㅎㅎ 사실 저도 SQL에 대해서 잘 알지 못했고, ORM으로만 쓰다 보니... 내가 제대로 모르고 쿼리를 날리고 있었구나라는 생각을 하게 되더라고요. 그래서 요즘에 DataBase에 더 관심을 가지게 되었고 이런 부분에 대해서 좀 더 신경 쓰려고 노력하는 것 같습니다!

 

사실 DataBase, Transaction, Dead Lock에 대해 깊은 내용을 다루기는 분량도 그렇고, 제가 아직 공부할 범위가 너무 많아서 일부분에 대해서만 설명했는데, 꼭 위 세 가지에 제가 링크로 남긴 블로그들 보시면서 공부해보시길 추천드립니다!! ㅎㅎ 그럼 오늘도 좋은 하루 되시고 즐거운 코딩 하세요!!

 

[참고 블로그]

https://youngminz.netlify.app/posts/get-or-create-deadlock

https://suhwan.dev/2019/06/09/transaction-isolation-level-and-lock/

https://singun.github.io/2019/03/10/mysql-innodb-locking/