Caching no Yii

otavio publicou em 04/10, 04:08 hs , e editou pela última vez há mais de 4 anos atrás.

O Yii, como todos os bons frameworks de desenvolvimento modernos, possui um conjunto de mecanismos para tornar o processo de cache mais simples.

A grande vantagem do uso de componentes de caching integrados não está na criação do cache. Criar, por exemplo, uma tabela de resumo de dados não é, nem de perto, ciência espacial. A grande vantagem é usar com agilidade a grande capacidade de mecanismos de expiração (seja por tempo total, seja por alguma trigger externa, como a atualização de algum dado usado no cache).

Não vamos aqui falar de aplicações como o Varnish , que funcionam como um proxy e muito usado em sites de alto tráfego com páginas abertas (como grandes sites de e-Commerce e de notícias).

Aqui vamos falar um pouco sobre o quando, o que e o como do uso de cache em sua aplicação, direcionado à solução proposta pelo Yii. Para que não fique muito extenso, alguns aspectos mais avançados serão colocados em posts subsequentes.

Algo importante é: cache é dado descartável e que pode ser gerado novamente a qualquer momento. Informações como: “quantos usuários existiam na base em janeiro?” ou “Com quantas lojas o vendedor X trabalhava em abril de 2012?” são informações que devem ser persistidas adequadamente para consulta futura.

Quando:

Essa talvez seja a mais fácil das respostas. O quando é, basicamente: quando sua aplicação precisa escalar. Mais do que isso, que você possa ter as variáveis de uma página fixas por tempo suficiente para justificar o cache.

Sadalage e Fowler, em seu livro de NoSQL falam muito sobre o relaxamento da consistência e seus efeitos sobre a aplicação. Pode não fazer sentido fazer cache do número de acessos a uma página em uma página que só exibe isso. Mas será que não podemos ter, por exemplo, o valor total de vendas do dia com até 1 hora de defasagem? Se a resposta for sim e o número de transações por hora for grande o suficiente para dar trabalho de calcular instantâneamente, porque não fazer um cache por 1h?

O que:

Basicamente, trabalho com cache quando:

  1. O número de acessos a uma página é muito grande e a página é muito parecida, melhor ainda se igual, para a maior parte dos usuário, melhor ainda se para todos.
  2. O tempo de acesso à página é maior do que o esperado pelo usuário médio
    • após 3 segundos pode ter certeza que seu usuário estará achando sua página lenta.

Veja abaixo um exemplo (usando o backup de uma base real) de um teste ab com duas páginas (uma de login e outra de dashboard gerencial):

ab -n 1000 http://test.localhost/index.php?r=site/login
Antes  do cache: Time taken for tests:   52.843 seconds
Depois do cache: Time taken for tests:   32.549 seconds

ab -n 20 http://test.localhost/index.php?r=dashboard/index
Antes  do cache: Time taken for tests:   12.224 seconds
Depois do cache: Time taken for tests:   0.580 seconds

Como:

De forma simplista, no Yii, temos 2 variáveis: como persistir o cache e qual a forma de gerar o caching.

Das formas de persistir, as que mais gosto são, por ordem:

  • CRedisCache (a partir de yii-1.1.14)
  • CFileCache
  • CMemCache

Cada um tem vantagem e desvantagem. O CRedisCache usa o Redis como backend e é muito rápido. Sua vantagem é ainda maior quando o número de páginas em caching cresce ou quando o cache necessita ser acessado por mais de uma aplicação. Um ponto contra, hoje, é que se o servidor estiver fora do ar, sua aplicação retornará uma excessão. Isso é algo tratável, mas deveria, no mínimo, ser objeto de configuração (na maior parte dos casos, falhar graciosamente seria uma boa opção).

Já o CFileCache tem a vantagem de não ter que instalar/configurar nada. Um simples diretório cache dentro de runtime (protected/runtime/cache) é mais do que suficiente para colocar no ar a operação. O problema é que esse tipo de solução limita, em muito o compartilhamento entre servidores de aplicação (dependência de área comum) e o número de arquivos gerados simultâneamente pode, até mesmo, tornar o cache mais lerdo do que o acesso direto.

CMemCache está aqui por ser o servidor padrão de caching. A vantagem é, justamente, ser um padrão entre diferentes aplicações. A desvantagem é o tempo de setup da operação.

Para definir qual o broker de cache da sua aplicação, altere seu main para incluir um componente:

