Sentinel은 Redis와는 별도의 구성으로, Redis Sentinel 문서에서 권장하는 최소 Sentinel인 3대의 Sentinel을 띄워 quorum을 만족시키도록 구성했다. 위 포스팅에서도 언급한 바 있지만, Sentinel은 failover 시 quorum을 만족해야 마스터를 전환할 수 있는데, 2개 이상의 Sentinel이 동의해야 객관적 장애(odown)로 판단할 수 있기 때문이다.
#entrypoint.sh
#!/bin/sh
sed -i "s/\$SENTINEL_QUORUM/$SENTINEL_QUORUM/g" /etc/redis/sentinel.conf
sed -i "s/\$SENTINEL_DOWN_AFTER/$SENTINEL_DOWN_AFTER_MS/g" /etc/redis/sentinel.conf
sed -i "s/\$SENTINEL_FAILOVER/$SENTINEL_FAILOVER_TIMEOUT/g" /etc/redis/sentinel.conf
exec docker-entrypoint.sh redis-server /etc/redis/sentinel.conf --sentinel
# sentinel.conf
# Example sentinel.conf can be downloaded from http://download.redis.io/redis-stable/sentinel.conf
port 26379
dir /tmp
# 도커 서비스명을 hostname으로 인식,
# 해당 설정이 없으면 `Can't resolve master instance hostname` 오류 발생.
sentinel resolve-hostnames yes
# master redis를 감시.
sentinel monitor mymaster redis-master 6379 $SENTINEL_QUORUM
# 장애 간주 시간 설정 (MS)
sentinel down-after-milliseconds mymaster $SENTINEL_DOWN_AFTER_MS
# 새로운 master가 된 redis에 동기화할 수 있는 slave(repl) 제한
sentinel parallel-syncs mymaster 1
# failover과정 전체의 timeout
sentinel failover-timeout mymaster $SENTINEL_FAILOVER_TIMEOUT
bind 0.0.0.0
이전 글에서 메시지 큐의 장애 발생 상황을 여러가지로 가정하고, 간단한 해결책들을 생각해서 서술했었다.
이번 글에서는 그 중에서도 특히 많은 메시지 큐에서 Redis를 저장소로 사용하거나 지원하는 만큼, Redis의 failover전략 중 하나인 Redis Sentinel에 대해 공식 문서와 실제 사례를 기반으로 공부한 내용을 작성한다.
Redis에 장애가 발생한다면?
생각해보면 Redis는 애플리케이션을 구성할 때 거의 대부분 사용했던 것 같다. 질의를 위한 쿼리에 대한 최적화를 수행해도 UX를 저해하는 경우에 캐싱하여 사용하고 있다. 추가로 랭킹 등의 집계 후 자주 변하지 않는 데이터에도 Redis에 올려 사용하고, 주기적으로 갱신하곤 했다. 기타 여러 상황들이 있겠지만, 나의 경우는 이 대부분의 모든 카테고리가 캐싱 이다.
내가 사용하는 Redis사례나 기타 사례 등은 Redis의 빠른 응답 특성을 이용해 최대한 DB 조회를 피하고자 하는 전략이 대부분이다.
보통의 이런 캐싱 전략에서, 정해놓은 주기가 만료된 후의 최초 요청에서는 캐싱된 데이터가 Redis에 존재하지 않기 때문에 DB Fetching 후 Redis에 적재하는 일련의 과정을 거친다. 만약에 이 Redis에 문제가 생겨서 Redis 서버가 다운됐다고 가정해보자.
Redis에 데이터가 없는 것을 포함한 모든 예외 상황 시 DB에서 데이터를 가져오게 만들었다고 가정해보자. 이제 Redis 장애로 인해 모든 캐시 미스 요청이 DB로 직행하게 되고, 이는 곧 DB의 TPS가 급증하게 되어 DB CPU의 과부하로 이어진다. 단일 DB 인프라에서는 특히 감당하지 못하고 서버 전체가 죽는 시나리오로 연결될 수 있다.
직전 포스팅에서도 메시지 큐가 레디스의 문제로 동작하지 않는다면, 서비스 직원분들의 업무 알림이 전혀 발생하지 않아 모든 업무가 마비될 것이다. 이는 곧 매출에 심각한 영향이 발생할 수 있다.
간단히 Redis의 장애 발생 시 여파들에 대해 알아봤다. Redis의 확장을 고려해야할 때가 온다면, Cluster에 대해서도 깊게 다뤄볼 예정이다. 하지만 고가용성만을 목적으로 했기 때문에 아래에서부터는, 현재의 환경에 맞춘 failover 을 구성하기 위한 Sentinel만을 다룬다.
Redis Sentinel
Redis Sentinel은 Redis의 고가용성(HA: High Availability)을 보장하기 위한 구성방식이다.
Sentinel의 특징
Sentinel은 Active-Passive 구조로 동작한다. 즉, 하나의 Master 노드가 활성화되어 있고, 나머지 Replica 노드들은 대기 상태에 있다. Sentinel은 이 Redis 인스턴스들을 모니터링 하며, 장애가 발생했을 때 해당 상태를 감지 하고, 알림을 전송 하며, 필요 시 Replica중 하나를 Master로 승격시켜 자동으로 failover를 수행 한다.
Sentinel 사용 권장사항
Sentinel은 단순한 Redis 프로세스가 아니다. 서로 통신하고 감시하며 장애 발생 시 투표를 통해 Failover을 트리거하는 분산시스템의 일부 이다. 이런 Sentinel이 하나만 있다면, 그것이 죽는 순간 전체 시스템의 복구 능력도 함께 사라진다. Sentinel을 1개만 두는 경우, 해당 인스턴스에서 장애 발생 시 아무도 Redis를 감지할 수 없고, 자동 failover도 동작하지 않는다. 가용성을 위한 감시 시스템 자체가 SPOF 이 되는 셈이다.
그래서 Redis 공식 문서에서도 항상 3개 이상의 Sentinel 프로세스를 운영할 것을 가장 우선해서 권장한다. 이런 이유로 Sentinel은 독립적으로 장애가 발생할 것으로 예상되는 서버에 배치하는게 좋다. 아래 예시 구성에서는 도커 컨테이너로 세 개의 Sentinel을 띄워 테스트해 볼 예정이다.
다시 정리하고 넘어가자면, Sentinel을 분산 배치하는 이유는 장애 감지의 신뢰성 확보와 더불어 자체 장애에 대한 복원력 확보에 있다. 특정 Sentinel의 오탐지로 잘못 바뀌는걸 방지하며, Sentinel이 죽더라도 다른 Sentinel들이 감시를 이어갈 수 있기 때문이다.
Sentinel들은 다음과 같은 방식으로 협업한다.
모든 Sentinel들은 Redis Master의 상태를 독립적으로 모니터링한다.
Master에 문제가 생긴 것으로 의심되면, 이를 다른 Sentinel에게 전파한다.
이 상태에서 정해진 정족수(Quorum)이상이 Master가 죽었다고합의(Voting)하면, Failover가 시작된다.
Sentinel들은Quorum이라는 설정된 최소 합의 Sentinel 수가 서로 합의되어야 failover를 수행한다. 이 때 Quorum은 기본적으로 과반수를 따르지만, 설정을 통해 지정할 수도 있다.
Sentinel은 "하나의 Sentinel은 누구도 믿지 않는다" 는 전제를 기반으로 신뢰 기반의 감시 구조를 만들기 위해 반드시 다중 구성과 투표 기반 구조를 요구한다.
그렇다면, Sentinel은 언제 어떤 기준으로 Master가 죽었다고 판단할까? 장애 인식 과정을 간단하게 알아보자.
장애 인식과 Failover
Sentinel은 SDOWN(주관적 다운)과 ODOWN(객관적 다운)을 통해 Master 노드의 장애를 인식한다.
우선 각 Sentinel은 개별적으로 ping을 보내 상태를 감시하는데, 이 때 응답이 없다면 SDOWN으로 간주한다.
잠시 네트워크 이상 등의 일시적인 현상일 수도 있기 때문에, 주관적 다운 상태로 변경된다. 하지만 여러 Sentinel들이 동일하게 이 SDOWN을 감지한다면 Master 노드에 문제가 있는 상태로 간주한다. Quorum 이상의 SDOWN이 감지되는 이 때, ODOWN으로 승격되고 failover 작업이 시작된다.
가장 먼저 투표 요청을 보내고, 과반수 이상의 동의를 얻은 Sentinel이 리더로 선출된다. 이 때 리더는 다음과 같은 역할을 수행한다.
Replica 노드들 중 적절한 노드를 선택하여 새로운 Master로 전환한다
나머지 Replica를 새 Master를 바라보도록 설정
다른 Sentinel들과 클라이언트에게 새로운 Master의 정보를 전파
새로운 Master 노드가 선정되더라도 시스템 전체가 기존처럼 하나의 구성으로 수렴되어야한다. 리더 Sentinel은 새로운 레디스의 구성을 Configuration Epoch 값과 함께 전파한다. 이 값은 일종의 버전 관리를 위한 값으로 가장 최신의 구성이 무엇인지를 구분할 수 있게 해준다.
또한, 모든 Sentinel들은 __sentinel__: hello 채널을 통해 주기적으로 구성 정보를 공유 하는데, 이 때 더 높은 epoch를 가진 구성을 선택하여 자연스럽게 일관적인 시스템 구성으로 수렴 된다.
512MB 미만의 일반적인 문자열을 저장할 수 있다. 직렬화된 객체나 이진 배열을 포함한다.
바이너리 데이터를 포함할 수 있어 이미지 저장이 가능하다.
HTML Fragments, 페이지 등의 캐싱에 유용하다.
증감 연산을 사용할 수 있다. (INCR, DECR)
SETEX, PSETEX, SETNX는 2.6.12버전부터 deprecated되었고, SET에서 통합하여 사용이 가능하다.
127.0.0.1:6379> SET STRING1 "STRING1"
OK
127.0.0.1:6379> GET STRING1
"STRING1"
127.0.0.1:6379> SET STRING1 "STRING1" NX
(nil)
127.0.0.1:6379> SET STRING1 "STRING2" XX
OK
127.0.0.1:6379> GET STRING1
"STRING2"
127.0.0.1:6379> SET STRING2 "STRING2" EX 1
OK
# 1초 뒤
127.0.0.1:6379> GET STRING2
(nil)
SET 명령어
명령어
구문
설명
시간복잡도
SET
key value [NX | XX] [GET] [EX s | PX ms | EXAT unix-s | PXAT unix-ms | KEEPTTL]
데이터를 저장한다. key가 있으면 덮어쓴다.
O(1)
SETRANGE
key offset value
지정된 offset에서 시작하여 value의 전체 길이로 덮어쓴다.
O(1)
MSET
key value [key value ...]
여러 데이터를 한 번에 저장한다.
O(N)
MSETNX
key value [key value ...]
여러 데이터를 한 번에 저장한다. 이미 존재하는 키가 있을 경우, 아무 작업도 수행하지 않는다.
O(N)
APPEND
key value
데이터가 존재하는 경우, value를 추가한다. 데이터가 존재하지 않을 경우 데이터를 저장한다 (=SET)
O(1)
NX(Not Exists) | XX(Exists): key가 없을 경우에만 저장 | 존재할 경우 업데이트
EX | PX: 초 단위, 밀리초 단위 만료 설정
EXAT | PXAT: 초 단위의 unixtimestamp 만료 설정, 밀리초 단위의 unixtimestamp 만료 설정
KEEPTTL: 업데이트 시 기존 TTL을 유지
GET 명령어
명령어
구문
설명
시간복잡도
GET
key
key에 해당하는 value를 조회한다.
O(1)
GETRANGE
key start end
start ~ end의 idx에 해당하는 value를 조회한다. (substr)
O(N)
MGET
key [key ...]
key에 해당하는 value들을 조회한다.
O(N)
STRLEN
key
value의 길이를 조회한다.
O(1)
INCR 명령어
64비트 Integer 혹은 Float를 증감시킨다.
명령어
구문
설명
시간복잡도
INCR
key
value를 1 증가시킨다.
O(1)
INCRBY
key increment
increment만큼 value를 증가시킨다.
O(1)
INCRBYFLOAT
key increment
increment만큼 부동소수점을 증감시킨다.
O(1)
DECR
key
value를 1 감소시킨다.
O(1)
DECRBY
key decrement
decrement만큼 value를 감소시킨다.
O(1)
기타
명령어
구문
설명
시간복잡도
GETDEL
key
key에 해당하는 value를 조회하고, 삭제한다.
O(1)
GETEX
key [EX s | PX ms | EXAT unix-s | PXAT unix-ms | PERSIST]
리스트의 마지막 index를 POP. 리스트에 데이터가 없다면 timeout만큼 BLOCKING된다.
O(N)
BRPOPLPUSH
source destination timeout
BRPOP + LPUSH source 리스트에서 데이터를 꺼내서 destination의 왼쪽에 넣는다.
O(1)
리스트에 데이터가 들어올 때 까지대기할 수 있다는 특성을 이용하여 Task Queue, 실시간 이벤트 처리, 로드 밸런싱 등으로 활용된다.
기타 명령어
명령어
구문
설명
시간복잡도
LMOVE
source destination <LEFT | RIGHT> <LEFT | RIGHT>
source의 POP위치, destination의 SET 위치를 결정하여 source 리스트에서 destination 리스트로 데이터 이동.
O(1)
LPOS
key element [RANK rank] [COUNT num-matches] [MAXLEN len]
리스트 내에서 rank번째로 등장하는 element를 최대 count만큼 len 내에서 찾는다.
O(N)
LTRIM
key start stop
start ~ stop 범위에 속하지 않는 데이터를 삭제한다.
O(N)
127.0.0.1:6379> RPUSH LPOS A B C B E C B D
(integer) 8
127.0.0.1:6379> LRANGE LPOS 0 -1
1) "A"
2) "B"
3) "C"
4) "B"
5) "E"
6) "C"
7) "B"
8) "D"
127.0.0.1:6379> LPOS LPOS B
(integer) 1
127.0.0.1:6379> LPOS LPOS B RANK 2
(integer) 3
127.0.0.1:6379> LPOS LPOS B RANK 2 COUNT 2
1) (integer) 3
2) (integer) 6
127.0.0.1:6379> LPOS LPOS B RANK 3 COUNT 2
1) (integer) 6
127.0.0.1:6379> LPOS LPOS B MAXLEN 1
(nil)
127.0.0.1:6379> LPOS LPOS B COUNT 2 MAXLEN 4
1) (integer) 1
2) (integer) 3
Set
우리가 아는 그 Set 자료구조이다.
집합 연산(교집합, 합집합, 차집합 등)을 수행할 수 있다.
주로 관계를 나타낼 때 사용한다.
Set의 저장 가능한 최대 원소 개수는 2^32-1개이다.
SET 명령어
명령어
구문
설명
시간복잡도
SADD
key member [member ...]
데이터를 저장한다. 이미 존재하는 데이터는 무시된다.
O(1)
SMOVE
source destination member
source에서 destination으로 데이터를 옮긴다. 이미 존재한다면 추가되지 않는다.
O(1)
GET 명령어
명령어
구문
설명
시간복잡도
SMEMBERS
key
SET 전체를 조회한다.
O(N)
SISMEMBER
key member
SET 내부에 member가 있다면 1, 없다면 0을 반환한다.
O(1)
SMISMEMBER
key member [member ...]
SISMEMBER의 다중 검사
O(N)
SRANDMEMBER
key [N]
SET의 임의의 데이터 N개를 반환한다.
O(N)
SCARD
key
카디널리티를 반환한다. (SET은 중복이 불가능하므로 SIZE와 동일하다.)
O(1)
SSCAN
key cursor [MATCH pattern] [COUNT count]
SET 내부의 일정 단위 개수만큼 데이터를 가져온다.
SRANDMEMBER의 N
양수일 경우 반복되는 요소는 없으며, SET의 SIZE보다 클 경우 SET 전체를 반환한다.
음수일 경우 반복되는 요소가 존재하며, SET의 SIZE보다 크더라도 N개 만큼의 요소가 완전히 무작위로 반환된다.
SSCAN
작은 데이터셋일 경우 COUNT와 무관하게 전체 데이터를 가져올 수 있다.
패턴은 item*처럼 패턴을 사용할 수 있다.
Redis의 SCAN 명령어(SSCAN, ZSCAN, HSCAN)는 모두 Hash Bucket 기반으로 동작한다.
명령어의 COUNT 옵션은 버킷 개수를 조정하는 힌트 역할을 하며 정확한 개수를 보장하지는 않는다. 데이터셋이 작다면 위 사진처럼 COUNT값을 조정해도 전체 데이터를 한 번에 반환할 가능성이 크다. 이러한 특징으로 대용량 데이터를 순회하는 데 유리하다.
POP 명령어
명령어
구문
설명
시간복잡도
SPOP
key [N]
SET의 임의의 데이터 N개를 POP
O(N)
REMOVE 명령어
명령어
구문
설명
시간복잡도
SREM
key member [member ...]
SET에서 지정한 요소들을 삭제한다.
O(N)
집합 명령어
명령어
구문
설명
시간복잡도
SUNION
key [key ...]
SET들의 합집합
O(N)
SUNIONSTORE
destination key [key ...]
합집합을 destination SET에 저장
O(N)
SINTER
key [key ...]
SET들의 교집합
O(N * M)
SINTERSTORE
destination key [key ...]
교집합을 destination SET에 저장
O(N * M)
SINTERCARD
M key [key ...] [LIMIT limit]
교집합의 카디널리티(개수)를 반환
O(N * M)
SDIFF
key [key ...]
SET들의 차집합
O(N)
SDIFFSTORE
destination key [key ...]
차집합을 destination SET에 저장
O(N)
활용 예시
좋아요 기능을 Set으로 간단하게 구현할 수 있다. 좋아요 기능처럼 자주 I/O가 발생하고, UNIQUE를 동시에 제어해야하는 상황 등에 알맞게 사용할 수 있다.
SortedSet (ZSet)
정렬을 지원하는 SET으로 SET에 저장된 score를 기준으로 자동 정렬된다.
Skip List 구조로 정렬된 상태를 유지하며, 탐색에는 O(logN)의 시간이 걸림.
시간 기반 정렬과 범위 삭제(Sliding Window)에 최적화되어있다.
리더보드(랭킹)나 우선순위 큐(Priority Queue)를 쉽게 구현할 수 있다.
ZREMRANGEBYSCORE, ZCOUNT를 활용하여 Rate Limiter을 쉽게 구현할 수 있다.
특정 API에 Rate Limit을 설정하고 싶다고 해보자. 내가 설정한 정책은 1분에 5번의 요청만 가능하도록 설정해두었다. 이럴 때 아래처럼 URI:User로 SortedSet을 생성하여, 요청에 대한 시간값을 score로 담는다. 요청을 수행하기 전에 ZCOUNT로 60초 이내에 해당 유저가 몇 번 요청을 하였는지 확인하고 5번을 초과하였다면 429를 반환할 수 있다. 60초가 지났다면 ZREMRANGEBYSCORE를 통해 범위 밖의 데이터들을 초기화해줄 수 있다.
SET 명령어
명령어
구문
설명
시간복잡도
ZADD
key [NX | XX] [GT | LT] [CH] [INCR] score member [score member ...]
score을 가진 요소를 추가한다.
O(logN)
NX(Not Exists) | XX(Exists): 새로운 요소만 추가 | 기존 요소를 업데이트
LT(Less Than) | GT(Greater Than): 요소가 이미 있을 때, 현재 score보다 낮거나 높은 경우에만 추가한다.
CH(Changed): 변경된 요소의 개수를 반환한다. (설정하지 않으면 변경되었는지 보이지 않는다.)
INCR(Increase): 요소가 있다면 score만큼 누적시킨다. (없다면 요소를 추가)
GET 명령어
ZRANGEBYLEX, ZRANGEBYSCORE, ZREVERANGE, ZREVERANGEBYSCORE, ZREVERANGEBYLEX는 6.2.0버전부터 deprecated되었고, ZRANGE에 통합하여 사용이 가능하다.
SortedSet을 활용하여 트래픽 제어를 통한 안정성 확보 뿐 아니라 위처럼 과금별 Rate Limits를 제어하는 등의 비즈니스 모델도 수립할 수 있다. 이와 같은 Window Rate Limiter을 SortedSet을 통해 정책 시간 별 카디널리티가 초과되었다면 429를 반환하여 Rate Limiter을 구현할 수 있다.
Sendbird의 과금 별 RateLimites
Hash
Field - Value형태로 Map / Dictionary 형태와 유사하다.
SET 명령어
HMSET은 4.0.0버전부터 deprecated되었고, HSET을 사용하여 Multi-Set이 가능하다.
XREADGROUP은컨슈머 그룹을 사용해 메세지를 여러 개의 컨슈머에 분배하는 명령어이다.한 메세지는 한 컨슈머에게만 전달되며, 그룹 내부에서는PEL(Pending Entries List)을 관리하여 메세지 상태를 추적할 수 있으며비동기 방식(BLOCK)으로 사용이 가능하다.
PEL(Pending Entries List)
컨슈머 그룹이 메세지를 읽을 때 Redis에서는 PEL에 메세지를 저장하고 XACK를 호출해야 메세지가 PEL에서 제거된다. PEL에 남아있는 메세지는처리 중이거나 실패한 메세지로 간주된다. 하지만 NOACK 옵션을 통해자동으로 Acked(확인)된 것으로 간주하여 PEL에 저장하지 않을 수도 있다.
명령어
구문
설명
시간복잡도
XACK
key group id [id ...]
컨슈머에 메세지 처리 완료 알림 처리. 컨슈머 그룹의 PEL에서 메세지 삭제.
O(1)
XCLAME
key group consumer min-idle-time id [id ...] [IDLE ms] [TIME unix-mils] [RETRYCOUNT count] [FORCE] [JUSTID] [LASTID lastid]
미처리 메세지를 다른 컨슈머에 재할당
O(logN)
XAUTOCLAME
key group consumer min-idle-time start [COUNT count] [JUSTID]
오래된 미처리 메세지를 자동으로 재할당 XCLAME + XPENDING
O(1)
XREADGROUP
GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] id [id ...]
컨슈머 그룹을 활용한 메세지 분배 후 POP
O(N)
XPENDING
key group [[IDLE min-idle-time] start end count [consumer]]
컨슈머 그룹의 미처리 메세지 조회
O(N)
XGROUP
CREATE key group <id | $> [MKSTREAM] [ENTRIESREAD entries-read]
새로운 컨슈머 그룹 생성
O(1)
CREATECONSUMER key group consumer
특정 컨슈머 그룹에 새로운 컨슈머 추가
O(1)
SETID key group <id | $> [ENTRIESREAD entries-read]
컨슈머 그룹의 마지막 메시지 ID 변경
O(1)
DELCONSUMER key group consumer
컨슈머 그룹에서 특정 컨슈머 제거
DESTROY key group
컨슈머 그룹 제거
O(N)
min-idle-time: 최소 대기 시간 (이 시간 동안 PEL에 있던 메세지만 가져옴)
FORCE: 기존 컨슈머가 존재하지 않아도 강제로 메세지를 가져옴
JUSTID: 메세지 ID만 반환
기타 명령어
명령어
구문
설명
시간복잡도
XINFO
GROUPS key
컨슈머 그룹 메타데이터 반환
O(1)
STREAM key [FULL [COUNT count]]
스트림 메타데이터 반환
O(1)
Geospatials
좌표를 저장하고 검색할 수 있는 데이터 타입으로 거리 계산, 범위 탐색 등을 지원한다. SortedSet에 저장되기 때문에, ZSET 명령어를 통한 조회, 삭제 등이 가능하다.
SET 명령어
명령어
구문
설명
시간복잡도
GEOADD
key [NX | XX] [CH] longitude latitude member [longitude latitude member ...]
지리 정보(경도, 위도, 이름)를 추가한다.
O(logN)
GET 명령어
GEORADIUS 명령어들은 6.2.0버전부터 deprecated되었다. GEOSEARCH에 통합하여 사용이 가능하다.
명령어
구문
설명
시간복잡도
GEOPOS
key [member [member ...]]
특정 위치의 좌표(Lat, Lon) 조회
O(1)
GEODIST
key member1 member2 [M | KM | FT | MI]
두 지점 간의 거리 계산
O(1)
GEOHASH
key [member [member ...]]
특정 위치의 Geohash 값을 반환
O(1)
GEOSEARCH
key <FROMMEMBER member | FROMLONLAT longitude latitude> <BYRADIUS radius <M | KM | FT | MI> | BYBOX width height <M | KM | FT | MI>> [ASC | DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]
지정된 범위 내 위치 검색
O(logN + M)
GEOSEARCHSTORE
destination source <FROMMEMBER member | FROMLONLAT longitude latitude> <BYRADIUS radius <M | KM | FT | MI> | BYBOX width height <M | KM | FT | MI>> [ASC | DESC] [COUNT count [ANY]] [STOREDIST]
위치 검색 후 결과를 새로운 GEO에 저장
O(logN + M)
FROMMEMBER: 특정 멤버 기준으로 검색
FROMLONLAT: 특정 좌표에서 검색
BYRADIUS: 특정 반경 내에서 검색
BYBOX: width * height 크기의 BOX 내에서 검색
COUNT: 가져올 결과 개수 (ANY를 사용 시 성능 최적화)
WITHCORD: 위치 좌표를 함께 반환
WITHDIST: 거리를 함께 반환
WITHHASH: GEOHASH를 함께 반환
Geospatial을 이용해 홍대 ~ 강남역 까지의 직선거리를 구해보자.
GEOADD TEST 127.027583 37.497928 GANGNAM
(integer) 1
127.0.0.1:6379> GEODIST TEST GANGNAM HONGDAE KM
"11.2561"
추가로, 반경과 x*y 박스의 범위검색 또한 가능하다.
Bitmaps
실제의 데이터 타입은 아니며, String 타입을 기반으로 비트 단위 연산을 지원한다. 비트 단위로 값을 설정하고 읽을 수 있기 때문에, 매우 작은 메모리 공간으로도 데이터를 표현할 수 있으며, 효율적인 공간 활용 및 빠른 연산이 특징이다. 사용자 로그인 여부, 출석 체크, 중복 체크 등의 비트로도 수행할 수 있는 연산에 활용하면 효율적이다.
# 로그인 기록으로 사용
SETBIT user:1001 0 1 # 첫날 로그인
SETBIT user:1001 1 0 # 둘째 날 미로그인
SETBIT user:1001 2 1 # 셋째 날 로그인
명령어
명령어
구문
설명
시간복잡도
SETBIT
key offset value
offset에 value를 저장
O(1)
GETBIT
key offset
offset의 비트 값을 반환
O(1)
BITCOUNT
key [start end [BYTE | BIT]]
1로 설정된 비트 개수를 반환
O(N)
BITOP
<AND | OR | XOR | NOT> destkey key [key ...]
비트 연산 결과를 destkey의 새로운 Bitmaps에 저장
O(N)
BITPOS
key bit [start [end [BYTE | BIT]]]
지정한 비트가 처음으로 등장하는 위치를 반환
O(N)
BITFIELD
key [subcommand [argument] ...]
더 작은 크기의 정수 필드 단위로 읽고 수정하는 명령어.
O(1)
활용 예시
온라인 상태 표시 등의 수시로 변경되는 값에 사용한다.
HyperLogLogs
집합의 카디널리티를 추정할 수 있는 확률형 자료구조이다.
member의 값을 해싱하여 버킷에 해시 값에 맞게 표시한다.
동일한 아이템이 추가 될 경우 카디널리티를 일정하게 계산할 수 있다.
Set과 유사하지만 실제 값을 저장하지 않기 때문에 매우 작은 저장 공간(12kb)을 사용한다.
실제 값을 저장하지 않기 때문에 저장된 데이터를 다시 확인할 수 없다.
확률적인 계산식을 사용하기 때문에 결과값이 실제와 일정 부분 오차가 발생할 수 있다.
정확성을 일부 포기하는 대신 저장 공간을 효율적으로 사용할 수 있으며, 평균 에러율은 0.81%이다.
명령어
명령어
구문
설명
시간복잡도
PFADD
key [element [element ...]]
요소를 저장
O(1)
PFCOUNT
key [key ...]
저장된 요소의 개수(카디널리티)를 반환
O(1)
PFMERGE
destkey [sourcekey [sourcekey ...]]
로그들을 destkey로 병합
O(N)
활용 예시
일일 방문자 수를 Hyerploglog로 구현해보자. 유의할 점은 100% 정확한 수치가 아닐 수도 있다는 점이다.
트랜잭션의 격리 수준 포스팅에서 다루지 않았던 이상현상 중 Lost Update와 Write Skew같은 일관되지 않은 쓰기 결과를 반환하는 데이터 부정합 문제가 있다. PostegraSQL에서는 격리 수준을 REPEATABLE READ로 설정하는 것만으로도 쓰기 결과를 올바르게 보장할 수 있다. 하지만 MySQL의 MVCC(Multi Version Concurrency Control)은 일관된 읽기(Consistence Read)를 지원하지만 위와 같은 데이터 업데이트의 부정합 문제를 REPEATABLE READ의 격리 수준 만으로는 해결할 수 없다.
아래 그림은 MySQL에서 REPEATABLE READ 격리 수준을 사용했을 때 Lost Update가 발생하는 상황이다. 고객 A가 10,000원을 사용한 뒤 관리자가 곧바로 20,000원을 환불했지만 결과적으로 고객의 잔액은 30,000원 이 되어버렸다. 이는 두 트랜잭션이 같은 데이터를 읽고 각각의 연산 결과를 덮어버린 탓에 발생한 문제다.
격리 수준을 SERIALIZABLE로 설정하면 모든 데이터 부정합 문제를 해결할 수 있지만 그만큼 동시성이 보장되지 않는다. 결제, 재고, 선착순 이벤트 등과 같은 상황에서 특히 데이터 정합성이 필수적이다. 또한 그런 기능은 생각보다 보편화되어 널리 사용되고 있다.
위처럼, Lost Update와 Write Skew는 동시에 실행된 트랜잭션에서 같은 데이터를 조회하면서 데이터에 일관성이 깨져버린다. 그렇다면 MySQL에서 동시성을 보장하면서 Lost Update, Write Skew같은 부정합 문제도 해결하기 위해서 어떤 방법을 사용해야할까?
Lost Update
Lost Update란 위 예시처럼 두 트랜잭션이 동일한 레코드를 읽고 각각 수정한 결과를 커밋할 때 발생하는 문제이다. 첫 번째 트랜잭션의 업데이트가 두 번째 트랜잭션의 결과로 덮여버리며, 데이터의 정합성이 깨지게 된다.
위 그림을 테스트 코드로 작성했다. 거의 비슷한 시간에 고객이 캐시를 사용하면서 관리자가 캐시를 환불해주었다. 우리는 캐시 사용과 환불을 통해 2만원이라는 결과를 기대하지만, 고객의 캐시 사용에 대한 커밋은 이후의 트랜잭션에 의해 덮어졌다. 이를 개선해서 올바른 업데이트를 반영시켜보자.
Optimistic Lock(낙관적 잠금)
Optimisitic Lock은 데이터를 동시에 수정하지 않을 것이라고 가정하는 방식이다. 예를 들어, 회원 정보 수정처럼 동시에 수정될 가능성이 낮은 작업에서 효과적이다. 데이터에 Lock을 걸지 않기 때문에 동시 요청에 대해 처리 성능이 뛰어나다. 대신 충돌이 발생하면 이를 감지하고 롤백하거나 재시도해야한다.
@Entity('icash')
export class ICashEntity {
@VersionColumn()
version!: number;
}
version 컬럼을 활용해 Optimistic Lock을 구현했다. Optimisitic Lock을 사용하면 version을 통해 데이터의 상태 변화를 감지하고, 트랜잭션 충돌을 방지할 수 있다. 트랜잭션 A가 업데이트하면서 version을 증가시키면, 트랜잭션 B는 자신의 version 조건이 충족되지 않아 업데이트에 실패한다.
하지만, 이 방식 테이블에 추가 컬럼이 필요하고, 충돌 시 롤백이나 재시도 로직이 필요하다.
위의 테스트 코드에서처럼, B에서 실패 후처리를 하지 않았기 때문에 캐시는 환불되지 않았다.
따라서 데이터 충돌 가능성이 높은 경우에는 배타적 잠금(Pessimistic Lock)을 활용하는 것이 더 적합할 수 있다.
하지만 이런 방식에도 문제점이 없지는 않다. 데이터를 읽는 시점에 Lock을 획득했기 때문에 동시성을 떨어뜨릴 수 있다. 두 트랜잭션 모두 동시에 Lock을 획득했다고 가정해보자. 결국 쓰기 작업에서 타임아웃 등이 발생할 수도 있다. 그렇기 때문에 대기 시간을 관리하여야 한다. wait(nowait) 옵션을 활용하여 락 대기 시간을 조절하지 않으면, 락 대기 시간이 길어질 경우 시스템 성능에 영향을 미칠 수 있다.
Write Skew
Write Skew은 두 트랜잭션이 동일한 데이터를 읽지만 커밋된 데이터가 일관적이지 않은 것을 말한다.
it('REPEATABLE READ:: Write Skew - Event Registration', async () => {
// 초기 데이터: 현재 99명 등록
await eventRepo.save({
eventId: 1,
maxParticipants: 100,
currentParticipants: 99
});
runnerA = dataSource.createQueryRunner();
runnerB = dataSource.createQueryRunner();
await runnerA.startTransaction('REPEATABLE READ');
await runnerB.startTransaction('REPEATABLE READ');
// 두 트랜잭션이 동시에 현재 참가자 수를 확인
const eventA = await runnerA.manager.findOne(EventEntity, {
where: { eventId: 1 }
}); // 99명 읽음
const eventB = await runnerB.manager.findOne(EventEntity, {
where: { eventId: 1 }
}); // 99명 읽음
// 각 트랜잭션이 독립적으로 참가 가능 여부 확인
if (eventA.currentParticipants < eventA.maxParticipants) {
// 참가자 A 등록
await runnerA.manager.insert(ParticipantEntity, {
eventId: 1,
userId: 'userA'
});
await runnerA.manager.update(EventEntity,
{ eventId: 1 },
{ currentParticipants: eventA.currentParticipants + 1 }
);
}
if (eventB.currentParticipants < eventB.maxParticipants) {
// 참가자 B 등록
await runnerB.manager.insert(ParticipantEntity, {
eventId: 1,
userId: 'userB'
});
await runnerB.manager.update(EventEntity,
{ eventId: 1 },
{ currentParticipants: eventB.currentParticipants + 1 }
);
}
await runnerA.commitTransaction();
await runnerB.commitTransaction();
const result = await eventRepo.findOne({
where: { eventId: 1 }
});
// Write Skew: 101명이 등록됨 (제한 100명 초과)
expect(result.currentParticipants).toBe(101);
});
위와 같은 선착순 이벤트 시나리오에서, 100명의 정원이 있다. 두 트랜잭션에서 99라는 같은 데이터를 읽었고, 두 트랜잭션은 아직 신청이 가능하다고 판단했다. 그래서 각자 다른 레코드를 추가하여 100명 제한이 초과되어버렸다. 이는 비즈니스 규칙을 위반하는 심각한 문제가 되었다.
Write Skew의 경우에도, 여러 해결책이 있겠지만 데이터베이스 관점에서는 SERIALIZABLE 격리 수준을 사용하거나, 비관적 잠금과 같은 Locking Reads를 사용하는 방법으로 해결할 수 있다. 위의 비관적 잠금 예시와 동일하니 예제는 생략하도록 하자.
정리
Lost Update는 Write Skew의 부분집합이겠구나, 라는 생각을 하면서 차이점들을 정리해보았다.
Lost Update: 단일 레코드에서 발생하며 단순한 데이터 덮어쓰기 현상
Write Skew: 여러 레코드 또는 관련 데이터에 발생할 수 있으며 비즈니스 규칙이나 데이터 정합성을 위반한다.
동시성을 향상시키면서 데이터에도 문제가 생기지 않도록 비즈니스 로직을 구현할 때 생각을 많이 해야할 것 같다. 성능을 버리고 안정성에 몰빵하는 SERIALIZABLE부터 DB 레벨에서의 락을 활용하거나 Redis 등으로 동시성을 제어하는 등 구현하고자 하는 상황에 맞는 기술을 선택하는 것이 중요해 보인다.
[데이터베이스] MVCC(다중 버전 동시성 제어 - Multi Version Concurrency Control)
Tech/Database2024. 12. 2. 22:47
728x90
728x90
동시성 제어(Concurrency Control)
DBMS에서 동시성 제어는 동시에 데이터에 접근하는 여러 사용자, 즉 여러 트랜잭션의 상호작용에서 트랜잭션의 isolation을 보장하고 일관성과 무결성을 유지할 수 있도록 하는 목적으로 사용되는 기술이다.
이러한 동시성 제어를 하는 대표적인 방식 중 가장 대표적인 Lock을 간단하게 알아보자.
공유 잠금(읽기 잠금, shared lock)이나 배타적 잠금(쓰기 잠금, exclusive lock)을 통해 Lock을 획득한 후 트랜잭션 내부의 작업을 수행하는 방식이다. (read로도 쓰기 잠금을 획득할 수 있다) 다른 트랜잭션은 이전 트랜잭션에서 Lock을 반환해야 작업이 수행이 가능하다는 의미이다.
이는 곧 SERIALIZABLE하다는 의미이며, 읽기 작업과 쓰기 작업이 서로 방해를 일으키기 때문에 병목 지점이 발생하며 데드락과 같은 동시성 문제가 발생할 수도 있다.
동시성을 제어하는 이유는 DML의 데이터 무결성을 보장하면서, 트랜잭션의 수를 최대화하는데 있다. 하지만 Lock을 사용한 동시성 제어에는 SERIALIZABLE하다는 특성 때문에 Lock이 커밋/롤백될 때까지 기다려야 한다. 이로 인해 동시 요청 처리 속도가 상당히 떨어지게 된다. 이를 해결하기 위해 등장한 방식이 MVCC이다.
MVCC(다중 버전 동시성 제어 - Multi Version Concurrency Control)
MVCC는 Lock과 같이 동시성을 제어하기 위해 사용하는 방법 중 하나이다.
MVCC는 쓰기 작업이 진행중인 레코드에 대해 원본과 변경 중인 데이터를 동시에 유지하는 방식이다. 원본 데이터에 대한 스냅샷(Snapshot)을 백업하여 보관한다. 이 스냅샷을 이용해 하나의 레코드에 대해 여러 버전을 관리하며, 데이터에 대한 변경 사항이 반영되기 전까지 다른 사용자가 변경 사항을 볼 수 없도록 보장한다.
MySQL에서는 Undo Log라는 롤백 세그먼트(백업 공간)을 통해 스냅샷을 구현한다. 데이터 변경 요청이 들어오면 변경된 레코드의 이전 정보를 Undo Log에 저장한다. 이 롤백 세그먼트를 통해 트랜잭션 롤백에 필요한 실행 취소 작업을 수행한다. 마찬가지로 변경 사항이 반영되기 전에 데이터를 읽더라도 이 Undo Log를 활용하여 일관된 읽기(Consistent Read)를 수행한다.
이러한 MVCC는Lock을 필요로 하지 않기 때문에 일반적인 RDBMS보다 매우 빠르게 작동한다. 또한 롤백 세그먼트를 활용해 데이터를 읽기 시작할 때 누군가 데이터를 변경하더라도 영향을 받지 않는다.하지만 메모리 영역에 사용하지 않는 공간이 계속 늘어나게 되므로 데이터를 정리하는 작업이 필요하다. 하나의 레코드, 데이터에 대한 여러 버전이 생길 수 있기 때문에 데이터 버전 충돌 시 애플리케이션 영역에서 해결해야한다.
[MySQL] 트랜잭션 격리수준(isolation level)과 이상현상 (with 테스트 코드)
Tech/Database2024. 11. 27. 14:56
728x90
728x90
기억에 오래남고 이해하기 쉽게 현재 조직의 웨딩 도메인의 적립금을 예시로 간단한 엔터티 설계와 더불어 테스트 코드를 작성하여 각 격리수준과 이에 따른 이상현상을 정리해보았다. 개념들은 MySQL의 공식문서를 활용하여 정리하였고, AUTO_COMMIT은 FALSE를 가정하고 예제들을 작성하였다.
(예제에 필요한 기본적인 엔터티와 데이터 세팅은 아래를 참조)
CREATE TABLE icash (
no INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_no INT UNSIGNED UNIQUE NOT NULL,
icash INT UNSIGNED DEFAULT 0 NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
)
CREATE TABLE icash_transaction (
no INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
icash_no INT UNSIGNED NOT NULL,
amount INT UNSIGNED NOT NULL,
type ENUM('GRANT', 'USE', 'REFUND') NOT NULL,
referenced_transaction_no INT NULL COMMENT '환불 시 트랜잭션 번호',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
INDEX idx_icash_no (icash_no),
INDEX idx_type (type)
)
CREATE TABLE user (
no INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
id VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
icashicash_transaction
트랜잭션 격리 수준(Isloation Level)
트랜잭션의 격리(ACID의 I)는 여러 트랜잭션이 동시에 데이터를 변경하는 등의 쿼리를 수행할 때 다른 트랜잭션의 작업이 끼어들지 못하도록 보장하며, Lock을 통해 다른 트랜잭션이 접근하지 못하도록 격리할 수 있지만, 잘못 사용하게 되면 교착 상태인 데드락(DeadLock)에 빠질 수 있다.
트랜잭션의 격리 수준은 트랜잭션의 격리를 어디까지 허용할 것인지 설정하는 것이다. 설정한 격리 수준에 따라 여러 트랜잭션이 동시에 처리될 때, 다른 트랜잭션에서 데이터를 변경하거나 조회하는 데이터를 볼 수 있게 허용할 수 있다.
SERIALIZABLE
REPEATABLE READ
READ COMMITTED
READ UNCOMMITTED
1. 우리는 트랜잭션을 관리해야하기 때문에 아래의 예제들에서는 AUTO_COMMIT이 FALSE인 상황만을 다룬다. 2. InnoDB의 기본값은 REPEATABLE_READ이다. 3. 격리 수준 별 이상 현상은 아래 격리 수준이 위의 격리 수준의 이상 현상을 포함하고 있다.
SERIALIZABLE
SERIALIZABLE은 가장 엄격한 격리수준으로, 트랜잭션을 직렬화된 순서와 동일하도록 보장한다. REPEATABLE READ와 유사하지만, 모든 SELECT문을 SELECT ... FOR SHARE로 간주한다. 다시 말해 모든 조회에도 넥스트 키 락(Next-Key Lock)이 읽기 잠금(Locking Reads)으로 걸린다는 의미이고 이는 곧 조회 조건에 따라 인덱스 레코드와, 레코드 간의 간격(GAP)에 대해 읽기 잠금을 설정한다는 뜻이다.
넥스트 키 락(Next-Key Lock) 조건에 일치하는 인덱스 레코드에 락을 설정하며, 해당 레코드와 연관된 레코드 간의 간격(GAP)에도 잠금을 걸어 다른 트랜잭션이 GAP 내에서 새로운 데이터를 삽입하거나 수정하지 못하도록 방지한다.
SELECT ... FOR SHARE 읽은 행에 공유 모드 잠금(SHARED LOCK)을 설정한다. 다른 트랜잭션이 행을 읽을 수는 있지만, 커밋될 때 까지 변경할 수 없다. 커밋하지 않은 다른 트랜잭션에 의해 레코드가 변경된 경우, 쿼리는 트랜잭션이 끝날 때 까지 기다렸다가 최신 값을 조회한다.
읽기 잠금(Locking Reads) InnoDB 테이블에 대해 잠금 작업을 수행하는 SELECT문으로, 트랜잭션의 격리 수준에 따라 데드락이 발생할 가능성이 있다.
MySQL의 락 매커니즘 - MySQL에서는 테이블의 레코드가 아닌, 인덱스의 레코드를 잠근다. - 락이 걸리는 인덱스는 클러스터 인덱스 및 논클러스터 인덱스를 모두 포함한다. - 만약 PK가 없는 테이블이라면 내부적으로 자동 생성되는 PK를 이용하여 설정한다. - 테이블에 적절한 인덱스가 없다면, 풀 스캔을 통해 참조되는 모든 레코드에 락을 걸 수 있기 때문에 적절한 인덱스를 설정하여 성능 저하를 고려하여 적절한 인덱스를 설정해야 한다.
이를 통해 다른 트랜잭션에서 해당 레코드나 갭에 대해 절대 쓰기 작업을 할 수 없다. 동시 접근을 차단하여 부정합 문제를 절대 발생시키지 않는다. 가장 안전함과 동시에 가장 성능이 떨어져 데드락에 빠지기 쉽다.
1. 교착 상태(DEADLOCK)
테스트 코드를 통해 SERIALIZABLE을 구현해보고, 데드락을 발생시켜보았다.
it('SERIALIZABLE:: DEAD LOCK', async () => {
runnerA = dataSource.createQueryRunner();
runnerB = dataSource.createQueryRunner();
let deadLock = false;
try {
// 트랜잭션 A 시작
await runnerA.startTransaction('SERIALIZABLE');
const repoA = runnerA.manager.getRepository(ICashEntity);
// 트랜잭션 A에서 no = 1에 Lock
await repoA.findOne({ where: { no: 1 } });
// 트랜잭션 B 시작
await runnerB.startTransaction('SERIALIZABLE');
const repoB = runnerB.manager.getRepository(ICashEntity);
// 트랜잭션 B에서 no = 2에 Lock
await repoB.findOne({ where: { no: 2 } });
// 서로 다른 트랜잭션에서 락을 건 레코드에 쓰기 작업 요청
await Promise.all([
repoA.update({ no: 2 }, { icash: 3000 }),
repoB.update({ no: 1 }, { icash: 2000 })
]);
} catch (e: any) {
console.error(e);
if (e.code == 'ER_LOCK_DEADLOCK') {
deadLock = true;
}
} finally {
// 트랜잭션 정리
if (runnerA.isTransactionActive) await runnerA.rollbackTransaction();
if (runnerB.isTransactionActive) await runnerB.rollbackTransaction();
}
// 데드락 발생 여부 검증
expect(deadLock).toBe(true);
}, 10000);
위에서 언급한 것 처럼 SERIALIZABLE에서는 단순한 SELECT문도 SELECT ... FOR SHARE로 간주된다. 때문에 트랜잭션 A가 no = 1에 대해 SELECT를 실행하면, 해당 레코드에 대해 읽기 잠금이 발생하며 동일하게 트랜잭션 B가 no = 2에 대해 읽기 잠금을 발생시킨다.
이 상황에서는, 두 트랜잭션이 서로 다른 레코드에 대해 잠금을 걸기 때문에, 충돌이 발생하지 않는다. 위의 그림처럼, 서로 다른 레코드에 락을 걸게 된다. 만약 같은 레코드를 조회했더라도, 읽기 잠금은 참조(읽기)가 가능하기 때문에 아무런 이상현상이 발생하지 않는다.
2. 쓰기(UPDATE) 발생
// 서로 다른 트랜잭션에서 락을 건 레코드에 쓰기 작업 요청
await Promise.all([
repoA.update({ no: 2 }, { icash: 3000 }),
repoB.update({ no: 1 }, { icash: 2000 })
]);
이제, 서로 다른 트랜잭션에서 락을 획득한 레코드에 대해 업데이트해보자.
트랜잭션 A는, B가 획득한 자원에 대해, 트랜잭션 B는 A가 획득한 자원에 대해 쓰기 작업을 시도한다. 각 트랜잭션들은 이미 하나의 락을 획득하였고, 다른 트랜잭션에서 할당된 자원에 대해 쓰기 작업을 시도하여 배타적 잠금을 요청하며 이전 락이 해제될 때 까지 기다리게 된다. 이로 인해 데드락이 발생하게 된다.
2. LOCK_WAIT_TIMEOUT
이번엔, 교착 상태는 아니지만 다른 상황을 만들어보았다.
it('SERIALIZABLE:: TIMEOUT', async () => {
runnerA = dataSource.createQueryRunner();
runnerB = dataSource.createQueryRunner();
// InnoDB의 DEFAULT LOCK WAIT TIMEOUT = 50이기 때문에
// 테스트를 위해 5초로 변경
await runnerB.manager.query('SET SESSION innodb_lock_wait_timeout = 5');
let timeout = false;
try {
// 트랜잭션 A 시작
await runnerA.startTransaction('SERIALIZABLE');
const repoA = runnerA.manager.getRepository(ICashEntity);
// 트랜잭션 A에서 no = 1에 Lock
await repoA.findOne({ where: { no: 1 } });
await runnerB.startTransaction('SERIALIZABLE');
const repoB = runnerB.manager.getRepository(ICashEntity);
await repoB.update({ no: 1 }, { icash: 2000 });
} catch (e: any) {
if (e.code == 'ER_LOCK_WAIT_TIMEOUT') {
timeout = true;
}
} finally {
// 트랜잭션 정리
if (runnerA.isTransactionActive) await runnerA.rollbackTransaction();
if (runnerB.isTransactionActive) await runnerB.rollbackTransaction();
}
// 타임아웃 발생 여부 검증
expect(timeout).toBe(true);
}, 10000);
마찬가지로 트랜잭션 B는 트랜잭션 A가 커밋을 하기 전까지 기다리게 된다. 만약 설정된 LOCK_WAIT_TIME을 초과하게되면, LOCK_WAIT_TIMEOUT 에러가 발생하게 되고, 작업을 수행할 수 없다.
정리
SERIALIZABLE은 모든 SELECT에도 잠금을 발생시기 때문에 다른 트랜잭션에서 절대로 쓰기 작업을 수행할 수 없고 이전 트랜잭션에서 작업이 완료되기를 기다려야만 한다. 가장 안전할 것처럼 보이나, 작업이 오래 걸리는 트랜잭션이 겹겹이 쌓여 병목 현상이 발생하고, 원하는 작업이 수행되지 못하는 등 가장 성능이 떨어지기 때문에 극단적으로 안전한 작업이 아니라면 사용을 기피하는 것으로 알려져 있다.
REPEATABLE READ
REPEATABLE READ는 InnoDB의 기본 트랜잭션 격리 수준으로, 하나의 트랜잭션 내에서 같은 SELECT 결과가 항상 동일함을 보장한다. 이는 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)매커니즘에 의해 트랜잭션이 시작되면 그 시점의 스냅샷이 생성된다. 이후 해당 트랜잭션 내의 SELECT는 이 스냅샷을 기반으로 데이터를 읽게 된다.
REPEATABLE READ는 트랜잭션의 실행 순서를 참고하여(트랜잭션 번호) 자신보다 먼저 실행된 트랜잭션의 데이터만을 조회한다. 이 때 테이블에 자신보다 이후에 실행된 트랜잭션의 데이터가 존재할 경우 백업된 언두 로그를 활용하여 데이터를 조회한다.
따라서 위 그림과 같이, 트랜잭션 A보다 늦게 시작된 트랜잭션에서 데이터를 변경하더라도 조회 결과는 동일하다. 즉 REPEATABLE READ에서는 다른 트랜잭션에서 데이터를 변경하더라도 일관된 읽기(Consistent Read) 결과를 보장한다. 아래 테스트 결과를 보면, 일관된 읽기가 보장됨을 알 수 있다.
it.only('REPEATABLE READ:: 데이터 변경 시 팬텀리드가 발생하지 않는다.', async () => {
runnerA = dataSource.createQueryRunner();
runnerB = dataSource.createQueryRunner();
// 트랜잭션 A 시작
await runnerA.startTransaction('REPEATABLE READ');
const repoA = runnerA.manager.getRepository(ICashEntity);
// 트랜잭션 A에서 SELECT
const beforeTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });
// 트랜잭션 B 시작
await runnerB.startTransaction('REPEATABLE READ');
const repoB = runnerB.manager.getRepository(ICashEntity);
// 팬텀리드를 발생시키기 위해 update
await repoB.update({ no: 10 }, { icash: 30000 });
// 트랜잭션 B 커밋
await runnerB.commitTransaction();
const afterTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });
expect(beforeTrxBCommited.length).toBe(10);
//A보다 뒤에 실행된 트랜잭션 B가 변경한 레코드는 무시한다.
expect(afterTrxBCommited.length).toBe(10);
expect(afterTrxBCommited[9].icash).toBe(38120);
});
그러나 REPEATABLE READ 수준에서도 특정 조건에서 데이터 부정합, 팬텀 리드와 같은 이상 현상이 발생할 수 있다. 위에서 강조했듯이 REPEATABLE READ는 데이터를 변경할 때 일관된 읽기를 보장한다고 했다. 즉 새로운 레코드가 추가될 때는 아래 그림처럼 팬텀 리드가 발생할 수 있다.
팬텀 리드가 발생할 것으로 생각되는 상황을 가정하고, 테스트를 돌려보면 테스트는 실패한다. MySQL에서는 레코드가 추가되는 상황에서도 MVCC 덕분에 팬텀 리드가 발생하지 않는다. 위에서 언급했듯, MVCC를 사용하여 동일 트랜잭션 내에서 같은 데이터를 읽을 때 언두 로그 기반의 일관된 스냅샷을 제공하기 때문이다. 그렇다면 언제 팬텀리드가 발생할까? 여러 테스트를 작성하여 팬텀 리드가 발생하는 상황을 알아보려고 했다.
테스트 케이스의 결과를 통해 REPEATABLE READ에서의 팬텀 리드 발생 상황을 정리해볼 수 있었다.
SELECT > DML > SELECT : 팬텀리드가 발생하지 않음
SELECT FOR UPDATE(배타적 잠금) > DML > SELECT : 팬텀리드가 발생하지 않음
SELECT FOR SHARE(읽기 잠금) > DML > SELECT : 팬텀 리드가 발생하지 않음
SELECT > DML > SELECT FOR UPDATE(배타적 잠금) : 팬텀리드 발생
SELECT > DML > SELECT FOR SHARE(읽기 잠금) : 팬텀 리드 발생
테스트 결과를 보면, 2번 3번 테스트가 유독 긴 테스트 시간이 소요되었다. 타임아웃이 발생한 것이다. 위 테스트의 경우에 no < 15인 인덱스 레코드에 대해 조회와 동시에 잠금을 걸었기 때문에 다른 트랜잭션에서는 쓰기 작업을 위해 해당 트랜잭션에서 잠금을 해제하여야 한다. 즉, 트랜잭션 A가 해당 레코드 범위에 대해 잠금을 해제할 때 까지 트랜잭션 B는 대기상태에 들어가고, 설정해 둔 3초의 시간이 지나 타임아웃이 발생한 것이다.
MySQL에서 REPEATABLE READ 수준에서의 팬텀 리드는 데이터가 변경된 후 잠금을 사용하는 조회 상황에서는 팬텀 리드가 발생할 수 있다. 스냅샷이 아닌 실제 테이블 상태를 참조하기 때문에 다른 트랜잭션에서 추가한 새로운 행이 보이게 되는 것이다. 마찬가지로, 트랜잭션 없이 실행되는 SELECT에서도 팬텀 리드가 발생할 수 있다.
정리
MySQL의 REPEATABLE READ 수준에서 MVCC를 통해 한 트랜잭션 내에서 거의 모든 상황에 일관된 읽기를 수행할 수 있고 테이블에 잠금을 설정하지 않기 때문에 트랜잭션에서 일관된 읽기를 수행하는 동안 다른 트랜잭션에서 해당 테이블을 자유롭게 수정할 수 있다.
READ COMMITED
커밋된 데이터만 조회할 수 있는 격리수준이다. 아래는 테스트 코드와 이를 풀어낸 그림으로, 트랜잭션 A에서 업데이트 후 커밋하기 전의 조회 결과와 커밋한 후의 조회 결과가 다름을 보여준다.
이 결과는 트랜잭션 B가 먼저 시작되더라도 동일하다. 다른 트랜잭션에서 커밋이 되었는지 안되었는지, 커밋 결과로 레코드가 업데이트 되었는지가 중요하다. 다른 트랜잭션에서의 커밋 여부에 따라 조회 결과가 계속해서 변할 수 있다.
이처럼 동일한 조건으로 데이터를 조회했음에도 다른 트랜잭션의 커밋 여부에 따라 결과가 달라지는 이상 현상을 반복 읽기 불가능 (Non-Repeatable Read)라고 한다. 예상 가능하겠지만, READ COMMITTED 수준에서는, 트랜잭션 내에서 실행되는 조회와 트랜잭션 밖에서 실행되는 조회의 차이가 거의 없다.
READ UNCOMMITTED
READ UNCOMMITTED는 트랜잭션 처리가 완료되지 않은 데이터까지도 읽을 수 있는 격리 수준이다. READ COMMITTED와 같은 예시에서, READ UNCOMMITTED 수준에서는 반영이 되지 않은 데이터에 접근할 수 있음을 알 수 있다.
이렇게, 작업이 완료되지 않았는데도 데이터를 읽을 수 있는 이상 현상을 Dirty Read라고 한다. 커밋이나 롤백되지 않은, 작업이 완료되지 않은 결과를 읽고 다른 작업을 수행한다. 특히 롤백 상황에서, 롤백 전의 데이터로 무언가 작업이 일어날 우려가 있기 때문에 READ UNCOMMITTED는 데이터 정합성에 문제가 많은 격리 수준이다.
목차, 색인, 책갈피와 같은 기능을 하는 인덱스는, 데이터베이스 분야에서는 어떤 데이터를 검색할 때 속도를 높여주는 자료 구조로 메모리 영역에 생성되는 일종의 책갈피이다.
인덱스 구조
보통 Hash, B-Tree, B+Tree가 있다.
실제 사용하고있는 MySQL MYISAM의 인덱스 일부
Hash
우리가 알고있는 Key, Value형태의 자료 구조다. 해시 함수로 키 값을 해시값으로 변환하고, 이 해시 값을 기반으로 데이터를 빠르게 조회할 수 있다. - O(1)
하지만 이런 해시구조의 특성상 범위 검색에 효율이 떨어진다. 키 값이 조금이라도 변하게 되면 완전히 다른 해시 값을 반환하기 때문이다. 범위 검색에서의 부등호 연산을 포함한 범위 조건(BETWEEN, LIKE 등)에는 적합하지 않다.
B-Tree
B-Tree는 이진 트리를 확장한 트리 구조이다. 자식 노드를 2개보다 더 많이 가질 수 있다.
Root, Branch, Leaf의 구조로 되어있으며 Branch가 곧 리프가 되는 경우도 있다. B-Tree의 주요 특징 중 하나는 모든 리프 노드가 동일한 깊이에 있다는 점이다. 이는 트리의 균형을 유지하여 연산 시간의 복잡도 logN으로 보장하여 효율적인 검색을 가능하게 한다.
B-Tree의 구조가 효율적인 이유는 위에서 언급한 균형 잡힌 구조와 더불어 대수 확장성에 있다. 대수 확장성이란 트리 깊이가 리프 노드 수에 비해 매우 느리게 성장하는 것을 의미한다. 4차 B-Tree구조가 있다고 가정해보자. 각 노드는 최대 4개의 자식노드를 가질 수 있다. 즉 트리의 깊이가 1씩 증가할 때마다 최대 4배의 인덱스가 추가될 수 있다. 따라서 깊이가 d인 4차 B-Tree는 최대 4^d의 리프 노드를 가질 수 있다. 깊이가 10만 되어도 리프 노드수는 100만 개가 넘는다.
트리의 깊이가 낮기 때문에 I/O작업이 적고 연산이 빠르게 일어날 수 있다.
B+Tree
B-Tree를 개선시킨 자료 구조로 각 노드에는 인덱스 키만 저장되고, 실제 데이터는 리프 노드에 저장된다.
리프 노드들은 서로 연결 리스트형태로 연결되어 있어 범위 검색에 효율적이다. 하지만 데이터를 조회하기 위해선 반드시 리프 노드까지 탐색(log2N)이 되어야 한다.
주의사항
DML의 성능 저하
DML(insert, update, delete) 명령에 따라 레코드에 해당하는 인덱스를 생성, 삭제, 변경해야 하기 때문에 성능 저하가 발생한다. 또한 인덱스의 개수가 너무 많으면 레코드 당 인덱스 변경의 횟수가 많아지기 때문에 성능 저하를 발생시킬 수 있다. 인덱스의 개수가 많아지면 옵티마이저가 잘못된 인덱스를 선택하여 조회 성능에 영향을 미치지 못하는 경우도 있다.
페이지 크기와 레코드 저장
MySQL에서는 디스크와 메모리에 데이터를 읽고 쓰는 최소 작업의 단위를 페이지라 한다.
인덱스를 포함해 PK와 테이블 모두 페이지 단위로 관리된다.
우리는 하나의 레코드를 읽기 위해서 여러 레코드가 포함된 페이지를 읽어서 그 중 일치하는 레코드를 읽는 것이다. (당연히 페이지에는 여러 레코드가 기록되어 있을 수 있다.) 만약 레코드를 찾기 위해 1개의 페이지로 해결이 되지 않는다면 다른 페이지들을 읽어야 하는 추가적인 디스크 I/O가 발생하게 된다.
(참고) InnoDB의 경우 페이지 크기는 16KB로 고정되어 있었지만, 공식 문서에 따르면 MySQL 5.7.6 이후부터는 페이지 크기를 32KB, 64KB로 설정하는 것이 가능하다고 한다. 하지만 한 레코드의 크기는 16KB로 여전히 제한된다고 한다.
인덱스 역시 페이지 단위로 관리가 된다. 인덱스로 사용되는 PK, 컬럼의 크기가 크면 클수록 인덱스 페이지가 줄어들게 된다. 그럼 인덱스를 활용한 조회 시에도 여러 인덱스 페이지를 읽어야 하는 문제가 발생한다. 그렇기에 인덱스 키는 길면 길수록 성능 저하를 발생시킨다.
InnoDB의 경우 하나의 레코드 크기가 페이지 크기의 절반 이상일 경우 레코드에서 외부에 저장할 가변 길이 컬럼을 선택해서 외부 페이지(off-page)에 데이터를 저장하고 기존의 페이지에는 off-page를 가리키는 포인터를 저장한다.
인덱스 컬럼 기준
단일 인덱스
단일 인덱스를 생성할 때는 카디널리티(Cardinality)가 가장 높은 것을 잡아야 한다.
카디널리티란 중복된 수치를 얘기한다. Unique 한 값일수록 카디널리티가 높다. 예를 들어 성별은 카디널리티가 낮고, 주민등록번호는 카디널리티가 높다.
카디널리티가 높은 컬럼을 인덱스로 생성해야 하는 이유는 인덱스로 많은 부분을 걸러낼 수 있어야 하기 때문이다. 인덱스를 통해 많이 걸러내면 걸러낼수록 조회 성능이 올라간다.
복합 인덱스
복합 인덱스를 최적화하기 위해서는 카디널리티를 포함하여 다음 상황들을 고려해야 한다.
공통으로 사용되는 필수 조건
=, IN 연산
정렬
범위 비교 연산 (BETWEEN, LIKE, 비교연산자)
여러 자료를 통해 학습을 했을 때, 인덱스는 범위 비교 시에는 다음 인덱스 컬럼은 사용되지 않는다고 했다. 인덱스를 지정할 때 위의 상황들을 오름차순으로 생성하는 게 효율적이라는 자료도 있었다.
실제 데이터가 인덱스 순서에 따라 정렬되어 검색 시 매우 빠른 성능을 보일 수 있다. 하지만 위에서 말한 것처럼 정렬된 상태여야 한다. no, id값의 일반적인 auto_increment값을 가진 PK라면 문제가 되지 않겠으나, UUID 같은 정렬되지 않은 키를 사용한다면 정렬을 위해 추가적인 리소스를 발생시킬 수 있다. 성능이 저하될 수 있다는 의미이다.
PK의 보안 때문에 UUID를 고려한다면 UUIDv7을 사용해 보는 것도 괜찮을 것 같다. UUIDv7은 UNIX_TIMESTAMP를 ms 단위로 인코딩하여 효율적인 색인화가 가능하다고 한다.
Non-Clustered Index
논-클러스터드 인덱스는 테이블에 저장된 물리적 순서에 따라 데이터를 정렬하지는 않는다.
클러스터드 인덱스와 달리 리프 노드에는 데이터 페이지에 대한 포인터가 있어 포인터를 통해 데이터 페이지를 조회할 수 있는 형태이고 데이터 페이지는 정렬되어있지 않다.
데이터를 찾을 때 추가적인 스텝 (리프 레벨에서 데이터 페이지에 접근)이 필요하기 때문에, 클러스터드 인덱스보다는 속도가 느리고 데이터 입력 시 별도의 공간에 인덱스를 생성해야 하기 때문에 추가 작업이 요구된다. 별도의 공간도 따로 할당되어야 한다. (약 10%)
마무리
블로그를 돌아보니 DB 포스팅이 SQLD를 취득하기 위한 학습 이후에 멈춰있었는데, 사실상 현재 해나가고 있는 모든 것들을 포스팅하는 내 성격상 현재 가장 많이 다루고 있는 것이 데이터베이스인데, 네트워크와 PS에 빠져서 소홀했다는 생각이 들었다.
리팩토링 과정에서 단순히 실행 계획을 바탕으로 튜닝을 이것저것 많이 처리하고, 일의 양이 많아 바로 다음 작업으로 넘어가다 보니 정확히 쿼리 튜닝을 위해 관련된 지식들을 학습할 필요를 느껴 다시 하나하나 시작해보려 한다. DB의 여러 부분들의 공부를 깊게 해 보면서 실제로 적용시켜 보며 정리를 위한 포스팅을 계속 이어나갈 수 있도록 해야겠다.
1. 회원 탈퇴의 경우 일정 기간이 지나면 자동으로 테이블에서 삭제 처리 2. 통계 테이블에 해당 날짜의 자정이되면 자동으로 insert처리
위와 같은 DB 자동화를 구축해 보고 싶었고 강사님께 여쭈어봤더니 CI/CD ?? Jenkins 같은 것들을 학습하면 구현하는 데 도움이 될 거라고 하셨는데 너무 생소한 개념이기도 하고 학습하는데 시간이 다 가서 프로젝트 수행을 못할 것 같아 방법을 찾아보던 중 프로시저를 활용해 MySQL 내에 이벤트를 구현해 간단한 자동화??아닌 자동화를 구현한 적이 있다.
그 때 당시 이해했던 프로시저는 Java의 메서드와 같은 역할을 하는 것인줄 알았다.
함수를 생성하여 이벤트 스케쥴러를 통해 프로시저를 호출했기 때문이다.
지금와서 지난 쿼리를 보니 잘못 사용했다는 생각이 든다.
DELIMITER $$
CREATE PROCEDURE paidDelAuto()
BEGIN
DELETE FROM PAID WHERE PAID_DATE < (select date_format(date_sub(now(), interval 7 day), '%Y-%m-%d'));
END $$
DELIMITER ;
찾아보니 프로시저는 여러 쿼리문을 하나로 합쳐서 사용하기 위해 주로 사용된다고 한다.
굳이 프로시저가 아니라 이벤트 생성시 단일쿼리문을 넣어서 사용했으면 그냥 돌아갔을 것 같다 ㅋㅋ;;
정확히 포스팅해서 다음부터는 올바르게 사용할 수 있도록 하자.
프로시저
MySQL에서는 스토어드 프로시저 (Stored Procedure)라 한다.
장점
1. 하나의 프로시저로 여러 SQL문의 실행이 가능하다. (모듈화) 2. 호스팅언어와 SQL문이 분리되어 보수가 용이하다. 3. 네트워크 소요 시간을 줄일 수 있다. (구문 분석 및 코드 변환을 미리 진행하여 처리 시간이 단축됨)
단점
1. 재사용성이 낮다. (DB 버전 별 구문 규칙과 호환성이 다름) 2. 처리 성능이 좋지않다. (MySQL 스토어드의 프로그램 처리 성능이 낮다)
함수(Function)와의 차이점
함수 : 클라이언트에서 처리하며, 리턴 값이 단 하나이고 꼭 값을 가져와야 한다. 프로시저 : 서버로 보내 처리하며, 리턴값을 여러개 반환 가능하다.
사용하기
생성
DELIMITER $$
CREATE PROCEDURE paidDelAuto()
BEGIN
DELETE FROM PAID WHERE PAID_DATE < (select date_format(date_sub(now(), interval 7 day), '%Y-%m-%d'));
END $$
DELIMITER ;
DELIMITER(구문 문자)
프로시저 내부에 세미콜론이 많을 경우, SQL문이 나눠져 서버로 보내지게 되어 프로시저가 어디까지인지 모른다. 따라서 DELIMITER $$를 선언하고, 프로시저 작성이 끝날 경우 END $$로 프로시저의 끝을 알려야 한다. END $$로 끝나는 부분을 표시하고, 마지막에 DELIMITER를 세미콜론으로 바꿔준다.
호출
CALL paidDelAuto();
활용하기
답변 테이블에서 원본글인지 답변글인지 판별하고, 답변여부에 따라 삭제여부 UPDATE 혹은 DELETE
DELIMITER $$
DROP PROCEDURE IF EXISTS deleteReboard $$ #같은 이름이 있다면 지우기
CREATE PROCEDURE deleteReboard #저장 프로시저 생성
(
#변수 선언
m_no INT,
m_step INT,
m_groupNo INT
)
BEGIN #SQL 프로그래밍 부분 시작
DECLARE cnt INT; #답변 변수 설정
SET cnt=0; #변수 초기화
/*답변이 달린 원본 글인 경우에는 삭제하지 말고 delFlag를 Y 로 update하자*/
IF m_step=0 THEN /*원본글인 경우*/
/*답변이 달렸는지 확인*/
SELECT COUNT(*) INTO cnt FROM reboard WHERE groupno=m_groupNo;
IF cnt >1 THEN /*답변이 달린 경우*/
UPDATE reboard SET delflag='Y' WHERE NO=m_no;
ELSE /*답변이 안 달린 경우*/
DELETE FROM reboard WHERE NO=m_no;
END IF;
ELSE /*답변글인 경우*/
DELETE FROM reboard WHERE NO=m_no;
END IF;
END$$
DELIMITER ;
create event visitDaily
on schedule every 1 hour
starts now()
--comment 주석
do
insert into VISIT(VISIT_DATE, NUMBER)
select date_format(now(), "%Y%m%d"), 0
from dual
where not exists(select VISIT_DATE from VISIT where VISIT_DATE = date_format(now(), "%Y%m%d"));
create event 이벤트이름 on schdule every 주기(month,week,day,hour,month,등등) starts 시간 comment 이벤트주석 do 아래 실행시킬 내용