Coisas que aprendi ao importar 50 mil artigos para o WordPress

Nota do Editor: E se, um dia, um conhecido vos pedisse para lidar com a importação, todos os dias, de 50 mil entidades encaminhando-as para artigos numa instalação WordPress?  O mais certo é que tivessem de coçar a cabeça em busca de uma solução. Foi o que aconteceu a Luís Rodrigues. As primeiras tentativas não foram bem sucedidas. Até que… Mas o melhor é ser o próprio Luís Rodrigues a contar a história.

Recentemente, tive a oportunidade de desenvolver um plugin para um cliente que, todos os dias, obtém um ficheiro com cerca de 50 mil entidades, e insere-as ou actualiza-as como artigos, numa instalação WordPress.

A tarefa seria relativamente trivial para um baixo número de entidades, mas como tudo o que é desproporcionado em software, as coisas depressa se tornam estranhas. De timeouts a erros por falta de memória, há muito a esperar de um desafio destes.

Eis algumas das coisas que aprendi.

A tempo e horas

O WP Cron é o mecanismo de agendamento do WordPress, e funciona de forma semelhante ao crontab do Unix. O CMS agenda tarefas pontuais ou recorrentes (como a publicação de artigos) e na data prevista (na melhor das hipóteses), essa tarefa é executada.

Digo “na melhor das hipóteses” porque a execução de tarefas não é um mecanismo independente, sendo despoletada pelas visitas ao site. Ou seja, se existe uma tarefa agendada para as 15:30, mas ninguém visita o site até às 17:59, essa tarefa não é invocada até às 17:59.

O desafio que tinha pela frente não era a pontualidade da operação, mas sim o facto dos pedidos do WP Cron estarem à mercê dos timeouts do servidor web. Por muito que optimizasse a operação, não seria fácil pôr o PHP a importar 50 mil posts em menos dos 60 segundos configurados.

Prevendo que o processo seria longo, a primeira coisa a fazer foi evitar o WP Cron nativo e obrigá-lo a correr através do PHP pela linha de comandos. O PHP-CLI tem a vantagem de ser independente do servidor web, e poder ter configurações de memória e execução próprias.

Por isso, editei o ficheiro wp-config.php e acrescentei a seguinte linha:

define( 'DISABLE_WP_CRON', true );

Depois, editei o crontab (crontab -e) do servidor e acrescentei a seguinte linha:

*/10 * * * * cd /www/wordpress && HTTP_HOST=exemplo.com QUERY_STRING=doing_wp_cron php -f wp-cron.php > /dev/null 2>&1

Sem me querer alongar sobre a sintaxe do crontab e da shell, a linha acima indica que pretendo, a cada 10 minutos, mudar para a directoria do WordPress (cd /www/wordpress) e correr o script wp-cron.php (php -q -f wp-cron.php).

É importante mudar para a directoria do WordPress antes de executar o wp-cron.php porque este precisa de encontrar os restantes ficheiros do WordPress relativamente ao caminho de execução.

Estou ainda a comunicar duas variáveis de ambiente HTTP_HOST e QUERY_STRING, necessárias para evitar alguns erros na execução do WordPress.

Finalmente, digo ao PHP para trabalhar e estar caladinho (> /dev/null 2>&1).

Ler na diagonal

O segundo passo consistiu no carregamento do ficheiro de entidades propriamente dito. O formato é JSON, que é simples de processar, mas o tamanho está na ordem das dezenas de megabytes, pelo que não podemos adoptar a abordagem tradicional de carregar todo o ficheiro em memória para o trabalhar.

À falta de um web service (a solução ideal), podia ter optado pelo carregamento de ficheiros mais pequenos, mas a solução depressa se tornaria incomportável, obrigando o cliente a particionar os dados e manter dezenas de ficheiros JSON.

O processo de carregamento para importação baseou-se por isso no leitor por streaming JsonStreamingParser, cujo código está disponível no Github e pode ser facilmente instalado como dependência do Composer. Para quem já experimentou parsers SAX para XML, o funcionamento é muito parecido.

