TIL/개발 칼럼

#6 타행 이체 기능 성능 개선기, 속도(gap lock으로 인한 insert 병목 해결)

김민석(갈레, 페퍼) 2023. 4. 20. 17:33
반응형

목차

1. 간단 요약

2. 사전 지식

3. 문제 상황

4. 접근 방법과 해결 과정

  • Gap lock 분석
  • 현 설계의 문제점
  • 로컬 테스트로 검증
  • 개선 방향
  • 성능 테스트

5. 결과

6. 배운점


✍️ 간단 요약

 OutBox 데이터를 polling 하는 스케줄러가 읽기 연산을 할 경우, connection은 (마지막 레코드, 무한대) 범위에 gap lock을 겁니다(MySQL ver 8.0 innodb, Repeatable-read 기준). 그로 인해 이체 출금의 insert 쿼리가 병목이 되어 TPS가 낮게 나왔었습니다. Read Committed로 변경한 결과, gap lock의 해제를 기다리지 않고 insert할 수 있게 됐습니다. 이는 Mean Test Time 감소로 이어져 tps를 86%(66.6 → 124.2) 향상시킨 결과로 이어졌습니다.


✍️ 사전 지식

용어

  • 당행 : 사용자로부터 이체 요청을 받고, 이체 입금을 진행하는 은행 
  • 타행 : 이체 과정에서 출금을 진행하는 은행
  • 당행 이체 : 입금 은행과 출금 은행이 동일한 이체
  • 타행 이체 : 입금 은행과 출금 은행이 다른 이체
  • OutBox 데이터 : 이벤트나 메시지를 DB에 있는 아웃박스에 저장해서 DB 트랜잭션의 일부로 발행하는 패턴을 Transactinal OutBOx라고 합니다. 서로 다른 서버의 트랜잭션을 관리하고 할 때, eventual consistency를 보장하기 위해 DB 테이블을 메세지 큐로 사용하는 방식을 사용합니다. 이때 DB 테이블에 있는 이벤트 데이터를 OutBox 데이터라고 본 글에선 칭했습니다.
  • OutBox polling : DB에 있는 아웃박스를 폴링해서 메시지를 발행하는 방식입니다. Transactional OutBox 패턴에 사용되는 Polling 발행기 패턴입니다.
Transactional OutBox 패턴 Ref : https://thebook.io/007035/0185/
Polling 발행기 패턴 Ref : https://thebook.io/007035/0186/

 

테스트 환경

  • 구체적인 타행 이체 상황 : 타행 이체 중에서도 계좌 A에서 계좌 B로 연속 이체하는 상황을 테스트 했습니다. 주식 트레이드 시스템 처럼 단위 시간 동안 수많은 타행 이체가 일어나는 상황을 가정했습니다.

✍️ 문제 상황

제가 설계하고 구현한 코드가 예상되는 변경 상황에도 적절히 동작하는지 확인하는 과정에서 문제를 인식했습니다. 문제 인식 당시에는 gap lock에 대해 잘 몰랐었음을 감안하고 읽으시길 바랍니다.

 

 현재 타행 이체 API에서는 이체 입금 요청 이벤트를 적재합니다. 해당 이체 입금 요청은 스케줄러가 polling 방식으로 event를 db에서 긁어온 다음 타행에 http 요청을 보내는 방식으로 진행됩니다. 스케줄러가 select * from outbox_table for update 쿼리로 이벤트를 db에서 가져오는데, 이때 gap lock이 발생하게 됩니다. 해당 gap lock은 was가 하나일 때는 상관이 없지만, 스케일 아웃을 한 상황에선 문제가 될 것으로 예상했습니다. 주변 동료와 이야기를 나눈 결과, 제가 gap lock에 대해 제대로 모르고 있겠다고 결론 내렸고, 저의 코드를 통제하고 있지 못하다고 판단했습니다. 이에 gap lock에 대해 먼저 학습을 시작했습니다.

 

걱정한 상황 예시)

  1. was1,2가 있는 상황에서 was1,2가 OutBoxTable에 select for update 쿼리를 실행.
  2. was1 서버가 다운되어 ID1,2,3의 lock이 풀림.
  3. was1 서버가 복구된 후 ID 1,2,3…7,8,9에 걸쳐서 lock을 잡음.

 

