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 승격) 복구되면 해당 쓰기 롤백 |
예시) 쓰기 유실
더보기
더보기
- 클라이언트 Z1가 Node B에 SET key value 요청
- Node B는 즉시 "OK" 응답
- 이후 B → B1, B2 등에 비동기 복제 전송
- 그런데! B가 복제 전송 전에 장애 발생 → → → 레플리카는 그 값을 못 받음
➡️ 그리고 그중 하나(B1)가 새로운 마스터로 승격되면 → 클라이언트가 확인받았던 데이터는 사라짐
해결) 쓰기 유실
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 접속
명령어) 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
테스트) 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
Add a new node
Remove a node
Replica migration
Upgrade nodes in a Redis Cluster
Migrate to Redis Cluster
출처
'Database > Redis' 카테고리의 다른 글
[실전 레디스] 2-2. 자료형과 기능: 보조 자료형 (0) | 2025.03.20 |
---|---|
[실전 레디스] 2-1. 자료형과 기능: 기본 자료형 (0) | 2025.03.20 |
[실전 레디스] 1. 레디스의 시작 (0) | 2025.03.19 |
[Redis][Community Edition] 2-6. Manage Redis: Replication (0) | 2024.09.08 |
[Redis] 2. Understanding Data Types (0) | 2024.09.05 |