Procedimiento almacenado de la base de datos con un "modo de vista previa"

15

Un patrón bastante común en la aplicación de base de datos con la que trabajo es la necesidad de crear un procedimiento almacenado para un informe o utilidad que tenga un "modo de vista previa". Cuando dicho procedimiento se actualiza, este parámetro indica que los resultados de la acción deben devolverse, pero el procedimiento no debe realizar las actualizaciones en la base de datos.

Una forma de lograr esto es simplemente escribir una ifdeclaración para el parámetro y tener dos bloques de código completos; uno de los cuales actualiza y devuelve datos y el otro solo devuelve los datos. Pero esto no es deseable debido a la duplicación de código y a un grado relativamente bajo de confianza de que los datos de vista previa son en realidad un reflejo preciso de lo que sucedería con una actualización.

El siguiente ejemplo intenta aprovechar los puntos de guardado y las variables de la transacción (que no se ven afectados por las transacciones, en contraste con las tablas temporales que sí lo están) para usar un solo bloque de código para el modo de vista previa como el modo de actualización en vivo.

Nota: Las reversiones de transacciones no son una opción, ya que esta llamada de procedimiento puede anidarse en una transacción. Esto se prueba en SQL Server 2012.

CREATE TABLE dbo.user_table (a int);
GO

CREATE PROCEDURE [dbo].[PREVIEW_EXAMPLE] (
  @preview char(1) = 'Y'
) AS

CREATE TABLE #dataset_to_return (a int);

BEGIN TRANSACTION; -- preview mode required infrastructure
  DECLARE @output_to_return TABLE (a int);
  SAVE TRANSACTION savepoint;

  -- do stuff here
  INSERT INTO dbo.user_table (a)
    OUTPUT inserted.a INTO @output_to_return (a)
    VALUES (42);

  -- catch preview mode
  IF @preview = 'Y'
    ROLLBACK TRANSACTION savepoint;

  -- save output to temp table if used for return data
  INSERT INTO #dataset_to_return (a)
  SELECT a FROM @output_to_return;
COMMIT TRANSACTION;

SELECT a AS proc_return_data FROM #dataset_to_return;
RETURN 0;
GO

-- Examples
EXEC dbo.PREVIEW_EXAMPLE @preview = 'Y';
SELECT a AS user_table_after_preview_mode FROM user_table;

EXEC dbo.PREVIEW_EXAMPLE @preview = 'N';
SELECT a AS user_table_after_live_mode FROM user_table;

-- Cleanup
DROP TABLE dbo.user_table;
DROP PROCEDURE dbo.PREVIEW_EXAMPLE;
GO

Estoy buscando comentarios sobre este código y patrón de diseño, y / o si existen otras soluciones para el mismo problema en diferentes formatos.

NReilingh
fuente

Respuestas:

12

