Z 1,27 GB do 35 MB — jak odchudziłem bazę danych WattLog.pro
12 października 2026 rano na produkcji przestało działać rozpoczynanie jazd. Reszta WattLog.pro działała, ale kliknięcie „Start” wisiało 30 sekund i kończyło się błędem. Przyczyną nie był kod, tylko dysk. Serwer, na którym działa WattLog.pro, miał wolne 3,6 GB z 19 GB, a broker wiadomości, który przekazuje polecenia do trenażera, przy tak małym zapasie miejsca celowo wstrzymuje wysyłanie. Posprzątałem i jazdy znów ruszyły. Został jednak wykres, który pokazywał, że to się powtórzy: nocna kopia bazy urosła tego dnia z 21 do 73 MB.
Rozwijam WattLog.pro sam i to ja płacę za serwer, więc pytanie „ile miejsca zajmuje jedna godzina jazdy” to dla mnie pytanie o to, ilu kolarzy zmieści ta sama maszyna. Poniżej jest cała historia: co zmierzyłem, co zmieniłem i jak usuwałem dane tak, żeby nie stracić żadnej jazdy.
Gdzie znikało miejsce
Trenażer, miernik mocy i pulsometr wysyłają odczyt mniej więcej raz na sekundę: moc, kadencję, prędkość, dystans, tętno. Tak samo wygląda import ze Stravy, gdzie każda aktywność to strumień wartości sekunda po sekundzie. WattLog.pro od początku zapisywał każdy taki odczyt jako osobny wiersz w bazie PostgreSQL. To naturalny wybór, bo każdą liczbę łatwo potem znaleźć i policzyć.
Spośród kilkudziesięciu tabel liczyły się dwie. Zmierzyłem je na kopii bazy produkcyjnej:
| Wiersze | Dane | Indeksy | Na odczyt | |
|---|---|---|---|---|
| Odczyty z czujników (moc, kadencja, dystans…) | 7,07 mln | 461 MB | 585 MB | 155 B |
| Odczyty tętna | 3,36 mln | 219 MB | 267 MB | 152 B |
Te dwie tabele to około 1,5 GB z bazy, która ważyła 1,56 GB. Przy tym indeksy ważyły więcej niż same dane. Liczba mocy to kilka bajtów, ale każdy wiersz ma nagłówek, identyfikator, odnośnik do treningu i znacznik czasu, a do tego każdy indeks przechowuje własną kopię części tych pól. Z kilku bajtów robi się 155.
Najprostszy rachunek: godzina jazdy kosztowała około 1,4 MB, niezależnie od źródła (import ze Stravy 1,38 MB, jazda na żywo w trybie ERG 1,54 MB, plik TCX 1,00 MB). 10-godzinna jazda ze Stravy zajmowała 14,2 MB. Najaktywniejszy kolarz miał w ostatnim roku 770 godzin jazdy, czyli sam zajmowałby około 1,1 GB rocznie.
Prognoza w starym formacie. Kolarz, który jeździ 4 godziny w tygodniu, to około 0,28 GB rocznie. Kolarz trenujący na poważnie to około 1,1 GB. Do tego, po połączeniu konta ze Stravą, WattLog.pro importuje 12 miesięcy historii naraz. Dla 200 kolarzy, z których co dziesiąty trenuje poważnie, to około 140 GB w pierwszym roku. Na serwerze zostały około 4 GB do wykorzystania. Wystarczyłoby to na rok jazdy trzech lub czterech poważnie trenujących kolarzy.
Jak to robi Strava
Zastanowiłem się, jak aplikacja naprawdę czyta te dane. Kluczowe było to, że nigdy nie pyta o jedną sekundę jednej jazdy. Strona treningu, wykres mocy, strefy, eksport do FIT/TCX, analiza po jeździe: wszystkie one wczytują całą jazdę naraz. Wykresy obejmujące wiele tygodni, takie jak TSS i forma w czasie, nie czytają już pojedynczych sekund, tylko małe podsumowania liczone raz po każdej jeździe.
Skoro jazda zawsze jest czytana w całości, nie ma powodu trzymać jej jako tysięcy osobnych wierszy. Strava od lat udostępnia aktywności jako streams: dla każdej aktywności osobną tablicę na każdy kanał (czas, moc, tętno, kadencja…) [1]. WattLog.pro robił już to samo z geometrią tras: cały ślad GPS jednej jazdy to jeden skompresowany obiekt w bazie. Trzeba było zrobić to samo z próbkami.
Jedna jazda, jeden strumień
Nowy format zapisuje wszystkie próbki jednej jazdy jako jeden obiekt binarny (tzw. blob):
- Kanały zamiast wierszy. Każdy kanał (moc z danego urządzenia, kadencja, tętno, dystans…) to osobna tablica liczb. Pola, które w danej jeździe mają zawsze tę samą wartość (na przykład puste), są zapisane raz zamiast w każdej próbce.
- Różnice zamiast wartości (delta encoding). Zamiast znaczników czasu 10:00:00, 10:00:01, 10:00:02 zapisuję pierwszy czas, a potem tylko różnice: +1 s, +1 s, +1 s. Moc 182, 184, 183 W staje się wartościami 182, +2, −1. Liczby całkowite idą jako 4-bajtowe różnice, a wszystko inne jako pełne liczby zmiennoprzecinkowe.
- Kompresja. Na koniec całość przechodzi przez zlib, standardowy algorytm kompresji wbudowany w Pythona. Ciąg „+1 s, +1 s, +1 s…” powtórzony 36 000 razy kompresuje się prawie do zera, a różnice mocy, które w większości są małymi liczbami blisko zera, kompresują się dużo lepiej niż same waty.
Pierwszy test na dwóch prawdziwych jazdach dał takie wyniki:
| Jazda | Jako wiersze | zlib | lzma |
|---|---|---|---|
| Strava, 10,4 h | ~14,2 MB | 80 KB (182×) | 59 KB (246×) |
| Najdłuższa jazda ERG, 1,8 h | ~2,65 MB | 11 KB (249×) | 9 KB (296×) |
Te liczby były jednak zbyt ładne. W teście zaokrągliłem czas do pełnych sekund, a jazdy na żywo zapisują go z dokładnością do milisekund: 99,9–100% ich próbek nie wypada na pełnej sekundzie. Niektóre odczyty powtarzają się z tym samym znacznikiem czasu. Gotowy format zapisuje to wszystko dokładnie, więc rzeczywisty wynik musiał być niższy. Algorytm lzma kompresował lepiej, ale wybrałem zlib, bo jest szybszy, a różnica w kilobajtach przestaje mieć znaczenie, gdy całość i tak jest ponad sto razy mniejsza.
Wynik bezstratny, na wszystkich jazdach: 660 jazd, 1233 MB w wierszach, 10,3 MB jako strumienie, czyli 120 razy mniej. Każda jazda po odczytaniu z bloba była identyczna z wierszami, z których powstała. Przy tym tempie 200 kolarzy w pierwszym roku to 1–3 GB zamiast 140 GB.
Bezstratnie, czyli dokładnie co do bitu
Mogłem zapisać moc w zaokrągleniu do wata, a czas do sekundy, i uzyskać jeszcze lepszy wynik. Nie zrobiłem tego, bo z tych samych próbek WattLog.pro liczy moc znormalizowaną, strefy, rekordy krzywej mocy i eksportuje pliki FIT i TCX. Gdyby po zmianie formatu rekord na 5 sekund przesunął się o wat albo eksport różnił się od oryginału, zmiana byłaby gorsza niż problem, który rozwiązuje.
Dlatego warunek jest ostry: odczytany strumień musi być identyczny z wierszami co do typu i bitu. Zwykłe porównanie „równa się” tu nie wystarcza, bo uznaje −0,0 i 0,0 za to samo, a wartość „nie-liczba” (NaN) za różną od samej siebie. Napisałem więc osobną funkcję porównującą, która takich różnic nie przepuszcza. To ona decyduje później, czy wolno usunąć wiersze.
Dlaczego nie gotowe narzędzie?
Nic z tego nie jest nowym pomysłem. Zapisywanie różnic zamiast wartości i kompresja to standardowa technika dla szeregów czasowych: Facebook opisał jej rozbudowaną wersję w 2015 roku przy okazji swojej bazy Gorilla [3], a Strava trzyma aktywności właśnie jako strumienie. Zanim napisałem własny format, rozważyłem gotowe narzędzia:
- TimescaleDB. Rozszerzenie PostgreSQL z wbudowaną kompresją kolumnową dla szeregów czasowych. Na tych danych dałoby najpewniej podobny rząd wielkości. Wymagałoby jednak wymiany samej bazy na serwerze, który ma do dyspozycji około 1 GB wolnej pamięci, i zostawiłoby model wierszy, choć aplikacja i tak zawsze czyta całą jazdę naraz.
- Parquet i Apache Arrow. Gotowe formaty kolumnowe z dobrą kompresją. Mógłbym trzymać w bazie plik Parquet zamiast własnego formatu, ale biblioteka to ponad 100 MB zależności dla jednej funkcji.
- Lepszy algorytm kompresji. zstd albo lzma dałyby kilka procent mniej kosztem nowej zależności albo wolniejszego działania. Przy 120 razy mniejszych danych nie ma to już znaczenia.
Mój format to kilkaset linii zbudowanych w całości ze standardowej biblioteki Pythona: zlib, array i struct. Strumień leży w tej samej bazie co reszta danych i trafia do tej samej kopii zapasowej, bez nowej usługi do utrzymania. A przede wszystkim kompresja okazała się najprostszą częścią całej zmiany. Każde z tych narzędzi wymagałoby tej samej migracji: przeniesienia 33 modułów, sprawdzenia każdej jazdy i bezpiecznego usunięcia starych danych. Tego żadna biblioteka za mnie nie zrobi.
Cztery etapy, każdy do cofnięcia
Takiej zmiany nie robi się w jeden wieczór, bo na tych danych opiera się każdy wykres w aplikacji. Podzieliłem ją na etapy, z których każdy dało się osobno wdrożyć i wycofać:
- Format. Kod, który zamienia próbki w strumień i z powrotem, plus raport, który przepuścił przez niego każdą jazdę i sprawdził, czy wraca identyczna. Wynikiem było 120 razy mniej i zero różnic.
- Podwójny zapis. Każda zakończona jazda dostaje strumień obok swoich wierszy. Nic go jeszcze nie czyta, więc ewentualny błąd nikomu nie szkodzi. Historię uzupełnił skrypt, który przed zapisaniem każdego strumienia porównuje go z wierszami.
- Jedno miejsce odczytu. Dane o próbkach czytały 33 moduły aplikacji. Przeniosłem je po kolei na jedną funkcję, która czyta strumień, a gdy go nie ma, wiersze. Testy uruchamiają każdy z tych modułów dwa razy: raz na wierszach, raz na samym strumieniu, i porównują wyniki.
- Usuwanie wierszy. Dopiero gdy wszystko czyta już strumień, wiersze przestają być potrzebne.
Trzeci etap od razu się opłacił, bo testy na samym strumieniu złapały dwa błędy, których na wierszach nie było widać. Karta Moc według nachylenia na stronie postępów wybierała jazdy po tym, czy mają wiersze z dystansem. Po usunięciu wierszy nie znalazłaby żadnej. Gorszy był drugi błąd: analiza po zapisie jazdy na samym początku odbudowywała strumień z wierszy. Gdyby wierszy już nie było, nadpisałaby jazdę pustym strumieniem. Po czwartym etapie ponowne przeliczenie jazdy po cichu skasowałoby ją w całości.
Jak usuwam dane, żeby żadnej nie stracić
Po usunięciu wierszy strumień jest jedyną kopią jazdy. Ten etap ma więc najwięcej zabezpieczeń:
- Doba zapasu. Wiersze jazdy znikają najwcześniej 24 godziny po jej zakończeniu. Przez ten czas mogą jeszcze dojść spóźnione próbki, a ewentualny błąd w formacie da się naprawić, póki wiersze istnieją.
- Sprawdzenie przed każdym usunięciem. Narzędzie, które usuwa wiersze, w jednej transakcji na jazdę czyta wiersze i strumień i porównuje je bit po bicie. Każda różnica oznacza, że ta jazda zostaje nietknięta, a narzędzie zapisuje błąd i przechodzi do następnej. Usuwane są tylko te wiersze, które zostały sprawdzone. Próbka, która dojdzie w trakcie, czeka na koniec transakcji i przeżywa.
- Głośny błąd zamiast pustej jazdy. Jeśli strumień jazdy, której wiersze już usunięto, nie daje się odczytać, aplikacja pokazuje błąd i nie liczy niczego dalej. Najgorszy scenariusz to jazda, która po cichu ma zero watów i nadpisuje Twoje statystyki zerami. Ten scenariusz jest zablokowany wprost.
- Spóźnione próbki. Gdy do starej jazdy dojdzie próbka, zostaje dołączona do strumienia, a całość jest ponownie sprawdzana przed zapisem.
- Kopia przed pierwszym usunięciem. Przed pierwszym przebiegiem odkładam pełną kopię bazy w osobnym miejscu, którego nie dotyka rotacja kopii zapasowych, i trzymam ją przez kilka miesięcy działania nowego formatu.
- Stare formaty na zawsze. W repozytorium leży zapisany strumień w pierwszej wersji formatu. Test pilnuje, żeby każda kolejna wersja aplikacji umiała go odczytać co do bitu, bo dla jazdy bez wierszy nie ma innej kopii.
Próba generalna
Całość przećwiczyłem na kopii bazy produkcyjnej:
| Przed | Po | |
|---|---|---|
| Odczyty z czujników | 924 MB | 640 kB |
| Odczyty tętna | 315 MB | 96 kB |
| Strumienie | — | 11 MB |
| Cała baza | 1273 MB | 35 MB |
661 jazd przeniesionych w niecałe dwie minuty, żadnej pominiętej, ponowne uruchomienie nie miało już nic do zrobienia. 4 jazdy zakończone mniej niż dobę wcześniej zachowały wiersze, tak jak powinny. Jeszcze jedna rzecz: PostgreSQL po usunięciu wierszy nie oddaje miejsca na dysku sam z siebie. Plik tabeli zostaje tak samo duży, a puste miejsce czeka na nowe wiersze. Miejsce odzyskuje dopiero jednorazowe VACUUM FULL, które przepisuje tabelę od nowa [2]. Na małych już tabelach trwało to 2,3 sekundy.
Przy okazji odczyt przyspieszył. Karta Moc według nachylenia liczyła się ze strumieni dwa razy szybciej niż z wierszy, bo jedno odczytanie małego bloba to mniej pracy niż pobranie kilkunastu tysięcy wierszy.
Trzy pierwsze etapy już działają na produkcji: zakończone jazdy dostają strumień i aplikacja czyta je z niego. Usuwanie wierszy na produkcji wdrażam teraz powoli: najpierw przebieg próbny, potem 10 jazd, sprawdzenie dysku i stron tych jazd, a potem kolejne partie po 100.
Co to znaczy dla Ciebie
- Import całej historii ze Stravy przestaje być ryzykiem. Rok jazd poważnie trenującego kolarza to dziś kilka megabajtów zamiast ponad gigabajta, więc import nie zagraża dyskowi serwera, a więc i jeździe kogoś innego, kto akurat startuje trening.
- Twoje dane się nie zmieniają. Każda sekunda jazdy jest zapisana tak samo dokładnie jak wcześniej: moc, tętno, kadencja, czas co do milisekundy. Strony jazd, strefy i eksport do FIT/TCX dają te same wyniki.
- Jeden serwer obsłuży wielu więcej kolarzy. To, ilu kolarzy zmieści WattLog.pro, ograniczał dysk, a nie procesor. Mniejsza baza to także mniejsze i szybsze kopie zapasowe, robione przed każdym wdrożeniem.
Czego to nie rozwiązuje
- Jazda w toku dalej zapisuje się wierszami. Strumień powstaje po zakończeniu jazdy. Jazdy trwające i porzucone zostają w starym formacie, dopóki nie zostaną zakończone.
- Strona jazdy rozpakowuje cały strumień. Średnio to około 26 ms na jazdę. Jedna strona treningu wysyła kilka zapytań i każde rozpakowuje ten sam strumień osobno. Na razie to nie boli, ale jeśli zacznie, następnym krokiem jest pamięć podręczna albo odczyt tylko potrzebnych kanałów.
- Wykresy z wielu tygodni potrzebują podsumowań. Strumienie zmniejszają bazę, ale wykresu z całego sezonu nie powinno się liczyć przez rozpakowanie 255 jazd przy każdym otwarciu strony. Takie wykresy czytają małe podsumowania liczone raz po każdej jeździe. Kilka miejsc jeszcze tego nie robi i są na liście.
- Jazdy wielodniowe nie były testowane. Najdłuższa prawdziwa jazda w bazie ma 10 godzin. Narzędzie trzyma w pamięci naraz jej wiersze i strumień, co przy 10 godzinach to około 14 MB. Przed obsługą jazd wielodniowych trzeba będzie to sprawdzić.
Co z tego wynoszę
Zawieszone rozpoczynanie jazd wyglądało jak błąd po wdrożeniu, a było problemem z miejscem na dysku. Dopiero pomiar pokazał, gdzie to miejsce znika: nie w samych watach, tylko w tym, jak były zapisane. Format, który wybrałem na początku, był dobry do szybkiego zbudowania aplikacji i zły do jej utrzymania. Zmiana nie wymagała nowej bazy ani nowego serwera, tylko zapisania danych tak, jak aplikacja je czyta: całą jazdę naraz.
Źródła
- Strava API v3 — Streams. developers.strava.com
- PostgreSQL Documentation — VACUUM. postgresql.org
- Pelkonen T, Franklin S, Teller J, Cavallaro P, Huang Q, Meza J, Veeraraghavan K. Gorilla: A Fast, Scalable, In-Memory Time Series Database. Proc VLDB Endow. 2015;8(12):1816–1827. vldb.org
Wgraj całą historię ze Stravy
Załóż konto, połącz Stravę i zobacz ostatni rok jazd w wykresie formy, krzywej mocy i strefach. Każda sekunda zostaje zapisana.
Sprawdź WattLog.pro za darmo →