Etiquetas
Para ir al grano y que no perdáis el tiempo los que no estéis interesados en esta frikada de optimización de consultas a base de datos [MySQL]: el código siguiente es el código SQL que selecciona y obtiene los datos de los enlaces que aparece en la portada del Menéame.
SELECT link_id AS id, link_author AS author, link_blog AS blog, /* muchas columnas adicionales del objeto */ FROM links INNER JOIN users ON (user_id = link_author) LEFT JOIN (categories AS cat, categories AS meta) ON (cat.category_id = links.link_category AND meta.category_id = cat.category_parent) LEFT JOIN votes ON (link_date > @enabled_votes AND vote_type='links' AND vote_link_id = links.link_id AND vote_user_id = @user_id AND ( @user_id > 0 OR vote_ip_int = @ip_int ) ) LEFT JOIN favorites ON (@user_id > 0 AND favorite_user_id = @user_id and favorite_type = 'link' AND favorite_link_id = links.link_id) LEFT JOIN link_clicks AS clicks ON (clicks.id = links.link_id) INNER JOIN (SELECT link_id FROM links $from WHERE $where $order_by LIMIT $offset,$page_size) AS id USING (link_id)
Nota: Las variables que comienzan con $, como $from o $where son variables asignadas desde el PHP para definir las condiciones (publicadas o pendientes, ordenadas por fecha o por karma, etc). Las que comienzan con @, como @user_id o @ip_int, son variables de sesión del MySQL.
La pregunta es ¿por qué tan complicado? ¿por qué esos LEFT e INNER JOIN en vez de consultas más simples? Por eficiencia, y también comodidad, como intentaré explicar en este apunte. Creo que sería muy complicado encontrar una consulta más eficiente que la anterior para la base de datos del Menéame. Pero no ha sido una idea que se me ocurrió de un día para otro, ni siquiera en semanas. Fue la evolución y el resultado de 5 años de experiencia directa, a veces dolorsa, y de aprender muchas cosas en el proceso. Experimenté en carne propia la carencia que tenemos los programadores sobre la arquitectura y modo de trabajo de las bases de datos. Es un mundo completamente diferente, se necesitan miles de horas para dominarlo mínimamente [1], aunque tengas muchos años de experiencia como programador.
[1] Y así acabamos matando mosquitos a mosquetazos, i.e. recurriendo a soluciones «NoSql» sin siquiera darnos cuenta que mejor es la escopeta de al lado, que está bien probada.
Estructura de un enlace
Vamos a ver cuál es la estructura básica de un enlace:
Además de la tabla links (línea 1-2) (tiene casi un millón de filas), para poder mostrar todos los datos hacen falta datos de otras 5 tablas:
- Usuario (azul) de la tabla users (línea 3). Es obligatorio (i.e. clave foránea). La tabla tiene más de 200.000 filas.
- Metacategoría (naranja) y categoría (marrón) (línea 4). En la tabla links se almacena la categoría obtenida (en este caso «política») de la tabla categories. De la misma tabla se obtiene la metacategoría (actualidad). La categoría es obligatoria, salvo cuando el enlace está en proceso de edición inicial (cuando está siendo creado por el usuario). La tabla es pequeña, unas pocas decenas (pueden verse todas las entradas si se espande la lista en el signo «+» al lado de la palabra «todas» en la parte superior de la portada de Menéame).
- Voto del usuario actual (rojo) (línea 5). Se obtiene de la tabla votes, que tiene un índice compuesto: tipo, id de link, usuario [0 para anónimos, valor del voto [positivo o negativo] e IP desde la que se votó (almacenada en entero). El voto puede existir o no, para la gran mayoría de accesos no existe el voto (hay más o menos un millón de páginas impresas por día, que significa se muestran los datos de unos 10-12 millones de enlaces, pero sólo hay unos 50.000 votos a enlaces diarios). Los votos totales llegan a casi 100 millones, pero diariamente se «sumariza» esta tabla para mantenerla en unos 4 millones de filas ya que tiene varios «covering indices» que ocupan más espacio que los datos.
- Favorito (amarillo), de la tabla favorites (línea 6), indica si el usuario actual marcó esa noticia como favorita. Esta tabla tiene algo más de 300.000 filas.
- Contador de clics (verde) al enlace, de la tabla link_clicks (línea 7). No todos los enlaces tienen una entrada correspondiente de clics, bien porque acaba de ser enviado y nadie ha hecho clic todavía, o porque es un enlace antiguo, cuando no existía todavía el contador.
Supongo que muchos ya os habéis dado cuenta más o menos las optimizaciones que se hicieron, pero falta por aclarar la necesidad (y ventaja) de usar las variables de sesión, y el porqué del INNER JOIN (línea 8) junto con ese sub-select. Ya entenderéis las razones más adelante.
Object-Relational Mapping
La arquitectura de Menéame comenzó como un Active Record (una tabla por objeto), luego evolucionó a algo más similar al patrón Object Relational Mapping para sus clases más importantes Link, Comment, Post, User, etc. Cada una de estas clases implementa método de instancia read() y store() para leer y almacenar los datos de una instancia en la base de datos.
El acceso a la base de datos se hace a través de una pequeña (y muy eficiente) capa de abstracción que oculta detalles de la interfaz PHP con MySQL y sobre todo porque facilita mucho la programación ya que permite trabajar siempre con objetos, arrays y diccionarios (además de los más modernos iteradores, como veremos más adelante).
En las primeras versiones del Menéame en el read() se implementaba un SQL bastante obvio y sencillo para consultar a la base de datos. Por ejemplo el read() de la clase Link de hace unos 5 años (sí, ahora me avergüenzo de mi propio código, supongo que eso es bueno, aprendí mucho en estos años):
function read($key='id') { global $db, $current_user; switch ($key) { case 'id': $cond = "link_id = $this->id"; // Otros case default: $cond = "link_id = $this->id"; } if(($link = $db->get_row("SELECT links.*, UNIX_TIMESTAMP(link_date) as link_ts, UNIX_TIMESTAMP(link_sent_date) as sent_ts, UNIX_TIMESTAMP(link_published_date) as published_ts, UNIX_TIMESTAMP(link_modified) as modified_ts, users.user_login, users.user_email, users.user_avatar, users.user_karma, users.user_level, users.user_adcode FROM links, users WHERE $cond AND user_id=link_author"))) { $this->id=$link->link_id; $this->author=$link->link_author; $this->username=$link->user_login; // Asignación de otros campos del objeto if ($this->category > 0) { $meta_info = $db->get_row("SELECT categories.category_name, categories.category_uri, meta.category_name as meta_name, meta.category_uri as meta_uri FROM categories, categories as meta WHERE categories.category_id = $this->category AND meta.category_id = categories.category_parent"); $this->category_name=$meta_info->category_name; $this->meta_name=$meta_info->meta_name; $this->meta_uri=$meta_info->meta_uri; } $this->read = true; return true; } $this->read = false; return false; }
Es evidente en el código anterior uno de los problemas: se hacen dos consultas diferentes (línea 10 y 16) a la base de datos para leer los datos (todavía incompletos) de una instancia de la clase Link. Nunca es buena idea hacer diferentes consultas, no sólo por la latencia de la red (el servidor de base de datos es remoto), sino también porque el parser y optimizador se tienen que ejecutar por cada consulta, y luego hay que pasar otra vez por la capa de control de concurrencia y transacciones. Optimizar esta parte fue sencillo, fue simplemente agregar un LEFT JOIN combinado con un INNER JOIN idéntico al de la línea 4 del SQL del primer código:
Pero eso no acaba allí. Hay todavía muchas más consultas adicionales que ahora ya no existen. El siguiente es el pseudocódigo (en realidad en Python, para aprovechar el resaltado de sintaxis de WordPress) para generar la portada de menéame (15 enlaces por página):
links = db.get_column("SELECT link_id FROM links WHERE link_status = 'published' ORDER BY link_date DESC LIMIT Y,Y") for id in links: link = new Link link.read(id) link.print_summary()
Primero se obtienen en un array los IDs de los enlaces a mostrar, y luego en un bucle se lee cada enlace y se muestra sus datos (el que se ve en la captura de pantalla). La llamada a la función read() agrega una consulta adicional por cada enlace que se muestra (ya llevamos 15 más). Aún es peor, la función print_summary() tiene que verificar si el usuario había votado a la noticia (incluso si es usuario anónimo no registrado), si el usuario lo marcó como favorito, etc. Es decir, unas cuantas consultas más por cada enlace mostrado, lo que significa casi 80 consultas para la página principal, y sólo para información de los enlaces.
El MySQL no tenía demasiado problemas, teníamos una media de 1.500 consultas/segundo (unas 5.000 a 10.000 por segundo en horarios pico) con una consumo máximo de CPU del 40%. Pero empezamos a notar una latencia importante cuando se mostraba una noticia con un número importante de comentarios (se muestran has 100 comentarios por página). Esto ocurría porque la estructura era muy similar, para la página de comentarios se ejecutaba el siguiente código para mostrar los comentarios:
comments = db.get_column("SELECT comment_id FROM comments WHERE comment_link_id = LINK_ID ORDER BY comment_order ASC LIMIT X,Y") for id in comments: comment = new Comment comment.read(id) comment.print_summary()
De nuevo, la función print_summary() tiene que hacer dos consultas adicionales (voto del usuario y si está marcado como favorito). Con 100 comentarios significaban 300 consultas… para mostrar sólo los comentarios. Era un problema que había que solucionar, y la solución es exactamente igual a la de los enlaces.
Convertir a una sóla consulta por instancia
La idea fue hacer una función de clase «factory» que unificase todas las consultas en una sólo, com los LEFT JOINS. Así definí una constante de clase con el SQL básico para recuperar los datos de la instancia (veréis que el SQL mostrado es el mismo que el mostrado en el primer SQL de este apunte):
// sql fields to build an object from mysql const SQL = " link_id as id, link_author as author, link_blog as blog, link_status as status, link_votes as votes, link_negatives as negatives, link_anonymous as anonymous, link_votes_avg as votes_avg, link_votes + link_anonymous as total_votes, link_comments as comments, link_karma as karma, link_randkey as randkey, link_category as category, link_url as url, link_uri as uri, link_url_title as title, link_title as title, link_tags as tags, link_content as content, UNIX_TIMESTAMP(link_date) as date, UNIX_TIMESTAMP(link_sent_date) as sent_date, UNIX_TIMESTAMP(link_published_date) as published_date, UNIX_TIMESTAMP(link_modified) as modified, link_content_type as content_type, link_ip as ip, link_thumb_status as thumb_status, link_thumb_x as thumb_x, link_thumb_y as thumb_y, link_thumb as thumb, user_login as username, user_email as email, user_avatar as avatar, user_karma as user_karma, user_level as user_level, user_adcode, cat.category_name as category_name, cat.category_uri as category_uri, meta.category_id as meta_id, meta.category_name as meta_name, meta.category_uri as meta_uri, favorite_link_id as favorite, clicks.counter as clicks, votes.vote_value as voted FROM links INNER JOIN users on (user_id = link_author) LEFT JOIN (categories as cat, categories as meta) on (cat.category_id = links.link_category AND meta.category_id = cat.category_parent) LEFT JOIN votes ON (link_date > @enabled_votes and vote_type='links' and vote_link_id = links.link_id and vote_user_id = @user_id and ( @user_id > 0 OR vote_ip_int = @ip_int ) ) LEFT JOIN favorites ON (@user_id > 0 and favorite_user_id = @user_id and favorite_type = 'link' and favorite_link_id = links.link_id) LEFT JOIN link_clicks as clicks on (clicks.id = links.link_id) ";
Luego implementa la factory function Link::from_db() que retorna un instancia después de leer los datos de la base de datos con una sola consulta:
static function from_db($id, $key = 'id') { global $db; if ($key == 'uri') { $id = $db->escape($id); $selector = " link_uri = '$id' "; } else { $id = intval($id); $selector = " link_id = $id "; } if(($object = $db->get_object("SELECT".Link::SQL."WHERE $selector", 'Link'))) { return $object; } return false; }
Supongo que está clara la idea, Link::SQL es una constante que puede ser usado en cualquier sitio (se usa en varios) y que define la consulta básica. Luego es responsabilidad del que la use de completarla con las condiciones necesarias en el «WHERE» (línea 12).
Pero había unos temas a tener en cuenta:
- El control «rápido» de votos (i.e. para visualizar) es diferente para usuarios (por id de usuario) que para lo anónimos (por la IP).
- Hacen falta los datos de código de usuario y/o de la dirección IP para hacer los controles anteriores.
- No tiene sentido controlar el voto si los votos ya están cerrados, en estos casos, por eficiencia, es mejor no consultar a la tabla de votos.
- No tiene sentido verificar en la tabla de favoritos si el usuario es anónimo (sólo los registrados puedes marca favoritos, por razones obvias).
Para solucionarlo de forma simple y elegante se usan las variables de instancia (@user_id, @ip_int y @enabled_votes). Estas variables se inicializan con un sólo «SET» casi al principio, en libs/login.php justo después de verificar si es usuario autentificado y que luego servirán para decenas de consultas:
$db->query(" set @user_id = $this->user_id, @ip_int = ".$globals['user_ip_int']. ", @ip_int = ".$globals['user_ip_int']. ", @enabled_votes = date_sub(now(), interval ". intval($globals['time_enabled_votes']/3600). " hour)");
Si os fijáis como se usan y por su posición, las condiciones sobre estas variables «truncan» inmediatamente consultas innecesarias a otras tablas. Por ejemplo, en la línea 5 del código superior se evita la consulta a la tabla de votos si ya no están habilitados. En la línea 6 se evita la consulta a la tabla de favoritos si el usuario no está autentificado.
Con estas optimizaciones logramos reducir mucho el tiempo de latencia, y el número de consultas a la base de datos. Bajamos de más de 1.600/segundo a 1.200 por segundo. Ahora la función print_summary() ya no tiene que hacer consultas adicionales, todo ya está almacenado en la instancia al momento de su creación o lectura.
Iteradores
Pero todavía me quedaba algo pendiente que no sabía como encarar hasta hace pocos días que se me aclararon las ideas. Quería eliminar las consultas por cada objeto que se leía de la base de datos. Así, en vez de primero obtener los IDs de los enlaces que se mostrarán, y luego hacer la lectura por cada uno de ellos, quería implementar un iterador PHP que sea capaz de usar la misma constante Link::SQL (o Comment::SQL, Post::SQL) para poder interar sobre cada objeto como si fuese una lista (similar a los cursores).
Es decir, quería hacer algo como:
$links = $db->object_iterator("SELECT ".Link::SQL."etc. etc.", "Link"); foreach ($links as $link) { $link->print_summary(); }
El objetivo era sencillo, minimizar aún más las consultas y latencia. El SQL para leer cada objeto ya es bastante complejo, lo que hace aumentar tiempo y consumo de CPU del servidor de base de datos (por las mismas razones que antes, el parser, el optimizador, etc.). Así implementé dos clases nuevas en la abstracción de la base de datos (pueden verse a partir de la línea 222 del fichero):
class ObjectIterator implements Iterator {...} class QueryResult extends MySQLi_Result implements IteratorAggregate {...}
Con estas clases implementé el método object_iterator() de la clase RGDB (línea 127 del mismo fichero), que básicamente hace:
if ($this->real_query($query)) { if ($is_select && $this->field_count) { return new QueryResult($this, $class);
Así, el código para obtener los enlaces del index.php –y que genera el código SQL del principio– se hace simplemente:
// We use a "INNER JOIN" in order to avoid "order by" with filesorting. $links = $db->object_iterator("SELECT".Link::SQL."INNER JOIN (SELECT link_id FROM links $from WHERE $where $order_by LIMIT $offset,$page_size) as id USING (link_id)", "Link"); if ($links) { foreach($links as $link) { $link->print_summary(); } }
La pregunta del millón es ¿por qué ese INNER JOIN con un sub SELECT en vez de hacer algo más sencillo como?:
$links = $db->object_iterator("SELECT".Link::SQL."WHERE $where $order_by LIMIT $offset,$page_size", "Link");
La respuesta está en el comentario del código usado: para evitar el uso de «filesorting», es decir que el MySQL ordene los resultados en memoria, en vez de obtenerlo directamente ordenados desde el índice.
Si recordáis el pseudocódigo de más arriba, antes se seleccionabas primero los IDs ya ordenados:
SELECT link_id FROM links WHERE link_status = 'published' ORDER BY link_date DESC LIMIT Y,Y
En este caso el MySQL usa el índice existente (link_status, link_date) para obtener los resultados ordenados directamente. Pero en esta consulta más compleja, con varios LEFT e INNER JOINs es incapaz de usar el mismo índice. La solución es «empotrar» la consulta anterior para que retorne ya los IDs ordenados, eso es exactamente lo que se hace en el sub SELECT. Con ello logramos enviar un sólo SQL para obtener todos los enlaces (y toda su información) para generar toda la página índice.
Super eficiente, y bonito ¿o no?
Resultados
- Hemos logrado reducir el tiempo de generación de páginas notablemente. El tiempo de reloj completo para generar toda la página principal del Menéame es de media 0.02 seg, 0.03 seg con la compresión on-the-fly incluida. Antes llegaba a 2 décimas de segundo (con mucha más variabilidad, era más sensible a las latencias de la red de Amazon).
- Las instancias web de Menéame son las «small», con sistema de 32 bits. Pero son capaces de generar más de 200 páginas índices por segundo (entre 180 y 230 con el Apache Benchmark y con concurrencia > 5). Antes generaban hasta 40.
- A pesar del aumento de tráfico de las últimas semanas (+10% en enero respecto a diciembre), el consumo de CPU del servidor de base de datos raramente llega al 30% (antes superaba el 40% durante horas pico).
- El número de consultas a la base de datos se redujo notablemente. Antes nos daba una media de 1.600, luego 1.200, ahora estamos (después de haber reiniciado por actualización de seguridad hace pocos días) a 833 por segundo:
Uptime: 5 days 17 hours 49 min 45 sec
Threads: 6 Questions: 413714009 Slow queries: 1885 Opens: 1045 Flush tables: 1
Open tables: 837 Queries per second avg: 833.789
Moraleja
Seguramente te pasa como a mi: creía conocer a esas bestias serviles de base de datos relacionales. Lo más probable es que a menos que tengas años de experiencia en caso complejos, no tengas idea de cómo obtener los mejores resultados. Así que la próxima vez, antes de proponer usar una solución «NoSQL» para tu «proyectito» porque una relacional no aguantaría, piénsalo un millón de veces y lee algunos libros imprescindibles [por ejemplo 1 ó 2]. Posiblemente no estés haciendo lo mejor, y seguro, no, segurísimo, que con una NoSql no podrás tener un código tan «elegante» y breve como:
$links = $db->object_iterator("..."); if ($links) { foreach($links as $link) { $link->print_summary(); } }
que sea capaz de hacer tantas cosas, y tan eficientemente (a pesar el PHP) a partir de una única consulta a la base de datos
Extra
Si usas un framework moderno, felicidades, ojalá hubiesen existido cuando comencé a programar el Menéame. Pero no te creas que generarán SQL y código muy eficientes y adaptados a tu problema. Si piensas que la base de datos, o los SQL, podrían ser un cuello de botella, asegúrate que el framework es lo suficientemente flexible para que puedas optimizar las consultas.
¡Ah! antes de olvidarme, si tu modelo de datos de un proyecto web requiere de JOINS de más de 5 ó 6 tablas, la estás cagando. Un programa web no tiene nada que ver con un programa de gestión (ni con los requerimientos y limitaciones que te dan en las clases de bases de datos de la universidad).
Dicho está. Luego no estés babees como crío cada vez que alguien dice «NoSql» [2].
[2] Por supuesto que tiene su utilidad y a veces son imprescindibles, sobre todo si necesitas distribuir una gran cantidad datos, con una «consistencia relativa» y bajas latencias. Pero en el mundo hay muy pocos proyectos con Facebook, Twitter o Tuenti. La probabilidad que el tuyo tenga requerimientos similares es muy bajo, y si lo usas perderás las ventajas de usar relacionales (ACID, etc.) y la potencia del SQL. Sobre estos temas hablé y di más detalles en un podcast de hace pocos meses: Especial SysAdmin en «Kernel Panic» con Ricardo Galli.
Orestes dijo:
¡Bravo! Da gusto leer puntes como este 🙂
Pingback: Tweets that mention Optimizando obsesivamente las consultas al MySQL « Ricardo Galli, de software libre -- Topsy.com
Joan Ballester dijo:
Útil hasta no sabes cuanto, Ricardo 🙂
Ya tenemos diversión para unas semanitas en un proyecto…
Gracias!
Chema dijo:
gracias por compartir 😀 muy útil
Emilius dijo:
Ja ja, que gracia, el gran galli ha seguido el mismo y doloroso camino que yo en su relacion con las bases de datos, se me ha caído un mito :-p
Bueno, en realidad eso son cosas que te las da la experiencia y el pelearte con ello dia a dia. Como decía un compañero guiri que tuve la máxima es: let the database work for you.
Jaime dijo:
El artículo está muy bien, y es francamente interesante. Creo que, llegado un unto determinado, es imprescindible incluso entrar en «las tripas» de las implementaciones particulares para evitar cosas como usar el disco, etc, que pueden ayudar mucho con la eficiencia.
Sin embargo, no termino de entender muy bien el ataque un tanto gratuito a las bases de datos NoSQL, que son como son, y tienen algunas aplicaciones en las que encajan muchas veces mejor que una base de datos relacional. Es verdad que se debe hacer un análisis previo, pero para eso están las opciones, para poder elegir.
Por ejemplo, en este artículo acerca de Reddit ( http://highscalability.com/blog/2010/5/17/7-lessons-learned-while-building-reddit-to-270-million-page.html ) es curioso que hayan terminado utilizando una base de datos relacional como si no lo fuera (una única tabla de «cosas»). Al poco de la presentación movieron todo a Cassandra (una base de datos NoSQLl)
Ya digo, no es la panacea. No deben usarse sólo porque sea lo nuevo y molón y sea lo ultimo en Sillicon Valley. Pero tampoco deben ignorarse. Además de que cada día hay herramientas más completas y trabajar con ello puede ser tan fácil como el código que has puesto al final…
PEro, ya digo, excelente artículo sobre optimización 😉
renegm dijo:
«Experimenté en carne propia la carencia que tenemos los programadores sobre la arquitectura y modo de trabajo de las bases de datos. Es un mundo completamente diferente, se necesitan miles de horas para dominarlo mínimamente [1], aunque tengas muchos años de experiencia como programador.»
Te agradezco la frase profundamente. Programo a ratos, y por triste necesidad leo código (Leer código es mucho más difícil y aburrido que escribirlo, no es una tarea menor. Quien lo dude es porque no ha hecho nunca ninguna de las dos cosas). Pero no me considero programador. Mi mundo son las bases de datos y los servers. Y a diario sufro por causa de los programadores y sus (a mis ojos) disparatados modos de usar el SQL o acceder a las BD. Pocos son los que llegan, no ya a aprender lo suficiente, sino al menos, a realizar ese ejercicio de autocrítica que haces tú en la frase que cito y al final del post.
Una parte del problema es que el SQL no es propiamente un lenguaje de programación. Es un lenguaje de declaraciones no de procedimientos. Su lógica es de conjuntos, no de procesos.
Te recomiendo que le eches una mirada a los libros de Joe Celko. Son sobre SQL, no dedicados a plataformas específicas aunque obviamente las menciona.
Un saludo
Pingback: Optimizando obsesivamente las consultas MySQL de menéame
Francesc dijo:
Y espera el dia que empieces a utilizar tecnicas como los «covering index» o «vistas indexadas», puedes multiplicar la velocidad por 10 o incluso por 100.
Ademas, existen otras tecnicas, pero generalmente siempre cambias espacio de almacenamiento por velocidad de respuesta, es decir, cuanto mas rapido quieres ir, mas espacio gastas.
Joss dijo:
Gran post, gracias!
SQL dijo:
En vez de mejorar el acceso a la base de datos, deberías rediseñar la base de datos para que fuera óptima. Tanto left join es una auténtica chapuza.
Paco Ferre dijo:
Muy interesante, no conocía esto:
LEFT JOIN (categories AS cat, categories AS meta) ON (cat.category_id = links.link_category AND meta.category_id = cat.category_parent)
Juan Lupión dijo:
Vaya por delante que me parece una anotación magnífica, de quitarse el sombrero.
Pero no estoy de acuerdo con la afirmación de «seguro, no, segurísimo, que con una NoSql no podrás tener un código tan elegante y breve como «. Digo yo que dependerá. Igual que con una base de datos relacional puedes hacer un envoltorio para trabajar de forma más cómoda (como el que se ha hecho en Meneame), con un motor NoSQL puedes hacer exactamente igual, y trabajar con una API similar a la que se ha currado Galli por debajo.
Por otro lado, no sé quién fue el que dijo aquello de que depurar el código es el doble de complicado que escribirlo, así que no deberías tratar de escribir el código más inteligente que puedas, porque luego no lo podrás depurar. Si ha hecho falta un Ricardo Galli para escribir esto, no quiero ni pensar en el talento necesario para depurarlo si algún día surgen problemas o hay que hacer cambios y el autor ya anda en otra cosa 😉
Javier dijo:
Gran aporte!!
Nenillo dijo:
Muy interesante el apunte pero me queda la duda de las etiquetas. ¿Qué pasa con ellas? ¿Están también en una tabla a parte? Si es así el número de registros de la tabla que relaciona etiquetas con enlaces debe ser bastante grande. ¿Cómo resolvéis ese problema?
¡Gracias!
Ernesto dijo:
Por mi experiencia, tal y como bien dices, los JOINS son un peligro de cara al rendimiento, y hemos encontrado que es mucho más práctico meter redundancia en algunos campos para evitarlos. Aumenta el tamaño de las bases de datos, pero en la web ese un precio menor a cambio de conseguir consultas más rápidas. Viendo el código de la consuta (y desconociendo los detalles, que cada servidor es un mundo), ¿no sería más eficiente meter en la base de datos de enlaces los campos de usuario y categorías que hicieran falta, además de los identificadores de los mismos? Entiendo que así podrían ahorrase un par de joins ¿?
Pingback: Enlaces interesantes 2 febrero | Infonomada.
Juan dijo:
Un post muy util, guardado en favoritos, gracias por compartirlo
Juan dijo:
Algo que podría darle más rapidez a las consultas es usar filtrado por fecha. En PostgreSQL se llama partitioning (http://www.postgresql.org/docs/8.1/static/ddl-partitioning.html) y hace que se creen «subtablas» y así es MUCHO más rápida la búsqueda porque hay menos registros.
Ojeé por encima esta historia y estoy de acuerdo en general. Es necesario eliminar los joins, incluso cuando eso implique hacer una sola tabla con muchos campos.
Podrían mantener la estructura actual de la BD, crear esta súper tabla, y tener procesos periódicos que sacan los datos de la BD relacional y los ingresan a esta súper tabla. Al final sacarían toda la información de esta súper tabla. Importante: ¡la super tabla no tiene relaciones con nadie! Así es más rápido. En fin, es una idea que implementamos en un proyecto donde manejábamos varios millones de registros, y resultó bastante bien. Espero ayude de algo a este gran sitio de noticias 🙂
rafamg dijo:
El extra me ha llevado a plantearme lo de los joins de forma obsesiva en algo que estoy haciendo ahora mismo.. *dolor de cabeza asomando*
Tom dijo:
Sólo decir: NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, NoSQL, ….
Una cosa: Hay que optimizar más tus entradas, 5h que podías dedicar a más entradas buenas como esta…
Otra cosa: La entrada me ha gustado 😀
Pablo dijo:
Bastante interesante ¡gracias!
gallir dijo:
@ernesto
Mira todos los campos que habría que duplicar y mantener sincronizados: «… user_login as username, user_email as email, user_avatar as avatar, user_karma as user_karma, user_level as user_level, user_adcode…»
@juan
«Podrían mantener la estructura actual de la BD, crear esta súper tabla, y tener procesos periódicos que sacan los datos de la BD relacional y los ingresan a esta súper tabla»
Los datos de noticias enviadas en los últimos días se actualizan desde cada pocos segundos, hasta varias veces por segundo. Y sin cuentas el contador de clicks, muchas veces por segundo. Esa estrategia no sirve, o sería muy complicada de usarla adecuadamente. En cambio con estas optimizaciones de las consultas se mantiene la arquitectura y esquema muy sencillos, y funciona sobrado en temas de rendimiento.
Mira el uso de CPU del servidor máster de BBDD ahora mismo, con mucho tráfico: http://twitpic.com/3vqjf5 (y es un servidor large, con sólo dos core, y que cuesta < 200€ al mes y sopoprta 100 millones de consultas diarias, con poco más del 20% de CPU durante horas pico).
Rafa dijo:
Una duda desde el desconocimiento, ¿influye el orden de los joins en el rendimiento?, veo que el join que limita los resultados está en último lugar. A lo mejor si la limitación de resultados está en primer lugar se limita también las operaciones de los joins subsiguientes.
gallir dijo:
Para aclarar un poco esta «broma», agregué la nota [2] al final del artículo, con un enlace a un podcast donde discutí estos temas de SQL vs NoSQL, ventajas y desventajas, y la imposibiliad de tener un «ACID» en sistemas distribuidos: http://www.daboblog.com/2010/11/18/kernel-panic-especial-sysadmin-administracion-servidores-web-con-ricardo-galli-meneame-uib/
gallir dijo:
El primero que se ejecuta es el INNER JOIN final que limita a la primera (y única) table en el FROM (links). Al menos en MySQL, no probé nada equivalente en otras BBDD.
Rafa dijo:
Cierto. Releyendo el SQL con tu comentario en mente veo que tiene todo el sentido que empiece por el final. Se necesita evaluar la parte izq. del JOIN para saber con qué se debe cruzar la parte derecha, que declara el resultado. Imagino que en otras BBDD será igual.
Muchas gracias.
beto dijo:
Interesante pero y la cache? Yo intento evitar todo INNER y en cambio realizo consultas simples cacheadas. Así evito consultas a la BBDD y además no hay problemas de que la cache contenga datos desactualizados. Por ejemplo, si quiero los datos de un único producto y hago un INNER JOIN de tabla «productos» y la tabla «empresa» y luego modifico algún dato de la empresa, la cache de esta consulta contendría datos antiguos y para poder solucionarlo debería borrar (y si se desea, volver a consultar para regenerar) toda la cache de los productos de esta empresa. En cambio si hago dos consultas cacheadas (una a la tabla productos y otra a la tabla empresa), si luego modifico algún dato de la empresa y regenero la cache del getter de empresa nunca habrá datos desactualizados en ninguna consulta.
Estoy bastante de acuerdo con esto.
Nunca podré estar de acuerdo con esto aunque a veces me tiento. Creo que es un fallo de arquitectura y siempre habrá una solución mejor a esto. Además, y esto es muy importante también, la duplicidad hace que la intuición (por conocimiento) del programador no baste y esto provoca errores humanos.
Y ahora os ruego su opinión!
Buen post!
lector dijo:
Siempre es interesante conocer los entresijos de meneame de mano de su autor, con los comentarios y explicaciones incluídas. Gracias!
Pau Iglesias dijo:
Gracias por compartirlo, supongo que no se podrá optimizar mucho más, pero quería hacer un par de comentarios, a ver como lo ves 😉
Si las categorías no sufren muchos cambios, volcarlas de vez en cuando con el CRON o bien cuando se detecten cambios a un archivo PHP y después cargarlo con includes, o bien hacer lo mismo con memcached, y así acceder a esta información directamente en memoria. De la select se quitaría entonces el join de categorias y recuperamos los nombres y otros campos de categorías de los links relacionando los identificadores con las claves ajenas. Sip, es algo burrada porque nos cargamos la integridad referencial, pero supongo que algo se puede ganar, aparte que puede servir para simplificar otras queries que hagan uso de las categorías.
Y una pregunta sobre redundancia, me refiero a si es posible aplicarla solo a la información de contadores (votos, favoritos y clicks), manteniendo esta información también en la tabla de links, pero no con procesos que sincronicen, sino hacerlo a la vez que se actualiza en las otras tablas. Supongo que será complicado realizar este tipo de actualizaciones, o no sé, puede que incluso sea negativo si implica aumentar la carga en otras situaciones.
gallir dijo:
@francesc @beto
Sí que usamos covering indexes, lo digo en al apunte, especialmente en votos donde es imprescindible: Los votos totales llegan a casi 100 millones, pero diariamente se “sumariza” esta tabla para mantenerla en unos 4 millones de filas ya que tiene varios “covering indices” que ocupan más espacio que los datos.
@Pau Iglesias
No tiene mucho sentido ponerlas en PHP, se ganaría muy poco en eficiencia (la tabla es muy pequeña y está siempre «hot» en memoria) pero se perdería en simplicidad.
Con el memcached para eso se perdería bastante por la latencia de red adicional para obtener tan pocos datos (y se pierde en simplicidad).
El contador de votos se duplica en links para evitar los «count()» costosos, va bastante bien. La de clics es otra historia porque su actualización es mucho más frecuente. Por eso van en una tabla muy simple.
Fabio dijo:
Tal vez una de las cosas que más coincido con Galli es en evitar frameworks para ciertas cosas, también soy de los que hace años comenzaron a programar por su cuenta (el CMS de mi blog, del cual nació también una especie de clon de Menéame para Linksdeviernes.com) y la eficiencia de poder jugar con SQL a mano es muy interesante y diría, mucho mejor al final.
Es que los frameworks sirven, justamente, para alejarse de esa parte crucial, poder tocar las consultas a mano y buscar el mejor rendimiento, es ideal.
el trabajo de índices también es imprescindible, muchos los ignoran pero una base bien indexada puede solucionar muchos problemas
lxsplk dijo:
Que tal ? La verdad que me encanta tu blog, esta muy bueno y paso casi siempre, el tema de las base de datos es bastante complejo, ya que si partimos desde un diseño conceptual algo complejo, la parte logica se nos va a ser SUPER lenta y para nada optimizada, lo mejor que se puede hacer es realizar paso a paso un diseño conceptual y luego uno logico (utilizando la primera segunda y tercera forma normal) espero que puedas pasar por el mio TuReceta.wordpress.com, podrás encontrar recetas faciles, rapidas y ricas para la gente que no esta acostumbrada a cocinar y para expertos .Un saludo grande
Fernan2 dijo:
En Rankia.com tenemos consultas parecidas en complejidad y en tamaño de tablas (excepto la tabla de votos, que en Meneame es incomparablemente mayor), y efectivamente no accedemos cada vez a la tabla usuarios para leer el nick del usuario, sino que lo duplicamos en la tabla de contenidos, aprovechando que no permitimos cambiar el nick. Y en cuanto a las categorías… ahí creo que es un error leerlo de BBDD cada vez, esas tablas tan pequeñas se tienen enteras en caché (o incluso se declaran como constantes, aunque la caché es más limpia), y el ID se convierte automáticamente en el texto correspondiente.
s2
Fernan2 dijo:
En lo que está pintado de azul no veo que se usen el email, karma, ni level; obviamente, si se usan, podemos descartar la solución de duplicar, pero no veo ese uso, sólo veo el username y el avatar… y en cuanto al avatar, el archivo se puede renombrar para que coincida con el username, con lo que bastaría con duplicar el username (que no hace falta mantener sincronizado si no es editable).
Espero muy interesado los detalles al respecto!!
s2
Demiurgo dijo:
el código inicial es una blasfemia en toda regla :X
la evolución muy interesante 😀
Pingback: Top Posts — WordPress.com
Pingback: Optimizando obsesivamente el funcionamiento interno de Menéame | PHP SPain
Diego dijo:
Hola Ricardo,
Descubro con un par de dias de retraso este articulo, enhorabuena, por el, y por el trabajo de optimizacion que estais llevando a cabo.
Tengo una duda concreta, que espero que puedas resolver. Desde el punto de vista de un profesional de la informatica, entiendo que el amor propio, la profesionalidad, y el amor al reto que supone el hacer frente esta herculea tarea.
Desde el punto de vista ‘empresarial’, donde prima el coste/beneficio, ¿Podeis cuantificar el beneficio monetario?-me explico, salvando las distancias entre procesos productivos e informatica- si cocacola reduce un 0.0001% el alumnio de una lata, ahorran un pastizal, medible vamos, que justifica la inversion en i+d que ha lelvado al nuevo diseño. Si la carga de CPU de meneame se reduce un tanto por ciento, y yo fuese el CIO que tiene que presentar la aprobacion del presupuesto qeu suponga el coste hora y el i+d aplicado, ¿Como y en que terminos podria justificar la modificacion?
Vaya toston os he pegado.. Espero que haya podido quedar clara mi pregunta,
de nuevo, enhorabuena.
gallir dijo:
@diego
Si el tema económico fuese tan importante para mí, no hubiese empezado Menéame, o lo hubiese cerrado a los dos meses. Con el tiempo y esfuerzo dedicado, habría ganado muchísimo más dinero haciendo otras cosas.
Dicho eso, si tienes un problema similar a este, no haces falta que gastes tanto tiempo, puedes usar la misma técnica. Para eso lo explico, para que otros no pierdan el mismo tiempo que yo 😉
abrmoralesm dijo:
Estupenda entrada. No conocía tu blog. A partir de hoy estaré atento.
http://unandaluzenpolonia.wordpress.com
Pingback: Optimizando obsesivamente las consultas al MySQL « DbRunas – Noticias y Recursos sobre Bases de Datos
Dabo dijo:
Otro que va con retraso, pero enhorabuena Ricardo, este es de los que me imprimiré 😉
Pingback: Algunos links interesantes « unreal4u's Personal Network
David dijo:
Hola,
No me considero un experto, pero si que me ha tocado realizar unas cuantas optimizaciones. Hay un par de cosas que me extrañan:
– ¿Por que no utilizas tablas temporales en lugar de subconsultas?
Desde el punto de vista de la legibilidad las anidaciones son un caos mayúsculo. Queda mucho más claro tener una tabla con un nombre descriptivo.
En cuanto al rendimiento, según mi experiencia las tablas temporales suelen ser bastante más rápidas en la mayoría de los casos.
– ¿Por que no alineas las columnas y las condiciones? Igual es manía mía, pero creo que es mucho más legible:
SELECT columna1
, columna2
, columna3
FROM mitabla mia
join otratabla otra on mia.id = otra.id
and otra.tipo = 69
WHERE cond1 = 1
and cond2 in (1,2,3)
and lalala
que:
SELECT columna1, columna2, columna3,
FROM mitabla mia
INNER JOIN otratabla otra ON mia.id = otra.id and otra.tipo = 69
WHERE cond1 = 1 and cond2 in (1,2,3) and lalala
a veces creo que algunos programadores deben tener pantallas ultrapanoramicas 😀
David dijo:
¡Maldita sea! Algún malvado se comió mis espacios, quería decir esto:
SELECT columna1
….., columna2
….., columna3
..FROM mitabla mia
..join otratabla otra on otra.id = mia.id
…………………and otra.tipo = 69
.WHERE cond1 = 1
…and cond2 in (1,2,3)
…and lalala
Fernan2 dijo:
Pues claro!! Los iMac de 27″ tienen una resolución de 2500 px… ¡no te conformes con menos!! Y no es coña, van de lujo…
Por cierto, a ver si a mí me sale…
😉
Pingback: links for 2011-02-07 « Gatunogatuno’s Weblog
Ivan de la Jara dijo:
Has descrito exactamente el escenario que me hace odiar MySql y por el que cada vez la uso menos. Lo que me intriga es porque no has empezado a cachear y usar bases de datos basadas en documentos antes de ponerte a descifrar jeroglíficos mysql durante horas y horas… Supongo que hay gente a la que le gusta crear casas enteras con fichas de Lego. O quizá forma parte de la persistente búsqueda y admiración heterosexual por el tamaño del pene…
gallir dijo:
@Ivan de la Jara
El porqué no usamos «basadas en documentos» está explicado (brevemente) en el apunte: son cosas diferentes, más simples (por ejemplo en que la mayorái está basado sólo en clave-valor) y no tienen un lenguaje potente como el SQL (ni aseguran ACID, por ejemplo).
Por otro lado, no se trata que MySQL sea malo (cualquier otra base de datos relacional tiene los mismos «problemas»), sino en que hay que aprender cómo funcionan para poder usarlas correctamente. Es lo que argumenté en todo este apunte, pero parece que no hs entendido así 😉
Ivan de la Jara dijo:
Totalmente de acuerdo. Yo por ejemplo me pasa lo mismo con PHP. No me apetece aprender Scala, ni frameworks «desos» como ruby o cosas así… A los frameworks es que no les veo la utilidad, prefiero un CMS.
Aunque un detalle. Mysql tampoco es ACID. En teoría lo es, pero no lo es. Supongo que ya tendréis scripts para arreglar esos fallos no-ACID de mysql. Hasta las transacciones fallan a veces. En ese sentido supongo que cualquier programa o base de datos es susceptible a esos fallos. Lo que quiero decir es que tampoco se puede tomar como un dogma.
Hay varias NoSQL bastante «sql» incluso algunas que usan sql e incluso mysql dentro de si… pero por otra parte el «dogma» de «nosql es escalable» tampoco es verdad en muchos casos… Hay que aceptar tanto lo bueno como lo malo…
Aunque supongo que es lo que tu dices, para la mayoría de gente que ya sabe mysql no le interesa cambiar de modelo pero para el que esta empezando pues supongo que si. «por si acaso en el futuro».
David dijo:
¿En tu curro los programadores tienen pantallas de 27″? Te voy a pasar un curriculum… 🙂
Aún con esas es bastante incomodo leer textos panorámicos. Este mismo blog, y cualquier otro, deja la mitad de mi pantalla de 23″ vacía… por algo debe ser.
gallir dijo:
@ivan de la Jara
> Mysql tampoco es ACID. En teoría lo es, pero no lo es.
Ostras, sí que lo es, usamos InnoDB y es «full ACID», sin duda alguna.
Ivan de la Jara dijo:
Uhm pues no te sabría decir. Se que cambie a innodb en algunas tablas (o todas? no recuerdo) También estoy hablando de hace unos años quizá haya mejorado la cosa pero me supongo que cuando peta la ram, mysql o php fallara igualmente ¿no?
Ya no recuerdo las recomendaciones que decían de cambiar mysam por innodb creo que era porque iba mas rápido escribiendo o algo así… De todas formas también tenia tablas en ram con datos poco importantes y petaba… a parte de cuando petó el disco duro varias veces que ahí ya fue un desmadre…
Todas esas cosas están ya solucionadas en la mayoría de nosql. Llevo tanto tiempo usando mysql para almacenar json/etc que no te sabría decir. Lo que si te puedo decir es que no veo necesidad de procesar y buscar todos los datos por cada usuario que visite la web, lo correcto creo que seria por cada cambio en información y esto en una estructura de documentos lo tienes. Es como usar/modificar archivos cache.
De todas formas me supera la incultura del tema. Quiero decir que no te sabría dar mas motivos o datos mas fiables. Sobre si realmente peta o no. Supongo que es cuestión de hacer un query y buscar relaciones rotas pero ya te digo es la primera noticia que tengo sobre que sea ACID de verdad.
Quizá es porque ahora se estén esforzando mas con el tema de las nosql pisándole los talones… No se.