Django ORM의 get_or_create() 함수

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

obj, created = Person.objects.get_or_create(first_name='John', last_name='Lennon', defaults={'birthdate': date(1940, 10, 9)})
try:
    obj = Person.objects.get(first_name='John', last_name='Lennon')
    created = False
except Person.DoesNotExist:
    obj = Person(first_name='John', last_name='Lennon', birthday=date(1940, 10, 9))
    obj.save()
    created = True

get_or_create() 함수는 select_for_update() 함수와 동시에 사용할 수도 있습니다. 아래의 두 코드는 동일한 작업을 수행합니다.

with transaction.atomic():
    obj, created = Person.objects.select_for_update().get_or_create(first_name='John', last_name='Lennon', defaults={'birthdate': date(1940, 10, 9)})
with transaction.atomic():
    try:
        obj = Person.objects.select_for_update().get(first_name='John', last_name='Lennon')
        created = False
    except Person.DoesNotExist:
        obj = Person(first_name='John', last_name='Lennon', birthday=date(1940, 10, 9))
        obj.save()
        created = True

로직 상, Person 객체가 이미 존재하는지 확인했는데 없으면 새 객체를 만듭니다. 아래는 이 과정에서 실행되는 SQL입니다. (update_or_create 함수도 객체가 없는 경우 동일한 동작을 합니다)

BEGIN;

SELECT * FROM person WHERE first_name='John' AND last_name='Lennon'
FOR UPDATE; 
-- -> 결과 없음! 이 때 FOR UPDATE이므로, "존재하지 않는 열"의 Lock을 획득함.

INSERT INTO person (first_name, last_name, birthday) 
VALUES ('John', 'Lennon', '1940-10-09'); 
-- -> 데이터를 INSERT 처리함.

COMMIT;

얼핏 보면 문제는 없어 보입니다.

MySQL Bug #25847 - Predicate Locking을 추가하여 존재하지 않는 열을 Lock을 잡을 때 Deadlock을 피할 수 있게 하기

버그의 내용은 REPEATABLE READ 에서 존재하지 않는 열을 SELECT ... FOR UPDATE 구문을 사용하면 Deadlock이 발생한다는 것입니다. 버그의 재현은 정말 간단합니다.

세션 1: CREATE TABLE t (id int) ENGINE=InnoDB; -- 테스트용 테이블 생성
세션 1: BEGIN; -- 세션 1에서 트랜잭션 시작
세션 1: SELECT id FROM t WHERE id = 1 FOR UPDATE; -- 세션 1에서 존재하지 않는 id = 1 값을 INSERT 하려고 Lock 획득
세션 2: BEGIN; -- 세션 2에서 트랜잭션 시작
세션 2: SELECT id FROM t WHERE id = 2 FOR UPDATE; -- 세션 2에서 존재하지 않는 id = 2 값을 INSERT 하려고 Lock 획득
세션 1: INSERT INTO t (id) VALUES (1); -- (1) id = 1 값을 테이블에 삽입
세션 2: INSERT INTO t (id) VALUES (2); -- (2) id = 2 값을 테이블에 삽입

이 때,

  • (1): 세션 1은 (2)가 실행될 때까지 Hang에 걸립니다.
  • (2): 세션 1은 입력에 성공하고, 세션 2는 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 오류가 발생합니다.

위의 트랜잭션 중 하나는 성공, 하나는 실패로 끝납니다. Latest Detected Deadlock에 아래와 같이 내용이 추가됩니다.

SHOW ENGINE INNODB STATUS;
->
------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-10-24 05:12:47 7f5b00a6e700
*** (1) TRANSACTION:
TRANSACTION 2342, ACTIVE 21 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 360, 2 row lock(s)
MySQL thread id 1, OS thread handle 0x7f5b00ab0700, query id 40 172.18.0.1 root update
INSERT INTO t (id) VALUES (1)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 8 page no 3 n bits 72 index `GEN_CLUST_INDEX` of table `peoplefund`.`t` trx id 2342 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** (2) TRANSACTION:
TRANSACTION 2343, ACTIVE 12 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 360, 2 row lock(s)
MySQL thread id 2, OS thread handle 0x7f5b00a6e700, query id 41 172.18.0.1 root update
INSERT INTO t (id) VALUES (2)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 8 page no 3 n bits 72 index `GEN_CLUST_INDEX` of table `peoplefund`.`t` trx id 2343 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 8 page no 3 n bits 72 index `GEN_CLUST_INDEX` of table `peoplefund`.`t` trx id 2343 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (2)

