Executando Jobs em AdvPL

Introdução

Em todos os posts anteriores sobre escalabilidade, desempenho e afins, sempre aparece o “tal” do JOB. No AdvPL, genericamente damos o nome de “Job” para um processamento de uma função AdvPL iniciada em um ambiente sem interface com o usuário — ou seja, sem conexão com o SmartClient). Neste post, vamos ver em detalhes algumas formas que o AdvPL permite a execução de JOBs.

JOBS “ONSTART”

No arquivo de configurações do Servidor de Aplicação Protheus Server, conhecido por “appserver.ini”, podemos definir um ou mais JOBS, que por default são iniciados apenas uma vez, no momento que o serviço do Protheus é iniciado.

Basta criar uma nova seção nomeada no appserver.ini para cada JOB ou programa que deve ser executado em JOB — lembrando de usar um nome intuitivo para o job, e nao usar nenhuma palavra reservada de configuração — , depois criar uma seção chamada [ONSTART], e dentro dela colocar a lista de jobs a serem executados no início do serviço do Protheus Server dentro da configuração JOBS, separados por vírgula.

Exemplo 01

[ONSTART]
JOBS=JOBMSG1

[JOBMSG1]
main=conout
environment=envp12
nparms=1
parm1="Meu Primeiro Job"

No exemplo acima, configuramos um JOB para ser executado apenas uma vez, na subida do serviço do Protheus Server. O JOB foi configurado para chamar a função “Conout” do AdvPL, para emitir uma mensagem no log de console. Também foi configurado para passar uma string de parâmetro para a função conout(), contendo o texto “Meu Primeiro Job”.

A passagem de parâmetros para um JOB é opcional, as configurações obrigatórias são:

  • MAIN = Identifica o nome da função a ser chamada
  • ENVIRONMENT = Identifica o ambiente de execução desta função no Protheus.

Exemplo 02

[ONSTART]
JOBS=JOBMSG2

[JOBMSG2]
main=conout
environment=envp12
instances=3
nparms=2
parm1="Meu Primeiro Job Multiplo"
parm2="-------------------------"

No exemplo acima, apenas acrescentamos a configuração INSTANCES=3, para indicar para o Protheus Server que ele deve subir três processos (ao invés de apenas um) e executar em cada um deles a mesma função conout(), porém agora com dois parâmetros (a string “Meu Primeiro Job Múltiplo”, seguido de uma string de mesmo tamanho com uma sequencia de ‘-‘). O Resultado no log de console do AppServer deve ser algo parecido com:

Meu Primeiro Job Multiplo
-------------------------
Meu Primeiro Job Multiplo
-------------------------
Meu Primeiro Job Multiplo
-------------------------

Passagem de parâmetros ao JOB

Como vimos nos exemplos acima, podemos passar um ou mais parâmetros para os JOBS, identificando a quantidade de parâmetros a ser enviada usando a configuração NPARMS, e depois cada parâmetro como uma string, identificada como PARM1, PARM2, etc… Os parâmetros neste caso sempre serão passados para as funções a serem executadas como “C” Caractere. Caso os conteúdos dos parâmetros sejam informados entre aspas duplas, as aspas serão removidas automaticamente dos parâmetros para a passagem do valor ao AdvPL.

Exemplo 03

Vamos criar uma função simples para ser usada no lugar do conout() dos exemplos acima. Compile no seu repositõrio a função abaixo:

