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 (일부 노드에 장애가 생기거나 통신이 끊겨도 클러스터는 작동을 유지함)
  • ⚠️ 마스터 노드의 과반수가 다운되면 전체 클러스터 다운 (다수의 마스터가 살아있어야 운영 가능)

 

Master-Replica model

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

 

TCP ports

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

 

Docker & NAT

  • NAT나 Docker의 포트 매핑 방식에서 정상적으로 동작하지 않음
  • ⚠️ IP/Port 불일치로 노드 간 연결 실패
  • ✅ 노드 간 통신 시, 자신이 advertise한 IP/Port로만 연결 가능
  • ➡️ 노드의 advertise-ip/port와 요청 ip/port가 일치해야 함

 

해결 방법) host 네트워크

더보기

docker-compose.yml

version: '3'
services:
  redis-7000:
    image: redis:7
    network_mode: host
    command: >
      redis-server --port 7000
                   --cluster-enabled yes
                   --cluster-config-file nodes.conf
                   --cluster-node-timeout 5000

 

해결 방법) announce 설정

더보기

redis.conf

port 6379

cluster-enabled yes
...

cluster-announce-ip 192.168.0.10
cluster-announce-port 7000
cluster-announce-bus-port 17000

 

비동기 복제

  • eventually consistency (master는 replica의 ACK 없이 성공 처리함)
  • ⚠️ 쓰기 유실 가능성 있음
  • ⚠️ 네트워크 파티션 시 더 위험함

 

예시) 쓰기 유실

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

 

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

 

해결 방법) 쓰기 유실

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

 

예시) 네트워크 파티션

더보기
  • 네트워크 파티션으로 인해 클라이언트가 소수 노드(B)에 붙어 계속 쓰기 시작함
  • 승격 시, 과반수 있는 쪽이 살아남고 소수 쪽은 버려짐
  • 다수 노드(B1 승격) 복구되면 소수 노드에서 수행된 쓰기가 롤백될 수 있음

 

샤딩

  • 16384개의 Hash Slot을 기준으로 데이터를 분산 저장함
  • ✅ 슬롯 계산: CRC16(key) % 16384
  • ✅ 각 노드는 일정 범위의 해시 슬롯을 맡아 관리함
  • ✅ 무중단 스케일링 (노드 추가/제거 시에도 중단 없음)
  • ⚠️ 하나의 명령으로 여러 노드를 동시에 처리 불가 (노드 간 분산 구조이므로)

 

해결 방법) Hash Tag 사용

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

 

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

 

 

 

출처