이번 포스팅은 레디스에서 제공해주는 자료구조 중 하나인 Sorted Set을 간단하게 설명하고, Sorted Set을 이용해서 치킨 기프티콘 선착순 이벤트를 구현해봅니다.
모든 요청이 DB에 바로 부하가 가지 않고 차례대로 일정 범위만큼씩 처리
하는 구성을 해보려고합니다.간단히 정리하자면, 한 Key에 여러 value와 score를 가지고 있으며 중복되지 않는 value로 score순으로 데이터를 정렬합니다.
고유한 값
으로 세팅하면 됩니다.참여한 사람들을 순서대로 정렬
하기 위해, 이벤트를 참여한 시간을 유닉스타임(m/s) 값으로 넣어줍니다. (1) 100명의 유저가 기프티콘 발급 요청을 합니다.
(2) 100명의 유저는 대기열에 쌓이게 됩니다.
(3) 1초마다 동기화 돼어 기프티콘 발급 성공, 실패 로직을 수행합니다.
(4) 성공시, 이벤트가 종료되지 않았으면 100명의 유저중 먼저 들어온 순서대로 10명씩 기프티콘 발급
합니다.
(5) 실패시, 다음 대기열로 돌아가면서 남은 대기열 순번을 표출
합니다.
(6) 해당 과정을 반복하면서 이벤트는 종료(30개 발급완료)합니다
10개씩 발급하는 이유는
DB 부하를 줄이기 위해
입니다. 해당 예시는 10개이지만 실제 서비스에선 10000개의 요청 중 1000개의 기프티콘을 발급할 경우 1초마다 50개씩 순차적으로 발급해서 DB부하를 줄일 수 있을 것 같습니다.
30개의 치킨 기프티콘 선착순 이벤트를 진행합니다. 100명의 사용자가 요청하고, 1초마다 더 빨리 들어온 10명의 사용자에게 치킨 기프티콘을 발급합니다. 치킨을 발급 받지 못한 사용자는 대기열 순번이 1초마다 동기화됩니다. 30개의 치킨 기프티콘이 모두 발급되면 해당 이벤트는 종료됩니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
spring:
redis:
host: localhost
port: 6379
@Slf4j
@Component
@RequiredArgsConstructor
public class EventScheduler {
private final GifticonService gifticonService;
@Scheduled(fixedDelay = 1000)
private void chickenEventScheduler(){
if(gifticonService.validEnd()){
log.info("===== 선착순 이벤트가 종료되었습니다. =====");
return;
}
gifticonService.publish(Event.CHICKEN);
gifticonService.getOrder(Event.CHICKEN);
}
}
public void addQueue(Event event){
final String people = Thread.currentThread().getName();
final long now = System.currentTimeMillis();
redisTemplate.opsForZSet().add(event.toString(), people, (int) now);
log.info("대기열에 추가 - {} ({}초)", people, now);
}
public void getOrder(Event event){
final long start = FIRST_ELEMENT;
final long end = LAST_ELEMENT;
Set<Object> queue = redisTemplate.opsForZSet().range(event.toString(), start, end);
for (Object people : queue) {
Long rank = redisTemplate.opsForZSet().rank(event.toString(), people);
log.info("'{}'님의 현재 대기열은 {}명 남았습니다.", people, rank);
}
}
public void publish(Event event){
final long start = FIRST_ELEMENT;
final long end = PUBLISH_SIZE - LAST_INDEX;
Set<Object> queue = redisTemplate.opsForZSet().range(event.toString(), start, end);
for (Object people : queue) {
final Gifticon gifticon = new Gifticon(event);
log.info("'{}'님의 {} 기프티콘이 발급되었습니다 ({})",people, gifticon.getEvent().getName(), gifticon.getCode());
redisTemplate.opsForZSet().remove(event.toString(), people);
this.eventCount.decrease();
}
}
addQueue()
: 사람들의 요청을 value : 사람의 고유한 값, score : 현재 시간(m/s)으로 대기열에 추가getOrder()
: 차례대로 들어온 사람들의 요청을 기반으로 대기열 순번 표출publish()
: 1초마다 이벤트에 참여하는 사람수(10명)씩 기프티콘 발급 후 대기열에서 제거 @Test
void 선착순이벤트_100명에게_기프티콘_30개_제공() throws InterruptedException {
final Event chickenEvent = Event.CHICKEN;
final int people = 100;
final int limitCount = 30;
final CountDownLatch countDownLatch = new CountDownLatch(people);
gifticonService.setEventCount(chickenEvent, limitCount);
List<Thread> workers = Stream
.generate(() -> new Thread(new AddQueueWorker(countDownLatch, chickenEvent)))
.limit(people)
.collect(Collectors.toList());
workers.forEach(Thread::start);
countDownLatch.await();
Thread.sleep(5000); // 기프티콘 발급 스케줄러 작업 시간
final long failEventPeople = gifticonService.getSize(chickenEvent);
assertEquals(people - limitCount, failEventPeople); // output : 70 = 100 - 30
}
private class AddQueueWorker implements Runnable{
private CountDownLatch countDownLatch;
private Event event;
public AddQueueWorker(CountDownLatch countDownLatch, Event event) {
this.countDownLatch = countDownLatch;
this.event = event;
}
@Override
public void run() {
gifticonService.addQueue(event);
countDownLatch.countDown();
}
}
AddQueueWorker
생성Thread.sleep(5000)
설정가장 빨리 참여한 "Thread-2", "Thread-3"가 치킨 기프티콘을 먼저 받는 것을 확인할 수 있다.
치킨을 받지 못한 사람들의 화면에 표출될 순번을 표출하고 있다.
100명의 사람중 30명이 치킨 기프티콘을 받아서 이벤트가 종료된 것을 확인할 수 있다.
Sorted Set 명령어는 이번 글에선 설명하지 않고 기프티콘 선착순 이벤트 구현에 초점을 맞춰 포스팅했습니다. 제 글만 접하고
명령어(add,range,rank 등)
에 대한 이해가 쉽지 않을 수 있습니다. Sorted Set 명령어는 해당 사이트에서 추가적으로 확인해보면 좋을 것 같습니다.
대용량 트래픽에 관해 영상을 보던 중 우연히 해당 참고 영상을 접해서 레디스 SortedSet이라는 자료구조를 알게 되었습니다. 그래서 언젠간 한 번 나도 구현해봐야겠다 생각하다가 이번에 마침 구현하게 되었습니다!
많은 요청이 바로 DB로 이어지지 않고 중간 정재 단계를 레디스로 거칠 수 있는 것을 해당 글을 쓰게 되면서 터득할 수 있었습니다. 추후에 팀 내에서 선착순 이벤트를 진행하거나 동일 시간에 민감한 데이터를 다루고, 대기열을 표출해야할 때 사용할 수 있는 하나의 수단이 될 것 같습니다!
timestamp값을 sorted set으로 사용하는게 인상적이네요 😮
애플리케이션에서 기록한 timestamp값을 레디스에 저장하면서, 이슈가 발생할 수도 있을 것 같다고 생각이 들었는데요
만약 A가 일찍요청하고, B가 비슷한시간에 요청하는 가정으로, A가 레디스에 저장할 때 지연이 발생하면 B가 레디스에 먼저 저장될 것 같은데요.
또 찰나의순간에 publish 스케줄러가 실행되면, A가 빨리요청했지만 선착순에 실패할 수도 있지않을까요?