Ostatnimi czasy wspierałem administratora baz danych podczas zmiany “compatibility level” dla jednej z baz hurtowni danych. Konkretnie zmiana dotyczyła przejścia z trybu 110 (zgodny z SQL Server 2012) na tryb 120 (zgodny z SQL Server 2014). Baza działała w trybie zgodnym z SQL Server 2012, natomiast Server został już wcześniej “upgradowany” do SQL Server 2014. W tym poście chciałbym podzielić się z problemami oraz wskazówkami dotyczącymi tej zmiany. Przedstawię swój plan pracy oraz poszczególne etapy oraz problemy, które napotkałem. Wskazówki mogą się okazać szczególnie przydatne w przypadku baz hurtowni danych, które uczestniczą w procesie ETL.
Teoria
Każda wersja SQL Server przynosi wiele zmian oraz nowości. Jako, że silnik bazy danych również ulega zmianie, systemy, które mają zostać migrowane do nowej edycji, mogą zacząć działać inaczej niż dotychczas. Oczywiści z założenia nowsza wersja powinna być lepsza i szybsza, natomiast może się również okazać, że po zmianie niektóre zapytania będą działać znacznie wolniej. “Compatibility level”, czyli tryb zgodności pozwala na zminimalizowanie takich ryzyk i zapewnia wsteczną kompatybilność. Warto zaznaczyć, że tryb zgodności jest ustawiany na poziomie pojedynczej bazy danych.
Opis środowisko
Zgodnie z tym co wspomniano powyej moja sytuacja wygldała następujaco:
- Baza danych w charakterze hurtowni danych
- Baza danych wykorzystywana w procesie ETL obsługiwanym przez SSIS
- Bardzo duża ilość procedur składowanych w tym dużo długich i skomplikowanych
- Wiele dużych tabel, większość z liczbą wierszy równą kilkuset milionów wierszy
- Zainstalowany wcześniej SQL Server 2014 i ustawiony tryb zgodności z SQL Server 2012 (120)
- Parametry serwera dobre, nie będę podawał szczegółowej specyfikacji
- Proces ETL działał stabilnie i czas ładowania nie ulegał dużym wahaniom, mniej niż 15 minut różnicy pomiędzy najwolniejszym i najszybszym uruchomieniem w ciągu ostatnich kilku miesięcy
- Plan zakładał przełączenie bazy w tryb zgodności zgodny z SQL Server 2014
Plan oraz szczegóły pracy
Przygotowanie do zmiany
- Sprawdzenie szczegółów technicznych oraz zmian pomiędzy wersjami – na samym początku powinniśmy sprawdzić szczegóły techniczne oraz zapoznać się ze zmianami pomiędzy poszczególnymi wersjami. Informacje te dostarcza Microsoft na stronach MSDN: https://docs.microsoft.com/en-us/sql/t-sql/statements/alter-database-transact-sql-compatibility-level oraz https://msdn.microsoft.com/en-us/library/dn673537.aspx Po tej lekturze, znając własną bazę oraz znając charakter naszych danych powinniśmy wiedzieć czego się spodziewać.
- Sprawdzenie potencjalnych błędów – przeglądniecie Internetu w poszukiwaniu typowych problemów. Oczywiście nie będziemy pierwszymi developerami, którzy dokonują migracji lub przełączają tryb zgodności, więc dobrym pomysłem jest zapoznać się z potencjalnymi problemami, które zostały już opisane.
- Sprawdzenie kompatybilności kodu – wykorzystanie Microsoft Data Migration Assistant. Microsoft przygotował narzędzie, które pozwoli nam sprawdzić kod bazy danych oraz jego zgodność pomiędzy wersjami SQL Servera. Narzędzie dostępne jest tutaj: https://www.microsoft.com/en-us/download/details.aspx?id=53595
Jako wynik działania aplikacji uzyskamy listę potencjalnych błędów oraz wskazówek jak je rozwiaząć. W moim przypadku zostałem poinformowany o następujących przypadkach:
- użycie starego typu złączeń w kilku zapytaniach (“Unqualified Join(s)”)
- użycie typów danych, które wkrótce mogą przestać być wspierane, np. TEXT (“Deprecated data types TEXT, IMAGE or NTEXT”)
- użycie ORDER BY w niewłaściwy sposób (“ORDER BY specifies integer ordinal”)
- odwołania do nieudokumentowanych widoków systemowych (“Remove references to undocumented system tables”)
Ostrzeżeń tych było zaledwie kilka i żaden z tych ostrzeżeń nie został oznaczony jako przypadek krytyczny, czyli taki, który mógłby sprawić, że kod po przełączeniu przestałby działać (“BreakingChange”)
Zmiana
- Wybór środowiska
Zmiana powinna zostać szczegółowo zaplanowana. Według mnie najlepszą metodą jest zacząć od najniższego środowiska (DEV) i następnie przechodzić w górę (TEST, PREPROD, PROD). Oczywiście nie jest powiedziane, że problemy po zmianie będą identyczne na każdym środowisku, natomiast będziemy mogli się zorientować co może nas czekać przy najważniejszej zmianie, czyli zmianie na produkcji. Inna sprawa, że wszystkie środowiska muszą zostać przełączone. Musimy natomiast pamiętać, że deweloperzy, którze będą pisać nowe rzeczy powinni zadbać o kompatybilność swojego kodu między wersjami oraz o dodatkowe testy wydajnościowe. - Poinformowanie zespołu
Jak zaznaczono wyżej każda osoba, która pracuje z bazą danych na każdym środowisku dla którego ma być przeprowadzona zmiana powinien o tym wiedzieć. Po pierwsze, aby zadbać o kompatybilność nowego kodu, a po drugie, aby poinformować, że bieżący kod może działać wolniej lub inaczej niż było to do tej pory. Powinniśmy również zrozumieć czy inne zespoły, narzędzia lub serwisy nie korzystają z tych baz danych i w razie konieczności poinformować zainteresowanych o możliwych problemach. - Zmiana trybu
Przeprowadzenie samej zmiany jest bardzo proste i sprowadza się do kilku kroków.- Zmiana trybu
Aby wyświetlić bieżący tryb dla bazy danych można wykorzystać następujące zapytanie:SELECT d.name, d.compatibility_level FROM sys.databases d WHERE d.name = 'database_name'
Aby zmienić tryb zalecanym jest najpierw przełączyć się w tryb “SINGLE_USER”, następnie zmienić tryb kompatybilności i powrócić do “MULTI_USER”
USE master GO ALTER DATABASE [database_name] SET SINGLE_USER WITH ROLLBACK IMMEDIATE GO -- 110 - SQL Server 2012 -- 120 - SQL Server 2014, -- 130 - SQL Server 2016 ALTER DATABASE [database_name] SET COMPATIBILITY_LEVEL = 120 GO ALTER DATABASE [database_name] SET MULTI_USER GO
Jest to zalecana technika i Microsoft ostrzega, że bez przełączenia się w tryb “SINGLE_USER” niektóre zapytania mogą korzystać z niewłaściwych planów. Jak widać, sama zmiana jest bardzo prosta i ogranicza się do wykonania kilku komend.
- Czyszczenie planów zapytania
Niektóre miejsca w sieci podają, że w przypadku zmiany trybu zgodności procedury według potrzeby będą oznaczone do rekompilacji automatycznie. Z moich obserwacji wynika, że jest to nieprawda, lub nie zawsze tak jest. Moje testy wskazały, że mimo wszystko najbezpieczniejszą opcją będzie, zaraz po zmianie, wyczyszczenie całego cache’a.
DBCC FREEPROCCACHE -- clear all plans in cache GO CHECKPOINT -- write dirty pages GO DBCC DROPCLEANBUFFERS -- Clear Buffer pool GO
Można użyć zapytania, które po prostu oznaczy wszystkie procedury do rekompilacji w przypadku następnego uruchomienia – do znalezienia w Internecie. Wydaje mi się natomiast, że wyczyszczenie zupełnie cache’a w tym przypadku będzie również odpowiednie. W moim przypadku bez oznaczenia procedur i bez wyczyszczenia cache’a, po zmianie, proces ETL miał ogromne problemy. Dla niektórych procedur, które powinny wykonywać się około 3 minut, czas wykonywania uległ zmianie do kilku godzin. Po ponownej zmianie do poprzedniego trybu procedury działały poprawnie. Po wyczyszczeniu cache’a natomiast zdecydowana większość procedur posiadała spodziewany czas wykonywania – oprócz przypadków opisanych poniżej.
- Zmiana trybu
Obserwacja i monitorowanie
Bardzo ważnym jest umiejętne sprawdzanie oraz monitorowanie zmiany, aby być w stanie szybko sprawdzać czas wykonywania procedur i zapytań oraz parametrów serwera. Oprócz standardowych technik dobrze sprawdza się przygotowanie odpowiednich zapytań i wykresów – na przykład z wykorzystaniem języka R. Posiadając taki szablon można w prosty i szybki sposób porównać różne ładowania hurtowni. Przykład poniżej:
#install.packages("plotly") #install.packages("RODBC") ## params: pShowNames = 1 # 0 - show fake number IDs, 1 - show real object name pLoadsToCompare = '7476864 (new 11), 7470564 (new 10), 7440432 (new 9), 7434655 (new 8), 7428523 (new 7), 7422414 (new 6), 7410007 (new 5), 7403666 (new 4), 7391865 (new 1), 7385744 (new 2), 7397619 (new 3), 7187028 (old 1), 7171835 (old 2)' pDifferenceInMinLimit = "3" pRunType = '2' # 1 - packages, 2 - procedures pNotEndedDuration = '200' # in case that the run has been terminated put the fake long execution time to see this sp pObjectRunInMinLimit = 'null' # connection serverAddress = 'server_address,server_port' databaseName = 'database_with_log_name' ## end of params ################################################ # check object name by id: #as.character((res[res$IndexName == "26","objectName"])[1]) ################################################ library(plotly) library(RODBC) myConnectionString <- paste('driver=SQL Server;server=',serverAddress,';database=',databaseName,';trusted_connection=true', sep="") mySqlQuery <- paste("database_name.schema_name.sp_name @LoadIDsToCompare = \'" , pLoadsToCompare , '\', @DifferenceInMinLimit = ' , pDifferenceInMinLimit , ', @RunType = ' , pRunType , ', @NotEndedDuration = ' , pNotEndedDuration , ', @ObjectRunInMinLimit = ' , pObjectRunInMinLimit ) dbhandle <- odbcDriverConnect(myConnectionString) res <- sqlQuery(dbhandle, mySqlQuery) close(dbhandle) { if (pShowNames == 0) { res["ChartName"] <- res$IndexName } else { res["ChartName"] <- res$objectName } } ggplot(res, aes(factor(ChartName), Duration_SUM, fill = as.factor(packageDescriptionFull))) + geom_bar(stat="identity", position = "dodge") + #scale_fill_brewer(palette = "Set1") + scale_fill_manual(values=res$PackageDescriptionShortLegend) + theme(axis.text.x=element_text(angle=50,hjust=1)) + geom_text(aes(label=Duration_SUM), size=3, position=position_dodge(width=0.9), vjust=-0.25) + guides(fill=guide_legend(title="Load ID and description")) + labs(x = paste(toString(res$RunType[1])," name")) + labs(y = "Duration") + ggtitle(paste(res$RunType[1], "run statistics", " (n = ", nrow(res) ,")")) + theme(legend.position="top")
Wykres pokaże różnice w kolejnych ładowaniach dla poszczególnych obiektów. Dzięki filtrowi można pokazać tylko te obiekty dla których różnica pomiędzy najszybszym i najwolniejszym wykonaniem była większa niż X minut. Kolory pokazują ładowania przed i po zmianie trybu zgodności.
Kolejny przykład przyda się w przypadku porównywania pojedynczych procedur lub pakietów:
library(plotly) library(RODBC) library(RColorBrewer) serverAddress = 'server_name,server_port' databaseName = 'database_log_name' myConnectionString <- paste('driver=SQL Server;server=',serverAddress,';database=',databaseName,';trusted_connection=true', sep="") mySqlQuery <- paste(" DECLARE @procName VARCHAR(200) = 'stored_procedure_name' ;WITH cte AS ( SELECT DATEDIFF(SECOND, startTime, ISNULL(endTime, GETUTCDATE())) AS RunDuration, a.startTime AS StartTimeDateTime, a.endTime AS EndTimeDateTime, ROW_NUMBER() OVER(PARTITION BY CONVERT(DATE,a.startTime) ORDER BY a.startTime) AS RN, CONVERT(DATE,a.startTime) AS StartTimeDate, a.procRunLogID, CASE WHEN CONVERT(DATE,a.startTime) <= '2017-09-13' THEN 'OLD' ELSE 'NEW' END AS 'CompatibilityMode' FROM table_with_log_name a WHERE procName = @procName AND a.endTime IS NOT null ) SELECT cte.StartTimeDate AS CalendarDate, @procName AS ProcName, AVG(cte.RunDuration) AS RunDuration_AVG_SECONDS, MIN(cte.RunDuration) AS RunDuration_MIN_MINUTES, MAX(cte.RunDuration) AS RunDuration_MAX_MINUTES, AVG(cte.RunDuration) AS RunDuration_AVG_MINUTES, SUM(cte.RunDuration) AS RunDuration_SUM_MINUTES, MIN(cte.CompatibilityMode) AS CompatibilityMode, COUNT(*) AS CNT FROM cte GROUP BY cte.StartTimeDate ORDER BY cte.StartTimeDate DESC " ) dbhandle <- odbcDriverConnect(myConnectionString) res <- sqlQuery(dbhandle, mySqlQuery) close(dbhandle) p <- plot_ly( x = res$CalendarDate, y = res$RunDuration_AVG_SECONDS, color = res$CompatibilityMode, colors = c("#b60c0c", "#062452"), text = paste( "start time: ", res$CalendarDate, "<BR>Proc name: ", res$ProcName, "<BR>Run Duration AVG SECONDS: ", res$RunDuration_AVG_SECONDS, "<BR>Run Duration MIN MINUTES: ", floor(res$RunDuration_MIN_MINUTES/60 * 100) / 100, "<BR>Run Duration MAX MINUTES: ", floor(res$RunDuration_MAX_MINUTES/60 * 100) / 100, "<BR>Run Duration AVG MINUTES: ", floor(res$RunDuration_AVG_MINUTES/60 * 100) / 100, "<BR>Run Duration SUM MINUTES: ", floor(res$RunDuration_SUM_MINUTES/60 * 100) / 100, "<BR>Compatibility mode: ", res$CompatibilityMode, "<BR>Number of executions: ", res$CNT ), type = "bar") %>% layout( margin = list(l = 50, r = 50, b = 80, t = 50, pad = 4), xaxis = list(tickangle = 45), barmode = 'group' ) %>% add_trace(y = res$RunDuration_SUM_MINUTES, name = 'SUM', marker = list(color = '#C9EFF9')) p
W tym przypadku wykres pokazuje poszczególne wykonania procedury. Znowu z podziałem na wywołania przed i po zmianie.
Oczywiście, aby wykorzystać te skrypty trzeba posiadać odpowiednie logowanie oraz przygotować odpowiednie zapytania, natomiast pozwoli to zaoszczędzić sporo czasu podczas monitorowania.
Plan awaryjny
Zmiana może spowodować, że niektóre zapytania lub procedury znacznie spowolnią. Szczególnie przed zmianą na serwerze produkcyjnym trzeba mieć zatem plan B.
- Źródło problemu – na samym początku musimy zdać sobie sprawę, że coś jest nie tak. Oczywiście w pierwszej kolejności musimy monitorować podstawowe charakterystyki pracy serwera oraz monitorować to, co aktualnie dzieje się na serwerze. Możemy też wykorzystać różne narzędzia (np. RedGate) lub własne rozwiązania do monitorowania oraz logowania. W tym przypadku będziemy szukać zapytań, które po zmianie stały się wolniejsze.
- Zmiana trybu dla pojedynczej procedury – w przypadku, gdy pojedyncza procedura wykonuje się wolno możemy skorzystać z flagi i wymusić tryb dla całej procedury.
DBCC TRACEON (2312); -- 120 DBCC TRACEON (9481); -- 110
- Zmiana trybu dla pojedynczego zapytania – możemy również wymusić konkretny tryb dla pojedynczego zapytania
option (QUERYTRACEON (2312); option (QUERYTRACEON (9481);
Dzięki temu, po zmianie, możemy wymusić poprzedni tryb zgodności tylko dla wybranych obiektów, natomiast wszystkie pozostałe będą korzystać z nowej wersji silnika.
Napotkane problemy
Osobiście spotkałem się z następującymi problemami:
- Zmienne tabelaryczne – o zmiennych tabelarycznych napisano i powiedziano już naprawdę wiele. W tym przypadku zmienna tabelaryczna przechowywała tylko kilka wierszy, natomiast w przypadku kilku procedur powodowało to ogromne problemy dla nowej wersji silnika.
Przykładowy fragment kodu wyglądał tak:DECLARE @List TABLE ( col SMALLINT ) INSERT INTO @List ( col ) SELECT col FROM tdw.dbo.getsList(@IdList)
Następnie zmienna tabelaryczna była wykorzystywana w kolejnych zapytaniach, aby przefiltrować wiersze. Po zmianie niektóre procedury znacznie spowolniły. Lepszym wariantem będzie wykorzystanie tabeli tymczasowej:
CREATE TABLE #List ( col SMALLINT ) INSERT INTO #List ( col ) SELECT sk_brand FROM tdw.dbo.getskList(@IdList)
poniżej plany zapytania:
Zamiana zmiennych tabelarycznych na tabele tymczasowe przyniosła zauważalne zmiany w niektórych procedurach.
- Niewłaściwe “hinty”
Z pewnych względów niektóre “hinty” przestały działać i powodowały spadek wydajności. Zauważyłem problemy z:OPTION (FORCE ORDER) Inner Loop Join
Po usunięciu tych “hintów” zapytania zaczęły znowu działać poprawnie.
- Niepokrywające indeksy
Zmiana trybu poprawności uwypukliła również niepokrywające indeksy. Poniższy przykład prezentuje plan zapytania, dla dokładnie takiego samego zapytania (OLD, NEW). W przypadku “compatibility mode” ustawionego dla SQL Server 2014 zapytanie znacznie spowolniło.
Problemem okazał się niepokrywający indeks. Będąc dokładnym to tabela posiadała prawidłowy indeks, natomiast zapytanie nie wykorzystywało wszystkich kolumn indeksów tak jak powinno. Po zapewnieniu odpowiedniego zapytania czas wykonywania uległ skróceniu.
- Intra-Query Parallel Thread Deadlocks
W przypadku jednej z procedur wystąpił również problem z Intra-Query Parallel Thread Deadlocks, czyli problem z wielowątkowością, co pokazał odpowiedni deadlock. Deadlocki zostały wychwycone przez narzędzia do monitorowania, natomiast możemy również wykorzystać, na przykład, Extended Events:CREATE EVENT SESSION [DeadlockTrace] ON SERVER ADD EVENT sqlserver.lock_deadlock ( ACTION ( package0.collect_system_time, package0.event_sequence, package0.process_id, sqlserver.database_name, sqlserver.plan_handle, sqlserver.request_id, sqlserver.session_id, sqlserver.sql_text, sqlserver.tsql_stack ) ), ADD EVENT sqlserver.lock_deadlock_chain ( ACTION ( package0.collect_system_time, package0.event_sequence, package0.process_id, sqlserver.database_name, sqlserver.plan_handle, sqlserver.request_id, sqlserver.session_id, sqlserver.sql_text, sqlserver.tsql_stack ) ) ,ADD EVENT sqlserver.xml_deadlock_report ( ACTION ( package0.collect_system_time, package0.event_sequence, package0.process_id, sqlserver.database_name, sqlserver.plan_handle, sqlserver.request_id, sqlserver.session_id, sqlserver.sql_text, sqlserver.tsql_stack ) ) ADD TARGET package0.event_file ( SET filename=N'G:\log.02\Deadlock\DeadlockFile.xel'), ADD TARGET package0.ring_buffer(SET max_events_limit=(10000), occurrence_number=(50000) ) WITH ( MAX_MEMORY=4096 KB, EVENT_RETENTION_MODE=ALLOW_SINGLE_EVENT_LOSS, MAX_DISPATCH_LATENCY=30 SECONDS, MAX_EVENT_SIZE=0 KB, MEMORY_PARTITION_MODE=NONE, TRACK_CAUSALITY=OFF,STARTUP_STATE=OFF ) GO
Do sprawdzenia wyników:
WITH cte AS ( SELECT XEvent.query('(event/data/value/deadlock)[1]') AS DeadlockGraph, CONVERT( nvarchar(MAX), XEvent.query('(event/data/value/deadlock)[1]')) AS DeadlockGraphTxt FROM ( SELECT XEvent.query('.') AS XEvent FROM ( SELECT CAST(target_data AS XML) AS TargetData FROM sys.dm_xe_session_targets st INNER JOIN sys.dm_xe_sessions s ON s.address = st.event_session_address WHERE s.NAME = 'DeadlockTrace' AND st.target_name = 'ring_buffer' ) AS Data CROSS APPLY TargetData.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS XEventData(XEvent) ) AS source ) SELECT *, SUBSTRING(DeadlockGraphTxt,CHARINDEX('procname="',DeadlockGraphTxt)+10, CHARINDEX('"',SUBSTRING(DeadlockGraphTxt,CHARINDEX('procname="',DeadlockGraphTxt)+10, 500)) -1) FROM cte
Poniżej schemat “deadlocka”:
Rozwiązaniem problemy w tym przypadku była zmiana zapytania na bardziej wydajne. Alternatywnie moglibyśmy skorzystać z ustawienia MAXDOP dla tego zapytania.
- Problem z Cross Apply
Nowy “Cardinality Estimator” z pewnych względów zaczął mieć również problemy z cross apply. Pomijając treść samego zapytania po zamianie cross-apply na dodatkowe zapytanie oraz tabelę tymczasową zapytanie znacząco przyspieszyło.
Powyżej statystyki dla zapytania z cross-apply oraz dla nowej wersji zapytania.
- Problem ze zmianą typu danych dla partycji
Po zmianie trybu zgodności poniższe zapytanie zwracało błąd:SELECT boundary_id + 1, prv.value FROM TDW.sys.partition_range_values prv INNER JOIN TDW.sys.partition_functions pf ON prv.function_id = pf.function_id WHERE pf.name = 'partitionDayFunction' AND (CAST(prv.value AS Date) IN (SELECT CAST(DATEADD(DAY, -6, GETUTCDATE()) AS Date)))
Problem dotyczył konwertowania wartości funkcji z typu SQL_Variant na datę.
SELECT boundary_id + 1, prv.value FROM TDW.sys.partition_range_values prv INNER JOIN TDW.sys.partition_functions pf ON prv.function_id = pf.function_id WHERE pf.name = 'partitionDayFunction' AND CAST(DATEADD(DAY, -6, GETUTCDATE()) AS DATE) = CASE WHEN pf.name = 'partitionDayFunction' THEN CAST(prv.value AS DATE) END
W rozwiązaniu problemu pomógł Adam Machanic:
Co ciekawe, przed zmianą, procedury z podobnym kodem nigdy nie zwróciły błędu, natomiast po zmianie procedury zawsze kończyły się błędem.
- Merge
Odnośnie mergy planowana jest dłuższa seria… Z całą pewnością natomiast niektóre procedury z klauzulą “Merge” po zmianie trybu zgodności z 110 na 120 spowolniły. Dla niektórych zmiana była dość znaczna i zapytania trzeba było przepisać.
Podsumowanie
Zmiana trybu zgodności dla SQL Server jest zmianą bardzo prostą i szybką, natomiast w niektórych sytuacjach może przysporzyć wielu problemów. W celu zniwelowania potencjalnych problemów w środowisku produkcyjnym trzeba mieć dobry plan, dobrze rozumieć potencjalne problemy i wiedzieć jak na nie reagować. Mam nadzieję, że ten post oraz “mój” plan pomoże Wam podczas Waszych przygód ze zmianą trybu zgodności lub instalacją nowej wersji SQL Server.
Dodatek
Od wersji SQL Server 2016 warto również przyjrzeć się ALTER DATABASE SCOPED CONFIGURATION https://docs.microsoft.com/en-us/sql/t-sql/statements/alter-database-scoped-configuration-transact-sql
- Docker dla amatora danych – Tworzenie środowiska (VM) w Azure i VirtualBox (skrypt) - April 20, 2020
- Power-up your BI project with PowerApps – materiały - February 5, 2020
- Docker dla “amatora” danych – kod źródłowy do prezentacji - November 18, 2019
Panie Sławomirze mam pytanie odnośnie zmiany CL w przypadku klastra Always-On
Czy zmieniam CL na primary replika i czekam aż zmiany “przejeda” na pozostałe repliki czy może jest jakaś inna procedura