Optimizar el diseño del esquema para Spanner

Las tecnologías de almacenamiento de Google potencian algunas de las aplicaciones más grandes del mundo. Sin embargo, la escala no siempre es un resultado automático del uso de estos sistemas. Los diseñadores deben pensar cuidadosamente sobre cómo modelar sus datos para garantizar que su aplicación pueda escalar y funcionar a medida que crece en varias dimensiones.

Spanner es una base de datos distribuida y, para usarla de forma eficaz, es necesario pensar de forma diferente en el diseño del esquema y en los patrones de acceso que se utilizan en las bases de datos tradicionales. Los sistemas distribuidos, por su naturaleza, obligan a los diseñadores a pensar en los datos y la localización de procesamiento.

Spanner admite consultas y transacciones de SQL con la capacidad de escalar horizontalmente. A menudo, es necesario un diseño cuidadoso para aprovechar al máximo las ventajas de Spanner. Este documento analiza algunas de las ideas clave que ayudarán a garantizar que la aplicación pueda escalar a niveles arbitrarios y maximizar su rendimiento. Dos herramientas en particular tienen un gran impacto en la escalabilidad: definición de clave e intercalado.

Diseño de la tabla

Las filas de una tabla de Spanner se organizan lexicográficamente por PRIMARY KEY. Conceptualmente, las claves se ordenan por la concatenación de las columnas en el orden en que se declaran en la cláusula PRIMARY KEY. Esto exhibe todas las propiedades estándar de localización:

  • La búsqueda en la tabla en orden lexicográfico es eficiente.
  • Las filas suficientemente cercanas se almacenarán en los mismos bloques de disco, y se leerán y almacenarán en caché juntas.

Spanner replica tus datos en varias zonas para ofrecer disponibilidad y escalabilidad. Cada zona contiene una réplica completa de tus datos. Cuando aprovisionas un nodo de instancia de Spanner, especificas su capacidad de computación. La capacidad de computación es la cantidad de recursos de computación asignada a tu instancia en cada una de estas zonas. Aunque cada réplica es un conjunto completo de tus datos, los datos de una réplica se particionan entre los recursos de computación de esa zona.

Los datos de cada réplica de Spanner se organizan en dos niveles de jerarquía física: divisiones de bases de datos y bloques. Las divisiones contienen intervalos contiguos de filas y son la unidad por la que Spanner distribuye tu base de datos entre los recursos de computación. Con el tiempo, las divisiones se pueden dividir en partes más pequeñas, fusionarse o trasladarse a otros nodos en la instancia para aumentar el paralelismo y permitir que la aplicación se escale. Las operaciones que abarcan divisiones son más costosas que las operaciones equivalentes que no lo hacen, debido al aumento de la comunicación. Esto es cierto incluso si esas divisiones son publicadas por el mismo nodo.

Hay dos tipos de tablas en Spanner: tablas raíz (a veces llamadas tablas de nivel superior) y tablas intercaladas. Las tablas intercaladas se definen especificando otra tabla como su principal, lo que provoca que las filas de la tabla intercalada se agrupen con la fila principal. Las tablas raíz no tienen elementos superiores y cada fila de una tabla raíz define una nueva fila de nivel superior o fila raíz. Las filas intercaladas con esta fila raíz se denominan filas secundarias, y el conjunto de una fila raíz más todos sus descendientes se denomina árbol de filas. La fila superior debe existir para poder insertar filas secundarias. La fila principal ya puede existir en la base de datos o se puede insertar antes de insertar las filas secundarias en la misma transacción.

Spanner divide automáticamente las particiones cuando lo considera necesario debido al tamaño o la carga. Para mantener la localidad de los datos, Spanner prefiere añadir límites de división lo más cerca posible de las tablas raíz, de forma que cualquier árbol de filas se pueda mantener en una sola división. Esto significa que las operaciones de un árbol de filas suelen ser más eficientes porque es poco probable que requieran comunicación con otras divisiones.

Sin embargo, si hay un punto de acceso en una fila secundaria, Spanner intentará añadir límites de división a las tablas intercaladas para aislar esa fila de punto de acceso, junto con todas las filas secundarias que haya debajo.

Elegir qué tablas deben ser raíces es una decisión importante al diseñar la aplicación para escalar. Las raíces suelen ser cosas como Usuarios, Cuentas, Proyectos y similares, y sus tablas secundarias contienen la mayoría de los demás datos sobre la entidad en cuestión.

Recomendaciones:

  • Si quieres mejorar la localización, usa un prefijo de clave común para filas relacionadas en la misma tabla.
  • Intercala datos relacionados en otra tabla cuando tenga sentido.

Ventajas y desventajas de localización