USER FUNCTION MYJOB1( cMsg1, cMsg2 ) 
conout("Thread ["+cValToChar(ThreadID())+"] executando ... ")
conout("Thread ["+cValToChar(ThreadID())+"] cMsg1 = "+cValToChar(cMsg1))
conout("Thread ["+cValToChar(ThreadID())+"] cMsg2 = "+cValToChar(cMsg2)
sleep(1000)
conout("Thread ["+cValToChar(ThreadID())+"] saindo ... ")
return

Agora, usando a configuração do Exemplo 02, troque na configuração main= conout por main=U_MYJOB1, compile o fonte acima, pare e suba o Servidor de Aplicação novamente, e vamos ver o resultado no LOG de console:

Thread [7048] executando ...
Thread [7048] cMsg1 = Meu Primeiro Job Multiplo
Thread [7048] cMsg2 = -------------------------
Thread [16432] executando ...
Thread [16432] cMsg1 = Meu Primeiro Job Multiplo
Thread [16432] cMsg2 = -------------------------
Thread [16508] executando ...
Thread [16508] cMsg1 = Meu Primeiro Job Multiplo
Thread [16508] cMsg2 = -------------------------
Thread [7048] saindo ...
Thread [16432] saindo ...
Thread [16508] saindo ...

Exemplo 04

Agora, vamos mexer um pouco na função MYJOB1, para ela “nao sair” …

USER FUNCTION MYJOB1( cMsg1, cMsg2 ) 
conout("Thread ["+cValToChar(ThreadID())+"] executando ... ")
conout("Thread ["+cValToChar(ThreadID())+"] cMsg1 = "+cValToChar(cMsg1))
conout("Thread ["+cValToChar(ThreadID())+"] cMsg2 = "+cValToChar(cMsg2)]
While !killapp()
  conout("Thread ["+cValToChar(ThreadID())+"] Hora Atual = "+time())
  sleep(1000)
Enddo
conout("Thread ["+cValToChar(ThreadID())+"] saindo ... ")
return

O resultado esperado no log de console será algo parecido com isso:

Thread [2928] executando ...
Thread [2928] cMsg1 = Meu Primeiro Job Multiplo
Thread [2928] cMsg2 = -------------------------
Thread [2928] Hora Atual = 01:00:21
Thread [13224] executando ...
Thread [13224] cMsg1 = Meu Primeiro Job Multiplo
Thread [13224] cMsg2 = -------------------------
Thread [13224] Hora Atual = 01:00:21
Thread [15056] executando ...
Thread [15056] cMsg1 = Meu Primeiro Job Multiplo
Thread [15056] cMsg2 = -------------------------
Thread [15056] Hora Atual = 01:00:21
Thread [2928] Hora Atual = 01:00:22
Thread [13224] Hora Atual = 01:00:22
Thread [15056] Hora Atual = 01:00:22
Thread [2928] Hora Atual = 01:00:23
Thread [15056] Hora Atual = 01:00:23
Thread [13224] Hora Atual = 01:00:23
Thread [2928] Hora Atual = 01:00:24
Thread [15056] Hora Atual = 01:00:24
Thread [13224] Hora Atual = 01:00:24

A função KillApp() somente retornará .T. caso o processo atual seja derrubado pelo Protheus Monitor, ou caso o serviço do Protheus Seja finalizado. No nosso exemplo, cada job fica em LOOP mostrando a hora atual no log de console, dorme por 1 segundo, e recomeça o loop.

Vale lembrar que um JOB é colocado no ar apenas na instância do Protheus Server na qual ele foi configurado no arquivo de inicialização. Colocar um JOB na seção [ONSTART] do Aplication Server configurado para ser, por exemplo, o servidor Master para Balanceamento de Carga, fará o JOB rodar apenas naquela instância do Protheus Server.

Evolução dos Exemplos

Conhecendo um pouco mais das funções de comunicação entre processos do AdvPL, como por exemplo as funções IPCWAITEX e IPCGO, com mais algumas linhas de código podemos criar um mecanismo de processamento em JOB assíncrono, onde cada job pode ficar esperando uma requisição de IPC usando um mesmo identificador nomeado, e receber notificações de processamento de um ou mais programas sendo executados neste mesmo Serviço do Protheus.

Exemplo 05

Vamos mexer mais um pouco no nosso JOB, para ele ser capaz de receber o nome de uma função de usuário para ser executada em modo assíncrono. Criamos uma função “Cliente”, que solicita o processamento de uma função especifica, para contar de 1 a 10. Colocamos três jobs no ar disponíveis para realizar as chamadas, e executamos a aplicação client, solicitando um novo envio de processamento a cada 1 segundo, por quatro vezes, e vamos ver o que acontece.

USER Function MyClient()
While MsgYesNo("Envia um processamento a um JOB ?")
  If IpcGo("MYJOB_IPC","U_MYTASK",1,10)
    MsgInfo("Processamento enviado.")
  Else
    MsgSTop("Nao foi possivel enviar a requisicao. Nao há jobs disponiveis.")
  Endif
Enddo
Return

USER FUNCTION MYJOB1( cMsg1, cMsg2 ) 
Local cFN,p1,p2

conout("Thread ["+cValToChar(ThreadID())+"] iniciando ... ")

While !killapp()
  cFN := NIL 
  p1 := NIL
  p2 := NIL 
  conout("Thread ["+cValToChar(ThreadID())+"] Aguardando um chamado ... ")
  If IpcWaitEx("MYJOB_IPC",5000,@cFN,@p1,@p2)
    conout("Thread ["+cValToChar(ThreadID())+"] Executando " + p1)
    &cFN.(p1,p2)
  Endif
Enddo

conout("Thread ["+cValToChar(ThreadID())+"] saindo ... ")
return


USER Function MYTASK(p1,p2)
Local nX
conout("Thread ["+cValToChar(ThreadID())+"] --- Inicio da tarefa ...")
For nX := p1 TO p2 
  conout("Thread ["+cValToChar(ThreadID())+"] --- Contando "+cValToChar(nX)+" ( "+cValToChar(p1)+" a "+cValToChar(p2)+" )")
  Sleep(1000)
Next
conout("Thread ["+cValToChar(ThreadID())+"] --- Final da tarefa ...")
Return

Log de execução

Thread [9664] iniciando ...
Thread [9664] Aguardando um chamado ...
Thread [7368] iniciando ...
Thread [7368] Aguardando um chamado ...
Thread [11852] iniciando ...
Thread [11852] Aguardando um chamado ...
Thread [9664] Aguardando um chamado ...
Thread [7368] Aguardando um chamado ...
Thread [11852] Aguardando um chamado ...
Thread [9664] Executando U_MYTASK
Thread [9664] --- Inicio da tarefa ...
Thread [9664] --- Contando 1 ( 1 a 10 )
Thread [9664] --- Contando 2 ( 1 a 10 )
Thread [9664] --- Contando 3 ( 1 a 10 )
Thread [7368] Executando U_MYTASK
Thread [7368] --- Inicio da tarefa ...
Thread [7368] --- Contando 1 ( 1 a 10 )
Thread [9664] --- Contando 4 ( 1 a 10 )
Thread [7368] --- Contando 2 ( 1 a 10 )
Thread [9664] --- Contando 5 ( 1 a 10 )
Thread [11852] Aguardando um chamado ...
Thread [7368] --- Contando 3 ( 1 a 10 )
Thread [9664] --- Contando 6 ( 1 a 10 )
Thread [11852] Executando U_MYTASK
Thread [11852] --- Inicio da tarefa ...
Thread [11852] --- Contando 1 ( 1 a 10 )
Thread [7368] --- Contando 4 ( 1 a 10 )
Thread [9664] --- Contando 7 ( 1 a 10 )
Thread [11852] --- Contando 2 ( 1 a 10 )
Thread [7368] --- Contando 5 ( 1 a 10 )
Thread [9664] --- Contando 8 ( 1 a 10 )
Thread [11852] --- Contando 3 ( 1 a 10 )
Thread [7368] --- Contando 6 ( 1 a 10 )
Thread [9664] --- Contando 9 ( 1 a 10 )
Thread [11852] --- Contando 4 ( 1 a 10 )
Thread [7368] --- Contando 7 ( 1 a 10 )
Thread [9664] --- Contando 10 ( 1 a 10 )
Thread [11852] --- Contando 5 ( 1 a 10 )
Thread [7368] --- Contando 8 ( 1 a 10 )
Thread [9664] --- Final da tarefa ...
Thread [9664] Aguardando um chamado ...
Thread [11852] --- Contando 6 ( 1 a 10 )
Thread [7368] --- Contando 9 ( 1 a 10 )
Thread [11852] --- Contando 7 ( 1 a 10 )
Thread [7368] --- Contando 10 ( 1 a 10 )
Thread [11852] --- Contando 8 ( 1 a 10 )
Thread [7368] --- Final da tarefa ...
Thread [7368] Aguardando um chamado ...
Thread [11852] --- Contando 9 ( 1 a 10 )
Thread [11852] --- Contando 10 ( 1 a 10 )
Thread [9664] Aguardando um chamado ...
Thread [11852] --- Final da tarefa ...
Thread [11852] Aguardando um chamado ...
Thread [7368] Aguardando um chamado ...
Thread [9664] Aguardando um chamado ...
Thread [11852] Aguardando um chamado ...
Thread [7368] Aguardando um chamado ...
Thread [9664] Aguardando um chamado ...
Thread [11852] Aguardando um chamado ...

O programa cliente enviou três requisições com sucesso, onde cada uma ocupou um dos processos em espera. Cada processo deve ficar ocupado por 10 segundos, logo se o programa cliente tentou enviar uma quarta requisição em menos de 10 segundos depois de ter enviado a primeira, ela não vaio encontrar nenhum JOB aguardando em IPCWAITEX, e a função IPCGO() retorna .F. pois não conseguiu enviar a chamada para frente.

O resultado é lindo, a função cliente apenas disparou um IPCGO() com o que precisa ser feito, e a função foi executada em um job pré-configurado, sem que o cliente precisasse esperar pelo retorno ou pelo fim do processamento. Isso para um teste ou uma prova de conceito, até funciona. Mas no mundo real, normalmente é necessário saber se aquele processamento foi concluído com sucesso, ou se aconteceu alguma coisa durante o processamento, e o que aconteceu.

Fatiando processos

Mas, por hora vamos focar em um ponto importante: — Você não precisa e nem deve preocupar-se com “quantas instancias” serão colocadas no ar para dividir ou dimensionar seu trabalho.

Sim, é isso mesmo. É natural você pensar imediatamente em registros por processos e número de processos, por exemplo: Se eu tenho 50 mil títulos para serem processados, e vou subir 10 jobs, então eu vou mandar 5 mil títulos para cada JOB … Porém isso traz algumas consequências desagradáveis.

A primeira delas é: Mesmo que a velocidade média do processamento de um título seja igual entre os processos, cada job vai ficar no ar ocupado por muito tempo processando um lote muito grande de informações. Qualquer variação no tempo de processamento pode fazer um job terminar a sua parte muito rápido, enquanto outros jobs podem demorar mais tempo para terminar a sua parte … e o seu processamento somente está COMPLETO quando todos os JOBS terminarem. Por exemplo, imagina que a média de processamento de um título seja entre 1,5 e 1 segundo. Isto significa que, um processamento de 5 mil títulos pode demorar de 41 a 82 minutos.

Para evitar isso, o ideal é definir um lote de títulos de um determinado tamanho, de modo que o processamento de um lote demore de 30 a 120 segundos. Depois, você deve subir uma quantidade de jobs no ambiente que não prejudiquem o desempenho deste servidor de aplicação, e esse numero varia de ambiente para ambiente. Mantendo cada JOB ocupado por um período mais curto de tempo, eles vão passar mais tempo rodando em paralelo.

Considerando o caso anterior, vamos simplificar o exemplo dividindo 10 mil registros em 2 jobs, cada um deles já pegou a sua “faixa” de registros para processar … embora os dois tenham iniciados juntos, um deles terminou a parte dele em 45 minutos, e o outro demorou mais 20 minutos, e o processo “pai” teve que esperar todos os processos filho terminarem para obter um resultado. Criando um lote de processamento de, por exemplo 60 títulos por vez, cada job ficará alocado de 30 segundos a um minuto para processar o seu lote, ficando disponível novamente para pegar o próximo lote. Neste caso, os dois jobs ficariam quase todo o tempo rodando em paralelo, ficando um deles no máximo 30 segundos “sozinho” terminando de processar o ultimo lote.

Controlando a execução

Voltando um pouco antes da parte de “Fatiar” um processo, é importante saber se todas as requisicoes enviadas para processamento foram realmente processadas com sucesso. Existem várias formas de se fazer isso, inclusive durante a execução do processo, ou no final dele.

Por exemplo, sabendo que cada processo dedicado deve pegar um lote de 30 títulos — fixos ou determinados por uma faixa de intervalo — o programa principal (pai) deste processamento pode criar uma tabela registrando todas as requisições que foram distribuídas, e cada programa que finaliza o processamento de uma requisição, atualiza o Flag dela na tabela de controle. Assim, no final do processo, quando todos os jobs tiverem terminado de processar e não houver nada mais pendente, o LOG de operação deve ser verificado para ver se está tudo em ordem

Inclusive, existem erros graves que não são interceptados por rotinas de tratamento e recuperação de erro, que geram apenas um log local. Quando pensamos em múltiplos jobs, temos estes pontos a considerar.

A função STARTJOB

Utilizando a função STARTJOB() do AdvPL, podemos subir uma instância para rodar uma função qualquer em tempo de execução. O novo processo será colocado no ar no mesmo serviço do Protheus em uso pelo processo “pai” que iniciou o JOB. Para subir múltiplas instâncias, basta fazer um FOR .. NEXT, e podemos passar nenhum, um ou vários parâmetros para a função a ser executada. Veja a documentação da função na TDN, nas referências no final do post.

Existe JOB remoto ?

Sim, existe. Quando usamos uma conexão RPC nativa do AdvPL entre serviços do Protheus Server, podemos iniciar um JOB no servidor alvo da conexão RPC. Logo, uma rotina em AdvPL, onde você possa mapear e fazer RPC para os serviços do Protheus que devam fazer parte de um grande processamento, seria capaz de subir jobs remotos via RPC, e através do RPC, fazer uma distribuição de tarefas para os jobs dinamicamente, usando por exemplo um algoritmo round robin — pega a lista de objetos RPC conectados, passa uma tarefa para um job do primeiro servidor, vai para o próximo servidor, se a tarefa anterior foi passada com sucesso pega uma nova, senão tenta passar para o servidor atual, acabou a lista de conexões, volta para a primeira, mantem dentro desse loop até acabarem as tarefas, coloca um SLEEP de um segundo caso ele tenha varrido todo o array de objetos RPC e não foi encontrado nenhum job livre, espera os jobs remotos terminarem as suas tarefas, e finaliza a rotina.

Existem outros tipos de JOB ?

Sim, também configurados no appserver.ini, existem JOBS que são configurados como Pools de Working Threads, com ou sem inicialização automatica pela seção ONSTART. Por exemplo, o JOB TYPE=WEBEX, utilizado para Working Threads de Portais, WEBEX (AdvPL ASP) e WebServices.

Scheduler do ERP

Vale lembrar que o ERP Microsiga possui uma funcionalidade chamada “Scheduler”, que permite a execução programa de tarefas agendadas, com muito mais detalhes e controles. O objetivos dos exemplos deste post são direcionados a mostrar formas mais simples e nativas da linguagem AdvPL e do TOTVS Application Server de executar processos em JOB.

Conclusão

Cada linguagem de programação oferece um nível de flexibilidade para criarmos uma solução, o segredo está em buscar o uso de alternativas simples, e usar as alternativas mais complexas onde elas são necessárias, buscando implementá-las também de forma simples. Eu acredito que esse é o desafio constante do desenvolvedor de software.

Desejo a todos(as) TERABYTES de SUCESSO 😀

Referências

ONSTART 
IPCWAITEX
IPCGO 
Round Robin

Escalabilidade e Performance – Segredos

Introdução

Outo dia li um post muito interessante, onde o autor menciona alguns “segredos” para uma aplicação escalável, com algumas técnicas comuns utilizadas em aplicações WEB ( Vide post original nas referências no final do Post). Resolvi me basear nele para exemplificar o que cada um dos tópicos elencados poderia agregar ao sistema, e levantar algumas questões de aplicabilidade de cada um, dentro do ambiente AdvPL.

“STATELESS”

“Se você quer um sistema ou um serviço escalável, com certeza você quer que todas as requisições para este serviço sejam stateless.
Mas por que? Simplesmente por que caso em um futuro próximo você precise rodar a sua aplicação em um cluster, você não prende um cliente a um nó do cluster, cada requisição pode ir para o nó do cluster que estiver com a menor carga naquele momento, fazendo com que o tempo de resposta aquela requisição seja o menor possível, mantendo o nó do cluster que vai atender a esta requisição ocupado o menor tempo possível.” (Sobre Código: Os 5 segredos para um sistema altamente escalável, por Rodrigo Urubatan)

Esta afirmação casa com os princípios de escalabilidade e performance já mencionados em posts anteriores, mais especificamente sobre uma regra que diz “não estabeleça afinidade”. Basicamente, o “stateless” significa que uma requisição de processamento não deve depender de um estado específico de uma requisição anterior. Isto significa que o agente de processamento da requisição não deve reter nenhuma informação da requisição anterior, o que torna possível distribuir uma requisição de processamento para qualquer agente disponível no cluster.

Isto pode ser aplicado a diversos tipos de processamento, mas normalmente requer que a aplicação seja desenhada para trabalhar desta forma. Por exemplo, hoje quando desenvolvemos uma aplicação AdvPL para ser acessada através da interface do SmartClient, a aplicação trabalha com uma conexão TCP persistente, onde o programa AdvPL responsável pelas instruções de interface, também é responsável por montar o ambiente de execução e executar as aplicações de processamento e acesso a SGDB e meta-dados no mesmo processo.

Por exemplo, uma inclusão de um cliente através de um programa de interface SmartClient no AdvPL, é iniciada ao acionarmos um botão na janela, que executa uma função do código AdvPL pelo Application Server dentro do próprio processo, que acessa o SGDB e executa efetivamente a inserção e demais integrações e gatilhos sistêmicos relacionados (pontos de entrada do ERP), mantendo a interface em “espera” enquanto o processo é executado.

Para esta operação ser executada em um outro nó do cluster, a aplicação precisaria ser desenhada para, no momento de submeter a inclusão de um cliente, a função deveria gerar um request de inclusão de cliente, e despachá-la para um agente de distribuição de processos, que iria alocar um processo em espera no cluster de processamento, para este processo dedicado realizar o acesso ao banco e concluir a operação, liberando a interface para iniciar uma nova inclusão ou outra operação. A dificuldade de lidar com estes eventos é desenvolver um mecanismo resiliente, que permita gerar informações e um “BackLog” do que foi feito, pois procurar um erro de processamento ou comportamento em um ambiente assim, sem rastreabilidade das operações, não será uma tarefa fácil.

Um WebService por natureza é concebido como STATELESS. Existem algumas tecnologias que permitem criar afinidade e persistência em WebServices, porém isto foge ao objetivo principal da natureza das requisições e dos serviços que a implementação original se propõe a fazer. Se uma aplicação em WebServices precisa de afinidade e persistência, o uso de WebServices não parece uma boa escolha. Nem toda a operação pode ser totalmente assíncrona, mas muitas etapas de processo compensam o custo da transformação, revertida em escalabilidade.

“REQUEST RESPONSE TIME” (RRT)

Partindo de um ambiente de serviços distribuídos, onde vários processos podem realizar operações específicas, é fundamental que as requisições importantes ( ou prioritárias) sejam atendidas com o menor tempo de resposta possível. É o mesmo princípio dos caixas de um banco: Quando temos mais clientes do que caixas disponíveis, é formada uma fila, e o primeiro caixa que terminar o atendimento torna-se disponível e chama o primeiro da fila.

Se os caixas forem lentos, quando começar uma fila, as pessoas desta fila vão demorar para serem atendidas. Então, ou você coloca mais caixas, ou você melhora o desempenho dos caixas. Pensando por exemplo na priorização de atendimento a idosos, portadores de necessidades especiais, gestantes e mães com crianças de colo, são criadas duas filas de atendimento, vamos chamá-las de “normal” e “prioritária”. Todos os caixas podem atender a qualquer pessoa da fila, porém ocorre um escalonamento do atendimento.

Quanto mais rápido e eficiente for o atendimento de um caixa, mais clientes por hora ele será capaz de atender. A mesma regra vale para requisições de processamento (SOA/RPC/SOAP/REST). Se você tem um volume diário de X requisições, sujeitas a variações ou picos, e cada caixa aberto consegue atender em média N requisições por minuto, e você tem Y caixas abertos, se todas as X requisições chegarem ao mesmo tempo, as Y primeiras são atendidas imediatamente, e as demais entram na fila. Com todos os caixas atendendo, se ninguém mais entrar na fila, voce vai manter todos os caixas ocupados e trabalhando por M minutos até não ter mais ninguém na fila.

Pegamos o total de requisições (ou clientes) X , dividimos pelo numero de processos disponíveis (ou caixas abertos) Y, e temos uma média de quantas requisições devem ser processadas por cada processo (quantos atendimentos cada caixa deve fazer). Multiplicamos este número pelo tempo médio de processamento da requisição (tempo de atendimento), e teremos uma ideia de quanto tempo será necessário para eliminar a fila. Se chegam 20 requisições, e existem 4 processos disponíveis, cada um deles vai realizar 5 processamentos. Se cada processamento tem um tempo médio de 3 segundos, cada processo vai permanecer ocupado processando por 5×3 = 15 segundos. Se neste meio tempo não chegar mais ninguém na fila, em 15 segundos a fila acaba. Um caixa trabalhando sem parar em um minuto (60 segundos) , consegue atender (sem pausa ou intervalo) 60/3 = 20 requisições em um minuto. Como temos 4 processos, todos juntos conseguem atender 20 x 4 = 80 requisições por minuto.

Se dobrarmos o número de processos (8), conseguimos atender 160 requisições por minuto. Se o tempo de cada processamento (3 s.) diminuir pela metade (1,5 s.), conseguimos atender a 160 requisições por minuto com apenas 4 processos dedicados. Muitas vezes não temos como aumentar mais os processos, por limites de sistema ou esgotamento de recursos, então quanto mais leve e rápido for cada processo, melhor.

Usando AdvPL, existe flexibilidade em se criar pools de processos nativos, e dependendo de sua carga e consumo de memória, colocar múltiplos processos para atender requisições simultâneas. Porém, devem ser observados se os processos aderem aos princípios de paralelismo, senão a aplicação vai jogar memória fora e não vai escalar.

“CACHE”

Lembrando de uma das regras básicas de desempenho de tráfego de informações, buscar uma informação pela rede costuma ser mais lento que ler do disco, e pegar ela da memória é mais rápido do que ler do disco. Mas como não dá pra colocar tudo na memória, e estamos falando de paralelismo em sistemas distribuídos, usar um CACHE das informações mais repetidamente lidas na memória de cada máquina é uma forma bem eficiente de reduzir o RRT. Mas, lembre-se: Não é tudo que voce precisa colocar em um cache … apenas as informações mais acessadas, e com baixa volatilidade. Usar um cache local na memória do servidor, compartilhado pelos demais processos, pode economizar muitas idas e vindas de requisições pela rede.

Um exemplo disso se aplica mais facilmente a uma aplicação WEB dinâmica, onde uma página de promoções acessa o SGBD para mostrar os produtos em promoção. Se as promoções são alteradas diariamente, vale a pena fazer um cache dessa página, e removê-la do cache apenas quando houver alteração das promoções. Para um ERP, um cache interessante seria os meta-dados de algumas tabelas. Se boa parte dos usuários do sistema abre as mesmas tabelas e telas, a leitura destas definições poderiam ser colocadas em um cache na máquina dos slaves, alimentado sob demanda e com uma validade em tempo pré-determinada. O primeiro usuário que abre uma tela de cadastro de produtos ou pedidos alimenta o cache, e todos os demais usuários conectados em serviços naquela máquina pegam as definições do cache local, muito mais rápido, e sem consumir recurso de rede.

Existem meios de se criar caches em AdvPL, usando por exemplo variáveis globais, ou mesmo utilizar um cache de mercado (MemCacheDB, por exemplo), desde que seja escrito um Client em AdvPL que use a api client de Socket’s do Advpl (Classe tSocketClient) para fazer a comunicação. RPC e variáveis globais podem fazer uma bela dupla, mas vão precisar de um certo nível de controle e gerenciamento implementado manualmente, mas isto não inviabiliza esta alternativa.

“REMOTE DATA”

Quando falamos de aplicação Client-Server em Cluster, devemos ter em mente que as informações comuns a determinados processos precisam estar em um lugar onde todos os nós de processamento tenham acesso. Parte da premissa de não criar afinidade. Fica mais fácil citar um exemplo de servidor WEB com Upload de imagens, onde você grava a imagem em uma pasta do servidor: Ao invés de compartilhar esta pasta pelo sistema de arquivos, é possível criar um servidor dedicado a conteúdo estático, e fazer um mecanismo de cache ou réplica nos nós, se ou quando necessário.

A abordagem do AdvPL parte da premissa que cada serviço slave tenha acesso ao RootPath do ambiente, compartilhado pelo sistema operacional do equipamento. Definitivamente um excesso de acessos concorrentes pode prejudicar uma escalabilidade.

“REVERSE PROXY”

O exemplo de proxy reverso também se aplica mais em escalabilidade de ambientes WEB. Ele serve de “Front-End” da sua aplicação, permite a utilização de técnicas de Cache, mascaramento de IP, “esconde” a sua infra-estrutura interna, entre outas funcionalidades. O detalhe importante disso é que um Proxy Reverso, pelo fato de estar “na frente” de tudo, ele passa a ser um SPOF (Single Point of Failure). Se o Proxy Reverso “morrer”, sua aplicação morre junto.

Para aplicações SOA, uma alternativa interessante é usar o já mencionado “controlador” de requisições. Cada nó do cluster pergunta para um controlador de serviço onde está o nó mais próximo disponível, e cada serviço de controle fala com os demais. Assim, você pode deixar serviços ativos em vários nós, e caso um nó saia do ar, o controlador direciona a requisição para outro serviço disponível.

Resumo geral dos conceitos

Cada uma das técnicas ou “segredos” abordados exige que a aplicação seja desenvolvida para aderir a estes paradigmas. Uma aplicação Client-Server monolítica e de conexão persistente não se encaixaria neste modelo sem uma grande refatoração. É mais complicado gerenciar múltiplos recursos em múltiplos equipamentos, é mais complicado desenvolver pensando nestas características, a administração deste tipo de ambiente requer instrumentações, alertas e procedimentos da forma mais automatizada possível. Imagine você ter que conectar e abrir cada uma das máquinas para procurar por um LOG de erro específico, ou tentar descobrir por que ou como uma informação inconsistente foi parar na base de dados. Cada camada precisa estar “cercada” por mecanismos que visem minimizar inconsistências e evitar que elas se propaguem, além de permitir rastrear os caminhos da informação.

No AdvPL são fornecidos recursos como os WebServices (SOAP e REST), RPC nativo entre servidores, IPC entre processos da mesma instância de Application Server. A junção de todos eles pode tornar real e segura uma implementação desta natureza. Agrupando requisições por funcionalidade, e tendo um painel de gerenciamento e monitoramento dos recursos, você consegue medir quanto “custa” cada pedaço do sistema em consumo de recursos, e consegue lidar melhor com estimativas de crescimento, identificação de pontos críticos de melhoria, tornando assim possível a tão sonhada “escalabilidade horizontal” — em grande estilo.

Conclusão

A automatização de processos de Build e testes têm se mostrado muito efetiva em cenários de desenvolvimento e roll-out de produtos. Eu acredito que já esteja mais que na hora de criar mecanismos assistidos de deploy e mecanismos nativos de painéis de gestão de configuração e ambiente. Uma vez estabelecida a confiabilidade nos mecanismos de monitoramento, ações podem ser programadas e realizadas automaticamente, minimizando impactos e reduzindo os riscos de ter uma indisponibilidade total do ambiente computacional.

Criar uma aplicação que desde a sua concepção já considere estes pontos, será tão trabalhosa quando migrar uma aplicação que conta com comportamentos sistêmicos persistentes, mas ambos os esforços devem compensar o potencial de expansão que pode ser adquirido com esta abordagem.

Novamente, agradeço a audiência, e desejo a todos TERABYTES de sucesso 😀

Até o próximo post, pessoal 😉

Referências

Sobre Código: Os 5 segredos para um sistema altamente escalável, por Rodrigo Urubatan. Acessado em 06/12/2015 em <http://sobrecodigo.com/os-4-segredos-para-um-sistema-altamente-escalavel/>.

Stateless protocol. (2015, August 20). In Wikipedia, The Free Encyclopedia. Retrieved 01:33, October 25, 2015, from https://en.wikipedia.org/w/index.php?title=Stateless_protocol&oldid=677029705

Acelerando o AdvPL – Lendo arquivos TXT (ERRATA)

Pessoal,

No post “Acelerando o AdvPL – Lendo arquivos TXT”, foi corrigido um erro que causava um mau comportamento da rotina,  fazendo a leitura de linhas “inconsistentes”. Na chamada da função RAT(), o primeiro parâmetro deve ser a string a ser procurada, e o segundo parâmetro deve ser a string onde a primeira deve ser procurada. A passagem de parâmetros estava ao contrário, fazendo com que a quebra de linha no final de um bloco lido fosse identificada erroneamente, fazendo a rotina retornar linhas com quebras inexistentes. O código-fonte do post original já foi corrigido, segue abaixo o detalhe da correção.

Antes da correção (fonte incorreto)

// Determina a ultima quebra
nRPos := Rat(cBuffer,::cFSep)

Após a correção, o código deve ficar assim:

// Determina a ultima quebra
nRPos := Rat(::cFSep,cBuffer)

Estava trabalhando em um próximo post sobre escalabilidade e performance, e aproveitei a classe para ilustrar um processamento. Quando executei a rotina, percebi que as quebras de linha estavam inconsistentes, pois foram retornadas mais linhas do que eu havia inserido.

Escalabilidade e Performance – Parelelismo – Parte 01

Introdução

Em posts anteriores sobre escalabilidade e desempenho, foram frisados vários pontos e técnicas que devem ser levadas em conta no momento de desenvolver uma aplicação partindo das premissas de escalabilidade horizontal e desempenho. Agora vamos trazer isso para um contexto real dentro do AdvPL, explorando uma análise de um cenário muito comum: Processamento de grandes quantidades de dados.

Cenário proposto

Imagine um cenário onde você precisa realizar um processamento de um arquivo TXT (ou CSV), que pode ter muitos MB … O ponto importante é que você pode receber vários arquivos em um curto espaço de tempo, e estes arquivos precisam ser processados e alimentar tabelas do sistema da forma mais rápida possível. Nesta abordagem, vamos abordar a análise de caso e os pontos importantes a considerar.

Fase 01 – Determinar origem dos arquivos

A primeira coisa a pensar é onde estarão estes arquivos. Para eles serem processados mais rapidamente, eles precisam estar pelo menos em uma pasta no RootPath do ambiente. Se estes arquivos vêm de fora do sistema, e você tem que pegá-los em algum lugar fora da sua rede, a melhor alternativa de tráfego é um FTP. O FTP é um protocolo criado especificamente para transferência de arquivos. Nada impede de você usar um webservice para transferir o arquivo em pedaços, mas isto vai trazer um custo (overhead) do HTTP e do XML-SOAP. Se os arquivos estão na própria rede interna, eles podem ser copiados via sistema de arquivos do sistema operacional mesmo, com compartilhamento de rede, numa pasta dentro do RootPath, mas isto vai ser tão rápido quanto FTP, a menos que você utilize um comando mais robusto de cópia, como o ROBOCOPY.

Fase 02 – Determinar pontos de paralelismo

Os arquivos já estão na pasta. Agora, vamos verificar se é possível aplicar algum tipo de paralelismo neste processo. Se os arquivos precisam impreterivelmente obedecer a ordem de chegada, onde cada arquivo precisa ser processado por inteiro antes do próximo arquivo iniciar o processo, uma parte boa do paralelismo já foi perdida … Se os arquivos não tem ordem de processamento, isto é, cada um pode ser processado aleatoriamente, você poderia usar múltiplos JOBS , onde cada um processaria um arquivo inteiro por vez.

Mas nem tudo está perdido … Se dentro de um grande arquivo, as linhas não tiverem ordem de processamento, isto é, cada linha é uma unidade independente de informação, e você não precisa necessariamente ter terminado de processar a linha 999 para processar a linha 1000, … ainda temos uma ótima possibilidade de paralelismo. Vamos assumir que este cenário é possível no nosso exemplo.

Certo, então para aproveitar o máximo possível de paralelismo, eu posso quebrar o arquivo em vários pedaços, e colocar um JOB para processar cada pedaço do arquivo. A pergunta a ser feita agora é: O processamento de uma determinada linha vai exigir algum recurso exclusivo durante o processamento, que também pode ser ou será efetivamente necessário para processar outra linha?

Por exemplo, de cada linha a ser processada do arquivo precisar atualizar um mesmo registro da base de dados, caso duas linhas distintas, sendo processadas pelos jobs precisem fazer um lock de alteração de um mesmo registro de uma tabela no SGBD, fatalmente um dos processos ficará esperando o outro terminar a transação e soltar o lock para conseguir fazer o seu trabalho. Isto implica em uma serialização do processamento neste ponto.

Se as chances disso ocorrer são pequenas durante o processamento, a maioria dos processos somente vai sofrer uma contenção ou serialização em pontos isolados, ainda temos muito a ganhar com processos em paralelo. Agora, se grandes blocos de registros correm o risco de colidir, mesmo assim nem tudo está perdido. No momento de quebrar o arquivo em pedaços, cada pedaço deve agrupar o máximo de linhas que utilizem o mesmo registro, para que um pedaço do arquivo evite concorrer com os demais.

Fase 03 – Quebrando o arquivo

Agora, vamos “quebrar o arquivo”. Vamos pensar bem nesta etapa, afinal existem muitas formas de fazer isso. Precisamos encontrar aquela que melhor se adequa a nossa necessidade, consumindo um mínimo de recursos possível. Se o consumo de um recurso for inevitável, precisamos encontrar a forma mais eficiente de fazê-lo.

Normalmente subimos Jobs ou Threads de processamento na mesma instância do Application Server onde estamos rodando o nosso programa. Porém, subir 2, 3 ou mais threads no mesmo Application Server, quando temos uma máquina com 8 cores HT — como um servidor Intel com processador DUAL-Quadricore HT, é mais interessante subir alguns jobs de processamento em mais de um Application Server. Isto pode exigir alguns controles adicionais, mas os resultados costumam ser muito interessantes.

O lugar onde subir estes Jobs também é importante. Na máquina onde está a unidade de disco com o ambiente do ERP, a leitura de arquivos é feita direto pelo FileSystem do Sistema Operacional, é muito mais rápido do que ler pela rede. Porém, normalmente nesta máquina utilizamos um c-Tree Server, para compartilhar os meta-dados (Dicionários, SXS) do ERP com todos os demais serviços. Colocar muito processamento nesta máquina pode usar muitos recursos dela, e prejudicar os demais processos.

A alternativa mais flexível que evita este risco, é poder subir os Jobs em qualquer um dos serviços de qualquer uma das máquinas disponíveis no parque de máquinas que rodam os Application Server’s. Para isso, fatalmente teremos que usar a rede. Porém, se usarmos ela com sabedoria, podemos minimizar impactos. Vamos partir para esta alternativa.

Fase 04 – Multiplos processos

Existem várias formas de colocarmos JOBS dedicados para realizar um trabalho em vários serviços. Podemos fazer a nossa aplicação iniciar os Jobs de processamento apenas quando necessário, usando por exemplo o RPC do AdvPL, onde no programa principal enumeramos os IPs e Portas dos Application Server’s que serão endereçados para esta atividade, e através de RPC subimos um ou mais Jobs em cada um, usando por exemplo a função StartJob(), ou podemos deixar em cada um dos serviços de processamento um ou mais jobs no ar, inicializados na subida de cada Application Server usando a seção [ONSTART] do appserver.ini, e depois fazendo RPC para cada serviço e distribuir os trabalhos usando IPC (Internal Procedure Call) do AdvPL.

Subir um pedaço grande inteiro do arquivo na memória inicialmente pode parecer uma boa alternativa, porém lembre-se que em se tratando de processos paralelos, várias juntas podem consumir muita memória, E , se muita memória é ocupada e endereçada, os processos de lidar com esta memória podem ficar mais lentos do que lidar com pedaços menores. Nas minhas experiências, buffers de 8 KB ou 16K dão conta do recado, fazendo um acesso eficiente em disco, e gerando um pedaço de dados que não vai “doer” na memória dos processos.

A parte importante aqui é: Como quebrar um arquivo grande de forma eficiente, e distribuir seus pedaços para processamento? Uma primeira idéia que me veio na cabeça foi fazer com que o programa principal de processamento abrisse a tabela e fizesse a leitura de várias linhas. Após acumular na memória um bloco de linhas, com o mínimo de processamento possível (por exemplo colocando as linhas dentro de um array), este bloco de linhas pode ser enviado por RPC para um dos slaves, e disparar um IpcGo() para uma das threads disponíveis no slave onde o RPC está conectado.

Desta forma, ao invés de mandar linha por linha em cada requisição, onde em cada uma você vai ter um pacote de rede muito pequeno, ao agrupar um número de linhas que chegasse perto de 64 KB de dados, enviar este bloco de uma vez, em um único evento de envio de rede por RPC, para o Slave “da vez”, ele receberia este pacote de uma vez só, isso aproveitaria bem melhor a banda de rede, e o processo alocado naquele Application Server que recebeu este pacote estaria dedicado a processar todas as linhas recebidas naquela solicitação.

Fase 05 – Sincronização de Processos

Parece lindo, mas ainda têm 2 problemas: Partindo de um ambiente com 4 serviços de Protheus, com 2 threads dedicadas em cada um, você teria um total de 8 threads espalhadas. Fazendo um Round-robin entre os serviços, a primeira requisição iria para a T1S1 (primeira thread do serviço 1), a próxima para T1S2, depois T1S3, T1S4, T2S1, T2S2, T2S3, T2S4, e agora as 8 threads estão ocupadas processando cada uma 64 KB de linhas.

Quando você for enviar a requisição 09, ela vai novamente para o serviço 1 … se nenhuma das threads do serviço 1 terminou de processar o pacote anterior, você gastou 64 KB da banda de rede trafegando um pacotão de coisas, para um servidor ocupado … Então você tenta enviar para o servidor 2, gasta mais banda de rede, e… nenhuma thread livre …. Mesmo que você crie uma mensagem de “consulta”, onde você não passasse os dados, apenas perguntasse se tem threads livres naquele servidor, você ficaria metralhando a rede com pacotes pequenos em todos os serviços mapeados para o processo, até que um deles iria responder “estou livre”, para você então mandar para ele mais um pacote de linhas.

Este pequeno percalço pode ser resolvido com um mecanismo chamado de “CallBack”. Nada impede que o seu Job possa fazer um RPC de volta para você, para avisar que um determinado pedaço foi processado. Com isso você pode usar o RPC com IPC em “mão dupla”, com dois pares de conexões. Quando o seu programa principal enviar as 8 requisições, ele entra em um loop de espera por uma mensagem de IPC. Cada JOB alocado precisa receber no momento da alocação um IP e porta e ambiente para conectar-se de volta, para notificar os estados de processamento. Quando um JOB terminar de processar um pacote, ele manda via RPC um sinal de IPC para avisar que ele terminou, informando “quem ele é” na mensagem. Como o seu processo principal vai estar esperando receber uma notificação via IPC, assim que ela chegar, o seu programa principal pega o próximo pacote de linhas e manda para aquela thread via RPC+IPC, pois assim que esta thread enviar a notificação de retorno, ela vai entrar em modo de espera de IPC para uma nova solicitação de processamento.

Este sincronismo também pode ser de mão única … ao invés do seu Job principal fazer um “push” das requisições de processamento, ele pode acionar uma vez os jobs dedicados para serem alocados para este processo, onde o job dedicado imediatamente faz o callback, e passa a “pedir” requisições de processamento, e notificar o programa principal pelo mesmo canal a cada pacote processado.

Existem outras formas de sincronismo, como por exemplo usar o banco de dados. O programa principal cria uma tabela para fins temporários no SGDB, e alimenta esta tabela com os pacotes a serem processados. Porém, se este mecanismo for utilizado, você acaba consumindo tempo e recursos para popular uma tabela grande no SGDB, e cada processo faz uma busca indexada na tabela por um pacote que ainda não foi processado. Ao conseguir locar o registro, o programa de processamento deve mudar o status para “em processamento” e manter o lock até o final do processo. No caso, o programa principal vai alimentando a tabela, enquanto os jobs dedicados vão pegando os registros não processados e fazendo suas tarefas. Quando o programa principal terminar de popular a tabela, ele passa a verificar quais registros ainda estão em processamento, aguardando todos ficarem prontos, e verificando se os jobs de processamento que pegaram os registros ainda estão no ar. NO final das contas, acaba sendo um mecanismo tão complexo quanto o sincronismo “online”, ainda com a desvantagem de colocar o SGDB no meio do caminho.

Fase 06 – Tratamentos de erro

Agora o bicho pega … Imaginar que tudo vai funcionar como um relógio é o mundo lindo … mas e se um job cair, e se a resposta não vir, e se todos os envios falharem, como é que o programa principal fica sabendo se aconteceu algo horrível ? Essa parte do controle é realmente um “parto” de ser feita … existem vários caminhos, mas normalmente os mais fáceis são os mais sujeitos a erros do tipo “falso-positivo” ou “falso-negativo”. Uma das formas mais interessantes de controle é ter um mecanismo confiável para saber se um determinado JOB que recebeu uma requisição ainda está no ar, e criar no programa principal um mecanismo de registro de envios, para dar baixa de cada pacote enviado conforme os eventos de retorno são recebidos, onde deve constar neste mecanismo o momento de envio, para que se um pacote começar a demorar muito, você possa consultar se o job que recebeu aquele pacote ainda está no ar — apenas está demorando um pouco mais — ou se ele saiu do ar — normalmente por erro.

Com estes processos sincronizados e as métricas definidas, o programa principal que inicia o processamento pode, após garantir que um pacote de linhas foi enviado e o job de destino “caiu” sem terminar o processo, você pode direcionar este pacote para outra thread em outro serviço, evitando assim ter que reiniciar o trabalho.

Vale lembrar que cada processo deve ter um transacionamento, que deve ser mantido aberto pelo menor tempo possível, e que os programas de processamento não vão ter “interface”, não será possível (não de maneira simples) perguntar pro operador do sistema o que fazer ou dar uma opção de múltipla escolha para um determinado tratamento dentro de uma linha em um job. Normalmente você trata no programa os cenários possíveis, e qualquer coisa que não estiver de acordo, você pode rejeitar o processamento e gerar um log do que foi recusado, para que sejam tomadas as providências quanto aquelas informações quando o processo terminar, e você puder rodar um reprocessamento apenas do que faltou, após arrumar os dados ou a condição não tratada.

Outras alternativas

Se, ao invés de usar Jobs dedicados, você subir vários jobs de acordo com a quantidade de pedaços da sua tabela, você pode subir um número de Jobs que pode gerar um colapso nos recursos do sistema, como CPU, rede, memória, disco, SGDB …. É mais saudável trabalhar com um número limitado de processos, e medir com testes se a quantidade pode ser aumentada sem comprometer a disponibilidade do resto do ambiente. Este processo é empírico, começa com um job em cada serviço, roda um processamento inteiro, verifica se algum recurso está sendo consumido excessivamente, aumenta alguns jobs, roda de novo …

Cuidados Especiais

Se você resolver subir os jobs sob demanda, usando um fator limitante, de qualquer modo você precisa de um mecanismo para saber se os jobs terminaram o que tinham que fazer, para você poder subir novos Jobs ou enviar requisições de processamento para um Job que já está livre.

É importante lembrar que, por mais abundantes que sejam os recursos, uma carga de processamento mal dimensionada pode derrubar o ambiente, gerando uma indisponibilidade geral de outros serviços. Um programa errado que começa a comer memória com farinha pode esgotar a memoria física de um equipamento, principalmente usando uma Build 64 Bits do Protheus, fazendo a máquina inteira entrar em “Swap” e paginação de memória em disco … eu já vi equipamentos literalmente entrarem em Negação de Serviço (DoS), onde não era possível sequer abrir um Terminal Services pra tentar parar os serviços.

Conclusão

Eu sei, neste post eu só “abrir o apetite”, e abri caminho para muitas reflexões importantes. No próximo post, vamos abordar cada uma destas etapas acompanhada de um exemplo prático 😉

Até o próximo post, pessoal ! Desejo a todos TERABYTES de sucesso 😀

Referências

Escalabilidade e Performance – Stored Procedures

Introdução

Em um tópico anterior sobre “Escalabilidade e performance – Técnicas”, um dos tópicos falava sobre Stored Procedures, inclusive sugerindo que seu uso deveria ser minimizado. Vamos entrar neste tema com um pouco mais de profundidade neste tópico. Vamos começar com o clone do tópico abordado, e esmiuçar ele dentro do contexto do AdvPL e Protheus.

Minimize o uso de Stored Procedures

Este é um ponto aberto a discussão, depende muito de cada caso. Não é uma regra geral, existem pontos em um sistema onde uma stored procedure realmente faz diferença, mas seu uso excessivo ou como regra geral para tudo impõe outros custos e consequências. O Princípio 1 diria: “use apenas stored procedures”. No entanto, esta decisão pode causar grandes problemas para o Princípio 2 devido à escalabilidade. As Stored procedures têm a vantagem de ser pré-compiladas, e não há nada mais perto de um dado no Banco de Dados.

Porém Bancos de Dados transacionais são especializados em quatro funções: Sort, Merge, gerência de Locks e Log. A gerência de lock é uma das tarefas mais críticas para a implementação de algoritmos distribuídos, e este é o real motivo de existirem poucos Bancos de Dados que possam implementar a escalabilidade horizontal. Se as máquinas de Banco de Dados têm dificuldade de escalar horizontalmente, ela é um recurso escasso e precioso. Temos então que otimizar seu uso para não consumir excessivamente seus recursos a ponto de onerar os demais processos do ambiente. Isto acaba adiando a necessidade de escalar horizontalmente o SGBD.

Abordando a questão de desempenho

Se o algoritmo para processamento de um grande grupo de informações pode ser escrito dentro de uma Stored Procedure no próprio Banco de Dados, esta alternativa tende fortemente a ser a mais performática. Num cenário onde o algoritmo é escrito usando um programa sendo executado dentro do servidor de aplicação da linguagem, cada processamento que dependa da leitura de grupos de dados e tenha como resultado a geração de novos dados vai ser onerado pelo tempo de rede de tráfego destes dados, na ida e na volta. Logo, com uma base de dados modelada adequadamente, e uma stored procedure bem construída, ela naturalmente será mais rápida do que um processamento que precisa trafegar os dados pra fora do SGDB e depois receba novos dados de fora.

Porém, este recurso não deve ser usado como solução mágica para tudo. Afinal, o SGDB vai processar uma Stored Procedure mais rápido, pois ele não vai esperar um processamento ser realizado “fora dele”, porém o SGDB vai arcar com o custo de ler, processar e gravar a nova informação gerada. Se isto for feito sem critério, você pode mais facilmente esgotar os recursos computacionais do Banco de Dados, ao ponto da execução concorrente de Stored Procedures afetar o desempenho das demais requisições da aplicação.

Outras técnicas pra não esgotar o SGDB

Existem alternativas de adiar um upgrade no SGDB, inclusive em alguns casos as alternativas são a solução para você não precisar comprar um computador da “Nasa” …risos… Normalmente estas alternativas envolvem algum tipo de alteração na aplicação que consome o SGDB.

Réplicas de leitura

Alguns SGDBs permitem criar nativamente réplicas da base de dados acessadas apenas para consulta, onde as cópias de leitura são sincronizadas em requisições assíncronas. Existem muitas partes da aplicação que podem fazer uma leitura “suja”. Neste caso, a aplicação pode ler os dados de uma base sincronizada para leitura, e os processos que precisam de leitura limpa são executados apenas na instância principal. Para isso a aplicação precisaria saber qual e o banco “quente” e qual é o espelho, para fazer as coisas nos lugares certos.

Caches

Outra alternativa é a utilização de caches especialistas, implementados na própria aplicação. Utilizando por exemplo uma instância de um “MemCacheDB” em cada servidor, cada aplicação que pode reaproveitar a leitura de um dado com baixo índice de volatilidade (dados pouco atualizados ou atualizados em momentos específicos), poderiam primeiro consultar o cache, e somente se o cache não têm a informação desejada, a aplicação acessa o banco e popula o cache, definindo uma data de validade. Neste caso, o mais legal a fazer é definir um tempo de validade do cache (Expiration Time). E, paralelo a isso, para informações de baixa volatilidade, a rotina que fizer update desta informação pode eliminar ela do cache no momento que um update for realizado, ou melhor ainda, pode ver se ela se encontra no cache, e em caso afirmativo, ela mesma poderia atualizar o cache 😉

Sequenciamento de operações

Operações de inserção ou atualização de dados que não precisam ser refletidas em real-time no SGDB podem ser enfileiradas em pilhas de requisições, e processadas por um processo dedicado. O enfileiramento de requisições não essenciais em tempo real limita o consumo de recursos para uma determinada atividade. Caso a pilha se torne muito grande, ou um determinado processo dependa do esvaziamento total da pilha, podem ser colocados mais processos para ajudar a desempilhar, consumindo mais recursos apenas quando estritamente necessário.

Escalabilidade Vertical

Devido a esta questão de dificuldade de escalabilidade de bancos relacionais horizontalmente, normalmente recorremos a escalabilidade vertical. Escalamos horizontalmente as máquinas de processamento, colocando mais máquinas menores no cluster e balanceando carga e conexões, e quando a máquina de banco começa a “sentar”, coloca-se uma máquina maior só para o SGDB, com vários processadores, discos, memória e placas de rede. Mas tudo tem um limite, e quando ele for atingido, a sua máquina de Banco de Dados pode ficar mais cara que o seu parque de servidores de processamento.

Dificuldade de Implementação

Usar caches e réplicas e pilhas não é uma tarefa simples, fatores como a própria modelagem da base de dados podem interferir negativamente em algumas destas abordagens. Não se pode colocar tudo em cache, senão não vai ter memória que aguente. O cache é aconselhável para blocos de informações repetidas constantemente requisitadas, e de baixa volatilidade. Também não é necessário criar pilhas para tudo que é requisição, apenas aquelas que não são essenciais em tempo real, e que podem ter um delay em sua efetivação.

Stored Procedures no AdvPL

O ERP Microsiga disponibiliza um pacote de Stored Proecures, aplicadas no SGDB em uso por um programa do módulo “Configurador” (SIGACFG). As procedures foram desenvolvidas para funcionalidades específicas dentro de cada módulo, normalmente aquelas que lidam com grandes volumes de dados, e foi possível criar um algoritmo que realize o processamento dentro do SGDB, trafegando menos dados “pra fora” do Banco de Dados. Normalmente um pacote de procedures é “casado” com a versão dos fontes do Repositório, pois uma alteração na aplicação pode envolver uma alteração na procedure. Os ganhos de performance são nítidos em determinados processamentos, justamente por eliminar uma boa parte do tráfego de informações para fora do SGDB durante os processos.

Conclusão

Dado o SGDB como um recurso “caro e precioso”, como mencionado anteriormente, a utilização de recursos adicionais como réplicas e caches, ajuda a dar mais “fôlego” pro SGDB, você consegue aumentar o seu parque de máquinas e volume de dados processados sem ter que investir proporcionalmente na escalabilidade do SGDB. E em tempos de “cloudificação” , SaaS e IaaS, quando mais conseguimos aproveitar o poder computacional que temos em mãos, melhor !

Desejo novamente a todos TERABYTES de Sucesso 😀

Até o próximo post, pessoal 😉

Referências

“Escalabilidade e performance – Técnicas”

Balanceamento de Carga no Protheus

Introdução

No ano passado, ajudei um colega que estava concluindo um mestrado, cuja tese envolvia diretamente a eficiência de mecanismos de balanceamento de carga e suas abordagens. E, uma vez absorvido algum conhecimento a mais a respeito, acho que podemos dar um mergulho no assunto, e aproveitar para conhecer mais de perto o balanceamento de conexões nativo Application Server para conexões do SmartClient.

Balanceamento de Carga

Também conhecido por “Load balancing”, é um mecanismo que distribui requisições de processamento para múltiplos recursos computacionais, como computadores, “Cluster” de computadores, links de rede, unidades de processamento ou unidades de disco. O objetivo é otimizar o uso de recursos, maximizando disponibilidade e resultados, minimizando tempos de resposta, e evitar sobrecarga de uso de recursos. A utilização de múltiplos componentes com balanceamento de carga ao invés de um único componente deve aumentar a escalabilidade e disponibilidade através da redundância. Normalmente este recurso envolve software e/ou hardware específicos.

Entre as técnicas mais comuns, podemos citar o Round-Robin de DNS (onde uma URL de um WebSite retorna um IP diferente para cada consulta ao DNS, direcionando as requisições para outros servidores), algoritmos de distribuição (normalmente round-robin ou escolha randômica) são usados para decidir qual servidor irá processar uma chamada qualquer. Algoritmos mais sofisticados de balanceamento podem usar como fatores determinantes informações como tempo médio de resposta, estado do serviço, tráfego atual, etc.). Existe também um outro recurso, chamado “Proxy reverso” (Reverse Proxy), que além de outras funcionalidades, também pode ser utilizado para distribuição de requisições e balanceamento.

