동시에 같은 상품에 대한 주문이 생성되었고 재고 차감 과정 중에 같은 상품 데이터에 접근하여 데드락이 발생했다.
일반적인 동시성 제어 방법
- SQL DB Exclusive Lock(배타적 잠금)
- SELECT ~ FOR UPDATE 등의 row lock
- Zookeeper
- Kafka 등에서 활용되는 분산서버 관리 시스템
- 각 클라이언트의 Session 및 Heartbeat 체킹 등의 서버 로직을 응용하여 고가용성을 보장하며, 결제 등 Lock의 성능보다 안정성이 매우 중요한 경우 고려할만한 방법이다.
- Redis Distributed Lock(분산락)
- setnx 명령어와 함께 Spin Lock 또는 Publish/Subscribe 등의 방식으로 락의 획득 가능여부를 판단
📌 용어 정리
분산락(distributed lock) - 경쟁 상황(race condition)에서 하나의 공유자원에 접근할 때, 데이터의 결함이 발생하지 않도록 원자성(atomic)을 보장하는 기법
스핀락(spinlock) - 임계 구역(critical section)에 진입이 불가능할 때 진입이 가능할 때까지 루프를 돌면서 재시도하는 방식으로 구현된 락
Publish / Subscribe - 특정한 주제(topic)에 대하여 해당 topic을 구독한 모두에게 메시지를 발행하는 통신 방법으로 채널을 구독한 수신자(클라이언트) 모두에게 메세지를 전송 하는것
이전에 주문 쪽에서 동시성 문제가 발생했을 때는 database level에서 배타적 잠금을 통해 해결했었다.
2023.03.22 - [Programming/Ruby On Rails] - [Ruby On Rails] 동시성 제어하기 - ActiveRecord Locking
하지만, 재고 차감 부분에서는 아래의 이유들로 Redis 분산락을 적용해보기로 결정했다.
- DB에서 잠금을 실행했기 때문에 다른 작업에서 해당 데이터에 접근할 경우 데드락 발생할 가능성이 있고, DB는 가장 중요한 공유 자원이기 때문에 가능하면 부하를 덜 주는 것이 좋다.
- AWS Aurora가 분산 DB이기 때문에 문제 발생 여지가 있다.
Redis 분산 락
Redis는 분산락을 구현하기 위해 3가지 특성을 보장해야 한다고 말한다.
- 오직 한 순간에 하나의 작업자만이 락을 걸 수 있다.
- 락 이후, 어떠한 문제로 인해 락을 풀지 못하고 종료된 경우라도 다른 작업자가 락을 획득할 수 있어야 한다.
- 레디스 노드가 작동하는 한 모든 작업자는 락을 걸고, 해체할 수 있어야 한다.
Redis 락은 인스턴스 구성에 따라 구현 방식이 달라질 수 있다.
- 단일 인스턴스 - 기본적인 SETNX 명령어를 사용한 분산락
- Redis는 싱글 스레드 기반이기 때문의 SET 명령어의 NX 옵션처럼 key가 존재하지 않는 경우에만 값을 저장하는 특성을 이용하여 간단한 lock 구현이 가능
- Single Redis에 의존하여 결함 허용성(Fault Tolerance)이 부족
- 다중 인스턴스 - Redlock 알고리즘 사용 권장
- Redis에서는 Expire 기능을 활용하여 안정성이 보장된 분산 락 프로토콜을 구현한 RedRock 알고리즘을 사용할 수 있다.
- Redlock 구현 시 SPOF(single point of failure, 시스템 구성 요소 중에서, 동작하지 않으면 전체 시스템이 중단되는 요소) 방지를 위해 최소 3개 이상의 독립적인 인스턴스로 구성하도록 권고한다.
Redlock 알고리즘
- 현재 시간을 ms 단위로 가져온다
- 모든 Redis instance에서 동일한 key와 난수값을 사용하여 순차적으로 lock을 획득한다
- 클라이언트는 앞서 얻어 놓은 ms 시간과 현시간을 비교하여 lock 획득에 소요된 시간을 계산한다. 대부분의 인스턴스에서 lock을 획득하는 데 걸린 총 시간이 잠금 유효시간보다 짧은 경우에만 lock을 획득한 것으로 간주한다.
- lock을 획득한 경우의 lock 유효시간은 초기에 설정한 유효시간에서 경과된 시간을 뺀 것으로 간주한다.
- 만약 클라이언트가 어떤 이유로 잠금을 획득하지 못 했다면, 모든 Redis instance에서 잠금 해제를 시도한다.
결론 (어떤 구성을 선택했는가)
현재 우리 서비스에서 사용 중인 redis는 단일 인스턴스로 구성되어 있기 때문에
SET 명령어를 통해 간단하고 효율적으로 락 구현이 가능하지만, 결함 허용성이 없다.
→ Redis 가 장애가 나는 상황에 대한 고려가 필요한 상황
(일시적인 Timeout, 혹은 Redis 인스턴스의 장애로 Lock이 동작하지 않을 때 Lock 없이 실행할 건지, 실행을 안 할 건지, 혹은 일정 시간 이후 재시도 할 것인지에 대한 결정 등)
확실하게 결함 허용성을 지키기 위해서는 인스턴스를 여러개로 구성하여 Redlock 알고리즘을 사용해야 한다.
그러나, 현상황에서 결함 허용성을 위해서 Redis 버전 업그레이드 및 Clustering, 라이브러리 적용 등을 진행하는 것은 득보다 실이 크다고 판단했다.
⇒ 간단하게 자체적으로 setnx 명령어 + Spin Lock 방식으로 Lua 스크립트를 활용하여 원자성을 보장한 락 구현
- setnx (“값이 존재하지 않으면 세팅”) 명령어를 이용하여 레디스에 값이 존재하지 않으면 세팅하게 하고, 값이 세팅되었는지 여부를 리턴 값으로 받아 락을 획득하는데에 성공했는지 확인
- 락을 획득할때까지 계속 락 획득을 시도(spinlock), 혹시나 레디스에 너무 많은 요청이 가지 않도록 약간의 sleep과 최대 요청 횟수 추가
- 락을 획득한 후에 연산을 수행 (재고 수량 업데이트)
- 락을 사용 후 ensure 에서 락을 해제(레디스에서 해당 키 삭제)
테스트
Redis Lock 없이 decrement 수행 (0.1초 이내 5번 동시 호출)
=> 모든 요청이 병렬적으로 처리되고 있으며, 응답값의 재고 수량이 동일함
Redis Lock 적용 후 decrement 수행
=> 순서대로 락 획득을 대기하며 순차적으로 처리됨
<참고>
https://www.korecmblog.com/blog/redis-dlm
https://channel.io/ko/blog/distributedlock_2022_backend
https://charsyam.wordpress.com/2022/12/31/입-개발-분산-락에-대해서/