레디스 클라이언트는 레디스 서버에 연결하여 레디스 데이터 구조를 조작할 수 있게 해준다. 서버 개발자는 cli가 아닌 코드 상에서 레디스에 접근해서 데이터를 다룰 수 있어야 한다.
Java용 Redis 클라이언트 라이브러리로는 Lettuce, Redisson, Jedis 등이 있다.
참고로 Jedis는 멀티 쓰레드 불안정, 스레드 풀 문제 등으로 잘 사용되지 않는 추세라고 한다. Spring Boot 2.0부터 기본 클라이언트가 lettuce로 바뀌었다.
실무에서 서비스의 로그들을 보면서 느끼는건데 '따닥' 요청은 생각보다 많이 들어온다. 사용자가 실수로 그렇게 한걸 수도 있고, 어뷰징 목적으로 그렇게 한 것일 수도 있다. 이때 만약 어떤 버튼을 눌렀을때 돈이 나가는 api를 친다면, 이런 따닥은 막아야한다. 물론 프론트에서도 막아야하지만 서버에서도 막아야 한다.
물론 이러면 자바 단에서 synchronized나 db에서 락을 걸면되는거 아니냐고 할 수 있어서 간단히 정리한다.
자바의 synchronized는 하나의 프로세스 내에서만 보장이 된다. 서버가 두대 이상일 경우 메서드에 synchronized를 걸었다해도 그 메서드를 두 곳 이상에서 실행할 수 있는거다.
lock을 걸지 않고 version column을 만들어준다. 데이터를 읽고 업데이트를 할 때 버전이 맞는지 확인하며 업데이트를 진행하는 방식이다.
이건 업데이트가 실패했을때 재시도 로직을 개발자가 직접 짜워야하고, 충돌이 자주 일어난다면 뒤에 나오는 pessimistic lock이 나을 수 있다.
다른 트랜잭션이 특정 row의 lock을 획득하는 것을 방지하는거다. 이런 경우 서버1이 락을 걸면 서버 2는 서버1이 락을 해제하기 전까지 데이터를 변경할 수 없다.
하지만 데드락이 발생할 수 있고, 락을 거는 것은 성능 저하를 유발한다.
redis-cli를 사용하여 Redis 서버에 접속하고, SETNX 명령어를 사용하여 락을 시도한다. SETNX 명령어는 해당 키가 존재하지 않는 경우에만 키를 생성하고, 락을 획득할 수 있다. SETNX 명령어는 락을 시도한 클라이언트에게 1(True)을 반환하며, 다른 클라이언트에 의해 락이 이미 획득된 경우 0(False)을 반환한다.
SETNX 명령어 (SET if Not eXists)를 이용한다. 기존의 값이 없을때 set 하는 방식이다.
RedisLockRepository
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
LettuceLockStockFacade
@Component
public class LettuceLockStockFacade {
private RedisLockRepository redisLockRepository;
private StockService stockService;
public LettuceLockStockFacade(RedisLockRepository repository, StockService stockService) {
this.redisLockRepository = repository;
this.stockService = stockService;
}
public void decrease(Long key, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(key)) {
Thread.sleep(100);
}
try {
stockService.decrease(key, quantity);
} finally {
redisLockRepository.unlock(key);
}
}
}
lettuce는 코드에서 보면 알듯이 spin lock 방식이다. retry 로직을 개발자가 작성해야 하는데, 락을 획득할 수 있는지 반복적으로 확인하는 것이다. 이는 자원의 낭비가 되기 쉽다. 하지만 spring data redis를 사용할 경우 기본이기도해서 재시도가 필요하지 않으면 쓰기 좋다.
redisson에서는 pub-sub 방식으로 lock 구현을 제공한다. 즉 공통 채널을 하나 만들고, 락을 점유중이던 스레드가 끝나면 채널에 알려주고, 다른 스레드가 락 획득을 시도하는 방식인 것이다. 스핀락이 아니기 때문에 부하가 훨씬 덜하다.
@Component
public class RedissonLockStockFacade {
private RedissonClient redissonClient;
private StockService stockService;
public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
this.redissonClient = redissonClient;
this.stockService = stockService;
}
public void decrease(Long key, Long quantity) {
RLock lock = redissonClient.getLock(key.toString());
try {
boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(key, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
RedissonClient 인터페이스를 보면 Rlock을 반환하는데,
RedissonClient
스레드의 대기 순서를 고려하지 않는다.
RLock
코드에서 사용했던 tryLock을 보면 waitTime까지 기다려 락을 획득하려는 것을 알 수 있다. leaseTime이 지나면 락은 풀린다.
RedissonLock은 RedissonBaseLock을 확장했고, RedissonBaseLock은 RLock을 구현했다.
RedissonClient를 구현한 Redisson에서 getLock을 하면 RedissonLock을 반환한다.
이 RedissonLock의 코드를 보면 아래와 같이 문자열이 하드코딩된 것을 볼 수 있다.
이게 루아 스크립트인데, 레디스에서 루아 엔진이 있기 때문에 이 스크립트를 실행할 수 있는 것이다.
local value1 = redis.call('get', KEYS[1])
local value2 = redis.call('get', KEYS[2])
return tonumber(value1) + tonumber(value2)
위는 레디스에 저장된 두 개의 값을 가져와서 더한 값을 반환하는 스크립트다.
@Service
class RedisService(private val redisTemplate: RedisTemplate<String, Any>) {
fun executeScript(): Int {
// 레디스 Lua 스크립트를 작성합니다.
val script = """
local value1 = redis.call('get', KEYS[1])
local value2 = redis.call('get', KEYS[2])
return tonumber(value1) + tonumber(value2)
""".trimIndent()
// 레디스 Lua 스크립트를 실행합니다.
val result = redisTemplate.execute(
RedisScript.of(script, Int::class.java),
listOf("key1", "key2"),
"value1",
"value2"
)
return result ?: 0
}
}
RedisTemplate을 사용해서 lua 스크립트를 실행하는 코드다.
루아스크립트를 쓰면 여러개의 Redis 명령어를 원자적으로 실행 가능하고, 또 스프링 서버와 레디스 서버간의 네트워크 비용을 줄일 수 있다. 또 동적으로 레디스에서 스크립트 실행 중 데이터를 처리할 수 있는 것도 장점이라고 한다.
하지만 레디스의 메인 스레드는 싱글스레드로 동작하기 때문에, 당연히 하나의 작업이 지나치게 길어지지 않게 주의해야 한다.
아직까지 실무에서 루아스크립트를 써서 문제를 해결해본 적이 없어서 이 부분은 생기면 추가하겠다.
https://docs.spring.io/spring-data/redis/docs/2.3.3.RELEASE/reference/html/#tx.spring
http://redisgate.kr/redis/clients/redisson_intro.php
https://engineering.linecorp.com/ko/blog/atomic-cache-stampede-redis-lua-script