Database/Redis

[Redis][Community Edition] 2-7. Manage Redis: Scale with Redis Cluster

noahkim_ 2024. 9. 8. 00:17

1. Redis Cluster 101

  • 수평 확장 및 복제를 위한 공식 구조
항목 설명
Auto Sharding
데이터가 여러 노드에 자동 분산 저장됨 (수동 분할 불필요)
Partial Failure Tolerance
일부 노드에 장애가 생기거나 통신이 끊겨도 클러스터는 일정 수준의 작동을 유지함
전체 장애 시 중단
마스터 노드의 과반수가 다운되면
→ 전체 클러스터가 중단됨
→ 클러스터는 최소 다수의 마스터가 살아있어야 운영 가능

 

TCP ports

포트 종류 설명
클라이언트 포트
- 일반 Redis 명령 처리용 포트 (예: 6379)
- 클라이언트가 명령을 보내는 포트
- 클러스터 간 키 마이그레이션 시에도 사용됨
클러스터 버스 포트
- 노드 간 내부 통신 전용 포트
- 기본값: 클라이언트 포트 + 10000 (예: 16379)
- 바이너리 프로토콜로 작동 (가볍고 빠름)
- 내부 제어 메시지 전달용 (장애 감지, 설정 전파, failover 승인 등)
- ⚠️ 클라이언트는 이 포트에 접근하면 안 됨

 

Docker & NAT

항목 설명
❌ NAT 환경 미지원
Redis Cluster는 NAT나 포트 리맵핑 환경에서는 정상 동작하지 않음
❌ 포트 매핑 비호환
Redis Cluster에서 Docker의 일반적인 포트 매핑 방식은 IP/포트 인식 오류를 발생시킴
✅ host 네트워킹 사용 권장
컨테이너의 네트워크를 호스트와 동일하게 설정해야 함
--net=host 또는 network_mode: host 설정
💡 이유
Redis Cluster는 노드 간 직접 통신을 위해 자신의 IP/포트 정보를 그대로 노출해야 함
NAT나 포트 포워딩이 있으면 노드 간 연결에 실패하게 됨

 

Hash Slot 기반 샤딩

항목 설명 특징
해시 슬롯 개수 16384개  
슬롯 계산 방법
CRC16(key) % 16384
 
노드 책임 범위 각 노드는 일정 범위의 해시 슬롯을 맡아 관리함
예: Node A → 슬롯 0~5500
샤드 재조정 가능
슬롯 단위로 데이터를 다른 노드로 이동 가능
무중단 스케일링 지원
(노드 추가/제거 시에도 동작 중단 없음)

 

멀티 키 명령 제한
  • 여러 키를 다루는 명령어는 해당 키들이 같은 해시 슬롯에 위치해야만 실행됨
  • ❌ 하나의 명령으로 여러 노드를 동시에 조작할 수 없음 (데이터를 여러 노드에 분산 저장하기 때문)

 

해결 방법) Hash Tag 사용

더보기
더보기
MGET user:{123}:name user:{123}:email  → ✅ 실행됨
  • 같은 해시 값을 가지는 식별값에 대해, 키 안에 중괄호를 넣고 그 안의 문자열만 해싱되도록 함
  • 여러 키가 같은 해시 슬롯에 배치됨

 

Master-Replica model

  • Redis Cluster는 모든 해시 슬롯(16384개)을 마스터 노드들이 분할해서 관리
  • 각 마스터는 1개 이상의 레플리카(복제 노드)를 가질 수 있음 (총 N개 중 1개가 마스터, 나머지는 레플리카)
  • 마스터 노드가 죽으면, 해당 마스터의 레플리카 중 하나를 자동으로 마스터로 승격하여 서비스 유지

 

비동기 복제

항목 내용
❌ Strong Consistency 보장 안 함
클라이언트가 OK 받았다고 해서 모든 레플리카에 쓰기가 반영된 건 아님
⚠️ 쓰기 유실 가능성 있음
- OK 응답 직후 마스터 장애 발생
- 아직 레플리카로 복제되지 않은 상태에서 레플리카가 승격되면 데이터 유실
☢️ 네트워크 파티션 시 더 위험
클라이언트가 소수 노드(B)에 붙어 쓰기 계속
→ 다수 노드(B1 승격) 복구되면 해당 쓰기 롤백

 

