Optimizando una Webapp sin morir en el intento

Santi Ruiz
Codenares
Published in
7 min readApr 30, 2017

La eterna lucha de un desarrollador de backend se encuentra siempre en el ámbito de la eficiencia.

En los primeros meses de vida de una aplicación, el buen desarrollador consigue optimizar tanto código como procesos para que sean lo más eficientes posible en los tres ámbitos principales:

  • Consultas de base de datos
  • Uso de memoria
  • Carga de CPU de la instancia o servidor

Pero según avanza el tiempo de vida, me temo que todos terminamos descargando unos a costa de otros:

  • Reducimos las consultas sobre cargando la memoria
  • Particionamos procesos para no llegar a hacer swap en memoria pero incrementamos el uso de CPU o llamadas a servicios externos (de caché por ejemplo) de forma excesiva

Repasemos los puntos clave para, al menos, hacer lo básico en cada fase.

Los primeros pasos: diseño, lógica de negocio y eficiencia técnica en sincronía

Como se suele decir: lo que bien empieza, bien acaba. Y me atrevería a decir que 9 de cada 10 proyectos fracasan en este punto. Igual que a la parte técnica se le exige que respete el diseño y la lógica de negocio, estos apartados deberían respetar al técnico. Una aplicación en sus primeros pasos no suele sufrir sobre carga alguna. Y esto suele dar la falsa impresión de que todo vale y que los cuellos de botella se podrán aliviar sobre la marcha. Y esto no es verdad: la falta de base suele provocar a la larga una tormenta perfecta que hace colapsar todo el sistema.

Claves para este apartado:

  • Lógica de negocio de una sola dirección: Una aplicación debe tener claro su cometido, y que su flujo de uso sea, idealmente, de una sola dirección. Replicar funcionalidades en distintas secciones o “que se pueda hacer lo mismo en varios sitios” no sólo provoca problemas de mantenimiento a corto plazo sino problema de eficiencia y carga de datos.
  • Secciones monolíticas: Cada sección debe tratar de un ámbito concreto del negocio y no mezclar infinidad de tipos de datos. Se suele caer muchas veces en el “meter todo ahí para que se ve a simple vista”. Por compleja que sea la aplicación, para ver un tipo de dato deberías de tener que ir “siempre” a su sección.

Afinando la puntería: N+1 consultas

Hemos visto que no es recomendable que una sección mezcle datos de diferentes modelos, pero en ocasiones es inevitable. Esto provoca en la mayoría de casos la necesidad de tener que anidar consultas en iteraciones para completar listados o reportes.

Voy a poner un ejemplo de la lógica de negocio que tenemos en Timp.pro, mi empresa actual, que es lo que tengo más fresco. En ella usamos Ruby on Rails para el backend. Veamos el caso: un centro (Center) vende (Purchase) bonos (Voucher) a sus clientes, y el gestor suele necesitar listar reportes de ventas.

Pongamos que el gestor quiere analizar el rendimiento de cada uno de los bonos que oferta, listando el número de ventas que tiene cada uno:

@center.vouchers.each do |voucher|
= voucher.name
= voucher.purchases.count
end

Este listado hará una consulta para cargar todos los bonos del centro:

SELECT * FROM vouchers WHERE vouchers.center_id = ?

Y para cada uno de ellos hará otra consulta para calcular el número de ventas que ha tenido cada uno:

SELECT COUNT(*) FROM purchases WHERE purchases.voucher_id = ?

Como se ve, aquí tenemos un caso claro de N+1 consultas, algo que provocará serios problemas de rendimiento en la base de datos según crezca el parqué de clientes.

La solución oficial: precargar los anidados

Rails ofrece de serie una solución para este caso: el include de Active Record. La solución no pondrá ser más sencilla:

@center.vouchers.include(:purchases).each do |voucher|
= voucher.name
= voucher.purchases.count
end

Esto realizará primero la consulta para los bonos:

SELECT * FROM vouchers WHERE vouchers.center_id = ?

Y con los bonos cargados una única consulta para cargar todas las ventas:

SELECT * FROM purchases WHERE purchases.voucher_id IN ?

Problema de N+1 consultas solucionado. Válido para una gran mayoría de casos, pero no para un caso como Timp.pro, y seguramente tampoco para el tuyo.

¿Dónde está la pega? Posiblemente en el primer mes de vida de un centro sólo se hagan unos cientos de ventas. Pero según el centro tenga recorrido acumulará miles y mires de ventas, y estamos haciendo a la base de datos recopilarlas todas y transferirlas al servidor, donde se instanciarán y provocarán los tristemente habituales problemas de picos de memoria.

El problema de la memoria Swap: las consultas N+1 son malas, pero los picos de memoria son aun peor

Un negocio de éxito suele tener miles de solicitudes por minuto. Esto hace que unas decenas de milisegundos por solicitud puedan marcar definitivamente la diferencia. Cuando una solicitud consume memoria en exceso, esto afecta de forma notoria no sólo a las que están sucediendo concurrentemente, sino a las que vengan después durante varios y, para tí, larguísimos minutos.

¿A qué se debe? Para empezar, el sistema de recogida de basura de un sistema operativo que está siendo atacado por miles de solicitudes por minuto no puede actuar tras cada solicitud. Sería otro problema de eficiencia a añadir a la lista. Por ello, si las consultas no son suficientemente homogéneas la memoria utilizada por cada solicitud no será recuperable durante una buena fracción de tiempo e inevitablemnete el sistema tendrá que recurrir a la memoria swap (que es mucho más lenta).

