Publicações

Reduzindo o uso do banco de dados utilizando checksum

Richard do Nascimento Fagundes
Richard do Nascimento Fagundes Backend Developer

Conforme o crescimento dos dados, é natural que encontremos eventuais limites na arquitetura. Abordagens como aumentar cada vez mais recurso computacional ou mudar significativamente a estrutura desses dados, normalmente, são atitudes radicais que podem ser prejudiciais para o estado atual do software.

Identificando um problema e realizando correlações

Em um determinado projeto, possuímos diversos micro serviços que periodicamente realizam atualizações em massa de dados, como stack principal esse serviços utilizam Django, RabbitMQ e PostgreSQL.

Um dos micro serviços desse projeto interno que consome dados de múltiplas fontes, apresentou um consumo elevado de memória, levando a instância ao erro Out-of-Memory Killer. O Out-of-Memory Killer é um limite do kernel Linux. Esse limite resultava em reinicializações constantes no serviço.

Dado os problemas de Out-of-Memory Killer, a investigação começa logicamente pelo consumo de memória da aplicação.

Realizando testes para reproduzir o comportamento do serviço em produção, foi notado que a primeira vez em que os dados foram disparados para a aplicação, não houve impacto significativo na memória da instância. Porém, quando a aplicação era alimentada com os mesmos dados em um período muito curto, agora sim, o mesmo comportamento de aumento no consumo de memória era replicado.

Simultaneamente, outros efeitos também foram notados:

  • Consumo anormal de CPU no banco de dados;
  • Acúmulo de mensagens não entregues pelo broker;
  • Aplicações recebendo uma quantidade expressiva de dados a serem processados;
  • O período de alto consumo de CPU coincidia com o período em que eventos de atualização eram executados.

Um fato interessante é que, olhando para os dados, a grande maioria deles não sofreram alteração entre uma atualização e outra.

A partir da análise desse comportamento e reflexão sobre as informações obtidas, surge o seguinte insight:

Se nem todos os dados sofrem alteração, então porque simplesmente ignorar os dados não alterados e aliviar a utilização do banco de dados?

Para isso, foi realizada uma abordagem utilizando checksum para verificar se houve ou não modificação nos dados a serem atualizados.

Um checksum é o resultado de um algoritmo de função hash e é utilizado para validar a integridade dos dados.

Assim que um dado de entrada é utilizado em uma função de checksum, a sua saída deve ser sempre a mesma, como uma identificação desse dado.

EntradaSaída
valor1f87147a9bb4358e0d46c69cacc66ecb5788d692c
valor1f87147a9bb4358e0d46c69cacc66ecb5788d692c
valor26a29dc5a3309b0b72d3534a450d555dace71fb31

Definindo uma solução paliativa

Somado a característica que alguns serviços tinham de persistir um objeto por vez no banco de dados, junto a necessidade de processar vários dados sem esbarrar no tempo de espera do banco de dados nasce uma solução.

Duas abordagens complementares foram utilizadas na refatoração dos serviços que tinham esse comportamento:

  1. Realizar inserções e atualizações por lote em vez de um objeto por vez.
  2. Utilizar checksum para verificar modificações nos dados antes de salvar.

Fluxograma da solução

A solução consiste em um campo que armazena o checksum.

