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

Badając niezawodność przechowywania danych w systemach chmurowych, postanowiłem sprawdzić swoje umiejętności i upewnić się, że rozumiem podstawowe zagadnienia. Ja zacząłem od przeczytania specyfikacji NVMe aby zrozumieć, jakie gwarancje dotyczące niezawodnego przechowywania danych (to znaczy — gwarancje, że dane będą dostępne po awarii systemu) oferują nam dyski NVMe. Oto moje główne wnioski: należy uznać dane za uszkodzone w momencie, gdy zostanie wydane polecenie zapisania danych, aż do momentu, gdy zakończony zostanie zapis na nośniku informacji. Jednak w większości programów do zapisu danych bez obaw używa się wywołań systemowych.

W tym artykule badam mechanizmy niezawodnego przechowywania danych oferowane przez API plików w systemie Linux. Wydaje się, że wszystko powinno być proste: program wywołuje polecenie write(), a po zakończeniu działania tej komendy, dane powinny być niezawodnie zapisane na dysku. Ale write() jedynie kopiuje dane aplikacji do cache'u jądra znajdującego się w pamięci operacyjnej. Aby wymusić na systemie zapis danych na dysku, należy użyć dodatkowych mechanizmów.

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

Ogólnie rzecz biorąc, ten materiał stanowi zbiór notatek na temat tego, co dowiedziałem się w interesującej mnie dziedzinie. Jeśli bardzo skrótowo opisać najważniejsze informacje, to okazuje się, że do organizacji niezawodnego przechowywania danych należy używać komendy fdatasync() lub otwierać pliki z flagą O_DSYNC. Jeśli chcesz szczegółowo dowiedzieć się, co się dzieje z danymi w trakcie ich drogi od kodu programu do dysku, zerknij na tego artykuł.

Cechy użycia funkcji write()

Wywołanie systemowe write() jest zdefiniowane w standardzie IEEE POSIX jako próba zapisu danych do deskryptora pliku. Po pomyślnym zakończeniu operacji write() operacje odczytu danych powinny zwracać dokładnie te bajty, które wcześniej zostały zapisane, nawet w przypadku, gdy do danych uzyskują dostęp inne procesy lub wątki (oto odpowiedni rozdział standardu POSIX). Tutaj, w sekcji poświęconej interakcji strumieni z typowymi operacjami plikowymi, znajduje się uwaga, która wskazuje, że jeśli każdy z dwóch strumieni wywołuje te funkcje, to każde wywołanie powinno widzieć albo wszystkie określone konsekwencje wynikające z wykonania innego wywołania, albo w ogóle żadnych konsekwencji. To pozwala wyciągnąć wniosek, że wszystkie operacje plikowe wejścia/wyjścia muszą utrzymywać blokadę zasobu, z którym pracują.

Czy oznacza to, że operacja write() jest atomowa? Technicznie — tak. Operacje odczytu danych powinny zwracać albo wszystko, albo nic z tego, co zostało zapisane za pomocą write(). Jednak operacja write(), zgodnie ze standardem, nie musi koniecznie kończyć się zapisaniem wszystkiego, co została poproszona o zapisanie. Może wykonać zapis tylko części danych. Na przykład, mamy dwa strumienie, z których każdy dołącza 1024 bajty do pliku opisanego tym samym deskryptorem pliku. Z punktu widzenia standardu akceptowalnym wynikiem będzie sytuacja, w której każda z operacji zapisu może dołączyć do pliku tylko po jednym bajcie. Operacje te pozostaną atomowe, ale po ich zakończeniu dane zapisane przez nie w pliku będą pomieszane. Oto bardzo interesująca dyskusja na ten temat na Stack Overflow.

Funkcje fsync() i fdatasync()

