캐싱 지침

이 문서는 GitLab에서 사용 중인 다양한 캐싱 전략, 효율적인 구현 방법 및 다양한 주의 사항에 대해 설명합니다. 이 내용은 훌륭한 캐싱 워크숍에서 추출되었습니다.

캐시란 무엇인가요?

데이터에 대한 빠른 리포지터리로, 여러 영역에서 사용됩니다.

  • 프로세서에는 캐시가 있고, 하드 디스크에도 캐시가 있으며, 여러 가지에 캐시가 있습니다!
  • 종종 데이터를 최종 목적지에 가까이 두는 것입니다.
  • 데이터에 대한 간단한 리포지터리입니다.
  • 일시적입니다.

빠르다는 것은 무엇을 의미하나요?

모든 웹 페이지의 목표는 100ms 미만으로 반환하는 것이어야 합니다.

  • 이것은 달성 가능하지만, 현대 애플리케이션에서 캐싱이 필요합니다.
  • 더 큰 응답은 더 오래 걸리며, 캐싱은 일정한 속도를 유지하는 데 중요합니다.
  • 캐시 읽기는 일반적으로 하위 1밀리초 미만입니다. 이로 인해 거의 모든 것이 개선됩니다.
  • 초기 경험도 중요하므로 반복적인 페이지 로드에서 빠르다고 좋은 것은 아닙니다.
  • 사용자별 데이터는 이를 어렵게 만들며, 이 속도 목표를 충족하기 위해 기존 애플리케이션을 개선하는 데 가장 큰 어려움을 제시합니다.
  • 사용자별 캐시는 효과적일 수 있지만, 사용자 간에 공유되는 일반적인 캐시보다 적은 캐시 히트를 가져옵니다.
  • 우리는 항상 페이지 로드의 대다수를 캐시에서 끌어오기를 목표로 합니다.

왜 캐시를 사용해야 하나요?

  • 더 빠르게 만들기 위해!
  • IO를 피하기 위해:
    • 디스크 읽기
    • 데이터베이스 쿼리
    • 네트워크 요청
  • 동일한 결과를 여러 번 다시 계산하는 것을 피하기 위해:
    • 뷰 렌더링
    • JSON 렌더링
    • Markdown 렌더링
  • 여분의 제공. 경우에 따라 캐싱은 CloudFlare의 “항상 온라인” 기능과 같이 다른 곳의 실패를 가리는 데 도움이 될 수 있습니다.
  • 메모리 사용량을 줄이기 위해. 루비에서의 처리를 줄이고 큰 문자열만 가져오기
  • 돈을 절약하기 위해. 특히 프로세서가 RAM에 비해 비싼 클라우드 컴퓨팅에서는 더욱 그렇습니다.

캐싱에 대한 의문점

  • 일부 엔지니어들은 캐싱을 마지막 수단으로만 사용해야 한다고 반대하며, 이를 해킹으로 간주하고 진정한 해결책은 기본 코드를 더 빠르게 개선하는 것이라고 생각합니다.
  • 이는 캐시 만료에 대한 두려움으로부터 유발될 수 있습니다.
  • 그러나 캐싱은 여전히 더 빠릅니다.
  • 참한 성능을 달성하려면 두 가지 기술을 모두 사용해야 합니다:
    • 예를 들어 초기 쓰기 작업이 너무 느려서 시간 초과되는 경우 캐싱하는 것은 의미가 없습니다.
    • 그러나 캐싱이 성능 향상을 가져오지 않는 경우는 드뭅니다.
  • 그러나 완전한 솔루션에 몇 달이 걸리는 경우에도 캐싱을 빠른 해킹으로 완전히 사용할 수 있습니다.

GitLab에서의 캐싱

Redis 캐싱에 단점이 있지만, GitLab 애플리케이션 내부와 GitLab.com에서 캐싱 설정을 적극적으로 활용해도 좋습니다. 저희의 캐시 활용을 위한 예측에 따르면 충분한 여유 공간이 있다는 것을 보여줍니다.

워크플로우

방법론

  1. 가능한 한 최종 사용자에게 가능한 가까운 위치에서 캐시 사용.
    • 뷰 렌더링을 캐싱하는 것이 훨씬 성능 향상이 됩니다.
  2. 가능한 한 많은 사용자의 데이터를 최대한 많이 캐시:
    • 일반적인 데이터는 모든 사람을 위해 캐시할 수 있습니다.
    • 새로운 기능을 만들 때 이 사항을 염두에 두어야 합니다.
  3. 캐시 데이터를 최대한 유지하려고 노력:
    • 만료 후에도 최대한 많은 캐시된 데이터를 유지하기 위해 중첩 캐시를 사용합니다.
  4. 가능한 한 적은 요청을 캐시에 하기:
    • 네트워크 문제로 인한 가변적인 대기 시간을 줄입니다.
    • 캐시에서의 각 읽기의 오버헤드를 줄입니다.

