비관적 락 사용시 속도 개선

개나뇽·2024년 5월 29일
0
post-thumbnail

문제

경쟁 상태로 인한 동시성 이슈로 상품 재고의 변경이 제대로 이뤄지지 않는 문제를 비관적 락을 이용해 해결했지만, 느린 속도로 인하여 개선이 필요하다.

현재 상황의 수치 파악을 위해 Jmeter를 사용

쓰레드 그룹 스펙

쓰레드: 2000
반복 횟수: 5
총 10K의 요청

Tomcat 설정

tomcat:
  accept-count: 500
  threads:
    min-spare: 500
    max: 1000
  max-connections: 25000

측정


표본 수: 10,000
평균응답시간: 11361(ms)
TPS(초당 처리 요청 수): 159.0/sec

해결 방법

Redisson

현재 로컬환경에서 1대의 reids 서버를 구동하는 환경이므로 지속적으로 요청을 보내 Redis서버에 부하를 많이주는 Spin lock 보다는 Redisson을 이용해 pub/sub 방식을 활용해 분산락을 선택했다.

설정

build.gradle, config 설정

implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

@Configuration
public class RedisConfig {

  private static final String REDISSON_HOST_PREFIX = "redis://";

  @Bean
  public RedissonClient redissonClient() {
    Config config =  new Config();
      config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + "localhost:6379");
      return Redisson.create(config);
  }
}

redis의 실행을 로컬에서 진행하였기에 위와 같이 했으며 RedissonClient를 Bean으로 지정했다.

분산락 구현

커스텀 어노테이션

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {

  String value(); // lock의 이름 (고유값)

  long waitTime() default 6000L; // lock 흭득 시도 최대 시간 (ms)

  long leaseTime() default 1000L; // 락 획득 후 점유 최대 시간 (ms)
}

분산락 로직

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {

  private final RedissonClient redissonClient;

  @Around("적용범위 설정")
  public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    RedissonLock annotation = method.getAnnotation(RedissonLock.class);
    String lockKey  = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(),
        joinPoint.getArgs(),annotation.value());

    RLock lock = redissonClient.getLock(lockKey);

    try {
      boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
      if (!lockable){
        log.info("Lock 흭득 실패 ={}", lockKey);
      return;
      }
      log.info("로직 수행중인 락 = {}", lockKey);
      joinPoint.proceed();
    }catch (InterruptedException e){
      log.info("에러 발생");
      throw e;
    }finally {
      log.info("락 해제");
      lock.unlock();
    }
  }
}

커스텀 Parser

public class CustomSpringELParser {

  public static Object getDynamicValue(String[] parameterNames, Object[] args, String key ){
    SpelExpressionParser parser = new SpelExpressionParser();
    StandardEvaluationContext context = new StandardEvaluationContext();

    for (int i = 0; i< parameterNames.length; i++){
      context.setVariable(parameterNames[i], args[i]);
    }
    return parser.parseExpression(key).getValue(context, Object.class);
  }

}

분산락 기능 구현은 커스텀 어노테이션과, AOP를 이용해 만들었으며 생성한 어노테이션을 api에 적용하면 된다.

측정


표본 수: 10,000
평균응답시간: 14322(ms)
TPS(초당 처리 요청 수): 129.9/sec

예상과 다르게 성능의 개선이 아닌 오히려 저하가 발생하여 해당 방법은 실패로 체크했다.

ref
분산락을 활용하여 동시성 문제 해결하기
Kurly Tech Blog -풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

RedisCache

이번에는 redis에 상품재고를 저장해 활용하기로 했다.

구현

config.class

@Configuration
@EnableCaching
public class RedisConfig {

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

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

  @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;
  }
}

Respository.class

@Component
@RequiredArgsConstructor
public class RedisRepository {

  private final RedisTemplate<String, Integer> redisTemplate;
  private final ProductRepository productRepository;
  private final UserHistoryRepository userHistoryRepository;
  private static final String DECREASE_STOCK =
      "local leftStock = tonumber(redis.call('get', KEYS[1]))" +
          "if leftStock - ARGV[1] >= 0 then" +
          "  redis.call('decrby', KEYS[1], ARGV[1]) " +
          "  return true " +
          "else " +
          "  return false " +
          "end";
  private final RedisScript<Boolean> decreaseStockScript = new DefaultRedisScript<>(DECREASE_STOCK,
      Boolean.class);

  public void saveProductStockToRedis(Product product) {
    String key = "ls" + product.getSerialNumber();
    if (redisTemplate.hasKey(key)) {
      throw new BusinessException(ErrorCode.EXISTED_CACHE);
    }
    redisTemplate.opsForValue().set(key, product.getStock());
  }