예시) 쓰기 유실

더보기
더보기
  1. 클라이언트 Z1가 Node B에 SET key value 요청
  2. Node B는 즉시 "OK" 응답
  3. 이후 B → B1, B2 등에 비동기 복제 전송
  4. 그런데! B가 복제 전송 전에 장애 발생 → → → 레플리카는 그 값을 못 받음

 

➡️ 그리고 그중 하나(B1)가 새로운 마스터로 승격되면 클라이언트가 확인받았던 데이터는 사라짐

 

해결) 쓰기 유실

더보기
더보기
WAIT <replica> <ms>
  • 지정된 수의 레플리카가 복제를 확인(ACK)할 때까지 블로킹

 

2. Redis Cluster configuration parameters

설정 항목 설명 기본값
cluster-enabled <yes/no> Redis 인스턴스를 Cluster 모드로 활성화 no
cluster-config-file <파일명>
클러스터 상태를 저장하는 내부 파일
- 클러스터 구성/변경 시 자동 갱신됨 (직접 수정 )
nodes.conf
cluster-node-timeout <ms>
노드 간 통신이 끊겼다고 판단하는 시간
- 해당 시간 동안 응답 없으면 failover 발생 또는 쓰기 차단됨
15000 (15초)
cluster-slave-validity-factor <정수>
레플리카가 마스터 승격을 시도할 수 있는 시간 제한 설정
→ node-timeout × factor 만큼 마스터와의 연결이 끊기면 승격 ❌
10
cluster-migration-barrier <정수>
마스터가 최소 몇 개의 레플리카와 연결되어 있어야 다른 레플리카가 orphan 마스터로 마이그레이션 가능한지 결정
1
cluster-require-full-coverage <yes/no>
모든 슬롯이 커버되지 않으면 쓰기 중단
- no: 일부 키 슬롯만 살아 있어도 동작 가능
yes
cluster-allow-reads-when-down <yes/no>
클러스터가 실패 상태일 때 읽기도 막을지 여부
- yes: 읽기는 허용, 쓰기만 차단
no

 

3. Create and use a Redis Cluster 

Requirements to create a Redis Cluster

설정) redis.conf

더보기
더보기
port 7000
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
  • 각 노드에 해당 설정을 적용함

 

Create a Redis Cluster

명령어) cluster 생성

더보기
더보기
docker run \
    -e "IP=0.0.0.0" \
    -e "BIND_ADDRESS=0.0.0.0" \
    -e "INITIAL_PORT=7001" \
    -e "MASTERS=3" \
    -e "SLAVES_PER_MASTER=1" \
    -p 7001-7006:7001-7006 \
    --name redis-cluster-6 \
    grokzen/redis-cluster:7.0.15

 

Interact with the cluster

명령어) cluster 접속

더보기
더보기
redis-cli -c -p 7000
  • -c: 클러스터 모드 사용.
  • -p: 포트 지정 (여기선 7000번 노드에 연결).

 

명령어) cluster 명령

더보기
더보기
redis 127.0.0.1:7000> set foo bar
-> Redirected to slot [12182] located at 127.0.0.1:7002
OK

redis 127.0.0.1:7002>
  • 해당 키가 slot 12182에 속한다고 판단하고, 이 슬롯을 담당하는 노드(7002번)로 자동 리다이렉트

 

Write an example app with spring-data-redis

  • Redis 클러스터에 연결하기 위해 LettuceCOnnectionFactory를 빈으로 등록하기
  • 소켓 및 클러스터 토폴로지 정보를 클라이언트에 동기화하는 설정이 열려있음

 

설정) SocketOptions

더보기
더보기

소켓 연결 타임아웃 및 TCP Keep-Alive 설정.

SocketOptions socketOptions = SocketOptions.builder()
    .connectTimeout(Duration.ofMillis(100L))
    .keepAlive(true)
    .build();

 

옵션 설명
connectTimeout 연결 타임아웃
keepAlive 커넥션 유지 여부

 

설정) ClusterTopologyRefreshOptions

더보기
더보기

