Redis - atomic하게 실행하여 동시성 문제를 방지하는법(Transaction, Lua-Scripting)

taehee kim·2023년 6월 2일
7
post-custom-banner

0. 작성 배경

Redis를 사용하면서 동시성 문제가 발생할 수 있음을 알게 되었고 왜 Single Thread임에도 동시성 문제가 발생할 수 있는지, 해당 문제를 어떻게 해결할 수 있는지 등을 정리해보려고 합니다.

1. Redis Single Thread

Redis는 Single Thread입니다. Single Thread이기 때문에 다음과 같은 장점들을 갖습니다.

장점

  1. 스레드 생성, 컨텍스트 스위칭으로 인한 오버헤드 감소
  2. 동시성 문제로 인한 복잡성, 오버헤드 감소(락 생성, MVCC등 복잡한 설계와 오버헤드가 적게 발생함.)

하지만 다음과 같은 한계점들도 가질 수 있습니다.

단점

  1. 특정 연산이 오랜 시간이 걸릴 경우 이후 요청 및 처리가 지연되면서 장애, 병목 발생 가능
  2. CPU 멀티코어를 활용하기 힘듬
  3. 동시성 문제에서 고려할 부분이 적지만 Single Thread는 Parallel할 수 없는 것이지Concurrent 할 수 있기 때문에 동시성 문제가 발생함.

단점 극복 방법

  1. 특정 연산을 주의 하면 됨.
  2. Clustering시 multi core를 multi process방식으로 활용할 수 있음.
  3. Transaction, lua script와 같은 atomic을 보장해주는 기능을 활용하는 것만으로도 동시성 문제를 일으키지 않음.

앞으로 이야기할 내용은 마지막에 언급한 Transaction, lua script 입니다. 해당 기능 사용시 주의점과 동작 방식에 대해서 알아보려고 합니다. 해당 공식문서를 참고하였습니다.
https://redis.io/docs/manual/transactions/
https://redis.io/docs/manual/programmability/eval-intro/

2. Redis Transaction

Redis 트랜잭션의 핵심 개념은 모든 command가 Redis에서 atomic하게 실행되면 Redis는 Single Thread이기 때문에 실행되는 동안 다른 Client 트랜잭션에서 실행된 command가 실행될 수 없습니다. 따라서 트랜잭션을 거는 것 만으로 순차적으로 트랜잭션이 실행되기 때문에 동시성 문제가 발생하지 않습니다.

2-1. Multi, Exec|Discard

Multi

트랜잭션을 시작하는 커맨드 입니다. Match이후로 실행하는 커맨드는 바로 실행되지 않고 쌓입니다. Exec|Discard를 호출할때 한번에 실행됩니다.

Exec

Match 이후로 실행된 커맨드들을 한번에 실행합니다. 이때 Exec이전에는 Client쪽에서 해당 커맨드들을 모아놓고 있다가 해당 커맨드 호출시 단 한번의 네트워크 호출로 모두 전송하고 실행합니다. Exec이후에 실행된 커맨드에 해당하는 key가 없는 등 커맨드가 정상 실행되지 않더라도 트랜잭션은 취소 되지않습니다.

Discard

Multi이후에 모아놓은 커맨드를 모두 버립니다.

Watch

Watch로 특정키를 지정하면 Exec하기까지 사이에 변경이 발생할 경우 트랜잭션을 종료합니다.
낙관적 락의 매커니즘입니다.

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
  • 다음 커맨드를 두 클라이언트가 수행하면 val은 원래라면 +2가 되어야 하지만 +1이 될 수 있을 것입니다. 이런 경우를 방지하기 위해 WATCH를 수행하면 뒤에 발생한 트랜잭션은 실패합니다.

트랜잭션만 있어도 원자적으로 수행하여 동시성을 보장해줄 수 있을텐데 왜 Watch가 따로 있는지 이상하다는 생각이 드실 수 있습니다. 그 이유는 트랜잭션은 한번의 네트워크 호출만을 허용하기 때문에 클라이언트는 Transaction내의 GET을 통해 결과를 먼저 반환받을 수 없기 때문입니다. 해당 결과를 반환받고 수정하려는 경우 트랜잭션 외부에서 GET을 통해 먼저 값을 읽은 후 트랜잭션을 통해 수정커맨드들을 호출합니다.

2-2. ACID

Redis Transaction은 ACID를 완벽하게 보장해주지 않습니다. 일단 Rollback이라는 개념이 없고 AOF로 Durability를 어느정도 보장할 수 있지만 완벽하지는 않기 때문입니다.