Najprostszym sposobem na zrzut danych na dysk jest wywołanie funkcji fsync(). Ta funkcja żąda od systemu operacyjnego przeniesienia wszystkich zmodyfikowanych bloków z cache na dysk. Obejmuje to także wszystkie metadane pliku (czas dostępu, czas modyfikacji pliku itd.). Uważam, że potrzeba tych metadanych występuje rzadko, więc jeśli wiesz, że nie są dla ciebie istotne, możesz korzystać z funkcji fdatasync(). W pomocy w fdatasync() , mówi się, że w trakcie pracy tej funkcji zapisywany jest na dysk taki wolumen metadanych, który „jest niezbędny do poprawnego wykonania następnych operacji odczytu danych”. A to jest dokładnie to, co interesuje większość aplikacji.

Jednym z problemów, który może się pojawić, jest to, że te mechanizmy nie gwarantują, że plik będzie możliwy do odnalezienia po ewentualnej awarii. W szczególności, gdy tworzy się nowy plik, należy wywołać fsync() dla katalogu, który go zawiera. W przeciwnym razie po awarii może okazać się, że ten plik nie istnieje. Powód tego tkwi w tym, że w UNIX-ie, z powodu stosowania twardych dowiązań, plik może istnieć w kilku katalogach. Dlatego przy wywołaniu fsync() dla pliku nie ma sposobu, aby dowiedzieć się, jakie dokładnie dane z tego katalogu również należy zrzucić na dysk (tutaj można o tym poczytać więcej). Wydaje się, że system plików ext4 jest w stanie automatycznie stosować fsync() do katalogów, które zawierają odpowiednie pliki, ale w przypadku innych systemów plików może być inaczej.

Ten mechanizm może być różnie wdrażany 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. Oba systemy wydają standardowe polecenia zapisu na dysk zarówno dla zawartości plików, jak i dla dziennika systemu plików, zrzucają pamięć podręczną i kończą działanie, wykonując zapis FUA (Force Unit Access, zapis danych bezpośrednio na dysk, pomijając pamięć podręczną) w dzienniku. Prawdopodobnie robią to, aby potwierdzić wykonanie operacji. Na dyskach, które nie wspierają FUA, powoduje to dwa zrzuty pamięci podręcznej. Moje eksperymenty wykazały, że fdatasync() nieco szybciej fsync(). Narzędzie blktrace wskazuje, że fdatasync() zwykle zapisuje na dysk mniej danych (w ext4 fsync() zapisuje 20 KiB, a fdatasync() — 16 KiB). Ponadto ustaliłem, że XFS jest nieco szybszy niż ext4. A tu z pomocą blktrace udało się dowiedzieć, że fdatasync() zrzuca na dysk mniej danych (4 KiB w XFS).

Niejednoznaczne sytuacje związane z używaniem fsync()

Mogę przypomnieć sobie trzy niejednoznaczne sytuacje dotyczące fsync(), z którymi spotkałem się w praktyce.

Pierwszy taki przypadek zdarzył się w 2008 roku. Wtedy interfejs Firefox 3 'zawieszał się', gdy wykonywano zapis na dysk dużej liczby plików. Problem polegał na tym, że w implementacji interfejsu do przechowywania informacji o jego stanie używano bazy danych SQLite. Po każdej zmianie, która miała miejsce w interfejsie, wywoływana była funkcja fsync(), co dawało dobre gwarancje trwałego przechowywania danych. W używanym wówczas systemie plików ext3 funkcja fsync() zrzucała na dysk wszystkie "brudne" strony w systemie, a nie tylko te, które miały związek z danym plikiem. Oznaczało to, że kliknięcie przycisku w Firefoxie mogło wywołać zapis megabajtów danych na dysk magnetyczny, co mogło zająć wiele sekund. Rozwiązanie problemu, jak zrozumiałem z tego materiału, polegało na przeniesieniu pracy z bazą danych do asynchronicznych zadań w tle. Oznacza to, że wcześniej w Firefoxie wprowadzono bardziej rygorystyczne wymagania dotyczące trwałości przechowywania danych, niż było to rzeczywiście potrzebne, a cechy systemu plików ext3 tylko pogłębiły ten problem.