캐싱의 이점을 식별

캐시가 추가되는 것이 “가치 있는지” 확인해야 합니다. 이를 메트릭하는 것은 어려울 수 있지만 다음을 고려할 수 있습니다:

  • 캐시된 데이터의 크기는 어떻습니까?
    • 이것은 큰 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 -f log/development.log | grep "cache hits"
      • tail -f log/development.log | grep "Rendered "
  • 올바른 곳을 찾았다면:
    • 원인을 찾을 때까지 코드 부분을 제거하거나 주석 처리합니다.
    • 라이브 요청에서 둘러보려면 binding.pry를 사용합니다. 이를 위해서는 전경 웹 프로세스가 필요합니다.

검증

  • 특히 다음 대시보드에서 Grafana:
  • 로그
    • Grafana 차트가 필요로 하는 상황에 대비해 Kibana를 대신 사용하세요.
  • 피처 플래그:
    • 캐시를 추가할 때 거의 항상 피처 플래그를 사용하는 것이 좋습니다.
    • 이를 0% 및 100%로 토글하고 Grafana에서 변동성있는 선을 확인하세요.
    • 캐시가 워밍될 때 초기에 응답 시간이 늘어나는 것에 대비하세요.
    • 효과는 100%에서 플래그를 실행하여야만 명확해집니다.
  • 성능 막대:
    • 로컬에서 사용하고 Redis 디렉터리에서 캐시 호출을 찾습니다.
    • 캐시 키가 예상대로인지 프로덕션에서도 사용하세요.
  • Flamegraph:
    • 페이지에 ?performance_bar=flamegraph를 추가하세요.

캐시 레벨

상위 수준

  • HTTP 캐싱:
    • ETag 및 만료 시간을 사용하여 브라우저에 자체 캐시된 버전을 제공하도록 지시합니다.
    • 여전히 Rails를 탭니다만, 뷰 레이어를 건너뛸 수 있습니다.
  • 역방향 프록시 캐시의 HTTP 캐싱:
    • 이는 브라우저를 대신하여 역방향 프록시(예: NGINX, HAProxy, Varnish)에 캐시된 버전을 제공하는 것입니다.
    • 다음 요청에서는 결코 Rails에 닿지 않습니다.
  • HTML 페이지 캐싱:
    • 디스크에 HTML 파일을 쓰기
    • 웹 서버(예: NGINX, Apache, Caddy)가 Rails를 건너뛰고 HTML 파일 자체를 제공합니다.
  • 뷰 또는 액션 캐싱
    • Rails가 전체 렌더링된 뷰를 캐시 리포지터리에 기록하고 다시 제공합니다.
  • 프래그먼트 캐싱:
    • Rails 캐시 리포지터리에 뷰 부분을 캐시합니다.
    • 캐시된 부분이 렌더링될 때 뷰에 삽입됩니다.

Low level

  1. Method caching:
    • 동일한 메서드를 여러 번 호출하나 값은 한 번만 계산됩니다.
    • Ruby 메모리에 저장됩니다.
    • @article ||= Article.find(params[:id])
    • strong_memoize_attr :method_name
  2. Request caching:
    • 웹 요청의 기간 동안 키에 대해 동일한 값을 반환합니다.
    • Gitlab::SafeRequestStore.fetch
  3. Read-through 또는 write-through SQL caching:
    • 데이터베이스 앞단에 위치한 캐시입니다.
    • Rails는 동일한 쿼리에 대해 동일한 요청 내에서 이 작업을 수행합니다.
  4. 새로운 캐시 방법.
  5. 특정 사용 사례를 위한 초특화 캐시.

Rails 내장 캐싱 도우미

이 내용은 Rails 가이드에서 상세히 설명되어 있습니다.

  • HTML 페이지 캐싱 및 액션 캐싱은 더 이상 기본 포함되지 않았지만 여전히 유용합니다.
  • Rails 가이드에서는 HTTP 캐싱을 “조건부 GET”이라고 부릅니다.
  • Rails의 캐시 스토어에서 두 가지 매우 중요하고 거의 동일한 메서드를 기억하세요:
    • 뷰에서 사용되는 cache, 거의 Rails.cache.fetch의 별칭입니다.
    • cache에는 “템플릿 트리 다이제스트”가 포함되어 있어 뷰 파일을 수정할 때마다 변경됩니다.

Rails 캐시 옵션

expires_in

이 옵션은 캐시 항목의 Time To Live (TTL)을 설정하며, 가장 유용하고 자주 사용되는 캐시 옵션입니다. 대부분의 Rails 캐싱 도우미에서 지원됩니다.

race_condition_ttl

