문제
대기열 구현을 마친 후 jmeter로 동시에 1000명의 유저가 대기열에 등록했을 때 각 유저의 발권번호가 중복으로 겹치는 동시성 이슈가 발생하였다.
현재 대기열에 등록하는 로직은 다음과 같다.(테이블의 경우 발권번호 테이블, 웨이팅 테이블이 존재)
- 발권번호 테이블 컬럼
- storeId(가게 아이디)
- waitingNumber(발권번호) -> 은행의 대기번호와 같은 개념
- 웨이팅 테이블
- userId(웨이팅 등록 유저의 아이디)
- storeId(등록할 가게의 아이디)
- orderNum(순서)
- 유저가 대기열에 등록 요청을 보낸다.
- 발권번호 테이블에서 해당 가게의 발권번호가 존재하는지 검사
- 발권번호가 있다면 대기열 번호를 저장해두고 1을 증가시킨다.
- 발권번호가 없다면 새로 생성한다.
- 대기열 테이블에 가게 아이디와, 유저 아이디, 저장했던 대기열 번호로 엔티티들 생성해서 저장한다
현재 결과는 다음과같다.
- Test 파라미터
- userId: 1
- storeId: 1
- ThreadCount: 1000
- Cycle: 1
Jmeter
웨이팅 테이블
order_num에 대한 동시 접근으로 해당 값이 올바르게 증가되지 않고, 웨이팅에 중복 order_num을 할당하는 상황
발권번호 테이블
기대했던 결과인 컬럼은 1개에 store_id: 1 , waiting_number가 1001이 아닌 상황
시도
1차
waiting_number를 처리하는 메서드에 synchronized 사용
public synchronized WaitingQueueResponse.Info addUserToQueue(AuthUser authUser, long storeId) {
List<WaitingNum> byStoreId = waitingNumRepository.findByStoreId(storeId);
WaitingNum waitingNum = null;
if(byStoreId.isEmpty()) {
waitingNum = waitingNumRepository.save(TestWaitingNum.builder()
.storeId(storeId)
.waitingNumber(1)
.build());
} else {
waitingNum = byStoreId.get(0);
}
Waiting build = Waiting.builder()
.userId(authUser.getUserId())
.storeId(storeId)
.orderNum(waitingNum.getWaitingNumber())
.build();
waitingRepository.save(build);
waitingNum.increase();
return new WaitingQueueResponse.Info(waitingNum.getWaitingNumber() - 1, authUser.getUserId());
}
결과
중복이 조금 줄어들었지만 해결하지는 못했다. 왜냐하면 synchronized메서드에 Transactional 어노테이션이 적용되어 있는데 @Transactional은 AOP로 구현되어있다. 무슨말이냐하면, 해당 메서드를 감싸서 메서드를 실행시키고 그 다음 commit을 하도록 돼있다. 따라서, commit()이 일어나는 시점에 동시성 이슈가 발생할 수 있다는 것이다.
{
method.invoke(~); // 이 부분이 synchronized가 적용되는 부분
commit(); // 이 때 db에 커밋
}
2차
synchronized로 처리가 안되는 것을 알고 DB Lock을 써보기로 했다. 낙관적 락과 비관적 락 중에 비관적 락을 사용했다. 이유는, 대기열이며 충돌할 일이 많아 낙관적 락을 사용한다면 많은 재시도가 발생해 성능이 하락할 것이라 판단돼서 비관적 락을 사용하기로 했다.
1차 결과
Jmeter
발권번호 테이블
웨이팅 테이블
비관적 락을 사용해서 발권번호 테이블에 같은 storeId로 중복해서 생기는 문제는 해결이 됐지만, 이번에는 1000개가 아닌 992개가 생성됐고, 발권번호도 1001이 아니였다.
문제를 해결하기 위해 show engine innodb status 명령어로 로그를 확인해보았다.
LATEST DETECTED DEADLOCK
------------------------
2024-10-30 17:21:12 0x6a48
*** (1) TRANSACTION:
TRANSACTION 2555202, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 1522, OS thread handle 26464, query id 112185 localhost 127.0.0.1 root update
/* insert for com.projectw.domain.waiting.test.TestWaitingNum */insert into test_waiting_num (store_id,waiting_number) values (1,1)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 9135 page no 4 n bits 72 index PRIMARY of table template.test_waiting_num trx id 2555202 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9135 page no 4 n bits 72 index PRIMARY of table template.test_waiting_num trx id 2555202 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (2) TRANSACTION:
TRANSACTION 2555203, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 1523, OS thread handle 21844, query id 112183 localhost 127.0.0.1 root update
/* insert for com.projectw.domain.waiting.test.TestWaitingNum */insert into test_waiting_num (store_id,waiting_number) values (1,1)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 9135 page no 4 n bits 72 index PRIMARY of table template.test_waiting_num trx id 2555203 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9135 page no 4 n bits 72 index PRIMARY of table template.test_waiting_num trx id 2555203 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** WE ROLL BACK TRANSACTION (2)
확인을 해보니, 1번 트랜잭션과 2번 트랜잭션이 storeId와 waiteing_number를 동일한 값으로 삽입하려고 하면서 두 트랜잭션이 서로의 락을 기다리며 데드락이 발생했다. 그 후 트랜잭션을 롤백해서 데드락을 해결한 걸 볼 수 있었다.
이를 해결하기 위해 발권번호 테이블에 아래와 같이 미리 생성해줘서 insert를 안하게 변경했다.
이를 해결하기 위해 아래와 같이 미리 생성해줘서 insert를 안하도록 변경했다.
id | storeId | waiting_number |
1 | 1 | 1 |
문제 처리 후 결과
Jmeter
발권번호 테이블
웨이팅 테이블
3차
동시성은 해결됐지만 웨이팅 테이블과 발권번호 테이블을 사용하지 않기로 했다. 이유는
- 웨이팅 테이블을 가지고 있을 필요가 없을 것 같다.
- 웨이팅은 사람들이 가볍게 웨이팅을 걸고 취소할 수 있으므로, 빈번한 DB 접근이 일어나 성능에 악영향을 줄 수 있다고 판단했다.
따라서, RDB의 대안으로 Redis를 사용하기로 했다.
레디스를 사용하는 이유는, 레디스는 싱글 스레드로 동작하기 때문에 대기열에 등록/해제 할 때 Lock처리를 따로 안해도 순차적으로 처리할 수 있다.
Redis를 사용해서 웨이팅 대기열을 처리했을 때 예상되는 문제점은 Redis는 인메모리 DB이기 때문에 데이터가 휘발된다는 점이다.
결과
종합
동시성 이슈를 해결한 후 DB Lock으로 해결했을 때 Redis로 해결 했을 때 성능 차이를 비교해봤다.
먼저 사용툴은 jmeter로 테스트를 했고, 설정은 Thread: 1000, Ramp-up period:1, Cycle:30으로 설정 후 진행했다.
DB Lock
레디스
레디스가 응답시간에서 약 30.8% 처리률에서 43.2% 빠른 것을 알 수 있었다.
레디스 말고 어떤 방법을 생각해볼 수 있을까?
redis로 구현을 하고 보니 다른 방법으로도 구현해볼 수 있겠다는 생각이 들었다. 만약 서버를 스케일아웃하지 않는다면 쓰레드 세이프한 자료구조 혹은 자바의 Lock과 Min heap 자료구조로 구현해볼 수 있을 것 같다. 만약 스케일 아웃을 할 가능성이 있는 상황이라면 레디스 말고 다른 메세지 큐, RabbitMq, Aws SQS나 카프카를 사용해서 구현해볼 수 있을 것 같다!
Redis 서버에 문제가 생겨서 다운될 시 메모리 휘발은 어떻게 해결 할 것 인가?
위 문제는 결론부터 말하면 AOF로 해결할 것이다. 현재 프로젝트의 레디스 서버는 클러스터로 구성되어있고, 이 클러스터는 세 개의 마스터와 세 개의 레플리카 노드로 구성돼있다. 마스터가 노드가 다운 시 레플리카 노드를 마스터로 승격시키게 failover처리를 해놨다. 또한, AOF 설정도 해놓아서 명령어들을 모두 저장해놓고 있다. 따라서, 혹시라도 레디스 서버가 다운되면 1차적으로 슬랙에 알림을 보낸 후, 다운된 레디스를 복구 시키면 해결할 수 있다. 하나의 마스터가 죽더라도 클러스터가 작동하고 있다면 읽기 쓰기 모두 다 할 수 있다.
'트러블 슈팅' 카테고리의 다른 글
MockMvc 테스트 코드 작성중 문제 (0) | 2024.09.10 |
---|---|
프로젝트 중 문제.. (0) | 2024.09.03 |
JPA 문제 (0) | 2024.08.20 |
깃 커밋 메세지 깨짐 (0) | 2024.08.11 |
CORS 너는 누군데!! 나를 괴롭히냐.. (0) | 2024.07.18 |