artigo SQL Magazine 02 - Otimização de SQLs e Tunning de Banco de Dados SQL Server

Artigo da Revista SQL Magazine -Edição 2.

Otimização e tunning de um banco de dados SQL Server 2000 definitivamente não é uma ciência exata : existem o que podemos chamar de “regras de boa conduta” que devem ser implementadas de modo a extrair o máximo de performance, mas nem sempre as configurações válidas num determinado ambiente poderão ser aplicadas com o mesmo êxito noutra configuração, isso porquê para que seja feito um ajuste fino na base de dados são necessários esforços bastante abrangentes, que vão desde a análise de códigos Transact SQL até a análise do hardware do servidor (memória, discos, processador e rede) .Nessa matéria, analisaremos o plano de execução de queries, forneceremos também dicas sobre otimização de códigos Transact SQL e finalizaremos com um breve estudo sobre bloqueios.

Análise do plano de execução de uma query no SQL Server 2000

Analisaremos um comando “select” executado no database NorthWind conforme a seguir, presente nas instalações default do SQL Server 2000:

select * from [NorthWind].[dbo].[Orders] o inner join [Northwind].[dbo].[Order Details] od on o.OrderID = od.OrderID where o.orderid =10248

As tabelas utilizadas “Orders” e “Orders Details” (Figura 1) tiveram seus índices alterados conforme demonstrado na Figura 2. O plano de execução gerado pode ser obtido diretamente no Query Analyzer, selecionando “Display Estimated Execution Plan” na opção “Query” e os detalhamentos em amarelo na Figura 3 são obtidos ao posicionar o cursor no respectivo objeto. Toda análise descrita a seguir refere-se a Figura 4.

Figura 1. Estrutura de “Orders” e “Orders Details” no database “NorthWind”

order details

PK_Order_Details

clustered, unique, primary key located on PRIMARY

OrderID, ProductID

order details

ProductID

nonclustered located on PRIMARY

ProductID

Orders

CustomerID

nonclustered located on PRIMARY

CustomerID

Orders

OrderDate

nonclustered located on PRIMARY

OrderDate

Orders

PK_Orders

nonclustered located on PRIMARY

OrderID

Figura 2.Composição dos indices em “Orders” e “Orders Details”

Figura 3. Análise do plano de execução da query

Figura 4. Análise completa

Nested Loop: o otimizador elege uma tabela (conhecida por “Outer Table”) que servirá de base para a varredura de registros. A cada registro lido nessa tabela, é efetuada uma busca pelo registro correspondente na outra tabela participante do join (conhecida por “Inner Table” e esta deve possuir índice adequado para busca). Esse método é bastante eficiente quando uma das tabelas possui quantidade pequena de registros (ou o join possui filtros que tornam o result-set pequeno) e a outra um índice adequado formado pela(s) coluna(s) que unem as duas tabelas. A tabela com menor número de registros será definida como Outer Table.

Merge Join: se as duas tabelas possuírem índices adequados, que permitam sortear o conteúdo das tabelas participantes do join (considerando os filtros estabelecidos na cláusula “where”) esse tipo será o escolhido. O otimizador recupera uma coluna de cada lista sorteada, efetuando a comparação. Em caso de igualdade, retorna as colunas selecionadas. Caso contrário, a coluna de menor valor será descartada, obtendo o próximo valor dessa mesma lista onde foi efetuado o descarte. O processo se repete até que todas as linhas tenham sido processadas. Esse tipo de join é bastante eficaz, normalmente sendo utilizado para tabelas com grande número de registros, que inviabilizam o Nested Loop.

Hash Join: se não existirem índices adequados para a igualdade definida no join, esse método será utilizado. Para que o join possa acontecer, o otimizador precisará de uma maneira bastante rápida de indexar as duas tabelas, por isso utiliza um algoritmo de hash para codificar as colunas envolvidas no join e estabelecer a combinação.Unir duas tabelas sem índices apropriados ou com baixa seletividade é um fator de queda de performance, portanto investigue as ocorrências desse tipo de join.

Após a escolha do tipo de join, a query é executada retornando o select para a estação

