¿Por qué 10 ^ 37/1 arroja un error de desbordamiento aritmético?

11

Continuando con mi tendencia reciente de jugar con grandes números , recientemente herví un error con el siguiente código:

DECLARE @big_number DECIMAL(38,0) = '1' + REPLICATE(0, 37);

PRINT @big_number + 1;
PRINT @big_number - 1;
PRINT @big_number * 1;
PRINT @big_number / 1;

El resultado que obtengo para este código es:

10000000000000000000000000000000000001
9999999999999999999999999999999999999
10000000000000000000000000000000000000
Msg 8115, Level 16, State 2, Line 6
Arithmetic overflow error converting expression to data type numeric.

¿Qué?

¿Por qué funcionarían las primeras 3 operaciones pero no la última? ¿Y cómo puede haber un error de desbordamiento aritmético si @big_numberobviamente puede almacenar la salida de @big_number / 1?

Nick Chammas
fuente

Respuestas:

18

Comprensión de precisión y escala en el contexto de operaciones aritméticas

Analicemos esto y observemos de cerca los detalles del operador aritmético de división . Esto es lo que MSDN tiene que decir sobre los tipos de resultados del operador de división :

Tipos de resultados

Devuelve el tipo de datos del argumento con mayor precedencia. Para obtener más información, vea Precedencia de tipo de datos (Transact-SQL) .

Si un dividendo entero se divide por un divisor entero, el resultado es un entero que tiene cualquier parte fraccionaria del resultado truncado.

Sabemos que @big_numberes un DECIMAL. ¿Qué tipo de datos convierte SQL Server 1? Lo lanza a un INT. Podemos confirmar esto con la ayuda de SQL_VARIANT_PROPERTY():

SELECT
      SQL_VARIANT_PROPERTY(1, 'BaseType')   AS [BaseType]  -- int
    , SQL_VARIANT_PROPERTY(1, 'Precision')  AS [Precision] -- 10
    , SQL_VARIANT_PROPERTY(1, 'Scale')      AS [Scale]     -- 0
;

Para las patadas, también podemos reemplazar el 1bloque de código original con un valor explícitamente escrito como DECLARE @one INT = 1;y confirmar que obtenemos los mismos resultados.

Entonces tenemos una DECIMALy una INT. Dado que DECIMALtiene una precedencia de tipo de datos más alta que INT, sabemos que la salida de nuestra división se convertirá en DECIMAL.

Entonces, ¿dónde está el problema?

El problema es con la escala de DECIMALen la salida. Aquí hay una tabla de reglas sobre cómo SQL Server determina la precisión y la escala de los resultados obtenidos de las operaciones aritméticas:

Operation                              Result precision                       Result scale *
-------------------------------------------------------------------------------------------------
e1 + e2                                max(s1, s2) + max(p1-s1, p2-s2) + 1    max(s1, s2)
e1 - e2                                max(s1, s2) + max(p1-s1, p2-s2) + 1    max(s1, s2)
e1 * e2                                p1 + p2 + 1                            s1 + s2
e1 / e2                                p1 - s1 + s2 + max(6, s1 + p2 + 1)     max(6, s1 + p2 + 1)
e1 { UNION | EXCEPT | INTERSECT } e2   max(s1, s2) + max(p1-s1, p2-s2)        max(s1, s2)
e1 % e2                                min(p1-s1, p2 -s2) + max( s1,s2 )      max(s1, s2)

* The result precision and scale have an absolute maximum of 38. When a result 
  precision is greater than 38, the corresponding scale is reduced to prevent the 
  integral part of a result from being truncated.

Y esto es lo que tenemos para las variables en esta tabla:

e1: @big_number, a DECIMAL(38, 0)
-> p1: 38
-> s1: 0

e2: 1, an INT
-> p2: 10
-> s2: 0

e1 / e2
-> Result precision: p1 - s1 + s2 + max(6, s1 + p2 + 1) = 38 + max(6, 11) = 49
-> Result scale:                    max(6, s1 + p2 + 1) =      max(6, 11) = 11

