Django ORM select_for_update()와 Lock

Created
Oct 10, 2020
Created by
Tags
Django
Database
Property
 
 

Django ORM에서 발생하는 race condition

 
 
race condition은 2개 이상의 스레드 또는 프로세스가 동일한 자원을 공유할 때 서로의 조작이 공유되지 않는 상황을 말한다. 파이썬에는 GIL(Global Interpreter Lock) 이 있기 때문에 다중 쓰레드 환경에서는 메모리 상에 있는 객체에 race condition이 발생하지 않는다.
아래 코드의 occur_race_condition() 함수는 before_after라는 패키지 모듈을 사용하여 고의적으로 race condition을 일으키도록 한다.
2개의 쓰레드가 동시에 전역객체 x에 접근해서 x += 1 연산을 시도하기 때문에 2가 출력될 것으로 예상하지만 GIL에 의해 어느 한 쓰레드가 전역객체에 접근하여 연산을 하는 사이, 다른 쓰레드는 객체에 접근하지 못한다. 따라서 x의 값을 보고 1을 더하는 연산이 순차적으로 2번 발생하므로 2가 아닌 3이 출력된다.
 
# 전역변수 x x = 1 def get_x(): return x def another_stuff(): print('eeehhhh') def plus_x(): another_stuff() global x x += 1 def occur_race_condition(): with before_after.after('owner.adhome.race_condition.another_stuff', plus_x): plus_x() global x print(x)
 
>>> from study.race_condition import occur_race_condition >>> occur_race_condition() eeehhhh eeehhhh 3
 
그러나 공유 자원이 메모리 상의 객체가 아니라 데이터베이스에 있는 row라면 경우가 달라진다.
아래와 같이 ShoppingItem이라는 모델이 있다고 하자.
 
from django.db import models class ShoppingItem(models.Model): id = models.IntegerField(primary_key=True) price = models.PositiveIntegerField() class Meta: db_table = 'shopping_item'
 
ShoppingItem 모델의 어떤 객체를 2개의 쓰레드가 접근하여 price라는 컬럼의 값에 1000만큼 더하려고 한다. 기존의 44000 이라는 값에 1000이 2번 더해져서 46000으로 갱신되는걸 의도했으나 45000이 들어가있는 것을 확인할 수 있다.
 
def another_stuff(item_id): print('eeehhhh') def plus_price(item_id): another_stuff(item_id) item = ShoppingItem.objects.get(id=item_id) item.price += 1000 item.save() def occur_race_condition(): item_before_plus = ShoppingItem.objects.get(id=39) print('before race condition occurred: ', item_before_plus.price) with before_after.after('study.race_condition.another_stuff', plus_price): plus_price(39) item_after_plus_twice = ShoppingItem.objects.get(id=39) print('after race condition occurred: ', item_after_plus_twice.price) assert int(item_before_plus.price + 2000) == int(item_after_plus_twice.price)
 
>>> occur_race_condition() before race condition occurred: 44000 eeehhhh eeehhhh after race condition occurred: 45000 Traceback (most recent call last): File "<input>", line 1, in <module> occur_race_condition() File "/Users/c201809035/study/race_condition.py", line 26, in occur_race_condition assert int(item_before_plus.price + 2000) == int(item_after_plus_twice.price) AssertionError
 
두 개의 쓰레드가 같은 row에 접근해서 컬럼값을 변경할 때 before_after는 아래와 같이 race condition을 일으키기 때문이다.
 
# thread1 # thread2 another_stuff(item_id) another_stuff(item_id) # item.price는 44000 item = ShoppingItem.objects.get(id=39) # item.price = 45000 item = ShoppingItem.object.get(id=39) item.price += 1000 item.save() # thread1에서 값을 바꾼것을 알 수 없음. item.price += 1000 item.save()
 
 
 

race condition을 방지하는 select_for_update()

 
 
위와 같은 race condition을 방지하려면 select_for_update() 를 사용해야 한다. select_for_update()select ~ for update 쿼리를 실행하는 ORM 함수로, 실제로 아래와 같은 쿼리를 DB에 실행한다.
 
SELECT "shopping_item"."id", "shopping_item"."price", FROM "shopping_item" WHERE "shopping_item"."id" = 39 FOR UPDATE;
 
select_for_update() 를 사용했을 때 race condition이 발생하는지 확인해보자.
 
def another_stuff(item_id): print('eeehhhh') def plus_price(item_id): with transaction.atomic(): another_stuff(item_id) item = ShoppingItem.objects.select_for_update().get(id=item_id) item.price += 1000 item.save() def occur_race_condition(): item_before_plus = ShoppingItem.objects.get(id=39) print('before race condition occurred: ', item_before_plus.price) with before_after.after('study.race_condition.another_stuff', plus_price): plus_price(39) item_after_plus_twice = ShoppingItem.objects.get(id=39) print('after race condition occurred: ', item_after_plus_twice.price) assert int(item_before_plus.price + 2000) == int(item_after_plus_twice.price)
 
