Por que 10 ^ 37/1 gera um erro de estouro aritmético?

11

Continuando minha tendência recente de jogar com grandes números , recentemente refiz um erro que estava ocorrendo no seguinte 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;

A saída que recebo para esse código é:

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

O que?

Por que as três primeiras operações funcionariam, mas não a última? E como pode haver um erro de estouro aritmético se, @big_numberobviamente, é possível armazenar a saída de @big_number / 1?

Nick Chammas
fonte

Respostas:

18

Entendendo precisão e escala no contexto de operações aritméticas

Vamos detalhar isso e dar uma olhada nos detalhes do operador aritmético de divisão . Isto é o que o MSDN tem a dizer sobre os tipos de resultados do operador de divisão :

Tipos de resultado

Retorna o tipo de dados do argumento com maior precedência. Para obter mais informações, consulte Precedência de tipos de dados (Transact-SQL) .

Se um dividendo inteiro é dividido por um divisor inteiro, o resultado é um número inteiro que tem qualquer parte fracionária do resultado truncada.

Sabemos que @big_numberé um DECIMAL. Que tipo de dados o SQL Server lança 1como? Ele lança para um INT. Podemos confirmar isso com a ajuda 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 chutes, também podemos substituir o 1no bloco de código original por um valor digitado explicitamente como DECLARE @one INT = 1;e confirmar que obtemos os mesmos resultados.

Então nós temos um DECIMALe um INT. Como DECIMALpossui uma precedência de tipo de dados mais alta que INT, sabemos que a saída de nossa divisão será convertida em DECIMAL.

Então, onde está o problema?

O problema está na escala de DECIMALsaída. Aqui está uma tabela de regras sobre como o SQL Server determina a precisão e a escala dos resultados obtidos nas operações 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.

E aqui está o que temos para as variáveis ​​nesta tabela:

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

De acordo com o comentário do asterisco na tabela acima, a precisão máxima que uma DECIMALlata pode ter é 38 . Portanto, a precisão de nossos resultados é reduzida de 49 para 38 e "a escala correspondente é reduzida para impedir que a parte integrante de um resultado seja truncada". Não está claro neste comentário como a escala é reduzida, mas sabemos disso:

De acordo com a fórmula da tabela, a escala mínima possível que você pode ter depois de dividir dois DECIMALs é 6.

Assim, acabamos com os seguintes 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.

Como isso explica o estouro aritmético

Agora a resposta é óbvia:

A saída de nossa divisão é lançada DECIMAL(38, 6)e DECIMAL(38, 6)não pode conter 10 37 .

Com isso, podemos construir uma outra divisão que sucede por ter certeza que o resultado pode caber em 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;

O resultado é:

10000000000000000000000000000000.000000

Observe os 6 zeros após o decimal. Podemos confirmar tipo de dados do resultado é DECIMAL(38, 6)usando SQL_VARIANT_PROPERTY()como acima:

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
;

Uma solução perigosa

Então, como contornar essa limitação?

Bem, isso certamente depende do motivo pelo qual você está fazendo esses cálculos. Uma solução para a qual você pode ir imediatamente é converter seus números FLOATpara os cálculos e depois convertê-los novamente DECIMALquando terminar.

Isso pode funcionar em algumas circunstâncias, mas você deve ter cuidado para entender quais são essas circunstâncias. Como todos sabemos, converter números de e para FLOATé perigoso e pode fornecer resultados inesperados ou incorretos.

No nosso caso, converter 10 37 para e de FLOATobtém um resultado totalmente errado :

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
;

E aí está. Divida com cuidado, meus filhos.

Nick Chammas
fonte
2
RE: "Maneira mais limpa". Você pode querer olhar paraSQL_VARIANT_PROPERTY
Martin Smith
@ Martin - Você poderia fornecer um exemplo ou uma explicação rápida de como eu poderia usar SQL_VARIANT_PROPERTYpara executar divisões como a discutida na pergunta?
9118 Nick Chammas
1
Há um exemplo aqui (como uma alternativa à criação de uma nova tabela para determinar o tipo de dados)
Martin Smith
@ Martin - Ah sim, isso é muito mais arrumado!
Nick Chammas