Sirviendo 1.000 millones de peticiones a la semana con Symfony

Este caso de estudio ha sido escrito por Antoni Orfin, co-fundador y arquitecto de software en Octivi y ha sido traducido con su permiso. También puedes leer el artículo original en inglés: Push it to the limits - Symfony2 for High Performance needs


Muchos programadores piensan que utilizar un framework completo como Symfony2 equivale a crear sitios web lentos. En Octivi creemos que lo importante es elegir la mejor herramienta para cada proyecto.

Cuando uno de nuestros clientes nos pidió que optimizáramos uno de sus sitios web, lo primero que hicimos fue analizar con detalle su situación actual. Como resultado de este dianóstico decidimos migrar a una arquitectura orientada a servicios (en inglés, SOA).

En este caso de estudio desvelaremos algunos detalles de la arquitectura de esta aplicación Symfony2 que sirve 1.000 millones de peticiones cada semana. Primero mostraremos la arquitectura de alto nivel y después nos centraremos en las características de Symfony2 que más nos gustan. Y no te preocupes que también hablaremos de lo que no nos gusta y de lo que no utilizamos.

A continuación mostramos algunas cifras que describen bien la escala de la aplicación:

  • La aplicación en su conjunto sirve 1.000 millones de peticiones cada semana.
  • Las instancias de Symfony2 sirven 700 peticiones por segundo, con un tiempo de respuesta medio de tan sólo 30 milisegundos.
  • Varnish sirve hasta 12.000 peticiones por segundo en nuestras pruebas de resistencia (stress tests).
  • Redis almacena 160 millones de registros y MySQL más de 300 millones.

Requerimientos de negocio

La aplicación a desarrollar debía cumplir con los dos siguientes requisitos impuestos por los responsables de negocio:

  • Confiabilidad - el sistema debe ofrecer alta disponibilidad, ya que los tiempos de caída deben ser prácticamente inexistentes.
  • Rendimiento - el sistema anterior tenía algunos problemas de rendimiento, por lo que el nuevo sistema debía solventar todos esos problemas y estar preparado para soportar sin problemas el crecimiento de clientes.

La importancia de elegir las mejores herramientas

Algunos de los programadores que están leyendo este caso de estudio puede que se pregunten por qué elegimos Symfony2 en vez de otras tecnologías como por ejemplo NodeJS, que también funciona muy bien para crear APIs muy rápidas.

En realidad se trata de una decisión condicionada por el negocio. La plataforma actual del cliente está creada con PHP y tienen muchos programadores PHP expertos en plantilla. Desarrollar un sistema con NodeJS hubiera supuesto un montón de problemas de mantenimiento, además de una gran inversión para contratar nuevos programadores. Así que elegir Symfony2 supuso una reducción drástica en los costes de desarrollo y mantenimiento del sistema.

No obstante, para confirmar que el rendimiento del sistema basado en Symfony fuera suficiente, tuvimos que desarrollar una prueba de concepto. Estas pruebas confirmaron que la elección había sido correcta.

La arquitectura de la aplicación

El flujo de la petición en la arquitectura orientada a servicios

El sistema está estructurado en diferentes servicios web que se encargan de las peticiones REST, de las API y de la aplicación Symfony2.

  1. Frontend - cuando el usuario final visita el sitio web, este se comunica internamente con un servicio web, tal y como se describe en este artículo.
  2. Servicio web - se encargan de la lógica de negocio del sistema.
  3. Almacenamiento - sólo los servicios web acceden a los sistemas que almacenan la información.

Logical Architecture

(pincha en la imagen para verla más grande)

Arquitectura física de la aplicación

