캐싱 가이드라인

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

캐시란 무엇인가요?

데이터를 더 빠르게 저장하는 공간으로, 다음과 같습니다:

  • 컴퓨팅의 많은 영역에서 사용됩니다.
    • 프로세서에는 캐시가 있고, 하드 디스크에도 캐시가 있으며, 여러 가지에 캐시가 있습니다!
  • 최종 목적지에 가까운 곳에 자주 있습니다.
  • 데이터에 대한 더 간단한 저장소입니다.
  • 임시로 사용됩니다.

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

모든 웹 페이지의 목표는 100ms 미만으로 반환하는 것입니다:

  • 이것은 달성 가능하지만, 현대적인 애플리케이션에는 캐싱이 필요합니다.
  • 큰 응답은 빌드하는 데 더 오랜 시간이 걸리며, 캐싱은 일정한 속도를 유지하는 데 중요합니다.
  • 캐시 읽기는 일반적으로 서브 1ms입니다. 이로써 개선되지 않는 것이 거의 없습니다.
  • 초기 사용 경험도 중요하기 때문에, 다음 페이지로드만 빠른 것으로 충분하지 않습니다.
  • 사용자별 데이터는 이를 도전적으로 만들며, 기존 애플리케이션을 이 속도 목표를 달성하도록 리팩토링하는 데 큰 도전을 제시합니다.
  • 사용자별 캐시는 여전히 효과적일 수 있지만, 그것은 사용자 간에 공유되는 일반적인 캐시보다 더 적은 캐시 히트를 유발합니다.
  • 우리의 목표는 항상 페이지 로드의 대부분을 캐시에서 가져오도록 하는 것입니다.

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

  • 더 빨라지도록!
  • IO를 피하기 위해.
    • 디스크 읽기.
    • 데이터베이스 쿼리.
    • 네트워크 요청.
  • 동일한 결과를 다시 계산하는 것을 피하기 위해:
    • 뷰 렌더링.
    • JSON 렌더링.
    • Markdown 렌더링.
  • 여분의 공간을 제공하기 위해. 경우에 따라 캐싱은 다른 곳에서의 실패를 감추는 데 도움이 될 수 있습니다. CloudFlare의 “얼웨이즈 온라인” 기능과 같이.
  • 메모리 소비 감소를 위해. 루비에서 적게 처리하지만 큰 문자열을 가져 오는 것.

캐싱에 대한 의문

  • 일부 엔지니어들은 캐싱을 마지막 수단으로 제외하고, 실제 해결책은 기본 코드를 개선하여 더 빠르게 만드는 것이라고 생각합니다.
  • 이것은 이해할 수 있는 적대감으로 캐시 만료에 대한 두려움에서 올 수 있습니다.
  • 하지만 캐싱은 여전히 더 빠릅니다.
  • 진정한 성능을 달성하기 위해 두 가지 기술을 혼용해야 합니다:
    • 예를 들어, 초기 차가운 쓰기가 너무 느려서 시간이 초과되면 캐싱하는 것은 의미가 없습니다.
    • 그러나 캐싱이 성능 향상을 가져오지 않는 경우는 거의 없습니다.
  • 그러나 빠른 해킹으로 캐싱을 완전히 사용할 수 있고, 그것 역시 멋집니다. 때로는 “실제” 수정이 몇 달이 걸리지만, 캐싱은 하루만에 구현할 수 있습니다.

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하는 경우 주의할 점:
      • 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 캐시 저장소에 뷰의 일부를 캐시합니다.
    • 캐시된 부분은 뷰 렌더링 시 삽입됩니다.

하위 레벨

  1. 메소드 캐싱:
    • 동일한 메소드를 여러 번 호출하지만 값은 한 번만 계산합니다.
    • Ruby 메모리에 저장됩니다.
    • @article ||= Article.find(params[:id])
    • strong_memoize_attr :method_name
  2. 요청 캐싱:
    • 웹 요청 기간 동안 동일한 키에 대해 동일한 값을 반환합니다.
    • Gitlab::SafeRequestStore.fetch
  3. 읽기 또는 쓰기 전달 SQL 캐싱:
    • 데이터베이스 앞에 있는 캐시입니다.
    • Rails는 같은 쿼리에 대해 요청 내에서 이를 수행합니다.
  4. 신기 캐시.
  5. 특정 사용 사례를 위한 고도로 특화된 캐시.

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::JsonCacheGitlab::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_versioncache_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
  1. 뷰 이름과 템플릿 트리 다이제스트 views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29
  2. 모델 이름, ID 및 updated_atprojects/16-20210316142425469452
  3. 문자열로 변환된 우리가 전달한 심볼 tag_list

찾아보세요

  • 사용자별 데이터
    • 가장 중요한 사항입니다!
    • 특히 뷰에서는 명확하지 않을 수 있습니다.
    • 캐시하려는 영역에서 사용된 모든 헬퍼 메서드를 찾아봐야 합니다.
  • “빌리가 8분 전에 이것을 게시했습니다”와 같이 시간별 데이터.
  • 업데이트되지만 updated_at 필드를 활성화하지 않는 레코드
  • Rails 헬퍼는 뷰에서 템플릿 다이제스트를 키에 포함시키지만 헬퍼와 같은 다른 곳에서는 이런 일이 발생하지 않습니다.
  • Grape::Entity는 API 계층에서 효과적인 캐싱을 매우 어렵게 만듭니다. 이에 대해 나중에 더 알아보겠습니다.
  • 뷰의 조각 캐시 헬퍼 내에서 breakreturn을 사용하지 마세요. 캐시 항목을 그때마다 작성하지 않습니다.
  • 이전 데이터를 반환할 수 있는 캐시 키 항목의 순서를 변경하세요:
    • nil을 반환할 수 있는 두 값이 있는 경우 서로 바꾸는 것과 같은 경우입니다.
    • { project: nil }와 같은 해시를 사용하세요.
  • Rails는 배열의 멤버에서 #cache_key를 호출하여 키를 찾지만 해시의 값에서는 호출하지 않습니다.