Azure SQL Database HyperScale es una nueva modalidad alternativa al modelo tradicional de Azure SQL Database (single, elastic y managed instance) que aporta unas características especiales que la hacen única. Desde el punto de vista técnico supone una revolución respecto a lo que tenemos disponible on-premise y en otras alternativas cloud. Podríamos decir que actualmente tenemos SQL Server en distintos sabores.

Por una parte, tenemos un SQL Server 2019 “clásico” tanto en on-premise, como en cloud. En esta categoría entrarían tanto Azure SQL databases single/elastic/managed instances. A partir de ahí tenemos otras variantes que se diferencian sustancialmente del SQL Server “clásico”:

  • Azure SQL Database HyperScale. Esta variante es en la que nos centramos en este artículo y se caracteriza por tener una arquitectura SMP donde la capa de storage es escalable horizontalmente. Solo está disponible en Azure.
  • Azure Synapse Analytics. Previamente conocida como Parallel Datawarehouse, esta alternativa se apoya en una arquitectura MPP donde tanto el storage como el compute es escalable permitiendo un gran rendimiento incluso en volúmenes de datos elevados. Solo está disponible en Azure.
  • Big Data Cluster. Apoyada sobre Kubernetes esta solución ofrece una solución similar a Azure Synapse Analytics que puede ejecutar tanto en on-premise como en cloud hospedado en Azure AKS u otra alternativa.

Como ya hemos comentado Azure SQL Database HyperScale se caracteriza principalmente por separar el almacenamiento de la computación mediante el uso de servidores de páginas y servicio de log. El siguiente diagrama muestra la arquitectura completa:

Desde el punto de vista de acceso a disco vemos que tenemos una estructura multinivel con varias cachés. En los propios nodos de compute, tenemos una caché RBPEX persistente (Resilient Buffer Pool Extension) que permite que las páginas más frecuentemente accedidas no tengan que ir a ningún servidor de páginas, estén en SSD local. En el caso de fallo de caché en este nivel, pasaríamos a la caché RBPEX de mayor tamaño situada en cada uno de los servidores de páginas. En el caso de fallo en dicha caché ya accederíamos al storage tradicional de Azure.

