>백엔드 개발 >Golang >Go의 확률적 조기 만료

Go의 확률적 조기 만료

Mary-Kate Olsen
Mary-Kate Olsen원래의
2024-09-29 06:19:02743검색

캐시 스탬피드 정보

이런저런 캐시를 해야 하는 상황에 자주 빠지곤 합니다. 종종 이러한 값은 일정 기간 동안 캐시됩니다. 당신은 아마도 패턴에 익숙할 것입니다. 캐시에서 값을 얻으려고 시도하고, 성공하면 이를 호출자에게 반환하고 하루를 호출합니다. 값이 없으면 (아마도 데이터베이스에서) 가져오거나 계산하여 캐시에 넣습니다. 대부분의 경우 이는 훌륭하게 작동합니다. 그러나 캐시 항목에 사용하는 키에 자주 액세스하고 데이터를 계산하는 작업에 시간이 걸리면 여러 병렬 요청이 동시에 캐시 누락되는 상황이 발생하게 됩니다. 이러한 모든 요청은 소스에서 독립적으로 로드되고 값을 캐시에 저장합니다. 이로 인해 리소스가 낭비되고 서비스 거부로 이어질 수도 있습니다.

예를 들어 설명하겠습니다. 캐시에는 redis를 사용하고 그 위에는 간단한 Go http 서버를 사용하겠습니다. 전체 코드는 다음과 같습니다.

package main

import (
    "errors"
    "log"
    "net/http"
    "time"

    "github.com/redis/go-redis/v9"
)

type handler struct {
    rdb *redis.Client
    cacheTTL time.Duration
}

func (ch *handler) simple(w http.ResponseWriter, r *http.Request) {
    cacheKey := "my_cache_key"
    // we'll use 200 to signify a cache hit & 201 to signify a miss
    responseCode := http.StatusOK
    cachedData, err := ch.rdb.Get(r.Context(), cacheKey).Result()
    if err != nil {
        if !errors.Is(err, redis.Nil) {
            log.Println("could not reach redis", err.Error())
            http.Error(w, "could not reach redis", http.StatusInternalServerError)
            return
        }

        // cache miss - fetch & store
        res := longRunningOperation()
        responseCode = http.StatusCreated

        err = ch.rdb.Set(r.Context(), cacheKey, res, ch.cacheTTL).Err()
        if err != nil {
            log.Println("failed to set cache value", err.Error())
            http.Error(w, "failed to set cache value", http.StatusInternalServerError)
            return
        }
        cachedData = res
    }
    w.WriteHeader(responseCode)
    _, _ = w.Write([]byte(cachedData))
}

func longRunningOperation() string {
    time.Sleep(time.Millisecond * 500)
    return "hello"
}

func main() {
    ttl := time.Second * 3
    rdb := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })

    handler := &handler{
        rdb: rdb,
        cacheTTL: ttl,
    }

    http.HandleFunc("/simple", handler.simple)
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatalf("Could not start server: %s\n", err.Error())
    }
}

/simple 엔드포인트에 로드를 걸어 어떤 일이 일어나는지 살펴보겠습니다. 이를 위해 베지터를 사용하겠습니다.

베지터 공격 -duration=30s -rate=500 -targets=./targets_simple.txt >를 실행합니다. res_simple.bin. Vegeta는 결국 30초 동안 초당 500개의 요청을 보냅니다. 나는 이를 각각 100ms에 걸쳐 있는 버킷이 포함된 HTTP 결과 코드의 히스토그램으로 그래프로 표시합니다. 그 결과는 다음과 같은 그래프입니다.

Probabilistic Early Expiration in Go

