Praticamente todos os produtos de software modernos consistem em vários serviços. Muitas vezes, o alto tempo de resposta dos canais entre serviços se torna uma fonte de problemas de desempenho. A solução padrão para esse tipo de problema é agrupar várias solicitações entre serviços em um único pacote, chamado de processamento em lote (batching).
Se você estiver utilizando processamento em lote, pode não estar satisfeito com o resultado em termos de desempenho ou clareza do código. Este método não é tão simples para a parte chamadora como pode parecer. Para diferentes objetivos e em diferentes situações, as soluções podem variar bastante. Com exemplos concretos, demonstrarei os prós e contras de várias abordagens.
Projeto de demonstração
Para uma melhor ilustração, vejamos o exemplo de um dos serviços do aplicativo em que estou trabalhando atualmente.
Esclarecimento sobre a escolha da plataforma para os exemplosO problema de desempenho ruim é bastante comum e não se limita a linguagens ou plataformas específicas. Neste artigo, para demonstrar tarefas e soluções, serão utilizados exemplos de código em Spring + Kotlin. O Kotlin é igualmente compreensível (ou incompreensível) para desenvolvedores Java e C#, além de permitir um código mais compacto e claro do que o Java. Para facilitar a compreensão para desenvolvedores puramente Java, evitarei a mágica negra do Kotlin e usarei apenas a branca (na linha do Lombok). Haverá alguns métodos de extensão, mas eles são, na verdade, familiares a todos os programadores Java como métodos estáticos, então isso será um pequeno doce que não estragará o sabor do prato.
Existem serviços de aprovação de documentos. Alguém cria um documento e o submete à discussão, durante a qual são feitas revisões, e no final, o documento é aprovado. O próprio serviço de aprovação não sabe nada sobre os documentos: é apenas um chat de aprovadores com algumas funções adicionais menores, que não iremos considerar aqui.
Então, existem salas de chat (correspondentes aos documentos) com um conjunto predefinido de participantes em cada uma delas. Como em chats comuns, as mensagens contêm texto e arquivos e podem ser respostas (reply) e encaminhamentos (forward):
classe de dados MensagemChat(
// nullable так как появляется только после persist
val id: Longo? = null,
/** Ссылка на автора */
val autor: ReferenciaUsuario,
/** Сообщение */
val mensagem: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val files: Lista<ReferenciaArquivo>? = null,
/** Если является ответом, то здесь будет оригинал */
val responderA: MensagemChat? = null,
/** Если является пересылкой, то здесь будет оригинал */
val encaminharDe: MensagemChat? = null
)
Os links para o arquivo e o usuário são links para outros domínios. Aqui isso funciona assim:
tipoalias ReferenciaArquivo = Longo
tipoalias ReferenciaUsuario = Longo
Os dados dos usuários são armazenados no Keycloak e recuperados através de REST. O mesmo se aplica aos arquivos: os arquivos e suas metainformações residem em um serviço separado de armazenamento de arquivos.
Todas as chamadas desses serviços são requisições pesadas. Isso significa que os custos de transporte dessas requisições são muito maiores do que o tempo necessário para o processamento por um serviço externo. Em nossos ambientes de teste, o tempo típico de chamada para esses serviços é de 100 ms, então, para fins futuros, usaremos esses números.
Precisamos criar um controlador REST simples para obter as últimas N mensagens com todas as informações necessárias. Ou seja, consideramos que no frontend a modelagem das mensagens é quase igual e precisamos retransmitir todos os dados. A diferença no modelo para o frontend é que o arquivo e o usuário precisam ser apresentados de forma um pouco desdobrada, para que se tornem links:
/** В таком виде отдаются ссылки на сущности для фронта */
classe de dados Interface de Referência(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val nome: String
)
classe de dados Interface de Mensagem do Chat(
val id: Longo,
/** Ссылка на автора */
val autor: Interface de Referência,
/** Сообщение */
val mensagem: String,
/** Ссылки на аттачи */
val files: Lista<Interface de Referência>,
/** Если являтся ответом, то здесь будет оригинал */
val responderA: Interface de Mensagem do Chat? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val encaminharDe: Interface de Mensagem do Chat? = null
)
Precisamos implementar o seguinte:
interface ChatRestApi {
função getLast(n: Int): Lista<Interface de Mensagem do Chat>
}
O postfix UI refere-se aos modelos DTO para o frontend, ou seja, aquilo que precisamos devolver via REST.
Pode parecer surpreendente que não estamos passando nenhum identificador de chat e que mesmo no modelo ChatMessage/ChatMessageUI ele não está presente. Fiz isso intencionalmente para não sobrecarregar o código dos exemplos (os chats estão isolados, então podemos considerar que temos apenas um).
Uma digressão filosóficaTanto na classe ChatMessageUI quanto no método ChatRestApi.getLast, é utilizado o tipo de dado List, enquanto na verdade deveria ser um Set ordenado. O JDK não lida bem com isso, então não será possível declarar a ordem dos elementos a nível de interface (manter a ordem ao adicionar e retirar). Assim, é uma prática comum usar List nos casos em que um Set ordenado é necessário (também existe o LinkedHashSet, mas isso não é uma interface).
Uma limitação importante: vamos considerar que longas cadeias de respostas ou retransmissões não existem. Ou seja, elas existem, mas seu comprimento não ultrapassa três mensagens. No frontend, a cadeia de mensagens deve ser transmitida por completo.
Para obter dados de serviços externos, existem APIs como estas:
interface ChatMessageRepository {
função encontrarÚltimo(n: Int): Lista<MensagemChat>
}
classe de dados FileHeadRemote(
val id: ReferenciaArquivo,
val nome: String
)
interface FileRemoteApi {
função obterCabeçaPorId(id: ReferenciaArquivo): FileHeadRemote
função obterCabeçasPorIds(id: Definir<ReferenciaArquivo>): Definir<FileHeadRemote>
função obterCabeçasPorIds(id: Lista<ReferenciaArquivo>): Lista<FileHeadRemote>
função obterCabeçasPorChat(): Lista<FileHeadRemote>
}
classe de dados UserRemote(
val id: ReferenciaUsuario,
val nome: String
)
interface UserRemoteApi {
função obterUsuárioPorId(id: ReferenciaUsuario): UserRemote
função obterUsuáriosPorIds(id: Definir<ReferenciaUsuario>): Definir<UserRemote>
função obterUsuáriosPorIds(id: Lista<ReferenciaUsuario>): Lista<UserRemote>
}
É evidente que os serviços externos originalmente preveem o processamento em lote, tanto nas duas variantes: através de Set (sem manter a ordem dos elementos, com chaves únicas) e através de List (podem haver duplicatas - a ordem é mantida).
Implementações simples
Implementação ingênua
A primeira implementação ingênua do nosso controlador REST parecerá, na maioria das vezes, algo assim:
classe ChatRestController(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.mapa { it.toFrontModel() }
private fun MensagemChat.toFrontModel(): Interface de Mensagem do Chat =
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
author = userRepository.getUserById(author).toFrontReference(),
message = message,
files = files?.let { files ->
fileRepository.getHeadsByIds(files)
.mapa { it.toFrontReference() }
} ?: listOf(),
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Tudo é perfeitamente claro, e isso é um grande ponto positivo.
Usamos processamento em lote e obtemos dados de serviços externos em pacotes. Mas o que acontece com o desempenho?
Para cada mensagem, será feita uma chamada ao UserRemoteApi para obter dados do campo author e uma chamada ao FileRemoteApi para obter todos os arquivos anexados. Parece que é isso. Vamos supor que os campos forwardFrom e replyTo para ChatMessage sejam obtidos de forma que não exijam chamadas extras. Mas transformar isso em ChatMessageUI levará a recursão, ou seja, os contadores de chamadas podem aumentar bastante. Como dissemos antes, vamos supor que não temos muita complexidade e que a cadeia é limitada a três mensagens.
No final, obteremos de duas a seis chamadas a serviços externos para uma única mensagem e uma chamada JPA para todo o pacote de mensagens. O número total de chamadas variará de 2*N+1 a 6*N+1. Quanto isso representa em unidades reais? Suponha que para renderizar a página precisamos de 20 mensagens. Para obtê-las, levará de 4 a 10 segundos. Horrível! Gostaríamos de concluir em 500 ms. E como no frontend sonhamos em fazer um scroll contínuo, os requisitos de desempenho desse endpoint podem ser dobrados.
Acesso direto aos sistemas de cabos transpacíficos. Ping mínimo para a China, Japão, Austrália e costa oeste dos EUA.
- O código é conciso e auto-documentável (um sonho para o suporte).
- O código é simples, portanto as chances de cometer erros são quase inexistentes.
- O processamento em lote não parece algo estranho e se encaixa organicamente na lógica.
- Mudanças na lógica serão feitas facilmente e serão locais.
Ponto negativo:
Desempenho horrível, devido ao fato de que os pacotes são muito pequenos.
Esse approach pode ser frequentemente encontrado em serviços simples ou protótipos. Se a velocidade de implementação de mudanças é importante, provavelmente não vale a pena complicar o sistema. Ao mesmo tempo, para nosso serviço muito simples, o desempenho acaba sendo terrível, então a abrangência de aplicabilidade desse tipo de abordagem é bastante restrita.
Processamento paralelo ingênuo
É possível executar o processamento de todas as mensagens em paralelo — isso permitirá eliminar o aumento linear do tempo em relação ao número de mensagens. Este não é um caminho particularmente bom, pois levará a uma grande carga de pico no serviço externo.
A implementação do processamento paralelo é muito simples:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Usando o processamento paralelo das mensagens, podemos atingir entre 300 a 700 ms em condições ideais, o que é muito melhor do que uma implementação ingênua, mas ainda assim não é rápido o suficiente.
Com essa abordagem, as requisições ao userRepository e fileRepository serão executadas de forma síncrona, o que não é muito eficiente. Para corrigir isso, será necessário alterar bastante a lógica das chamadas. Por exemplo, através de CompletionStage (também conhecido como CompletableFuture):
private fun MensagemChat.toFrontModel(): Interface de Mensagem do Chat =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).toFrontReference()
}.thenCombine(
files?.let {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).mapa { it.toFrontReference() }
}
} ?: CompletableFuture.completedFuture(listOf())
) { author, files ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
author = author,
message = message,
files = files,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}.get()!!
É evidente que um código de mapeamento originalmente simples se tornou menos compreensível. Isso se deve ao fato de que tivemos que separar as chamadas aos serviços externos do local onde utilizamos os resultados. Isso, por si só, não é ruim. Mas a combinação das chamadas não parece muito elegante e se assemelha a uma típica "massa reativa".
Se utilizarmos corrotinas, tudo parecerá mais apresentável:
private fun MensagemChat.toFrontModel(): Interface de Mensagem do Chat =
join(
{ userRepository.getUserById(author).toFrontReference() },
{ files?.let { fileRepository.getHeadsByIds(files)
.mapa { it.toFrontReference() } } ?: listOf() }
).let { (autor, arquivos) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
autor = autor,
message = message,
arquivos = arquivos,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Onde:
função <A, B> join(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(assíncrono { a() }, assíncrono { b() })
}.let {
it[0] as A para it[1] as B
}
Teoricamente, usando esse processamento paralelo, podemos atingir entre 200 a 400 ms, o que já está próximo de nossas expectativas.
Infelizmente, um bom nível de paralelização não é realista, e o preço a pagar é bastante alto: quando poucos usuários estão trabalhando simultaneamente, os serviços enfrentarão uma enxurrada de requisições que, de qualquer maneira, não serão processadas em paralelo, resultando novamente nos nossos tristes 4 s.
Meu resultado ao usar tal serviço é de 1300 a 1700 ms para processar 20 mensagens. Isso é mais rápido do que na primeira implementação, mas ainda assim não resolve o problema.
Aplicação alternativa de requisições paralelasE se os serviços externos não suportarem o processamento em lote? Por exemplo, podemos ocultar a falta de implementação do processamento em lote dentro dos métodos das interfaces:
interface UserRemoteApi {
função obterUsuárioPorId(id: ReferenciaUsuario): UserRemote
função obterUsuáriosPorIds(id: Definir<ReferenciaUsuario>): Definir<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
função obterUsuáriosPorIds(id: Lista<ReferenciaUsuario>): Lista<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Isso faz sentido se houver esperança de que o processamento em lote seja implementado nas próximas versões.
Acesso direto aos sistemas de cabos transpacíficos. Ping mínimo para a China, Japão, Austrália e costa oeste dos EUA.
- Implementação simples do processamento paralelo por mensagens.
- Boa escalabilidade.
Pontos negativos:
- A necessidade de separar a obtenção de dados de seu processamento ao realizar requisições paralelas a diferentes serviços.
- Aumento da carga sobre serviços externos.
Parece que os limites de aplicabilidade são aproximadamente os mesmos que os do método ingênuo. Usar o método de solicitações paralelas faz sentido se você deseja aumentar várias vezes o desempenho do seu serviço às custas da exploração intensa de terceiros. No nosso exemplo, o desempenho aumentou em 2,5 vezes, mas isso é claramente insuficiente.
Cache
É possível implementar cache no espírito do JPA para serviços externos, ou seja, armazenar os objetos recebidos na sessão para não obtê-los novamente (incluindo durante o processamento em lote). Você pode criar esses caches por conta própria, pode usar o Spring com seu @Cacheable, além de sempre poder utilizar um cache pronto, como o EhCache, manualmente.
O problema geral está relacionado ao fato de que caches só são eficazes se houver ocorrências. No nosso caso, as ocorrências por meio do campo author são bastante prováveis (digamos, 50%), enquanto não haverão ocorrências para arquivos. Esse método proporcionará algumas melhorias, mas não mudará radicalmente o desempenho (e precisamos de uma ruptura).
Caches intersessão (longos) requerem lógica de invalidação complexa. De modo geral, quanto mais tarde você chegar ao ponto de resolver problemas de desempenho usando caches intersessão, melhor.
Acesso direto aos sistemas de cabos transpacíficos. Ping mínimo para a China, Japão, Austrália e costa oeste dos EUA.
- Implementação de caching sem alteração no código.
- Aumento de desempenho várias vezes (em alguns casos).
Pontos negativos:
- Possibilidade de diminuição de desempenho em caso de uso inadequado.
- Altas sobrecargas de memória, especialmente com caches longos.
- Invalidação complexa, cujos erros podem levar a problemas difíceis de reproduzir em tempo de execução.
Muito frequentemente, caches são usados apenas para corrigir rapidamente problemas de design. Isso não significa que não devem ser usados. No entanto, deve-se sempre abordá-los com cautela e primeiro avaliar o aumento de desempenho obtido, antes de tomar uma decisão.
No nosso exemplo, os caches proporcionarão um aumento de desempenho em torno de 25%. No entanto, os caches apresentam várias desvantagens, então eu não os utilizaria aqui.
Resultados
Assim, examinamos a implementação ingênua de um serviço que utiliza processamento em lote e várias maneiras simples de acelerar isso.
A maior vantagem de todos esses métodos é a simplicidade, da qual surgem muitas consequências agradáveis.
Um problema comum desses métodos é o baixo desempenho, principalmente relacionado ao tamanho dos pacotes. Portanto, se essas soluções não atenderem às suas necessidades, é aconselhável considerar métodos mais radicais.
Existem duas direções principais nas quais você pode procurar soluções:
- trabalho assíncrono com dados (exige uma mudança de paradigma, portanto, não será abordado neste artigo);
- aumento do tamanho dos pacotes mantendo o processamento síncrono.
O aumento do tamanho dos pacotes permitirá reduzir significativamente a quantidade de chamadas externas, mantendo o código síncrono. Este será o tema da próxima parte do artigo.
Fonte: habr.com
