Redis를 사용하면서 동시성 문제가 발생할 수 있음을 알게 되었고 왜 Single Thread임에도 동시성 문제가 발생할 수 있는지, 해당 문제를 어떻게 해결할 수 있는지 등을 정리해보려고 합니다.
Redis는 Single Thread입니다. Single Thread이기 때문에 다음과 같은 장점들을 갖습니다.
하지만 다음과 같은 한계점들도 가질 수 있습니다.
앞으로 이야기할 내용은 마지막에 언급한 Transaction, lua script 입니다. 해당 기능 사용시 주의점과 동작 방식에 대해서 알아보려고 합니다. 해당 공식문서를 참고하였습니다.
https://redis.io/docs/manual/transactions/
https://redis.io/docs/manual/programmability/eval-intro/
Redis 트랜잭션의 핵심 개념은 모든 command가 Redis에서 atomic하게 실행되면 Redis는 Single Thread이기 때문에 실행되는 동안 다른 Client 트랜잭션에서 실행된 command가 실행될 수 없습니다. 따라서 트랜잭션을 거는 것 만으로 순차적으로 트랜잭션이 실행되기 때문에 동시성 문제가 발생하지 않습니다.
트랜잭션을 시작하는 커맨드 입니다. Match이후로 실행하는 커맨드는 바로 실행되지 않고 쌓입니다. Exec|Discard를 호출할때 한번에 실행됩니다.
Match 이후로 실행된 커맨드들을 한번에 실행합니다. 이때 Exec이전에는 Client쪽에서 해당 커맨드들을 모아놓고 있다가 해당 커맨드 호출시 단 한번의 네트워크 호출로 모두 전송하고 실행합니다. Exec이후에 실행된 커맨드에 해당하는 key가 없는 등 커맨드가 정상 실행되지 않더라도 트랜잭션은 취소 되지않습니다.
Multi이후에 모아놓은 커맨드를 모두 버립니다.
Watch로 특정키를 지정하면 Exec하기까지 사이에 변경이 발생할 경우 트랜잭션을 종료합니다.
낙관적 락의 매커니즘입니다.
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
트랜잭션만 있어도 원자적으로 수행하여 동시성을 보장해줄 수 있을텐데 왜 Watch가 따로 있는지 이상하다는 생각이 드실 수 있습니다. 그 이유는 트랜잭션은 한번의 네트워크 호출만을 허용하기 때문에 클라이언트는 Transaction내의 GET을 통해 결과를 먼저 반환받을 수 없기 때문입니다. 해당 결과를 반환받고 수정하려는 경우 트랜잭션 외부에서 GET을 통해 먼저 값을 읽은 후 트랜잭션을 통해 수정커맨드들을 호출합니다.
Redis Transaction은 ACID를 완벽하게 보장해주지 않습니다. 일단 Rollback이라는 개념이 없고 AOF로 Durability를 어느정도 보장할 수 있지만 완벽하지는 않기 때문입니다.
해당 경우는 두가지 케이스가 있습니다.
1. Exec호출 전 에러 발생 - 주로 문법적 문제
2. Exec호출 후 에러 발생 - 주로 key가 없는 문제등 주로 실제 서버에서 커맨드를 실행해봐야 아는 문제
1의 문제는 2.6.5부터 Discard가 호출 되는 것처럼 커맨드들을 모두 버려버립니다.
2의 문제는 에러가 발생하더라도 그대로 실행됩니다. Redis는 성능 적인 부분을 중요시 하기 때문에 이런 경우 rollback이 존재하지 않습니다. 데이터 정합성 문제가 발생할 수 있기 때문에 주의해야합니다.
Client구현에 따라 다르겠지만 일반적으로 에러를 발생시키고 에러를 발생시키지 않더라도 여러 분산 노드에 대해서 분산 트랜잭션을 수행하면 오버헤드가 클것입니다.
미리 Client에서 해당 key들이 같은 node에 포함되어 있는지 알기는 어렵습니다. cluster node라는 command를 사용하면 알 수 는 있지만 추가적인 호출과 로직을 구현해야하며 만약 resharding이 일어날 경우 더 복잡해지기 때문입니다.
- PlatformTrnasactionalManager하위의 Bean을 등록합니다.
- setEnableTransactionSupport(true);
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<?, ?> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setEnableTransactionSupport(true); // redis Transaction On !
return redisTemplate;
}
@Bean // 만약 PlatformTransactionManager 등록이 안되어 있다면 해야함, 되어있다면 할 필요 없음
public PlatformTransactionManager transactionManager() throws SQLException {
// 사용하고 있는 datasource 관련 내용, 아래는 JDBC
return new DataSourceTransactionManager(datasource());
// JPA 사용하고 있다면 아래처럼 사용하고 있음
return new JpaTransactionManager(entityManagerFactory);
}
}
개인적으로 이 방식이 더 좋다고 생각하는게 @Transactional은 DB Transaction과 혼재되어 원하는 대로 트랜잭션을 걸기 힘들다고 생각합니다. 콜백 형식으로 Multi, Exec, Discard구조를 만들어서 사용하는 것이 좋을 것 같습니다.
boolean sideEffect = true;
List<Object> txResults = stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
public List<Object> execute(RedisOperations operations) throws DataAccessException {
operations.multi();
operations.opsForValue().set("SABARADA", "1");
operations.opsForValue().set("KAROL", "2");
if (sideEffect) {
throw new RuntimeException("exception occur");
}
return operations.exec();
}
});
redis측에서는 미래에는 transaction을 삭제하려고 한다고 합니다. lua scripting이 Transaction이 할수 있는 거의 모든 기능을 제공하면서 더 나은 부분이 많기 때문입니다.
However it is not impossible that in a non immediate future we'll see that the whole user base is just using scripts. If this happens we may deprecate and finally remove transactions. - Redis website
Lua Scripting이란 script 언어 문법을 통해 Redis에 접근할 수 있는 기술입니다. transaction과 마찬가지로 atomic이 보장됩니다.
EVAL은 실행하려는 스크립트를 정의하는 커맨드 입니다. 이때 key의 개수, key, argu가 같이 정의 됩니다.
EVAL "return 'Hello, scredis> EVAL "return { KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3] }" 2 key1 key2 arg1 arg2 arg3
1) "key1"
2) "key2"
3) "arg1"
4) "arg2"
5) "arg3"
주의 할점은 사용할 키는 모두 key인자에 넣어서 사용해야한다는 점과 key들이 모두 하나의 node에 포함되어야 한다는 점입니다.
redis에 명령어를 보낼 수 있는 문법입니다.
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 foo bar
redis.pcall()과 redis.call()의 차이점은 redis.call()은 error가 발생하면 client side로 에러가 반환되고 넘어오게 되고 redis.pcall의 경우 script로 돌아가기 때문에 script내에서 handling하여 처리할 수 있다고 합니다.
모든 key들이 같은 노드에 있어야 실행이 가능합니다. transaction과 차이점은 Lua script를 이용하면 앞서 설명한 것과 같이 명령어를 실행할 때 키를 명시적으로 부여받으므로 서버 측에서 노드 연산을 타겟 서버로 리다이렉션할 수 있습니다.
redis.call()과 return을 활용하면 데이터를 응답받을 수 있습니다.
다음과 같이 실행할 스크립트를 미리 작성해둡니다. 이때 모든 key를 인자로 입력받도록 해야하며 같은 클러스터 노드에 존재해야합니다.
return {redis.call('mget', KEYS[1], KEYS[2]), redis.call('pttl', KEYS[1])};
redis.call('mset', KEYS[1], ARGV[1], KEYS[2], ARGV[2]);
redis.call('expire', KEYS[1], ARGV[3]);
redis.call('expire', KEYS[2], ARGV[3]);
@Configuration
public class LuaScriptConfig {
@Bean
public DefaultRedisScript<List> cacheGetRedisScript(){
DefaultRedisScript<List> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/cache_get.lua")));
redisScript.setResultType(List.class);
return redisScript;
}
@Bean
public DefaultRedisScript<List> cacheSetRedisScript(){
DefaultRedisScript<List> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/cache_set.lua")));
redisScript.setResultType(List.class);
return redisScript;
}
}
private final DefaultRedisScript<List> cacheGetRedisScript;
private final DefaultRedisScript<List> cacheSetRedisScript;
public Object probabilisticEarlyRecomputationGet(String originKey, Function<List<Object>, Object> recomputer, List<Object> args, Integer ttl) {
redisTemplate.execute(cacheSetRedisScript, List.of(key, getDeltaKey(key)), data, computationTime, ttl);
}
return data;
}
io.lettuce.core.RedisCommandExecutionException: CROSSSLOT Keys in request don't hash to the same slot
io.lettuce.core.RedisReadOnlyException: READONLY You can't write against a read only replica. script: e3445b1384645a345ff78b6e7c4313c518d67318, on @user_script:1.
nested exception is io.lettuce.core.RedisCommandExecutionException: ERR Script attempted to access a non local key in a cluster node script: e3445b1384645a345ff78b6e7c4313c518d67318, on @user_script:1.
https://redis.io/docs/manual/programmability/lua-api/#global-variables-and-functions
해당 링크를 참고하시면 될것 같습니다.