Pontos comuns

Cada mecanismo atua sob princípios diferentes, e visando suprir as necessidades dos ambientes onde estão inseridos, mas de uma forma geral, todos aderem aos mesmos princípios de desempenho, escalabilidade e resiliência. Como eu já disse, dentre os vários mecanismos disponíveis, não existe um “melhor de todos”, existem aqueles que melhor atendem as suas necessidades. Um sistema complexo pode usar vários níveis de balanceamento usando abordagens diferentes, para partes diferentes do sistema.

Balanceamento de carga no Protheus

O Protheus Server (ou Application Server) do AdvPL possui um mecanismo de balanceamento de carga nativo para as conexões recebidas das aplicações Smartclient. Basicamente, você provisiona dois ou mais serviços do Application Server, com as mesmas configurações de ambiente, distribuídos em quaisquer máquinas, e configura um novo serviço, chamado de “Master” ou “Balance”, com as configurações de balanceamento de carga das conexões especificados na seção [servernetwork] do arquivo de configuração do servidor (appserver.ini).

Quando iniciamos uma aplicação AdvPL a partir de um SmartClient, normalmente o programa executado é responsável pela montagem das telas de interface e pela execução do processamento. Por exemplo, a inclusão de um pedido de venda pelo SmartClient, quando você finaliza a operação e confirma a inclusão, a função de inclusão de pedido é executada pelo mesmo processo de interface, mantendo a interface em stand-by até a conclusão do processo e o programa devolver uma mensagem para a interface e permitir você prosseguir com a operação.

