Catálogo de padrões de sistemas distribuídos #6

Adriano Croco
5 min readAug 5, 2024

--

Olá!

Esse texto faz parte de uma série. Você encontra os textos anteriores nos links abaixo:

Data Replication — Parte 1
Data Replication — Parte 2
Data Replication — Parte 3
Time Management — Parte 4
Cluster Management — Parte 5

Neste texto, vou comentar sobre padrões relacionados a comunicação entre os nós de um cluster.

Single-Socket Channel

O problema que esse padrão resolve é o seguinte: manter a comunicação entre os nós sincronizada, ao mesmo tempo que usa poucas conexões de rede no processo. Portanto, no código de cada um dos nós, você usa somente uma conexão TCP para esse tipo de tarefa. Apesar de limitar o throughput, é mais saudável para o restante do cluster do que sobrecarregar a rede só com tráfego de instruções de gestão do sistema.

Geralmente, em servidores web, é recomendado usar um channel multi-socket, para suportar múltiplas conexões para aquele servidor simultaneamente e responder a múltiplos clientes.

Para não ficar tão abstrato, vamos a um exemplo prático de como seria esse socket único em uma linguagem com um bom suporte para TCP (Go), considerando o código que roda do lado do servidor:

//server-single-socket.go
package main

import (
"fmt"
"net"
"bufio"
)

func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Erro ao iniciar o servidor:", err)
return
}
defer ln.Close()
fmt.Println("Servidor ouvindo na porta 8080")

conn, err := ln.Accept()
if err != nil {
fmt.Println("Erro ao aceitar conexão:", err)
return
}
defer conn.Close()

message, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
fmt.Println("Erro ao ler mensagem:", err)
return
}
fmt.Print("Mensagem recebida: ", message)
}

Nesse caso, cada nó teria uma instância desse código rodando e recebendo as mensagens de gerenciamento, por exemplo, mensagens de eleição de um novo líder enviadas no padrão leaders and followers.

Para uma conexão multi-socket, que suporta múltiplos clientes externos e aumenta o throughput do servidor, temos as seguintes diferenças:

//server-multi-socket.go
package main

import (
"bufio"
"fmt"
"net"
"strings"
)

func handleConnection(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
message, err := reader.ReadString('\n')
if err != nil {
fmt.Println("Erro ao ler mensagem:", err)
return
}
message = strings.TrimSpace(message)
fmt.Println("Mensagem recebida:", message)
if message == "exit" {
fmt.Println("Fechando conexão com o cliente")
return
}
response := "Mensagem recebida: " + message + "\n"
conn.Write([]byte(response))
}
}

func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Erro ao iniciar o servidor:", err)
return
}
defer ln.Close()
fmt.Println("Servidor ouvindo na porta 8080")

for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Erro ao aceitar conexão:", err)
continue
}
//a principal diferença está aqui
//o multi-socket usa goroutines para fazer processamento async
go handleConnection(conn)
}
}

Ou seja, resumidamente, para conexões com clientes, suporte múltiplas conexões. Para conexões intra-cluster, use as conexões únicas para poupar recursos.

Request Pipeline

No contexto de sistemas distribuídos, a técnica consiste em usar o máximo da capacidade de comunicação intra-nós sempre que possível. Para tal, a recomendação é usar uma thread de saída de mensagens e uma outra dedicada a receber mensagens que vieram da rede.

Essa imagem se refere ao contexto de sistema distribuídos

Com isso, o canal de comunicação de leitura não precisa concorrer com a escrita e vice-versa. A título de curiosidade, o padrão de arquitetura CQRS acaba gerando o mesmo tipo de isolamento entre leitura e escrita.

Ao meu ver, esse isolamento é uma boa prática de maneira geral. Recomendo que pense nesses termos sempre que estiver projetando sistemas. Concorrência entre leitura e escrita no mesmo canal é um gargalo muito comum e facilmente resolvido com esse tipo de separação.

Porém, o mesmo nome (Request Pipeline) pode se referir a uma outra técnica de processamento de requests em frameworks web, no qual cada request tem o processamento feito em etapas, com uma responsabilidade bem definida entre cada passo. Outro termo para essa técnica é middleware.

Esse é o OUTRO Request Pipeline

Fiz questão de deixar essa desambiguação para pessoas não tão familiarizadas não se confundirem. Portanto, é por esse tipo de situação que pessoas que trabalham com software dizem que nomear coisas é difícil.

Request Batch

Essa técnica, particularmente, eu gosto bastante e já usei inúmeras vezes, com resultados muito bons em todas as situações.

Em textos anteriores sobre otimização, comento sobre a seguinte máxima da computação, que é válida na arquitetura de computadores atual: CPU é mais rápida que memória, que é mais rápida que disco, que é mais rápida que a rede.

Portanto, ao aplicar o mesmo princípio na comunicação entre nós via rede, basta otimizar o envio de mensagens ao enviar um conjunto de mensagens (batch) juntas, ao invés de enviar uma por uma.

Somente com isso, já ocorre um significativo aumento de performance. Para definir o tamanho do batch, é bom levar em consideração as limitações de hardware e banda, além de quanto RAM a mais a aplicação vai usar por armazenar algumas mensagens na memória por algum tempo e demais consequências relacionadas.

Exemplo de Request Batch em outro contexto (API Gateway), mas a ideia é a mesma

De uma maneira geral, o ganho de performance sempre compensa o esforço.

Request Waiting List

Imagine que o sistema distribuído precisa realizar uma leitura qualquer de forma assíncrona. No processo, ocorre a execução de outras técnicas de gerenciamento do cluster, como a definição de um novo líder ou até mesmo a decisão usando Majority Quorum de um novo valor a ser retornado como verdade.

Como esse processo é assíncrono, é necessário manter uma lista em algum lugar que junta quem pediu o quê e como retornar isso para esse solicitante. O jeito mais simples de fazer isso é uma lista que contém uma tupla com uma chave única por requisição (que pode ser o correlationId, por exemplo) e uma função de callback.

É de responsabilidade do callback lidar com a resposta e decidir se a solicitação do cliente pode ser atendida.

Com isso, encerro esta série. Espero que esse texto tenha sido útil para você de alguma forma.

Até!

Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.

--

--