Si los datos se escriben o se leen juntos con frecuencia, puede beneficiar tanto a la latencia como al rendimiento para agruparlos al seleccionar cuidadosamente las claves principales y utilizando el intercalado. Esto se debe a que hay un costo fijo para comunicarse con cualquier servidor o bloque de disco, así que ¿por qué no obtener tanto como sea posible mientras esté allí? Además, cuantos más servidores te comuniques, mayores serán las probabilidades de encontrarse con un servidor temporalmente ocupado, lo que aumentará las latencias de cola. Por último, las transacciones que abarcan divisiones, aunque son automáticas y transparentes en Spanner, tienen un coste de CPU y una latencia ligeramente superiores debido a la naturaleza distribuida de la confirmación en dos fases.

Por otro lado, si los datos se relacionan, pero no se accede a ellos con frecuencia, de forma conjunta, plantéate la posibilidad de separarlos. Se obtiene mayor beneficio de esto, cuando los datos a los que se accede con poca frecuencia son grandes. Por ejemplo, muchas bases de datos almacenan datos binarios grandes fuera de banda de los datos de fila principales, solo con referencias a los datos grandes intercalados.

Ten en cuenta que algún nivel de confirmación de dos fases y operaciones de datos no locales son inevitables en una base de datos distribuida. No te preocupes demasiado por obtener una historia de localización perfecta para cada operación. Concéntrate en obtener la localización deseada para las entidades raíz más importantes y los patrones de acceso más comunes, y permite que las operaciones distribuidas menos frecuentes o menos sensibles al rendimiento ocurran cuando lo necesiten. La confirmación de dos fases y las lecturas distribuidas están ahí para ayudar a simplificar los esquemas y facilitar el trabajo del programador: en todos los casos prácticos que no sean los de mayor rendimiento crítico, es mejor dejarlos.

Recomendaciones:

  • Organiza los datos en jerarquías de manera que los datos leídos o escritos juntos estén cerca.
  • Considera almacenar columnas grandes en tablas no intercaladas si se accede a ellas con menos frecuencia.

Opciones de índice

Los índices secundarios permiten encontrar filas rápidamente por valores distintos de la clave principal. Spanner admite índices no intercalados e intercalados. Los índices no intercalados son el tipo predeterminado y el más análogo a lo que se admite en un RDBMS tradicional. No imponen ninguna restricción sobre las columnas que se indexan y, aunque son potentes, no siempre son la mejor opción. Los índices intercalados se deben definir en columnas que comparten un prefijo con la tabla principal y permiten un mayor control de la localización.

Spanner almacena los datos de índice de la misma forma que las tablas, con una fila por entrada de índice. Muchas de las consideraciones de diseño para las tablas también se aplican a los índices. Los índices no intercalados almacenan datos en tablas raíz. Debido a que las tablas raíz se pueden dividir entre cualquier fila raíz, esto garantiza que los índices no intercalados puedan escalar a un tamaño arbitrario e, ignorando los puntos calientes, a casi cualquier carga de trabajo. También significa que las entradas de índice generalmente no están en las mismas divisiones que los datos primarios. Esto crea trabajo adicional y latencia para cualquier proceso de escritura, y agrega divisiones adicionales para consultar en tiempo de lectura.

Los índices intercalados, por el contrario, almacenan datos en tablas intercaladas. Son adecuados al buscar dentro del dominio de una sola entidad. Los índices intercalados obligan a los datos y las entradas de índice a permanecer en el mismo árbol de filas, haciendo que las uniones entre ellos sean mucho más eficientes. Ejemplos de usos para un índice intercalado:

  • Acceder a fotos por varios tipos de órdenes como fecha de toma, fecha de última modificación, título, álbum, etc.
  • Encontrar todas las publicaciones que tienen un conjunto particular de etiquetas.
  • Encontrar pedidos de compras anteriores que contenían un artículo específico.

Recomendaciones:

  • Usa índices no intercalados cuando necesites encontrar filas desde cualquier lugar de la base de datos.
  • Prefiere los índices intercalados siempre que las búsquedas tengan un alcance en una sola entidad.

Cláusula de índice STORING

Los índices secundarios permiten buscar filas por atributos que no sean la clave principal. Si todos los datos solicitados se encuentran en el índice en sí, se pueden consultar por sí solos sin leer el registro principal. Esto puede ahorrar recursos significativos ya que no se requiere unirse.

Las claves de índice están limitadas a 16 en número y 8 KiB en tamaño agregado, lo que restringe lo que se puede incluir en ellas. Para compensar estas limitaciones, Spanner puede almacenar datos adicionales en cualquier índice mediante la cláusula STORING. STORING una columna de un índice provoca que sus valores se dupliquen y que se almacene una copia en el índice. Puedes considerar un índice con STORING como una vista materializada de una sola tabla (las vistas no se admiten de forma nativa en Spanner en este momento).

