캐싱 지침

이 문서는 GitLab에서 사용되는 다양한 캐싱 전략, 이를 효과적으로 구현하는 방법, 그리고 다양한 주의사항에 대해 설명합니다. 이 자료는 훌륭한 캐싱 워크샵에서 추출되었습니다.

캐시란 무엇인가?

데이터를 위한 더 빠른 저장소로, 다음과 같은 특징이 있습니다:

  • 컴퓨팅의 여러 영역에서 사용됩니다.
    • 프로세서에는 캐시가 있고, 하드 디스크에도 캐시가 있으며, 많은 것들이 캐시를 가지고 있습니다!
  • 원하는 데이터가 최종적으로 위치할 곳에 더 가깝습니다.
  • 데이터에 대한 더 단순한 저장소입니다.
  • 일시적입니다.

빠르다는 것은 무엇인가?

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

  • 이는 달성 가능하나, 현대 애플리케이션에서는 캐싱이 필요합니다.
  • 더 큰 응답은 만드는 데 더 오랜 시간이 걸리며, 캐싱은 일정한 속도를 유지하는 데 중요해집니다.
  • 캐시 읽기는 일반적으로 1ms 이하입니다. 이를 개선하지 않는 것이 거의 없습니다.
  • 후속 페이지 로드에서만 빠른 것은 좋지 않으며, 초기 경험도 중요하므로 이는 완전한 해결책이 아닙니다.
  • 사용자 특정 데이터는 이를 어렵게 만들며, 기존 애플리케이션을 이 속도 목표에 맞추기 위해 리팩토링하는 데 가장 큰 도전이 됩니다.
  • 사용자 특정 캐시는 여전히 효과적일 수 있지만, 사용자 간에 공유되는 일반 캐시보다 캐시 적중률이 낮습니다.
  • 우리는 항상 페이지 로드의 대부분을 캐시에서 불러오는 것을 목표로 하고 있습니다.

캐시를 사용하는 이유는 무엇인가?

  • 더 빠르게 만들기 위한 것입니다!
  • IO를 피하기 위해서입니다.
    • 디스크 읽기.
    • 데이터베이스 쿼리.
    • 네트워크 요청.
  • 같은 결과를 여러 번 재계산하는 것을 피하기 위해서입니다:
    • 뷰 렌더링.
    • JSON 렌더링.
    • Markdown 렌더링.
  • 중복성을 제공하기 위해서입니다. 어떤 경우에는 캐싱이 다른 곳의 실패를 감추는 데 도움이 될 수 있습니다. 예를 들어 CloudFlare의 “Always Online” 기능.
  • 메모리 소비를 줄이기 위해서입니다. Ruby에서 덜 처리하지만 큰 문자열만 가져옵니다.
  • 비용을 절감하기 위해서입니다. 이는 클라우드 컴퓨팅에서는 특히 사실입니다. 프로세서에 비해 RAM이 비쌉니다.

캐싱에 대한 의문

  • 일부 엔지니어는 캐싱을 마지막 수단으로만 사용하는 것에 반대하며, 이를 해킹이라고 간주하고, 진정한 해결책은 기본 코드를 더 빠르게 개선하는 것이라고 생각합니다.
  • 이는 캐시 만료에 대한 두려움에서 비롯될 수 있으며, 이는 이해할 수 있습니다.
  • 그러나 캐싱은 여전히 더 빠릅니다.
  • 진정한 성능을 달성하려면 두 가지 기술을 모두 사용해야 합니다:
    • 예를 들어 초기의 콜드 쓰기가 너무 느려서 타임아웃된다면 캐싱하는 것은 의미가 없습니다.
    • 그러나 캐싱이 성능 향상이 아닌 경우는 거의 없습니다.
  • 그러나 캐싱을 단기 해크로 사용하는 것도 완전히 가능합니다. 가끔 “진짜” 수정은 몇 달이 걸리지만, 캐싱은 단 하루면 구현할 수 있습니다.

GitLab에서의 캐싱

Redis 캐싱의 단점에도 불구하고, GitLab 애플리케이션 내부 및 GitLab.com에서 제공되는 캐싱 설정을 잘 활용하는 것을 두려워하지 마십시오. 우리의 캐시 활용 예측 은 충분한 여유가 있음을 나타냅니다.

워크플로

