[Outstagram] Redis에서도 동시성 이슈가 발생한다고...? (lua script 적용기)

nick·2024년 6월 12일

Outstagram

목록 보기
9/14

현재 상황

  • 게시물의 좋아요 개수는 무조건 캐시에 쓰고 이를 5분 마다 DB에 스케쥴링으로 반영하고 있다

  • 근데 현재 코드에서는 동시에 2개의 thread가 같은 좋아요 요청을 한다면 둘 다 잘 실행되어서 캐시에 좋아요 기록이 2개 남고, 좋아요 개수도 2개 증가한다

  • 둘 중 하나만 실행되고, 나머지 하나는 이미 좋아요를 눌렀습니다~라는 에러를 던져야 한다

내 생각

  • 내가 공부했을 때, Redis는 Single Thread로 동작하기 때문에 동시성 이슈가 안생길 거라고 착각했다.

  • 사실 반은 맞고 반은 틀린 말이였다

  • 예를 들어 4번 post의 좋아요 개수가 10개라고 하자

  • 이때 동시에 10개의 thread가 4번 post의 좋아요 개수를 1씩 증가시킨다고 하면

  • single thread이기에 race condition 없이 10개 모두 증가해서 결국 좋아요 개수가 20개가 된다

  • 이렇게 보면 동시성 이슈가 없는 것 같다

  • 하지만 내 로직에서는 발생하는 이유가 뭘까...

내 로직에서 동시성 이슈가 생기는 이유

  • Redis는 operation 단위로 thread가 수행한다
@Transactional(isolation = Isolation.READ_COMMITTED)
public void increaseLike(Long postId, Long userId) {
    // Redis에 게시물에 대한 좋아요 개수 캐싱하기(없으면 DB에서 좋아요 개수 가져와서 캐싱하고 있으면 pass)
    loadLikeCountIfAbsent(postId); // 게시물 존재 여부도 검증함

    String likeCountKey = LIKE_COUNT_PREFIX + postId;
    String userLikeKey = USER_LIKE_PREFIX + userId;

    // 캐시(2) or DB(1)에 좋아요 기록 있음 -> 중복 좋아요 방지
    if (likeService.existsLike(userId, postId) != NOT_FOUND) {
        throw new ApiException(ErrorCode.DUPLICATED_LIKE);
    }

    // 캐시와 DB 모두 좋아요 기록 없음
    // 캐시에 좋아요 누른 기록 추가하고 좋아요 개수 1 증가
    LikeRecordDTO likeRecord = new LikeRecordDTO(postId, LocalDateTime.now());
    redisTemplate.opsForList().leftPush(userLikeKey, likeRecord);
    redisTemplate.expire(userLikeKey, Duration.ofHours(1)); // TTL 1시간
    redisTemplate.opsForValue().increment(likeCountKey, 1);
}
  • 여기서 A thread, B thread가 동시에 postId = 4, userId = 10 으로 increaseLike() 메서드를 호출했다고 가정하자

  • 또한, 아직 10번 유저는 4번 게시물을 좋아요 누른 적이 없다고 가정

  • A, B 모두 if (likeService.existsLike(userId, postId) != NOT_FOUND) 이 if문을 통과하게 된다

  • 그러면 A, B 모두 if문 아래에 있는 로직들을 수행할 수 있게 된다

  • 그럼 똑같은 좋아요 기록이 2개 생기고 좋아요 개수도 2개 증가한다

  • 하지만 우리가 원하는 건 하나의 요청만 실행되고 나머지 요청은 중복 좋아요 요청입니다. 등의 에러를 던지길 원함...

해결 방안

  • 둘 중 하나의 요청만 실행되길 원한다면 아래 operation들이 ATOMIC하게 수행되면 된다

    • 캐시에 현재 key의 값들 중에 내가 저장하려는 값이 있는지 확인
    • 만약에 없다면 그 때 좋아요 기록 캐시에 저장하고, 좋아요 개수 1 증가
    • 있다면 그대로 중복 좋아요 요청입니다 에러 던지기
  • 이전에는 이 operation들이 atomic 하게 묶여 있지 않아서 A, B thread 모두 첫 operation을 통과하니깐 둘 다 2, 3번 operation을 실행한 것

  • 그럼 이 3개 operation을 어떻게 원자적으로 묶냐??! -> Redis에서는 lua script를 지원한다