Hay varios defectos en este enfoque:

  1. El término "vista previa" puede ser bastante engañoso en la mayoría de los casos, dependiendo de la naturaleza de los datos que se están utilizando (y eso cambia de una operación a otra). Lo que debe garantizarse es que los datos actuales que se están operando estarán en el mismo estado entre el momento en que se recopilan los datos de "vista previa" y cuando el usuario regresa 15 minutos después, después de tomar un café, salir a fumar, caminar alrededor de la cuadra, volviendo y comprobando algo en eBay, y se da cuenta de que no hicieron clic en el botón "Aceptar" para realizar la operación y finalmente hacen clic en el botón.

    ¿Tiene un límite de tiempo para continuar con la operación después de que se genera la vista previa? ¿O posiblemente una forma de determinar que los datos están en el mismo estado en el momento de la modificación que en el SELECTmomento inicial ?

  2. Este es un punto menor ya que el código de ejemplo podría haberse hecho rápidamente y no representar un caso de uso verdadero, pero ¿por qué habría una "Vista previa" para una INSERToperación? Eso podría tener sentido al insertar varias filas a través de algo así INSERT...SELECTy podría haber un número variable de filas insertadas, pero esto no tiene mucho sentido para una operación singleton.

  3. esto no es deseable debido a ... un grado relativamente bajo de confianza de que los datos de vista previa son en realidad un reflejo preciso de lo que sucedería con una actualización.

    ¿De dónde viene exactamente este "bajo grado de confianza"? Si bien es posible actualizar un número diferente de filas que el que se muestra para SELECTcuando se unen varias tablas y hay una duplicación de filas en un conjunto de resultados, eso no debería ser un problema aquí. Las filas que deberían verse afectadas por un se UPDATEpueden seleccionar por sí mismas. Si hay una falta de coincidencia, entonces está haciendo la consulta incorrectamente.

    Y aquellas situaciones en las que hay duplicación debido a una tabla UNIDA que coincide con varias filas en la tabla que se actualizará no son situaciones en las que se generaría una "Vista previa". Y si hay una ocasión en la que este es el caso, entonces debe explicarse al usuario que se actualiza un subconjunto del informe que se repite dentro del informe para que no parezca un error si alguien solo está mirando el número de filas afectadas.

  4. En aras de la integridad (a pesar de que las otras respuestas mencionaron esto), no está utilizando la TRY...CATCHconstrucción, por lo que podría tener problemas al anidar estas llamadas (incluso si no usa Guardar puntos, e incluso si no usa Transacciones). Consulte mi respuesta a la siguiente pregunta, aquí en DBA.SE, para obtener una plantilla que maneja las transacciones a través de llamadas de procedimiento almacenado anidadas:

    ¿Estamos obligados a manejar la transacción en código C # así como en el procedimiento almacenado?

  5. INCLUSO SI se tuvieron en cuenta los problemas mencionados anteriormente, todavía hay una falla crítica: durante el corto período de tiempo que se realiza la operación (es decir, antes del ROLLBACK), cualquier consulta de lectura sucia (consultas que utilizan WITH (NOLOCK)o SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED) pueden obtener datos que No hay un momento después. Si bien cualquiera que use consultas de lectura sucia ya debería ser consciente de esto y haber aceptado esa posibilidad, operaciones como esta aumentan en gran medida las posibilidades de introducir anomalías en los datos que son muy difíciles de depurar (es decir: cuánto tiempo desea pasar intentando encuentra un problema que no tiene una causa directa aparente?

  6. Un patrón como este también degrada el rendimiento del sistema al aumentar el bloqueo al eliminar más bloqueos y al generar más actividad del Registro de transacciones. (Ahora veo que @MartinSmith también mencionó estos 2 problemas en un comentario sobre la Pregunta).

    Además, si hay Triggers en las tablas que se están modificando, eso podría ser un poco de procesamiento adicional (CPU y lecturas físicas / lógicas) que es innecesario. Los disparadores también aumentarían aún más las posibilidades de anomalías en los datos como resultado de lecturas sucias.

  7. En relación con el punto señalado directamente arriba: mayores bloqueos, el uso de la transacción aumenta la probabilidad de encontrarse con puntos muertos, especialmente si están involucrados activadores.

  8. Un problema menos grave que debería relacionarse solo con el escenario menos probable de las INSERToperaciones: los datos de "Vista previa" podrían no ser los mismos que los insertados con respecto a los valores de columna determinados por DEFAULTRestricciones ( Sequences/ NEWID()/ NEWSEQUENTIALID()) y IDENTITY.

  9. No hay necesidad de la sobrecarga adicional de escribir los contenidos de la variable de tabla en la tabla temporal. Esto ROLLBACKno afectaría los datos en la Variable de tabla (razón por la cual dijo que estaba usando Variables de tabla en primer lugar), por lo que tendría más sentido simplemente SELECT FROM @output_to_return;al final, y luego ni siquiera se moleste en crear el Temporal Mesa.

  10. En caso de que no se conozca este matiz de Save Points (difícil de distinguir del código de ejemplo, ya que solo muestra un único Procedimiento almacenado): debe usar nombres únicos de Save Point para que la ROLLBACK {save_point_name}operación se comporte como lo espera. Si reutiliza los nombres, un ROLLBACK revertirá el punto de guardado más reciente de ese nombre, que podría no estar en el mismo nivel de anidamiento desde el que ROLLBACKse llama. Consulte el primer bloque de código de ejemplo en la siguiente respuesta para ver este comportamiento en acción: Transacción en un procedimiento almacenado

A lo que se reduce esto es a:

  • Hacer una "Vista previa" no tiene mucho sentido para las operaciones orientadas al usuario. Hago esto con frecuencia para las operaciones de mantenimiento para poder ver lo que se eliminará / Recolección de basura si continúo con la operación. Agrego un parámetro opcional llamado @TestModey hago una IFdeclaración que hace una SELECTcuando @TestMode = 1más lo hace DELETE. A veces agrego el @TestModeparámetro a los procedimientos almacenados llamados por la aplicación para que yo (y otros) puedan hacer pruebas simples sin afectar el estado de los datos, pero la aplicación nunca usa este parámetro.

  • En caso de que esto no estuviera claro en la sección superior de "problemas":

    Si necesita / desea un modo "Vista previa" / "Prueba" para ver qué debería verse afectado si se ejecutara la instrucción DML, entonces NO use Transacciones (es decir, el BEGIN TRAN...ROLLBACKpatrón) para lograr esto. Es un patrón que, en el mejor de los casos, solo funciona realmente en un sistema de usuario único, y ni siquiera es una buena idea en esa situación.

  • La repetición de la mayor parte de la consulta entre las dos ramas de la IFdeclaración presenta un problema potencial de necesidad de actualizar ambas cada vez que hay que hacer un cambio. Sin embargo, las diferencias entre las dos consultas suelen ser lo suficientemente fáciles de detectar en una revisión de código y fáciles de solucionar. Por otro lado, los problemas como las diferencias de estado y las lecturas sucias son mucho más difíciles de encontrar y solucionar. Y el problema de la disminución del rendimiento del sistema es imposible de solucionar. Necesitamos reconocer y aceptar que SQL no es un lenguaje orientado a objetos, y la encapsulación / reducción de código duplicado no era un objetivo de diseño de SQL como lo era con muchos otros lenguajes.

    Si la consulta es lo suficientemente larga / compleja, puede encapsularla en una función en línea con valores de tabla. Luego puede hacer un simple SELECT * FROM dbo.MyTVF(params);para el modo "Vista previa", y UNIRSE a los valores clave para el modo "hacerlo". Por ejemplo:

    UPDATE tab
    SET    tab.Col2 = tvf.ColB
           ...
    FROM   dbo.Table tab
    INNER JOIN dbo.MyTVF(params) tvf
            ON tvf.ColA = tab.Col1;
  • Si este es un escenario de informe como usted mencionó que podría ser, entonces ejecutar el informe inicial es la "Vista previa". Si alguien quiere cambiar algo que ve en el informe (un estado tal vez), entonces eso no requiere una vista previa adicional ya que la expectativa es cambiar los datos que se muestran actualmente.

    Si la operación es quizás cambiar el monto de una oferta por un cierto% o regla comercial, entonces eso se puede manejar en la capa de presentación (JavaScript?).

  • Si realmente necesita hacer una "Vista previa" para una operación orientada al usuario final, primero debe capturar el estado de los datos (quizás un hash de todos los campos en el conjunto de resultados para UPDATEoperaciones o los valores clave para DELETEoperaciones), y luego, antes de realizar la operación, compare la información del estado capturado con la información actual, dentro de una transacción haciendo un HOLDbloqueo en la tabla para que nada cambie después de hacer esta comparación, y si hay CUALQUIER diferencia, arroje un error y hacer un en ROLLBACKlugar de proceder con el UPDATEo DELETE.

    Para detectar diferencias en las UPDATEoperaciones, una alternativa para calcular un hash en los campos relevantes sería agregar una columna de tipo ROWVERSION . El valor de un ROWVERSIONtipo de datos cambia automáticamente cada vez que hay un cambio en esa fila. Si tuviera una columna de este tipo, la incluiría SELECTjunto con los otros datos de "Vista previa" y luego la pasaría al paso "seguro, continúe y realice la actualización" junto con los valores clave y los valores cambiar. A continuación, comparar esos ROWVERSIONvalores pasados a partir de la "Vista previa" con los valores actuales (por cada tecla), y sólo proceder con el UPDATEcaso de ALLde los valores coincidentes. El beneficio aquí es que no necesita calcular un hash que tiene el potencial, incluso si es poco probable, de falsos negativos, y toma una cierta cantidad de tiempo cada vez que lo hace SELECT. Por otro lado, el ROWVERSIONvalor se incrementa automáticamente solo cuando se cambia, por lo que nada de lo que deba preocuparse. Sin embargo, el ROWVERSIONtipo es de 8 bytes, que pueden sumar cuando se trata de muchas tablas y / o muchas filas.

    Existen ventajas y desventajas en cada uno de estos dos métodos para tratar de detectar estados inconsistentes relacionados con las UPDATEoperaciones, por lo que deberá determinar qué método tiene más "pro" que "con" para su sistema. Pero en cualquier caso, puede evitar una demora entre la generación de la Vista previa y la realización de la operación para causar un comportamiento fuera de las expectativas del usuario final.

  • Si está haciendo un modo de "Vista previa" orientado al usuario final, además de capturar el estado de los registros en el tiempo de selección, pasar y verificar en el momento de la modificación, incluya un DATETIMEfor SelectTimey complete vía GETDATE()o algo similar. Pase eso a la capa de la aplicación para que pueda volver al procedimiento almacenado (principalmente como un parámetro de entrada único) para que pueda verificarse en el Procedimiento almacenado. Luego puede determinar que SI la operación no es el modo "Vista previa", entonces el @SelectTimevalor no debe ser más de X minutos antes del valor actual de GETDATE(). ¿Quizás 2 minutos? ¿5 minutos? Lo más probable es que no más de 10 minutos. Lanza un error si DATEDIFFen MINUTOS supera ese umbral.

Solomon Rutzky
fuente
4

El enfoque más simple es a menudo el mejor y realmente no tengo tantos problemas con la duplicación de código en SQL, especialmente no en el mismo módulo. Después de todas las dos consultas están haciendo cosas diferentes. Entonces, ¿por qué no tomar 'Route 1' o Keep It Simple y solo tener dos secciones en el proceso almacenado, una para simular el trabajo que necesita hacer y otra para hacerlo, por ejemplo, algo como esto:

CREATE TABLE dbo.user_table ( rowId INT IDENTITY PRIMARY KEY, a INT NOT NULL, someGuid UNIQUEIDENTIFIER DEFAULT NEWID() );
GO
CREATE PROCEDURE [dbo].[PREVIEW_EXAMPLE2]

    @preview CHAR(1) = 'Y'

AS

    SET NOCOUNT ON

    --!!TODO add error handling

    IF @preview = 'Y'

        -- Simulate INSERT; could be more complex
        SELECT 
            ISNULL( ( SELECT MAX(rowId) FROM dbo.user_table ), 0 ) + 1 AS rowId,
            42 AS a,
            NEWID() AS someGuid

    ELSE

        -- Actually do the INSERT, return inserted values
        INSERT INTO dbo.user_table ( a )
        OUTPUT inserted.rowId, inserted.a, inserted.someGuid
        VALUES ( 42 )

    RETURN

GO

Esto tiene la ventaja de autodocumentarse (es decir, IF ... ELSEes fácil de seguir), de baja complejidad (en comparación con el punto de guardado con el enfoque de tabla variable IMO), por lo tanto, es menos probable que tenga errores (excelente lugar de @Cody).

Con respecto a su punto de baja confianza, no estoy seguro de entender. Lógicamente, dos consultas con el mismo criterio deberían hacer lo mismo. Existe la posibilidad de desajuste de cardinalidad entre an UPDATEy a SELECT, pero sería una característica de sus uniones y criterios. ¿Puedes explicar más?

Por otro lado, debe establecer la propiedad NULL/ NOT NULLy sus tablas y variables de tabla, considere establecer una clave primaria.

Su enfoque original parece un poco demasiado complicado, posiblemente podría ser más propenso a los puntos muertos, ya que las operaciones INSERT/ UPDATE/ DELETErequieren niveles de bloqueo más altos que los simples SELECTs.

Sospecho que sus procesos del mundo real son más complicados, por lo que si cree que el enfoque anterior no funcionará para ellos, vuelva a publicar con algunos ejemplos más.

wBob
fuente
3

Mis preocupaciones son las siguientes.

  • El manejo de transacciones no sigue el patrón estándar de estar anidado en un bloque Begin Try / Begin Catch. Si se trata de una plantilla, en un procedimiento almacenado con algunos pasos más, puede salir de esta transacción en modo de vista previa con los datos aún modificados.

  • Seguir el formato aumenta el trabajo del desarrollador. Si cambian las columnas internas, también deben modificar la definición de la variable de la tabla, luego modificar la definición de la tabla temporal y luego modificar las columnas de inserción al final. No va a ser popular.

  • Algunos procedimientos almacenados no devuelven el mismo formato de datos cada vez; Piense en sp_WhoIsActive como un ejemplo común.

No he proporcionado una mejor manera de hacerlo, pero no creo que lo que tenga sea un buen patrón.

Cody Konior
fuente