Neste modelo, como o processamento é realizado pelo mesmo processo que atende a conexão, toda a “carga” do processo está atrelada a esta conexão, e a aplicação foi projetada sob o conceito de persistência de conexão. Logo, em se tratando das conexões de SmartClient, ao balancear uma conexão, automaticamente estamos balanceando a “carga”.

Requisitos e comportamentos

Ao configurar no “Master” todos os serviços “Slave” de balanceamento, devemos enumerar host/ip e porta de conexão TCP de cada serviço. Este IP e porta deve ser visível na rede usada pelos SmartClients para estabelecer a conexão. Se houver um firewall entre os smartclients e os application servers, os IPs e portas dos “Slave’s” também precisam ser “abertos”, não basta abrir o IP e porta do “Master”.

O serviço de balanceamento não atua como um “Proxy Reverso”, as conexões estabelecidas com ele são indiretamente redirecionadas ao “Slave” escolhido no momento que a conexão foi estabelecida. Se ele atuasse como um proxy reverso, ele se tornaria um gargalo de comunicação, e caso ele fosse derrubado, todas as conexões atualmente em uso de todo o ambiente cairíam junto.

Quando o Balance recebe a conexão de um SmartClient, ele decide na hora qual dos serviços cadastrados para balanceamento é o mais adequado a receber esta conexão, e retorna ao SmartClient uma instrução de reconexão, indicando ao SmartClient qual é o Serviço que ele deve se conectar. Logo, o Smartclient conecta com o “Master”, recebe a informação de qual o serviço adequado para conextar-se, desconecta do “Master”, e conecta-se diretamente com este serviço.