'components' => array(
'cache' => array(
 // precisa do diretório protected/runtime/cache
 'class' => 'system.caching.CFileCache',
),
//'cache' => array(
//// precisa da versão 1.1.14 do Yii
//'class' => 'CRedisCache',
//'hostname' => 'localhost',
//'port' => 6379,
//'database' => 0,
//),

As três formas de gerar o cache no Yii são:

Page Caching:

Cada um apresenta uma virtude, que vamos tratar aqui. Para resumir, o que mais gosto do page caching é seu caráter menos obstrusivo. O código da sua action não precisa ser mudado para o cache funcionar. Mas esse recurso depende muito da página ser a mesma para todos. E isso é difícil em ambientes autenticados (mas o Dynamic content resolve a maior parte desses problemas).

Em seu controller, altere os filtros para:

public function filters()
{
    return array(
        'accessControl', // perform access control for CRUD operations
        array(
            'COutputCache + login,index',
            'duration' => 1200,
            'varyByParam' => array('id'),
        ),
    );
}

Isso garante o cache por 1200 segundos das páginas login e index. Agora a pegadinha do page cache. O que aconteceria se eu só tivesse feito isso e o login/senha falhasse? Resposta: iria mostrar uma página de login exatamente igual à inicial. Sem flash message. Por isso, fiz o seguinte hack no form:

$form = $this->beginWidget('bootstrap.widgets.TbActiveForm', array(
'id' => 'verticalForm',
'action' => array('site/login', 'id' => date('Ymdhms')),
...

Ao formulário de post do login eu adicionei o parâmetro de action, com um id que sempre varia. Assim, se tiver que mostrar a página de login por conta da falha, como a data/hora vai sempre mudar, mostra o erro.

E o filtro eu alterei para que ele valide se a url possui ou não um id. Se possui, não faço cache (duration = 0). Além disso, uso o varyByExpression para garantir que a página que não será cacheada é a com o id. A sem o id será/estará sempre cacheada (usando o time a expression sempre muda e uma nova página é gerada).

array(
 'COutputCache + login',
 'duration' => isset($_GET['id']) ? 0 : 3600,
 'varyByExpression' => isset($_GET['id']) ? time() . '' : '0',
),

Usando o Query Caching:

Como o nome diz, esse recurso permite cachear uma consulta por um determinado período. Esse recurso deve ser usado quando uma mesma consulta é usada repetidas vezes em uma ou mais actions. O cache é da consulta realizada.

$sql = 'SELECT id, nome, ean FROM produtos order by id';
$dependency = new CDbCacheDependency('SELECT MAX(id FROM produtos');
$rows = Yii::app()->db->cache(1000, $dependency)->createCommand($sql)->queryAll();

Esse recurso pode ser útil mas é, a meu ver, um recurso menor e que, na maior parte das vezes, pode ser substituído por um fragment cache sem grandes perdas.

O uso do CDbCacheDependency será explicado mais adiante.

Fazendo o Fragment Caching:

Usamos o fragment caching para guardar parte de uma informação em cache. No exemplo abaixo, eu modifiquei o comportamento da actionIndicadores para cachear a consulta dos últimos meses de vendas por 1h:

$aDadosVendasUltimosMeses = Yii::app()->cache->get('meses_para_tras' . $meses_para_tras);
if ($aDadosVendasUltimosMeses === false) {
  $aDadosVendasUltimosMeses = Vendas::resumoAteAgoraDesde($mesesParaTras);
  Yii::app()->cache->set('meses_para_tras' . $meses_para_tras, $aDadosVendasUltimosMeses, 3600);
}

Cache Dependency:

Algo interessante a avaliar é a Cache Dependency. Pode ser uma consulta, uma expressão, algo que force a expiração do fragmento ou da página (isso serve para qualquer das formas de realizar o caching).

No exemplo anterior de Fragment Caching, o set seria alterado para:

Yii::app()->cache->set('meses_para_tras' . $meses_para_tras,  
 $aDadosVendasUltimosMeses,
 3600,
 array('dependency'=>array(
  'class'=>'system.caching.dependencies.CDbCacheDependency',
  'sql'=>'SELECT MAX(id) FROM ordem_venda')
 )
);

Assim, se a maior ordem de venda fosse modificada, a expiração do cache seria realizada.

Links úteis:

  • http://www.yiiframework.com/doc/api/1.1/COutputCache#varyByParam-detail
  • http://stackoverflow.com/a/13207059
if(typeof jQuery == 'undefined'){ document.write("