Drugie rozbieżność miała miejsce w 2009 roku. Po awarii systemu użytkownicy nowego systemu plików ext4 napotkali problem, że wiele niedawno utworzonych plików ma zerową długość, podczas gdy w przypadku starszego systemu plików ext3 coś takiego nie miało miejsca. W poprzednim akapicie mówiłem o tym, że ext3 zrzucała na dysk zbyt wiele danych, co znacznie spowalniało działanie fsync(). Aby poprawić sytuację, w ext4 na dysk zrzucane są tylko te "brudne" strony, które mają związek z konkretnym plikiem. A dane innych plików pozostają w pamięci przez znacznie dłuższy czas niż w przypadku stosowania ext3. Zrobiono to w celu poprawy wydajności (domyślnie dane pozostają w tym stanie przez 30 sekund, można to dostosować za pomocą dirty_expire_centisecs; tutaj , można znaleźć dodatkowe materiały na ten temat). Oznacza to, że duża ilość danych może zostać bezpowrotnie utracona po awarii. Rozwiązaniem tego problemu jest stosowanie fsync() w aplikacjach, które muszą zapewnić trwałe przechowywanie danych i maksymalnie zabezpieczyć je przed skutkami awarii. Funkcja fsync() działa przy wykorzystaniu ext4 znacznie efektywniej niż przy użyciu ext3. Wada tego podejścia polega na tym, że jego zastosowanie, jak dawniej, spowalnia wykonanie niektórych operacji, takich jak instalacja programów. Szczegóły na ten temat znajdziesz tutaj i tutaj.

Trzecia problem, dotyczący fsync(), pojawił się w 2018 roku. Wówczas w ramach projektu PostgreSQL ustalono, że jeśli funkcja fsync() napotyka błąd, oznacza "brudne" strony jako "czyste". W rezultacie kolejne wywołania fsync() nic nie robi się z takimi stronami. Z tego powodu zmodyfikowane strony są przechowywane w pamięci i nigdy nie są zapisywane na dysku. To jest prawdziwa katastrofa, ponieważ aplikacja uzna, że jakieś dane zostały zapisane na dysku, podczas gdy w rzeczywistości tak nie jest. Takie awarie fsync() zdarzają się rzadko, aplikacja w takich sytuacjach prawie nic nie może zrobić, aby stawić czoła problemowi. W dzisiejszych czasach, gdy to się zdarza, PostgreSQL i inne aplikacje kończą działanie awaryjnie. Tutaj, w materiale „Czy aplikacje mogą odzyskać się z błędów fsync?”, problem ten jest badany w pełnych szczegółach. Obecnie najlepszym rozwiązaniem tego problemu jest użycie Direct I/O z flagą O_SYNC lub z flagą O_DSYNC. Przy takim podejściu system zgłosi błędy, które mogą wystąpić podczas wykonywania konkretnych operacji zapisu danych, ale to podejście wymaga, aby aplikacja zarządzała buforami samodzielnie. Szczegóły na ten temat można znaleźć w tutaj i tutaj.

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

Wróćmy do omówienia mechanizmów Linux, które zapewniają trwałe przechowywanie danych. A mianowicie mówimy tu o użyciu flagi O_SYNC lub flagi O_DSYNC przy otwieraniu plików za pomocą wywołania systemowego open(). Przy tym podejściu każda operacja zapisu danych jest wykonywana tak, jakby po każdym poleceniu write() systemowi są wydawane odpowiednio polecenia fsync() i fdatasync(). W specyfikacji POSIX nazywa się to „Zakończeniem integracji pliku zatwierdzonego I/O” i „Zakończeniem integralności danych”. Główną zaletą tego podejścia jest to, że w celu zapewnienia integralności danych wystarczy wykonać jedno wywołanie systemowe, a nie dwa (na przykład — write() i fdatasync()). Główną wadą tego podejścia jest to, że wszystkie operacje zapisu korzystające z odpowiedniego deskryptora pliku będą synchronizowane, co może ograniczać możliwości strukturyzacji kodu aplikacji.