La capa de Symfony2 utiliza varios servidores en una configuración redundante para mantener la alta disponibilidad y para garantizar un gran rendimiento:

  1. Todas las peticiones se gestionan a través de un balanceador de carga que utiliza HAProxy para distribuir las peticiones entre varios proxys inversos de tipo Varnish.
  2. Varnish lo utilizamos únicamente como una capa de caché, no como un balanceador de carga. Cada servidor de la aplicación Symfony2 cuenta con su propia instancia de Varnish. De esta manera evitamos caer en el problema del SPOF (Single Point of Failure) o "Punto Único de fallo". En este caso estamos primando la alta disponibilidad sobre el rendimiento, ya que no tenemos problemas de rendimiento (cada instancia de Varnish soporta hasta 12.000 peticiones por segundo).
  3. La última pieza de este puzzle es un servidor Apache2 con una aplicación Symfony2. Utilizamos PHP 5.4 a través de PHP-FPM y también hacemos uso de APC. Esta capa es capaz de servir 700 peticiones por segundo.

Almacenando información para sistemas de alto rendimiento

Obviamente todas las aplicaciones web necesitan un tipo u otro de almacenamiento de información. Nuestro sistema hace uso de:

  1. Redis - sistema de almacenamiento en memoria extremadamente rápido. Se trata de una especia de Memcache mejorado, ya que soporta persistencia, alta disponibilidad, etc. Almacenamos 160 millones de registros y persistimos el 98% de ellos, por lo que no perdemos información aunque el servidor tenga problemas. Atacamos a Redis a un ritmo de 15.000 hits por segundo y la integración la realizamos con el bundle SncRedisBundle y la librería Predis.
  2. MySQL - se trata de la base de datos relacional más famosa del mundo. Nosotros la usamos como un último nivel de caché en el que almacenamos 300 millones de registros, que equivalen a unos 400 GB de datos. Para conectarnos a la base de datos utilizamos Doctrine DBAL.

Así que como puedes ver, estamos haciendo un uso curioso de la persistencia: Redis es nuestro principal sistema de almacenamiento de información y MySQL es simplemente un último nivel de caché.

Las cosas que más nos gustan de Symfony2

La estructura de proyectos basada en bundles

Symfony2 es muy flexible y no te obliga a usar ninguna estructura para tu proyecto. En la práctica las aplicaciones Symfony se dividen en bundles, que contienen el código relacionado con Symfony, y otras partes, que contienen el resto del código genérico que no tiene nada que ver con Symfony.

Aplicando esta filosofía dividimos el proyecto en varios bundles que contienen cada una de las partes lógicas en las que se divide la aplicación. Como apenas modificamos la estructura de la edición estándar de Symfony2, cualquier programador que se incorpore al proyecto y tenga cierta experiencia con Symfony2 puede empezar a contribuir desde el primer minuto.

Extendiendo el código con el componente EventDispatcher

¿Necesitas cambiar el formato de la respuesta de todos los controladores de tu aplicación? ¡Es facilísimo! Simplemente crea un nuevo ResponseFormatListener y haz que escuche el evento kernel.response.

En el pasado hemos visto tanto código espagueti en tantos proyectos, que nos encanta que se hayan popularizado conceptos como los eventos de Symfony. Aunque no es algo nuevo en el mundo del software (simplemente se trata del patrón Observer) sí que es algo que no se utilizaba en el código legacy de PHP.

Además de los eventos de Symfony2, decidimos utilizar nuestros propios eventos. Los event listeners hacen que tu código sea bastante limpio, ya que los métodos solamente notifican sus eventos y otras partes del código pueden conectarse a ese código, todo ello sin hacer cambios en el código original.

Como el rendimiento es tan importante en este proyecto, también evaluamos el impacto de los eventos en el rendimiento. La conclusión es que apenas afectan al rendimiento, ya que internamente no es más que un array que almacena las instancias de los event listeners.

Obteniendo la información de la petición con el componente OptionsResolver

Al diseñar la aplicación también buscamos la manera más eficiente de obtener y validar la información de la petición. Nuestro objetivo era transformar la petición de Symfony en un objeto de tipo DTO (Data Transfer Object). De esta manera no trabajamos con arrays asociativos, que son muy difíciles de mantener, y seguimos la filosofía de Symfony de utilizar el objeto Request.

Básicamente lo que hacemos en la aplicación es utilizar la query string para pasar las consultas. Con Symfony2 podríamos tratar esta query string de muchas formas.