SELECT ... FOR UPDATE 구문은 정상적으로 실행이 되었는데, INSERT를 하려고 보니 Deadlock이 발생하는 형태입니다. 일반적으로 SELECT ... FOR UPDATE 구문은 쓰기를 수행하기 위한 Lock를 잡기 위해 사용하기 때문에, 이 구문이 성공하고 INSERT 가 실패하는 것은 제게는 이해하기 어려운 동작이었습니다. 왜 이렇게 동작하는지 알기 위해 MySQL 버그 리포트를 조금 더 자세히 읽어 보았습니다.

기본 Isolation Level인 REPEATABLE READ에서는, Index Page의 Supremum 레코드의 Lock을 걸어야 합니다. 어떤 트랜잭션에서 존재하지 않는 i = 1000000000에 대해 Lock를 걸면 다른 트랜잭션에서는 i = 1000000000인 열을 INSERT 해서는 안 됩니다. InnoDB에서 이를 할 수 있는 유일한 방법은 Supremum 레코드의 Lock을 잡는 것입니다. InnoDB는 존재하지 않는 i = 1000000000 레코드만을 Lock을 잡는 Predicate Locking 기능이 없습니다.

MySQL Internal

이 현상을 보다 잘 이해하기 위해, 필요한 지식들을 추가적으로 공부해 정리해 보았습니다.

  • Predicate Locking: 논리적인 조건을 이용하여 현재 데이터베이스에 존재하지 않는 (Phantom) 열의 Lock을 잡는 방법입니다. 예를 들면, SELECT ... WHERE age >= 18 FOR UPDATE 으로 Lock을 걸었다면, 다른 트랜잭션에서는 age가 18 이상인 열은 INSERT 할 수 없지만, 18 미만인 열은 INSERT를 할 수 있습니다. 하지만 MySQL의 InnoDB는 이를 지원하지 않아서, 그 대안으로 Supremum 레코드에 Lock을 겁니다.
  • Record Lock: MySQL에서는 특정한 열에 Lock을 건다고 하면, 사실 인덱스의 레코드에 Lock을 거는 것입니다. 그래서 그 열에만 Lock이 걸리지 않고, 인덱스가 참조하고 있는 모든 열에 Lock이 걸립니다. REPEATABLE READ에서는 동일한 트랜잭션 내에서는 여러 번 읽어도 항상 같은 튜플 셋이 반환되기에, Lock을 걸 때에는 PRIMARY 인덱스를 타게 하려는 목적으로 Primary Key를 지정해서 Lock을 거는 것이 안전합니다. FOR UPDATE 구문을 사용할 때 Index를 타지 않는 WHERE 조건을 이용해 쿼리하면 자칫 전체 테이블에 Lock이 걸릴 수도 있습니다.
  • Gap Lock: Record Lock의 일부입니다. 비어 있거나, 하나, 혹은 그 이상의 인덱스를 잠그는 Lock 입니다. 특이하게도 S Lock과 X Lock의 차이가 없으며, 현재의 트랜잭션에서 Gap Lock을 획득하여도 다른 트랜잭션에서 동일한 Gap의 Lock을 획득할 수 있습니다! 다른 트랜잭션에서 INSERT 를 할 때에만 막습니다.
  • Infimum 레코드와 Supremum 레코드
    • Infimum과 Supremum은 수학 용어로는 각각 하한, 상한을 뜻합니다.
    • InnoDB에서, Infimum 레코드는 Greatest Lower Bound (GLB), 즉 다른 어떠한 키들보다도 낮은 특수한 레코드입니다. 이와는 반대로 Supremum 레코드는 Least Upper Bound (LUB), 즉 다른 어떠한 키들보다도 높은 특수한 레코드를 말합니다. 실제 인덱스 데이터가 들어가는 User 레코드는 이 사이에 있다고 할 수 있습니다.
  • User 레코드에 Lock을 걸 때에는 적절한 인덱스를 태웠다면 Record Lock이 걸립니다.
  • Supremum 레코드에 Lock을 걸 때에는, 실제로 존재하는 레코드가 아니기 때문에 Gap Lock이 걸립니다.
    • Gap Lock은 여러 트랜잭션에서 획득할 수 있습니다. → 여러 트랜잭션에서 SELECT ... FOR UPDATE 가 성공한 이유입니다.
    • 이 Lock이 COMMIT 혹은 ROLLBACK 되어 해제되기 전까지는, 다른 트랜잭션에서는 아무도 INSERT 할 수 없습니다.
    • Supremum 레코드에 걸린 Gap Lock은 다른 트랜잭션에서 INSERT 를 하는 것을 막습니다. → Gap Lock이 다른 트랜잭션에서 같은 범위에 대해 걸려 있으면, 서로의 트랜잭션에서 INSERT 를 하는 것을 막습니다. 각자의 트랜잭션에서 INSERT 를 하려고 하는데, 이를 위해서는 서로의 Lock이 해제되기를 계속해서 기다리고 있는 것입니다. 즉 교착 상태가 발생합니다! MySQL은 이를 감지하고 Deadlock 오류를 내면서 단 하나의 트랜잭션만 남기고 나머지를 강제 롤백 처리해버립니다.

