경쟁 상태로 인한 동시성 이슈로 상품 재고의 변경이 제대로 이뤄지지 않는 문제를 비관적 락을 이용해 해결했지만, 느린 속도로 인하여 개선이 필요하다.
현재 상황의 수치 파악을 위해 Jmeter를 사용
쓰레드: 2000
반복 횟수: 5
총 10K의 요청
tomcat:
accept-count: 500
threads:
min-spare: 500
max: 1000
max-connections: 25000
표본 수: 10,000
평균응답시간: 11361(ms)
TPS(초당 처리 요청 수): 159.0/sec
현재 로컬환경에서 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
이번에는 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%가 개선되었다.