Podríamos utilizar el componente de formularios, crear la estructura de la petición como un nuevo form type y pasarle el objeto Request. Esta solución funcionaría bien y proporcionaría muchas funcionalidades, pero penalizaría muchísimo el rendimiento. Además, no necesitamos funcionalidades avanzadas como los campos anidados.

Otra solución podría consistir en pasar la información en una estructura JSON dentro del cuerpo de la petición. Utilizando un serializador (ya sea el JMSSerializer o el componente Serializer de Symfony) y validar después los objetos DTO creados con esta información. En cualquier caso, esta solución también tendría problemas de rendimiento por la serialización y la validación de la información.

Después de pensarlo un poco, nos dimos cuenta de que en realidad no necesitamos ninguna validación avanzada (simplemente necesitamos comprobar si se han incluido algunas opciones obligatorias y tenemos que validar el formato de alguna otra opción).

Como además la estructura del objeto Request está diseñada de manera muy simple, decidimos utilizar el componente OptionsResolver. Como puede que sepas, este componente es el que utiliza internamente el componente Form para gestionar las opciones. Nuestra solución consiste en pasarle el array GET a ese componente y obtener como resultado un objeto validado y con la estructura deseada (en realidad se trata de una proceso de conversión de arrays en objetos DTO).

Una de las ventajas de validar la información de esta manera es que las excepciones incluyen mensajes muy detallados que facilitan mucho la depuración de errores y nos permiten crear mensajes de error muy buenos para la API.

Ejemplo de cómo gestionamos las peticiones

<?php
 
namespace Octivi\WebserviceBundle\Controller;
 
/**
 * @Route("/foo")
 */
class FooController extends BaseController
{
    /**
     * Ejemplos de peticiones:
     *  - OK:    api.com/foo/bars?type=simple&offset=10
     *  - OK:    api.com/foo/bars?type=simple
     *  - Error: api.com/foo/bars?offset=10
     *  - Error: api.com/foo/bars?limit=many
     *
     * @Route("/bars")
     */
    public function barsAction()
    {
        $request = new GetBarsRequest($this->getRequest()->query->all());
        $results = $this->get('main.foo')->getBars($request);
        return $results;
    }
}
 
<?php
 
namespace Octivi\WebserviceBundle\Request;
 
class GetBarsRequest extends AbstractRequest
{
    protected $type;
    protected $limit;
    protected $offset;
 
    public function __construct(array $options = array())
    {
        parent::__construct($options);
    }
 
    protected function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        parent::setDefaultOptions($resolver);
 
        $resolver->setRequired(array(
            'type',
        ));
 
        $resolver->setOptional(array(
            'limit',
            'offset'
        ));
 
        $resolver->setAllowedTypes(array(
            'limit' => array('numeric'),
            'offset' => array('numeric')
        ));
 
        $resolver->setDefaults(array(
            'limit' => 10,
            'offset' => 0
        ));
 
        $resolver->setAllowedValues(array(
            'type' => array('simple', 'extended'),
        ));    
    }
 
    // ...
}

Se trata de una solución muy sencilla, ¿verdad? Además, nos ofrece todo lo que estábamos buscando:

  • Validación básica con:
    • Algunos campos obligatorios
    • Algunos parámetros opcionales
    • Gestión de los tipos de datos (numeric) y de los valores permitidos
    • Valores por defecto para las opciones
  • Representación de la información en un objeto DTO

Incluye la configuración en el código mediante anotaciones

Has leído bien. Usamos anotaciones en una aplicación de alto rendimiento como esta. ¿Te parece extraño? ¡A nosotros no! ¡Nos encantan las anotaciones!

Las anotaciones de PHP son simplemente comentarios con un formato similar a PHPdoc. Durante el proceso de regeneración de la caché Symfony parsea las anotaciones y las convierte en archivos PHP normales y corrientes. De hecho, desde el punto de vista del rendimiento no importa si utilizas XML, YAML o anotaciones, ya que al final todo se convierte en código PHP.