Abaixo, são listados mais alguns objetos importantes na avaliação do plano de execução. A lista completa dos objetos utilizados pelo otimizador na resolução de queries pode ser obtida no procurando por “execution plan icons” na guia “Search” do SQL Server Books On Line. Após pesquisa, selecione “Graphically Displaying the Execution Plan Using SQL Query Analyzer”. (ou no endereço http://msdn.microsoft.com/library/default.asp?url=/library/en-us/optimsql/odp_tun_1_5pde.asp)

Dicas para otimização de códigos Transact-SQL

1 ) Conheça os passos dados pelo otimizador para resolver uma query :

Listagem 1. Select’s idênticos para o otimizador

select o.OrderId from [NorthWind].[dbo].[Orders] o inner join [Northwind].[dbo].[Order Details] od on o.OrderID = od.OrderID

Seleção do melhor plano de execução, baseado nos custos calculados no item 3.

2 ) Limite sua busca restringindo ao máximo o número de colunas solicitadas na cláusula “select”. Colunas adicionais, além de consumir mais recursos de I/O e largura de banda, muitas vezes inibem a utilização de índices ou causam buscas desnecessárias na área de dados à partir do índice (bookmark lookup).

3 ) Filtre sempre o resultado de suas pesquisas, fornecendo parâmetros de busca que se adequem à estrutura dos índices existentes, obedecendo a ordenação de suas colunas.

Ex: para o índice composto PK_Order_Detals, formado pelas colunas OrderId e ProductId, é fundamental que uma pesquisa forneça pelo menos o número da ordem a ser pesquisada (OrderId). Fornecendo somente ProductId, torna pouco provável a utilização do índice pelo otimizador.

4 ) Evite utilização de funções diretamente sobre colunas pesquisadas, que inibem a utilização de índices. Ex:

substitua ... where substring(ShipName,1,1) = ‘M’ por ………where ShipName like (‘M%’)

Se não for possível evitar a função, considere a criação de índices sobre colunas calculadas:

select datepart(month,ShippedDate) from [NorthWind].[dbo].[Orders] where datepart(month,ShippedDate)=7

Pode ser otimizado se criarmos um índice sobre uma coluna calculada:

alter table [NorthWind].[dbo].[Orders] add Month_OrderDate as datepart(month,ShippedDate) create index IX_Month_OrderDate on [NorthWind].[dbo].[Orders](Month_OrderDate)

Podemos agora reescrever o “select” :

select Month_OrderDate from [NorthWind].[dbo].[Orders] where Month_OrderDate=7

Repare que o “Table Scan” foi substituído por um “Indes Seek” !

5 ) Utilize tabelas derivadas em oposição à tabelas temporárias. Tabela derivada é o resultado da utilização de um comando select após a clausula “from” num “select” existente. Apenas para efeito de exemplo :

select o.EmployeeId,od.Quantity from [NorthWind].[dbo].[Orders] o inner join ( select * from [Northwind].[dbo].[Order Details] od where ProductId=11 ) as od on o.OrderID = od.OrderID

6 ) Lembre-se que índices existem para comparar igualdades. Evite a utilização de operadores do tipo “<>”,”!>”,”!<”, “NOT”. A utilização de “lógica negativa” inibe a escolha de índices pelo otimizador.

7 ) Para fins de performance, considere a utilização de Indexed Views. A utilização de views simplifica bastante a programação, mas não otimiza performance, haja visto que seu código é executado de maneira integral a cada solicitação.

8 ) Se a sua query utiliza agrupamentos e filtragem de dados na cláusula “having”, considere a opção de filtragem diretamente na cláusula “where”, reduzindo significativamente o trabalho do “group by”, já que um número menor de registros deverão ser processados.

9 ) Utilize a cláusula “like” com critério. Lembre-se que o comando “… where name like(‘SQL%’)“ utilizará um índice formatado para a coluna “name”, se esse índice existir. Já o comando “… where name like (‘%SQL’) “ realizará um table scan (ou clustered index scan, se a tabela possuir índice cluster) na tabela em questão.

10 ) Evite ao máximo a utilização de cursor nos servidores. Experimente reescrever o código utilizando subqueries, tabelas derivadas, tabelas temporárias ou mesmo a cláusula “case”.

11 ) Sempre que possível, utilize variáveis do tipo “table” em oposição à tabelas temporárias.

12 ) Para monitoramento, utilize o Profiler para capturar as queries mais demoradas, analisando seu plano de execução.

