본문 바로가기

0. 동시성 문제에 대해서

 

  RDBMS의 테이블에 동시에 여러 커넥션이 같은 테이블의 row를 수정하면 데이터가 예상과는 다르게 변경되는 문제가 생긴다. 이를 해결하기위해 RDBMS에서는 Lock을 통해서 동시성을 제어한다. MySQL에서 제공하는 Lock의 종류를 알아보자. 스토리지 엔진으로MyISAM이나 InnoDB를 사용하는데 스토리지엔진에 따라서 지원하는 Lock이 다르다.

 

  1. Lock에 대해서
  2. MyISAM Lock
    • Table Lock
    • Global Read Lock
    • User Lock
  3. InnoDb Lock
    • Record Lock
    • Gap Lock
  4. 서비스 애플리케이션레벨에서 동시성 테스트 해보기
    • repeatable read transaction isolation level
    • serializable transaction isolation level
    • row level lock
    • 정리

 

1. Lock에 대해서

  RDBMS의 테이블에 동시에 여러 세션이 같은 테이블의 row를 수정하면 데이터가 예상과는 다르게 변경되는 문제가 생긴다. 이를 해결하기위해 RDBMS에서는 Lock을 통해서 동시성을 제어한다. 트랜잭션도 결국 여러가지 Lock을 이용해서 ACID를 보장하는 원리이다.

 

 

2. MySQL에서 제공하는 Lock

 

  MySQL에서 제공하는 Lock의 종류를 알아보자. 이를 위해 테스트용 테이블을 한개 생성하자. 동시에 여러개의 세션에서 한개의 row를 수정하는 경우를 가정하기 위해서 영화관 좌석 테이블을 생성하자. 스토리지 엔진으로 MyISAM이나 InnoDB 를 사용하는데 스토리지엔진에 따라서 지원하는 Lock이 다르다.

 

 

MyISAM Lock

  아래 쿼리문은 Lock의 종류를 알아보기 위해서 사용하는 테이블이다. 먼저 MyISAM 스토리지 엔진을 사용하도록 테이블을 생성하자.

CREATE TABLE seat (
seat_id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id  INT,
seat_col VARCHAR(5),
seat_row INT,
price DECIMAL,
status VARCHAR(1)
) ENGINE=MyISAM;

insert into seat(event_id,seat_col,seat_row,price,status) values(1,'A',1,15000,0);

insert into seat(event_id,seat_col,seat_row,price,status) values(1,'A',2,15000,0);

 

 

Table Lock

 

 

  세션에서 테이블의 자원에 접근해서 데이터를 read/write할때 LOCK을 걸면 다른 세션에서는 LOCK을 획득하기 전까지는 테이블 자원에 접근하지 못한다. 테이블 단위로 READ LOCK과 WRITE LOCK이 있다.

 

 

READ LOCK (TABLE LOCK)

 

  첫번째 세션을 위해서 터미널을 열고 아래 쿼리를 실행해서 READ LOCK을 걸자

LOCK TABLES seat READ;

  두번째 세션도 마찬가지로 터미널을 열고 아래 쿼리를 실행해보자

UPDATE seat SET status = 1 WHERE seat_id = 1;

  아래와 같이 두번째 세션에서는 커넥션 타임아웃이 걸려서 쿼리를 정상적으로 실행하지 못하는것을 확인할 수 있다. TABLE READ LOCK을 걸면 다른 세션에서는 WRITE작업을 수행하지 못한다.

https://images.velog.io/images/seong-dodo/post/03318b9b-bfc9-472f-a984-0cf5e9c810f9/image.png

 

  첫 번째 세션에서 아래 쿼리를 실행해서 락을 해제해보자

UNLOCK TABLES;

  두 번째 세션에서 실패한 쿼리를 다시 실행해보자

UPDATE seat SET status = 1 WHERE seat_id = 1;

 

 

  테이블에 LOCK이 걸려있지 않기때문에 정상적으로 UPDATE를 수행할 수 있다. 테이블 단위로 LOCK을 걸기 때문에 다른 세션에서는 어떠한 row도 수정하지 못한다.

