Trwałe przechowywanie danych i interfejsy API plików systemu Linux

Ja, badając stabilność przechowywania danych w systemach chmurowych, postanowiłem się sprawdzić, aby upewnić się, że rozumiem podstawowe rzeczy. I zacznij od przeczytania specyfikacji NVMe aby zrozumieć, jakie gwarancje dotyczące trwałości danych (czyli gwarancje, że dane będą dostępne po awarii systemu) dają nam dyski NMVe. Doszedłem do następujących głównych wniosków: należy brać pod uwagę uszkodzone dane od momentu wydania polecenia zapisu danych, aż do momentu ich zapisania na nośniku. Jednak w większości programów wywołania systemowe są dość bezpiecznie używane do zapisywania danych.

W tym artykule badam mechanizmy trwałości zapewniane przez API plików systemu Linux. Wydaje się, że tutaj wszystko powinno być proste: program wywołuje polecenie write(), a po zakończeniu działania tego polecenia dane zostaną bezpiecznie zapisane na dysku. Ale write() kopiuje jedynie dane aplikacji do pamięci podręcznej jądra znajdującej się w pamięci RAM. Aby wymusić na systemie zapisanie danych na dysk, należy zastosować dodatkowe mechanizmy.

Trwałe przechowywanie danych i interfejsy API plików systemu Linux

Ogólnie rzecz biorąc, ten materiał to zbiór notatek odnoszących się do tego, czego się dowiedziałem na interesujący mnie temat. Jeśli porozmawiamy bardzo krótko o najważniejszym, okaże się, że aby zorganizować trwałe przechowywanie danych, należy użyć polecenia fdatasync() lub otwieraj pliki z flagą O_DSYNC. Jeśli chcesz dowiedzieć się więcej o tym, co dzieje się z danymi w drodze z kodu na dysk, zajrzyj do to artykuł.

Funkcje korzystania z funkcji write().

Wywołanie systemowe write() zdefiniowane w normie IEEE POSIX jako próba zapisania danych do deskryptora pliku. Po pomyślnym zakończeniu pracy write() operacje odczytu danych muszą zwrócić dokładnie te bajty, które zostały wcześniej zapisane, nawet jeśli dostęp do danych jest uzyskiwany z innych procesów lub wątków (tutaj odpowiednia sekcja standardu POSIX). Tutajw sekcji dotyczącej interakcji wątków z normalnymi operacjami na plikach znajduje się uwaga, która mówi, że jeśli każdy z dwóch wątków wywołuje te funkcje, to każde wywołanie musi albo widzieć wszystkie wskazane konsekwencje, do których prowadzi wykonanie drugiego wywołania, albo nie widzę żadnych konsekwencji. Prowadzi to do wniosku, że wszystkie operacje we/wy na plikach muszą blokować zasób, nad którym pracują.

Czy to oznacza, że ​​operacja write() jest atomowy? Z technicznego punktu widzenia tak. Operacje odczytu danych muszą zwracać całość lub nic z tego, co zostało zapisane write(). Ale operacja write()zgodnie ze standardem nie musi kończyć się na spisaniu wszystkiego, o co została poproszona. Dopuszczalny jest zapis tylko części danych. Na przykład możemy mieć dwa strumienie, każdy dołączający 1024 bajty do pliku opisanego przez ten sam deskryptor pliku. Z punktu widzenia normy wynik będzie akceptowalny, gdy każda z operacji zapisu będzie mogła dodać do pliku tylko jeden bajt. Operacje te pozostaną niepodzielne, ale po ich zakończeniu dane zapisywane w pliku zostaną pomieszane. tutaj jest bardzo interesująca dyskusja na ten temat na Stack Overflow.

Funkcje fsync() i fdatasync().

