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:
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:
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
}
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
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
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
}
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.