  // 매분 캐시 변경분을 db에 저장
  @Scheduled(cron = "0 * * * * *")
  public void saveProductStockFromRedis() {
    Set<String> keys = redisTemplate.keys("ls*");
    if (keys.isEmpty() || keys == null) {
      return;
    }
    for (String key : keys) {
      Integer leftStock = redisTemplate.opsForValue().get(key);
      String serialNumber = key.substring(2);
      Product product = productRepository.findBySerialNumber(serialNumber);
      product.setStock(leftStock);
      productRepository.save(product);
    }
  }

  public Boolean hasStockInRedis(String serialNumber) {
    String key = "ls" + serialNumber;
    try {
      return redisTemplate.hasKey(key);
    } catch (Exception e) {
      return false;
    }
  }

  public Boolean decreaseStock(String serialNumber, int count) {
    String key = "ls" + serialNumber;
    return redisTemplate.execute(decreaseStockScript, Collections.singletonList(key), count);
  }

  // 티켓 취소시
  public void incrementStock(String serialNumber, int count) {
    String key = "ls" + serialNumber;
    redisTemplate.opsForValue().increment(key, count);
  }

  public void deleteStockInRedis(Product product) {
    String key = "ls" + product.getSerialNumber();
    if (!redisTemplate.hasKey(key)) {
      throw new BusinessException(ErrorCode.EXISTED_CACHE);
    }
    saveProductStockFromRedis();
    redisTemplate.delete(key);
    refreshProduct(product);
  }

  public void refreshProduct(Product product) {
    Integer accurateStock = userHistoryRepository.sumCountBySerialNumber(product.getSerialNumber());
    if (accurateStock == null) {
      accurateStock = 0;
    }
    Integer accurateLeftStock = product.getStock() - accurateStock;
    product.setStock(accurateLeftStock);
    if (hasStockInRedis(product.getSerialNumber())) {
      String key = "ls" + product.getSerialNumber();
      redisTemplate.opsForValue().set(key, accurateLeftStock);
    }
  }
}

Service.class

  @Transactional(isolation = Isolation.READ_COMMITTED)
  public void createTicketingByRedis(String asapName, String serialNumber) {
    User user = userRepo.findByAsapName(asapName)
        .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

    Boolean hasLeftStock = redisRepo.hasStockInRedis(serialNumber);
    if (hasLeftStock) {
      decreaseStockInRedis(serialNumber);
    } else {
      decreaseStockInDB(serialNumber);
    }
    userHistoryRepo.save(UserProductHistory.of(user, serialNumber, ticketNumber()));
  }

  private void decreaseStockInDB(String serialNumber) {
    Product product = productRepo.findBySerialNumberWithPessimisticLock(serialNumber);
    if (product.getStock() - 1 < 0) {
      throw new BusinessException(ErrorCode.PRODUCT_IS_SOLD_OUT);
    }
    product.decrease();
    productRepo.save(product);

  }

  private void decreaseStockInRedis(String serialNumber) {
    Boolean success = redisRepo.decreaseStock(serialNumber, 1);
    if (!success) {
      throw new BusinessException(ErrorCode.PRODUCT_IS_SOLD_OUT);
    }
  }
  ------------RedisService------------
  
  @Scheduled(cron = "0 0 0 * * ?")
  public void closeProduct() {
    List<Product> products = productRepository.findAllByEventEndDate();
    for (Product product : products) {
      product.eventOpen(false);
      redisRepository.deleteStockInRedis(product);
      log.info("{}번 상품 판매 일정이 {}로 수정되었습니다.", product.getSerialNumber(), product.isOpen());
    }
    productRepository.saveAll(products);
  }

  @Scheduled(cron = "0 0 23 * * ?")
  public void openProduct() {
    List<Product> products = productRepository.findAllByEventStartDate();
    for (Product product : products) {
      product.eventOpen(true);
      redisRepository.saveProductStockToRedis(product);
      log.info("{}번 상품 판매 일정이 {}로 수정되었습니다.", product.getSerialNumber(), product.isOpen());
    }
    productRepository.saveAll(products);
  }
}

측정


표본 수: 10,000
평균응답시간: 8259(ms)
TPS(초당 처리 요청 수): 220.3/sec

비관락 사용시보다 TPS는 약 38% 개선, 응답시간의 경우 27%가 개선되었다.

  • Redis cache와 Redisson의 구현과 성능 개선의 경험을 얻는것을 1순위로 하여 깊게 파고들지 않아 좀 더 깊에 파고들어 재측정후 다시 정리할 필요가 있음
profile
정신차려 이 각박한 세상속에서!!!

0개의 댓글