다시 한 번 정리해 보겠습니다.

SELECT … FOR UPDATE → UPDATE

SELECT ... FOR UPDATE 구문을 사용하면 다른 트랜잭션에서 특정한 레코드를 업데이트 하지 못하게 막습니다. 현재의 트랜잭션에서만 Lock을 잡은 레코드를 UPDATE 한 후 COMMIT 하거나 ROLLBACK 할 수 있습니다. 이렇게 하면 다른 비즈니스 로직에서 동시에 접근할 수 없게 잠그는 역할을 합니다. InnoDB는 단순하게 Index의 레코드를 잠금으로써 이를 구현합니다.

SELECT … FOR UPDATE → INSERT

그런데, SELECT ... FOR UPDATE 구문을 INSERT 구문과 사용하려면, “존재하지 않는” Index의 레코드를 어떻게 잠글 수 있을까요? 기본 트랜잭션 격리 수준인 REPEATABLE READ 를 사용한다면, InnoDB는 Gap Lock을 활용합니다. 아직 만들어지지는 않았지만 새로 생성될 열의 값이나 범위를 지정해주면, InnoDB는 Record 들의 Gap에 Lock을 걸어서 다른 트랜잭션에서는 Gap에 INSERT 되는 것을 막습니다.

기본 트랜잭션 격리 수준인 REPEATABLE READ 에서는, 존재하지 않는 열에 대해 SELECT ... FOR UPDATE 를 수행하는 것은 다른 트랜잭션이 진입하는 것을 막지 않습니다. 그렇기 때문에, 두 트랜잭션이 동시에 존재하지 않는 열에 대해 SELECT ... FOR UPDATE 를 수행하면 두 트랜잭션 모두 Lock이 걸리며, 어떤 트랜잭션에서도 레코드를 업데이트 할 수 없습니다. 결국 Deadlock이 감지되어 롤백 처리 됩니다.

문제가 생길 수 있는 상황

아래의 네 가지 상황이 동시에 맞아 떨어졌을 때 Deadlock이 발생할 수 있습니다. 단 네 번째 조건은 동시성에 대한 이슈입니다. 대개 동시성에 대한 이슈는 한 번에 하나의 트랜잭션은 잘 실행되는 경우가 많기 때문에, 스트레스 테스트가 없다면, 운영에 올라가기 직전까지의 환경에서는 동작에 문제가 없다가 운영 환경에서 사용자가 몰릴 때 문제가 생기곤 합니다. 이번 버그에 대해서도 일부 요청에 대해서만 실패했기 때문에, 재현 조건을 파악하기 전까지 로컬 환경에서 재현하기가 쉽지 않았습니다.

  • Django의 select_for_update() 함수를 get_or_create() 함수나 create_or_update() 함수와 같이 사용
  • MySQL InnoDB 엔진 사용
  • Isolation Level을 REPEATABLE READ 으로 설정 (READ COMMITTED Level에서는 발생하지 않음)
  • 두 개 이상의 트랜잭션에서 동시에 존재하지 않는 열에 대해 SELECT ... FOR UPDATE 를 수행하여 Supreme 레코드에 대해 Gap Lock을 획득

피플펀드 시스템에서의 실제 Troubleshooting 사례

문제 인지

Error Aggregation & Tracking Tool 으로 사용 중인 Sentry에서 동시다발적으로 오류가 발생하고 있다는 알람이 도착했습니다. 다수의 사용자에게 미치는 상황임을 확인하고 즉시 원인 파악에 들어갔습니다.

원인 파악

