Clave primaria compuesta en la base de datos de SQL Server multiinquilino

16

Estoy creando una aplicación multiinquilino (base de datos única, esquema único) usando ASP Web API, Entity Framework y la base de datos SQL Server / Azure. Esta aplicación será utilizada por 1000-5000 clientes. Todas las tablas tendrán el campo TenantId(Guid / UNIQUEIDENTIFIER). En este momento, uso la clave primaria de campo único que es Id (Guid). Pero al usar solo el campo Id, tengo que verificar si los datos proporcionados por el usuario provienen del inquilino correcto. Por ejemplo, tengo una SalesOrdertabla que tiene un CustomerIdcampo. Cada vez que los usuarios publican / actualizan un pedido de ventas, tengo que verificar si CustomerIdes del mismo inquilino. Se pone peor porque cada inquilino podría tener varios puntos de venta. Entonces tengo que comprobar TenantIdy OutletId. Es realmente una pesadilla de mantenimiento y malo para el rendimiento.

Estoy pensando en agregar TenantIda la clave principal junto con Id. Y posiblemente agregue OutletIdtambién. Así que la clave principal de la SalesOrdertabla será: Id, TenantId, y OutletId. ¿Cuál es la desventaja de este enfoque? ¿El rendimiento dolería gravemente con una clave compuesta? ¿Importa el orden de las teclas compuestas? ¿Hay mejores soluciones para mi problema?

Reynaldi
fuente

Respuestas:

34