En nuestra aplicación usamos todas las anotaciones que podemos:

  • Enrutamiento - como has podido ver en la acción barsAction del código de ejemplo anterior, definimos las rutas mediante la anotación @Route. En nuestra opinión es mejor tener esta configuración justo al lado del código del controlador, en vez de tener que rebuscar entre varios archivos de configuración YAML o XML. Para que funcionen las anotaciones solamente tienes que añadir una línea de configuración en el archivo app/config/routing.yml.
  • Servicios - gracias al bundle JMSDiExtraBundle no tenemos que preocuparnos de mantener archivos YAML con la definición del contenedor. En cualquier caso, la configuración de los servicios para las clases externas sí que la hacemos mediante archivos XML.

Ejemplo de un listener que utiliza anotaciones para su configuración:

<?php
 
namespace Octivi\WebserviceBundle\EventListener;
 
use JMS\DiExtraBundle\Annotation\Service;
use JMS\DiExtraBundle\Annotation\Observe;
use JMS\DiExtraBundle\Annotation\InjectParams;
use JMS\DiExtraBundle\Annotation\Inject;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
 
/**
 * @Service
 */
class FormatListener
{
    /**
     * Constructor uses JMSDiExtraBundle for dependencies injection.
     * 
     * @InjectParams({
     *      "em"         = @Inject("doctrine.orm.entity_manager"),
     *      "security"   = @Inject("security.context")
     * })
     */
    function __construct(EntityManager $em, SecurityContext $security) {
        $this->em = $em;
        $this->security = $security;
    }
 
    /**
     * @Observe("kernel.response", priority = 0)
     */
    public function onKernelResponse(FilterResponseEvent $event)
    {
        $this->em->...;
    }
}

Creando comandos de consola con el componente Console

Otro de los componentes Symfony que utilizamos mucho es el componente Console. De hecho, es el componente que más utilizamos porque casi todas las funcionalidades de la aplicación se implementan como comandos de consola.

En los frameworks PHP legacy y en aplicaciones PHP como WordPress o Magento, crear comandos de consola era realmente difícil. Como no existían componentes estándar o los que habían eran muy limitados, al final cada programador acababa desarrollando su propia solución.

Symfony2 por su parte dispone de un componente completísimo para crear todo tipo de comandos de consola. Puedes definir el nombre del comando, sus opciones, sus argumentos, etc. En nuestro caso también es muy útil poder definir la documentación dentro del propio comando, ya que así conseguimos crear código autodocumentado. Esto es especialmente útil en entornos ágiles donde se crean nuevas funcionalidades continuamente y donde la documentación en papel se queda desfasada muy rápido.

El componente Console lo utilizamos tanto para crear herramientas de administración como para correr procesos durante mucho tiempo. De hecho, uno de nuestros procesos estuvo corriendo 6 días seguidos. Una buena muestra de que este componente no tiene ninguna fuga de memoria.

$ php app/console octivi:test-command --help
Usage:
 octivi:test-command [-l|--limit[="..."]] [-o|--offset[="..."]] table
 
Arguments:
 table                 Database table to process
 
Options:
 --limit (-l)          Limit per SQL query. (default: 10)
 --offset (-o)         Offset for the first statement (default: 0)

Monitorizando la aplicación con Profiler, Stopwatch y Monolog

Para monitorizar continuamente el rendimiento de la aplicación usamos los componentes Profiler y Stopwatch. De esta manera es muy sencillo descubrir si un método está tardando más de lo previsto e incluso podemos descubrir accesos a información (Redis o MySQL) que son demasiado lentos.

Cuando desarrollas un sistema muy grande y con una arquitectura basada en servicios, es necesario generar muchos logs. Si no, será muy difícil descubrir la verdadera causa de los problemas, como por ejemplo servicios de terceros o métodos de la API.

Para ello usamos Monolog (configurado directamente en el archivo app/config/config_prod.yml). Cualquier cosa fuera de lo normal se loguea en el archivo prod.log y el resto de logs los guardamos en otros archivos mediante el uso de otros canales.

En la aplicación no usamos el handler FingresCrossed, así que siempre añadimos tanta información de contexto como podemos en cada línea de log. De esta manera, las llamadas a los servicios web son más o menos así:

$exception = null;
 
