DB Concurrency 문제는 DB에 두 명 이상의 유저가 동시에 접근할 때 발생할 수 있는 문제이다. 즉, Concurrency는 transaction이 순차적으로 실행되는 것이 아니라 순서에 상관없이 동시에 실행되는 것을 말한다.
예를 들어, 두 유저가 동시에 계좌의 잔고에 변경이 일어나는 작업을 한다고 가정해 보자. 한 명은 해당 계좌에 저축을 하고 동시에 다른 한 명은 해당 계좌에서 다른 계좌로 이체를 한다. 이 경우 적절한 concurrency control이 없다면 어느 한 명은 계좌의 잔고가 내가 예상한 것과 다르다는 것을 보게될 것이다.
이처럼 여러 유저의 동시 접속 문제는 모든 서비스에서 당연히 일어날 수밖에 없는데, DBMS는 동시성을 제어할 수 있도록 Lock 기능과 SET TRANSACTION
명령어를 이용해서 트랜잭션의 격리성 수준을 조정할 수 있도록 제공하고 있다.
그러나 Locking은 읽기 작업과 쓰기 작업이 서로 방해를 하며 동시성 문제가 발생하고 또한 데이터 일관성에 문제가 생기기도 한다. 또한, Lock이 걸리는 시간이 있어 성능 저하가 발생하기도 한다. 이러한 문제를 해결하기 위한 방법을 MVCC
(multi-version concurrency control)이라 하는데, 이에 대해서는 추후에 추가적으로 다루어 보겠다.
Transaction 격리 수준이란 여러 transaction이 동시에 처리될 때 transaction끼리 얼마나 고립되어 있는 지를 나타내는 것으로, RDBMS가 처리하는 격리 수준을 말한다.
격리 수준에 따라 특정 트랜잭션이 다른 트랜잭션에서 변경한 데이터를 볼 수 있도록 허용할 지 말 지를 결정할 수 있다.
이 때, 위에서 설명했던 것과 같이 두 유저가 동시에 DB에 접근하는 것을 막기 위해 전부 lock을 걸어 버리면 되지 않을까 생각할 수도 있지만 그렇게 되면 동시에 수행되는 많은 트랜잭션들은 순차적으로 처리할 수밖에 없게 되는데 그러면 데이터베이스의 성능은 떨어지게 된다. 따라서, 가장 효율적인 Locking 방법을 고민할 필요가 있다.
Lock의 종류와 특징
Lock에는 두 가지 종류가 있다.
REPEATABLE READ
의 정합성에 어긋난다.Non-repeatable read와 Phantom read의 차이점
비관적 동시성 제어와 낙관적 동시성 제어가 있다.
wait
또는 nowait
옵션과 함께 사용할 필요가 있다.위에서 언급했듯이, 각 DBMS마다 격리 수준이 다르게 설정되어 있다.
django에서 사용할 postgreSQL의 격리 수준을 조회해 보면 다음과 같다.
SHOW TRANSACTION ISOLATION LEVEL;
# read committed
이렇게 설정되어 있는 격리 수준을 더 높은 수준에 해당하는 repeatable read
, serializable
중 하나로 수정하면 된다.
select_for_update
는 트랜잭션이 끝날 때까지 row에 락을 걸 쿼리셋을 반환한다.
이에 해당하는 SQL 구문은 SELECT ... FOR UPDATE
이다.
SELECT *
FROM entries
WHERE id=10
FOR UPDATE;
select_for_update
(nowait=False, skip_locked=False, of=(), no_key=False)
공식 문서에 따르면 아래와 같이 사용할 수 있다.
from django.db import transaction
entries = Entry.objects.select_for_update().filter(author=request.user)
with transaction.atomic():
for entry in entries:
entry.left -= 1
entry.save()
entries 쿼리셋에 대해 “for entry in entries” 반복문을 도는 동안 entries에 매칭 되는 row는 모두 해당 transaction이 끝날 때까지 락이 잡힌다. 따라서 다른 트랜잭션은 읽기 작업 외에는 해당 row에 변경을 가하는 어떤 작업도 할 수 없다.
파라미터
ValueError
가 발생한다(기다리지 않는다는 설정과 무시한다는 설정은 상충 관계).DatabaseError
를 발생시킨다.selec_for_update()
는 select_related
를 통해 join된 테이블의 row도 함께 락을 잡는다. 만약 이를 원하지 않는다면, of 파라미터를 이용해 lock을 잡을 테이블만 명시할 수 있다.django는 django-redis 라이브러리를 이용해 redis를 사용할 수 있는데, Redis는 제한적이긴 하지만 SETNX
와 INCR
명령을 통해 Redis atomic operation을 지원한다.
SETNX
는 set()
의 파라미터 nx
를 통해 사용할 수 있다. SETNX
는 “set not exist”라는 의미로, “락이 존재하지 않으면 락을 획득한다”는 연산을 atomic하게 할 수 있도록 만들어 준다.
from django.core.cache import cache
movie_ticket_key = f'movie_ticket_key:{user_id}:{movie_id}'
if cache.set('movie_ticket_key', '1', nx=True):
movie = Movie.objects.get(id=movie_id)
movie.remaining_seat -= 1
movie.save()
따라서, 이를 통해 매 row마다 락이 잡혀 있지 않다면 락을 획득할 수 있다.
이 방법은 데이터를 수정할 때마다 version을 1 증가시키거나 updated_at을 현재 시각으로 갱신하는 방법이다.
그러면 다른 트랜잭션에서 동일한 버전으로 수정하려고 한다면 다른 트랜잭션은 version 충돌이 일어나 실패하게 된다.
def is_reservation_available(self):
updated = Movie.objects.filter(
id=self.id,
version=self.version,
).update(
remaining_seat=remaining_seat-1,
version=self.version+1,
)
if updated > 0:
return False
return True
이는 모델에 version
혹은 updated_at
필드를 추가하여 구현할 수 있다. 이 함수에서는 updated > 0일 경우 예약이 불가하므로 False를 반환하는 함수인데 해당 쿼리에는 version 필드가 들어가 있다. 한 트랜잭션이 해당 쿼리를 실행 중일 때 version을 수정하게 되며 이 때 다른 필드가 동일한 버전으로 수정하려 한다면 version 충돌이 일어나 트랜잭션 롤백이 수행된다.
이 글에서는 django-concurrency 라이브러리를 이용해 여기서 제공하는 version field를 사용하는 것도 알려주었다.
READ COMMITTED
이다.READ COMMITTED
, READ UNCOMMITTED
, REPEATABLE READ
, SERIALIZABLE
이 있는데 PostgreSQL에서 READ COMMITTED
와 READ UNCOMMITTED
는 동일하게 취급 된다. 따라서, PostgreSQL에는 3개의 격리 수준이 있는 것과 다름 없다.PostgreSQL의 트랜잭션 격리 수준 변경은 트랜잭션 블럭 내에서만 실행할 수 있다. 따라서, 다음과 같이 실행하면 된다.
<실행>
BEGIN;
SET transaction isolation level read committed;
<확인>
SHOW transaction isolation level;
--------------
read uncommitted
참고
1) 요기요 기술 블로그
3) 격리 수준 설명