2-3. 트랜잭션 동작 원리 및 제약사항

2-3-1. 한번의 네트워크 호출

  1. 하나의 트랜잭션은 클라이언트에서 레디스 서버로 한번만 네트워크 호출을 합니다. 효율성을 위해서 입니다. 이 때문에 생기는 제약사항은 다음과 같습니다.
  • Spring Data Redis에서 Redis Template에서 Transaction, Pipeline을 활용할 때 조회를 사용하면 항상 null값을 return 합니다. 그 이유는 트랜잭션은 exec할때 한번의 네트워크 호출을 보내기 때문에 command를 실행하는 시점에 바로 값을 반환받지 못하기 때문입니다. 따라서 값을 받고 다시 SET을 수행하는 로직을 구현할 수 없습니다.

2-3-2. 트랜잭션 중 에러 발생

해당 경우는 두가지 케이스가 있습니다.
1. Exec호출 전 에러 발생 - 주로 문법적 문제
2. Exec호출 후 에러 발생 - 주로 key가 없는 문제등 주로 실제 서버에서 커맨드를 실행해봐야 아는 문제

1의 문제는 2.6.5부터 Discard가 호출 되는 것처럼 커맨드들을 모두 버려버립니다.
2의 문제는 에러가 발생하더라도 그대로 실행됩니다. Redis는 성능 적인 부분을 중요시 하기 때문에 이런 경우 rollback이 존재하지 않습니다. 데이터 정합성 문제가 발생할 수 있기 때문에 주의해야합니다.

2-3-3. Sharding이 되어있는 경우 같은 node의 key만 트랜잭션에 포함되어야 합니다.

Client구현에 따라 다르겠지만 일반적으로 에러를 발생시키고 에러를 발생시키지 않더라도 여러 분산 노드에 대해서 분산 트랜잭션을 수행하면 오버헤드가 클것입니다.
미리 Client에서 해당 key들이 같은 node에 포함되어 있는지 알기는 어렵습니다. cluster node라는 command를 사용하면 알 수 는 있지만 추가적인 호출과 로직을 구현해야하며 만약 resharding이 일어날 경우 더 복잡해지기 때문입니다.

2-4. SpringBoot로 Redis Transaction 사용법

2-4-1. @Transactional로 사용하는법

  1. PlatformTrnasactionalManager하위의 Bean을 등록합니다.
  2. 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);
  }
}

2-4-2. RedisTemplate.execute와 SessionCallback으로 사용하는법

개인적으로 이 방식이 더 좋다고 생각하는게 @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();
    }
});

3. Lua Scripting

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이 보장됩니다.

3-1. EVAL

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에 포함되어야 한다는 점입니다.

3-2. redis.call()

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하여 처리할 수 있다고 합니다.

3-3. 특징들

3-3-1. 모든 key들이 같은 노드에 있어야합니다.

모든 key들이 같은 노드에 있어야 실행이 가능합니다. transaction과 차이점은 Lua script를 이용하면 앞서 설명한 것과 같이 명령어를 실행할 때 키를 명시적으로 부여받으므로 서버 측에서 노드 연산을 타겟 서버로 리다이렉션할 수 있습니다.

3-3-2. transaction과 달리 get 명령어의 결과를 받을 수 있습니다.

redis.call()과 return을 활용하면 데이터를 응답받을 수 있습니다.

3-3-3. 프로그래밍적인 문법을 redis server 내에서 실행시킬 수 있습니다.

3-4. SpringBoot에서 구현

3-4-1. Lua Script 작성

다음과 같이 실행할 스크립트를 미리 작성해둡니다. 이때 모든 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]);

3-4-2. DefaultRedisScript Bean 등록

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

3-4-3. redis.execute()로 실행

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

3-4-4. 에러 사항들

  • 다른 node에 포함된 key에 명령
io.lettuce.core.RedisCommandExecutionException: CROSSSLOT Keys in request don't hash to the same slot
  • Replica에 readonly = true로 되어있는 경우
 io.lettuce.core.RedisReadOnlyException: READONLY You can't write against a read only replica. script: e3445b1384645a345ff78b6e7c4313c518d67318, on @user_script:1.
  • key를 인자로 넘겨주지 않고 직접 작성한 경우
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.

3-5. 상세 문법

https://redis.io/docs/manual/programmability/lua-api/#global-variables-and-functions
해당 링크를 참고하시면 될것 같습니다.

profile
Fail Fast
post-custom-banner

0개의 댓글