https://images.velog.io/images/seong-dodo/post/afaea3cc-c3d4-4110-91b2-294e8b90f380/image.png

 

 

WRITE LOCK (TABLE LOCK)

 

 

  테이블전체에 WRITE LOCK을 걸면 어떻게 되는지 확인해보자. 첫번째 세션에서 WRITE락을 걸자.

  LOCK TABLES seat WRITE;

 

 

  두번째 세션에서 SELECT를 수행해보자.

  SELECT * FROM seat;

 

 

  두번째 세션에서는 타임아웃이 걸려서 조회를 하지 못하는것을 확인할 수 있다.

https://images.velog.io/images/seong-dodo/post/70970167-b565-41c7-a618-46bc73797771/image.png

 

 

  두번째 세션에서 UPDATE를 실행시켜보면 SELECT와 마찬가지로 타임아웃이 걸린다. WRITE TABLE LOCK은 락을획득하지 못하면 read/write 하지 못한다. WRITE LOCK이 걸리면 다른 세션에서는 조회도 하지 못한다.

UPDATE seat SET status = 1 WHERE seat_id = 2;

 

Global Read Lock

 

 

  FLUSH TABLES명령어로 사용하면 된다. MySQL에서 제공하는 잠금 가운데 가장 범위가 크다. MySQL 서버 전체에 락을 걸어버린다. 다른 세션에서는 SELECT를 제외하고는 LOCK을 해제하기 전까지 무조건 대기하고 있는다.

첫번째 세션에서 아래와같이 Global Read Lock을 걸어보자

 

  FLUSH TABLES WITH READ LOCK;

 

 

  두번째 세션에서는 아래와같이 SELECT,UPDATE를 수행해보자. READ는 가능하지만 WRITE는 불가능하다.

update seat set status = 1 where seat_id = 2;

select * from seat;
  UNLOCK TABLES;

 

 

User Lock

 

 

  사용자 레벨에서 락을 거는 방법이다. 락의 이름과 타임아웃을 지정할 수 있다. 첫번재 세션에서 아래 쿼리를 실행시켜보자

select GET_LOCK('seat_lock',20);

 

 

  아래와 같이 lock을 획득한 것을 확인할 수 있다. 조회된 결과로 1이 나온다.

https://images.velog.io/images/seong-dodo/post/35808376-ad20-41d6-a04b-17741e24e3ec/image.png

 

 

  두번째 세션에서 똑같은 쿼리를 실행시켜보자. 20초동안 대기하고 20초후에 아래와같은 결과가 나온다. 첫번째 세션에서는 1이나왔지만 두번째 세션에서는 0이 나온것을 확인할 수 있다.

https://images.velog.io/images/seong-dodo/post/ae85f9a7-b1ce-4489-a260-3d4b1a93a4c0/image.png

 

 

 

  첫번째 세션에서 아래쿼리를 실행시켜서 잠금을 해제시켜보자. SELECT RELEASE_LOCK('잠금이름')으로 잠금을 해제할 수 있다. 만약, 잠금이 없는데 해제하면 null이 반환되고 잠금이 있는데 해제한경우에는 1이 반환된다.

 

 SELECT RELEASE_LOCK('seat_lock')

https://images.velog.io/images/seong-dodo/post/489d43c7-4505-428e-be00-0548d88217e2/image.png

 

 

 

 

  아래 그림은 존재하지 않는 lock을 해제한 결과이다.

https://images.velog.io/images/seong-dodo/post/596bf25a-cb36-4711-97a4-fab6109610cf/image.png

 

 

  첫번째 세션에서 잠금을 해제하였기 때문에 두번째 세션에서 정상적으로 잠금을 획득할 수 있다.

select GET_LOCK('seat_lock',20);

https://images.velog.io/images/seong-dodo/post/001f8e13-2134-4926-974a-5477d5daffa5/image.png

 

 

 


 