try {
    $response = $this->request($request);
} catch (\Exception $e) {
    $exception = $e;
}
 
if (null !== $exception) {
    $this->logger->error('Error response from XXX', array(
        'request'   => $request,
        'response'  => $response,
        'exception' => (string) $exception,
    ));
 
    throw $exception;
} else {
    $this->logger->debug('Success response from XXX', array(
        'request'  => $request,
        'response' => $response,
    ));
}

Las cosas de Symfony2 que no utilizamos

Doctrine

En nuestro caso utilizamos MySQL simplemente como una capa adicional de caché en la que almacenamos la información serializada en registros de tipo BLOB. Así que no utilizamos Doctrine porque nos penalizaría mucho el rendimiento. Lo que sí que utilizamos es Doctrine DBAL para obtener arrays asociativos con el resultado de las consultas y tenemos una capa de modelo que se encarga de serializar y normalizar la información.

En realidad nuestro problema con Doctrine no tiene que ver con el volumen de información sino con el rendimiento que ofrece al obtener un único registro. Los ORM añaden una penalización muy importante durante el proceso de hidratación / deserialización de la información. Si no te importa esta penalización, puedes utilizar Doctrine tanto si tienes 1.000 registros como si tienes 300 millones.

Twig

La aplicación solamente devuelve respuestas en formato JSON, así que no necesitamos el sistema de plantillas Twig. Obviamente sí que utilizamos Twig para generar el panel de control de administración del servicio.

Resumen

Symfony2 se puede utilizar con éxito en muchos tipos de proyectos diferentes. El secreto está en combinar de la mejor manera posible los componentes que te ofrece Symfony de manera que se ajusten al problema que debes resolver. El resultado será una aplicación con un código sencillo, muy fácil de mantener y con un altísimo rendimiento.

 ¿Quién ha desarrollado esta aplicación?

El sistema descrito en este caso de estudio ha sido desarrollado por Octivi, una empresa polaca que se especializa en crear sistemas escalables, de gran rendimiento y con alta disponibilidad.