Según el comentario de asterisco en la tabla anterior, la precisión máxima que DECIMALpuede tener es 38 . Por lo tanto, la precisión de nuestros resultados se reduce de 49 a 38, y "la escala correspondiente se reduce para evitar que la parte integral de un resultado se trunca". No está claro en este comentario cómo se reduce la escala, pero sí sabemos esto:

Según la fórmula de la tabla, la escala mínima posible que puede tener después de dividir dos DECIMALs es 6.

Por lo tanto, terminamos con los siguientes resultados:

e1 / e2
-> Result precision: 49 -> reduced to 38
-> Result scale:     11 -> reduced to 6  

Note that 6 is the minimum possible scale it can be reduced to. 
It may be between 6 and 11 inclusive.

Cómo esto explica el desbordamiento aritmético

Ahora la respuesta es obvia:

La salida de nuestra división se emite DECIMAL(38, 6)y DECIMAL(38, 6)no puede contener 10 37 .

Con eso, podemos construir otra división que tenga éxito al asegurarnos de que el resultado pueda encajar DECIMAL(38, 6):

DECLARE @big_number    DECIMAL(38,0) = '1' + REPLICATE(0, 37);
DECLARE @one_million   INT           = '1' + REPLICATE(0, 6);

PRINT @big_number / @one_million;

El resultado es:

10000000000000000000000000000000.000000

Tenga en cuenta los 6 ceros después del decimal. Podemos confirmar que el tipo de datos del resultado es DECIMAL(38, 6)mediante el uso SQL_VARIANT_PROPERTY()anterior:

DECLARE @big_number   DECIMAL(38,0) = '1' + REPLICATE(0, 37);
DECLARE @one_million  INT           = '1' + REPLICATE(0, 6);

SELECT
      SQL_VARIANT_PROPERTY(@big_number / @one_million, 'BaseType')  AS [BaseType]  -- decimal
    , SQL_VARIANT_PROPERTY(@big_number / @one_million, 'Precision') AS [Precision] -- 38
    , SQL_VARIANT_PROPERTY(@big_number / @one_million, 'Scale')     AS [Scale]     -- 6
;

Una solución peligrosa

Entonces, ¿cómo podemos superar esta limitación?

Bueno, eso ciertamente depende de para qué estás haciendo estos cálculos. Una solución a la que puede saltar de inmediato es convertir sus números FLOATpara los cálculos y luego volver a convertirlos DECIMALcuando haya terminado.

Eso puede funcionar en algunas circunstancias, pero debe tener cuidado de comprender cuáles son esas circunstancias. Como todos sabemos, convertir números hacia y desde FLOATes peligroso y puede dar resultados inesperados o incorrectos.

En nuestro caso, la conversión de 10 37FLOAT ay desde obtiene un resultado que es simplemente incorrecto :

DECLARE @big_number     DECIMAL(38,0)  = '1' + REPLICATE(0, 37);
DECLARE @big_number_f   FLOAT          = CAST(@big_number AS FLOAT);

SELECT
      @big_number                           AS big_number      -- 10^37
    , @big_number_f                         AS big_number_f    -- 10^37
    , CAST(@big_number_f AS DECIMAL(38, 0)) AS big_number_f_d  -- 9999999999999999.5 * 10^21
;

Y ahí lo tienes. Divide con cuidado, hijos míos.

Nick Chammas
fuente
2
RE: "Forma más limpia". Es posible que desee mirarSQL_VARIANT_PROPERTY
Martin Smith
@ Martin: ¿podría proporcionar un ejemplo o una explicación rápida de cómo podría usar SQL_VARIANT_PROPERTYpara realizar divisiones como la que se discutió en la pregunta?
Nick Chammas
1
Aquí hay un ejemplo (como alternativa a la creación de una nueva tabla para determinar el tipo de datos)
Martin Smith
@ Martin - Ah sí, ¡eso es mucho más ordenado!
Nick Chammas