방법론

  1. 최종 사용자에게 가능한 한 가깝고 자주 캐시하십시오.
    • 뷰 렌더링을 캐싱하는 것은 성능 향상에 가장 큰 도움이 됩니다.
  2. 가능한 한 많은 사용자에 대해 가능한 한 많은 데이터를 캐시하십시오:
    • 일반 데이터는 모두에 대해 캐시할 수 있습니다.
    • 새로운 기능을 만들 때 이 점을 명심해야 합니다.
  3. 가능한 한 캐시 데이터를 보존하십시오:
    • 중첩 캐시를 사용하여 만료된 캐시를 최대한 유지합니다.
  4. 가능한 한 적은 요청을 캐시에 수행하십시오:
    • 이는 네트워크 문제로 인한 가변 대기 시간을 줄입니다.
    • 캐시에서의 각 읽기에 대한 오버헤드를 낮춥니다.

캐싱의 이점 식별

캐시가 추가되는 것이 “가치가 있는가”? 측정하기 어려울 수 있지만, 다음을 고려할 수 있습니다:

  • 캐시된 데이터의 크기는 얼마나 됩니까?

    • 이것은 대용량 HTML 응답을 RAM이 아닌 디스크에 저장하는 등 어떤 유형의 캐시 저장소를 사용해야 하는지에 영향을 미칠 수 있습니다.
  • 캐싱된 데이터로 인해 절약되는 I/O, CPU 및 응답 시간은 얼마나 됩니까?

    • 캐시된 데이터가 크지만 렌더링하는 데 걸리는 시간이 낮다면, 페이지에 큰 덩어리의 텍스트를 추가하는 것과 같이 캐시하는 최적의 위치를 나타낼 수 있습니다.
  • 이 데이터는 얼마나 자주 접근됩니까?

    • 자주 접근되는 데이터를 캐싱하면 일반적으로 더 큰 영향을 미칩니다.
  • 이 데이터는 얼마나 자주 변경됩니까?

    • 캐시가 다시 읽히기 전에 회전한다면, 이 캐시가 실제로 유용한가요?

도구

조사

  • 성능 바는 로컬 및 프로덕션에서 조사할 때 첫 번째 단계입니다.

    비싼 쿼리, 과도한 Redis 호출 등을 찾으세요.

  • 플레임 그래프 생성: URL에 ?performance_bar=flamegraph를 추가하여 시간 소모가 발생하는 메서드를 찾는 데 도움을 줍니다.

  • Rails 로그를 자세히 살펴보세요:

    • 부분 렌더링 시간도 주의 깊게 살펴보세요.

    • 응답 시간만 측정하려면 JSON 로그를 jq를 사용하여 파싱할 수 있습니다:

      • 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를 사용하세요. 이는 포그라운드 웹 프로세스를 요구합니다.

검증

  • Grafana, 특히 다음 대시보드:

  • 로그

    • Grafana 차트가 필요한 것을 커버하지 못하는 경우 Kibana를 대신 사용하세요.
  • 기능 플래그:

    • 캐시를 추가할 때는 거의 항상 기능 플래그를 사용하는 것이 좋습니다.

    • 켜고 끄고 Grafana의 wiggle 선을 주의 깊게 관찰하세요.

    • 캐시가 웜업되기 시작하면서 초기에는 응답 시간이 증가할 것으로 예상하세요.

    • 플래그를 100%로 실행할 때까지 그 효과는 명확하지 않습니다.

  • 성능 바:

    • 로컬에서 사용하고 Redis 목록에서 캐시 호출을 찾으세요.

    • 프로덕션에서도 이를 사용하여 캐시 키가 예상하는 것인지 확인하세요.

  • 플레임 그래프:

    • 페이지에 ?performance_bar=flamegraph를 추가하세요.

캐시 수준

고수준

  • HTTP 캐싱:

    • ETags 및 만료 시간을 사용하여 브라우저가 자체 캐시 버전을 제공하도록 지시하세요.

    • 이것은 여전히 Rails에 Hit하지만, 뷰 레이어는 건너뜁니다.

  • 리버스 프록시 캐시에서의 HTTP 캐싱:

    • 위와 동일하지만 public 설정을 추가합니다.

    • 브라우저 대신, 이것은 리버스 프록시(NGINX, HAProxy, Varnish 등)가 캐시된 버전을 제공하도록 지시합니다.

    • 이후의 요청은 Rails에 도달하지 않습니다.

  • HTML 페이지 캐싱:

    • HTML 파일을 디스크에 작성합니다.

    • 웹 서버(NGINX, Apache, Caddy 등)가 HTML 파일을 직접 제공하며, Rails를 건너뜁니다.

  • 뷰 또는 액션 캐싱:

    • Rails는 전체 렌더링된 뷰를 캐시 스토어에 기록하고 이를 다시 제공합니다.
  • 프래그먼트 캐싱:

    • Rails 캐시 스토어에 뷰의 일부를 캐시합니다.

    • 캐시된 부분은 렌더링되는 뷰에 삽입됩니다.

