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.

1273 MB → 35 MB
tyle waży ta sama baza WattLog.pro, gdy każda jazda trzyma swoje próbki w jednym skompresowanym strumieniu, a nie w milionach wierszy. Bez utraty jednej próbki.
1,4 MBtyle kosztowała godzina jazdy w starym formacie
155 Bjeden odczyt mocy jako wiersz, razem z indeksami
120×mniej po kompresji, bezstratnie, 660 jazd
0różnic między starymi a nowymi danymi

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:

WierszeDaneIndeksyNa odczyt
Odczyty z czujników (moc, kadencja, dystans…)7,07 mln461 MB585 MB155 B
Odczyty tętna3,36 mln219 MB267 MB152 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.

Na każdy megabajt odczytów przypadało więcej niż megabajt indeksów. Kopia bazy produkcyjnej, 12 października 2026.

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):

Pierwszy test na dwóch prawdziwych jazdach dał takie wyniki:

JazdaJako wierszezliblzma
Strava, 10,4 h~14,2 MB80 KB (182×)59 KB (246×)
Najdłuższa jazda ERG, 1,8 h~2,65 MB11 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:

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ć:

  1. 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.
  2. 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.
  3. 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.
  4. 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ń:

Próba generalna

Całość przećwiczyłem na kopii bazy produkcyjnej:

PrzedPo
Odczyty z czujników924 MB640 kB
Odczyty tętna315 MB96 kB
Strumienie—11 MB
Cała baza1273 MB35 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

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

  1. Strava API v3 — Streams. developers.strava.com
  2. PostgreSQL Documentation — VACUUM. postgresql.org
  3. 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 →

← Wszystkie wpisy na blogu