Najłatwiejszym sposobem opróżnienia danych na dysk jest wywołanie funkcji fsync(). Ta funkcja prosi system operacyjny o przesłanie wszystkich zmodyfikowanych bloków z pamięci podręcznej na dysk. Obejmuje to wszystkie metadane pliku (czas dostępu, czas modyfikacji pliku itd.). Uważam, że te metadane są rzadko potrzebne, więc jeśli wiesz, że nie są one dla Ciebie istotne, możesz skorzystać z tej funkcji fdatasync(), Wsparcie nadotycząca fdatasync() mówi, że podczas działania tej funkcji na dysku zapisywana jest taka ilość metadanych, która jest „niezbędna do prawidłowego wykonania kolejnych operacji odczytu danych”. I właśnie na tym zależy większości aplikacji.

Problemem, jaki może się tu pojawić, jest to, że mechanizmy te nie gwarantują możliwości odnalezienia pliku po ewentualnej awarii. W szczególności, gdy tworzony jest nowy plik, należy wywołać fsync() dla katalogu, który go zawiera. W przeciwnym razie po awarii może się okazać, że plik ten nie istnieje. Dzieje się tak dlatego, że w systemie UNIX ze względu na użycie twardych dowiązań plik może znajdować się w wielu katalogach. Dlatego dzwoniąc fsync() plik nie ma możliwości dowiedzenia się, które dane katalogu również powinny zostać usunięte na dysk (tutaj możesz przeczytać więcej na ten temat). Wygląda na to, że system plików ext4 jest w stanie to zrobić automatycznie zastosuj fsync() do katalogów zawierających odpowiednie pliki, ale może nie mieć to miejsca w przypadku innych systemów plików.

Mechanizm ten można zaimplementować w różny sposób w różnych systemach plików. użyłem blktrace aby dowiedzieć się, jakie operacje dyskowe są stosowane w systemach plików ext4 i XFS. Obydwa wydają zwykłe polecenia zapisu na dysk zarówno zawartości plików, jak i dziennika systemu plików, opróżniają pamięć podręczną i wychodzą, wykonując zapis do dziennika FUA (Force Unit Access, zapisywanie danych bezpośrednio na dysku, z pominięciem pamięci podręcznej). Prawdopodobnie robią to właśnie w celu potwierdzenia faktu transakcji. Na dyskach, które nie obsługują FUA, powoduje to dwa opróżnienia pamięci podręcznej. Moje eksperymenty to pokazały fdatasync() trochę szybciej fsync(). Pożytek blktrace wskazuje, że fdatasync() zwykle zapisuje mniej danych na dysk (w ext4 fsync() zapisuje 20 KiB i fdatasync() - 16 KiB). Dowiedziałem się również, że XFS jest nieco szybszy niż ext4. A tu z pomocą blktrace udało się tego dowiedzieć fdatasync() opróżnia mniej danych na dysk (4 KiB w XFS).

Niejednoznaczne sytuacje podczas używania fsync()

Przychodzą mi na myśl trzy niejednoznaczne sytuacje dotyczące fsync()z którymi spotkałem się w praktyce.

Pierwszy taki przypadek miał miejsce w 2008 roku. W tamtym czasie interfejs Firefoksa 3 „zawieszał się” w przypadku zapisywania na dysku dużej liczby plików. Problem polegał na tym, że implementacja interfejsu wykorzystywała bazę danych SQLite do przechowywania informacji o jego stanie. Po każdej zmianie jaka nastąpiła w interfejsie wywoływano funkcję fsync(), co dawało dobre gwarancje stabilnego przechowywania danych. W używanym wówczas systemie plików ext3 funkcja fsync() opróżnił na dysk wszystkie „brudne” strony w systemie, a nie tylko te, które były powiązane z odpowiednim plikiem. Oznaczało to, że kliknięcie przycisku w przeglądarce Firefox mogło spowodować zapisanie megabajtów danych na dysku magnetycznym, co mogło zająć wiele sekund. Rozwiązanie problemu, o ile zrozumiałem to materiału, polegało na przeniesieniu pracy z bazą danych do asynchronicznych zadań w tle. Oznacza to, że Firefox stosował bardziej rygorystyczne wymagania dotyczące trwałości pamięci, niż było to naprawdę konieczne, a funkcje systemu plików ext3 tylko pogłębiały ten problem.