class BaseModel(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    base_checksum = models.CharField(max_length=128, null=True)

    class Meta:
        abstract = True

O cálculo do checksum é realizado a partir do conteúdo a ser persistido no banco de dados.

def compute_checksum(dictionary: Dict) -> str:
    return blake2b(
        bytes(repr(sorted(dictionary.items())), 'utf-8')
    ).hexdigest()

No código abaixo, o método bulk_persistence é responsável por criar ou atualizar somente os dados que sofreram modificações, ele recebe como parâmetro unique_key que é uma identificação única daquele objeto, em fields os campos a serem considerados na atualização, o payload que é uma lista de dicionários contendo os dados dos objetos e batch_size que é passado para os métodos de persistência por lote:

@classmethod
def bulk_persistence(
    cls,
    unique_key: str,
    fields: List[str],
    payload: List[Dict],
    batch_size: Optional[int] = 500,
) -> Tuple[int, int]:

No trecho de código a seguir, é montado um dicionário com o valor do checksum e o id do objeto distinguindo-os pela unique_key:

 if "base_checksum" not in fields:
     fields.append("base_checksum")
 if unique_key == 'id':
     current_checksums = {
         model[0]: (model[0], model[1])
         for model in cls.objects.values_list(
             'id', 'base_checksum'
         ).iterator()
     }
 else:
     current_checksums = {
         model[0]: (model[1], model[2])
         for model in cls.objects.values_list(
             unique_key, 'id', 'base_checksum'
         ).iterator()
     }

Em seguida, é realizada a separação dos dados entre os que devem ser inseridos e atualizados a partir da comparação do checksum atual com o novo checksum:

update_payload = set()
create_payload = set()

for item in payload:
    item = item.copy()
    current_item = current_checksums.get(item.get(unique_key))
    new_checksum = compute_checksum(item)

    if current_item:
        current_id, current_checksum = current_item

        if not current_checksum == new_checksum:
            item.update({'id': current_id})
            item.update({'base_checksum': new_checksum})
            update_payload.add(cls(**item))
    else:
        item.update({'base_checksum': new_checksum})
        create_payload.add(cls(**item))

if create_payload:
    cls.objects.bulk_create(
        create_payload,
        batch_size=batch_size,
    )
if update_payload:
    cls.objects.bulk_update(
        update_payload,
        fields,
        batch_size=batch_size,
    )
return (len(create_payload), len(update_payload))

Analisando os resultados

Em testes com payloads com tamanhos de 500, 1000, e 5000 itens, com dados gerados aleatoriamente, conseguimos obter certos resultados com os seguintes benchmarks:

O código abaixo gera o benchmark da situação do pior caso, que apenas insere todos os dados pela primeira vez:

def run_current_approach(payload):
    start = time()
    equip_payload = [Equipment(**equip) for equip in payload]
    Equipment.objects.bulk_create(equip_payload, batch_size=500)
    end = time()
    Equipment.objects.all().delete()
    return end - start

def run_new_approach(payload):
    start = time()
    Equipment.bulk_persistence("id", FIELDS, payload)
    end = time()
    Equipment.objects.all().delete()
    return end - start

No pior caso, onde os dados são totalmente novos, podemos notar o overhead gerado pelo cálculo do checksum.

Benchmark do pior caso

Esse próximo trecho de código, gera o benchmark que representa a situação do melhor caso, onde todos os dados que não sofreram alterações são processados:

def run_current_approach(payload):
    start = time()
    equip_payload = [Equipment(**equip) for equip in payload]
    Equipment.objects.bulk_create(equip_payload, batch_size=500)
    Equipment.objects.bulk_update(equip_payload, FIELDS, batch_size=500)
    end = time()
    Equipment.objects.all().delete()
    return end - start

def run_new_approach(payload):
    start = time()
    Equipment.bulk_persistence("id", FIELDS, payload)
    Equipment.bulk_persistence("id", FIELDS, payload)
    end = time()
    Equipment.objects.all().delete()
    return end - start

No melhor caso, onde os dados a serem atualizados se repetem, uma vez já calculado e armazenado o checksum original, as novas atualizações trocam o tempo de espera pelo banco de dados pelo tempo do cálculo do checksum.

Podemos notar um ganho considerável na performance dessa abordagem ao se comparar com o resultado anterior.

Benchmark do melhor caso

Considerações sobre a solução

Prós:

  • Método único para inserção e atualização em massa dos dados.
  • Atualiza somente quando necessário, reduzindo a utilização do banco de dados.

Contras:

  • Toda a estratégia de calcular o checksum se torna irrelevante quando a grande maioria dos dados a serem atualizados são alterados com extrema frequência.
  • Lidar com relacionamentos entre as classes acaba se tornando manual, utilizando os id dos objetos para relacioná-los.

Como tudo na tecnologia, não existe bala de prata, devemos analisar com pensamento crítico sobre as adversidades e compor alternativas que entreguem resultado de acordo com as atuais necessidades e capacidades.

Referências

https://www.lifewire.com/what-does-checksum-mean-2625825 | Lifewire

https://www.oracle.com/technical-resources/articles/it-infrastructure/dev-oom-killer.html | Oracle IT Infrastructure

Solução de problemas, melhoria de gestão e aumento de produtividade

Com tecnologias renomadas, aplicamos práticas ágeis e capacitamos sua equipe.

Fale com um consultor