클라우드

캐시, 비었을 때 데이터베이스로 몰리는 요청

캐시가 요청 대부분을 받아 주면 데이터베이스는 한가해 보인다.
그 한가함은 캐시가 처리하고 남은 몫이어서, 캐시가 비는 순간 요청 전체가 데이터베이스로 몰린다.

이 글의 목차캐시가 하는 일6
  1. 캐시가 하는 일
  2. 없으면 그때 채우는 지연 로딩
  3. TTL은 만료 시각만 정한다
  4. 데이터베이스가 한가해 보이는 이유
  5. 캐시는 언제 비는가
  6. 캐시를 붙였다면 지켜볼 숫자

데이터베이스 응답이 느려지기 시작하면 앞에 캐시를 두라는 조언을 자주 듣는다.
AWS에서는 ElastiCache로 Valkey나 Redis OSS, Memcached 같은 캐시 서버를 띄우는 경우가 많다.

캐시를 붙이고 나면 대개 응답이 빨라지고 데이터베이스의 CPU 그래프도 내려간다.
그런데 이 낮아진 숫자를 데이터베이스의 여유로 읽으면 곤란해진다.

캐시가 하는 일

캐시는 자주 읽는 데이터의 사본을 앱과 데이터베이스 사이에 담아 두는 저장소다.
ElastiCache도 값을 메모리에 두는 키-⁠값 저장소라서, 디스크까지 내려가야 하는 데이터베이스보다 대개 빨리 답한다.

키-⁠값 저장소는 product:12345 같은 키 하나로 값을 넣고 꺼내는 단순한 구조다.
캐시 입장에서 진짜 데이터가 있는 데이터베이스는 원본이라고 부른다.

앱이 캐시에 값을 물었는데 있으면 적중(hit), 없거나 만료됐으면 미스(miss)⁠라고 한다.
미스가 나면 앱은 원본에서 값을 읽어 온다.

없으면 그때 채우는 지연 로딩

가장 흔한 방식은 이렇다.
앱이 먼저 캐시에 묻고, 미스가 나면 데이터베이스에서 읽어 온 뒤 그 값을 캐시에 넣어 둔다.
다음에 같은 값을 찾는 요청은 캐시에서 끝난다.

AWS의 캐시 전략 문서는 이 방식을 지연 로딩(lazy loading)⁠이라고 부른다.
실제로 요청된 값만 캐시에 들어가니 공간을 아낀다.

대신 미스 한 번에 왕복이 세 번 생긴다.
캐시에 묻고, 데이터베이스에서 읽고, 캐시에 쓴다.
그리고 데이터베이스의 값이 바뀌어도 캐시에는 예전 값이 남을 수 있다.

그래서 데이터베이스에 쓸 때 캐시도 함께 고치는 방법이 있다.
이것을 라이트 스루(write-through)⁠라고 하는데, 모든 쓰기가 같은 경로를 지나야 효과가 있다.
캐시를 건너뛰는 쓰기 경로가 하나라도 있으면 예전 값은 다시 남는다.

AWS의 전자상거래 캐시 안내는 쓸 때 캐시 값을 고치기보다 지우라고 권한다.
그러면 다음 읽기가 데이터베이스에서 새 값을 채운다.

그래도 아주 짧은 순간 옛 값이 다시 채워질 틈은 남는다.
쓰기가 데이터베이스에 반영되기 전에 옛 값을 읽어 간 요청이, 키가 지워진 뒤에 그 값을 캐시에 넣는 경우다.
이렇게 들어간 옛 값은 만료되거나 다음 쓰기가 키를 지울 때까지 남는다.

쓰기와 읽기가 겹칠 때의 캐시 값 쓰기 전에 옛 값을 읽은 요청이 키 삭제 뒤에 그 값을 넣으면, 캐시는 만료될 때까지 옛 값을 돌려준다. 개념 도해 쓰기와 읽기가 겹칠 때의 캐시 값 두 요청의 처리 순서 읽기 요청 쓰기 요청 옛 값 보유 ① 캐시 조회 미스 ② 옛 값 읽기 데이터베이스 ③ 새 값 저장 데이터베이스 ④ 키 삭제 캐시 ⑤ 옛 값 저장 캐시 캐시와 데이터베이스의 값 ③ ④ ⑤ 만료 시각 캐시 비어 있음 옛 값 데이터베이스 옛 값 새 값 시간 쓰기 전에 옛 값을 읽은 요청이 키 삭제 뒤에 그 값을 넣으면, 캐시는 만료될 때까지 옛 값을 돌려준다. SOSHIN · DEV NOTES
④의 키 삭제가 ⑤보다 먼저 끝나서, ⑤ 뒤로는 캐시와 데이터베이스의 값이 서로 다르다.