InnoDb Lock

 

 

  스토리지 엔진을 InnoDB로 교체해서 테스트해보자. InnoDb는 여러 트랜잭션이 경합하는 상황에서 성능을 올리기 위해서 여러가지 lock을 조합해서 사용한다.

DROP TABLES seat;

CREATE TABLE seat (
seat_id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id  INT,
seat_col VARCHAR(5),
seat_row INT,
price DECIMAL,
status VARCHAR(1)
) ENGINE=InnoDB;

insert into seat(event_id,seat_col,seat_row,price,status) values(1,'A',1,15000,0);

insert into seat(event_id,seat_col,seat_row,price,status) values(1,'A',2,15000,0);

 

 

먼저 ,S lock과 X Lock에대해서 간단하게 알아보자.

 

 

  • Shared lock(S)
    • SELECT 위한 READ LOCK
    • S lock걸려있는동안 다른 트랜잭션에서는 X락 획득 불가능하지만 S락은 획득가능
    • 한개의 row에 대해서 읽기는 가능하지만 쓰기는 불가능

 

  • Exclusive lock(X)
    • UPDATE,DELET위한 WRITE LOCK
    • X lock이 걸려있으면 다른 트랜잭션에서는 쓰기,읽기 모두 불가능

 

 

Record Lock

 

 

  인덱스 레코드에 락을 거는 방식이다. PK,Unique Index로 조회해서 하나의 row에만 Lock을 건다. id=2인 row에 S lock이 걸린다. 동시에 실행되는 다른 트랜잭션에서는 조회는 가능하지만 수정은 불가능하다

SELECT seat_id,status FROM seat WHERE seat_id = 2 LOCK IN SHARE MODE;

  id=2인 row에 X lock이 걸린다. 동시에 실행되는 다른 트랜잭션에서는 조회와 수정 모두 불가능하다.

SELECT seat_id,status FROM seat WHEREseat_id = 2 FOR UPDATE

  첫번째 세션에서 트랜잭션을 만들어보자

START TRANSACTION;
SELECT seat_id,status FROM seat WHERE seat_id = 2 LOCK IN SHARE MODE;

  두번째 세션에서도 트랜잭션을 만들어보자

START TRANSACTION;

  조회는 정상적으로 된다.

SELECT * FROM seat where seat_id = 2;

  첫번째 세션에서 S Lock을 가지고 있기때문에 쓰기는 불가능하다. 첫번째 세션에서 commit을 하면 두번째 세션에서 쓰기가 가능하다. (잠금을 가지고 있는 트랜잭션에서 커밋하거나 롤백해야 잠금해제)

UPDATE  seat SET status = 1 WHERE seat_id = 2;

  마찬가지로, FOR UPDATE를 통해서 X LOCK을 획득하면 다른 트랜잭션에서는 조회/쓰기 모두 불가능한것을 확인할 수 있다.

START TRANSACTION;
SELECT seat_id,status FROM seat WHEREseat_id = 2 FOR UPDATE;

  두번째 세션에서는 락획득을 기다리다가 타임아웃 발생

START TRANSACTION;
SELECT * FROM seat where seat_id = 2;

 

 

Gap Lock

 

 

  지정된 범위에서 인덱스 사이의 gap에 락을 거는 방식이다. gap lock은 지정한 범위에 새로운 레코드가 생성되는것을 방지할 수 있다.

첫번째 세션에서 Gap Lock을 걸어보자

START TRANSACTION;
SELECT * FROM seat where seat_id BETWEEN 1 AND 10 LOCK IN SHARE MODE

 

 

  두번째 세션에서는 새로운 레코드를 삽입하지 못한다. (PK값인 seat_id가 10보다 크면 삽입 가능)

START TRANSACTION;
insert into seat(event_id,seat_col,seat_row,price,status) values(1,'A',3,15000,0);

 


 