Havendo a necessidade de obter o ficheiro de um servidor remoto, e na impossibilidade de o carregar directamente com fopen() por questões de segurança, usei a função wp_remote_get().

Aqui foi necessário assegurar que o download também seria feito por streaming. Não havendo esse cuidado, o WordPress carregaria a totalidade do ficheiro em memória, o que daria azo a erros fatais para a plataforma.

wp_remote_get( $remote_file, array(
    'blocking'         => true,
    'stream'           => true, // IMPORTANTE
    'filename'         => $local_file
) );

(O argumento stream não está documentado. Foi necessário algum trabalho de investigação com o fiel ag para o descobrir.)

E assim, obtido o ficheiro e integrados os event listeners do JsonStreamingParser, pude começar a importar os conteúdos.

Muni-me da função wp_insert_post(), útil para criar e actualizar os artigos propriamente ditos, e lancei-me finalmente ao que interessava.

A natureza dos dados a importar não é relevante, mas como qualquer entidade obtida de outra base de dados, estes traziam identificadores próprios. Estes seriam úteis para referenciar o artigo no WordPress, quando tivesse de actualizar a sua informação, pelo que guardei os identificadores num campo personalizado (invocando update_post_meta( $post_ID, 'remote_ID', $entity['ID'] )).

Assim, quando fosse necessário actualizar conteúdos previamente importados, poderia correr o seguinte método para obter o post relevante:

protected function get_post_by_entity_id ( $entity_id ) {
    $args = array (
        'post_type'      => 'entity',
        'posts_per_page' => '1',
        'meta_query'     => array(
            array(
                'key'    => 'entity_ID',
                'value'  => $entity_id,
            ),
        )
    );

    $get_posts = new WP_Query();
    $posts     = $get_posts->query( $args );

    return count( $posts ) ? $posts[0] : false;
}

Concluídos estes desenvolvimentos, fiz os testes necessários para validar o resultado da importação com um ficheiro de exemplo, e dei a tarefa por concluída.

Todo pimpão, agendei a importação no WP Cron com o ficheiro de dados definitivo, e—

Boom!

A importação falhava, repetidamente, após importar apenas 500 posts. O erro já será conhecido de qualquer entusiasta do WordPress que tenha instalado algumas dezenas de plugins a mais:

Allowed memory size of 134217728 bytes exhausted (tried to allocate 72 bytes)…

Para complicar ainda mais a situação, o meu ambiente de desenvolvimento demorava cerca de 20 minutos a importar estes 500 posts. Se o ambiente de produção fosse tão lento quanto a minha máquina, a importação dos 50 mil posts consumiria para lá de 33 horas.

Uma questão de perfil(agem)

Na década de 1970, o matemático e programador Donald Knuth lavrou uma frase, inúmeras vezes repetida, avisando que a optimização prematura é a raíz de todos os males.

Um corolário desta máxima é que não se deve iniciar um processo de optimização sem conhecimento do que está de facto a acontecer e sem medir o comportamento do sistema em várias fases da execução.

Uma optimização às cegas pode revelar-se tão trabalhosa quanto infrutífera, havendo o risco de tornar a aplicação menos robusta e mais difícil de manter a troco de um ganho marginal no desempenho.

É aqui que entram ferramentas de perfilagem como o Xdebug, o XHProf ou o memprof, que servem para medir os tempos de execução e utilização de memória das várias operações e componentes aplicacionais.

Um tutorial sobre a natureza e utilização destas ferramentas foge ao âmbito deste texto, mas escusado será dizer que todas foram preciosas quando precisei de estudar o comportamento do meu código e do código do WordPress.

Cache? Where we’re going, we don’t need cache.

O primeiro ponto de estrangulamento detectado pelas ferramentas de perfilagem dizia respeito à cache de objectos e base de dados do WordPress.