저수준

  1. 메서드 캐싱:
    • 동일한 메서드를 여러 번 호출하지만 값을 한 번만 계산합니다.
    • 루비 메모리에 저장됩니다.
    • @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, 이는 거의 다음과 같은 별칭입니다:
    • 어디서나 사용할 수 있는 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 객체와 관련된 문제를 일으킬 수 있으며, 이는 값을 호출할 때 변경되지 않다가 reload를 호출할 때 발생하는 경향이 있습니다. 이는 테스트 스위트에서 자주 발생합니다.

요청 캐싱을 사용할 때

  • 메서드 캐싱과 유사한 사용 패턴이지만, 여러 메서드에서 사용할 수 있습니다.
  • 요청의 지속 시간 동안 무언가를 저장하는 표준화된 방법입니다.
  • 조회 방식이 캐시 조회(출처: GitLab 구현)와 유사하므로, 두 경우 모두 동일한 키를 사용할 수 있습니다. 이것이 Gitlab::Cache.fetch_once가 작동하는 방식입니다.

가능한 단점

  • 예를 들어, Gitlab::Cache::JsonCacheGitlab::SafeRequestStore를 사용하여 캐시된 객체에 새로운 속성을 추가하면, 캐시 데이터가 새로운 속성에 적합한 값을 갖지 않게 되는 오래된 데이터 문제를 초래할 수 있습니다 (이 과거 사건을 참조하세요).

SQL 캐싱을 사용할 때

Rails는 요청 내에서 동일한 쿼리에 대해 자동으로 이를 사용하므로, 해당 사용 사례에 대해서는 추가 작업이 필요하지 않습니다.

  • 하지만 identity_cache와 같은 gem을 사용하는 것은 다른 목적을 가집니다: 여러 요청에 걸쳐 쿼리를 캐싱합니다.
  • Article.find(params[:id])와 같은 단일 객체 조회에서는 사용을 피하세요.
  • 결과를 사용할 수 없는 경우도 있으며, 이는 읽기 전용 객체를 제공합니다.
  • 관계를 캐시할 수도 있으며, 이는 목록을 반환하려고 하지만 이를 필터링하거나 정렬하는 것에는 신경 쓰지 않는 상황에서 유용합니다.

참신한 캐시를 사용할 때

다른 옵션을 모두 소진했고, 정말 awkward한 무언가를 캐시해야 한다면, 사용자 정의 솔루션을 고려할 때입니다:

  • GitLab의 예로는 RepositorySetCache, RepositoryHashCacheAvatarCache가 있습니다.
  • 가능한 경우, 비일관성을 더하는 사용자 정의 캐시 구현체를 만드는 것을 피해야 합니다.
  • 매우 효과적일 수 있습니다. 예를 들어, RepositoryHashCache를 사용하여 merged_branch_names 주위에서 캐싱합니다.

캐시 만료

Redis가 키를 만료하는 방법

간단히 말해: 가장 오래된 항목이 새 항목으로 대체됩니다:

  • Redis를 LRU 캐시로 구성하는 것에 대한 유용한 기사입니다.
  • 다양한 캐시 제거 전략에 대한 많은 옵션이 있습니다.
  • 아마도 allkeys-lru를 원할 것입니다. 이는 기능적으로 Memcached와 유사합니다.
  • 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_at
    projects/16-20210316142425469452

  3. 우리가 전달한 심볼을 문자열로 변환한 값
    tag_list

찾아야 할 것들

  • 사용자 특정 데이터
    • 이것은 가장 중요합니다!
    • 특히 뷰에서는 항상 명확하지 않습니다.
    • 캐시하려는 영역에서 사용된 모든 헬퍼 메소드를 살펴봐야 합니다.
  • “Billy가 8분 전에 이 내용을 게시했습니다”와 같은 시간 특정 데이터.
  • updated_at 필드가 변경되지 않지만 레코드가 업데이트 되는 경우.
  • Rails 헬퍼는 뷰의 키에 템플릿 다이제스트를 통합하지만, 헬퍼와 같은 다른 곳에서는 발생하지 않습니다.
  • Grape::Entity는 API 레이어에서 효과적인 캐싱을 매우 어렵게 만듭니다. 이에 대해서는 나중에 더 설명하겠습니다.
  • 뷰에서 조각 캐시 헬퍼 안에 break 또는 return을 사용하지 마세요 - 캐시 항목을 작성하지 않습니다.
  • 이전 데이터를 반환할 수 있는 캐시 키의 항목 순서를 변경하는 것:
    • 예를 들어 nil을 반환할 수 있는 두 값을 가진 경우 이들을 교환하는 것.
    • 대신 { project: nil }와 같은 해시를 사용하세요.
  • Rails는 배열의 구성원에 대해 #cache_key를 호출하여 키를 찾지만, 해시의 값에 대해서는 호출하지 않습니다.