Para resolver nuestro problema debemos evitar recurrir al uso de include de Active Record y usar un enfoque más creativo y que atenta contra la normalización de la base de datos: cachés de registros.

Evitando usar memoria swap: caché de registros

Ruby on Rails viene nuevamente al rescate con una solución genérica que para muchos casos será definitiva: el counter_cache a nivel de modelo.

Para nuestro ejemplo, bastará con añadir a la tabla de Voucher un atributo purchases_count de tipo entero y modificar el modelo Purchase:

class Purchase < ActiveRecord::Base
belongs_to :voucher, counter_cache: true
end

Cada vez que una compra sea creada, incrementará el contador en la tabla Voucher.

Para el caso que nos ocupa, es una solución definitiva sin lugar a dudas:

@center.vouchers.each do |voucher|
= voucher.name
= voucher.purchases_count
end

Sólo tenemos una consulta a base de datos para todo el proceso e instanciamos sólo lo necesario.

Pero nuevamente, según avanza nuestro negocio, los casos se vuelven más complejos. Veamos algunos.

Llegando más lejos: terrenos propios de tu negocio

Cuando llegamos a terreno propio hasta las búsquedas en Stack Overflow nos hacen sentir en terreno inexplorado. No suele ser la realidad, pero es que se hace complejo encontrar artículos que hablen de nuestros problemas en mismos términos.

Siguiendo en la línea de nuestro ejemplo, es obvio pensar que el gestor de nuestro centro no va a querer conocer valores de venta absolutos durante mucho tiempo: querrá segmentar por fechas. Y, lamentablemente, esto se va convirtiendo poco a poco en una aplicación tan de tiempo real, que las cachés se vuelven totalmente inútiles.

Buscando soluciones oficiales podemos encontrar cachés de vista, particionado de tablas o hasta abatimientos de métricas en tablas de base de datos hechas a medida para tus clientes

¿Cachés de vista?

En mi experiencia son sólo útiles para tiendas, blogs y similares. Contenido que “todo el mundo ve igual siempre y en todo momento”. En otro caso no recomiendo utilizarlas jamás. El enfoque de “las muñecas rusas” de Rails es muy bueno para fragmentos concretos: úsalas en parciales para renderizar contenido concreto e invariable.

¿Por qué no sirve en nuestro ejemplo? Una venta puede producirse en cualquier momento, y el gestor querrá un reporte de un intervalo de tiempo indeterminado. Preparar cachés para miles de clientes y miles de situaciones probables se plantea irrelevante.

Particionado de tablas / vistas de base de datos

Podríamos prepara la base de datos de forma que particione las ventas por fechas y segmente los datos de forma que se nos presenten más inmediatos y fáciles de recoger.

¿Por qué no sirve en nuestro ejemplo? Una aplicación con mucho tráfico y cambios constantes haría que la base de datos estuviese reconstruyéndose constantemente. Esto, nuevamente movería el problema de un lado para dejarlo en otro (en este caso el afectado sería el servidor de base de datos).

Abatimientos o un modelo de métricas

Preparar un modelo de métricas, que se reconstruyan regularmente y ofrezca métricas precalculadas a los clientes es un buen enfoque. El cliente tendría gráficas muy resultonas y llamativas.

¿Por qué no sirve en nuestro ejemplo? Por ser poco manejables. El cliente no podría llegar a sacar reportes para su caso. Hay centros que necesitarán métricas diarias, otros segmentadas por grupos de bonos según oferta. Además llegar a ofrecer datos en tiempo real sería complejo.

La solución: ser creativos y saber adaptarnos

Para este caso, la solución a la que he llegado es hacer una consulta a medida. Supongamos que el cliente elige un arqueo de fechas que tenemos en la siguiente variable:

@dates = (‘2017–01–01’..’2017–12–31')

Guardamos los bonos:

@vouchers = @center.vouchers

Cargamos la estadística de ventas:

@purchases_count = Purchase.where(voucher: @vouchers, created_at: @dates).group(:voucher_id).count

Y listamos los resultados:

@center.vouchers.each do |voucher|
= voucher.name
= @purchases_count[voucher.id]
end

La consulta al hacer un group by nos viene segmentada por voucher_id en un hash muy fácilmente consultable. Con esta solución volvemos a resolver todo en dos consultas a base de datos con un uso de memoria ínfimo.

Conclusiones

Según he ido enfrentándome a situaciones como las que aquí describo, he ido aprendiendo que una buena base es esencial. Esa base puede seguir al pie de la letra las convecciones que todos hemos estudiado: sobre todo en cuanto a formas normales de base de datos se refiere.

Pero según avanza un negocio, creo de verdad que hace falta ser creativo y decidir dónde romper con lo convenido para sacar adelante un requisito.

Newrelic es la herramienta más famosa para medir estos problemas, pero aprovecho para recomendar otras dos con las que no sólo he medido, sino que he aprendido.

La primera que me hizo cambiar fue Skylight. Fue la primera vez que un monitor quitaba de mi vista “todo” para centrarse en lo importante. Me ayudó a aprender ya que se centraba en las llamadas problemáticas, destacándolas cuando se comportaban de forma irregular y extrayendo posibles causas. El primer monitor que trató las consultas N+1 de forma manejable para mí.

Pero a día de hoy hay otra herramienta que ha sido capaz de sustituir tanto a Newrelic como Skylight. Es Scout, y se ha ganado la exclusividad al ayudarme a comprender cómo funcionan los picos de memoria gracias a que es capaz de extraer tanto las llamadas que los provocan como el código que las origina. Sin olvidar detalles como llamadas a servicios externos o consultas N+1.

--

--