Esta nueva configuración para la capa de datos nos aporta una escalabilidad horizontal respecto al tamaño de nuestra base de datos. Esto nos permite, por ejemplo, que los tiempos de restauración de una base de datos no dependan del volumen de datos almacenados sino que será un tiempo casi constante. Microsoft Research publicó el año pasado un whitepaper muy interesante sobre el proyecto Sócrates (https://www.microsoft.com/en-us/research/uploads/prod/2019/05/socrates.pdf) que ha sido la base el que ha generado la oferta SQL Database HyperScale y donde hay muchos detalles sobre el funcionamiento interno, los benchmarks realizados, etc.

Si analizamos los costes que ahora mismo Azure para una misma configuración con 4 vcores y 500 GB podemos hacernos una idea aproximada de dónde se posiciona esta alternativa:

  • Azure SQL Database General Purpose: $793/mes
  • Azure SQL Managed Instance General Purpose: $791/mes
  • Azure SQL Database HyperScale: $875/mes
  • Azure SQL Database Business Critical: $2108/mes
  • Azure SQL Managed Instance Business Critical: $2103/mes

Desde el punto de vista de costes es una opción algo más costosa que las alternativas General Purpose, pero no mucho más, y si mucho más económica que las Business Critical. A nivel de rendimiento, en teoría, nos encontramos en un punto intermedio desde el lado generalista. Es decir, en Business Critical disponemos de almacenamiento SSD local, el más rápido, mientras que en General Purpose utilizamos SSDs pero remotos (Premium storage). En el caso de HyperScale la capa de almacenamiento final es storage tradicional, pero que gracias a la jerarquía de cachés SSD que comentábamos se comportará a nivel de rendimiento mucho más parecido a los SSD en la mayoría de casos.

Desde el punto de vista de escrituras en el log de transacciones, HyperScale tiene un objetivo de 100 MB/s, que es superior incluso al que tenemos con Business Critical. Donde sí tenemos una diferencia más sustancial es en el tamaño máximo de base de datos/instancia que con HyperScale podemos alcanzar hasta 100 TB versus el máximo de 4TB en Business Critical y 8TB en General Purpose. Es cierto que estos tamaños son bastante elevados, pero para el caso de Managed Instance debemos tener en cuenta que serán compartidos por toda la instancia, por lo que esos 4-8 TB pueden llegar a resultar escasos cuando agregamos los tamaños de varias bases de datos.

Una vez dicho esto creemos que, al menos en teoría, el escenario ideal para esta alternativa HyperScale serían esas bases de datos de tipo OLTP pero que han ido “engordando desmesuradamente” durante el tiempo y donde la mayor cantidad de operaciones se acaban concentrando en los datos “recientes” aunque podemos tener alguna operación puntual de tipo analítico, o algún informe pesado, etc. que consulte los datos históricos.

Para ello vamos a crear una base de datos SQL Database General Purpose con la base de datos AdventureWorksLT (el sample que se ofrece) y vamos a “engordarla” para que tenga cierto tamaño. Comenzaremos configurando la instancia con 4 vCores y un máximo de 500 GB:

Una vez tengamos el sample, vamos a proceder al “engordado” para lo que primero crearemos una primera tabla que nos servirá como plantilla:

CREATE TABLE SalesLT.SalesOrderHeaderEnlarged
  (
  SalesOrderID int NOT NULL IDENTITY (1, 1) NOT FOR REPLICATION,
  RevisionNumber tinyint NOT NULL,
  OrderDate datetime NOT NULL,
  DueDate datetime NOT NULL,
  ShipDate datetime NULL,
  Status tinyint NOT NULL,
  OnlineOrderFlag dbo.Flag NOT NULL,
  SalesOrderNumber  AS (isnull(N'SO'+CONVERT([nvarchar](23),[SalesOrderID],0),N'')),
  PurchaseOrderNumber dbo.OrderNumber NULL,
  AccountNumber dbo.AccountNumber NULL,
  CustomerID int NOT NULL,
  BillToAddressID int NOT NULL,
  ShipToAddressID int NOT NULL,
  CreditCardApprovalCode varchar(15) NULL,
  SubTotal money NOT NULL,
  TaxAmt money NOT NULL,
  Freight money NOT NULL,
  TotalDue  AS (isnull(([SubTotal]+[TaxAmt])+[Freight],(0))),
  Comment nvarchar(128) NULL,
  rowguid uniqueidentifier NOT NULL ROWGUIDCOL,
  ModifiedDate datetime NOT NULL
  )  ON [PRIMARY]
GO

SET IDENTITY_INSERT SalesLT.SalesOrderHeaderEnlarged ON
GO
INSERT INTO SalesLT.SalesOrderHeaderEnlarged (
SalesOrderID, RevisionNumber, OrderDate, DueDate, ShipDate, Status, OnlineOrderFlag, PurchaseOrderNumber, AccountNumber, CustomerID, BillToAddressID, ShipToAddressID, CreditCardApprovalCode, SubTotal, TaxAmt, Freight, Comment, rowguid, ModifiedDate)
SELECT 
SalesOrderID, RevisionNumber, OrderDate, DueDate, ShipDate, Status, OnlineOrderFlag, PurchaseOrderNumber, AccountNumber, CustomerID, BillToAddressID, ShipToAddressID,  CreditCardApprovalCode, SubTotal, TaxAmt, Freight, Comment, rowguid, ModifiedDate 
FROM SalesLT.SalesOrderHeader 
GO
SET IDENTITY_INSERT SalesLT.SalesOrderHeaderEnlarged OFF

Insertaremos en esta tabla unos cuantos registros multiplicados a partir de la tabla original:

INSERT INTO SalesLT.SalesOrderHeaderEnlarged 
  (RevisionNumber, OrderDate, DueDate, ShipDate, Status, OnlineOrderFlag, 
   PurchaseOrderNumber, AccountNumber, CustomerID, 
   BillToAddressID, ShipToAddressID, CreditCardApprovalCode, SubTotal, TaxAmt, Freight, Comment, 
   rowguid, ModifiedDate)
SELECT RevisionNumber, DATEADD(dd, number, OrderDate) AS OrderDate, 
   DATEADD(dd, number, DueDate),  DATEADD(dd, number, ShipDate), 
   Status, OnlineOrderFlag, 
   PurchaseOrderNumber, 
   AccountNumber, 
   CustomerID, BillToAddressID, 
   ShipToAddressID, CreditCardApprovalCode, 
 SubTotal, TaxAmt, Freight, SalesOrderID, 
   NEWID(), DATEADD(dd, number, ModifiedDate)
FROM SalesLT.SalesOrderHeader AS soh WITH (HOLDLOCK TABLOCKX)
CROSS JOIN (
    SELECT number
    FROM (	SELECT TOP 1000 ROW_NUMBER() over (order by (select 1)) number
        FROM sys.objects s1,sys.objects s2,sys.objects s3,sys.objects s4,sys.objects s5
      ) AS tab
) AS r

Una vez hecho esto, por agilizar un poco el tema, clonaremos las tablas con SELECT INTO hasta llegar a un tamaño de 400 GB, numerándolas como si se tratara de un particionado “no nativo” o similar:

SELECT * INTO SalesLT.SalesOrderDetailEnlarged2 FROM SalesLT.SalesOrderDetailEnlarged
SELECT * INTO SalesLT.SalesOrderDetailEnlarged3 FROM SalesLT.SalesOrderDetailEnlarged 
SELECT * INTO SalesLT.SalesOrderDetailEnlarged4 FROM SalesLT.SalesOrderDetailEnlarged 
SELECT * INTO SalesLT.SalesOrderDetailEnlarged5 FROM SalesLT.SalesOrderDetailEnlarged 
SELECT * INTO SalesLT.SalesOrderDetailEnlarged6 FROM SalesLT.SalesOrderDetailEnlarged
(…)
SELECT * INTO SalesLT.SalesOrderDetailEnlarged885 FROM SalesLT.SalesOrderDetailEnlarged
SELECT * INTO SalesLT.SalesOrderDetailEnlarged886 FROM SalesLT.SalesOrderDetailEnlarged 
SELECT * INTO SalesLT.SalesOrderDetailEnlarged887 FROM SalesLT.SalesOrderDetailEnlarged 
SELECT * INTO SalesLT.SalesOrderDetailEnlarged888 FROM SalesLT.SalesOrderDetailEnlarged 
SELECT * INTO SalesLT.SalesOrderDetailEnlarged889 FROM SalesLT.SalesOrderDetailEnlarged

Al finalizar el proceso tenemos ya nuestra base de datos engordada:

Una vez tenemos los datos, las pruebas que realizaremos serán un count(*) que incluya todas las tablas/particiones generadas y un par de operaciones que obtengan un sample del 0.1% y el 1% para cada una de dichas tablas/particiones. Con ello lo que queremos simular sería el caso de una operación masiva que tenga que leer todos los datos junto a un par de operaciones que aún siendo masivas respecto al “espacio total” consultado realmente procesan una cantidad de filas bastante menor.

La primera prueba del count(*) consiste básicamente en ejecutar una consulta similar a esta que realiza un UNION ALL de todas las tablas implicadas:

SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged10 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged100 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged101 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged102 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged103 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged104 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged105 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged106 UNION ALL
(…)
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged899 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged9 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged90 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged91 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged92 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged93 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged94 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged95 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged96 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged97 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged98 UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged99 

En este escenario podemos ver como el rendimiento de la entrada/salida de la General Purpose no es suficiente para “alimentar” los 4 vCores asignados, lo que ralentiza la consulta:

El tiempo total de la operación fue muy elevado, de 4179 segundos, unos 70 minutos, cada muestreo representa 5 minutos en el gráfico. Si volvemos a ejecutar la operación la duración es bastante similar, no apreciamos diferencias significativas.

Para las pruebas sobre un sample de 0.1 y de 1 percent utilizaremos una consulta similar a la anterior usando la opción TABLESAMPLE:

SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged10 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged100 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged101 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged102 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged103 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged104 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged105 TABLESAMPLE (0.1 PERCENT) UNION ALL 
(…)
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged9 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged90 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged91 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged92 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged93 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged94 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged95 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged96 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged97 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged98 TABLESAMPLE (0.1 PERCENT) UNION ALL 
SELECT COUNT (*) FROM SalesLT.SalesOrderDetailEnlarged99 TABLESAMPLE (0.1 PERCENT)

En el caso del 0.1% de filas, el total de filas contadas son unas 6K por tabla, haciendo un conteo total de 4.5M de filas. La duración en General Purpose ha sido de 52 segundos para el 0.1% mientras que para el 1% ha requerido 411 segundos.

El siguiente paso es escalar a Business Critical esta base de datos de 400 GB:

El proceso en total para los 400 GB se demoró algo más de una hora en total, lo cual no es un tiempo desmesurado teniendo en cuenta el volumen de datos y que este tipo de operaciones normalmente son poco habituales:

Una vez tenemos la base de datos en Business Critical, vamos a repetir las consultas anteriores. Para el caso del count(*) masivo el tiempo baja de 70 minutos de la General Purpose a 846 segundos, 14 minutos. Además, podemos ver como se utiliza la CPU por encima del 80%, mientras que la entrada/salida baja al 30% por lo que ocurre lo justo lo contrario que en el caso del General Purpose:

Para los conteos del 0.1% pasamos de 52 segundos a 9 segundos y en el caso del 1% bajamos de 411 segundos a 36 segundos por lo que la mejora es muy significativa. En estos casos concretos hablamos de mejoras entre 5 y 10 veces al pasar a Business Critical. Si volvemos a ejecutar las operaciones obtenemos tiempos prácticamente calcados, lo cual denota una buena estabilidad en el rendimiento en este entorno.

Por tanto, podemos concluir que, si tenemos volúmenes elevados, el consumo de entrada/salida es importante y queremos tiempos estables la Business Critical resulta mucho más equilibrada que la General Purpose.

Una vez que tenemos estas referencias, vamos a proceder a movernos a HyperScale. Es importante tener en cuenta que este cambio no es reversible, por lo que no podremos volver luego a Business Critical ni a General Purpose.

El proceso de cambio se alargó unas 2 horas lo cual puede ser un indicio que el rendimiento puede ser peor que en Business Critical (como el propio posicionamiento por precio sugiere):

Una vez tenemos el escalado realizado, procederemos a ejecutar el primer count(*) por primera vez sobre HyperScale y nos encontramos con una duración total de 3804 segundos. Este valor se encuentra entre el tiempo de General Purpose (4179 segundos) y el de Business Critical (846 segundos) aunque más cerca del primero en realidad. Sin embargo, teniendo en cuenta la arquitectura de este servicio y la existencia de múltiples niveles de caché disponibles deberían existir una diferencia cuando el dato tuviera que volver a ser accedido. Si volvemos a lanzar la operación una segunda vez vemos que el tiempo de ejecución baja a 1027 segundos, que se encuentra, esta vez sí, mucho más cerca del tiempo de Business Critical que del General Purpose. Terceras y posteriores ejecuciones realizadas vemos que se mueven en el entorno de entre 1009 y 1047 segundos por lo que no mejoramos sustancialmente la cifra de la segunda ejecución.

Si analizamos el consumo de CPU, podemos ver cómo claramente la segunda ejecución obtiene picos más altos de uso al obtener un mayor rendimiento de entrada/salida y de ahí que se reduzca el tiempo total de ejecución:

Indicar también que debido a que se desacopla el almacenamiento de la computación, la métrica avg_data_io_percent parece no indicar nada significativo “globalmente”. Es decir, en realidad si indica algo, pero solamente a nivel local, la parte de IO que corresponde a tempdb y a la caché local.

Desgraciadamente a nivel de métrica en Azure no tenemos información sobre ratios de aciertos de caché, cuantos Page Server tenemos en cada momento, que tamaño tienen cada uno, etc.

Podemos obtener parte de esta información desde DMVs, concretamente a través de dm_io_virtual_file_stats tal y como se indica en la documentación (https://docs.microsoft.com/en-us/azure/sql-database/sql-database-HyperScale-performance-diagnostics):

select * from sys.dm_io_virtual_file_stats(0,NULL); -- Tempdb and local cache
select * from sys.dm_io_virtual_file_stats(db_id(),1); -- Data from Page Servers
select * from sys.dm_io_virtual_file_stats(db_id(),2); -- Log service 

De estos datos llama la atención la poca cantidad de aciertos de la caché local (fila con database_id=0 y file_id=0), por lo que, o bien estos datos no son correctos, o bien el beneficio de rendimiento que apreciamos tiene que venir vía caché local en memoria más la caché RBPEX propia de los servidores de páginas.

Respecto a los tiempos en el caso del count(*) sobre un 0.1% pasamos de los 52 segundos de General Purpose y 9 segundos de Business Critical a 17 segundos en HyperScale, el doble que Business Critical pero lejos de los 52 segundos de General Purpose. Para el caso del conteo sobre el 1% pasamos de los 411 segundos de General Purpose y 36 segundos de Business Critical a 100 segundos en HyperScale, de nuevo en un punto medio pero más cerca de Business Critical que de General Purpose.

En una última prueba hemos querido comparar también los tiempos de inserción que teníamos para cada una de las tablas que clonamos en General Purpose, que cada operación implicaba unos 30 segundos, con HyperScale implica únicamente unos 6-7 segundos. Puede ser por tanto un buen caso de uso cuando la velocidad de escritura en el log de las otras alternativas se quede escasa:

SELECT * INTO SalesLT.SalesOrderDetailEnlarged_hyper FROM SalesLT.SalesOrderDetailEnlarged  
-- 30-35 sec GP
-- 6-7 sec HyperScale

Podemos ver como en el caso de HyperScale no se limita en base al número de cores dicho rendimiento del log, es un máximo constante, por lo que en cargas más intensivas de escritura en una configuración con pocos vCores lo previsible sería incluso batir a Business Critical en este aspecto (es cierto que es un aspecto donde cojea Business Critical).

Por último, no tenemos que olvidar que otra característica clave de HyperScale es el uso de snapshots tanto para backups como para restores. Esta característica, cuando hablamos de grandes volúmenes de datos, marca una diferencia enorme en el tiempo necesario para realizar estas operaciones de forma ágil.

Otra característica que es especialmente ágil en HyperScale es el escalado rápido hacia arriba o hacia abajo comparado con otras alternativas. Por ejemplo, pasar de 4 vCores a 6 vCores nos ha llevado unos pocos segundos, ni siquiera llegó a 1 minuto, pese a tratarse de una base de datos de 400 GB, gracias a esa desconexión entre computación y datos:

En conclusión, creemos que HyperScale aporta un punto intermedio a nivel de rendimiento entre General Purpose y Business Critical muy interesante. En la mayor parte de los casos, el rendimiento está más cercano a Business Critical y sin embargo el coste es mucho más cercano a General Purpose. No olvidemos también que nos permite llegar hasta 100 TB de tamaño, muy por encima de los 8 TB y 4 TB de las otras alternativas por lo que puede llegar a ser la única opción PaaS cuando el volumen de datos a tratar sea muy elevado.

Si tienes una base de datos que ha ido creciendo de tamaño fuera de control, soporta tanto cargas OLTP como analíticas y quieres modernizarla con una alternativa PaaS te recomendamos que consideres seriamente una prueba de concepto con Azure SQL Database HyperScale.

 

¿Quieres asistir a uno de nuestros workshops en Microsoft Ibérica? Mira:

👉🏻 Discover Workshop – Hands-on lab, Data Modernization in a Day 

Jueves 12 de Marzo de 2020 | 10:00h – 17:00h – Pozuelo de Alarcón. Madrid

En este taller, aprenderás a desarrollar un plan para migrar las bases de datos de las máquinas virtuales locales y SQL Server 2008 R2 a una combinación de servicios IaaS y PaaS en Azure. Se realizarán estudios para revelar cualquier problema de homogeneidad y compatibilidad entre las bases de datos SQL Server 2008 R2 del cliente y las ofertas de bases de datos administradas en Azure. A continuación, se diseñará una solución para migrar sus servicios locales, incluyendo máquinas virtuales y bases de datos, a Azure, con un tiempo de inactividad mínimo o nulo. Finalmente, se mostrarán algunas de las características avanzadas de SQL disponibles en Azure para mejorar la seguridad y el rendimiento en las aplicaciones del cliente.

🗣 Cualquier persona interesada en conocer buenas prácticas sobre cómo hacer una migración de bases de datos a servicios cloud.

>> Más información e inscripción
Rubén Garrigós