lua script란..?

  • 경량 프로그래밍 언어로, 임베디드 스크립팅 언어로 많이 사용

  • Redis에서 lua script를 지원하는데 그 이유는 아래와 같다

    • 원자적 연산 : Lua 스크립트는 단일 명령으로 실행되므로, 여러 Redis 명령을 하나의 스크립트로 묶어 원자적으로 실행할 수 있음

lua script 적용하기

  • lua script를 통해 값 있는지 + 있으면 기록 & 좋아요 개수++ 하는 이 로직들을 원자적으로 묶어 보자
---@diagnostic disable: undefined-global
local likeCountKey = KEYS[1]
local userLikeKey = KEYS[2]
local likeRecord = ARGV[1]

local decodedLikeRecord = cjson.decode(likeRecord)

-- 사용자 좋아요 기록을 확인
local userLikes = redis.call('lrange', userLikeKey, 0, -1)
for i, v in ipairs(userLikes) do
    local decodedValue = cjson.decode(v)
    if decodedValue.postId == decodedLikeRecord.postId then
        return redis.error_reply('DUPLICATED_LIKE')
    end
end

-- 사용자 좋아요 기록 추가 및 좋아요 수 증가를 원자적으로 처리
redis.call('lpush', userLikeKey, likeRecord)
redis.call('incr', likeCountKey) -- 좋아요 개수 증가

return 'OK'
@Transactional(isolation = Isolation.READ_COMMITTED)
public void increaseLike(Long postId, Long userId) {
    // Redis에 게시물에 대한 좋아요 개수 캐싱하기(없으면 DB에서 좋아요 개수 가져와서 캐싱하고 있으면 pass)
    loadLikeCountIfAbsent(postId); // 게시물 존재 여부도 검증함

    String likeCountKey = LIKE_COUNT_PREFIX + postId;
    String userLikeKey = USER_LIKE_PREFIX + userId;

    // 캐시(2) or DB(1)에 좋아요 기록 있음 -> 중복 좋아요 방지
    if (likeService.existsLike(userId, postId) != NOT_FOUND) {
        throw new ApiException(ErrorCode.DUPLICATED_LIKE);
    }

    LikeRecordDTO likeRecord = new LikeRecordDTO(postId, LocalDateTime.now());

    // likeRecord 직렬화
    String likeRecordString;
    try {
        likeRecordString = objectMapper.writeValueAsString(likeRecord);
    } catch (JsonProcessingException e) {
        throw new ApiException(ErrorCode.JSON_CONVERTING_ERROR, "JSON 직렬화 오류");
    }

    RedisSerializer<String> stringSerializer = redisTemplate.getStringSerializer();

    // lua script execute
    try {
        redisTemplate.execute(increaseLikeScript, stringSerializer, stringSerializer,
            Arrays.asList(likeCountKey, userLikeKey), likeRecordString);
    } catch (RedisSystemException e) {
        throw new ApiException(e, ErrorCode.DUPLICATED_LIKE);
    }
}
  • A, B thread가 거의 동시에 if (likeService.existsLike(userId, postId) != NOT_FOUND) 이 if문을 통과해도

  • Redis는 한번에 하나의 operation만 처리할 수 있기에 둘 중 하나의 thread가 먼저 lua script를 수행할 것이다 (lua script를 하나의 operation으로 간주함)

  • 예를 들어 A thread가 먼저 lua script를 수행한다면 좋아요 기록 없기에 A thread가 좋아요 기록 남기고 좋아요 개수 1 증가 시킨다

  • 직후에 B thread가 들어와서 lua script를 실행하면 좋아요 기록이 이미 캐시에 있기에 에러를 던지면서 실행이 끝난다.

  • 해결~~

결론

  • 캐시에 좋아요 기록 있는지 있다면 기록 및 개수 증가하고 없다면 에러를 뿜도록

  • 위 로직을 lua script를 통해 원자적으로 묶음으로써 동시성 이슈를 해결함

profile
티스토리로 이전 : https://andantej99.tistory.com/

0개의 댓글