Użycie Direct I/O z flagą O_DIRECT

Wywołanie systemowe open() wspiera flagę O_DIRECT, która ma na celu, omijając pamięć podręczną systemu operacyjnego, wykonywanie operacji wejścia/wyjścia, współpracując bezpośrednio z dyskiem. Oznacza to, że w wielu przypadkach polecenia zapisu wydawane przez program będą bezpośrednio przekształcane w polecenia dotyczące pracy z dyskiem. Jednak w ogólnym przypadku ten mechanizm nie jest zastąpieniem funkcji fsync() lub fdatasync(). Chodzi o to, że sam dysk może odłożyć lub buforować odpowiednie komendy zapisu danych. Co gorsza, w niektórych szczególnych przypadkach operacje we/wy wykonywane przy użyciu flagi O_DIRECT, są przesyłane do tradycyjnych operacji buforowanych. Najłatwiej rozwiązać ten problem, używając w otwieraniu plików także flagi O_DSYNC, co oznacza, że każda operacja zapisu będzie miała wywołanie fdatasync().

Okazało się, że w systemie plików XFS niedawno dodano „szybką ścieżkę” dla O_DIRECT|O_DSYNC-zapisu danych. Jeśli blok jest nadpisywany za pomocą O_DIRECT|O_DSYNC, to XFS, zamiast zrzucić bufor, wykona polecenie FUA-zapisu, jeśli urządzenie to obsługuje. Udało mi się to potwierdzić, korzystając z narzędzia blktrace w systemie Linux 5.4/Ubuntu 20.04. Takie podejście powinno być bardziej efektywne, ponieważ przy jego użyciu na dysk zapisywana jest minimalna ilość danych i stosowana jest jedna operacja, a nie dwie (zapis i zrzut bufora). Znalazłem odniesienie do łatkę jądra z 2018 roku, w którym zrealizowano ten mechanizm. Jest tam dyskusja dotycząca zastosowania tej optymalizacji w innych systemach plików, ale o ile mi wiadomo, XFS to jak na razie jedyny system plików, który to obsługuje.

Funkcja sync_file_range()

W systemie Linux istnieje wywołanie systemowe sync_file_range(), które umożliwia zrzucenie na dysk tylko części pliku, a nie całego pliku. To wywołanie inicjuje asynchroniczny zrzut danych i nie czeka na jego zakończenie. Jednak w dokumentacji do sync_file_range() mówi się, że ta komenda jest „bardzo niebezpieczna”. Nie zaleca się jej używania. Cechy i niebezpieczeństwa sync_file_range() są bardzo dobrze opisane w tym materiale. W szczególności, wygląda na to, że to wywołanie używa RocksDB do zarządzania tym, kiedy jądro zrzuca „brudne” dane na dysk. Ale przy tym, dla zapewnienia trwałego przechowywania danych, używane jest także fdatasync(). W kodzie RocksDB są interesujące komentarze na ten temat. Na przykład, wydaje się, że wywołanie sync_file_range() przy użyciu ZFS nie prowadzi do zrzutu danych na dysk. Doświadczenie podpowiada mi, że kod, który używany jest rzadko, może zawierać błędy. Dlatego doradziłbym, aby nie korzystać z tego wywołania systemowego bez poważnej potrzeby.

Wywołania systemowe, które pomagają zapewnić trwałe przechowywanie danych