클러스터의 토폴로지 갱신 정책 설정 (슬롯 ↔ 노드 매핑)

ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
    .enableAllAdaptiveRefreshTriggers()
    .enablePeriodicRefresh(Duration.ofSeconds(60))
    .dynamicRefreshSources(true)
    .build();

 

옵션 설명
enableAllAdaptiveRefreshTriggers 클러스터 이벤트 발생 시 갱신 (MOVED/ASK 등)
enablePeriodicRefresh 주기적 토폴로지 강제 갱신
dynamicRefreshSources 모든 노드에게 질의하여 최신 토폴로지 확보

 

설정) ClusterClientOptions

더보기
더보기

Redis 클러스터 클라이언트의 전반적인 동작 정책

ClusterClientOptions clusterClientOptions = ClusterClientOptions.builder()
    .pingBeforeActivateConnection(true)
    .autoReconnect(true)
    .socketOptions(socketOptions)
    .topologyRefreshOptions(clusterTopologyRefreshOptions)
    .maxRedirects(3)
    .build();
옵션 설명
pingBeforeActivateConnection 연결 시 PING으로 유효성 검사
autoReconnect 재연결 활성화
socketOptions 위에서 정의한 소켓 옵션 적용
topologyRefreshOptions 위에서 정의한 토폴로지 리프레시 설정
maxRedirects 리다이렉션 허용 횟수

 

설정) RedisConfig

더보기
더보기
@Bean
public RedisConnectionFactory redisConnectionFactory(ClientResources clientResources, RedisClusterProperties redisClusterProperties) {
    RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration(redisClusterProperties.getClusterNodes()); // TODO: cluster 정보 주입받기
    clusterConfig.setMaxRedirects(3);

    SocketOptions socketOptions = SocketOptions.builder()
            .connectTimeout(Duration.ofMillis(100L))
            .keepAlive(true)
            .build();

    ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
            .enableAllAdaptiveRefreshTriggers()
            .enablePeriodicRefresh(Duration.ofSeconds(60))
            .dynamicRefreshSources(true)
            .build();

    ClusterClientOptions clusterClientOptions = ClusterClientOptions.builder()
            .pingBeforeActivateConnection(true)
            .autoReconnect(true)
            .socketOptions(socketOptions)
            .topologyRefreshOptions(clusterTopologyRefreshOptions)
            .maxRedirects(3)
            .build();

    LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
            .commandTimeout(Duration.ofMillis(150L))
            .clientResources(clientResources)
            .clientOptions(clusterClientOptions)
            .build();

    LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(clusterConfig, clientConfig);
    lettuceConnectionFactory.setValidateConnection(false);

    return lettuceConnectionFactory;
}

 

Reshard the cluster

  • 일부 해시 슬롯을 다른 마스터 노드로 이동시키는 작업

 


명령어) reshard

더보기
더보기
redis-cli --cluster reshard 127.0.0.1:7000
  • 아무 마스터 노드나 입력하면 됨

 

How many slots do you want to move (from 1 to 16384)?
→ 1000  # 예시

What is the receiving node ID?
→ 대상 마스터의 Node ID 입력 (ex: 97a3a64...)

From what node(s) you want to take the slots?
→ all  # 모든 노드에서 분산 추출

 

A more interesting example application

redis cluster의 데이터 일관성이 지켜지는지 테스트

 

테스트) redis cluster test

더보기
더보기
@SpringBootTest(classes = Main.class)
public class RedisClusterTest {

    @Autowired
    private RedisTemplate redisTemplate;

    private static final int KEY_COUNT = 1000;
    private static final String KEY_PREFIX = "key_";

    private final Map<String, Integer> map = new ConcurrentHashMap<>();
    private final Random random = new Random();
    private final ValueOperations<String, Object> stringOps = redisTemplate.opsForValue();

