Phase 2 : Redis 전략 적용 + 분산락 버그 해결
배경
- 기존에 Redis 분산락을 적용한 코드가 잘 적용이 되었던건지 검증을 위해 이전 게시글에서 200VU 이후에 지연이 생겼던 문제가 있었는데 과연 개선이 되었는지 체크를 위해 500vu로 테스트를 돌려봤다.
문제
- 근데 테스트 결과는 충격적이었다.
| 지표 | DB-Only(Phase1) | Redis 적용(기존코드) | 차이 |
|---|---|---|---|
| TPS | 590 | 260 | 56% 감소 |
| p50(ms) | 573ms | 1,530ms | 2.7배 악화 |
| p95(ms) | 1,040ms | 1,960ms | 1.9배 악화 |
| p99(ms) | 1,370ms | 2,580ms | 1.9배 악화 |
| 정합성 | 👌 | 👌 | 동일 |
| 에러율 | 0.0% | 0.0% | 동일 |
- Redis를 붙였더니 오히려 더 느려져있었다…
- 1년 6개월 전의 나는 무슨 짓을 한걸까?
- 원인이 뭔지 찬찬히 찾아보자
의심1 : 이중 잠금
- 기존에 코드를 구현할 때 점진적으로 발전에만 초점을 맞춰서 DB단에 비관적 락 -> Redis 분산락 적용할 때 DB단에 비관락을 제거하지 않아 이중으로 락을 걸었던 문제가 있었다.
1 | |
-
Redis라는 별도의 캐시를 도입을 하긴 했지만 여전히 DB가 커밋전가지 락을 유지하기 때문에 개선이 되지 않고 오히려 +가 된것이다..
-
해결
@Lock(LockModeType.PESSIMISTIC_WRITE)이걸 제거했다.
새로운 문제
- 제거하고 나니까 DB 갱신 손실문제가 생겼다.
1 | |
- VU=500, 재고=10000에서 정합성 불일치가 발생했다.
원인
1 | |
- 지금 현재 쿠폰 발급 메서드에 issueCoupon 메서드 전체가 @Transactional이 잡혀있다.
- 그리고 processCouponIssueRequest()메서드 안에서 Redis 락 획득이 시작된다. 즉 트랜잭션(DB 스냅샷 MVCC)이 Redis의 락 획득 보다 먼저 시작 된다는 것이다
- MySQL 기본 격리 수준인 REPEATABLE READ는 트랜잭션 시작 시점의 스냅샷을 기반으로 select를 수행한다. redis락이 직렬화를 보장해도, 트랜잭션이 락 회득 전에 시작되었기 때문에 락 획득 후의 DB 읽기에도 여전히 오래된 스냅샷을 사용해서 정합성 이슈가 생긴것이다.
1 | |
- 더 느려진 성능을 개선하기 위해서 Redis를 도입을 했는데 그래서 문제였던 이중 락을 제거를 했더니 트랜잭션 시작 시점과 락 획득 시점의 미스 문제가 발견이 되었다.
그렇다면 어떻게 해결?
- 가장 먼저 떠오른 방법은 DB단에서 정합성을 보장 + 트랜잭션 경계 조정하도록 하는것이었다. 결국 MVCC 스냅샷 문제 + Transaction 시작 시점 미스로 이런 문제가 생겼으니 말이다.
- 트랜잭션이 먼저 시작되면서 MVCC 스냅샷이 고정되었고 Redis락으로 직렬화 해도 오래된 스냅샷을 읽게 된다.
- 그래서?
- 트랜잭션 바깥에서 Redis 락을 먼저 획득하고
- DB 처리만 별도의 Transaction으로 수행하고(최신 상태를 DB단에서
select for update로 읽어오거나 아니면update ... where stock > 0과 같은 조건부 벌크 업데이트로 원자성을 확보하는 방식? -> MVCC 스냅샷 문제로 생기는 갱신 손실은 방지 할 수 있을 것이다.) - 커밋 이후에 락을 해제하면 해결 할 수 있을 것이다.
==그런데 여기서 한가지 의문이 생겼다.==
Redis 분산락을 썼는데, 왜 DB에서 또 정합성을 보장해야하지? (락을 잡았는데도 정합성이 깨짐) 단지 재고를 1감소시키는 짧은 연산을 처리학기 위해 이런 복잡한 과정을 거쳐야 하는거지? 문제를 해결하고 있는 것이 아니라 더 어려운 방식으로 우회해서 해결하려고 하는 것 같다는 생각이 들었다.
- “락으로 직렬화할 것이 아니라 재고를 원자적으로 변경하면 되지 않을까?”
- 요청 줄세우기 -> 재고를 빠르게 변경
의심2 : 분산락은 필요없다?
- 요청 줄세우기 -> 재고를 빠르게 변경
분산락은 쿠폰 발급(제고 관리)에서는 과한 방법이었다.
==분산락은 어떨때 사용하는걸까?==
- 여러 인스턴스 간 공유 자원 보호
- 긴 임계 구역 보호
- 복잡한 상태 변경 제어 예: 같은 배치 작업이 여러 인스턴스에서 동시에 돌면 안 되는 경우, 외부 API 호출 포함한 긴 작업 등
하지만 쿠폰 발급 같은 경우에는 단순한 카운터 감소만 제어하면 되고, 고 트래픽 환경이다. 즉 가벼운 연산을 원자적으로 처리할 문제였다.
해결
- 그래서 분산락 대신 ==Redis의 원자 연산==을 사용하기로 했다.
- ==Redis가 먼저 재고를 관리하다가 DB에서는 성공한 요청만 DB로 내려보내는 것이다.==
- 이렇게 바꾸면 락 대기가 없고 요청을 줄 세우지 않으며 빠르게 실패를 반환 할 수 있다.
1 | |
- Redis는 재고를 빠르게 처리하는 계층(원자적) DB는 최종 정합성을 보장하는 계층
1 | |
- Redis에서 재고를 원자적으로 감소시키고
- 성공한 요청만 DB로 내려보낸 뒤
- DB에서 유니크 제약과 조건부 업데이트로 최종 확정 즉 Redis는 요청이 처리 가능한지 빠르게 판단하고 DB는 이 요청이 유효한지 검증을 담당한다.
그렇다면 Redis에서 먼저 재고를 줄였는데 DB에서 실패하면? 정합성이 깨지지 않을까? 이 문제는 Redis에서 재고를 선점했지만 DB에서 중복 발급이나 실패가 발생하면 Redis 재고를 다시 증가하는 방식으로 해결하면 된다.
- 이렇게 변경하면 기존에 락 획득시간이 사라지게 되면서 불필요한 RTT가 감소가 되면서 전체 레이턴시가 감소되지 않을까 예상을 해본다.
부하 테스트 비교
- 조건은 db-only일때와 동일한 조건으로 진행을 했다.
| VU | Phase 1 TPS | Phase 2 TPS | Phase 1 p99 | Phase 2 p99 |
|---|---|---|---|---|
| 200 | 580 | 1,916 | 167ms | 311ms |
| 500 | 590 | 2,198 | 1,370ms | 445ms |
| 1000 | 591 | 2,218 | 2,110ms | 804ms |
분석
- ==TPS: 590 -> 1,916~2,198 (3.2~3.8x)==
1 | |
- ==p99레이턴시: VU=500기준 1,370ms -> 445ms (-68%)==
- Phase 0은 VU=500에서 모든 요청이 DB락 경합 큐에 쌓이며 대기하고
- Phase 1은 DB 트랜잭션 자체는 동일하게 존재하지만, 재고 소진 후 Redis에서 즉시 거절하므로 DB 대기 큐가 짧아진다.
결론
줄세우기가 아니라 원자성(충돌없는 연산)으로 해결
- 확실히 동일한 DB 커넥션 풀(max=10) 조건에서도 수정된 이후에 뚜렸한 성능 개선 성과가 있었다.
- 핵심적인 이유는 크게 두가지가 있는데
- 재고 소진 판정 위치 차이
- 기존 db-only의 경우 DB 비관락 내부에서 판단을 했었다 그래서 항상 락 획득 비용이 발생했었는데
- 이번에 phase2의 경우 Redis의 원자 연산으로 판단을 진행해서 이 단계에서 실패 요청의 경우는 DB 커넥션을 사용하지 않게 되었다
- 커넥션 반환 속도
- 기존 phase1의 경우는 재고 소진 후에도 모든 요청이 DB 커넥션 점유를 했다면
- phase2의 경우 재고 소진 시점 이후에는 DB 커넥션 부하가 급감하여 발급 구간 처리량에 집중할 수 있게 되었다.
- 재고 소진 판정 위치 차이
다음으로..
- Redis의 도입으로 DB의 커넥션 경쟁을 재고의 수만큼만 경쟁하도록 개선하여 성능을 크게 개선을 하였다.
- 하지만 아직 한계는 있다.
- 우선 Redis가 단일 인스턴스여서 SPOF(Single Point of Failure)문제가 생긴다.
- 만약 Redis가 죽으면? 쿠폰 발급 서비스 자체가 중단된다. db-only 방식(phase0)를 failback 로직으로 둘 수는 있겠지만 이러면 원활한 서비스 운영이 아닐것이다.
- DB가 여전히 동기 병목이 있다
- 앞서 설명했듯이 재고의 수만큼만(Redis 선차감을 통과한 요청) DB 커넥션 경쟁에 참여하는데 DB 트랜잭션을 동기적으로 완료해야한다.
- 즉 재고 100개 쿠폰에 10,000명이 동시 요청하면 DB 접근은 ~100건으로 줄지만, 이 100건이 동시에 몰린다
- 근데 재고가 적을 때는 큰문제 아닐것이다. 하지만 만약 재고가 많아진다면? 그런 경우에는 또다시 병목이 생길것이다. -> 이 경우는 DB의 커넥션 풀(HikariCP)을 늘려서 어느정도는 해결을 할 수는 있겠지만 무진장 늘릴수도 없을 뿐더러 잘못하면 오히려 DB CPU의 부하가 발생할 수 도 있다.
- API의 응답이 동기 구조이다.
- 동기 구조이기 때문에 클라이언트는 발급이 완료될 때까지는 HTTP 커넥션을 유지해야한다.
- 만약 트래픽이 증가를 하면 Tomcat 스레드 풀이 빠르게 소진될것이고 -> DB 트랜잭션 대기 시간이 길어지면 Tomcat 스레드가 더 오래 점유될것이다 -> 스레드가 고갈되어 후속 요청에 대해 reject가 될것이다.
- 스레드 풀을 조정하면 되지 않냐고? -> 역시 마찬가지로 무한정 늘릴수도 없을 뿐더러 얼마나 트래픽이 몰릴지를 예측하기도 힘들것이다.
- Redis와 DB 재고 불일치 보상 트랜잭션이 실패할수있음
- 현재 구조는 Rollback이 되거나 스케줄러상에서 불일치가 발생하면 보상 로직이 실행되도록 구현되어있는데
- 이 보상 트랜잭션 자체가 오류가 나는 경우에는 여전히 Redis 재고가 DB보다 적게 남을수있고 만약 스케줄러가 보정을 한다고 해도 실시간으로 스케줄러를 돌리는 것이 아니기떄문에 그 사이의 시간동안 불일치가 유지 된다.
- 중복 발급 검증이 DB에서만 이뤄진다
- Redis 경로에서는 사전 중복 검증없이 바로 재고를 차감하도록 되어있고. 중복 발급이 발생한다면 DB의 유니크 제약으로 방어를 하도록 되어있다. 그래서 insert가 실패하면 보상 로직이 실행되도록 구현이 되어있다.
- 역시 불필요한 DB 커넥션소비가 있다. Redis -> DB까지 갔다가 롤백을 해야하는 불필요한 과정을 거치게 되고 만약 중복 요청이 많이 발생한다면 장애로까지 이어질 수 있다.
- 우선 Redis가 단일 인스턴스여서 SPOF(Single Point of Failure)문제가 생긴다.
==또 한가지 치명적인 한계가 있다. 3번과 이어지는 부분이긴한데 지금 구조가 순서를 보장하지 않고 있었다는 것이다.== 그래서 사용자의 요청이 도착 순서대로 발급되는걸 보장하지 않는다.
이런 문제들을 다음편에서 해결해보자