W ostatnim czasie zainteresowałem się nieco technologią In-memory OLTP zachęcony paroma demonstracjami, które było mi dane zobaczyć na przestrzeni ostatnich miesięcy. Dlatego też postanowiłem podzielić się z czytelnikami wiedzą na temat tej właśnie technologii – ten post jest swoistego rodzaju wstępem do tego aby zacząć zabawę z Hekatonem i ma za zadanie zachęcić Was do zgłębiania wiedzy na temat tego świetnego produktu.
Hekaton to nazwa kodowa omawianej technologii i nie jedyna, oficjalnie możemy ją nazywać In-memory OLTP ale funkcjonuje jeszcze trzecia nazwa, a mianowicie XTP czyli akronim od eXtreme Transaction Processing. Każda z tych nazw gdzieś funkcjonuje – pierwsza używana bardzo często z przyzwyczajenia przez ekspertów, druga to oficjalna nomenklatura Microsoftu, trzecia z kolei pojawia się w obiektach systemowych wbudowanych w SQL Server. Sama technologia pojawiła się w SQL Server 2014 i była flagowym produktem tego wydania.Opierała się ona na trzech podstawowych filarach, które koegzystując ze sobą miały dawać nieosiągalny do tej pory poziom wydajności:
Cała technologia miała za zadanie minimalizację użycia dysku, całkowicie współbieżny i niezależny system transakcji oraz natywną kompilację obiektów. Czy to wszystko coś zmienia? Z całą pewnością! Można wręcz powiedzieć, że technologia ta całkowicie zmienia warunki gry ze względu na wspomnianą wcześniej wydajność. Ponadto gdy chcemy wykorzystać mechanizmy in-memory nie musimy instalować żadnych dodatkowych komponentów gdyż wszelkie potrzebne składniki są wbudowane w silnik SQL. W ramach niniejszego artykułu chciałbym opowiedzieć Wam o tym jak zacząć pracować z tabelami w ramach technologii In-memory OLTP, jak z nich korzystać i co kryje się pod spodem.
Nie owijając w bawełnę przejdźmy na samym początku od razu do stworzenia przykładowej bazy danych na której będziemy wykonywać nasze demonstracje:
CREATE DATABASE [Hekaton_demo] ON PRIMARY ( NAME = N'Hekaton_demo', FILENAME = N'C:\Program Files\Microsoft SQL Server\MSSQL13.SQL16\MSSQL\DATA\Hekaton_demo.mdf' , SIZE = 524288KB , FILEGROWTH = 65536KB ) LOG ON ( NAME = N'Hekaton_demo_log', FILENAME = N'C:\Program Files\Microsoft SQL Server\MSSQL13.SQL16\MSSQL\DATA\Hekaton_demo_log.ldf' , SIZE = 131072KB , FILEGROWTH = 65536KB ) GO
Jak widać bazę, która będzie zawierała nasze obiekty in-memory tworzymy w tradycyjny sposób. Oczywiście w powyższym kodzie pomijamy kwestie, że pliki bazy znajdują się na dysku systemowym 🙂 Kolejnym krokiem jaki musimy przedsięwziąć jest dodanie do naszej bazy danych specjalnej grupy plików:
ALTER DATABASE Hekaton_demo ADD FILEGROUP AWMemGroup CONTAINS MEMORY_OPTIMIZED_DATA GO
Kluczowa jest tutaj fraza CONTAINS MEMORY_OPTIMIZED_DATA to właśnie dzięki temu zapisowi SQL Server będzie wiedział, na której grupie plików działać w przypadku obiektów in-memory. Grupa plików stworzona w ten sposób jest grupą plików mechanizmu FILESTREAM, który w wersji SQL Server 2014 zarządzał wszelkimi plikami obiektów in-memory, a obecnie jest jedynie kontenerem na pliki, a całość zarządzania przekazana została do silnika in-memory OLTP. Na obecną chwilę (SQL Server 2016 SP1) możemy mieć tylko jedną tego typu grupę plików na bazę. Kolejnym krokiem jest dodanie pliku do powstałej grupy – robimy to niemal w standardowy sposób komendą ADD FILE:
ALTER DATABASE [Hekaton_demo] ADD FILE ( NAME = N'Hekaton_demo_inmem', FILENAME = N'C:\Program Files\Microsoft SQL Server\MSSQL13.SQL16\MSSQL\DATA\Hekaton_demo_inmem' ) TO FILEGROUP [AWMemGroup] GO
Dlaczego niemal? Ponieważ jak zapewne zauważyliście to tak naprawdę nie dodaliśmy pliku tylko folder – to właśnie w tej lokalizacji będą znajdować się pliki np. zrzucane procesem Checkpoint na dysk twardy. W tym miejscu może pojawić się pytanie “jak to pliki zrzucane na dysk twardy?! Przecież to in-memory!” – otóż w przypadku gdy chcemy aby nasze dane były trwałe to jakaś interakcja z dyskiem twardym być musi ale nie jest to konieczne o czym przekonamy się już za chwilę. Gdy już mamy stworzone podwaliny pod nasze obiekty in-memory możemy przejrzeć metadane grupy plików:
select name, type, type_desc from sys.filegroups AS FG
Rezultatem jest prosta informacja o tym czy dana grupa plików jest przeznaczona na obiekty tradycyjne czy też na obiekty in-memory:
Z powyższych przykładów płynie wniosek, że kontener jakim jest baza danych może zawierać obiekty zarówno te tradycyjne jak i nowe co daje nam pewne możliwości jeśli chodzi o hybrydową architekturę mieszającą oba podejścia. Tak więc na poziomie bazy danych nie zobaczymy informacji czy zawiera ona obiekty in-memory czy też nie. Skoro mamy już przygotowaną bazę danych stwórzmy pierwsze obiekty jakimi będą tabele:
CREATE TABLE dbo.ShoppingCart ( ShoppingCartId INT IDENTITY(1,1) PRIMARY KEY NONCLUSTERED, UserId INT NOT NULL INDEX ix_UserId, CreatedDate DATETIME2 NOT NULL, TotalPrice MONEY ) WITH (MEMORY_OPTIMIZED=ON, DURABILITY=SCHEMA_AND_DATA) GO CREATE TABLE dbo.UserSession ( SessionId INT IDENTITY(1,1) PRIMARY KEY NONCLUSTERED HASH WITH (BUCKET_COUNT=400000), UserId int NOT NULL, CreatedDate DATETIME2 NOT NULL, ) WITH (MEMORY_OPTIMIZED=ON, DURABILITY=SCHEMA_ONLY) GO
Jak widać sama składnia nie jest zbyt skomplikowana i przypomina tradycyjną deklarację tabeli. Ważne jest to aby wskazać opcję MEMORY_OPTIMIZED=ON, która odróżnia tabelę dyskową od in-memory. Dodatkowym wymogiem jest to, aby tabela miała minimum jeden indeks. W ramach tabel in-memory mamy dostępne dwie całkowicie nowe struktury indeksowe tj:
- NONCLUSTERED (zwany również RANGE) INDEX
- HASH INDEX
O tym jak one działają wewnętrznie zdążymy sobie jeszcze powiedzieć, na ten moment warto wiedzieć, że tabela może mieć maksymalnie osiem indeksów z czego, jak już wspomniałem, jeden musi wystąpić zawsze. Kolejną opcją jaką możemy podać w deklaracji CREATE TABLE jest trwałość tabeli, którą definiujemy właściwością DURABILITY. Właściwość ta może przyjmować dwa tryby:
- SCHEMA_AND_DATA – dane będą trwałe to znaczy będą logowane w dzienniku transakcyjnym i czasowo zrzucane procesem CHECKPOINT na dysk twardy, jest to opcja domyślna
- SCHEMA_ONLY – dane nie będą trwałe co oznacza, że w żadne operacje na tabelach tego typu nie będzie angażowany dysk, a co za tym idzie operacje te będą ekstremalnie szybkie jednakże w przypadku awarii lub restartu instancji zostaną one utracone
Jak możecie się domyślać dla obu trybów trwałości danych w tabelach można znaleźć całkiem ciekawe zastosowania. Pierwszy z nich nie powinien sprawić problemu aby znaleźć mu scenariusz zastosowania, drugi z kolei może być wykorzystany np. jako obszar przejściowy hurtowni danych bądź też np. w aplikacjach z danymi sesyjnymi, których utrata praktycznie nie jest dla nas problemem czy nawet jako alternatywa dla tabel tymczasowych nie obciążająca przy tym bazy systemowej tempdb. Sprawdźmy czy mamy w widoku sys.tables jakieś informacje na temat in-memory:
SELECT name, is_memory_optimized, durability, durability_desc FROM sys.tables
Jak można było przypuszczać w metadanych mamy flagę is_memory_optimized wskazującą czy tabela jest in-memory czy też nie oraz tryb trwałości dla każdej tabeli. Podczas tworzenia tabeli in-memory SQL Server tworzy i kompiluje do DLL kod odpowiedzialny za działania na tabelach – sam silnik bezpośrednio nie działa na tych strukturach, on po prostu wywołuje określone metody odpowiedzialne za daną czynność na tabeli. Ze względu na takie działanie w SQL Server 2014 nie było możliwe wpływanie na strukturę tabeli komendą ALTER, od SQL Server 2016 jednakże można używać tego polecenia lecz w rzeczywistości tworzy on nową tabelę i kopiuje dane ze starych do nowych struktur co powoduje zwiększone wykorzystanie pamięci. Rozmiar pojedynczego wiersza dla danych in-row nie może przekraczać 8060 bajtów jednakże właśnie od SQL Server 2016 wspierany jest off-row czyli obiekty o zmiennej długości przekraczające ten limit oraz obiekty LOB jak (N)VARCHAR(max) czy VARBINARY(MAX).
Przechodząc dalej wypełnijmy nasze tabele używając prostej struktury pętli:
--insert sample data for dbo.Shopping Cart DECLARE @Counter INT=0 DECLARE @NoOfRows INT=1000000 WHILE @Counter<=@NoOfRows BEGIN INSERT dbo.ShoppingCart(UserId,CreatedDate,TotalPrice) VALUES (CAST(RAND()*10000 AS INT), DATEADD(DAY,-CAST(RAND()*1000 AS INT),GETDATE()),RAND()*100) SET @Counter=@Counter+1 END GO --insert sample data for dbo.UserSession DECLARE @Counter INT=0 DECLARE @NoOfRows INT=100000 WHILE @Counter<=@NoOfRows BEGIN INSERT dbo.UserSession(UserId,CreatedDate) VALUES (CAST(RAND()*10000 AS INT), DATEADD(DAY,-CAST(RAND()*1000 AS INT),GETDATE())) SET @Counter=@Counter+1 END GO
Co do samego ładowania to bardzo duży narzut generowała pętla jednakże warto w tym miejscu zauważyć, że ładowanie tabeli dbo.UserSession trwało dokładnie 30 sekund z kolei ładowanie tabeli dbo.ShoppingCart trwałoponad minutę! Różnica wynika z ustawienia trwałości, ze względu na to że dbo.UserSession ustawione zostało jako SCHEMA_ONLY to nie mieliśmy żadnego styku z dyskiem twardym, żadnego logowania co skutkowało znacznie szybszym czasem wykonania – z tego też powodu tego typu tabele świetnie się sprawdzają w procesach ETL do przechowywania tymczasowych rezultatów. Aby dostać się do danych przechowywanych w strukturach tego typu możemy użyć jednego z dwóch podejść tzn. standardowego interpretowanego kodu TSQL lub też procedur natywnie kompilowanych. Różnice pomiędzy tymi podejściami mogą zdawać się oczywiste – standardowy kod TSQL musi być przez silnik odpowiednio zinterpretowany, parsowany, na jego podstawie budowany jest plan zapytania oraz wykonywana musi być jego kompilacja – dodatkowo kod odwołujący się do tabel in-memory musi wykorzystać specjalny komponent QUERY INTEROP, który powoduje dodatkowy narzut. Jak bardzo łatwo się domyśleć w przypadku procedur natywnie kompilowanych wszystko to odbywa się w momencie tworzenia takiej procedury po to by w późniejszym czasie tj. w momencie uruchomienia nie potrzeba było przechodzić przez te wszystkie kroki. Dlatego też już na tym etapie warto pamiętać, że najszybszą metodą na pracę z omawianymi tabelami jest bez wątpienia procedura natywnie kompilowana. Jej użycie natomiast nie jest zawsze możliwe gdyż w ramach takiej struktury nie możemy użyć całego wachlarza możliwości dostępnych w ramach języka TSQL (o tym napiszemy parę słów w kolejnym artykule o procedurach natywnie kompilowanych, który pojawi się już niedługo). Sam interpretowany TSQL też posiada pewne ograniczenia gdy odnosi się do tabel in-memory takie jak brak:
- TRUNCATE TABLE
- MERGE (w momencie gdy tabela in-memory jest ustawiona jako target)
- Transakcji i zapytań odpytujących obiekty z więcej niż jednej bazy
- zapytań opartych o Linked server
- hintów blokujących
- hintów poziomów izolacji (READUNCOMMITTED,READCOMMITTED,READCOMMITTEDLOCK)
- kursorów dynamicznych
- kilku innych hintów jak np. IGNORE_CONSTRAINTS
Gdy zdecydujemy się na wykorzystanie standardowego, interpretowanego TSQL to możliwe jest w pojedynczym zapytaniu odpytać zarówno MEMORY OPTIMIZED TABLES jak i tabele tradycyjne – zapytania tego typu w nomenklaturze hekatonu nazywamy cross-container queries. Natywne procedury z kolei mogą odpytywać tylko i wyłącznie struktury in-memory.
Przechodząc dalej po załadowaniu danych kolejnym krokiem w naszej demonstracji jest włączenie statystyki wejścia-wyjścia oraz statystyk czasowych i odpytanie naszych tabel:
SET STATISTICS IO,TIME ON GO SELECT * FROM dbo.UserSession WHERE SessionId= 9319 GO
Niemal identyczne statystyki otrzymałem odpytując tabelę dbo.ShoppingCart. Sam rezultat nie jest szczególnie interesujący – bardziej ciekawe są wiadomości, które otrzymaliśmy. Jak można zauważyć przeszukanie miliona wierszy trwało rząd wielkości krócej niż milisekunda – tutaj możemy wręcz mówić o nanosekundach! Ponadto nie mamy żadnych statystyk IO! Jest to podstawowa zasada działania MEMORY OPTIMIZED TABLES tzn. dane nigdy nie są odczytywane z dysku twardego – zawsze odczytywane są z pamięci. Dysk jest potrzebny tylko i wyłącznie w celu Disaster Recovery i to tylko dla tabel oznaczonych jako DURABILITY=SCHEMA_AND_DATA.
To, że omawiana technologia wykorzystuje dużo pamięci operacyjnej jest oczywiste, ale ile jej wykorzystuje? Możemy to sprawdzić na kilka sposobów, a najprostszym z nich jest użycie nowego DMV o nazwie sys.dm_db_xtp_table_memory_stats. Daje on nam informacje o tym ile pamięci zajmuje dana tabela – użyjmy go zatem:
SELECT t.object_id, t.name, ISNULL((SELECT CONVERT(decimal(18,2),(TMS.memory_used_by_table_kb)/1024.00)), 0.00) AS table_used_memory_in_mb, ISNULL((SELECT CONVERT(decimal(18,2),(TMS.memory_allocated_for_table_kb - TMS.memory_used_by_table_kb)/1024.00)), 0.00) AS table_unused_memory_in_mb, ISNULL((SELECT CONVERT(decimal(18,2),(TMS.memory_used_by_indexes_kb)/1024.00)), 0.00) AS index_used_memory_in_mb, ISNULL((SELECT CONVERT(decimal(18,2),(TMS.memory_allocated_for_indexes_kb - TMS.memory_used_by_indexes_kb)/1024.00)), 0.00) AS index_unused_memory_in_mb FROM sys.tables t JOIN sys.dm_db_xtp_table_memory_stats TMS ON (t.object_id = TMS.object_id)
Powyższy zrzut ekranowy obrazuje rezultat wykonania zapytania dla naszej przykładowej bazy danych. Zatem nasza tabela ShoppingCart zajmuje obecnie około 69MB z czego 0.4MB jest nieużywane, indeksy na tej tabeli zajmują około 13MB z czego 0.9MB jest nieużywane. Bardzo przydatne informacje – część z Was może zapytać czemu napisałem zapytanie w tak dziwny sposób? Odpowiedź jest prosta – jest to zapytanie używane przez wbudowany raport o nazwie Memory Usage By Memory Optimized Objects:
O ile wolę wykorzystywać DMV wprost o tyle musze przyznać, że ten raport jest naprawdę treściwy i w naprawdę dobry sposób przedstawia istotne dla nas informacje na temat użycia pamięci przez poszczególne obiekty.
No dobrze, widzimy jak to wszystko dobrze działa jednakże wszędzie mówią o tym, że in-memory OLTP to całkiem nowa technologia – wiemy, że dane przetwarzane są w pamięci operacyjnej i zawsze z tej lokalizacji odczytywane ale jaka jest tego architektura? Przede wszystkim podstawową różnicą jest sposób przechowywania danych. W tradycyjnym “dyskowym” podejściu dane składowane są na stronach i ekstentach – w przypadku in-memory OLTP dane są przechowywane… w wierszach. Struktura wiersza została przedstawiona poniżej:
Standardowy wiersz składa się z dwóch elementów tj. nagłówka (Row Header) oraz sekcji PayLoad czyli po prostu z danych. Szczegółowa struktura nagłówka przedstawiona została poniżej:
Dane zapisywane do tabel in-memory są wersjonowane co umożliwia optymistyczną współbieżność transakcji. Informacje o wersji przechowywane są w dwóch polach tj. Begin TS oraz End TS. Pierwszy z nich to nic innego jak znacznik czasowy zatwierdzenia (COMMIT) transakcji, która wiersz wstawiła, drugi z kolei to czas zatwierdzenia transakcji, która wiersz usunęła. Warto w tym miejscu zdać sobie sprawę z faktu, że dane w wierszach nigdy nie są aktualizowane po to aby nie powodować blokad i dlatego też operacja UPDATE to nic innego jak oznaczenie wiersza jako nieaktualnego (czyli wstawienie wartość do End TS) i dodanie wiersza z nowymi wartościami, który End TS ma ustawione na specjalną wartość, którą możemy utożsamiać z nieskończonością. Na tej właśnie podstawie wersjonowane są wiersze w pamięci, w tle działa oczywiście specjalny proces (Garbage Collector), który “wyrzuca” z pamięci te wiersze, które nie są już “widoczne” dla żadnej z aktywnych transakcji.
Kolejna informacje zawarte w nagłówku to:
- Stmt Id, który jest identyfikatorem zapytania w ramach transakcji, które wstawiło/usunęło wiersz ( identyfikator potrzebny do tzw. Halloween Protection).
- Indx Link Count czyli licznik indeksów, które odwołują się do danego wiersza
- Padding – dodatkowa nieużywana przestrzeń
- Index Pointers – zestaw wskaźników indeksów wskazujących kolejny wiersz w łańcuchu indeksu.
Jak możecie wywnioskować ten sposób przechowywania znacznie różni się od tradycyjnego opartego na stronach danych. Do tego dochodzą dodatkowe plusy wynikające z pozostałych mechanizmów Hekatonu co sprawia, że cała technologia może działać naprawdę szybko. Oczywiście jest całkiem sporo mechanizmów nie wspieranych przez omawianą technologię jak kompresja czy też partycjonowanie jednakże warto pamiętać o tym, że niekiedy ich użycie jest wykluczone gdyż są one wprost przystosowane do obecnych struktur opartych na stronach i ekstentach (jak np. page compression) być może zostaną one w jakiś sposób dostosowane lub dostarczone w inny sposób w przyszłych wydaniach SQL Server ale nie wszystkie są potrzebne przy zmianie wewnętrznych mechanizmów z dyskowych na in-memory. Ze względu na to, że tak jak wspomniałem technologia ta jest w ostatnim czasie w kręgu moich zainteresować to możecie spodziewać się dalszych artykułów poruszających tematykę obiektów natywnie kompilowanych, transakcji , migracji logowania czy DR. Mam nadzieję, że będzie to dla Was interesujące.
- Avoiding Issues: Monitoring Query Pushdowns in Databricks Federated Queries - October 27, 2024
- Microsoft Fabric: Using Workspace Identity for Authentication - September 25, 2024
- Executing SQL queries from Azure DevOps using Service Connection credentials - August 28, 2024
Last comments