Después de haber trabajado en un sistema de múltiples inquilinos a gran escala (enfoque federado con clientes distribuidos en más de 18 servidores, cada servidor tiene un esquema idéntico, solo clientes diferentes y miles de transacciones por segundo por cada servidor), puedo decir:

  1. Hay algunas personas (algunas, al menos) que estarán de acuerdo en su elección de GUID como ID para ambos "TenantID" y cualquier entidad "ID". Pero no, no es una buena opción. Dejando a un lado todas las demás consideraciones, esa opción por sí sola perjudicará de varias maneras: fragmentación para comenzar, grandes cantidades de espacio desperdiciado (no digas que el disco es barato cuando piensas en el almacenamiento empresarial, SAN) o que las consultas tardan más debido a cada página de datos manteniendo menos filas de lo que podría con cualquiera INTo BIGINTincluso), soporte y mantenimiento más difíciles, etc. Los GUID son excelentes para la portabilidad. ¿Los datos se generan en algún sistema y luego se transfieren a otro? De lo contrario, cambie a un tipo de datos más compacto (p TINYINT. Ej SMALLINT. INT, O incluso BIGINT) e incremente secuencialmente mediante IDENTITYoSEQUENCE.

  2. Con el elemento 1 fuera del camino, realmente necesita tener el campo TenantID en CADA tabla que tenga datos de usuario. De esa manera, puede filtrar cualquier cosa sin necesidad de una UNIÓN adicional. Esto también significa que TODAS las consultas contra las tablas de datos del cliente deben tener la TenantIDcondición JOIN y / o la cláusula WHERE. Esto también ayuda a garantizar que no mezcle accidentalmente datos de diferentes clientes, ni muestre datos del Inquilino A del Inquilino B.

  3. Estoy pensando en agregar TenantId como clave principal junto con Id. Y posiblemente también agregue OutletId. Por lo tanto, las claves principales en la tabla de pedidos de ventas serán Id, TenantId, OutletId.

    Sí, debe tener sus índices agrupados en las tablas de datos del cliente como claves compuestas, incluyendo TenantIDy ID ** . Esto también garantiza que TenantIDesté en todos los índices no agrupados (ya que incluyen las claves de índice agrupadas) que necesitaría de todos modos, ya que el 98.45% de las consultas contra las tablas de datos del cliente necesitarán TenantID(la excepción principal es cuando la recolección de basura se basa en datos antiguos encendido CreatedDatey sin importarle TenantID).

    No, no incluirías FK como OutletIDen el PK. El PK necesita identificar de manera única la fila, y agregar FK no ayudaría con eso. De hecho, aumentaría las posibilidades de datos duplicados, suponiendo que OrderID fuera único para cada uno TenantID, en lugar de único para cada uno OutletIDdentro de cada uno TenantID.

    Además, no es necesario agregar OutletIDa la PK para garantizar que las salidas del inquilino A no se mezclen con el inquilino B. Dado que todas las tablas de datos de usuario tendrán TenantIDen la PK, eso significa TenantIDque también estarán en las FK . Por ejemplo, la Outlettabla tiene una PK de (TenantID, OutletID), y la Ordertabla tiene una PK de (TenantID, OrderID) y un FK de los (TenantID, OutletID)cuales hace referencia a la PK en la Outlettabla. Los FK correctamente definidos evitarán que los datos del inquilino se mezclen.

  4. ¿Importa el orden de las teclas compuestas?

    Bueno, aquí es donde se pone divertido. Existe cierto debate sobre qué campo debe venir primero. La regla "típica" para diseñar buenos índices es elegir el campo más selectivo para que sea el campo principal. TenantID, por su propia naturaleza, no será el campo más selectivo; El IDcampo es el campo más selectivo. Aquí hay algunos pensamientos:

    • ID primero: este es el campo más selectivo (es decir, el más exclusivo). Pero al ser un campo de incremento automático (o aleatorio si todavía se usan GUID), los datos de cada cliente se distribuyen en cada tabla. Esto significa que hay momentos en que un cliente necesita 100 filas, y eso requiere que se lean casi 100 páginas de datos del disco (no rápido) en el Grupo de búferes (ocupando más espacio que 10 páginas de datos). También aumenta la contención en las páginas de datos, ya que será más frecuente que múltiples clientes necesiten actualizar la misma página de datos.

      Sin embargo, por lo general, no se topa con tantos problemas de detección de parámetros / plan de caché incorrecto, ya que las estadísticas en los diferentes valores de ID son bastante consistentes. Es posible que no obtenga los planes más óptimos, pero será menos probable que obtenga planes horribles. Este método esencialmente sacrifica el rendimiento (ligeramente) en todos los clientes para obtener el beneficio de problemas menos frecuentes.

    • TenantID primero:Esto no es selectivo en absoluto. Puede haber muy poca variación en 1 millón de filas si solo tiene 100 TenantID. Pero las estadísticas para estas consultas son más precisas ya que SQL Server sabrá que una consulta para el Inquilino A retirará 500,000 filas, pero esa misma consulta para el Inquilino B es de solo 50 filas. Aquí es donde está el principal punto de dolor. Este método aumenta en gran medida las posibilidades de tener problemas de detección de parámetros donde la primera ejecución de un Procedimiento almacenado es para el Inquilino A y actúa de manera adecuada en función de que el Optimizador de consultas vea esas estadísticas y sepa que debe ser eficiente para obtener 500k filas. Pero cuando el Inquilino B, con solo 50 filas, se ejecuta, ese plan de ejecución ya no es apropiado y, de hecho, es bastante inapropiado. Y, dado que los datos no se insertan en el orden del campo inicial,

      Sin embargo, para que el primer TenantID ejecute un Procedimiento almacenado, el rendimiento debería ser mejor que en el otro enfoque, ya que los datos (al menos después de realizar el mantenimiento del índice) se organizarán física y lógicamente de modo que se necesiten muchas menos páginas de datos para satisfacer el consultas Esto significa menos E / S físicas, menos lecturas lógicas, menos contención entre Inquilinos por las mismas páginas de datos, menos espacio desperdiciado ocupado en el Grupo de búferes (por lo tanto, mejora la esperanza de vida de la página), etc.

      Hay dos costos principales para obtener este rendimiento mejorado. El primero no es tan difícil: debe realizar un mantenimiento de índice regular para contrarrestar el aumento de la fragmentación. El segundo es un poco menos divertido.

      Para contrarrestar los problemas de detección de parámetros aumentados, debe separar los planes de ejecución entre los inquilinos. El enfoque simplista es usar WITH RECOMPILEen procs o la OPTION (RECOMPILE)sugerencia de consulta, pero eso es un éxito en el rendimiento que podría borrar todas las ganancias obtenidas al poner TenantIDprimero. El método que encontré que funcionó mejor es usar SQL dinámico parametrizado a través de sp_executesql. La razón por la que se necesita el SQL dinámico es permitir la concatenación del TenantID en el texto de la consulta, mientras que todos los demás predicados que normalmente serían parámetros siguen siendo parámetros. Por ejemplo, si estaba buscando un Pedido en particular, haría algo como:

      DECLARE @GetOrderSQL NVARCHAR(MAX);
      SET @GetOrderSQL = N'
        SELECT ord.field1, ord.field2, etc.
        FROM   dbo.Orders ord
        WHERE  ord.TenantID = ' + CONVERT(NVARCHAR(10), @TenantID) + N'
        AND    ord.OrderID = @OrderID_dyn;
      ';
      
      EXEC sp_executesql
         @GetOrderSQL,
         N'@OrderID_dyn INT',
         @OrderID_dyn = @OrderID;

      El efecto que esto tiene es crear un plan de consulta reutilizable para ese TenantID que coincida con el volumen de datos de ese Inquilino en particular. Si ese mismo Inquilino A ejecuta el procedimiento almacenado nuevamente para otro @OrderID, reutilizará ese plan de consulta en caché. Un inquilino diferente que ejecute el mismo procedimiento almacenado generaría un texto de consulta que solo era diferente en el valor del TenantID, pero cualquier diferencia en el texto de consulta es suficiente para generar un plan diferente. Y el plan generado para el Inquilino B no solo coincidirá con el volumen de datos para el Inquilino B, sino que también será reutilizable para el Inquilino B para diferentes valores de @OrderID(ya que ese predicado todavía está parametrizado).

      Las desventajas de este enfoque son:

      • Es un poco más de trabajo que simplemente escribir una consulta simple (pero no todas las consultas deben ser SQL dinámico, solo las que terminan teniendo el problema de detección de parámetros).
      • Dependiendo de cuántos inquilinos hay en un sistema, aumenta el tamaño de la caché del plan ya que cada consulta ahora requiere 1 plan por TenantID que lo está llamando. Esto podría no ser un problema, pero al menos es algo a tener en cuenta.
      • El SQL dinámico rompe la cadena de propiedad, lo que significa que no se puede asumir el acceso de lectura / escritura a las tablas al tener EXECUTEpermiso en el Procedimiento almacenado. La solución fácil pero menos segura es simplemente dar al usuario acceso directo a las tablas. Ciertamente, esto no es lo ideal, pero suele ser una solución de compromiso rápida y fácil. El enfoque más seguro es utilizar la seguridad basada en certificados. Es decir, crear un Certificado, luego crear un Usuario a partir de ese Certificado, otorgarle a ese Usuario los permisos deseados (un Usuario o Inicio de Sesión basado en un Certificado no puede conectarse a SQL Server por sí solo) y luego firmar los Procedimientos Almacenados que usan SQL Dinámico con eso mismo certificado a través de AGREGAR FIRMA .

        Para obtener más información sobre la firma de módulos y certificados, consulte: ModuleSigning.Info
         

    Consulte la sección ACTUALIZACIÓN hacia el final para ver temas adicionales relacionados con el tema de tratar con los problemas de estadísticas de mitigación resultantes de esta decisión.