TTL은 만료 시각만 정한다

캐시에 넣는 값에는 TTL(Time to Live)⁠을 붙일 수 있다.
몇 초 뒤에 그 값을 만료시킬지 정하는 숫자다.
만료된 키를 읽으면 없는 키처럼 다루므로, 앱이 데이터베이스에서 다시 읽고 캐시를 채운다.

TTL을 5분으로 정했다고 5분 내내 최신 값이 보장되지는 않는다.
저장한 직후 원본이 바뀌면 남은 시간 동안 예전 값이 보인다.
TTL은 값이 지나치게 오래 낡아 있는 것을 막아 줄 뿐이다.

그래서 데이터마다 TTL을 다르게 잡는다.
AWS는 쇼핑몰을 예로 들어 상품 설명에 5분, 재고 수량에 30초를 권한다.
재고는 낡은 값이 초과 판매로 이어지니, 구매가 일어나면 TTL을 기다리지 않고 캐시를 바로 지운다.

TTL 길이에 따른 옛 값 응답 구간 TTL은 캐시 값이 만료되는 시각만 정하므로, 원본이 바뀐 뒤 만료까지는 옛 값이 응답되고 그 구간은 TTL보다 길어지지 않는다. 개념 도해 TTL 길이에 따른 옛 값 응답 구간 한 키의 TTL과 원본 변경 TTL 5분 캐시 옛 값 옛 값 응답 · 4분 새 값 원본 옛 값 새 값 ① 0분 · 캐시에 넣음 ② 1분 · 원본 변경 ③ 5분 · 만료 ④ 다음 읽기가 채움 원본 변경 뒤 옛 값이 남는 최대 시간 TTL 5분 예: 상품 설명 TTL 30초 예: 재고 수량 TTL 30초 · 구매 때 삭제 예: 구매가 일어난 재고 최대 5분 최대 30초 키 삭제 · 다음 읽기가 새 값 0분 1분 2분 3분 4분 5분 원본 변경 뒤 시간 TTL은 캐시 값이 만료되는 시각만 정하므로, 원본이 바뀐 뒤 만료까지는 옛 값이 응답되고 그 구간은 TTL보다 길어지지 않는다. SOSHIN · DEV NOTES
위에서 옛 값이 보이는 4분은 TTL 5분 가운데 남은 시간이고, 아래 막대는 캐시에 넣은 직후 원본이 바뀐 가장 긴 경우다.

결제처럼 정확해야 하는 순간에는 캐시를 아예 거치지 않기도 한다.
항상 마지막으로 쓴 값을 읽어야 하는 질의는 캐시에 맞지 않는다.
방금 쓴 값을 바로 다시 읽어야 하는 트랜잭션 안의 질의도 마찬가지다.

데이터베이스가 한가해 보이는 이유

캐시를 처음 붙일 때 놓치기 쉬운 부분은 여기서부터다.
요청 10개 중 8개는 캐시가 답하고 2개만 데이터베이스로 간다고 하면, 전체 조회 가운데 캐시에서 끝난 비율인 적중률은 80%⁠다.

데이터베이스 지표에 찍히는 부하는 그 2개의 몫이다.
데이터베이스가 혼자서 10개를 다 받아 낼 수 있는지는 이 숫자로 알 수 없다.

캐시가 전혀 답하지 못하면 같은 10개가 모두 원본으로 간다.
방문자 수는 그대로인데 데이터베이스가 받는 요청은 다섯 배가 된다.

캐시가 비면 요청은 어디로 갈까

같은 요청 10개라도 캐시 상태에 따라 원본이 받는 요청 수가 달라진다.

캐시에서 응답 8개

원본으로 이동 2개

캐시가 8개에 답하면 원본은 2개를 받는다. 캐시가 비어 10개 모두 원본으로 가면, 같은 방문자 수에서도 원본 요청은 다섯 배가 된다.

동시에 들어온 요청의 첫 조회를 단순화한 예시다. 실제 적중률이나 처리량을 측정한 값은 아니다.

이 숫자는 관계를 보여 주려고 든 예시다.
실제로는 요청 하나가 데이터베이스 질의를 여러 개 만들기도 하고, 미스가 난 값이 다시 채워지는 속도도 달라서 적중률 하나로 용량을 계산하지는 못한다.

캐시를 붙이고 한동안 지나면, 줄어든 부하에 맞춰 데이터베이스를 작은 사양으로 바꾸기 쉽다.
그 뒤에 캐시가 비면 작아진 데이터베이스로 요청 전체가 몰려 장애로 이어질 수 있다.

