quinta-feira, 2 de setembro de 2010

Solucionando IO bloqueante do mysql no ruby

Fui a uma palestra muito interessante sobre performance na Oscon. A palestra No Callbacks, No Threads: Async & Cooperative Web Servers with Ruby 1.9, tratava do problema de IO bloqueante do driver do mysql para ruby, e como solução era proposto o uso de recursos como Event Machine e Fibers do ruby 1.9. Contudo, a solução não ficava nada elegante, tornando a manutenção do código muito complicada. Embora hoje haja um esforço para tornar esse trabalho transparente, consegui obter o mesmo resultado em performance basicamente aumentando o número de processos a atenderem as requisições.

Mas antes de explicar a solução é preciso demonstrar o problema. O caso é que embora tenhamos threads no ruby, alguns drivers como o do mysql são bloqueantes, ou seja, quando estão em uma operação de IO eles bloqueiam o processo inteiro, inclusive todas suas threads. Veja por exemplo o seguinte código:

class TestesController < ApplicationController
def index
Thread.new { Teste.connection.execute("insert into testes (id) select sleep(2)") }
render :text => 'ok'
end
end

Teoricamente ao fazermos a requisição a este controller de teste, a requisição não deveria durar os dois segundos de espera pelo retorno do insert ao mysql. Mas não é isso que acontece. As requisições acabam sendo enfileiradas pois o processo inteiro fica bloqueado a cada execução de query no banco. Isso pode ser comprovado utilizando o Apache Benchmark.

> ab -c 10 -n 10 http://localhost:3000/testes
...
Concurrency Level: 10
Time taken for tests: 20.514 seconds
Complete requests: 10
...

É bom deixar claro que o bloqueio ocorre somente ao usar o método execute do driver, que é responsável por fazer atualizações no banco. Fazendo somente consultas, o bloqueio não ocorre.

Na palestra em questão, foi demonstrado que utilizando Event Machine e Fibers é possivel desbloquear o processo utilizando callbacks. No final o tempo total foi reduzido para 2 segundos e alguns milésimos. Esse mesmo resultado eu obtive configurando um nginx com passenger configurado com 10 forks. Uma solução bem mais limpa. São apenas dois parametros, um do nginx, e outro do passenger:

worker_processes  10;
#...
http {
passenger_max_pool_size 10;
}

E o resultado do teste:

> ab -c 10 -n 10 http://localhost/testes

Concurrency Level: 10
Time taken for tests: 2.424 seconds
Complete requests: 10

É claro que ainda sim a solução proposta na palestra é mais performática, até porque nela o consumo de memória é bem menor. Resta saber se essa economia vale a pena quando se pesa na balança o custo de manter um código mais complicado e os problemas que a concorrência podem trazer ao seu projeto. E para demonstrar o quão escalável é dividir as requisições em processos, fiz ainda um ultimo teste, com 1000 requisições, sendo 200 simultâneas, configurando o nginx e o passenger para trabalhar com 200 forks:

> ab -c 200 -n 1000 http://localhost/testes

Concurrency Level: 200
Time taken for tests: 13.043 seconds
Complete requests: 1000

Ou seja, o tempo total de teste manteve-se estável. Levando- em conta que dificilmente alguém fará um insert no banco com sleep, acredito que esssa seja uma boa solução para o problema de IO bloqueante. Ao invés de threads, utilizar processos.

3 comentários:

  1. Achei maneiro essa tua pesquisa para um problema comum que enfrentamos thiago. Espero ter memoria no meu vps para implementar isso hehe.

    abs

    ResponderExcluir
  2. Achei muito legal Thiago. Como você bem observou, tem o custo de memória RAM que precisa ser pensado com cuidado. Outra coisa também é que talvez essa solução pode não ser ideal quando você precisa de Long Lived Connections, onde outras soluções podem ser estudadas.

    Grande Abraço,

    ResponderExcluir
  3. Excelente. Estou enfrentando esse problema no meu projeto atual, onde eu tenho muitos inserts no mysql.

    ResponderExcluir