Drugi problem pojawił się w 2009 roku. Następnie, po awarii systemu, użytkownicy nowego systemu plików ext4 odkryli, że wiele nowo utworzonych plików miało zerową długość, ale tak się nie stało w przypadku starszego systemu plików ext3. W poprzednim akapicie mówiłem o tym, jak ext3 zrzuciło zbyt dużo danych na dysk, co znacznie spowolniło działanie. fsync(). Aby poprawić sytuację, ext4 opróżnia tylko te „brudne” strony, które dotyczą konkretnego pliku. A dane innych plików pozostają w pamięci znacznie dłużej niż w przypadku ext3. Zrobiono to w celu poprawy wydajności (domyślnie dane pozostają w tym stanie przez 30 sekund, możesz to skonfigurować za pomocą brudne_expire_centisecs; tutaj można znaleźć więcej informacji na ten temat). Oznacza to, że po awarii duża ilość danych może zostać bezpowrotnie utracona. Rozwiązaniem tego problemu jest użycie fsync() w aplikacjach, które muszą zapewnić stabilne przechowywanie danych i maksymalnie zabezpieczyć je przed konsekwencjami awarii. Funkcjonować fsync() działa znacznie lepiej z ext4 niż z ext3. Wadą tego podejścia jest to, że jego użycie, tak jak poprzednio, spowalnia niektóre operacje, takie jak instalowanie programów. Zobacz szczegóły na ten temat tutaj и tutaj.

Trzeci problem dot fsync(), powstał w 2018 roku. Następnie w ramach projektu PostgreSQL odkryto, że jeśli funkcja fsync() napotka błąd, oznacza „brudne” strony jako „czyste”. W rezultacie następujące połączenia fsync() nic nie rób z takimi stronami. Z tego powodu zmodyfikowane strony są przechowywane w pamięci i nigdy nie są zapisywane na dysku. To prawdziwa katastrofa, bo aplikacja będzie myślała, że ​​jakieś dane są zapisywane na dysku, a tak naprawdę tak nie będzie. Takie niepowodzenia fsync() są rzadkie, zastosowanie w takich sytuacjach nie może prawie nic zrobić, aby zwalczyć problem. Obecnie, gdy tak się dzieje, PostgreSQL i inne aplikacje ulegają awarii. Tutajw artykule „Czy aplikacje mogą odzyskać siły po awariach fsync?” problem ten został szczegółowo omówiony. Obecnie najlepszym rozwiązaniem tego problemu jest użycie opcji Direct I/O z flagą O_SYNC lub z flagą O_DSYNC. Dzięki takiemu podejściu system będzie raportował błędy, które mogą wystąpić podczas wykonywania określonych operacji zapisu danych, jednak takie podejście wymaga, aby aplikacja sama zarządzała buforami. Przeczytaj więcej na ten temat tutaj и tutaj.

Otwieranie plików przy użyciu flag O_SYNC i O_DSYNC

Wróćmy do omówienia linuksowych mechanizmów zapewniających trwałe przechowywanie danych. Mianowicie mówimy o używaniu flagi O_SYNC lub flaga O_DSYNC podczas otwierania plików za pomocą wywołania systemowego otwarty(). Dzięki takiemu podejściu każda operacja zapisu danych jest wykonywana tak, jakby była po każdym poleceniu write() system otrzymuje odpowiednie polecenia fsync() и fdatasync(), Specyfikacje POSIX nazywa się to „ukończeniem integralności zsynchronizowanego pliku we/wy” i „ukończeniem integralności danych”. Główną zaletą tego podejścia jest to, że aby zapewnić integralność danych, wystarczy wykonać tylko jedno wywołanie systemowe, a nie dwa (na przykład − write() и fdatasync()). Główną wadą tego podejścia jest to, że wszystkie operacje zapisu przy użyciu odpowiedniego deskryptora pliku zostaną zsynchronizowane, co może ograniczyć możliwość strukturyzacji kodu aplikacji.