** Personalmente, realmente no me gusta usar solo "ID" para el nombre del campo PK en cada tabla, ya que no es significativo, y no es coherente en todas las FK ya que la PK siempre es "ID" y el campo en la tabla secundaria tiene que incluye el nombre de la tabla principal. Por ejemplo: Orders.ID-> OrderItems.OrderID. Me resulta mucho más fácil tratar con un modelo de datos que tiene: Orders.OrderID-> OrderItems.OrderID. Es más legible y reduce el número de veces que obtendrá el error "referencia de columna ambigua" :-).


ACTUALIZAR

  • ¿ OPTIMIZE FOR UNKNOWN La sugerencia de consulta (introducida en SQL Server 2008) ayudaría con el pedido de la PK compuesta?

    Realmente no. Esta opción evita problemas de detección de parámetros, pero simplemente reemplaza un problema con otro. En este caso, en lugar de recordar la información estadística de los valores de los parámetros de la ejecución inicial del procedimiento almacenado o la consulta parametrizada (que es definitivamente excelente para algunos, pero potencialmente mediocre para algunos y potencialmente horrible para algunos), utiliza un método general. estadística de distribución de datos para estimar recuentos de filas. Esto es impredecible en cuanto a cuántas consultas (y en qué grado) se verán afectadas de manera positiva, negativa o nada. Al menos con la detección de parámetros, se garantizó que algunas consultas se beneficiarían. Si su sistema tiene inquilinos con volúmenes de datos muy variados, esto podría afectar el rendimiento de todas las consultas.

    Esta opción logra lo mismo que copiar los parámetros de entrada a las variables locales y luego usar las variables locales en la consulta (he probado esto pero no hay espacio para eso aquí). Se puede encontrar información adicional en esta publicación de blog: http://www.brentozar.com/archive/2013/06/optimize-for-unknown-sql-server-parameter-sniffing/ . Al leer los comentarios, Daniel Pepermans llegó a una conclusión similar a la mía con respecto al uso de SQL dinámico que tiene una variación limitada.

  • Si ID es el campo principal en el índice agrupado, ¿sería útil / suficiente tener un índice no agrupado en (TenantID, ID) o simplemente (TenantID) para tener estadísticas precisas para las consultas que procesan muchas filas de un solo inquilino?

    Sí, eso ayudaría. El gran sistema en el que mencioné trabajar durante años se basó en un diseño de índice de tener el IDENTITYcampo como el campo principal porque era más selectivo y reducía los problemas de detección de parámetros. Sin embargo, cuando necesitábamos operaciones contra una buena parte de los datos de un Inquilino en particular, el rendimiento no se mantuvo. De hecho, un proyecto para migrar todos los datos a nuevas bases de datos tuvo que quedar en espera porque los controladores SAN se maximizaron en términos de rendimiento. La solución fue agregar índices no agrupados a todas las tablas de datos de inquilinos para que sean justos (TenantID). No es necesario hacerlo (TenantID, ID) ya que la ID ya está en el índice agrupado, por lo que la estructura interna del índice no agrupado era naturalmente (TenantID, ID).

    Si bien esto resolvió el problema inmediato de poder hacer consultas basadas en TenantID de manera mucho más eficiente, todavía no eran tan eficientes como podrían haber sido si el Índice agrupado estuviera en el mismo orden. Y, ahora teníamos un índice más en cada tabla. Eso aumentó la cantidad de espacio SAN que estábamos usando, aumentó el tamaño de nuestras copias de seguridad, hizo que las copias de seguridad tardaran más en completarse, aumentó el potencial de bloqueo y puntos muertos, disminuyó el rendimiento INSERTy las DELETEoperaciones, etc.

    Y todavía nos quedamos con la ineficiencia general de tener los datos de un Inquilino distribuidos en muchas páginas de datos, entremezclados con muchos otros datos del Inquilino. Como mencioné anteriormente, esto aumenta la cantidad de contención en estas páginas y llena el Buffer Pool con muchas páginas de datos que tienen 1 o 2 filas útiles en ellas, especialmente cuando algunas de las filas en esas páginas eran para clientes que estaban inactivos pero todavía no se había recogido basura. Hay mucho menos potencial para la reutilización de las páginas de datos en el Buffer Pool en este enfoque, por lo que nuestra expectativa de vida de la página era bastante baja. Y eso significa más tiempo para volver al disco para cargar más páginas.