Comentarios

  1. Brillante.

    Muy positiva la aportación de este ejemplo. Un caso real, con unas necesidades reales y con un resultado real. Esto es lo que necesitamos (sin perder de vista, claro, que no deja de ser un ejemplo específico. No significa que esta configuración sea útil para todo el mundo...).

    Respecto lo de las anotaciones, que es supongo la parte que más me ha sorprendido, es un ejemplo más que es una opción a considerar en ciertos casos cuando sabes que estás firmando un contrato con el framework, como es el caso.

    Felicidades por el post y por el ejemplo :)

    Marc Morera el 4 de agosto de 2014, 11:07:23

  2. Artículos como esto son la evidencia de que, al contrario de como muchos piensan, se pueden desarrollar aplicaciones de alto rendimiento con Symfony. Esta discusión ya la he tenido en varias ocasiones con algunos detractores de Symfony, quienes me han argumentado que es un framework "muy pesado". Gracias por compartir esta experiencia real que en lo adelante empuñaré como arma para defenderme de todos aquellos que quieran atacar mi framework preferido, jajaja.

    Jesús Damián García Pérez el 4 de agosto de 2014, 14:44:17

  3. Excelente artículo, estoy convencido que trabajar con un framework es la mejor opción a la hora de llevar adelante un proyecto. Symfony, además de ser seguro y estable brinda la integración de manera sencilla con otras tecnologías. Estaría investigando sobre Redis y Varnish para incluir a mis proyectos, de acuerdo al articulo se puede mejorar el rendimiento de las aplicaciones.

    Luis Fernando Vera Ortíz el 4 de agosto de 2014, 15:25:45

  4. Marc estoy muy de acuerdo con tu comentario. Cada vez estoy más convencido de que hay que desacoplar al 100% tu lógica de negocio, pero que tienes que acoplarte al 100% en todo lo demás (controladores, enrutamiento, etc.) para sacar el máximo partido al framework que utilices, sea Symfony o cualquier otro.

    Jesús me alegra que este artículo te sirva como base para argumentar que Symfony2 se puede utilizar en aplicaciones de alto rendimiento.

    Luis, integrar Redis es bastante sencillo y aprender Redis no debe ser muy difícil. Sin embargo, instalar y configurar bien Varnish para aplicaciones complejas no debe ser nada fácil.

    Javier Eguiluz el 4 de agosto de 2014, 16:12:12

  5. Aunque está muy bien la parte en cómo explican de qué manera han usado Symfony, lo que les permite llegar a esas 700 req/s en cada frontal es la combinación de Redis + Varnish. De otro modo, me mostraría mucho más incrédulo. Mi sensación es que el framework que se use ahí tiene poco que ver. Para mí, si se quiere performance mejor centrarse en arquitectura y hierro, si se quiere mantenibilidad y productividad lo mejor es invertir tiempo en escoger un buen framework (cómo es el caso de Symfony).

    A nivel de arquitectura, una de las cosas que más me ha sorprendido es la de usar MySQL cómo cache de segundo nivel. Tengo la impresión de que ahí podrían haber herramientas que pudieran aportar mayor performance, aunque el artículo entra poco en detalle así que es imposible de hacerse una idea de la decisión que hay detrás. Hubiera estado muy bien profundizar en esa parte.

    Otra cosa que hecho en falta quizá, es haber entrado más al detalle sobre la orientación a servicios que han implementado. Cómo han hecho la división de cada servicio, qué criterio han aplicado, cómo han estructurado los equipos y si han experimentado un impacto relevante en la productividad al pasar de un modelo monolítico a uno SOA.

    Por lo demás, sobretodo la parte de Symfony igual hay cosas en las que quizá disiento más que otras. Pero en general, están en la linea.

    Enhorabuena por el post!

    Christian Soronellas el 4 de agosto de 2014, 17:07:25

  6. Varias cosas:

    • creo que básicamente se trata en su mayor parte de un sitio de "solo lectura"
    • Twig se "come" rápidamente el 50% del tiempo de cualquier request, así que eliminarlo es un win a nivel de performance (aquí me queda la duda de si montan el HTML a lo vieja escuela o si es que sólo están hablando de la performance del webservice)
    • no me sorprende que utilicen MySQL en segundo nivel: por un lado tienes ACID pero si la performance es importante siempre puedes utilizar el interfaz NoSQL (al menos en las últimas versiones).
    • me ha extrañado bastante lo de "[...] since most of the new features for the application come as CLI commands."

    Sergi de Pablos el 4 de agosto de 2014, 20:03:47

  7. Muy bueno esto he visto con mis propias manos la versatilidad de symfony2 y sus componentes desacoplados que brindan a nosotros los programadores la facilidad de minimizar costos reutilizando codigo, además que si colaboras puedes mostrar a la comunidad tus componentes y así corroborar esto del Open Source...Me encanta ver casos de estudio relacionado con el ecositema de symfony2.

    Maikel Suárez Corrales el 4 de agosto de 2014, 20:47:19

  8. Muy buen post gracias!

    Miguel Garcia el 5 de agosto de 2014, 0:18:43

  9. Como dice Christian Soronellas, aquí lo que permite estos números en rendimiento es la arquitectura Varnis + Redis. Y lo que va a permitir al proyecto ser fácil de mantener, y de seguir evolucionando a buen ritmo y con calidad es Symfony.

    La lección es que los problemas en rendimiento casi siempre son problemas en la arquitectura. Tu framework va a tener mas impacto en otros puntos del proyecto que en rendimiento. Sino que les pregunten a la gente de Social Point o de YouPorn que manejan unos números increíbles y también son Symfony 2.

    Daniel Gallardo el 6 de agosto de 2014, 12:54:03

Este artículo ya no permite añadir más comentarios.
¿Por qué? Los artículos cierran sus comentarios automáticamente unos meses después de su publicación para asegurar que estos sigan siendo relevantes.

Publicada el

4 de agosto de 2014

Proyectos Symfony destacados

La plataforma de eCommerce 100% Symfony que rivaliza con Magento y PrestaShop. Ver más

Síguenos en @symfony_es para acceder a las últimas noticias.