Melhorando a resiliência de sua aplicação com Stale Cache
Renan de Andrade

Renan de Andrade @renandotcorrea

About: An passionate software developer that likes do interesting things.

Location:
Maranhão, Brazil
Joined:
Mar 8, 2023

Melhorando a resiliência de sua aplicação com Stale Cache

Publish Date: May 15
0 0

Durante nossa jornada como desenvolvedores de software é possível encontrarmos problemas que exigem técnicas inusitadas para resolvê-los. Mas a grande maioria pode ser resolvida com técnicas simples.

O assunto que este artigo apresenta pode ser não ser o mais badalado do momento, mas pode ser bastante útil se usado de forma certa.

O problema

Imagine que você tem uma aplicação Web simples, com um proxy reverso na frente, uma API rodando na sua linguagem favorita (vamos utilizar Go aqui :)), um banco Redis para cache, um banco de dados e uma chamada para uma aplicação externa. Como no exemplo a seguir:

diagrama de uma sustem design simples com um proxy reverso, uma api, um banco Redis, um banco de dados e uma API Externa

A aplicação externa consumida provê dados que enriquecem as informações dos produtos oferecidos por sua aplicação. Mas o temos um problema: essa API externa é muito instável. Dependendo da carga aplicada, ela começa a ficar bem lenta e às vezes até pára de responder.

Você não pode deixar que seus clientes sintam essa instabilidade (pelo menos não deveria), como resolver isso então?

Solução

Dentre as várias técnicas de resiliência que podem ser aplicadas, vamos trabalhar com uma bem simples aqui: fazer caching no Redis. É uma solução óbvia, dado que sua aplicação já possui um Redis.

Mas um ponto importante a se considerar é que todo caching tem um TTL (Time to Live). E sempre há um trade-off na escolha de um valor para essa configuração (como tudo na Engenharia de Software). Se o tempo for muito curto, o cache pode não ser eficiente. Se for muito longo, você pode entregar dados muito desatualizados para o cliente.

Isso significa que em algum momento você vai precisar fazer um refresh desse cache. Mas e se a API não responder? :()

Para casos como esse (um pouco específicos, eu admito), usar cache stale pode ser um saída interessante.

Cache Stale

A proposta é super simples, você precisa entregar algo para o cliente, mesmo que seja um valor um pouco velho. E o quão velho é permitido que esse valor seja também será um escolha feita por você, ok?!

Dito isso, podemos então dividir o tempo de vida (TTL) do nosso dado em caching em duas partes: período fresco (fresh) e período obsoleto (stale).

Em palavras simples:

Por um período, o valor em cache é considerado útil para ser entregue ao cliente. No restante, ele é considerado velho, que precisa ser renovado, mas que pode ser entregue ao cliente se a renovação der errado.

Observe a representação gráfica abaixo:

Representação gráfica do TTL final

Isso significa que nosso TTL final a ser informado no Redis será a soma dessas duas fatias de tempo. Mas o controle de quando fazer a renovação do cache ficará por parte da aplicação.

Aqui temos um exemplo de como implementar essa lógica usando golang:

func (repo *RedisRepository) Set(ctx context.Context, key string, value interface{}, config ...CacheConfig) error {
    ttl := repo.defaultRedisConfig.MaxStaleAge + repo.defaultRedisConfig.ValidAge
    if len(config) > 0 {
        ttl = config[0].MaxStaleAge + config[0].ValidAge
    }

    err := repo.client.Set(ctx, key, value, ttl).Err()
    if err != nil {
        return errors.New("error to set to Redis")
    }

    return nil
}

func (repo *RedisRepository) Get(ctx context.Context, key string, config ...CacheConfig) ([]byte, bool, error) {
    maxStaleAge := repo.defaultRedisConfig.MaxStaleAge
    if len(config) > 0 {
        maxStaleAge = config[0].MaxStaleAge
    }

    var remainingTTLCmd *redis.DurationCmd
    var valueCmd *redis.StringCmd
    stale := false

    _, err := repo.client.Pipelined(ctx, func(pipe redis.Pipeliner) error {
        valueCmd = pipe.Get(ctx, key)
        remainingTTLCmd = pipe.TTL(ctx, key)

        return nil
    })

    err = valueCmd.Err()
    if err != nil {
        if errors.Is(err, redis.Nil) {
            return nil, false, err
        }

        log.Println("error to get from Redis ", err.Error())
        return nil, false, errors.New("error to get from Redis")
    }

    err = remainingTTLCmd.Err()
    if err != nil {
        log.Println("error to get TTL to Redis ", err.Error())
        return nil, false, errors.New("error to get TTL to Redis")
    }

    stale = remainingTTLCmd.Val().Seconds() <= maxStaleAge.Seconds()

    return []byte(valueCmd.Val()), stale, err
}
Enter fullscreen mode Exit fullscreen mode

Renovando o cache

A renovação do valor em cache pode acontecer de forma síncrona ou assíncrona. Abaixo temos uma representação gráfica de cada um.

Abordagem síncrona

Renovação de cache síncrona

A vantagem de usar a síncrona é que existe a possibilidade de entregar um dado mais fresco de imediato para o usuário.

Abordagem assíncrona

Renovação de cache assíncrona

Já no assíncrono temos a vantagem de não deixar o usuário esperando por um dado mais fresco, podemos entregar o stale de imediato e renovar o cache em paralelo.

Para o nosso exemplo, vamos fazer a renovação síncrona. Mas desafie-se a também implementar a assíncrona :)

func (repo *RedisRepository) GetOrRefresh(ctx context.Context, key string, refreshFn func() (value []byte, err error), config ...CacheConfig) ([]byte, error) {
    val, stale, err := repo.Get(ctx, key, config...)
    if err == nil && !stale {
        return val, nil
    }

    if err != nil && !errors.Is(err, redis.Nil) {
        log.Println("error to get from Redis", err)
        return nil, errors.New("error to get to Redis")
    }

    freshValue, err := refreshFn()
    if err != nil {
        log.Println("error to refresh Cache", err)
        return nil, errors.New("error to refresh Cache")
    }

    go func() {
        err := repo.Set(ctx, key, freshValue, config...)
        if err != nil {
            log.Println("error to set to Redis", err)
        }
    }()

    return freshValue, nil
}
Enter fullscreen mode Exit fullscreen mode

Conclusão

Desta forma, você garante uma boa resiliência na sua aplicação. Além de garantir que o cliente sempre receba os dados, mesmo que a fonte (API externa) venha a falhar.

Esta é um dica simples mas que pode ser muito útil quando lidamos com aplicações que precisam de alta resiliência. Se este post gerou mais dúvidas que respostas, melhor ainda. Sei que você não vai deixar essas dúvidas em aberto :)

O código desenvolvido neste artigo pode ser encontrado neste repo no Github. Se tiver alguma dúvida ou sugestão, aguardo você nos comentários :)

Tenho certeza que nos veremos novamente. Até a próxima. #keephacking

Crédito de cover image: Photo by Mohamed B. on Unsplash.

Comments 0 total

    Add comment