13 ) Existem duas configurações de servidor que podem ser utilizadas para limitar o tempo de execução de uma query, são elas : “query governor cost limit” e “query wait”. A primeira (“query governor”) é baseada numa projeção de tempo de execução da query calculada pelo otimizador: se o tempo projetado for superior ao limite pré-definido nessa configuração a query é abortada ANTES de sua execução. “Query wait” simplesmente aborta uma query se esta superar o limite estabelecido nesse parâmetro, o que pode ser desastroso principalmente se a transação for extensa e já houver adquirido muitos locks. Como sugestão, avalie a opção “query governor”. Implante limites que você considera suficientes para seu ambiente (com boa margem de segurança !) e vá reduzindo gradativamente, até chegar ao ponto ótimo.

Fatores que afetam performance : análise de bloqueios e deadlocks

Bloqueios são fundamentais para garantia da consistência de dados em transações. O isolamento fornecido por um bloqueio no SQL Server 2000 permite que uma transação não efetue leituras ou modifique dados que estão sendo utilizados por outra transação.

Existem vários tipos de locks, cada um estabelecendo o isolamento necessário para comandos de manipulação de dados (select / insert / update / delete). O tipo de lock (shared, update, exclusive, shema lock ou bulk update lock) é selecionado automaticamente pelo SQL Server 2000 a menos que você utilize um hint, o que não é aconselhável.

O SQL Server 2000 trabalha com escalonamento de locks, permitindo que um bloqueio de registro seja promovido para um bloqueio de página ou de tabela. O escalonamento possibilita a economia de recursos (um lock consome 64 bytes de memória), pois ao promover um lock os bloqueios de nível menor são liberados. Como ilustração, imagine uma operação de update envolvendo todas as 1000 linhas de uma tabela - o que seria mais eficiente: 1000 locks de registro ou um lock de tabela ? É lógico que a segunda opção, já que todas as linhas serão atualizadas.