Używanie bezpośredniego we/wy z flagą O_DIRECT

Wywołanie systemowe open() wspiera flagę O_DIRECT, który ma na celu ominięcie pamięci podręcznej systemu operacyjnego w celu wykonywania operacji we/wy poprzez bezpośrednią interakcję z dyskiem. To w wielu przypadkach oznacza, że ​​polecenia zapisu wydane przez program zostaną bezpośrednio przetłumaczone na polecenia służące do pracy z dyskiem. Ale ogólnie rzecz biorąc, mechanizm ten nie zastępuje funkcji fsync() lub fdatasync(). Faktem jest, że sam dysk może opóźnienie lub pamięć podręczna odpowiednie polecenia do zapisu danych. I, co gorsza, w niektórych szczególnych przypadkach operacje we/wy wykonywane podczas używania flagi O_DIRECT, audycja w tradycyjne operacje buforowane. Najłatwiejszym sposobem rozwiązania tego problemu jest użycie flagi do otwierania plików O_DSYNC, co będzie oznaczać, że po każdej operacji zapisu nastąpi wywołanie fdatasync().

Okazało się, że system plików XFS dodał niedawno „szybką ścieżkę” dla O_DIRECT|O_DSYNC-rekordy danych. Jeśli blok zostanie nadpisany za pomocą O_DIRECT|O_DSYNC, wówczas XFS zamiast opróżniać pamięć podręczną wykona polecenie zapisu FUA, jeśli urządzenie je obsługuje. Sprawdziłem to za pomocą narzędzia blktrace w systemie Linux 5.4/Ubuntu 20.04. To podejście powinno być bardziej wydajne, ponieważ zapisuje minimalną ilość danych na dysk i wykorzystuje jedną operację, a nie dwie (zapis i opróżnienie pamięci podręcznej). Znalazłem link do łatka Jądro 2018, które implementuje ten mechanizm. Trwa dyskusja na temat zastosowania tej optymalizacji do innych systemów plików, ale o ile wiem, XFS jest jak dotąd jedynym systemem plików, który ją obsługuje.

funkcja sync_file_range().

Linux ma wywołanie systemowe zakres_pliku_synchronizacji(), co pozwala na opróżnienie tylko części pliku na dysk, a nie całego pliku. To wywołanie inicjuje asynchroniczne opróżnianie i nie czeka na jego zakończenie. Ale w odniesieniu do sync_file_range() polecenie to jest określane jako „bardzo niebezpieczne”. Nie zaleca się go używać. Funkcje i zagrożenia sync_file_range() bardzo dobrze opisane w to materiał. W szczególności to wywołanie wydaje się używać RocksDB do kontrolowania, kiedy jądro opróżnia „brudne” dane na dysk. Ale jednocześnie tam, aby zapewnić stabilne przechowywanie danych, jest również używany fdatasync(), kod RocksDB ma kilka interesujących komentarzy na ten temat. Wygląda to na przykład na połączenie sync_file_range() podczas korzystania z ZFS nie opróżnia danych na dysk. Doświadczenie mówi mi, że rzadko używany kod może zawierać błędy. Dlatego odradzam używanie tego wywołania systemowego, chyba że jest to absolutnie konieczne.

Wywołania systemowe, które pomagają zapewnić trwałość danych

Doszedłem do wniosku, że istnieją trzy podejścia, które można wykorzystać do wykonywania trwałych operacji we/wy. Wszystkie wymagają wywołania funkcji fsync() dla katalogu, w którym plik został utworzony. Oto podejścia:

  1. Wywołanie funkcji fdatasync() lub fsync() po funkcji write() (lepiej użyć fdatasync()).
  2. Praca z deskryptorem pliku otwartym z flagą O_DSYNC lub O_SYNC (lepiej - z flagą O_DSYNC).
  3. Użycie polecenia pwritev2() z flagą RWF_DSYNC lub RWF_SYNC (najlepiej z flagą RWF_DSYNC).