3. 서비스 애플리케이션레벨에서 동시성 문제 테스트 해보기

 

 

  실제 예약테이블을 만들어서 테스트해보자. 테스트 도구는 Ngrinder를 사용하였다. 동시성 문제를 테스트 해보기 위한 애플리케이션은 좌석예매 애플리케이션이고 좌석 예매의 flow는 아래와 같다.

 

  1. 예약되지 않은 좌석을 확인하고 해당 좌석으로 예약을 시도한다.
  2. 서버에서 해당 좌석이 예약되어 있는지 확인한다.
  3. 예약되어 있으면 예약 fail
  4. 예약되어 있지 않으면 예약 booking table에 insert

 

 

  아래 Groovy로 작성된 코드는 Ngrinder로 부하테스트를 진행하기 위한 부하테스트 시나리오 코드이다.

import HTTPClient.HTTPResponse
import HTTPClient.NVPair
import ch.qos.logback.classic.Level
import net.grinder.plugin.http.HTTPPluginControl
import net.grinder.plugin.http.HTTPRequest
import net.grinder.script.GTest
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
import org.junit.Test
import org.junit.runner.RunWith
import org.slf4j.LoggerFactory

import static net.grinder.script.Grinder.grinder
import static org.hamcrest.Matchers.is
import static org.junit.Assert.assertThat

@RunWith(GrinderRunner)
class LoginDemo {
    public static GTest test
    public static HTTPRequest request

    @BeforeProcess
    public static void beforeProcess() {
        HTTPPluginControl.getConnectionDefaults().timeout = 6000
        test = new GTest(1, "http://192.168.219.159:8081")
        request = new HTTPRequest()
        test.record(request);
        grinder.logger.info("before process.");
    }

    @BeforeThread
    public void beforeThread() {

        LoggerFactory.getLogger("worker").setLevel(Level.ERROR)
        grinder.statistics.delayReports = true;
        grinder.logger.info("before thread.");

    }

    private NVPair[] headers() {
        return [
                new NVPair("Content-type", "application/json;charset=UTF-8")
        ];
    }

     @Test
     public void test() {
         int id = Math.abs(new Random().nextInt()) % 100 + 1
         test1(id)
     }

     public void test1(id) {

        def json = '{"userId":' + id + ',"eventId":1,"seatIdList":[1]}';

        HTTPResponse result = request.POST("http://192.168.219.159:8081/reservation", json.getBytes(), headers());
        grinder.logger.info(result.getText());
        grinder.logger.info(result.getHeader("Content-type"));

        if (result.statusCode == 301 || result.statusCode == 302) {
            grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", result.statusCode);
        } else {
            assertThat(result.statusCode, is(200));
        }
    }

}

 

 

 

아래와 같이 ngrinder를 세팅하고 테스트를 돌려보자.

 

 

https://blog.kakaocdn.net/dn/v3Nd1/btqW4hj7IVz/nSkXiy1ywA7JnjYV8Gywk0/img.png

 

 

 

REPEATABLE READ ISOLATION LEVEL

 

 

  먼저 Transaction Isolation Level을 기본값은 Repeatable Read로 설정하고 테스트해보자. 동시에 100명의 유저가 1번 좌석을 예약하려고 시도하는 상황이다. 최종 예약현황에서 같은 자리에 대해서 여러개의 사용자가 예매를 성공한 것을 확인할 수 있다.

 

  Repeatable Read는 트랜잭션이 시작되고 종료되기 전까지 한 번 조회한 값은 계속 조회되는 격리 수준이다. 동시에 여러개의 트랜잭션이 시작되면 1번 좌석의 상태가 “예약가능“인 상태로 조회가 될것이다. “예약가능“인 상황에서는 reservation 테이블에 예약현황을 insert가능 하기 때문에 이런 현상이 생기게 된것이다.

 

 

https://blog.kakaocdn.net/dn/owLC6/btre9FPwcgE/6wWY3khdmgbpPGahqkksPK/img.png

 

 