Solomon Rutzky
fuente
2
¿Ha considerado o probado OPTIMIZAR PARA DESCONOCIDO en este espacio problemático? Sólo curioso.
RLF
1
@RLF Sí, investigamos esa opción, y debería ser al menos no mejor, y posiblemente peor, que el rendimiento menos que óptimo que obtuvimos al tener primero el campo IDENTIDAD. No recuerdo dónde leí esto, pero supuestamente proporciona las mismas estadísticas "promedio" que la reasignación de un parámetro de entrada a una variable local. Pero este artículo explica por qué esa opción realmente no resuelve el problema: brentozar.com/archive/2013/06 /... Al leer los comentarios, Daniel Pepermans llegó a una conclusión similar sobre: ​​SQL dinámico con variación limitada :)
Solomon Rutzky
3
¿Qué sucede si el índice agrupado está activado (ID, TenantID)y también crea un índice no agrupado (TenantID, ID), o simplemente (TenantID)para tener estadísticas precisas de consultas que procesan la mayoría de las filas de un solo inquilino?
Vladimir Baranov
1
@VladimirBaranov Excelente pregunta. Lo he abordado en una nueva sección ACTUALIZACIÓN hacia el final de la respuesta :-).
Solomon Rutzky
44
Un buen punto sobre el sql dinámico para generar planes para cada cliente.
Max Vernon