Os processos de leitura e criação de objectos do WordPress recorrem a mecanismos de caching para evitar que certas operações desnecessariamente pesadas ocorram de futuro. Por exemplo, não faz sentido voltar a obter o mesmo post da base de dados que ainda há segundos estava a ser apresentado. Assim, o artigo fica guardado num suporte intermédio de acesso rápido, poupando ciclos de processamento e atrasos devido a comunicação.

Por outro lado, as operações de escrita precisam de invalidar a cache para garantir que vamos usar a versão mais recente de um objecto, e não a que estava guardada em cache.

Ora, nada disto é necessário quando pretendo carregar ou actualizar 50 mil artigos.

Embora tenha de ler atributos do post da base de dados para determinar se este precisa de ser actualizado, isto apenas acontece uma vez para cada artigo. Ou seja, não é preciso manter artigos em cache porque não vou voltar a lê-los. Além disso, sendo provável que tenha de os actualizar, isso invalidaria a cache de qualquer das formas.

Posto isto, digo ao WordPress para suspender todas as operações de caching a partir daqui:

if (!defined( 'DONOTCACHEDB' ))     define( 'DONOTCACHEDB', true );
if (!defined( 'DONOTCACHEOBJECT' )) define( 'DONOTCACHEOBJECT', true );

wp_suspend_cache_addition( true );
wp_suspend_cache_invalidation( true );

Com estas instruções em efeito, o processo de importação tornou-se bastante mais rápido e passou a ocupar menos memória, uma vez que já não tinha de se ocupar com a manutenção da cache de objectos e base de dados.

Para excepções mais pontuais, é ainda possível invocar a função de get_posts() (ou instanciar um objecto WP_Query) com o argumento cache_results a falso. Por exemplo, o método que apresentei acima poderia ser revisto da seguinte forma:

protected function get_post_by_entity_id ( $entity_id ) {
    $args = array (
        'post_type'      => 'entity',
        'posts_per_page' => '1',
        'meta_query'     => array(
            array(
                'key'    => 'entity_ID',
                'value'  => $entity_id,
            ),
        ),
        'cache_results'  => false,
        'no_found_rows'  => true
    );

    $get_posts = new WP_Query();
    $posts     = $get_posts->query( $args );

    return count( $posts ) ? $posts[0] : false;
}

O argumento cache_results sobrepõe-se a dois outros argumentos, que podem ser ligados ou desligados individualmente para um controlo mais granular: update_post_meta_cache e update_post_term_cache.

Usei ainda o argumento no_found_rows para evitar a contagem dos artigos que correspondem ao pedido, algo que é apenas relevante para a apresentação de resultados paginados.

E pronto, certo?

Errado.

Apesar de ter indicado, nas instruções acima, para o WordPress ignorar a invalidação da cache, há um bug que faz com que persistam as operações sobre a cache das relações entre termos e artigos.

Pior, a função wp_update_term(), que serve para actualizar um termo de taxonomia, invoca a função clean_object_term_cache() para limpar as relações entre termos e objectos, indo buscar à base de dados todos os artigos com aquela taxonomia.

Isto traduz-se um overhead não só de processamento como também de ocupação de memória. Com a quantidade de artigos com que estava a lidar, esta operação rebentava imediatamente com a memória disponível mal eu tentasse actualizar nem que fosse uma só taxonomia.

A solução não foi trivial, uma vez que a operação do clean_object_term_cache() não pode ser modificada pela API de hooks do WordPress.

Contudo, depois de analisar o código do core, e de medir muito bem as necessidades da tarefa em mãos, concluí que podia suprimir temporariamente a associação entre tipos de artigos e taxonomias.

Ou seja, antes de iniciar a importação, executei o seguinte:

global $wp_taxonomies;
$backup_taxonomies = $wp_taxonomies;
$wp_taxonomies['custom-taxonomy']->object_type = array();

A solução é deselegante, mas cumpriu os meus objectivos. Funciona porque a função wp_update_term() percorre a lista de tipos de objecto suportados pela taxonomia. Ao esvaziar esta lista (com a atribuição $wp_taxonomies['...']->object_type = array()), acabo por impedir que alguns milhares de artigos sejam obtidos e, no final, que a cache de relações seja refrescada para cada um.

