230509 TIL #80 Redis Cache로 재고 관리 최종 정리

김춘복·2023년 5월 8일
0

TIL : Today I Learned

목록 보기
80/543
post-custom-banner

230509 Today I Learned

실전 프로젝트 6주차. 오늘 TIL에는 프로젝트를 하면서 트러블 슈팅을 했던 내용에 대해 최종적으로 정리한 것을 내 블로그에도 적어보려 한다.


Redis Cache로 재고 관리 최종 정리

  • 캐시 전략 FlowChart

  • 인메모리 저장소, 싱글 스레드, 단순연산의 경우 원자성 보장이라는 특성을 가진 Redis를 도입해서 속도 개선과 동시성제어 두 마리 토끼를 한 번에 잡아보고자 했다.

  • Key는 “ls+ticketInfoId”, Value는 TicketInfo Entity에 있는 LeftSeats(남은 좌석 수) 컬럼의 데이터로 <String, Integer> 구조의 ‘Redis 기본 String 자료구조’로 Redis에 저장했다.

  • 예매 트랜잭션이 시작되면 먼저 Redis로 남은 좌석 수를 차감해 예매를 진행하고, 키가 없거나 Redis 연결에 문제가 생기면 기존 DB의 비관적 락 로직으로 남은 좌석 수를 차감해 예매를 진행한다.

  • Redis로 아래의 Lua Script를 보내서 남은 좌석 수가 예매하려는 자리 수(count) 이상일 경우 value를 차감한 뒤 true를 반환하고, 아니면 false를 반환한다. 이 스크립트는 단순 연산으로 원자성이 보장된다.

  • Write Back 캐시 쓰기 전략으로 캐시에 있는 남은 좌석 수 값이 DB의 TicketInfo 테이블의 LeftSeats 컬럼에 매 분마다 저장된다.

  • 해당 캐시는 예매가 오픈될 때 생성되어 공연일이 되어 예매가 닫히면 expire 된다. 캐시가 사라지면 Reservation 테이블에서 해당 TicketInfo의 예매 count를 다 더해서 정확한 남은 좌석 수를 DB 안의 TicketInfo에 넣는다.

  • 예매 서비스 로직

// 2. 예매하기
  @Transactional(isolation = Isolation.READ_COMMITTED)
  public Long makeReservation(ReservationRequestDto dto, User user) {
    //    먼저 redis 캐시를 조회
    Boolean hasLeftSeats = redisRepository.hasLeftSeatsInRedis(dto.getTicketInfoId());
    if (hasLeftSeats) {
      //    캐시가 있으면 redis에서 남은 좌석 수 차감
      decrementLeftSeatInRedis(dto);
    } else {
      //    캐시가 없으면 DB를 통해 남은 좌석 수 차감
      decrementLeftSeatInDB(dto);
    }

    Reservation reservation = new Reservation(dto, user);
    reservationRepository.saveAndFlush(reservation);
    return reservation.getId();
  }

  //  2-1. redis로 좌석 수 변경
  private void decrementLeftSeatInRedis(ReservationRequestDto dto) {
    Boolean success = redisRepository.decrementLeftSeatInRedis(dto.getTicketInfoId(), dto.getCount());
    if (!success) {
      throw new CustomException(ExceptionType.OUT_OF_TICKET_EXCEPTION);
    }
  }

  // 2-2. 캐시 없으면 DB로 좌석수 변경
  private void decrementLeftSeatInDB(ReservationRequestDto dto) {
    TicketInfo ticketInfo = ticketInfoRepository.findByIdWithLock(dto.getTicketInfoId()).orElseThrow(
        () -> new CustomException(ExceptionType.NOT_FOUND_TICKET_INFO_EXCEPTION)
    );
    if (!ticketInfo.isAvailable()) {
      throw new CustomException(ExceptionType.RESERVATION_UNAVAILABLE_EXCEPTION);
    }
    if (ticketInfo.getLeftSeats() - dto.getCount() < 0) {
      throw new CustomException(ExceptionType.OUT_OF_TICKET_EXCEPTION);
    }
    ticketInfo.minusSeats(dto.getCount());
    ticketInfoRepository.save(ticketInfo);
  }
  • RedisRepository
@Component
@RequiredArgsConstructor
public class RedisRepository {
  private final RedisTemplate<String, Integer> redisTemplate;
  private final TicketInfoRepository ticketInfoRepository;
  private final ReservationRepository reservationRepository;
  private static final String DECREMENT_LEFT_SEAT_SCRIPT =
      "local leftSeats = tonumber(redis.call('get', KEYS[1])) " +
      "if leftSeats - ARGV[1] >= 0 then " +
      "  redis.call('decrby', KEYS[1], ARGV[1]) " +
      "  return true " +
      "else " +
      "  return false " +
      "end";

//  매분 캐시 변경분을 db에 저장
  @Scheduled(cron = "0 * * * * *")
  public void saveTicketInfoFromRedis(){
    Set<String> keys = redisTemplate.keys("ls*");
    if (keys.isEmpty() || keys == null) return;
    for (String key : keys) {
      Integer leftSeatsInRedis = redisTemplate.opsForValue().get(key);
      Long ticketInfoId = Long.parseLong(key.substring(2));
      TicketInfo ticketInfo = ticketInfoRepository.findById(ticketInfoId).orElseThrow(
          () -> new CustomException(ExceptionType.NOT_FOUND_TICKET_INFO_EXCEPTION));
      ticketInfo.setLeftSeats(leftSeatsInRedis);
      ticketInfoRepository.save(ticketInfo);
    }

  }

//  값 변경. count만큼 남은좌석수 차감
  public Boolean decrementLeftSeatInRedis(Long ticketInfoId, int count){
    String key = "ls" + ticketInfoId;
//    Lua Script 실행
    return redisTemplate.execute(decrementLeftSeatRedisScript, Collections.singletonList(key), count);
  }
  • CacheConfig
@Configuration
@EnableCaching
public class CacheConfig {

  @Value("${spring.redis.host}")
  private String host;

  @Value("${spring.redis.port}")
  private int port;

  @Bean
  public RedisConnectionFactory redisConnectionFactory() {
    return new LettuceConnectionFactory(host, port);
  }

  //  LeftSeats에 사용하는 redisTemplate
  @Bean
  public RedisTemplate<String, Integer> redisTemplate() {
    RedisTemplate<String, Integer> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Integer.class));
    return redisTemplate;
  }
profile
Backend Dev / Data Engineer
post-custom-banner

0개의 댓글