캐시 적중 여부에 따른 원본 부하 평소의 캐시 적중률만으로는 캐시가 비었을 때 원본이 전체 요청을 감당할지 알 수 없다. 개념 도해 캐시 적중 여부에 따른 원본 부하 요청 처리 경로 요청 ① 캐시 조회 적중 미스 반환 원본에 닿지 않는다 ② 원본 조회 ③ 캐시 적재 반환 왕복 두 번이 더 붙는다 원본 도달량 원본 처리 한도 정상 상태 미스분만 도달 캐시 소실 한도 초과 전량 도달 대기 시간 증가 요청 실패 평소의 캐시 적중률만으로는 캐시가 비었을 때 원본이 전체 요청을 감당할지 알 수 없다. SOSHIN · DEV NOTES
원본의 처리 여유가 작은 경우를 그렸다. 평소의 적중률만으로는 원본이 전체 요청을 감당할지 알 수 없다.

캐시는 언제 비는가

생각보다 자주 빈다.
캐시 노드가 고장 나 빈 노드로 바뀌면 앱은 계속 동작하지만, 미스가 날 때마다 데이터베이스를 조회한다.
메모리가 한도에 닿으면 캐시가 키를 밀어내서 자리를 만드는데, 밀려난 키는 다음 조회 때 미스가 된다.

만료가 한꺼번에 겹칠 때도 있다.
캐시를 새로 띄운 직후처럼 비슷한 시각에 값을 채우고 모두 같은 TTL을 주면, 만료도 같은 시각에 몰린다.

만료 시각이 겹칠 때의 원본 요청량 비슷한 시각에 채우고 같은 보관 시간을 준 항목은 만료 시점도 겹칠 수 있다. 개념 도해 만료 시각이 겹칠 때의 원본 요청량 일괄 만료 시각 시간 원본 요청량 미스 폭주 만료 분산 적용 시 비슷한 시각에 채우고 같은 보관 시간을 준 항목은 만료 시점도 겹칠 수 있다. SOSHIN · DEV NOTES
비슷한 시각에 채우고 같은 보관 시간을 주면 만료가 겹칠 수 있다.

인기 있는 값 하나가 만료될 때도 비슷하다.
그 값을 찾던 요청들이 동시에 미스를 내고 똑같은 질의를 데이터베이스에 보낸다.
이런 상황을 ‘썬더링 허드(thundering herd)‘라고 부른다.

캐시가 비는 상황데이터베이스에 생기는 일미리 할 수 있는 일
노드 장애와 교체다시 찰 때까지 미스가 몰림캐시 없이 버틸 용량 확인
같은 TTL의 동시 만료같은 시각에 미스가 겹침TTL에 작은 무작위 값 더하기
인기 키 하나의 만료같은 질의가 동시에 여러 번원본 조회를 한 번으로 묶기

원본 조회를 묶는다는 것은 같은 값을 기다리는 요청 가운데 하나만 데이터베이스에 보내고, 나머지는 그 결과를 나눠 받게 한다는 뜻이다.
이 방법을 요청 병합(request coalescing)⁠이라고 부른다.

캐시를 붙였다면 지켜볼 숫자

먼저 캐시가 효과가 있을 데이터인지 본다.
같은 질의가 자주 반복되고 데이터가 드물게 바뀌는 곳, 질의 자체가 비싼 곳에서 효과가 크다.

반대로 요청마다 결과가 다른 데이터라면 적중률이 거의 나오지 않아 캐시가 도움이 되지 않는다.

캐시를 붙인 뒤에는 적중률과 데이터베이스로 가는 요청 수를 함께 지켜본다.
ElastiCache의 CacheHitRate 지표는 적중 수를 전체 조회 수로 나눈 값이고, Evictions는 메모리 한도 때문에 밀려난 키의 수다.

AWS는 캐시가 멈추면 데이터베이스를 바로 읽어서, 느려지더라도 기능은 계속 돌게 만들라고 권한다.
이 대비가 통하려면 데이터베이스가 넘어온 요청을 실제로 받아 낼 수 있어야 한다.

그래서 캐시를 끈 상태로도 부하 시험을 해 둔다.
캐시 없이 데이터베이스가 얼마나 받는지 알아 두면, 캐시가 비는 날 데이터베이스로 보낼 요청에 상한을 얼마로 걸지도 정할 수 있다.

소신을 상징하는 까마귀 목판화 일러스트
작성자소신 · Soshin

웹과 서버를 만들고, 개발하며 배운 것들을 글로 남깁니다.

좌우로 움직여 보세요