실험을 시작하면 캐시가 비어 있습니다. 캐시에 저장된 값이 없습니다. 많은 요청이 서버에 도달하면 초기에 엄청난 속도로 몰려들게 됩니다. 그들 모두는 캐시를 확인하여 거기에 아무것도 없다는 것을 발견하고 longRunningOperation을 호출하여 캐시에 저장합니다. longRunningOperation은 처음 500ms 동안 이루어진 요청을 완료하는 데 최대 500ms가 걸리므로 결국 longRunningOperation을 호출하게 됩니다. 요청 중 하나가 캐시에 값을 저장하면 다음 모든 요청이 캐시에서 해당 값을 가져오고 상태 코드 200으로 응답을 보기 시작합니다. 그러면 Redis의 만료 메커니즘이 시작되면서 패턴이 3초마다 반복됩니다.

이 장난감 예에서는 이로 인해 문제가 발생하지 않지만 프로덕션 환경에서는 시스템에 불필요한 로드가 발생하거나 사용자 경험이 저하되거나 자체적으로 서비스 거부가 발생할 수도 있습니다. 그렇다면 어떻게 이를 예방할 수 있을까요? 음, 몇 가지 방법이 있습니다. 잠금을 도입할 수 있습니다. 캐시가 누락되면 코드가 잠금을 달성하려고 시도하게 됩니다. 분산 잠금은 사소한 일이 아니며 종종 섬세한 처리가 필요한 미묘한 경우가 있습니다. 백그라운드 작업을 사용하여 주기적으로 값을 다시 계산할 수도 있지만 이를 위해서는 코드에서 유지 관리하고 모니터링해야 하는 또 다른 톱니바퀴를 도입하는 추가 프로세스가 필요합니다. 동적 캐시 키가 있는 경우에는 이 접근 방식을 수행하지 못할 수도 있습니다. 확률적 조기 만료라는 또 다른 접근 방식이 있는데, 이에 대해 더 자세히 알아보고 싶습니다.

확률적 조기 만료

이 기술을 사용하면 확률에 따라 값을 다시 계산할 수 있습니다. 캐시에서 값을 가져올 때 확률에 따라 캐시 값을 다시 생성해야 하는지도 계산합니다. 기존 가치의 만료일에 가까울수록 확률은 높아집니다.

최적 확률적 캐시 스탬피드 방지에서 A. Vattani, F.Chierichetti 및 K. Lowenstein의 XFetch에 대한 구체적인 구현을 기반으로 하고 있습니다.

비용이 많이 드는 계산을 수행하지만 이번에는 캐싱 시 XFetch를 사용하는 HTTP 서버의 새 엔드포인트를 소개하겠습니다. XFetch가 작동하려면 비용이 많이 드는 작업에 걸린 시간(델타)과 캐시 키가 만료되는 시기를 저장해야 합니다. 이를 달성하기 위해 이러한 값과 메시지 자체를 담을 구조체를 소개하겠습니다.

type probabilisticValue struct {
    Message string
    Expiry time.Time
    Delta time.Duration
}

원본 메시지를 이러한 속성으로 래핑하고 redis에 저장하기 위해 직렬화하는 기능을 추가합니다.

func wrapMessage(message string, delta, cacheTTL time.Duration) (string, error) {
    bts, err := json.Marshal(probabilisticValue{
        Message: message,
        Delta: delta,
        Expiry: time.Now().Add(cacheTTL),
    })
    if err != nil {
        return "", fmt.Errorf("could not marshal message: %w", err)
    }

    return string(bts), nil
}

redis에서 값을 다시 계산하고 저장하는 메서드도 작성해 보겠습니다.

func (ch *handler) recomputeValue(ctx context.Context, cacheKey string) (string, error) {
    start := time.Now()
    message := longRunningOperation()
    delta := time.Since(start)

    wrapped, err := wrapMessage(message, delta, ch.cacheTTL)
    if err != nil {
        return "", fmt.Errorf("could not wrap message: %w", err)
    }
    err = ch.rdb.Set(ctx, cacheKey, wrapped, ch.cacheTTL).Err()
    if err != nil {
        return "", fmt.Errorf("could not save value: %w", err)
    }
    return message, nil
}

확률에 따라 값을 업데이트해야 하는지 결정하기 위해 probabilisticValue에 메서드를 추가할 수 있습니다.