    @Test
    void consistencyTest() throws ExecutionException, InterruptedException {
        AtomicLong readCount = new AtomicLong();
        AtomicLong writeCount = new AtomicLong();
        AtomicLong lostCount = new AtomicLong();

        ExecutorService executorService = Executors.newSingleThreadExecutor();
        Runnable task = () -> {
            long start = System.currentTimeMillis();
            while (System.currentTimeMillis() - start < 3000) {
                try {
                    String readKey = KEY_PREFIX + random.nextInt(KEY_COUNT);
                    String redisVal = stringOps.get(readKey).toString();
                    int actual = redisVal != null ? Integer.parseInt(redisVal) : 0;
                    int expected = map.getOrDefault(readKey, 0);

                    if (actual != expected) lostCount.addAndGet(expected - actual);
                    readCount.incrementAndGet();

                    String writeKey = KEY_PREFIX + random.nextInt(KEY_COUNT);
                    stringOps.increment(writeKey);
                    map.merge(writeKey, 1, Integer::sum);
                    writeCount.incrementAndGet();

                    Thread.sleep(1);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        };

        executorService.submit(task).get();

        System.out.printf("[TEST DONE] %d R | %d W | %d lost \n", readCount.get(), writeCount.get(), lostCount.get());

        executorService.shutdown();
    }
}

 

Test the failover

  • 마스터 노드 하나를 강제로 크래시시켜 failover 발생 시의 시스템 동작 확인
  • replica가 승격되기 전까지는 요청이 거부됨 (예외 발생)
  • 단, 불일치는 일어나지 않음

 

명령) master crash

더보기
더보기
kill -9 [master pid]

 

테스트) redis cluster test

더보기
더보기
@SpringBootTest(classes = Main.class)
public class RedisClusterTest {

    @Autowired
    private RedisTemplate redisTemplate;

    private static final int KEY_COUNT = 1000;
    private static final String KEY_PREFIX = "key_";

    private final Map<String, Integer> map = new ConcurrentHashMap<>();
    private final Random random = new Random();
    private final ValueOperations<String, Object> stringOps = redisTemplate.opsForValue();

    @Test
	void redisClusterConsistencyTest() throws ExecutionException, InterruptedException {
        ValueOperations<String, String> stringOps = redisTemplate.opsForValue();

        AtomicLong readCount = new AtomicLong();
        AtomicLong writeCount = new AtomicLong();
        AtomicLong lostCount = new AtomicLong();
        AtomicLong readFailCount = new AtomicLong();
        AtomicLong writeFailCount = new AtomicLong();

        ExecutorService executorService = Executors.newSingleThreadExecutor();
        Runnable task = () -> {
            long start = System.currentTimeMillis();
            while (System.currentTimeMillis() - start < 3000) {
                try {
                    String readKey = KEY_PREFIX + random.nextInt(KEY_COUNT);
                    String redisVal = stringOps.get(readKey);
                    int actual = redisVal != null ? Integer.parseInt(redisVal) : 0;
                    int expected = map.getOrDefault(readKey, 0);

                    if (actual != expected) lostCount.addAndGet(expected - actual);
                    readCount.incrementAndGet();

                    String writeKey = KEY_PREFIX + random.nextInt(KEY_COUNT);
                    stringOps.increment(writeKey);
                    map.merge(writeKey, 1, Integer::sum);
                    writeCount.incrementAndGet();

                    Thread.sleep(1);
                } catch (Exception e) {
                    String errMsg = e.getMessage();
                    if (errMsg.contains("READ")) readFailCount.incrementAndGet();
                    else if (errMsg.contains("WRITE")) writeFailCount.incrementAndGet();
//                    e.printStackTrace();
                }
            }
        };

        executorService.submit(task).get();

//        System.out.printf("[TEST DONE] %d R | %d W | %d lost \n", readCount.get(), writeCount.get(), lostCount.get());
        System.out.printf("[TEST DONE] %d R | %d W | %d lost | %d R_FAIL | %d W_FAIL\n",
                readCount.get(), writeCount.get(), lostCount.get(), readFailCount.get(), writeFailCount.get());

        executorService.shutdown();
    }
}

 

Manual failover

  • master를 안전하게 내리는 명령
  • 해당 master에 요청을 날려도 자동으로 관련 replica에 요청을 리다이렉트 (클라이언트 측에 예외가 던져지지 않음)

 

명령) master failover

더보기
더보기
redis-cli -p [replica port] cluster failover
  • master의 replica 포트로 요청해야 함

 

Add a new node

Remove a node

Replica migration

Upgrade nodes in a Redis Cluster

Migrate to Redis Cluster

 

 

 

출처