O problema relacionado a bloqueios advém de seu tempo de duração. Bloqueios curtos são eficientes, bloqueios longos são um transtorno. Alguns fatores que acarretam no aumento da duração de bloqueios são transações longas, ausência, excesso ou ineficiência de índices, nível de isolamento das transações, bases de dados não normalizadas, utilização indiscriminada de cursores, etc. Nesses ambientes, as mensagens de erro envolvendo query timeout (#1222) ou deadlocks (#1205) tendem a ocorrer com mais freqüência.

Query timeout acontece quando, ao executar um comando de manipulação de dados, aguardamos sua conclusão por tempo superior a um limite previamente estabelecido. Já deadlocks acontecem quando dois processos ficam aguardando pela liberação de recursos que o outro processo mantém, situação essa que é resolvida pelo SQL Server 2000 finalizando-se a conexão que consumir menos recursos.A seguir temos os códigos para gerar dois tipos de deadlock: cíclicos e de conversão.

Tipo cíclico:

  1. Abra duas sessões no Query
  2. Na sessão-1, execute o cmd abaixo:
    begin tran update [NorthWind].[dbo].[order details] set discount=1 where orderid=10248 and productid=11
  3. Na sessão-2, execute:
    begin tran update [NorthWind].[dbo].[orders] set employeeid=4 where orderid=10248
  4. Voltando na sessão-1, execute :
    update [NorthWind].[dbo].[orders] set employeeid=5 where orderid=10248 commit
    O update acima ficará travado, aguardando a liberação do recurso, bloqueado na sessão-2
  5. Na seção-2, execute:
    update [NorthWind].[dbo].[order details] set discount=0 where orderid=10248 and productid=11 commit
    Nesse momento acontece o deadlock :
    Server: Msg 1205, Level 13, State 50, Line 1 Transaction (Process ID 70) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.

Tipo Conversão:

  1. Abra duas sessões no Query Analyzer
  2. Na sessão-1, execute o cmd abaixo:
    begin tran select * from [NorthWind].[dbo].[Orders] (holdlock) where orderid=10248
  3. Na sessão-2, execute o mesmo comando:
    begin tran select * from [NorthWind].[dbo].[Orders] (holdlock) where orderid=10248
  4. Voltando na sessão-1, execute :
    update [NorthWind].[dbo].[Orders] set employeeid=4 where orderid=10248 commit
    O update acima ficará travado, aguardando a liberação do recurso, bloqueado na sessão-2
  5. Na seção-2, execute:
    update [Northnd].[dbo].[Orders] set employeeid=5 where orderid=10248 commit
  6. Nesse momento acontece o deadlock :
    Server: Msg 1205, Level 13, State 50, Line 1 Transaction (Process ID 70) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction

Uma das maneiras de se monitorar as transações envolvidas num deadlock é ativar as trace flags 3605 e 1204, que geram informações detalhadas no log do SQL Server 2000 à respeito do deadlock. Execute “dbcc traceon(3605) dbcc traceon(1204) “ para habilitar as trace flags; “dbcc traceoff (3605) dbcc traceoff (1204)” para desabilitar.

Outra possibilidade de monitoramento é através do SQL Profiler, habilitando-se o evento “Lock: DeadLock Chain”, que produz resultado semelhante às trace flags habilitadas acima.

Um aspecto interessante é que a duração de um lock é ilimitada no SQL Server 2000. Portanto se você não quiser que um processo aguarde indefinidamente pela liberação de um lock mantido noutra sessão, utilize em sua sessão a cláusula LockTimeOut (set LockTimeOut ) antes de comandos de manipulação de dados e efetue tratamento para erros de código #1222.

Minimizar tempo de bloqueios implica no cumprimento de algumas regras, a saber:

  1. Mantenha suas transações “enxutas” – quanto menos código melhor; lembre-se que quanto menor o tempo gasto por um bloqueio menor será a possibilidade de ocorrências de deadlocks e travamentos ;
  2. Estude a possibilidade de quebrar horizontal e/ou verticalmente tabelas com grande número de registros. Dados distribuídos permitem maior concorrência, já que os locks que originalmente incidiam sobre um objeto estão dispersos em outras tabelas;
  3. Procure atualizar tabelas nas transações seguindo sempre a mesma ordem, evitando assim a ocorrência de deadlocks cíclicos (Figura 7) ;
  4. Evite a utilização de select com hint “holdlock” seguidos de um update. Essa combinação explosiva é causa freqüente de deadlocks de conversão (Figura 8);
  5. Efetue expurgos periódicos em suas bases OLTP; não mantenha dados históricos em sua base de produção. Operações em tabelas com grande número de registros tendem a ser mais demoradas;
  6. Não crie índices desnecessariamente em bases OLTP. Um índice criado para otimizar uma query representará overhead nas operações de atualização de dados;
  7. Utilize sp’s em oposição a batchs. Por estarem residentes no servidor e muitas vezes com planos de execução cacheados, as sp’s apresentam performance superior;
  8. Trabalhe com locks otimistas em situações em que a leitura e modificação de dados representem processos com considerável separação de tempo. Use e abuse de colunas do tipo “TimeStamp” para essa finalidade. Ex: vamos supor que uma ordem de compra possa ser alterada em vários locais por vários vendedores. Seria interessante que dois vendedores não alterassem a mesma ordem ao mesmo tempo; poderiam alterar colunas diferentes e somente a última alteração se tornaria vigente. Para resolver esse tipo de questão, crie uma coluna chamada “OrderTS”, do tipo “timestamp” e, ao efetuar a leitura do registro na aplicação de front end, carregue o valor dessa coluna. Depois que o vendedor efetuar as alterações necessárias, submeta o registro à atualização, fornecendo o valor da coluna timestamp como argumento de pesquisa. Se o registro não for encontrado significa que aconteceram alterações concorrentes, sendo necessário uma releitura na base para posterior revisão pelo vendedor.

1 ) Adicionar coluna TS tipo TimeStamp em [Orders]

Alter table [Orders] add TS timestamp

2 ) Efetuar leitura de colunas da tabela Orders, submetendo-as a aplicação de front-end

declare @ShipName varchar (80) declare @ShipAddress varchar(120) declare @TS binary (8) select @ShipName=ShipName ,@ShipAddress=ShipAddress ,@TS=TS from [Orders] where OrderId=10248

3 ) Após alterações pelo vendedor, submeter o comando de update fornecendo a coluna timestamp guardada no passo anterior:

update [Orders] set ShipName = @ShipName , ShipAddress = @ShipAddress where OrderId =10248 and TS=@TS if @@rowcount = 0 begin /* Significa que o registro foi alterado noutra sessão ; a aplicação deverá ser desviada para o passo-2, relendo a ordem submetendo-a novamente à análise do vendedor */ raiserror ('Ordem alterada por outro vendedor. Verifique !',11,1) end

Conclusão

Normalmente só pensamos em otimização no momento em que nos deparamos com situações realmente críticas de performance, quando pode ser tarde demais.


Artigos relacionados