Toda a regra e controle de distribuição é realizado pelo “Master”. Como ele já possui pré-configurados os serviços que podem receber conexões, ele próprio mantém com cada serviço “Slave” uma conexão dedicada de monitoramento, através da qual ele “sabe” quantos processos estão em execução em cada um dos serviços, e se cada serviço está apto a receber novas conexões. Como cada conexão pode exercer uma carga variável durante seu uso, a uma métrica utilizada é direcionar a nova conexão ao serviço que têm o menor número de processos em execução no momento.

Cada serviço do Protheus, após a Build 7.00.090818, possui um processo interno dedicado ao monitoramento de memória de sua instância de serviço. Caso um limite de ocupação de memória da instância seja atingido, este serviço torna-se automaticamente bloqueado para aceitar novas conexões de SmartClient e subir novos Jobs. O Serviço “Master” se mantém atualizado sobre este status, e não direciona novas conexões para este “Slave” enquanto seu status for “bloqueado”. Veja as referências no final do artigo para as documentações da TDN que abordam em maior profundidade estes assuntos.

Não seria melhor usar outra regra ?

Muitas pessoas já me perguntaram isso, e a resposta é “neste modelo de processamento, não têm opção melhor”. Como a carga e consumo de memória e CPU depende da rotina que será chamada, e a rotina está atrelada a conexão, qualquer cenário de distribuição está sujeito a desequilíbrios, afinal as rotinas possuem consumo de memória, CPU e rede variáveis durante a sua execução, e mesmo se fosse possível transferir um processo para outro serviço “a quente”, se este processo também apresenta a mesma variabilidade de consumo de recursos, a transferência da conexão seria freqüente, tornando-se ineficiente e pesada.