SERIALIZABLE ISOLATION LEVEL

 

  트랜잭션 격리수준을 SERIALIZABLE로 올려서 위에서 발생항 동시성 문제를 해결해보자. 아래 그림처럼 정확하게 5개의 예약만 성공하는 것을 확인할 수 있다.

 

  SERIALIZABLE 레벨에서는T1에서 쿼리가 실행될때 SELECT문에 대해서는 자동으로 LOCK IN SHARE MODE를 붙인다. 즉 ,T1과 T2가 좌석의 예약현황을 조회할때 S락을 걸어버리고 T1과 T2가 reservation 테이블에 insert시키고 해당 좌석의 예약상태를 완료로 변경할때 X Lock을 걸려고 시도하는데 T1이 S락으로 해당 row을 점유하고 있고 T2또한 S락으로 해당 row를 점유하고 있기때문에 T1,T2는 서로 S락을 소유하고있지만 X락을 획득하려고 하면서 데드락 상태에 빠지고 timeout으로 두개의 트랜잭션은 모두 실패하게 된다. 즉, T1,T2가 동시에 실행되지 않는 경우에만 예약에 성공하고 동시에 실행되는 경우에는 데드락 상태에 빠져서 예약에 실패하는 것이다.

 

 

https://blog.kakaocdn.net/dn/dV4EMw/btrfarKbvN1/cpAgO1K4IIwe9VftNkKdSK/img.png

 

 

Row Lock

 

 

  다른 방법은 잔여좌석을 조회할때 SELECT .. FOR UPDATE 문을 이용해서 해당 row에 lock을 거는 방법이다. FOR UPDATE문을 걸어서 좌석 테이블의 해당 row에 X락을 걸어버려서 다른 트랜잭션은 조회하지 못하고 기다리게 만든다. 즉, Seriazable레벨에 비해서 데드락은 줄일 수 있다. 하지만, FOR UPDATE문은 타임아웃 구성이 까다롭기 때문에 무한 대기에 빠질수 있다.

 

 

https://blog.kakaocdn.net/dn/dV4EMw/btrfarKbvN1/cpAgO1K4IIwe9VftNkKdSK/img.png

 

 

정리

 

 

  서비스의 규모가 작고 트래픽이 적지만 동시성 문제를 반드시 해결해야한다면 SERIAZABLE레벨로 해결이 가능할것이다. 하지만, 트래픽이 많아지게되면 자연스럽게 데드락이 빈번하게 말생하게 될것이다.

 

  데드락이 발생하게 되면서 데드락이 풀리기전까지 데드락에 걸린 커넥션 스레드는 DB Connection Pool로 들어가지 못하고 데드락이 풀릴때까지 기다리고 있을것이다. 즉, 전체 DB Connection Pool에서 일부 스레드가 놀고있는것이다. DB Connection Pool이 모자라게 되면서 자연스럽게 응답지연이 발생하고 응답지연이 발생하면서 서버 소켓이 모자라는 현상까지 발생할것이다.

 

  위의 예시에서 확인한것 처럼 트랜잭션의 ACID는 RDBMS에 의해서 완전하게 보장될 수 있다. 하지만, ACID를 엄격하게 지키는 경우 동시성이 떨어지는 문제를 가진다. 트랜잭션 격리수준을 최고레벨로 올리거나 S Lock, X Lock을 걸어서 해결하더라도 트랜잭션의 수행시간이 길어지고 특정 레코드에 락이 오래걸리면서 다른 트랜잭션이 원할하게 수행되지 못하거나 데드락이 많이 발생한다.

 

  따라서, 서비스의 확장성을 고려한다면 RDBMS에 락을 걸기보다는 Redis를 활용해서 분산락을 거는 방법을 고려해야 한다. Redis의 운영이 부담스럽다면 MySQL에서 제공하는 User Level Lock을 활용해서 분산락을 거는 방법도 고려할 수 있다.

 

  시스템이 봉착한 문제를 해결할때는 기술을 위한 기술보다 현재 운영중인 서비스의 상황과 규모에 적합한 기술을 선택하는것이 중요하다.

댓글