이 옵션은 동일한 키에 대해 여러 개의 캐시 misses를 방지합니다. 키가 만료된 첫 번째 프로세스는 TTL을 이 값만큼 증가시키고, 그런 다음 새로운 캐시 값을 설정합니다.

이 옵션은 캐시 키가 매우 빈번하게 사용될 때 여러 동시 쓰기를 방지하는 데 사용되지만, 10초와 같은 낮은 값을 설정해야 합니다.

HTTP 캐싱을 사용해야 하는 경우

전체 응답이 캐시될 수 있는 경우 조건부 GET 캐싱을 사용합니다:

  • 공용 캐시를 사용하지 않을 때 개인 정보 노출 위험이 없습니다. 사용자가 볼 수 있는 것만 캐시됩니다.
  • 폴링을 받는 포인트에서 특히 유용합니다.
  • 좋은 예시:
    • 업데이트를 위해 폴링하는 토론 디렉터리. updated_at 값에 대한 etag를 사용합니다.
    • API 엔드포인트.

가능한 단점

  • 사용자 및 API 라이브러리는 캐시를 무시할 수 있습니다.
  • 때때로 Chrome이 캐시를 이상하게 처리하는 경우가 있습니다.
  • 개발 모드에서 존재를 잊고 변경 사항이 표시되지 않으면 화가 날 수 있습니다.
  • 이론적으로 조건부 GET 캐싱을 어디서나 사용하는 게 의미가 있다고 보지만, 실제로는 때로 이상한 문제를 일으킬 수 있습니다.

뷰 또는 액션 캐싱을 사용해야 하는 경우

Rails 세계에서는 이제 이 기능을 많이 사용하지 않습니다:

  • 이 기능은 Rails 코어에서 제거되었습니다.
  • 보통 역방향 프록시 캐싱이나 조건부 GET 응답을 살펴보는 것이 더 좋습니다.
  • 그러나 디스크에 쓰지 않고 HTML 페이지 캐싱을 모방하는 상당히 간단한 방법을 제공하므로 클라우드 환경에서 유용합니다.
  • 캐시 스토어에 상당히 큰 마크업 청크를 저장합니다.
  • 사용 사례가 더 중요한 API에서 사용되는 한 우리는 사용 가능한지라도 이 기능의 사용이 유용합니다. cache_action에는 실제로 이에 해당하는 사용자 정의 구현이 있습니다.

단편 캐싱을 사용해야 하는 경우

항상 사용하세요!

  • 아마 Rails에서 가장 유용한 캐싱 유형으로, 뷰의 섹션, 전체 파셜, 파셜 모음을 캐시할 수 있습니다.
  • 파셜의 렌더링을 주의 깊게 고려하여 cached: true를 사용하도록 작성되어야 합니다.
  • 파셜 주위에 캐시를 추가하는 것이 파셜 내부에 캐시를 넣는 것보다 빠릅니다. 그러나 이렇게 하면 템플릿 트리 다이제스트를 잃어버리게 되어 해당 파셜을 업데이트하면 캐시가 자동으로 만료되지 않습니다.
  • 루프 안에 캐시 호출을 놓는 등 많은 캐시 호출을 도입하는 것은 조심해야 합니다. 때로는 피할 수 없지만 부분 모음 캐싱과 같은 옵션이 있습니다.
  • 뷰 렌더링과 JSON 생성은 느리며 가능한 곳마다 캐시해야 합니다.

메서드 캐싱을 사용해야 하는 경우

  • 인스턴스 변수 또는 StrongMemoize를 사용하세요.
  • 동일한 값이 요청 내에서 여러 번 필요할 때 유용합니다.
  • 동일한 키에 대해 여러 번의 캐시 호출을 방지하는 데 사용할 수 있습니다.
  • 값이 변경되지 않은 ActiveRecord 객체에서 reload를 호출하기 전까지 값이 변경되지 않는 문제를 일으킬 수 있습니다. 이는 보통 테스트 스위트에서 발생합니다.

요청 캐싱을 사용해야 하는 경우

  • 메서드 캐싱과 유사한 사용 패턴을 가지지만 여러 메서드에 걸쳐 사용할 수 있습니다.
  • 요청의 기간 동안에 저장하는 표준화된 방법입니다.
  • (GitLab 구현에서처럼) 조회가 캐시 조회와 유사하기 때문에 두 가지에 동일한 키를 사용할 수 있습니다. 이것이 Gitlab::Cache.fetch_once의 작동 방식입니다.

가능한 단점

  • Gitlab::Cache::JsonCacheGitlab::SafeRequestStore를 사용하여 캐시된 객체에 새로운 속성을 추가하는 경우 캐시 데이터가 새로운 속성에 적합한 값을 가지지 않아 캐시된 데이터에 문제가 발생할 수 있습니다. 이에 대한 예시는 지난 문제에서 확인할 수 있습니다.