<그림1. was1,2가 select for update 진행>
<그림2. was1의 서버가 다운됨>
<그림3. was1이 다시 select for update 진행>


✍️ 접근 방법과 해결 과정

Gap lock 분석

환경

  • MySQL 8.0 innodb
  • Transaction isolation = REPEATABLE_READ
  • binlog_format = row

gap lock이란?

갭락은 인덱스 레코드 사이의 ‘gap’에 거는 락 혹은 첫번째 인덱스 이전 또는 마지막 인덱스 레코드 이후의 ‘gap’에 거는 락입니다.

A gap lock is a lock on a gap between index records, or a lock on the gap before the first or after the last index record. For example, SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;prevents other transactions from inserting a value of 15into column t.c1, whether or not there was already any such value in the column, because the gaps between all existing values in the range are locked.
Ref. https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html#innodb-gap-locks

 

왜 존재하는가?

갭락은 “순전히 억제” 만을 위해 존재합니다. 다른 트랜잭션이 gap에 insert하는 것을 막기 위해서죠. 이에 gap lock은 다른 gap lock과 호환이 됩니다. gap lock위에 gap lock을 덮어 씌울 수 있는 것이죠.

 

gap lock의 특성

  • “순전히 억제”만을 위해 존재(다른 트랜잭션의 gap 내 insert 방지)
  • gap lock과 호환o
Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from inserting to the gap. Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function.
Ref. https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html#innodb-gap-locks

 

Gap lock 확인

select * from outbox_table for update 쿼리 실행시 gap lock은 어디에 걸리는지 알기 위해 아래 퀴리로 확인해보겠습니다.

1. CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;

2. INSERT INTO child (id) values (90),(102);

3. select * from child;

4. START TRANSACTION;

5. SELECT * FROM child FOR UPDATE;

6. SELECT ENGINE_TRANSACTION_ID, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;

7. rollback;

 

1,2,3번은 간단하니, 실행 했다고 가정하고 넘어가도록 하겠습니다. 4번을 실행하면 현 세션에서 트랜잭션을 시작할 것입니다. 이후 5번을 실행하면 아래와 같은 결과가 나옵니다. where 절에 조건이 없으니 모든 것을 다 읽어옵니다.

<그림4. select for update>

 

 

어떤 lock을 잡았는지 확인하기 위해 6번을 실행해봤습니다. 결과를 보면, 2번째 row에 supremum pseudo-record라는 LOCK_DATA가 있습니다. 이는 (마지막 레코드, 무한대)까지의 gap에 lock을 건다는 뜻입니다. 3,4번째 row의 LOCK_MODE는 X라고만 돼있지만, 실은 row에 Record Lock을 잡고 (이전 레코드,LOCK_DATA] 영역에 gap lock을 잡는다는 뜻입니다.

<그림5. select for update시 lock 상태>

For the last interval, the next-key lock locks the gap above the largest value in the index and the “supremum” pseudo-record having a value higher than any value actually in the index. The supremum is not a real index record, so, in effect, this next-key lock locks only the gap following the largest index value.
Ref. https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html

 

 실제로 gap lock을 안잡는다면 LOCK_MODE에 (X,REC_NOT_GAP)으로 나옵니다.(Transaction isolation을 Read Committed로 잡은 후 select for update 쿼리를 날린 결과입니다)

<그림6. select for update without gap lock>

 


현 설계의 문제점

 현 설계에서 스케줄러는 select for update로 이체 입금 요청 이벤트 데이터를 읽어 옵니다. 이때 where로 범위가 지정 돼지 않았기 때문에 (마지막 레코드, supremum pseudo-record=무한대)로 gap 락이 잡힙니다. 스케줄러가 select for update로 gap락을 잡은 상황에서 타행 이체 요청이 들어와 이체 입금 요청 이벤트를 적재하려고 하면 insert 구문에서 block이 일어납니다. Insert로 인해서 발생하는 Insert Intention lock이 gap lock에 의해 block되기 때문이죠. 결국 현 설계는 gap lock으로 인해 이체 요청에 병목이 생기는 문제점이 있었습니다.

<그림7. 타행 이체 순차 다이어그램>

 


로컬 테스트로 검증