Doszedłem do wniosku, że do wykonywania operacji wejścia/wyjścia, zapewniających trwałe przechowywanie danych, można stosować trzy podejścia. Wszystkie one wymagają wywołania funkcji fsync() dla katalogu, w którym utworzono plik. Oto te podejścia:

  1. Wywołanie funkcji fdatasync() lub fsync() po funkcji write() (lepiej używać 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 (lepiej — z flagą RWF_DSYNC).

Notatki na temat wydajności

Nie przeprowadzałem dokładnych pomiarów wydajności różnych mechanizmów, które zbadałem. Zauważone przeze mnie różnice w szybkości ich działania są dość niewielkie. Oznacza to, że mogę się mylić, a przy innych warunkach to samo może dać inne wyniki. Najpierw opowiem o tym, co bardziej wpływa na wydajność, a potem o tym, co wpływa na nią mniej.

  1. Nadpisywanie danych pliku jest szybsze niż dołączanie danych do pliku (wzrost wydajności może wynosić od 2 do 100%). Dołączanie danych do pliku wymaga dokonania dodatkowych zmian w metadanych pliku, nawet po wywołaniu systemowym fallocate(), ale skala tego efektu może się zmieniać. Zalecam wywołać fallocate() w celu wstępnego przydzielenia wymaganego miejsca. Następnie to miejsce należy jawnie wypełnić zerami i wywołać fsync(). Dzięki temu odpowiednie bloki w systemie plików będą oznaczone jako 'przydzielone', a nie 'nieprzydzielone'. Daje to niewielką (około 2%) poprawę wydajności. Ponadto, w przypadku niektórych dysków pierwsza operacja dostępu do bloku może być wolniejsza od innych. Oznacza to, że wypełnienie miejsca zerami może prowadzić do znacznej (około 100%) poprawy wydajności. W szczególności może to wystąpić w przypadku dysków AWS EBS (to — dane nieoficjalne, nie mogłem ich potwierdzić). To samo dotyczy pamięci GCP Persistent Disk (a to — już oficjalne informację, potwierdzone testami). Inni specjaliści dokonali takich samych obserwacji, dotyczących różnych dysków.
  2. Im mniej wywołań systemowych — tym wyższa wydajność (wzrost może wynosić około 5%). Wydaje się, że wywołanie open() z flagą O_DSYNC lub wywołanie pwritev2() z flagą RWF_SYNC szybsze wywołanie fdatasync(). Podejrzewam, że to dlatego, że przy takim podejściu kluczowe jest to, że do rozwiązania tego samego zadania konieczne jest wykonanie mniejszej liczby wywołań systemowych (jedno wywołanie zamiast dwóch). Ale różnica w wydajności jest bardzo mała, więc możesz całkowicie zignorować ten aspekt i używać w aplikacji tego, co nie skomplikuje jej logiki.

Jeśli interesuje Cię temat trwałego przechowywania danych — oto kilka przydatnych materiałów:

  • Metody dostępu I/O — przegląd podstawowych mechanizmów wejścia/wyjścia.
  • Zagwarantowanie, że dane dotrą na dysk — opowieść o tym, co dzieje się z danymi na drodze od aplikacji do dysku.
  • Kiedy powinieneś używać fsync dla zawierającego katalogu — odpowiedź na pytanie, kiedy należy stosować fsync() dla katalogów. Mówiąc w skrócie, należy to zrobić przy tworzeniu nowego pliku, a powód tej rekomendacji polega na tym, że w Linuksie może być wiele dowiązań do tego samego pliku.
  • SQL Server na Linuksie: Wewnątrz FUA — tutaj znajduje się opis tego, jak trwałe przechowywanie danych jest realizowane w SQL Server na platformie Linux. Znajdziesz tu kilka interesujących porównań między wywołaniami systemowymi Windows i Linux. Prawie jestem pewien, że to dzięki temu materiałowi dowiedziałem się o optymalizacji FUA w XFS.

Czy kiedykolwiek utraciłeś dane, które uważałeś za trwale zapisywane na dysku?

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

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

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster