Post

(Transaction) lost update problem (isolation level, deadlock, update lock)

포인트 읽기 - 포인트 차감 순으로 DB 작업이 발생하는 상황이었다.

1
2
3
4
5
6
7
8
@Transactional
public foo bar() {
    // SELECT
    MemberRestMileageInfo member = getMemberMileageByKey(memberKey);
    ...
    // UPDATE
    memberInfoMapper.update(member);
}

이런 상황에서 같은 memberKey 를 대상으로 빠르게 두 번 요청하면 갱신 손실 문제가 발생한다.

1
2
3
4
5
6
7
        T1                                                T2
start transaction                     
                                                start transaction
SELECT<point : 10000>                                          
                                                SELECT<point : 10000>
UPDATE<point : 5000>
                                                UPDATE<point : 9000>

즉, 먼저 들어온 트랜잭션이 UPDATE 하기 전에 다른 트랜잭션이 SELECT를 해버리면, 먼저 들어온 트랜잭션 T1의 UPDATE는 덮어써져서 사라져버린다.
그래서 두 트랜잭션이 동시에 실행되지 않도록 하기 위해, 처음에는 고립 수준(isolation level)을 SERIALIZABLE로 조정했다. 그러나 이는 deadlock을 유발했다.

트랜잭션 고립 수준을 SERIALIZABLE 로 설정했을 때 발생하는 deadlock 문제

SERIALIZABLE은 SELECT로 가져온 row에 대해 read lock을 걸어준다.
read lock만 가지고 있기 때문에, 이후 UPDATE를 만나면 write lock을 획득하려 시도한다.

read lock 걸려있는 row에 대해서, 타 스레드에서 read는 가능하고, write는 lock이 풀릴 때 까지 불가. pending하게 된다.

1
2
3
4
5
6
7
        T1                                        T2
start transaction                     
                                        start transaction
SELECT<read_lock_for_mem1-1 획득>                                          
                                        SELECT<read_lock_for_mem1-2 획득>
UPDATE<write_L1 획득하기 위해 read_lock_for_mem1-2 해제 대기>
                                        UPDATE<wirte_L2 획득 위해 read_lock_for_mem1-1 해제 대기. deadlock 발생>

http://www.gurubee.net/lecture/23962.가. Lock 종류 부분에 이러한 문제가 잘 나와 있다.

Deadlock 데드락

sol

위와 같은 문제를 해결하기 위해서는 고립 수준은 기본으로 두고, 처음에 SELECT 할 때 부터 read lock(shared lock) 대신 write lock(update lock)을 획득하면 된다.

write lock은 배타적 lock이므로, 어떤 트랜잭션이 실행 중이라면 다른 트랜잭션은 기존 트랜잭션이 lock을 release할 때 까지 pending 상태가 된다.

MySQL에서 update lock을 획득하기. FOR UPDATE

DB Lock이 걸리는 범위?

  • WHERE에서 key column(index)에 대해 거르면 해당 row에 대해서만 lock이 걸린다.
  • index가 걸려있다면, 선택한 row들만 lock이 걸린다.
  • index가 안걸려 있어 전체 테이블을 서치하는 경우 테이블 전체에 lock이 걸린다.

app 단 lock VS DB 단 lock

이런 문제를 DB단이 아니라 앱단에서 lock을 걸어서 해결하는 것도 가능하다.
주로 redis로 특정 key에 대해서만 lock을 거는 방식을 많이 사용하게 된다.

  • 보통 인메모리 Map<Key, Lock> 보다는 redis를 활용한다.
  • SELECT - UPDATE 작업이 어떤 Key와 관계된다면, redis를 사용해 같은 회원에 대한 동시 작업은 막고, 서로 다른 회원에 대한 동시 작업은 가능하게끔 만들 수 있다.
    • e.g., 회원 정보 업데이트같이 회원 키로 SELECT하고 해당 레코드를 UPDATE하는 상황
  • 여러 테이블에 걸쳐 일어나는 tx 작업에 사용 할 수 있다.
    • 물론 DB lock도 여러 테이블에 걸쳐 사용 가능하다.
    • 보통 tx 시작하면서 모든 관련 테이블의 관련 row에 update lock을 걸고 시작하는게 아니라, 접근하면서 차례차례 lock을 획득하게 되므로 이 것이 DB lock을 사용하면서 문제가 되지 않나 생각 들 수 있는데, 잘 생각해 보면 갱신 손실 같은 문제가 생기지 않는다.
  • API call 같이 DB와 무관한 작업에 대한 동시성을 제어 할 수 있어 활용도가 높다.
  • 구조가 심플한 것도 장점이다.

SELECT - UPDATE 로직의 무결성을 유지하자.

별도의 로직이 필요 없는 경우라면 다음과 한 쿼리로 바로 더해버려도 되지만…

1
UPDATE user_point SET user_point + :point WHERE id = 1;

SELECT 해서 뭔가를 가져오고 검증한 다음에 UPDATE를 해줘야 되는 경우, SELECT - UPDATE 사이에 읽어온 row가 DB에서 변경되지 않도록 보장해주어야만 한다.

PROPAGATIONISOLATION과는 다르다!
이는 트랜잭션 내에서 또 다른 트랜잭션을 만나는 경우 어떻게 처리할 것인지를 결정한다.

This post is licensed under CC BY 4.0 by the author.