func (pv probabilisticValue) shouldUpdate() bool {
    // suggested default param in XFetch implementation
    // if increased - results in earlier expirations
    beta := 1.0
    now := time.Now()
    scaledGap := pv.Delta.Seconds() * beta * math.Log(rand.Float64())
    return now.Sub(pv.Expiry).Seconds() >= scaledGap
}

모두 연결하면 다음 핸들러로 끝납니다.

func (ch *handler) probabilistic(w http.ResponseWriter, r *http.Request) {
    cacheKey := "probabilistic_cache_key"
    // we'll use 200 to signify a cache hit & 201 to signify a miss
    responseCode := http.StatusOK
    cachedData, err := ch.rdb.Get(r.Context(), cacheKey).Result()
    if err != nil {
        if !errors.Is(err, redis.Nil) {
            log.Println("could not reach redis", err.Error())
            http.Error(w, "could not reach redis", http.StatusInternalServerError)
            return
        }

        res, err := ch.recomputeValue(r.Context(), cacheKey)
        if err != nil {
            log.Println("could not recompute value", err.Error())
            http.Error(w, "could not recompute value", http.StatusInternalServerError)
            return
        }
        responseCode = http.StatusCreated
        cachedData = res

        w.WriteHeader(responseCode)
        _, _ = w.Write([]byte(cachedData))
        return
    }

    pv := probabilisticValue{}
    err = json.Unmarshal([]byte(cachedData), &pv)
    if err != nil {
        log.Println("could not unmarshal probabilistic value", err.Error())
        http.Error(w, "could not unmarshal probabilistic value", http.StatusInternalServerError)
        return
    }

    if pv.shouldUpdate() {
        _, err := ch.recomputeValue(r.Context(), cacheKey)
        if err != nil {
            log.Println("could not recompute value", err.Error())
            http.Error(w, "could not recompute value", http.StatusInternalServerError)
            return
        }
        responseCode = http.StatusAccepted
    }

    w.WriteHeader(responseCode)
    _, _ = w.Write([]byte(cachedData))
}

핸들러는 첫 번째 핸들러와 매우 유사하게 작동하지만 캐시 적중이 발생하면 주사위를 굴립니다. 결과에 따라 방금 가져온 값을 반환하거나 값을 일찍 업데이트합니다.

HTTP 상태 코드를 사용하여 3가지 경우를 결정합니다.

  • 200 - 캐시에서 값을 반환했습니다
  • 201 - 캐시 누락, 값 없음
  • 202 - 캐시 적중, 확률적 업데이트 트리거

이번에는 새로운 엔드포인트에 대해 vegeta를 다시 시작했고 그 결과는 다음과 같습니다.

Probabilistic Early Expiration in Go

여기에 있는 작은 파란색 얼룩은 실제로 캐시 값을 일찍 업데이트한 시기를 나타냅니다. 초기 워밍업 기간 이후에는 더 이상 캐시 누락이 표시되지 않습니다. 초기 급증을 방지하려면 사용 사례에 중요한 경우 캐시된 값을 미리 저장할 수 있습니다.

캐싱을 더욱 적극적으로 사용하고 값을 더 자주 새로 고치고 싶다면 베타 매개변수를 사용해 보세요. 베타 매개변수를 2로 설정한 동일한 실험은 다음과 같습니다.

Probabilistic Early Expiration in Go

이제 확률적 업데이트가 훨씬 더 자주 표시됩니다.

이 모든 것은 캐시 압류를 방지하는 데 도움이 될 수 있는 깔끔하고 작은 기술입니다. 하지만 이는 캐시에서 동일한 키를 주기적으로 가져오는 경우에만 작동한다는 점을 명심하세요. 그렇지 않으면 큰 이점을 볼 수 없습니다.

캐시 폭주를 처리하는 다른 방법이 있나요? 실수를 발견하셨나요? 아래 댓글로 알려주세요!

위 내용은 Go의 확률적 조기 만료의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
이전 기사:GO에 HTMX 추가다음 기사:GO에 HTMX 추가