Assumindo que cada conexão persistente possui características variáveis de consumo, o mais elegante é dividir pelo número de processos por serviço. Mesmo havendo um eventual desequilíbrio no consumo de recursos, uma vez atingida uma quantidade de memória acima do normal em um serviço, ele automaticamente “bloqueia-se” para não aceitar mais conexões. Quando ao consumo de CPU, quando sabe-se que determinadas rotinas vão demandar muito tempo e consumo de recursos, pode-se montar um ambiente de balanceamento secundário, com outros “Slave’s” dedicados, onde os usuários de rotinas mais pesadas pode alocar outros serviços, e alguns deles podem ser colocados em scheduler para serem executados após o expediente.

Configuração de Balanceamento no AdvPL

Basicamente, você deve criar uma nova seção no appserver.ini para cada “Slave” que você quer relacionar no balanceamento. Recomenda-se criar um identificador cujo nome esteja relacionado de alguma forma com a máquina servidora e o número do serviço utilizado. Vamos dar um exemplo de balanceamento de serviços em apenas uma máquina, subindo o servidor “Master” na porta 6000, e quatro outros serviços “Slave” nas portas seguintes ( 6001 a 6004 )

[servernetwork]
servers=SL_1,SL_2,SL_3,SL_4
[SL_1]
Server=172.16.10.201
Port=6001
Connections=20
[SL_2]
Server=172.16.10.201
Port=6002
Connections=20
[SL_3]
Server=172.16.10.201
Port=6003
Connections=20
[SL_4]
Server=172.16.10.201
Port=6004
Connections=20

A configuração acima deve ser inserida no appserver.ini do serviço eleito como “Master”, que neste exemplo está na mesma máquina que os “Slave’s””, mas na porta 6000. Quando o serviço é iniciado, ele verifica se existe a seção [servernetwork], e verifica quais são os serviços enumerados para balanceamento. O “Master” sobe um processo interno dedicado para monitorar cada um dos “Slave’s”, e saber em tempo real quantos processos estão sendo executados em cada um, e se eles não estão bloqueados para aceitar novas conexões. Caso um processo não consiga encontrar um “Slave”, ele fica tentando estabelecer uma conexão, e enquanto ele não sabe se o “Slave” está no ar ou não, ele não redireciona nenhuma conexão para aquele “Slave”.

Cada serviço que nós chamamos de “Slave”, não precisa de nenhuma chave especial, ele precisa apenas refletir as mesmas configurações de ambiente entre os servidores, e o mesmo ambiente tem que apontar para o mesmo RootPath, para o mesmo License Server, e todos os ambientes devem estar usando a mesma cópia do repositório.

Como os IPs e Portas de cada “Slave” precisam estar visíveis para o SmartClient conseguir conectar, todos os arquivos de configuração dos SmartClients do parque de máquinas deve estar configurado para apontar diretamente para o IP e porta do “Master”. Caso algum Smartclient não esteja configurado desta forma, e estiver apontando para um determinado “Slave”, isto não atrapalha o balanceamento, pois mesmo que a conexão não tenha sido redirecionada pelo “Master”, ele vai decidir o balanceamento baseado no número de processos em execução em cada “Slave”. Porém, ao apontar diretamente para um “Slave”, se ele estiver bloqueado ou indisponivel, a conexão não será direcionada para nenhum outro “Slave”.

Limitação de Balanceamento

Para cada “Slave”, devemos especificar um número na chave “connections”. Este número por padrão não é um número “absoluto” de conexões, mas sim um número de “peso” de distribuição. Por exemplo, ao configurarmos o número 20 na chave “connections” de todos os “Slave’s” para balanceamento, o “Master” vai entender que a carga deve ser distribuída igualmente entre todos os “Slave’s”. Quando configuramos um dos “Slave’s” com connections=40 e os outros três com connections=20, haverá um percentual de diferença nesta distribuição.

A fórmula é calculada primeiro somando todos os números de conexão:

40+20+20+20 = 100

Agora, calculamos o percentual de conexões a serem desviadas para cada “Slave” dividindo a quantidade de conexões pelo numero total, e multiplicando por 100:

40/100 * 100 = 40 %
20/100 * 100 = 20 %
20/100 * 100 = 20 %
20/100 * 100 = 20 %

Logo, se neste ambiente forem feitas 10 conexões, 4 vão para o “Slave”1, e 2 para cada um dos outros “Slave’s”.

Porém, como a distribuição é feita igualmente, a primeira vai pro “Slave” 1 , a segunda também, a terceira vai pra um dos demais “Slave’s”, a quarta também, a quinta também. Quando o balanceamento atinge uma posição de equilíbrio, a sexta conexão deve ir para o balance 1, a sétima também, e as três últimas serão distribuídas para os demais “Slave’s”.

Normalmente em um ambiente equilibrado, o número é o mesmo. Você pode determinar um comportamento de excessão e priorizar mais os serviços de uma determinada máquina, caso ela tenha mais memória e CPU por exemplo. Porém, o mais saudável neste caso é criar mais de um serviço “Slave” na mesma máquina, para haver uma melhor utilização de recursos. Mesmo que este número originalmente não seja um fator limitante, você deve preenchê-lo com um valor aceitável do número máximo de conexões que realmente é saudável fazer em apenas um serviço.

Existe uma configuração na seção ServerNetwork, chamada BALANCELIMIT. Uma vez habilitada, ela considera que o somatório de conexões ( no nosso exemplo, 100 ) é um fator limitante. Por default, se o ambiente passar de 100 conexões, o balanceamento vai continuar distribuindo as conexões usando a mesma regra para os “Slave’s”, até que os “Slave’s” que atingirem um pico de memória não aceitarem mais conexões. Quando habilitamos esta configuração, o Balance vai parar de distribuir conexões para todos os “Slave’s”, caso a soma do numero de processos rodando em todos os “Slave’s” mapeados atingir ou superar a soma de conexões estipuladas nas chaves “connections” de cada “Slave” mapeado no balanceamento. Quando isso acontece, o Balance não redireciona mais conexões, retornando erros de indisponibilidade de serviços para o Smartclient que tentou se conectar.

Desequilíbrio de balanceamento

Enquanto todas as pessoas estão entrando no sistema, a distribuição de conexões é sempre uniforme. Porém, quando os usuários começam a sair do sistema, os serviços podem ficar desbalanceados, onde coincidentemente os usuários que saíram estavam conectados em alguns serviços específicos. Em condições normais de uso, este desbalanceamento não interfere no poder de processamento dos usuários que ainda estão conectados — exceto se coincidirem vários processos consumindo em excesso um determinado recurso de uma máquina, pois neste caso os demais usuários conectados em serviços naquela máquina podem ser penalizados pelo consumo destes processos. De qualquer modo, mesmo temporariamente desbalanceada a quantidade de conexões, as novas conexões feitas no ambiente vão ser priorizadas pelo “Master”, para entrar justamente nos “Slave’s” com o menor número de processos.