>>> occur_race_condition() before race condition occurred: 44000 eeehhhh eeehhhh after race condition occurred: 46000
 
두 쓰레드에서 plus_price()를 순차적으로 실행했으므로 price46000으로 갱신된 것을 확인할 수 있다. 여러 트랜잭션이 같은 리소스에 접근하여 변경하려고 할 때 각 트랜잭션은 lock을 설정하기 때문이다.
 
 

Lock

lock은 동시에 동작하는 다수의 트랜잭션들의 격리성을 유지하기 위해 존재한다. lock은 트랜잭션이 시작할 때 설정되며 트랜잭션이 커밋 또는 롤백할 경우 해제된다. 트랜잭션의 격리성을 유지하는 lock에는 exclusive lock과 share lock이 있다.
 
💡
격리성(Isolation) 트랜잭션 실행 도중에 다른 트랜잭션이 중간에 간섭할 수 없는 것을 말한다.
 
 

exclusive(배타적) lock

 
select ~ for update가 트랜잭션 안에서 실행될 때 걸리는 락(lock)은 exclusive(배타적) lock 이다.
exclusive lock은 delete/update/select~for update 와 같이 데이터를 변경할 때 사용되며, 트랜잭션이 완료될 때까지 유지된다. exclusive 라는 이름에 맞게 어떤 트랜잭션이 데이터를 변경하기 위해 lock을 점유하고 있으면 다른 트랜잭션은 lock이 해제될 때까지 데이터에 접근할 수 없다. select 조차도 불가능하다.
반대로 어떤 트랜잭션이 share(공유) lock 또는 exclusive lock을 걸었을 때 다른 트랜잭션이 exclusive lock을 설정할 수 없다.
 

share(공유) lock

 
share lock은 데이터를 select 하려고 할 때 사용한다. 어떤 트랜잭션이 데이터에 share lock을 걸었을 경우 다른 트랜잭션이 같은 데이터에 share lock을 또 거는 게 가능하다. 그러나 exclusive lock이 걸려있을 경우에는 share lock을 거는 게 불가능하다. 따라서 자신이 select 하고 있는 데이터를 다른 트랜잭션이 동시에 select 할 수는 있어도 delete/update/select~for update 와 같은 쿼리는 할 수 없다.
 
아래는 어떤 트랜잭션이 lock을 이미 걸어놓은 상태에서 다른 트랜잭션이 lock을 걸려고 할 때의 상황을 표로 만든 것이다.
 
상황 별 lock 가능 여부
- 2
- 1
-
share lock 설정되어있음
exclusive lock 설정되어있음
가능 (읽기 O)
불가능 (읽기 X)
불가능 (변경 X)
불가능 (변경 X)
 

blocking을 최소화하는 방법

 
blocking은 어느 트랜잭션이 lock을 설정하여 리소스를 변경하는 동안 다른 트랜잭션이 작업을 진행하지 못하고 멈춘 상태를 말한다. share lock 끼리는 blocking이 발생하지 않지만 exclusive lock 끼리 또는 share lock과 exclusive lock 에서 발생한다.
blocking은 트랜잭션이 lock을 해제하기 전까지 계속되므로 트랜잭션의 커밋 또는 롤백만이 해결할 수 있다. blocking이 자주 생긴다면 데이터베이스 I/O 레이턴시가 길어지므로 사용자 경험에 좋지 않다. blocking을 아예 없앨 수는 없지만 최소화하는 방법이 몇가지 있다.
 
  1. 트랜잭션의 원자성(all or nothing)을 훼손하지 않는 선에서 트랜잭션을 가능한 짧게 정의한다.
  1. 동일한 데이터를 변경하는 트랜잭션이 동시에 실행되지 않도록 한다.
  1. lock timeout을 설정한다.
    1. 다른 트랜잭션이 lock을 해제할 때 까지 대기하는 시간을 미리 정해놓고, 대기시간을 초과하면 예외가 발생하도록 한다. Django에서는 settings.pyOPTIONS 상수에 정의할 수 있다.
  1. 트랜잭션 격리성(isolation) 레벨을 불필요하게 높이지 않는다.
  1. select ~ for update nowait 쿼리를 사용한다.
    1. update/delete 쿼리를 실행하기 전에 select ~ for update nowait; 를 먼저 수행하여 lock이 이미 설정되어 있는지 확인한다. nowait 옵션은 이미 lock이 걸려있을 경우 예외를 발생시킨다.
      Django의 select_for_update() 에 nowait 옵션을 줘서 lock이 이미 걸려있을 경우 DatabaseError 예외를 발생시킬 수 있다.
       
 
 
 
 
 
 
참고