No final da importação, claro, reponho os dados alterados:

$wp_taxonomies = $backup_taxonomies;

Quantos são? Quantos são?

Detectei ainda que a operação de atribuir um termo de taxonomia a cada um dos artigos estava a levar uma quantidade cada vez mais considerável de tempo.

Que seria desta vez? Nada mais simples: sempre que se acrescenta um artigo e se atribui uma categoria, etiqueta ou outra taxonomia qualquer, o WordPress actualiza o total de artigos associados a cada termo.

Esta contagem é útil para, por exemplo, determinar se uma determinada categoria ou etiqueta tem artigos, ou quais são as etiquetas mais usadas num site.

Mas eu também não precisava de nada disto para importar um lote de artigos. Precisava apenas de suspender as contagens e só fazê-las uma vez no final.

Felizmente, os developers do WordPress pensaram na questão:

wp_defer_term_counting( true );

Para reactivar as contagens, invoco no final:

wp_defer_term_counting( false );

A instrução acima actualiza automaticamente a contagem de todos os artigos associados a todos os termos de taxonomia, pelo que não é preciso fazer mais nada.

E ainda

Outro ponto de falha dizia respeito ao meu ambiente de desenvolvimento, que estava configurado para guardar todos pedidos à base de dados. É muito útil quando tenho de diagnosticar algum problema, mas a definição não deve estar presente em ambientes de produção.

Garantir imunidade contra este tipo de situação é simples: basta definir, algures no ficheiro wp-config.php, a constante SAVEQUERIES.

define( 'SAVEQUERIES', false );

Ignição

Desbloqueados estes obstáculos, pude finalmente agendar a importação com dados reais, e confirmar que os novos artigos estavam a ser correctamente criados e que os existentes eram actualizados.

Identifiquei outros candidatos para optimizar, tanto no meu código como no WordPress, mas os ganhos e o risco das “melhorias” interferirem com o funcionamento da plataforma dificilmente justificam o esforço de as pôr em prática.

No nosso projecto, o processo de importação dos 50 mil artigos passou a decorrer em cerca de duas horas sem estourar com os 128 MB de RAM alocados ao PHP. Só a importação inicial foi mais longa devido a todas as inserções.

E, no final, o WordPress mostrou-se à altura do desafio. O engenheiro é que teve de crescer um bocadinho.

3 pensamentos sobre “Coisas que aprendi ao importar 50 mil artigos para o WordPress

  1. bom pelo que eu pude entender é este artigo é um ficheiro de clincher ou seja um sistema de liberar ou fechar um produtos , bom eu espero que este artigo porça mim ajudar nas minha paginas que eu tenho ou que pretendo ter em com o meu publicos em e que possamos te uma relasão melhor com o elenco leitor do dia adias deste sistema que eu fico muito ausente na maioria do tempo, pois tenho tantos outras coisa prafaser que nentenho tempo para ler os artigos em quantos eu tenho uma paginas que pode ate ter muito publicos e sites que eu nem sei mais entra neles pois forãom os primeiros primeiros que fiz e nem sei como fiz pois eu era leigos e a inda sou no assunto mais agora comais esperiencias em amigos leitores blogeiros,um luta que pra mim ja si arrasta por muito tempo mais isso pode ter fim como eu espero que um dia os web sites se desenvolva automaticamente só com os dados inseridos no sistemas de dados de geração de sites em os desanis se desenvolva com um sistema digitas mais modernos do momentos, que já se encontra nos desenvolvimentos de desenvolvedores de sites existente no mundo, pois sei que fez este site mais ou menos 6 mes e não entedir nada anida mais com o passar de tempo adquirirei o conhecimento pois é isso que eu espero em pessoal com o WordPress.com espero que um dia porça ter um retornos de meus envertimentos que já fiz com este sistema ok

Leave a Reply