캐싱 가이드라인
이 문서는 GitLab에서 사용 중인 다양한 캐싱 전략, 효과적인 구현 방법 및 다양한 유의 사항에 대해 설명합니다. 이 자료는 훌륭한 캐싱 워크샵에서 추출되었습니다.
캐시란 무엇인가요?
데이터를 더 빠르게 저장하는 공간으로, 다음과 같습니다:
- 컴퓨팅의 많은 영역에서 사용됩니다.
- 프로세서에는 캐시가 있고, 하드 디스크에도 캐시가 있으며, 여러 가지에 캐시가 있습니다!
- 최종 목적지에 가까운 곳에 자주 있습니다.
- 데이터에 대한 더 간단한 저장소입니다.
- 임시로 사용됩니다.
빠르다는 것은 무엇을 의미하나요?
모든 웹 페이지의 목표는 100ms 미만으로 반환하는 것입니다:
- 이것은 달성 가능하지만, 현대적인 애플리케이션에는 캐싱이 필요합니다.
- 큰 응답은 빌드하는 데 더 오랜 시간이 걸리며, 캐싱은 일정한 속도를 유지하는 데 중요합니다.
- 캐시 읽기는 일반적으로 서브 1ms입니다. 이로써 개선되지 않는 것이 거의 없습니다.
- 초기 사용 경험도 중요하기 때문에, 다음 페이지로드만 빠른 것으로 충분하지 않습니다.
- 사용자별 데이터는 이를 도전적으로 만들며, 기존 애플리케이션을 이 속도 목표를 달성하도록 리팩토링하는 데 큰 도전을 제시합니다.
- 사용자별 캐시는 여전히 효과적일 수 있지만, 그것은 사용자 간에 공유되는 일반적인 캐시보다 더 적은 캐시 히트를 유발합니다.
- 우리의 목표는 항상 페이지 로드의 대부분을 캐시에서 가져오도록 하는 것입니다.
왜 캐시를 사용해야 하나요?
- 더 빨라지도록!
- IO를 피하기 위해.
- 디스크 읽기.
- 데이터베이스 쿼리.
- 네트워크 요청.
- 동일한 결과를 다시 계산하는 것을 피하기 위해:
- 뷰 렌더링.
- JSON 렌더링.
- Markdown 렌더링.
- 여분의 공간을 제공하기 위해. 경우에 따라 캐싱은 다른 곳에서의 실패를 감추는 데 도움이 될 수 있습니다. CloudFlare의 “얼웨이즈 온라인” 기능과 같이.
- 메모리 소비 감소를 위해. 루비에서 적게 처리하지만 큰 문자열을 가져 오는 것.
캐싱에 대한 의문
- 일부 엔지니어들은 캐싱을 마지막 수단으로 제외하고, 실제 해결책은 기본 코드를 개선하여 더 빠르게 만드는 것이라고 생각합니다.
- 이것은 이해할 수 있는 적대감으로 캐시 만료에 대한 두려움에서 올 수 있습니다.
- 하지만 캐싱은 여전히 더 빠릅니다.
- 진정한 성능을 달성하기 위해 두 가지 기술을 혼용해야 합니다:
- 예를 들어, 초기 차가운 쓰기가 너무 느려서 시간이 초과되면 캐싱하는 것은 의미가 없습니다.
- 그러나 캐싱이 성능 향상을 가져오지 않는 경우는 거의 없습니다.
- 그러나 빠른 해킹으로 캐싱을 완전히 사용할 수 있고, 그것 역시 멋집니다. 때로는 “실제” 수정이 몇 달이 걸리지만, 캐싱은 하루만에 구현할 수 있습니다.
GitLab에서의 캐싱
Redis 캐싱의 단점에도 불구하고, GitLab 애플리케이션 내부와 GitLab.com에서 캐싱 설정을 효과적으로 활용할 수 있어야 합니다. 우리의 캐시 사용량 예측에 따르면 충분한 여유 공간이 있습니다.
워크플로우
방법론
- 가능한 한 최종 사용자에 가까이에 있는 곳에서 가능한 한 자주 캐싱하십시오.
- 뷰 렌더링을 캐싱하는 것이 아주 큰 성능 향상을 가져옵니다.
- 가능한 한 많은 사용자를 위해 가능한 한 많은 데이터를 캐싱하려고 노력하십시오:
- 일반적인 데이터는 모든 사람을 위해 캐싱할 수 있습니다.
- 새로운 기능을 구현할 때 이 점을 명심해야 합니다.
- 최대한 많은 캐시 데이터를 유지하려고 노력하십시오:
- 만료 전에 가능한 한 많은 캐시 데이터를 유지하기 위해 중첩된 캐시를 사용하십시오.
- 가능한 한 적은 수의 캐시 요청을 수행하십시오:
- 이는 네트워크 문제로 인한 가변적 지연을 줄입니다.
- 캐시에서 각 읽기의 오버헤드를 줄입니다.
캐싱의 이점을 어떻게 확인할까요?
추가된 캐시가 “가치 있는”지 어떻게 판단할까요? 이것은 측정하기 어려울 수 있지만, 다음을 고려할 수 있습니다:
- 캐시된 데이터의 크기는 얼마나 큰가요?
- 이것이 캐시 저장소의 종류를 결정할 수 있습니다. 예를 들어, 큰 HTML 응답을 RAM이 아닌 디스크에 저장하는 것과 같이.
- 캐싱을 통해 얼마나 많은 I/O, CPU 및 응답 시간이 절약되나요?
- 캐시된 데이터가 크더라도 렌더링에 필요한 시간이 적다면, 큰 텍스트 덩어리를 페이지에 덤프하는 것처럼 이것이 캐싱하기 가장 좋은 곳을 나타낼 수 있습니다.
- 이 데이터는 얼마나 자주 액세스되나요?
- 자주 액세스되는 데이터를 캐싱하는 것이 영향을 더욱 크게 줄입니다.
- 이 데이터는 얼마나 자주 변경되나요?
- 캐시가 다시 읽히기 전에 캐시가 회전한다면, 이 캐시는 실제로 유용한 것인가요?
도구
조사
- 성능 바가 현지 및 제작에서 조사할 때 첫 번째 단계입니다. 비용이 많이 드는 쿼리, 과도한 Redis 호출 등을 찾아보세요.
- Flamegraph 생성: URL에
?performance_bar=flamegraph
을 추가하여 메서드에서 시간을 소비하는 곳을 찾는 데 도움이 됩니다. - Rails 로그로 들어가기:
- 부분들의 렌더 시간을 면밀히 살펴보세요.
- 응답 시간만 측정하려면
jq
를 사용하여 JSON 로그를 파싱할 수 있습니다:tail -f log/development_json.log | jq ".duration_s"
tail -f log/api_json.log | jq ".duration_s"
-
development.log
를 tail하는 경우 주의할 점:tail -f log/development.log | grep "cache hits"
tail -f log/development.log | grep "Rendered "
- 적절한 곳을 찾았다면:
- 문제를 찾을 때까지 코드 부분을 제거하거나 주석 처리하세요.
-
binding.pry
를 이용하여 라이브 요청에서 조사하세요. 이는 foreground web process가 필요합니다.
검증
- 특히 다음 대시보드를 사용할 수 있는 Grafana:
- 로그
- Grafana 차트로 충분히 커버되지 않는 상황의 경우, 대신에 Kibana를 사용하세요.
- 기능 플래그:
- 캐시를 추가할 때 거의 항상 기능 플래그를 사용하는 것이 거의 항상 좋습니다.
- 플래그를 켜고 끈 후 Grafana의 꼬리표를 살펴보세요.
- 캐시가 따뜻해질 때 초기에 응답 시간이 올라갈 것이므로 기대하세요.
- 효과는 플래그를 100%로 실행할 때까지 명확해지지 않습니다.
- 성능 바:
- 이를 로컬로 사용하고 Redis 목록에서 캐시 호출을 찾으세요.
- 또한 예상한대로 캐시 키가 되는 것을 확인할 때 제작에서 사용하세요.
- Flamegraph:
- 페이지 끝에
?performance_bar=flamegraph
를 추가하세요.
- 페이지 끝에
캐시 레벨
상위 레벨
- HTTP 캐싱:
- ETag 및 만료 시간을 사용하여 브라우저에게 자체 캐시된 버전을 제공하도록 지시합니다.
- 이는 여전히 Rails에 영향을 주지만 뷰 레이어는 건너뜁니다.
- 리버스 프록시 캐시의 HTTP 캐싱:
- 위와 동일하지만
public
설정이 있습니다. - 브라우저 대신에 NGINX, HAProxy, Varnish와 같은 리버스 프록시에 캐시된 버전을 제공하도록 지시합니다.
- 후속 요청은 결코 Rails에 영향을 주지 않습니다.
- 위와 동일하지만
- HTML 페이지 캐싱:
- 디스크에 HTML 파일을 작성합니다.
- 웹 서버 (예: NGINX, Apache, Caddy)는 Rails를 건너뛰고 HTML 파일 자체를 제공합니다.
- 뷰 또는 액션 캐싱:
- Rails는 전체 렌더링된 뷰를 캐시 저장소에 작성하고 되돌려 제공합니다.
- 단편 캐싱:
- Rails 캐시 저장소에 뷰의 일부를 캐시합니다.
- 캐시된 부분은 뷰 렌더링 시 삽입됩니다.
하위 레벨
- 메소드 캐싱:
- 동일한 메소드를 여러 번 호출하지만 값은 한 번만 계산합니다.
- Ruby 메모리에 저장됩니다.
@article ||= Article.find(params[:id])
strong_memoize_attr :method_name
- 요청 캐싱:
- 웹 요청 기간 동안 동일한 키에 대해 동일한 값을 반환합니다.
Gitlab::SafeRequestStore.fetch
- 읽기 또는 쓰기 전달 SQL 캐싱:
- 데이터베이스 앞에 있는 캐시입니다.
- Rails는 같은 쿼리에 대해 요청 내에서 이를 수행합니다.
- 신기 캐시.
- 특정 사용 사례를 위한 고도로 특화된 캐시.
Rails의 내장 캐시 도우미
이는 Rails 가이드에서 잘 문서화되어 있습니다.
- HTML 페이지 캐싱 및 액션 캐싱은 더 이상 기본적으로 포함되지 않았지만 여전히 유용합니다.
- Rails 가이드에서는 HTTP 캐싱을 조건부 GET로 부릅니다.
- Rails의 캐시 저장소를 위해 기억해야 할 두 가지 매우 중요하고 거의 동일한 메소드는 다음과 같습니다.
- 거의 모든 곳에서 사용할 수 있는
cache
로, 거의 alias인 Rails.cache.fetch
- 거의 모든 곳에서 사용할 수 있는
-
cache
에는 뷰의 캐시가 수정되면 자동으로 만료되는 “템플릿 트리 다이제스트”가 포함되어 있습니다.
Rails 캐시 옵션
expires_in
이 옵션은 캐시 항목의 Time To Live (TTL)을 설정하며, 가장 유용하고 일반적으로 사용되는 캐시 옵션입니다. 이 옵션은 대부분의 Rails 캐싱 도우미에서 지원됩니다.
race_condition_ttl
이 옵션은 동일한 시간에 키에 대한 여러 비캐시된 히트를 방지합니다. 키의 최초 프로세스가 만료된 키를 발견하면 TTL을 일정량 증가시키고, 그런 다음 새로운 캐시 값을 설정합니다.
이 옵션은 캐시 키가 매우 무거운 부하에 노출될 때 여러 동시 쓰기를 방지하기 위해 사용됩니다. 하지만 10초와 같이 낮은 값으로 설정해야 합니다.
HTTP 캐싱을 사용해야 하는 경우
전체 응답이 캐시 가능한 경우 조건부 GET 캐싱을 사용하세요:
- 공용 캐시를 사용하지 않을 때 개인 정보 누설 위험이 없습니다. 사용자가 브라우저에서 보는 것만 해당 사용자의 브라우저에 캐시됩니다.
- 조회되는 엔드포인트에 특히 유용합니다.
- 좋은 예시:
- 업데이트를 위해 폴링하는 토론 목록.
updated_at
값으로etag
를 사용합니다. - API 엔드포인트.
- 업데이트를 위해 폴링하는 토론 목록.
잠재적인 단점
- 사용자 및 API 라이브러리가 캐시를 무시할 수 있습니다.
- 가끔 Chrome에서 캐시에 대해 이상한 동작을 할 수 있습니다.
- 개발 모드에서 해당 기능이 존재하는 것을 잊어 버리고 변경 내용이 나타나지 않아 화가 나는 경우가 있습니다.
- 이론적으로 조건부 GET 캐싱을 어디서나 사용하는 것이 합리적이지만 실제로는 가끔 이상한 문제를 일으킬 수 있습니다.
뷰 또는 액션 캐싱을 사용해야 하는 경우
이것은 더 이상 Rails 세계에서는 매우 일반적으로 사용되지 않습니다:
- 이를 지원하는 기능이 Rails 코어에서 제거되었습니다.
- 보통 리버스 프록시 캐싱이나 조건부 GET 응답을 살펴보는 것이 더 나은 방법입니다.
- 그러나 디스크에 쓰지 않고 HTML 페이지 캐싱을 흉내 내는 다소 단순한 방법을 제공하여 클라우드 환경에서 유용합니다.
- 캐시 저장소에 상당히 큰 마크업 청크를 저장합니다.
- API에서 이용 가능하며, 보다 유용한
cache_action
으로 사용 가능한 사용 사례가 있습니다.
단편 캐싱을 사용해야 하는 경우
항상 사용하세요!
- 뷰의 섹션, 전체 부분, 부분들의 컬렉션을 캐시할 수 있으므로 Rails에서 가장 유용한 캐싱 유형입니다.
- 부분들의 렌더링된 컬렉션은
cached: true
를 사용하기 위해 설계되어야 합니다. - 부분 내부에 캐시 호출을 배치하는 것보다 부분 주변의 렌더링 호출 주위에 캐시하면 더 빠릅니다. 그러나 그렇게 하면 템플릿 트리 다이제스트를 잃게 되고, 부분을 업데이트할 때 캐시가 자동으로 만료되지 않습니다.
- 루프 내에 캐시 호출을 배치하는 것과 같은 많은 캐시 호출을 도입하게 되면 주의해야 합니다. 때로는 피할 수 없지만, 부분 컬렉션 캐싱과 같은 선택지가 있습니다.
- 뷰 렌더링 및 JSON 생성은 느립니다. 가능한 곳마다 캐시하는 것이 좋습니다.
메소드 캐싱을 사용해야 하는 경우
- 인스턴스 변수 또는
StrongMemoize
를 사용하세요. - 하나의 요청에서 여러 번 동일한 값이 필요한 경우 유용합니다.
- 동일한 키에 대해 여러 번 캐시 호출을 방지하는 데 사용할 수 있습니다.
- 값이 변경되지 않는 ActiveRecord 객체에서 문제를 일으킬 수 있는데, 이는 주로 테스트 스위트에서 발생합니다.
요청 캐싱을 사용해야 하는 경우
- 메소드 캐싱과 유사한 사용 패턴을 가지고 있지만 여러 메소드에서 사용할 수 있습니다.
- 요청 기간 동안 무언가를 저장하기 위한 표준화된 방식입니다.
- (GitLab 구현에서) 조회는 캐시 조회와 유사하기 때문에 동일한 키를 사용할 수 있습니다. 이것이
Gitlab::Cache.fetch_once
가 작동하는 방식입니다.
가능한 단점
-
Gitlab::Cache::JsonCache
및Gitlab::SafeRequestStore
를 사용하여 캐시된 객체에 새로운 속성을 추가하는 경우, 예를 들어, 이전 사건에서 발견된 것처럼, 해당 인스턴스에 적합한 값을 캐시 데이터가 가지고 있지 않을 수 있습니다.
SQL 캐싱을 사용하는 시기
레일즈는 요청 내에서 동일한 쿼리에 대해 자동으로 사용하므로 이 경우에는 조치가 필요하지 않습니다.
- 그러나
identity_cache
와 같은 젬을 사용하는 것은 캐싱 쿼리를 여러 요청에 걸쳐 캐싱하는 다른 목적이 있습니다. -
Article.find(params[:id])
와 같은 단일 객체 조회에는 사용하지 않는 것이 좋습니다. - 때로는 결과를 사용할 수 없어 읽기 전용 객체를 제공합니다.
- 또한 관계를 캐시할 수 있으며, 이는 우리가 항목 목록을 반환하고 그들을 필터링하거나 다르게 정렬하기를 원하지 않는 상황에 유용합니다.
새로운 캐시를 사용하는 시기
다른 옵션을 고려해 본 후, 정말 어색한 것을 캐시해야 하는 경우 사용자 정의 솔루션을 고려해야 합니다:
- GitLab의 예시로는
RepositorySetCache
,RepositoryHashCache
,AvatarCache
등이 있습니다. - 가능한 경우 사용자 정의 캐시 구현을 만드는 것을 피해야 하며, 이는 일관성을 떨어뜨립니다.
- 매우 효과적일 수 있습니다. 예를 들어 RepositoryHashCache를 사용하여
merged_branch_names
주변의 캐싱이 그 예입니다.
캐시 만료
Redis가 키를 만료하는 방법
간단하게 말하면: 가장 오래된 것이 새로운 것으로 대체됩니다:
- LRU 캐시로 Redis를 구성하는 유용한 기사입니다.
- 다양한 캐시 만료 전략을 위한 많은 옵션이 있습니다.
- 기능적으로 Memcached와 유사한
allkeys-lru
를 사용하는 것이 좋습니다. - 4.0 버전 이상의 Redis에서는 유사하지만 다른 allkeys-lfu가 사용 가능합니다.
- 우리는 이제 모든 명시적인 삭제를
DEL
대신UNLINK
를 사용하여 처리하는데, 이를 통해 Redis가 즉시가 아닌 나중에 메모리를 회수할 수 있습니다.- 이는 키를 삭제로 표시하고 즉시 성공 값을 반환하지만 실제로는 나중에 삭제됩니다.
Rails가 키를 만료하는 방법
- Rails는 명시적인 삭제 대신 TTL 및 캐시 키 만료를 사용하는 것을 선호합니다.
- 캐시 키에는 뷰에서 조각 캐싱 시 기본적으로 템플릿 트리 다이제스트가 포함되어 있어 템플릿의 변경이 자동으로 캐시 만료되도록 보장합니다.
- 그러나 이는 헬퍼에서는 해당되지 않습니다.
- Rails에는 ActiveRecord 객체에서 두 가지 캐시 키 메서드가 있습니다:
cache_key_with_version
및cache_key
.cache_key_with_version
은 5.2 버전 이후에 기본으로 사용되며 이전부터의 표준 동작이며, 여기에는 키에updated_at
타임스탬프가 포함됩니다.
캐시 키 구성 요소
application.log
에서 찾은 예시:
cache(@project, :tag_list)
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29/projects/16-2021031614242546945
2/tag_list
- 뷰 이름과 템플릿 트리 다이제스트
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29
- 모델 이름, ID 및
updated_at
값projects/16-20210316142425469452
- 문자열로 변환된 우리가 전달한 심볼
tag_list
찾아보세요
- 사용자별 데이터
- 가장 중요한 사항입니다!
- 특히 뷰에서는 명확하지 않을 수 있습니다.
- 캐시하려는 영역에서 사용된 모든 헬퍼 메서드를 찾아봐야 합니다.
- “빌리가 8분 전에 이것을 게시했습니다”와 같이 시간별 데이터.
- 업데이트되지만
updated_at
필드를 활성화하지 않는 레코드 - Rails 헬퍼는 뷰에서 템플릿 다이제스트를 키에 포함시키지만 헬퍼와 같은 다른 곳에서는 이런 일이 발생하지 않습니다.
-
Grape::Entity
는 API 계층에서 효과적인 캐싱을 매우 어렵게 만듭니다. 이에 대해 나중에 더 알아보겠습니다. - 뷰의 조각 캐시 헬퍼 내에서
break
나return
을 사용하지 마세요. 캐시 항목을 그때마다 작성하지 않습니다. - 이전 데이터를 반환할 수 있는 캐시 키 항목의 순서를 변경하세요:
-
nil
을 반환할 수 있는 두 값이 있는 경우 서로 바꾸는 것과 같은 경우입니다. -
{ project: nil }
와 같은 해시를 사용하세요.
-
- Rails는 배열의 멤버에서
#cache_key
를 호출하여 키를 찾지만 해시의 값에서는 호출하지 않습니다.