SQL 캐싱을 사용해야 하는 경우

Rails는 동일한 쿼리에 대해 요청 내에서 자동으로 이 작업을 수행하므로 그 use case에 대해 추가 작업이 필요하지 않습니다.

  • 그러나 identity_cache와 같은 젬을 사용하면 여러 요청에 걸쳐 쿼리를 캐시할 수 있습니다.
  • Article.find(params[:id])와 같이 개별 객체 조회에는 사용하지 마세요.
  • 때로는 결과를 사용하는 것이 불가능할 수 있으며, 읽기 전용 객체를 제공합니다.
  • 관계도 캐시될 수 있어 필터링이나 정렬을 다르게 사용할 필요가 없는 상황에서 유용합니다.

신제품 캐시를 사용해야 하는 경우

기타 옵션을 모두 고려한 뒤에 매우 복잡한 캐시가 필요한 경우 커스텀 솔루션을 검토할 시간입니다:

  • GitLab에는 RepositorySetCache, RepositoryHashCache, AvatarCache와 같은 예시가 있습니다.
  • 가능하다면 일관성을 유지하기 위해 사용자 정의 캐시 구현을 만들지 않는 것이 좋습니다.
  • 매우 효과적일 수 있습니다. 예를 들어 RepositoryHashCache를 사용한 merged_branch_names 주변의 캐싱입니다.

캐시 만료

Redis가 키를 만료하는 방법

간단히 말하면, 가장 오래된 것은 새로운 것으로 대체됩니다:

  • LRU 캐시로 Redis를 구성하는 유용한 문서입니다.
  • 다양한 캐시 제거 전략을 위한 여러 옵션이 있습니다.
  • 기능적으로 Memcached와 유사한 allkeys-lru를 원할 것입니다.
  • Redis 4.0부터 allkeys-lfu를 사용할 수 있습니다만, 약간 다릅니다.
  • 이제 우리는 DEL 대신 UNLINK를 사용하여 모든 명시적인 삭제를 처리하고 Redis가 자체적으로 메모리를 회수할 수 있도록 합니다.
    • 이는 키를 삭제로 표시하고 나중에 실제로 삭제합니다.

Rails가 키를 만료하는 방법

  • Rails는 명시적인 삭제보다 TTL 및 캐시 키 만료를 선호합니다.
  • 캐시 키에는 뷰에서 단편 캐싱을 할 때 기본적으로 템플릿 트리 다이제스트가 포함되어 있어 템플릿 변경이 자동으로 캐시를 만료시킵니다.
    • 하지만 도우미에서는 이런 기능이 없으니 주의하십시오.
  • Rails에는 ActiveRecord 객체에 대한 두 가지 캐시 키 메서드가 있습니다: cache_key_with_versioncache_key. 첫 번째는 5.2 버전부터 기본적으로 사용되며 이전의 표준 동작 방식이고 키에 updated_at 타임스탬프를 포함합니다.

캐시 키 컴포넌트

application.log에서 발견된 예시:

cache(@project, :tag_list)
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29/projects/16-2021031614242546945
2/tag_list
  1. 뷰 이름과 템플릿 트리 다이제스트 views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29
  2. 모델 이름, ID 및 updated_atprojects/16-20210316142425469452
  3. 문자열로 변환된 전달한 심볼 tag_list

찾아보세요

  • 사용자별 데이터
    • 이것이 가장 중요합니다!
    • 이것은 항상 명백하지 않으며 특히 뷰에서는 명확하지 않습니다.
    • 원하는 영역에서 사용되는 모든 도우미 메소드를 순회해야 합니다.
  • “빌리가 8분 전에 이것을 게시했습니다.”와 같은 시간별 데이터.
  • 레코드가 업데이트되지만 updated_at 필드가 변경되지 않는 경우
  • Rails 도우미는 템플릿 다이제스트를 뷰의 키에 결합하지만, 이는 헬퍼와 같은 다른 곳에서는 발생하지 않습니다.
  • Grape::Entity는 API 레이어에서 효율적인 캐싱을 매우 어렵게 만듭니다. 이에 대해서는 나중에 더 알아보겠습니다.
  • 뷰의 단편화된 캐시 도우미 내에서 break 또는 return을 사용하지 마세요. 캐시 항목을 쓰지 않습니다.
  • 오래된 데이터를 반환할 수 있는 캐시 키 내에서 항목을 재정렬하세요:
    • nil을 반환할 수 있는 두 값을 가지고 순서를 바꾸는 것과 같은 경우.
    • 대신 { project: nil }과 같은 해시를 사용하세요.
  • Rails는 키를 찾기 위해 배열의 멤버에 대해 #cache_key를 호출하지만, 해시의 값에 대해서는 호출하지 않습니다.