Otra aplicación útil de STORING es como parte de un índice NULL_FILTERED. Esto permite definir, de forma efectiva, lo que es una vista materializada de un subconjunto disperso de una tabla que se puede buscar de manera eficiente. Por ejemplo, puedes crear un índice de este tipo en la columna is_unread de un buzón para poder ofrecer la vista de mensajes no leídos con un solo análisis de la tabla, pero sin tener que pagar por una copia completa de cada buzón.

Recomendaciones:

  • Haga un uso prudente de STORING para compensar el rendimiento del tiempo de lectura con el tamaño de almacenamiento y el rendimiento del tiempo de escritura.
  • Use NULL_FILTERED para controlar los costos de almacenamiento de los índices dispersos.

Antipatrones

Antipatrón: orden de marca de tiempo

Muchos diseñadores de esquemas tienden a definir una tabla raíz que se ordena por marca de tiempo y se actualiza en cada escritura. Lamentablemente, es una de las acciones menos escalables que puedes llevar a cabo. Esto se debe a que este diseño genera un punto de acceso enorme al final de la tabla que no se puede mitigar fácilmente. A medida que aumentan las velocidades de escritura, también lo hacen las RPC en una sola división, al igual que los eventos de contención de bloqueo y otros problemas. A menudo, este tipo de problemas no aparecen en pequeñas pruebas de carga y, en su lugar, aparecen después de que la aplicación ha estado en producción por cierto tiempo. Para entonces, ¡es demasiado tarde!

Si la aplicación debe incluir un registro ordenado por marca de tiempo, considera si puedes convertir el registro local intercalándolo en una de las otras tablas raíz. El beneficio de esto es el de distribuir el punto caliente en muchas raíces. Pero aún se debe tener prestar atención de que cada raíz distinta tenga una velocidad de escritura suficientemente baja.

Si se necesita una tabla ordenada por marca de tiempo global (raíz cruzada), y se necesita admitir velocidades de escritura más altas en esa tabla que las de un solo nodo es capaz de admitir, usa fragmentación de nivel de aplicación. Fragmentar una tabla significa dividirla en un número N de divisiones aproximadamente iguales llamadas fragmentos. Para ello, se suele añadir un prefijo a la clave principal original con una columna ShardId adicional que contenga valores enteros entre [0, N). El ShardId de una escritura determinada se suele seleccionar de forma aleatoria o mediante el cifrado hash de una parte de la clave base. Es preferible usar cifrado de hash porque puede usarse para asegurar que todos los registros de un tipo determinado entren en el mismo fragmento para mejorar el rendimiento de la recuperación. De cualquier manera, el objetivo es garantizar que, con el tiempo, las escrituras se distribuyan por todos los fragmentos por igual. Este enfoque a veces significa que las lecturas necesitan buscar todos los fragmentos para reconstruir el orden total original de las escrituras.

Ilustración de fragmentos para el paralelismo y filas en orden cronológico por fragmento

Recomendaciones:

  • Evite tablas e índices ordenados por marca de tiempo de alta velocidad de escritura a toda costa.
  • Use alguna técnica para propagar puntos calientes, ya sea intercalando en otra tabla o por fragmentación.

Antipatrón: secuencias

Los desarrolladores de aplicaciones adoran usar secuencias (o autoincrementos) de bases de datos para generar claves principales. Este hábito de los días de RDBMS (llamados "claves sustitutivas") es casi tan dañino como el antipatrón para ordenar las marcas de tiempo descrito anteriormente. La razón es que las secuencias de la base de datos tienden a emitir valores de una manera casi monotónica, a lo largo del tiempo, para producir valores que se agrupan uno cerca del otro. Esto normalmente produce puntos calientes cuando se usan como claves principales, especialmente para las filas de la raíz.

Contrario a la sabiduría convencional de RDBMS, recomendamos usar atributos del mundo real para claves primarias cuando tenga sentido. Este es particularmente el caso si el atributo nunca va a cambiar.

Si deseas generar claves principales únicas numéricas, intenta hacer que los bits de orden superior de los números subsiguientes se distribuyan aproximadamente igual en todo el espacio numérico. Un truco es generar números secuenciales por medios convencionales, y luego invertir los bits para obtener un valor final. También puedes usar un generador de UUID, pero ten cuidado: no todas las funciones de UUID se crean de la misma forma y algunas almacenan la marca de tiempo en los bits de orden superior, lo que anula la ventaja. Asegúrate de que el generador UUID elija bits de orden superior de forma pseudoaleatoria.

Recomendaciones:

  • Evita usar valores de secuencia incrementales como claves principales. En cambio, invierte los bits de un valor de secuencia, o usa un UUID cuidadosamente elegido.
  • Usa valores del mundo real para claves principales en lugar de claves sustitutivas.