Uwagi dotyczące wydajności

Nie zmierzyłem dokładnie działania różnych mechanizmów, które badałem. Różnice, które zauważyłem w szybkości ich pracy, są bardzo małe. Oznacza to, że mogę się mylić i że w innych warunkach to samo może dać inne rezultaty. Najpierw opowiem o tym, co wpływa bardziej na wydajność, a następnie o tym, co wpływa na wydajność w mniejszym stopniu.

  1. Nadpisywanie danych pliku jest szybsze niż dołączanie danych do pliku (wzrost wydajności może wynosić od 2 do 100%). Dołączenie danych do pliku wymaga dodatkowych zmian w metadanych pliku, nawet po wywołaniu systemowym fallocate(), ale wielkość tego efektu może być różna. Dla najlepszego efektu polecam zadzwonić fallocate() aby wstępnie przydzielić wymaganą przestrzeń. Następnie przestrzeń tę należy jawnie wypełnić zerami i wywołać fsync(). Zapewni to, że odpowiednie bloki w systemie plików zostaną oznaczone jako „przydzielone”, a nie „nieprzydzielone”. Daje to niewielką (około 2%) poprawę wydajności. Ponadto niektóre dyski mogą mieć wolniejszy pierwszy dostęp do bloku niż inne. Oznacza to, że wypełnienie przestrzeni zerami może prowadzić do znacznej (około 100%) poprawy wydajności. W szczególności może się to zdarzyć w przypadku dysków AWS EBS (to dane nieoficjalne, nie udało mi się ich potwierdzić). To samo dotyczy przechowywania. Dysk trwały GCP (i to już oficjalna informacja, potwierdzona badaniami). Inni eksperci zrobili to samo obserwacjepowiązane z różnymi dyskami.
  2. Im mniej wywołań systemowych, tym wyższa wydajność (wzrost może wynosić około 5%). Wygląda jak połączenie open() z flagą O_DSYNC albo zadzwoń pwritev2() z flagą RWF_SYNC szybsze połączenie fdatasync(). Podejrzewam, że chodzi o to, że przy tym podejściu rolę odgrywa fakt, że do rozwiązania tego samego zadania trzeba wykonać mniej wywołań systemowych (jedno wywołanie zamiast dwóch). Ale różnica w wydajności jest bardzo mała, więc można ją łatwo zignorować i zastosować w aplikacji coś, co nie doprowadzi do komplikacji jej logiki.

Jeśli interesuje Cię temat zrównoważonego przechowywania danych, oto kilka przydatnych materiałów:

  • Metody dostępu do wejść/wyjść — przegląd podstaw mechanizmów wejścia/wyjścia.
  • Zapewnienie, że dane dotrą na dysk — opowieść o tym, co dzieje się z danymi w drodze z aplikacji na dysk.
  • Kiedy należy wykonać fsync zawierający katalog - odpowiedź na pytanie, kiedy złożyć wniosek fsync() dla katalogów. W skrócie okazuje się, że trzeba to zrobić podczas tworzenia nowego pliku, a powodem tego zalecenia jest to, że w systemie Linux może istnieć wiele odniesień do tego samego pliku.
  • SQL Server w systemie Linux: elementy wewnętrzne FUA - tutaj znajduje się opis implementacji trwałego przechowywania danych w SQL Server na platformie Linux. Jest tu kilka interesujących porównań pomiędzy wywołaniami systemowymi Windows i Linux. Jestem prawie pewien, że to dzięki temu materiałowi dowiedziałem się o optymalizacji FUA XFS.

Czy zdarzyło Ci się kiedyś utracić dane, które Twoim zdaniem były bezpiecznie przechowywane na dysku?

Trwałe przechowywanie danych i interfejsy API plików systemu Linux

Trwałe przechowywanie danych i interfejsy API plików systemu Linux

Źródło: www.habr.com