실제로 병목이 생기는지 테스트 해봤습니다. 테스트 순서는 다음과 같습니다.

  1. 앱 실행, OutBox polling 스케줄러 Thread.sleep(). (스케줄러의 로직 처리가 오래 걸리는 상황 가정)
  2. 타행 이체 요청 실행

OutBox polling 스케줄러 코드

 

 

테스트 코드에선 앱 실행 후 스케줄러는 select * from outbox_table for update 쿼리를 날린 뒤 Thread.sleep을 합니다(스케줄러의 로직 처리가 오래 걸리는 상황 가정). 이때 MySQL의 lock 상황을 보면 supremum pseudo-record이 있습니다. 즉 (마이너스 무한대, 플러스 무한대)까지 gap lock이 걸린거죠.

 

 

이때 타행 이체를 Postman으로 진행해봤습니다. 아래 그림과 같이 ‘Sending request..’ 만 뜨고 응답이 바로 오지 않습니다.

이때 MySQL의 lock 데이터를 한번 더 살펴보면 5번째 row의 LOCK_MODE가 (X,INSERT_INTENTION)이며 LOCK_STATUS는 WATING입니다. 즉 select for update로 인해 잡힌 gap lock으로 인해 INSERT_INTENTION이 block되는 상황입니다. 문제 상황을 검증했으니 해결해보겠습니다.

 


개선 방향

 Insert 쿼리가 block되는 상황은 결국 gap lock으로 인해 발생했습니다. 그렇다면 반대로 gap lock이 없다면 Insert 쿼리가 block되는 일은 없겠죠. gap lock을 없애는 방법은 transaction_isolation=READ_COMMITTED로 변경하는 것입니다. READ_COMMITTED 상황에선 gap lock 없이 (X,REC_NOT_GAP)만 LOCK_MODE에 존재하기 때문이죠.

READ_COMMITTED로 변경해도 OutBox polling 로직에 문제가 없을지 확인했습니다. gap lock은 transaction_isolation=REPEATABLE_READ에서 Phantom Read를 방지하기 위해 존재하지만, polling 로직은 Phantom Read를 방지할 필요가 없습니다. 심지어 REPEATABLE_READ를 보장하지 않아도 됩니다. polling 로직 안에서 select for update를 두 번 할 일은 없기 때문이죠.

By default, InnoDB operates in REPEATABLE READ transaction isolation level. In this case, InnoDB uses next-key locks for searches and index scans, which prevents phantom rows (see Section 15.7.4, “Phantom Rows”).

Gap locking can be disabled explicitly. This occurs if you change the transaction isolation level to 
READ COMMITTED. In this case, gap locking is disabled for searches and index scans and is used only for foreign-key constraint checking and duplicate-key checking.
Ref. https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html#innodb-gap-locks

 

실제로 READ_COMMITTED에선 LOCK_MODE가 (X,REC_NOT_GAP)으로 작동합니다. 즉 select for update가 일어났더라도 insert가 일어날 수 있죠.

 


성능 테스트

Repeatable read

<결과>

  • tps : 66.6
  • 이체 처리 평균 시간 0.145초
  • MTT : 1523ms

 

Read Committed

<결과>

  • tps : 124.2
  • 이체 처리 평균 시간 0.696초
  • MTT : 823ms


✍️ 결과

 예상한 대로 스케줄러가 OutBox를 읽어올 때 (마지막 레코드, 무한대) gap lock을 걸기에 insert 병목 현상이 일어나서 TPS가 낮게 나왔었습니다. Read Committed로 변경한 결과, MTT를 줄일 수 있어서 tps를 86%(66.6 → 124.2) 향상 시켰습니다.

 


✍️ 배운점

  1. OutBox를 폴링하는 상황에서 gap lock으로 인해 발생할 수 있는 이벤트 insert의 병목 문제는 고립 수준을 Read Committed로 변경하여 해결할 수 있습니다.
  2. DB의 default transaction-isolation이 답이 아님. 비즈니스 로직에 따라 read-committed가 더 좋은 성능을 낼 수 있습니다.
  3. 내가 작성한 로직이 db에서 어떤 락을 잡고 다른 어떤 문제를 발생할 수 있는지 유의하는 습관을 가지도록 노력해야겠습니다.
반응형