Configurações adicionais

Por default, um servidor “Master” pode aceitar conexões vindas do SmartClient, caso não haja nenhum outro “Slave” disponivel para redirecionar a conexão. Este comportamento nem sempre é desejável em um ambiente, e pode ser desligado usando a configuração “Master”Connection=0 na seção [servernetwork].

E, existe uma configuração de diagnóstico, para colocar o mecanismo de balanceamento do “Master” em modo “Verbose”, onde cada conexão recebida por um SmartClient é registrada no log de console do Application Server (console.log), informando sua origem, e para qual “Slave” ela foi encaminhada. Basta colocar na seção ServerNetwork a chave Verbose=1

Balanceamento com SSL

Quando utilizamos SSL entre os SmartClients e o Application Server, cada serviço do Protheus, inclusive o “Master”, deve ser configurados com uma conexão SSL, em uma porta distinta. No momento de configurar cada seção de “Slave” no appserver.ini do serviço “Master”, devemos especificar a porta TCP original de cada serviço, e a porta SSL usando a configuração “SecurePort=nnnn”, onde nnnn é o número da porta reservada para conexão segura (SSL) com aquele “Slave”.

Internet e Intranet

Quando disponibilizamos um balanceamento de carga para SmartClients em um ambiente de Intranet, e queremos expandí-lo para a internet ou outra rede externa, a utilização do balanceamento de carga nativo do Application Server exige que os IPs e portas usadas pelos serviços estejam públicos, e também seja visíveis pelo mecanismo de balanceamento (Serviço “Master”), e criar dois serviços de balanceamento. Um IP e porta do “Master” de “Intranet” para serem usados pelos SmartClients dentro da sua rede interna, e um IP e Porta do “Master” para configurar os Smartclients vindos “de fora”.

Internamente, você pode fazer cada balance apontar para um grupo distinto de servidores dedicados ao acesso interno ou externo, ou colocar 2 IPs nas máquinas onde estão todos os seus serviços “Slave”, e pelo balanceamento apontar para todos os serviços disponíves na sua rede, lembrando que o “Master” de acesso interno precisa enxergar os IPs locais, e o “Master” para acesso externo tem que ser cofigurado com os IPs externos, e precisam acessar cada um dos serviços por estes IPs.

Posso usar “localhost” ?

Não, não pode. Se o “Master” retorna o IP e porta configurados para um “Slave”, na seção correspondente ao “Slave” no appserver.ini do “Master”, no ini do smartclient você apontou um IP e porta do “Master”, mas ao estabelecer a conexão, o “Master” verificou que o “localhost:6001” é a melhor opção, e vai devolver isso pro SmartClient … Logo, o smartclient vai tentar conectar com um serviço Protheus na própria máquina onde ele está instalado. Você pode usar uma máquina grande e colocar muitos serviços de Protheus nela, e fazer um balanceamento “local”, MAS os IPs e Portas usados na configuração devem ser visíveis aos SmartClients, ok ? Se você usar localhost, seu balanceamento somente vai funcionar para os SmartClients que você iniciar de dentro da própria máquina onde estão os serviços do Protheus.

Posso usar outro mecanismo de balanceamento ?

Sim, pode. Você pode usar um NLB, um Proxy reverso, qualquer outro mecanismo que não interfira no conteúdo dos pacotes trafegados e que mantenha a persistência da conexão com o “Slave” escolhido, sendo assim totalmente transparente para as aplicações envolvidas. Normalmente a única desvantagem deste mecanismo é saber se o “Slave” escolhido está realmente disponível para executar o programa. Quando um “Slave” está bloqueado, a porta TCP de conexão permanece no ar e aceita novas conexões, mas responde a elas com uma mensagem de erro e desconectam. Com este balanceamento, caso seja usada uma métrica qualquer, onde naquele momento este serviço esteja atendendo aquela métrica, uma nova conexão vai ser direcionada para ele, mas ele vai atender e retornar erro. Sem persistir a conexão, e a métrica sendo favorável para este “Slave”, você corre o risco de ninguém mais conseguir entrar no sistema, pois todas as conexões serão direcionadas para um “Slave” que está no ar, mas não está preparado para atender as conexões.

O mundo ideal

Um modelo muito interessante de distribuição de processos seria um MVC com o processamento realizado por um pool de processos dedicados, separados do processo de interface. Deste modo, o processo de interface teria um consumo de CPU e memória relativamente mais baixos, em razão apenas da quantidade de componentes de interface, e as requisições efetivas de processamento e iteração com os dados fossem distribuídos em um pool de serviços distribuídos (SOA – Service Oriented Architecture). Se as implementações de processamento mantivessem uma constante média de consumo de recursos por requisição, o mecanismo de balanceamento das requisições de processamento poderia utilizar métricas mais eficientes, direcionando um processo que consome mais CPU para um serviço onde a CPU não esteja “no gargalo”, e sabendo que um processo vai consumir mais memória que os demais, o mecanismo poderia verificar antes de distribuir quais os serviços disponíveis estão com a melhor ocupação, ou que aguentariam aceitar a requisição sem comprometer os processos em execução.

Isto atenderia tanto o cenário de desempenho quando o de resiliência. Com mais de uma máquina fisica configurada para atender mais de um tipo de requisição, caso um serviço ou uma máquina inteira apresentasse um problema ou indisponibilidade, até mesmo uma manutenção programada, cada máquina teria o seu próprio “orquestrador” de eventos, que ao perceber que uma máquina saiu fora do circuito, poderia direcionar para outro serviço em outra máquina. A sua alta disponibilidade teria um grau de tolerância de acordo com quantos porcento de máquina você tem de “sobra”.

Ao invés de configurar um cluster ativo-passivo, você determina qual é o mínimo de máquinas que precisam estar no ar para dar conta do recado, e coloca uma ou mais máquinas adicionais. Se o seu dia a dia ocuparia duas máquinas inteiras, com serviços espalhados nelas, você pode colocar uma terceira máquina. Logo, você vai ter uma máquina “de sobra”, mas utilizaria as três juntas para não bater 100% do seu uso de recursos. No pior cenário, se uma máquina inteira sair do ar por uma pane física ou uma janela de manutenção de hardware, o software redirecionaria toda a carga para os dois hardwares disponíveis, que em tese deveriam dar conta do recado enquanto essa terceira máquina não “volta”.

Se você coloca o dobro de poder computacional do que você realmente precisa, você pode “perder” momentaneamente até duas máquinas inteiras, que as duas restantes vão bater “na tampa” mas vão segurar o sistema no ar. Se neste cenário a terceira máquina ir pro vinagre, você tem ainda têm duas saídas de emergência: Priorizar os serviços importantes e tirar tudo que é supérfluo do ar naquele momento, limitando a quantidade de conexões e processos, pra a primeira máquina não sobrecarregar — é melhor alguns trabalhando do que tudo parado — e dependendo da granularização dos serviços, você pode pegar algum equipamento de “emergência” e colocar alguns “Slave’s” neles para operação de “contingência”, distribuindo os serviços essenciais que a maquina 1 não iria dar conta.

Uma outra vertente no balanceamento de carga seria a utilização dos “Slave’s” encadeados, onde os processos seriam distribuídos somente para um slave, até que um “threshold” (limite) seja atingido, então os novos processos passam a alocar o segundo slave, e assim por diante. Este cenário é muito atrativo quando pensamos em escalabilidade para ambientes “Cloud”, onde seria possível por exemplo determinar um número inicial ou mínimo de máquinas virtuais, e criar uma regra para, quando o último slave começar a receber conexões, uma nova VM é disponibilizada no ambiente, e automaticamente inserida no balanceamento. Estas máquinas virtuais adicionais, assim que todas as conexões forem encerradas, poderia ‘fechar-se’ e ser descartada.

Em um ambiente de processamento de múltiplos hardwares, isto também seria útil para fazer uma janela de manutenção de um Hardware. Por exemplo, alterando a priorização dos slaves, deixamos por último na fila todos os slaves de um determinado equipamento, e bloqueamos as conexões para estes slaves. Conforme os usuários fossem desconectando da interface, no final do dia a máquina não estaria com nenhuma conexão, podendo ser tirada inteira do ar para manutenção, se que nenhum usuário desse falta disso.

Conclusão

A busca por eficiência de processos deve ser sempre uma constante em qualquer sistema com previsão de crescimento. E já existem muitos recursos voltados para isso. Entender como a aplicação funciona é um passo a mais para abrir caminhos para mudanças, com mais certeza de sucesso nas mudanças planejadas. Espero que este post tenha dado alguma luz “a mais” neste mecanismo, e que vocês tenham TERABYTES de sucesso na utilização deles 😀

Até o próximo post, pessoal 😉

Documentação TDN

Balanceamento de carga entre serviços

Seção [ServerNetwork]

Processo de monitoramento e controle de memória

Configuração BALANCELIMIT

Referências

Load balancing (computing). (2015, October 16). In Wikipedia, The Free Encyclopedia. Retrieved 00:49, October 31, 2015, from https://en.wikipedia.org/w/index.php?title=Load_balancing_(computing)&oldid=686092130

Reverse proxy. (2015, October 23). In Wikipedia, The Free Encyclopedia. Retrieved 13:47, October 31, 2015, from https://en.wikipedia.org/w/index.php?title=Reverse_proxy&oldid=687157637

Acelerando o AdvPL – Lendo arquivos TXT

Introdução

Alguém me perguntou na sexta-feira, qual era o método mais rápido de ler um arquivo TXT, que utilizava apenas o código ASCII 13 (CR) como quebra de linha… Normalmente eu mesmo usaria as funções FT_Fuse() e FT_FReadLn() para ler o arquivo … mas estas funções não permitem especificar o caractere de quebra… elas trabalham com arquivos TXT onde a quebra pode ser CRLF ou apenas LF.

Como eu não lembrava quem tinha me perguntado, e não achei a pergunta no WhatsApp, e-mail, FaceBook, etc…. resolvi postar a resposta no Facebook mesmo, explicando duas alternativas rápidas de leitura, uma consumindo mais memória e lendo o arquivo inteiro, e outra para arquivos sem limite de tamanho, e consumindo menos memória. Como houve interesse no assunto e diversos comentários, resolvi fazer uma classe de exemplo da segunda abordagem, partindo das premissas do melhor desempenho versus o menor consumo de memória possível.

Fonte da Clase ZFWReadTXT

#include 'protheus.ch'
/* ======================================================================
 Classe ZFWReadTXT
 Autor Júlio Wittwer
 Data 17/10/2015
 Descrição Método de leitura de arquivo TXT
Permite com alto desempenho a leitura de arquivos TXT
 utilizando o identificador de quebra de linha definido
 ====================================================================== */
#define DEFAULT_FILE_BUFFER 4096
CLASS ZFWReadTXT FROM LONGNAMECLASS
  DATA nHnd as Integer
  DATA cFName as String
  DATA cFSep as String
  DATA nFerror as Integer
  DATA nOsError as Integer
  DATA cFerrorStr as String
  DATA nFSize as Integer
  DATA nFReaded as Integer
  DATA nFBuffer as Integer
  DATA _Buffer as Array
  DATA _PosBuffer as Integer
  DATA _Resto as String
  // Metodos Pubicos
  METHOD New()
  METHOD Open()
  METHOD Close()
  METHOD GetFSize()
  METHOD GetError()
  METHOD GetOSError()
  METHOD GetErrorStr()
  METHOD ReadLine()
  // Metodos privados
  METHOD _CleanLastErr()
  METHOD _SetError()
  METHOD _SetOSError()