문제가 발생한 소스 코드입니다. 위에서 설명했던 문제의 패턴과 동일함을 알 수 있습니다.

# 고객 계좌 초기화
with transaction.atomic():
    account, created = (
        Account.objects
        .select_for_update()
        .get_or_create(user=user)
    )

운영 환경에서 SHOW ENGINE INNODB STATUS; 를 찍어 보았을 때의 Latest Detected Deadlock에 supremum 이라는 단어가 포함되어 있는 것을 보고, 위의 문제와 관련이 있을 수 있겠다는 단서를 확인했습니다.

문제 해결

피플펀드에서는 MySQL을 직접 관리하지 않고 AWS의 관리형 데이터베이스 서비스를 이용하고 있습니다. AWS RDS MySQL & MariaDB, Aurora for MySQL은 InnoDB Engine에 REPEATABLE READ 말고는 선택할 수 없습니다. 이에, 기본 트랜잭션 격리 수준을 READ COMMITTED 으로 변경하는 옵션은 사용할 수 없었습니다.

get_or_create()가 존재하지 않는 열의 Lock를 잡아서 MySQL의 버그를 트리거하면 다른 트랜잭션에 영향을 미치는 것이 핵심 문제이므로, 위의 코드에서 select_for_update 함수를 제거했습니다.

맥락을 이해하기 위하여 간략화된 ERD를 그려 보았습니다. 문제가 발생한 로직의 테이블은 Account → User 테이블로 Foreign Key가 걸려 있는 구조입니다.

만약 Front-End에서 동시 요청을 막지 않아 같은 요청이 동시에 들어왔을 때 제대로 처리하지 못하면, 하나의 유저가 여러 개의 담보채권 계좌를 가지게 되므로 이를 어떻게 막을지 고민했습니다. REPEATABLE READ 에서는, 현재 트랜잭션이 실행되고 있는 동안에는 다른 트랜잭션이 성공하든 실패하든 시작한 이후에는 보이지 않게 되어 있습니다.

따라서 Django에서 사용하는 get_or_create() 함수나 update_or_create() 함수는 REPEATABLE READ 에서는 같은 값이 중복으로 입력될 수 있습니다. 이러한 상황에서는 Account 테이블에 Lock을 사용해서는 여러 개의 Row를 만드는 것을 막을 수 없습니다.

이를 해결하기 위해 두 가지 방법이 있습니다.

  • 이미 존재하는 User 열에 먼저 Lock을 잡은 후 select_for_update() 없이 Account의 get_or_create() 함수를 호출합니다.
  • 추가적으로, 데이터베이스의 account.user_id 칼럼에 UNIQUE 제약 조건을 추가하여 위의 로직에 문제가 생겨도 데이터베이스에 기록은 하지 못하도록 막습니다.

확실하게 하기 위해서 위의 두 가지 방법을 동시에 적용하였고, 수정된 버전을 배포한 후에는 현재까지 Deadlock이 발생하지 않았습니다!

Lesson Learned

  • Django에서 아래와 같이 Row가 있는지 확인한 후, 없으면 Row를 생성하는 함수들을 select_for_update() 함수와 함께 사용하는 것은 의도치 않은 Deadlock를 유발할 수 있습니다:
    • get_or_create()
    • update_or_create()
  • ORM은 코딩을 편리하게 해 주지만, 항상 옳은 쿼리만을 생성하는 것은 아닙니다. 항상 유의하여 코드를 짜야 합니다.
  • Sentry에 많이 발생하는 오류에 대해 Slack으로 알람을 보내 주도록 설정을 해 놓았습니다. 덕분에 문제 발생 조기에 문제가 있다는 것을 인지할 수 있었습니다.
  • Django 문서에 해당 버그가 문서화되어 있지 않아 아쉬웠습니다. 다만 Issues #13906에서는 언급되었습니다.
  • 최근에 DB의 중요성을 깨닫고 있습니다. 제대로 알고 써야 문제가 생기지 않으나, 제대로 알고 쓰기가 정말 어렵습니다.

References

  1. Django Issues - #13906 REPEATABLE READ (as used by default on MySQL) breaks atleast QuerySet.get_or_create().
  2. Bug #25847 - Add predicate locking to avoid deadlocks due to locking non-existent rows
  3. MySQLAB - What is predicate locking?
  4. MySQL Manual - InnoDB Locking
  5. MySQL Manual - The Infimum and Supremum Records
  6. Stack Overflow - Does “select for update” prevent other connections inserting when the row is not present