캐싱 가이드라인

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

캐시란 무엇인가요?

데이터에 대한 빠른 리포지터리로, 다음과 같습니다:

  • 컴퓨팅의 여러 영역에서 사용됩니다.
    • 프로세서에는 캐시가 있고, 하드 디스크에도 캐시가 있으며, 여러 가지에 캐시가 있습니다!
  • 원하는 데이터가 최종적으로 끝나기를 바라는 곳에 가깝게 위치합니다.
  • 더 간단한 리포지터리로, 임시적입니다.

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

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

  • 이것은 달성 가능하지만, 현대 애플리케이션에서는 캐싱이 필요합니다.
  • 큰 응답은 더 오래 걸려 만들기 때문에, 캐싱은 일정한 속도를 유지하는 데 중요합니다.
  • 캐시 읽기는 일반적으로 하위 1ms입니다. 이것을 향상시키지 않는 것은 거의 없습니다.
  • 초기 경험이 중요하므로 페이지 로드 후속에 빠른 것만으로 충분하지 않습니다. 따라서 이것은 완전한 해결책이 아닙니다.
  • 사용자별 데이터는 이를 도전적으로 만들고, 기존 애플리케이션을 이 속도 목표를 충족시키기 위한 가장 큰 도전을 제시합니다.
  • 사용자별 캐시는 여전히 효과적일 수 있지만, 사용자 간에 공유되는 일반 캐시보다 캐시 히트가 적게 발생합니다.
  • 항상 페이지로부터 대부분의 로드가 캐시에서 가져와지도록 독려하는 것이 목표입니다.

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

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

캐싱에 대한 의문점

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

GitLab에서의 캐싱

Redis 캐싱에 단점이 있음에도 불구하고, 여전히 GitLab 애플리케이션 및 GitLab.com의 캐싱 설정을 자유롭게 잘 활용해야 합니다. 저희의 캐시 이용률 예측에 따르면 충분한 여유가 있다고 나와 있습니다.

작업 프로세스

방법론

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

캐싱의 이점에 대해 파악하기

추가되는 캐시가 “가치 있는”가요? 이를 메트릭하는 것은 어려울 수 있지만, 고려할 수 있습니다:

  • 캐시된 데이터의 크기는 어떤가요?
    • 이것은 어떤 유형의 캐시 리포지터리를 사용해야 하는지에 영향을 줄 수 있습니다. 예를 들어, 큰 HTML 응답을 RAM이 아닌 디스크에 저장하는 것과 같이요.
  • 캐시를 통해 얼마나 많은 I/O, CPU 및 응답 시간이 절약되나요?
    • 캐시된 데이터가 크지만 렌더링에 소요되는 시간이 낮다면, 예를 들어 큰 텍스트 덩어리를 페이지에 덤프하는 것과 같이, 이것이 가장 적합한 캐시 위치를 나타낼 수 있습니다.
  • 이 데이터는 얼마나 자주 액세스되나요?
    • 자주 액세스되는 데이터를 캐시하는 것이 일반적으로 더 큰 영향을 미칩니다.
  • 이 데이터는 얼마나 자주 변경되나요?
    • 캐시가 다시 읽히기 전에 캐시가 교체된다면, 이 캐시는 실제로 유용할까요?

도구

조사

  • 성능 바는 로컬 및 프로덕션에서 조사를 할 때 첫 번째 단계입니다. 비싼 쿼리, 과도한 Redis 호출 등을 찾아보세요.
  • 플레임그래프를 생성합니다: 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를 사용하세요.
  • 피처 플래그:
    • 캐시를 추가할 때 항상 피처 플래그를 사용하는 것이 거의 항상 가치 있습니다.
    • 그것을 켜고 끄고, 그리고 Grafana에서 진동하는 선들을 살펴보세요.
    • 캐시가 초기에 따듯해지면 응답 시간이 증가할 것입니다.
    • 효과는 100%에서 플래그를 실행할 때까지 명확하지 않습니다.
  • 성능 바:
    • 로컬에서 사용하고, Redis 디렉터리에서의 캐시 호출을 확인하세요.
    • 또한 예상대로 캐시 키가 되는지 프로덕션에서 이 기능을 사용하여 확인하세요.
  • 플레임그래프:
    • 페이지에 ?performance_bar=flamegraph를 추가하세요.