ENDCLASS
METHOD New( cFName , cFSep , nFBuffer ) CLASS ZFWReadTXT
DEFAULT cFSep := CRLF
DEFAULT nFBuffer := DEFAULT_FILE_BUFFER
::nHnd := -1
::cFName := cFName
::cFSep := cFSep
::_Buffer := {}
::_Resto := ''
::nFSize := 0
::nFReaded := 0
::nFerror := 0
::nOsError := 0
::cFerrorStr := ''
::_PosBuffer := 0
::nFBuffer := nFBuffer
Return self

METHOD Open( iFMode ) CLASS ZFWReadTXT
DEFAULT iFMode := 0
::_CleanLastErr()
If ::nHnd != -1
 _SetError(-1,"Open Error - File already open")
 Return .F.
Endif
// Abre o arquivo
::nHnd := FOpen( ::cFName , iFMode )
If ::nHnd < 0
 _SetOSError(-2,"Open File Error (OS)",ferror())
Return .F.
Endif
// Pega o tamanho do Arquivo
::nFSize := fSeek(::nHnd,0,2)
// Reposiciona no inicio do arquivo
fSeek(::nHnd,0)
Return .T.
METHOD Close() CLASS ZFWReadTXT
::_CleanLastErr()
If ::nHnd == -1
 _SetError(-3,"Close Error - File already closed")
Return .F.
Endif
// Close the file
fClose(::nHnd)
// Clean file read cache 
aSize(::_Buffer,0)
::_Resto := ''
::nHnd := -1
::nFSize := 0
::nFReaded := 0
::_PosBuffer := 0
Return .T.
METHOD ReadLine( /*@*/ cReadLine ) CLASS ZFWReadTXT
Local cTmp := ''
Local cBuffer
Local nRPos
Local nRead
// Incrementa o contador da posição do Buffer
::_PosBuffer++
If ( ::_PosBuffer <= len(::_Buffer) )
 // A proxima linha já está no Buffer ...
 // recupera e retorna
 cReadLine := ::_Buffer[::_PosBuffer]
 Return .T.
Endif
If ( ::nFReaded < ::nFSize )
  // Nao tem linha no Buffer, mas ainda tem partes
  // do arquivo para ler. Lê mais um pedaço
  nRead := fRead(::nHnd , @cTmp, ::nFBuffer)
  if nRead < 0
    _SetOSError(-5,"Read File Error (OS)",ferror())
    Return .F.
  Endif
  // Soma a quantidade de bytes lida no acumulador
  ::nFReaded += nRead
  // Considera no buffer de trabalho o resto
  // da ultima leituraa mais o que acabou de ser lido
  cBuffer := ::_Resto + cTmp
  // Determina a ultima quebra
  nRPos := Rat(::cFSep,cBuffer)
  If nRPos > 0
    // Pega o que sobrou apos a ultima quegra e guarda no resto
    ::_Resto := substr(cBuffer , nRPos + len(::cFSep))
    // Isola o resto do buffer atual
    cBuffer := left(cBuffer , nRPos-1 )
  Else
    // Nao tem resto, o buffer será considerado inteiro
    // ( pode ser final de arquivo sem o ultimo separador )
    ::_Resto := ''
  Endif
 // Limpa e Recria o array de cache
 // Por default linhas vazias são ignoradas
 // Reseta posicionamento de buffer para o primeiro elemento 
 // E Retorna a primeira linha do buffer 
 aSize(::_Buffer,0)
 ::_Buffer := StrTokArr2( cBuffer , ::cFSep )
 ::_PosBuffer := 1
 cReadLine := ::_Buffer[::_PosBuffer]
 Return .T.
Endif
// Chegou no final do arquivo ...
::_SetError(-4,"File is in EOF")
Return .F.
METHOD GetError() CLASS ZFWReadTXT
Return ::nFerror
METHOD GetOSError() CLASS ZFWReadTXT
Return ::nOSError
METHOD GetErrorStr() CLASS ZFWReadTXT
Return ::cFerrorStr
METHOD GetFSize() CLASS ZFWReadTXT
Return ::nFSize
METHOD _SetError(nCode,cStr) CLASS ZFWReadTXT
::nFerror := nCode
::cFerrorStr := cStr
Return
METHOD _SetOSError(nCode,cStr,nOsError) CLASS ZFWReadTXT
::nFerror := nCode
::cFerrorStr := cStr
::nOsError := nOsError
Return
METHOD _CleanLastErr() CLASS ZFWReadTXT
::nFerror := 0
::cFerrorStr := ''
::nOsError := 0
Return

Como funciona

No construtor da classe (método NEW), devemos informar o nome do arquivo a ser aberto, e opcionalmente podemos informar quais são os caracteres que serão considerados como “quebra de linha”. Caso não especificado, o default é CRLF — chr(13)+chr(10). Mas pode ser especificado também apenas Chr(13) (CR) ou Chr(10) (LF). E, como terceiro parâmetro, qual é o tamanho do cache em memória que a leitura pode utilizar. Caso não informado, o valor default são 4 KB (4096 bytes). Caso as linhas de dados do seu TXT tenha em média 1 KB, podemos aumentar seguramente este número para 8 ou 16 KB, para ter um cache em memória por evento de leitura em disco de pelo menos 8 ou 16 linhas.

A idéia é simples: Na classe de encapsulamento de leitura, a linha é lida por referência pelo método ReadLine(), que pode retornar .F. em caso de erros de acesso a disco, ou final de arquivo (todas as linhas já lidas). A abertura do arquivo apenas determina o tamanho do mesmo, para saber quanto precisa ser lido até o final do arquivo. A classe mantém um cache de linhas em um array, e uma propriedade para determinar o ponto atual do cache. Ele começa na primeira linha.

Na primeira leitura, o array de cache está vazio, bem como um buffer temporário chamado “_resto”. A primeira coisa que a leitura faz é incrementar o ponteiro do cache e ver se o ponteiro não passou o final do cache. Como na primeira leitura o cache está vazio, a próxima etapa é verificar se ainda falta ler alguma coisa do arquivo.

Caso ainda exista dados para ler do arquivo, um bloco de 4 KB é lido para a memória, em um buffer de trabalho montado com o resto da leitura anterior (que inicialmente está vazio) mais os dados lidos na operação atual. Então, eu localizo da direita para a esquerda do buffer a ultima quebra do buffer lido, guardo os dados depois da última quebra identificada no buffer “_resto” e removo estes dados do buffer atual de trabalho.

Desse modo, eu vou ter em memória um buffer próximo do tamanho máximo do meu cache (4 KB), considerando a última quebra encontrada neste buffer. Basta eu transformá-lo em um array usando a função StrTokArr2, guardar esse array na propriedade “_Buffer” da classe, e retornar a primeira linha lida.

Quando o método ReadLine() for chamado novamente, o cache vai estar alimentado com “N” linhas do arquivo, eu apenas movo o localizador do cache uma unidade pra frente, e se o localizador ainda está dentro do array, eu retorno a linha correspondente. Eu nem preciso me preocupar em limpar isso a cada leitura, afinal a quantidade de linhas em cache vai ocupar pouco mais de 4 KB mesmo … eu somente vou fazer acesso a disco, e consequente limpeza e realimentação desse cache quando o cache acabar, e ainda houver mais dados no arquivo a ser lido.

Desempenho

Peguei um arquivo TXT aleatório no meu ambiente, que continha um LOG de instalação de um software. O arquivo têm 1477498 bytes, com 12302 linhas de texto. O arquivo foi lido inteiro e todas as linhas identificadas entre 39 e 42 milissegundos (0,039 a 0,042 segundos). Resolvi fazer um outro fonte de testes, lendo este arquivo usando FT_FUSE e FT_FREADLN. Os tempos foram entre 51 e 55 milissegundos (0,051 a 0,055 segundos). E, usando a classe FWFileReader(), os tempos foram entre 101 e 104 milissegundos (0,101 e 0,104 segundos).

Repeti o mesmo teste com um arquivo suficientemente maior, 50 mil linhas e 210 MB .. os tempos foram:

FWFileReader ...... 2,937 s.
FT_FreadLN ........ 1,233 s.
ZFWReadTXT ........ 0,966 s.

Conclusão

Ganhar centésimos de segundo pode parecer pouco … mas centésimos de segundos podem significar uma largada na “pole position”, ou largar na 5a fila … Em uma corrida de 60 voltas, um segundo por volta torna-se um minuto. A máxima do “mais com menos” permanece constante. Quanto menos ciclos de máquina você consumir para realizar um processamento, mais rápido ele será. Quando falamos em aplicações com escalabilidade “estrelar”, para milhões de requisições por segundo, onde temos um limite natural de número de processos dedicados por máquina, quanto mais rápido cada processo for executado, maior “vazão” de processamento pode ser obtido com a mesma máquina. Principalmente quando entramos em ambientes “Cloud”, mesmo tendo grande disponibilidade de processamento, normalmente você será tarifado proporcionalmente ao que consumir. Chegar ao mesmo resultado com menos consumo de recurso fará a diferença 😀

Nos próximos tópicos (aceito sugestões), vou procurar abordar as questões de “como fazer mais rápido” determinada tarefa ou processo, acredito que esta abordagem ajuda a aproximar todos os conceitos de escalabilidade e desempenho que foram abordados apenas na teoria nos posts iniciais sobre este tema !

Agradeço a todos pela audiência do Blog, e não sejam tímidos em dar sugestões, basta enviar a sua sugestão de post siga0984@gmail.com 😀 Para dúvidas pertinentes ao assunto do post, basta postar um comentário no post mesmo 😉

E, pra finalizar, segue abaixo o fonte de teste de desempenho utilizado:

#include 'protheus.ch'
#define TEXT_FILE '\meuarquivo.txt'
/* ======================================================================
Função U_LeFile1, 2 e 3()
Autor Júlio Wittwer
Data 17/10/2015
Descrição Fontes de teste comparativo de desempenho de leitura de arquivo TEXTO
U_LeFile1() - Usa ZFWReadTXT
U_LeFile2() - Usa FT_FREADLN
U_LeFile3() - Usa FWFileReader
====================================================================== */
User Function LeFile1()
Local oTXTFile
Local cLine := ''
Local nLines := 0
Local nTimer
nTimer := seconds()
oTXTFile := ZFWReadTXT():New(TEXT_FILE)
If !oTXTFile:Open()
  MsgStop(oTXTFile:GetErrorStr(),"OPEN ERROR")
  Return
Endif
While oTXTFile:ReadLine(@cLine)
  nLines++
Enddo
oTXTFile:Close()
MsgInfo("Read " + cValToChar(nLines)+" line(s) in "+str(seconds()-nTimer,12,3)+' s.',"Using ZFWReadTXT")
Return
User Function LeFile2()
Local nTimer
Local nLines := 0
nTimer := seconds()
FT_FUSE(TEXT_FILE)
While !FT_FEOF()
  cLine := FT_FReadLN()
  FT_FSkip()
  nLines++
Enddo
FT_FUSE()
MsgInfo("Read " + cValToChar(nLines)+" line(s) in "+str(seconds()-nTimer,12,3)+' s.',"Using FT_FReadLN")
Return
User Function LeFile3()
Local nTimer
Local nLines := 0
Local oFile
nTimer := seconds()
oFile := FWFileReader():New(TEXT_FILE)
If !oFile:Open()
  MsgStop("File Open Error","ERROR")
  Return
Endif
While (!oFile:Eof())
  cLine := oFile:GetLine()
  nLines++
Enddo
oFile:Close()
MsgInfo("Read " + cValToChar(nLines)+" line(s) in "+str(seconds()-nTimer,12,3)+' s.',"Using FWFileReader")
Return