캐시 레벨

상위 레벨

  • HTTP 캐싱:
    • 브라우저에게 캐시된 버전을 제공하도록 ETag와 만료 시간을 사용합니다.
    • 이것은 여전히 Rails에 요청을 보내지만 뷰 레이어를 건너뜁니다.
  • 역방향 프록시 캐시의 HTTP 캐싱:
    • 동일하지만 public 설정을 추가합니다.
    • 브라우저 대신 NGINX, HAProxy, Varnish와 같은 역방향 프록시에 캐시된 버전을 제공합니다.
    • 이후의 요청은 더 이상 Rails에 도달하지 않습니다.
  • HTML 페이지 캐싱:
    • HTML 파일을 디스크에 작성합니다.
    • Web 서버(예: NGINX, Apache, Caddy)가 HTML 파일 자체를 제공하여 Rails를 건너뛸 수 있습니다.
  • 뷰 또는 액션 캐싱:
    • 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로, 거의 Rails.cache.fetch를 대신 사용할 수 있습니다.
  • 에는 “템플릿 트리 다이제스트”가 포함되어 있어 뷰 파일을 수정할 때 자동으로 캐시가 만료됩니다.

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를 사용하세요.
  • 동일한 값이 요청 내에서 여러 번 필요할 때 유용합니다.
  • 동일한 키에 대해 여러 번 캐시 호출을 방지하는 데 사용될 수 있습니다.
  • 테스트 스위트에서 자주 발생하는데, 값이 reload를 호출할 때까지 변하지 않는 ActiveRecord 객체에서 문제를 일으킬 수 있습니다.

요청 캐싱 사용 시기

  • 메서드 캐싱과 유사한 사용 패턴이지만 여러 메서드 전반에 사용될 수 있습니다.
  • 요청 기간 동안 무언가를 저장하는 표준화된 방법입니다.
  • (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 키 만료 방법

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

  • Redis를 LRU(Last Recently Used) 캐시로 구성하는 유용한 기사가 있습니다.
  • 다양한 캐시 만료 전략을 위한 다양한 옵션이 있습니다.
  • 일반적으로 Memcached와 기능적으로 유사한 allkeys-lru를 사용하는 것이 좋습니다.
  • Redis 4.0 이상에서는 allkeys-lfu가 사용 가능하며, 이는 유사하지만 다릅니다.
  • 이제 우리는 명시적인 삭제를 DEL 대신 UNLINK를 사용하여 처리하여 Redis가 즉시가 아닌 자체적으로 메모리를 회수할 수 있게 합니다.
    • 이는 키를 삭제로 표시하고 나중에 실제로 삭제하는 빠른 성공 값을 반환합니다.

Rails 키 만료 방법

  • Rails는 명시적인 삭제 대신 TTL(Time To Live)과 캐시 키 만료를 선호합니다.
  • 캐시 키에는 기본적으로 뷰에서 fragment 캐싱을 할 때 템플릿 트리 다이제스트가 포함되어 있으며 이를 통해 템플릿에 대한 모든 변경 사항이 자동으로 캐시 만료되도록 보장합니다.
    • 그러나 헬퍼에서는 이러한 사실이 성립하지 않으며, 이에 대해 경고합니다.
  • 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 레이어에서 효과적인 캐싱을 매우 어렵게 만듭니다. 이에 대해 나중에 자세히 다루겠습니다.
  • 뷰의 fragment 캐시 헬퍼 내에서 break 또는 return을 사용하지 마세요. 이는 캐시 항목을 쓰지 않습니다.
  • 예전 데이터를 반환할 수 있는 캐시 키 내에서 항목의 순서를 변경하는 것을 조심하세요:
    • nil을 반환할 수 있는 두 값을 가지고 순서를 바꾸는 것과 같은 경우.
    • 대신 { project: nil }과 같이 해시를 사용하세요.
  • Rails는 배열의 멤버에 #cache_key를 호출하여 키를 찾지만 해시의 값에 대해 호출하지는 않습니다.