Jak używać transakcji w SQL, by nie blokować całej aplikacji webowej

0
35
Rate this post

Z tego tekstu dowiesz się...

Dlaczego transakcje potrafią „zatrzymać” aplikację webową

Równoległe modyfikacje danych z perspektywy programisty webowego

W aplikacji webowej każdy request HTTP zwykle oznacza osobne połączenie z bazą danych. Przy kilku użytkownikach wszystko działa płynnie. Problemy pojawiają się dopiero wtedy, gdy setki lub tysiące requestów jednocześnie próbują modyfikować te same rekordy: koszyki, zamówienia, saldo konta, stany magazynowe czy profile użytkowników.

Jeżeli logika biznesowa wykonuje zbyt długie lub zbyt szerokie transakcje SQL, baza zaczyna intensywnie zakładać blokady. Jeden request trzyma transakcję otwartą, więc inne requesty, które chcą zmodyfikować te same dane, zaczynają czekać. Z punktu widzenia użytkownika oznacza to „kręcące się kółko” w przeglądarce, a z punktu widzenia serwera – wiszące wątki, rosnące kolejki i timeouty.

Scenariusz jest zawsze podobny: niewinna funkcja typu placeOrder() lub updateProfile() otwiera transakcję na początku logiki, potem robi dużo rzeczy (walidacja, wywołania zewnętrznych API, generowanie PDF, wysyłka maila) i dopiero na końcu commit. Cały ten czas blokady w bazie są utrzymywane. W efekcie inne requesty, chcące zmienić te same dane, wiszą, a kolejne zaczynają się na siebie nakładać.

Kluczowe jest więc zrozumienie, że czas trwania transakcji SQL powinien być możliwie krótki i ograniczony do faktycznych operacji na danych. Wszystko, co nie wymaga otwartej transakcji (logika w pamięci, zewnętrzne wywołania, generowanie dokumentów), najlepiej wykonać przed lub po commit.

Co sprawdzić: powiąż raport najdłuższych requestów HTTP z logiem najdłużej trwających transakcji w bazie. Jeżeli request łączy się w czasie z jedną długą transakcją, to bardzo prawdopodobne, że to ona blokuje resztę użytkowników.

Jak działają blokady na poziomie bazy: rzędy, strony, tabele

Baza danych, by zapewnić izolację i spójność, używa blokad (locków). Najczęściej spotkasz:

  • blokady wierszy (row-level locks) – zakładane przy aktualizacji/usuń na konkretnych rekordach, stosowane m.in. w PostgreSQL i MySQL InnoDB;
  • blokady stron (page-level) – w niektórych systemach (np. SQL Server w pewnych sytuacjach), dotyczą całej „strony” danych w pliku bazy;
  • blokady tabel (table-level) – przy operacjach typu ALTER TABLE, niektórych masowych aktualizacjach lub przy określonych silnikach tabel (np. MyISAM).

W typowej aplikacji webowej najbardziej dokuczliwe są blokady wierszy. Jeżeli transakcja A aktualizuje rekord zamówienia o ID=123 i trzyma go zablokowanego, transakcja B, która też chce go zaktualizować, będzie czekać, aż A zrobi COMMIT lub ROLLBACK. Im dłużej A „trzyma” rekord, tym większe ryzyko kaskady oczekujących transakcji.

W niektórych przypadkach blokady mogą eskalować. Przykładowo, gdy zbyt dużo wierszy jest blokowanych w jednej tabeli, system może założyć blokadę wyższego poziomu, by uprościć zarządzanie. Wtedy nie tylko konkretne rekordy są zablokowane, ale cała sekcja lub cała tabela, co potrafi skutecznie „zamrozić” aplikację.

Najgorszy wariant to deadlock – sytuacja, gdy transakcja A trzyma blokadę X i czeka na Y, a transakcja B trzyma Y i czeka na X. System musi jedną z nich zakończyć błędem, by wyjść z impasu. Z punktu widzenia użytkownika jest to często losowy błąd 500 przy większym obciążeniu.

Request HTTP, który „wisi”, bo transakcja czeka na lock

Request wiszący kilkanaście sekund zwykle oznacza, że:

  • zapytanie SQL jest bardzo wolne (brak indeksu, nieoptymalny plan wykonania), lub
  • zapytanie SQL czeka na zwolnienie blokady założonej przez inną transakcję.

W logach bazy da się to łatwo rozróżnić. Jeśli dany SELECT/UPDATE ma status „waiting for lock” (nazwa zależy od systemu), to problemem nie jest wolne zapytanie, ale inna transakcja, która blokuje dane. W prostych słowach: request użytkownika czeka, aż ktoś inny skończy pracę na tych samych rekordach.

W aplikacjach webowych dodatkowym problemem jest domyślne ustawienie timeoutów. Serwer HTTP może przerwać request po np. 30 sekundach, ale baza nadal trzyma transakcję (w zależności od konfiguracji). Powstają wtedy „sieroty” – porzucone transakcje, które wciąż blokują dane, choć połączenie z aplikacją już nie istnieje albo zostało przerwane.

Co sprawdzić: w narzędziach monitoringu (pg_stat_activity w PostgreSQL, INFORMATION_SCHEMA / performance_schema w MySQL, DMVs w SQL Server) przejrzyj listę aktywnych transakcji wraz z czasem ich trwania i informacją, czy czekają na lock. Jeżeli widzisz transakcje trwające kilkanaście lub kilkadziesiąt sekund, to sygnał alarmowy.

Problemy kodu aplikacji vs. problemy bazy danych

Zacięta aplikacja webowa to nie zawsze wina bazy. Czasem blokada jest wynikiem błędu na poziomie kodu (np. niekończąca się pętla, błąd w obsłudze asynchroniczności), a czasem to baza dusi aplikację poprzez agresywne blokady.

Rozróżnienie:

  • Jeżeli request nie wchodzi nawet w warstwę bazy (brak logów SQL), to sprawa leży w aplikacji lub infrastrukturze sieciowej.
  • Jeżeli request dochodzi do bazy i jest tam widoczny przez długi czas, ale w statusie „active / waiting for lock”, to winne są transakcje i blokady.

Najczęstszy schemat w aplikacjach biznesowych: logika transakcji została „opakowana” zbyt szeroko (np. poprzez adnotację @Transactional na całym serwisie, który wykonuje też wywołania do zewnętrznych API). W efekcie baza zupełnie poprawnie blokuje dane, ale robi to dłużej, niż to konieczne – więc z punktu użytkownika „aplikacja stoi”.

Co sprawdzić: przeanalizuj, które metody usług, kontrolery lub handlery requestów są opatrzone zarządzaniem transakcjami. Zwłaszcza w świecie ORM-ów warto doprecyzować, która część logiki naprawdę musi być wykonywana w ramach jednej transakcji.

Osoba loguje się do aplikacji blockchain na laptopie, trzymając kartę
Źródło: Pexels | Autor: Morthy Jameson

Podstawy transakcji SQL w kontekście aplikacji webowej

ACID w praktyce – przykład zamówienia w sklepie internetowym

Transakcja SQL to logiczna jednostka pracy, która powinna spełniać cechy ACID:

  • Atomowość – albo uda się wszystko, albo nic; np. zamówienie zostanie zapisane wraz z pozycjami i płatnością, albo wszystko zostanie wycofane.
  • Spójność – po zakończeniu transakcji dane są w stanie zgodnym z regułami biznesowymi i integralności (klucze obce, ograniczenia, walidacje).
  • Izolacja – równoległe transakcje nie „widz ą” swoich niedokończonych zmian w sposób niekontrolowany (w zależności od poziomu izolacji).
  • Trwałość – po COMMIT dane nie znikną po restarcie serwera, zostaną zapisane na dysku (zależnie od konfiguracji WAL, logów transakcyjnych itd.).

Przykład zamówienia w sklepie:

  1. Utworzenie rekordu zamówienia w tabeli orders.
  2. Dodanie pozycji zamówienia w order_items.
  3. Zmniejszenie stanu magazynu produktów.
  4. Zapisanie informacji o płatności (np. „oczekuje na potwierdzenie”).

W podejściu transakcyjnym te kroki są wykonywane jako jedna transakcja: jeśli krok 3 się nie powiedzie (np. brak stanu magazynowego), transakcja jest cofana (ROLLBACK) i żaden z wcześniejszych kroków nie zostanie zapisany. Z zewnątrz operacja „złóż zamówienie” jest więc niepodzielna – albo całe zamówienie istnieje w spójnej formie, albo nie ma go wcale.

W świecie aplikacji webowych pokusa jest często taka: „skoro to jedna operacja biznesowa, to opakujmy wszystko w jedną gigantyczną transakcję”. To proste w kodzie, ale bardzo kosztowne pod względem blokad i skalowalności. Główne zadanie przy projektowaniu systemu to utrzymać zalety ACID, a jednocześnie skrócić czas trwania transakcji do minimum.

Co sprawdzić: wypisz najważniejsze operacje biznesowe (złożenie zamówienia, rejestracja użytkownika, zmiana hasła, transfer środków) i zobacz, jak wyglądają powiązane z nimi transakcje SQL: ile trwają, ile wykonują zapytań i na jakie tabele zakładają blokady.

BEGIN / COMMIT / ROLLBACK w popularnych bazach

Choć szczegóły różnią się między systemami (PostgreSQL, MySQL, SQL Server), podstawowy schemat transakcji SQL wygląda podobnie:

BEGIN;          -- lub START TRANSACTION
UPDATE ...;
INSERT ...;
COMMIT;         -- lub ROLLBACK w razie błędu

W aplikacjach webowych zwykle nie piszesz tych instrukcji ręcznie – robi to za Ciebie ORM lub warstwa dostępu do danych. Dobrze jest jednak rozumieć, co się dzieje pod spodem:

  • BEGIN – rozpoczyna transakcję; od tego momentu zmiany są „tymczasowe” i widoczne tylko w ramach tej transakcji (w zależności od poziomu izolacji).
  • COMMIT – zatwierdza wszystkie zmiany; dopiero wtedy stają się one widoczne dla innych transakcji.
  • ROLLBACK – cofa wszystkie zmiany wykonane od ostatniego BEGIN (lub SAVEPOINT).

Różnice między systemami:

  • PostgreSQL – tryb autocommit jest domyślny; każde pojedyncze zapytanie jest osobną mini-transakcją, jeśli nie otworzysz własnej.
  • MySQL (InnoDB) – także używa autocommit, ale pewne operacje (np. DDL) mogą wymuszać commit.
  • SQL Server – ma tryb implicit transactions, ale w aplikacjach zwykle używa się jawnego BEGIN TRAN/COMMIT w kodzie lub przez ORM.

Co sprawdzić: czy Twoja biblioteka/ORM nie otwiera transakcji wcześniej, niż jest to potrzebne (np. w momencie nawiązania połączenia w ramach requestu) i czy poprawnie wywołuje COMMIT/ROLLBACK w każdej ścieżce wykonania, także przy wyjątkach.

Autocommit vs. jawne transakcje w kodzie

Większość baz działa domyślnie w trybie autocommit: każde pojedyncze zapytanie (INSERT/UPDATE/DELETE/SELECT) jest wykonywane w ramach krótkiej, automatycznej transakcji. Taka transakcja trwa dokładnie tyle, ile trwa zapytanie. Nie ma ryzyka, że przypadkiem „zapomnisz” o COMMIT – baza robi to sama.

Problem pojawia się, gdy potrzebujesz kilku powiązanych operacji, które muszą być atomowe. Wtedy autocommit nie wystarczy i trzeba użyć jawnej transakcji. Przy korzystaniu z ORM-ów często decyduje o tym adnotacja (@Transactional), dekorator, kontekst lub manualne zarządzanie sesją.

Bezpośrednia konsekwencja: im większy fragment kodu obejmiesz transakcją, tym dłużej będą trzymane blokady. Gdy ORM domyślnie otacza transakcją całą metodę serwisu, a ta wykonuje sporo logiki niezwiązanej bezpośrednio z bazą, w praktyce przekreślasz zalety autocommit i generujesz długotrwałe locki.

Co sprawdzić: czy możesz przejść z modelu „transakcja na cały request” na „transakcja na krytyczny fragment zapisu”, np. od momentu pierwszego UPDATE/INSERT do ostatniego, a logikę wokół zostawić poza transakcją.

Relacja: jeden request HTTP a jedna lub wiele transakcji

Najprostszy model mówi: jeden request HTTP = jedna transakcja. Technicznie nie ma jednak powodu, żeby się tego sztywno trzymać. Można:

  • wykonać część logiki w autocommit (pojedyncze SELECTy bez BEGIN),
  • otworzyć krótką transakcję tylko na zapis danych,
  • w dłuższych procesach użyć kilku krótszych transakcji (np. jedna do rezerwacji zasobu, druga do potwierdzenia).

Dzięki temu każdy request może używać więcej niż jednej transakcji, ale każda z nich jest krótka i dotyczy wyłącznie koniecznych zmian w bazie. Pozostałe operacje, szczególnie te powolne lub zależne od zewnętrznych systemów, powinny być wykonywane poza transakcją – nawet, jeśli mieszczą się w tym samym requestcie HTTP.

Co sprawdzić: czy logika requestu jest sztucznie „przyspawana” do jednej transakcji. Jeśli tak, zastanów się, które fragmenty można wykonać przed otwarciem transakcji albo po jej zakończeniu, bez utraty gwarancji biznesowych.

Poziomy izolacji i ich wpływ na blokowanie

Przegląd poziomów izolacji: efekty zamiast teorii

Poziom izolacji określa, jak bardzo transakcje „widzą się nawzajem” i jakie anomalie są dopuszczalne. W standardzie SQL wyróżnia się:

  • READ UNCOMMITTED – może widzieć niezatwierdzone dane (brudny odczyt); w praktyce prawie nikt tego nie używa w poważnych systemach.
  • READ COMMITTED – odczyty widzą tylko dane zatwierdzone; to rozsądny kompromis i domyślny poziom w wielu bazach (np. PostgreSQL).
  • REPEATABLE READ – ta sama kwerenda w ramach jednej transakcji zwraca ten sam zestaw wierszy; zapobiega odczytom niepowtarzalnym, ale może generować więcej blokad i dłużej je trzymać.
  • SERIALIZABLE – najsilniejsza izolacja, zachowanie jakby transakcje wykonywały się jedna po drugiej; najbezpieczniejsza, ale też najbardziej konfliktogenna i kosztowna.

Z perspektywy aplikacji webowej kluczowe jest, jak te poziomy przekładają się na blokady i konflikty. READ COMMITTED zwykle wystarcza dla typowych operacji CRUD i raportów online. REPEATABLE READ przydaje się przy operacjach typu „sprawdź saldo → wykonaj przelew”, ale stosowany bez namysłu potrafi unieruchomić ruch na często używanych tabelach. SERIALIZABLE rezerwuj do naprawdę wrażliwych fragmentów – albo do krótkich, dobrze przemyślanych transakcji.

Krok 1: dla każdej ważniejszej operacji określ, jakich anomalii nie możesz zaakceptować (brudny odczyt, podwójne obciążenie, przeskakujące numery, zduplikowane rezerwacje). Krok 2: ustaw minimalny poziom izolacji, który te anomalie blokuje. Krok 3: zmierz, jak często pojawiają się konflikty i czy musisz wprowadzić dodatkową logikę retry.

MVCC vs. blokady – dlaczego teoria nie zawsze równa się praktyce

Nowoczesne bazy (PostgreSQL, Oracle, częściowo MySQL/InnoDB) stosują MVCC (Multi-Version Concurrency Control). Odczyty dostają „migawkę” danych z danego momentu, więc SELECT nie zawsze blokuje UPDATE i odwrotnie. W praktyce oznacza to, że przy rozsądnym poziomie izolacji możesz mieć sporo równoległych odczytów bez paraliżu zapisu.

Trzeba jednak uważać na dwa zjawiska: długie transakcje budujące ogromne „stare wersje” wierszy (rosnący bloat i obciążenie VACUUM/GC) oraz zapytania, które mimo MVCC wymuszają blokady (np. SELECT FOR UPDATE, modyfikacje indeksów unikalnych, DDL). Kiedy ktoś uruchamia wielominutowy raport w REPEATABLE READ, cała aplikacja może odczuć spowolnienie, mimo że nigdzie nie ma jawnego LOCK TABLE.

Praktyczny schemat: raporty i analitykę kieruj do osobnych replik/instancji albo uruchamiaj poza godzinami szczytu, na niższym poziomie izolacji (READ COMMITTED + brak SELECT FOR UPDATE). Operacje krytyczne, które faktycznie muszą synchronizować się na wierszach (np. przydział limitu, rezerwacja ostatniej sztuki produktu), owijaj w krótkie transakcje z selektywnym użyciem blokad wierszy.

READ COMMITTED jako „bezpieczne domyślne” i kiedy z niego zejść

Dla większości scenariuszy w aplikacjach www READ COMMITTED jest sensownym punktem wyjścia. Zapewnia brak brudnych odczytów, a przy MVCC nie wymusza nadmiernego blokowania odczytów przez zapisy. Ma jednak swoją cenę: dwukrotne wykonanie tego samego SELECTa w ramach jednej transakcji może zwrócić różne dane.

Jeśli masz logikę typu „pobierz koszyk → przelicz promocje → zapisz rabat”, to zmienność danych w trakcie transakcji zazwyczaj jest akceptowalna. Natomiast operacje finansowe, alokacja limitów czy generowanie numerów sekwencyjnych wymagają większej gwarancji spójności widoku. W takich miejscach warto podnieść izolację lokalnie lub użyć jawnego SELECT … FOR UPDATE na konkretnych wierszach zamiast przełączania całej aplikacji na REPEATABLE READ.

Co sprawdzić: domyślny poziom izolacji w bazie i w ORM (czasem różne), szczególnie w testach. Zidentyfikuj kilka newralgicznych fragmentów (płatności, limity, rezerwacje) i zdecyduj, czy rozwiązujesz problem przez mocniejszą izolację, czy przez jawne blokowanie konkretnych rekordów oraz ewentualne powtórzenie transakcji po konflikcie.

W aplikacjach, które masowo przetwarzają zamówienia czy zgłoszenia, dobrym kompromisem jest trzymanie READ COMMITTED globalnie, a w wąskich, krytycznych fragmentach włączanie mocniejszej izolacji tylko na czas konkretnej transakcji lub nawet pojedynczej procedury składowanej. Zamiast przełączać cały pool połączeń na REPEATABLE READ, ustaw poziom izolacji tuż przed BEGIN w danym flow lub użyj dedykowanego DataSource dla operacji „high risk”. To zmniejsza ryzyko, że przypadkowy raport, który wpadł w REPEATABLE READ, spowolni setki równoległych requestów.

Typowy błąd to podniesienie izolacji „bo testy coś zgłosiły” bez weryfikacji, gdzie faktycznie powstaje konflikt. Krok 1: zbierz logi blokad i deadlocków z bazy (np. pg_locks, deadlock trace). Krok 2: skonfrontuj je z konkretnymi endpointami, które wywoływali użytkownicy. Krok 3: zamiast globalnego SERIALIZABLE, spróbuj: a) zawęzić transakcję, b) wprowadzić SELECT … FOR UPDATE na małym zestawie wierszy, c) dodać mechanizm retry w razie konfliktu. Dopiero gdy to nie wystarcza, rozważ trwałą zmianę poziomu izolacji.

Co sprawdzić: czy nie używasz REPEATABLE READ lub SERIALIZABLE jako „plastra” na błędy w logice biznesowej. Zobacz, czy najmocniejsza izolacja jest ustawiona tylko tam, gdzie jest realna analiza ryzyka, a nie z przyzwyczajenia lub z obawy przed rzadkimi anomaliami, których i tak nie weryfikujesz testami.

Finalnie liczy się nie sama teoria transakcji, tylko to, jak konkretne decyzje projektowe przekładają się na zachowanie aplikacji pod obciążeniem. Krótkie, świadomie zaprojektowane transakcje, właściwie dobrany poziom izolacji i dobrze „oswojony” ORM zwykle wystarczą, by baza nie była wąskim gardłem, nawet przy dużym ruchu. Jeśli regularnie zaglądasz w logi blokad, mierzysz czas trwania transakcji i korygujesz najbardziej problematyczne ścieżki, ryzyko, że jedna transakcja zatrzyma całą aplikację webową, spada do naprawdę rzadkich incydentów zamiast być codziennością.

Osoba obsługuje aplikację blockchain na smartfonie i laptopie
Źródło: Pexels | Autor: Morthy Jameson

Zasada „krótkiej transakcji” – jak ją osiągnąć w praktyce

Rozdziel logikę biznesową od operacji na bazie

Długa transakcja to często wynik tego, że cała logika biznesowa jest „sklejona” z dostępem do bazy w jednym, monolitycznym fragmencie kodu. Każde dodatkowe wywołanie API, przeliczenie, walidacja – jeśli dzieje się w obrębie BEGIN/COMMIT – wydłuża czas trzymania blokad.

Przeczytaj także:  Top 10 - Najlepiej Opłacane Języki Programowania

Bezpieczniejszy wzorzec to:

  • zebrać wszystkie dane wejściowe i wykonać walidację przed otwarciem transakcji,
  • wejść w transakcję dopiero wtedy, gdy pozostaje już tylko „czysta” operacja zapisu / aktualizacji,
  • po COMMIT wykonać wszystkie czynności wtórne: wysyłkę maili, webhooki, przeładowanie cache.

Przykład: przy składaniu zamówienia w e‑commerce większość logiki (przeliczenie koszyka, weryfikacja adresu, komunikacja z bramką płatności) może działać poza transakcją. Sama transakcja w bazie powinna obejmować tylko: blokadę wierszy z dostępnością produktów, zapis zamówienia, aktualizację stanów magazynowych.

Krok 1: w kodzie endpointu rozrysuj, co jest „czystą logiką” (obliczenia, formatowanie, zewnętrzne API), a co dotyka bazy. Krok 2: przesuń operacje bazodanowe jak najbliżej końca flow. Krok 3: odetnij z transakcji wszystko, co nie wymaga gwarancji ACID.

Co sprawdzić: czy w obrębie BEGIN/COMMIT nie masz: wywołań HTTP, zapytań do innych baz, ciężkich obliczeń, pętli po dużych kolekcjach albo czekania na użytkownika (np. potwierdzenie 2FA).

Odczyty bez blokowania: pre‑read vs read‑inside‑txn

Duża część transakcji zawiera serię SELECTów, które poprzedzają UPDATE/INSERT. Jeśli te odczyty nie muszą być idealnie spójne z momentem zapisu, można je wykonać wcześniej – w trybie autocommit – i skrócić samą transakcję zapisującą.

Praktyczny wzorzec:

  1. SELECTy „informacyjne” (listy, widoki, wstępne dane) – poza transakcją, w READ COMMITTED, bez blokad wierszy.
  2. SELECTy „decyzyjne” (czy limit nie został przekroczony, czy zasób jest dostępny) – tu do wyboru:
    • albo robisz SELECT poza transakcją + w transakcji walidujesz warunek jeszcze raz (np. przez unikalny indeks),
    • albo wykonujesz od razu w transakcji SELECT … FOR UPDATE, ale dopiero wtedy, gdy naprawdę chcesz aktualizować te wiersze.

Częsty błąd: wykonywanie długich raportów lub skomplikowanych SELECTów analitycznych w tej samej transakcji, która potem aktualizuje kilka wierszy. Analiza powinna być robiona poza transakcją, a w niej jedynie krótki zapis wyników.

Co sprawdzić: które SELECTy w Twoich transakcjach są konieczne, a które można przenieść przed BEGIN. Zobacz w logach slow queries, czy nie ma ciężkich SELECTów uruchamianych w środku transakcji zapisującej.

Unikaj „pętli po rekordach” wewnątrz jednej transakcji

Przetwarzanie tysiąca rekordów w jednej transakcji kusi prostotą: „pobierz listę → zrób pętlę → dla każdego UPDATE”. Problem w tym, że przez cały czas trwania pętli trzymasz blokady, które potrafią zablokować inne requesty.

Bezpieczniejsze podejścia:

  • aktualizacja setowa: UPDATE z warunkiem WHERE, bez pętli po poszczególnych ID,
  • przetwarzanie w paczkach (batchach): np. po 100 rekordów na transakcję,
  • zadania asynchroniczne: zamiast robić masowe migracje w requestach HTTP, deleguj je do jobów w tle.

Jeśli musisz z jakiegoś powodu przetwarzać wiersze pojedynczo (złożona logika), użyj schematu „fetch‑process‑commit” w pętli: pobierasz mały zestaw rekordów, otwierasz transakcję, aktualizujesz tylko ten zestaw, commitujesz, potem kolejna porcja.

Co sprawdzić: czy nie masz endpointów administracyjnych, które w jednej transakcji robią duże migracje, przeliczenia lub „masowe poprawki”. Takie operacje powinny iść do kolejki/jobów lub być dzielone na mniejsze, osobne transakcje.

Timeouty transakcji i blokad jako bezpiecznik

Nawet najlepiej zaprojektowany kod może czasem „przytrzymać” transakcję dłużej niż zakładano – np. przez nagły spadek wydajności dysku lub niespodziewanie wolne zapytanie. Warto mieć techniczne bezpieczniki.

Kluczowe ustawienia:

  • statement timeout – maksymalny czas pojedynczego zapytania SQL; ogranicza ryzyko, że jedno zapytanie będzie trwać kilka minut,
  • lock timeout – jak długo zapytanie ma czekać na uzyskanie blokady przed wyrzuceniem błędu,
  • idle in transaction timeout – jak długo połączenie może „wisieć” w transakcji bez wykonywania zapytań.

Krok 1: ustaw rozsądne wartości globalnie (np. kilkanaście sekund, zależnie od charakterystyki systemu). Krok 2: dla naprawdę ciężkich operacji (raporty, batch processing) ustaw większe limity, ale na poziomie sesji/jobów, nie całej aplikacji. Krok 3: po stronie kodu obsłuż błędy timeoutów jak normalne scenariusze awaryjne – pokaż użytkownikowi komunikat, ewentualnie w tle ponów operację.

Co sprawdzić: czy w bazie i w aplikacji masz włączone time‑outy zapytań, blokad i bezczynnych transakcji. Zobacz w monitoringach, ile czasu faktycznie trwają Twoje transakcje pod obciążeniem.

Tablet z aplikacją blockchain, laptop i banknoty dolarowe na biurku
Źródło: Pexels | Autor: Morthy Jameson

Projektowanie logiki biznesowej pod kątem transakcji

Idempotencja operacji – tarcza przeciwko retry

Jeśli chcesz bezpiecznie skracać transakcje i stosować retry przy konfliktach, operacje muszą być możliwie idempotentne. Innymi słowy, powtórzenie tej samej akcji (z tym samym „kluczem operacji”) nie może produkować podwójnych efektów.

Praktyczne sposoby:

  • użycie unikalnych kluczy biznesowych (np. payment_request_id) i weryfikacja, czy taki rekord już istnieje,
  • zapisywanie w bazie „tokenów operacji” i sprawdzanie, czy dana operacja była już przetworzona,
  • projektowanie endpointów jako „set state” zamiast „increment” tam, gdzie to możliwe (np. ustaw status na „opłacone” zamiast „zwiększ saldo o X”).

Przy idempotentnych operacjach możesz spokojniej reagować na deadlocki czy błędy blokad: jeśli transakcja została wycofana przez bazę, aplikacja ponawia próbę. Z punktu widzenia użytkownika – jedno kliknięcie powoduje jeden efekt biznesowy, nawet jeśli pod spodem było kilka prób.

Co sprawdzić: które operacje w systemie mogą być wykonane więcej niż raz (np. dublujące się requesty z frontendu) i czy logika w bazie chroni przed skutkami ubocznymi (podwójną rezerwacją, podwójną płatnością).

Optymistyczna vs pesymistyczna kontrola współbieżności

Dwa główne podejścia do konfliktów między równoległymi requestami to:

  • pesymistyczne – blokujemy rekordy (SELECT … FOR UPDATE), zakładając, że konflikt jest prawdopodobny; inne transakcje czekają lub dostają błąd blokady,
  • optymistyczne – pozwalamy na równoległą pracę, ale zapis weryfikuje „wersję” danych (np. po polu version lub updated_at); przy konflikcie ostatni zapis przegrywa i musi ponowić całą operację.

W aplikacjach webowych często lepiej sprawdza się wariant optymistyczny, szczególnie przy aktualizacji rzadko dotykanych rekordów: formularze profilu użytkownika, konfiguracje, ustawienia. Zamiast blokować rekord na czas, gdy użytkownik ma otwartą stronę edycji, przechowujesz wersję i przy zapisie sprawdzasz, czy nikt inny nie zmienił danych w międzyczasie.

Schemat:

  1. tabela ma kolumnę version (INT) albo updated_at,
  2. formularz wysyła tę wartość razem z danymi,
  3. UPDATE ma warunek WHERE id = ? AND version = ?,
  4. jeśli zaktualizowano 0 wierszy – informujesz użytkownika o konflikcie i prosisz o odświeżenie widoku.

Co sprawdzić: które endpointy edycji danych mogą generować konflikty użytkowników „w tym samym miejscu” i czy możesz dla nich włączyć prostą kontrolę wersji zamiast blokad na poziomie bazy.

Modelowanie „stanów procesów” zamiast jednej wielkiej transakcji

Duże procesy biznesowe (np. onboarding klienta, wieloetapowe wnioski, długie workflow dokumentów) trudno jest zamknąć w jednej transakcji. Próba ubrania wszystkiego w jeden BEGIN/COMMIT kończy się paraliżem bazy albo rezygnacją z blokad tam, gdzie są potrzebne.

Praktyczniejsze jest podejście „stanowe”:

  • każdy ważny etap ma swój status zapisywany w bazie (np. „oczekuje na dokumenty”, „weryfikacja”, „zatwierdzony”),
  • każda zmiana stanu to osobna, krótka transakcja: aktualizacja statusu + ewentualne drobne zmiany towarzyszące,
  • komunikacja między etapami idzie przez kolejki/job queue, a nie przez „czekanie” w jednej transakcji.

W razie konfliktów powtarzasz tylko ostatni etap, a nie cały proces. Dodatkowo łatwiej jest wznowić przerwany proces (np. awaria połączenia) – wiesz, na jakim statusie wszystko się zatrzymało.

Co sprawdzić: czy masz długie procesy, które próbujesz wykonać „za jednym zamachem” w jednym requestcie i jednej transakcji. Jeśli tak, spróbuj rozrysować je jako maszynę stanów z krótkimi transakcjami na przejściach.

Rezerwacje i limity – transakcja jako krytyczna sekcja

Rezerwacja zasobów (miejsce w samolocie, ostatnia sztuka produktu, dzienny limit API) to klasyczne miejsce, gdzie transakcja bywa „zmuszona” do intensywnego blokowania. Da się to opanować, jeśli potraktujesz transakcję jak precyzyjną, krótką sekcję krytyczną.

Jedna z prostszych strategii:

  1. trzymaj liczby i stany w pojedynczym, dobrze zindeksowanym wierszu lub niewielkim zestawie wierszy (np. tabela limits z kolumną remaining),
  2. przy rezerwacji użyj:
    • albo UPDATE ... SET remaining = remaining - 1 WHERE id = ? AND remaining > 0 i sprawdź liczbę zaktualizowanych wierszy,
    • albo SELECT … FOR UPDATE na danym wierszu, szybka walidacja warunku w aplikacji, potem UPDATE.
  3. zachowaj transakcję tak krótką, jak się da: bez dodatkowych SELECTów „ozdobnych”, bez czekania na inne systemy.

Nie próbuj zamykać całego flow użytkownika (np. przejścia przez kilka ekranów) w jednej transakcji, żeby „mieć pewność rezerwacji”. Zamiast tego użyj pola typu „soft hold” z wygasaniem czasowym (np. timestamp ważności rezerwacji) i odświeżaj je krótką transakcją przy kluczowych krokach.

Co sprawdzić: wszystkie miejsca, gdzie operujesz „ostatnimi sztukami”, limitami i licznikami. Sprawdź, czy transakcja, która je aktualizuje, jest maksymalnie krótka i czy liczby trzymasz w możliwie małej liczbie wierszy.

Praca z ORM i frameworkami – jak nie dać się „magii”

Ukryte transakcje w ORM – gdzie powstają długie locki

Wiele ORM‑ów (Hibernate, Doctrine, Eloquent, JPA) zarządza transakcjami „w tle”. Często masz wrażenie, że wykonujesz pojedyncze operacje CRUD, a w rzeczywistości framework otwiera transakcję dużo wcześniej i zamyka ją dużo później, niż się spodziewasz.

Typowe pułapki:

  • „Session per request” / „Open Session in View” – transakcja rozciąga się na cały cykl życia requestu, łącznie z renderowaniem widoków,
  • leniwie ładowane relacje (lazy loading), które powodują dodatkowe SELECTy po modyfikacjach, ale nadal wewnątrz tej samej transakcji,
  • automatyczne flushowanie zmian w nieoczekiwanych momentach (np. przed każdym SELECTem), co miesza zapis z odczytem.

Bez analizy logów SQL łatwo przeoczyć fakt, że proste odwołanie do właściwości encji w templacie HTML generuje zapytanie w środku transakcji, która powinna być dawno zakończona.

Co sprawdzić: włącz logowanie SQL w środowisku testowym i prześledź, kiedy faktycznie następuje BEGIN i COMMIT/ROLLBACK. Zobacz, czy szablony widoków nie powodują dodatkowych zapytań w czasie, gdy transakcja powinna już nie być aktywna.

Świadome sterowanie transakcjami w kodzie aplikacji

Żeby uniknąć niespodziewanych blokad, transakcjami trzeba sterować świadomie – nie tylko polegać na domyślnej konfiguracji frameworka. Dobrze sprawdza się podejście „transakcja jako narzędzie domeny”, a nie „tło działania ORM‑a”.

Praktyczny schemat:

  1. krok 1: jasno zdefiniuj w serwisach domenowych metody, które mają być transakcyjne (np. finalizeOrder(), approvePayout()),
  2. krok 2: opakuj tylko te fragmenty w mechanizm transakcji (adnotacje, filtry, ręczne BEGIN/COMMIT),
  3. krok 3: zadbaj, żeby w środku nie było logiki prezentacji, zewnętrznych HTTP calli czy wolnych operacji I/O,
  4. krok 4: wszystko, co nie musi być w transakcji (generowanie PDF, wysyłka e‑mail, logika raportowa), przenieś przed lub po sekcji transakcyjnej, a najlepiej do asynchronicznych jobów.

Typowy błąd: dekorowanie całych kontrolerów adnotacją transakcyjną, bo „tak wygodniej”. Kończy się to tym, że transakcja obejmuje walidację requestu, autoryzację, renderowanie widoku, a czasem nawet generowanie pliku do pobrania.

Co sprawdzić: które klasy/metody są oznaczone jako transakcyjne i czy naprawdę każda linia kodu w nich musi działać pod blokadami bazy.

Konfiguracja ORM pod kątem blokad, a nie wygody

Domyślne ustawienia ORM‑ów są zwykle projektowane pod wygodę programisty, a nie pod zachowanie przy wysokim obciążeniu. Warto więc poświęcić trochę czasu na konfigurację stricte „anty‑lockową”.

Dobre kierunki zmian:

  • wyłączenie lub ograniczenie „Open Session in View” – sesja bazy i transakcja powinny kończyć się przed wejściem do warstwy widoku,
  • zmiana strategii ładowania relacji z „lazy wszędzie” na jawnie zdefiniowane „fetch join”/include() w miejscach, gdzie dane i tak będą potrzebne w jednym zapytaniu,
  • dostosowanie trybu flushowania (np. tylko explicite w serwisie domenowym), tak aby ORM nie wysyłał UPDATE/INSERT w losowych momentach.

Po takich modyfikacjach transakcje zwykle robią się krótsze same z siebie – mniej „niespodziewanych” zapytań w środku, mniej przypadkowych blokad.

Co sprawdzić: ustawienia „session per request”, strategię ładowania relacji oraz tryb flushowania zmian; poszukaj, gdzie ORM zbyt często komunikuje się z bazą bez Twojej wiedzy.

Logi SQL jako narzędzie diagnostyczne, nie ozdoba

Bez twardych danych łatwo zgadywać, co się dzieje w transakcjach. Dlatego w środowisku testowym logi SQL powinny być jednym z podstawowych narzędzi diagnostycznych, a nie „opcją na później”.

Prosty plan działania:

  1. krok 1: włącz logowanie wszystkich zapytań i granic transakcji (BEGIN/COMMIT/ROLLBACK) dla kluczowych endpointów,
  2. krok 2: odpal scenariusze obciążeniowe (kilku użytkowników równolegle) i zbierz logi,
  3. krok 3: przeanalizuj sekwencje: które zapytania wykonują się wewnątrz jednej transakcji i ile to trwa,
  4. krok 4: skracaj transakcje – przenoś zbędne SELECTy poza nie, agreguj odczyty w jedno zapytanie, usuwaj niepotrzebne round‑tripy do bazy.

Dobrym wskaźnikiem są też logi blokad bazy (np. deadlock trace w PostgreSQL lub MySQL). Pokazują, które konkretne zapytania wchodzą sobie w drogę i z jakich fragmentów kodu pochodzą.

Co sprawdzić: czy masz w projekcie łatwy przełącznik na „verbose SQL logging” oraz procedurę, jak z tych logów korzystać przy śledzeniu blokad i deadlocków.

Logi dobrze łączyć z metrykami z APM (New Relic, Datadog, Prometheus + Grafana). Jeśli widzisz, że konkretna akcja w aplikacji regularnie trwa kilka sekund, a w logach SQL ta sama operacja otwiera jedną transakcję na ten czas, masz mocny sygnał do refaktoryzacji. Kiedy zaś czasy odpowiedzi są niskie, ale baza zgłasza deadlocki lub długie locki, przyjrzyj się kolejności zapytań i miejscom, gdzie w trakcie jednej transakcji wykonujesz więcej niż jedno SELECT ... FOR UPDATE lub kilka UPDATE na różnych tabelach.

Dobrą praktyką jest też okresowe „polowanie” na najbardziej kosztowne zapytania. Raz na jakiś czas uruchom logowanie zapytań powyżej określonego progu (np. 200–300 ms) i sprawdź, które z nich są odpalane wewnątrz transakcji. Często okazuje się, że jedno źle zoptymalizowane zapytanie raportowe potrafi trzymać locki na gorących tabelach tylko dlatego, że zostało wywołane z poziomu kodu domenowego objętego transakcją.

Dobry schemat pracy z logami wygląda tak: krok 1 – łap logi pod obciążeniem, krok 2 – identyfikuj najdłuższe transakcje i ich zapytania, krok 3 – skracaj zakres transakcji i upraszczaj wzorce dostępu, krok 4 – powtarzaj test. Po kilku takich iteracjach widać już wyraźnie, które miejsca w kodzie „zabijają” współbieżność.

W efekcie transakcje przestają być czarną skrzynką, a stają się świadomie zaprojektowanym elementem architektury. Aplikacja webowa lepiej znosi obciążenie, konfliktów w bazie jest mniej, a gdy jednak coś się zablokuje – łatwiej namierzyć winowajcę i naprawić konkretny fragment kodu zamiast „dokręcać śrubę” całej infrastrukturze.

Najczęściej zadawane pytania (FAQ)

Jak skrócić czas trwania transakcji SQL w aplikacji webowej?

Krok 1: ogranicz transakcję tylko do operacji na bazie danych. Walidacje, wywołania zewnętrznych API, generowanie PDF, wysyłkę maili i ciężkie obliczenia wykonuj przed rozpoczęciem transakcji lub po COMMIT.

Krok 2: dziel długie scenariusze biznesowe na krótsze kroki, z osobnymi, krótszymi transakcjami. Zamiast jednej gigantycznej transakcji „placeOrder()”, osobno zapisz zamówienie, a osobno np. zainicjuj płatność asynchronicznie.

Co sprawdzić: przejrzyj miejsca z adnotacją typu @Transactional lub manualnym BEGIN/COMMIT i zobacz, czy w środku nie ma wywołań HTTP, maili, generowania raportów ani „ciężkiej” logiki w pamięci.

Dlaczego request HTTP „wisi”, gdy używam transakcji w SQL?

Najczęstsze powody są dwa: zapytanie jest naprawdę wolne (zły plan wykonania, brak indeksu) albo czeka na zwolnienie blokady założonej przez inną transakcję. Z perspektywy użytkownika oba przypadki wyglądają tak samo – kręci się kółko, a strona nie wraca.

Krok 1: sprawdź w narzędziach bazy (np. pg_stat_activity, performance_schema), czy dany request ma status „waiting for lock”. Krok 2: jeśli tak, znajdź transakcję, która trzyma blokadę na tych samych rekordach i zobacz, dlaczego trwa tak długo.

Co sprawdzić: porównaj czas trwania requestu HTTP z czasem trwania transakcji w bazie. Jeżeli prawie się pokrywają, a status to „waiting for lock”, problemem nie jest samo zapytanie, tylko inne, zbyt długie transakcje.

Jak uniknąć blokowania całej tabeli przy aktualizacjach w SQL?

Krok 1: zamiast dużych masowych UPDATE/DELETE na całej tabeli, wykonuj operacje porcjami (batchami), np. po kilkaset/kilka tysięcy rekordów na transakcję. Dzięki temu blokady obejmują mniej danych i trwają krócej.

Krok 2: dbaj o dobre warunki WHERE i indeksy, żeby baza mogła zakładać blokady głównie na potrzebnych wierszach, a nie na całych stronach lub tabelach. Unikaj też niepotrzebnych SELECT ... FOR UPDATE na dużych zakresach danych.

Co sprawdzić: sprawdź, czy w logach nie pojawiają się długie operacje bez selektywnego WHERE. Jeżeli w czasie ich wykonywania inni użytkownicy nagle zaczynają zgłaszać „zamrożoną” aplikację, to sygnał, że blokady są zbyt szerokie.

Jak rozpoznać, czy problem z „zawieszoną” aplikacją to wina kodu czy bazy danych?

Krok 1: zobacz, czy dla zawieszonych requestów w ogóle pojawiają się wpisy w logach SQL lub trace’ach ORM. Jeśli nie, przyczyna tkwi w kodzie aplikacji, kolejkach, wątkach lub sieci, a nie w samej bazie.

Krok 2: jeśli request jest widoczny w bazie jako aktywne zapytanie lub transakcja, sprawdź jego status: „executing” (zapytanie liczy, np. brak indeksu) czy „waiting for lock” (czeka na inny lock). To kluczowy podział przy szukaniu źródła problemu.

Co sprawdzić: dla kilku najdłuższych requestów HTTP prześledź pełną ścieżkę – log aplikacji, log bazy, monitor serwera. Zwróć uwagę, w którym miejscu czas „ucieka”: przed bazą, w samej bazie czy po jej stronie.

Jak bezpiecznie używać @Transactional w Springu, żeby nie blokować użytkowników?

Krok 1: zawężaj zasięg adnotacji @Transactional do konkretnych metod, które naprawdę wykonują operacje na bazie. Nie umieszczaj jej na całych kontrolerach ani serwisach, które robią też zewnętrzne wywołania HTTP czy generują pliki.

Krok 2: dziel logikę na część „przygotowującą” (bez transakcji), część „bazodanową” (wewnątrz @Transactional) i część „po” (np. wysyłka maila, integracje). Każdy fragment powinien mieć jasno określone granice czasowe.

Co sprawdzić: przeanalizuj metody oznaczone @Transactional pod kątem obecności pętli po dużych kolekcjach, zewnętrznych API oraz operacji I/O. Takie elementy są typowymi winowajcami długich transakcji i blokad.

Jak sprawdzić, które transakcje blokują inne zapytania w PostgreSQL/MySQL?

Krok 1: w PostgreSQL użyj pg_stat_activity, aby zobaczyć aktywne transakcje i czas ich trwania; dodatkowe widoki i rozszerzenia (np. pg_locks) pokażą, kto na kogo czeka. W MySQL zajrzyj do INFORMATION_SCHEMA lub performance_schema, szukając sesji w stanie „waiting for lock”.

Krok 2: zidentyfikuj „najstarsze” transakcje oraz te, które mają najwięcej blokowanych sesji. To zwykle pojedyncze, źle zaprojektowane fragmenty logiki aplikacji, które trzymają locki zbyt długo i powodują efekt domina.

Co sprawdzić: regularnie monitoruj czas trwania transakcji (np. powyżej kilku sekund) i liczby czekających sesji. Jeśli przy obciążeniu pojawiają się „kolejki” na tych samych rekordach lub tabelach, trzeba skrócić lub przeprojektować daną transakcję.

Jak pogodzić wymogi ACID z potrzebą skalowalności aplikacji webowej?

Krok 1: określ minimalny zakres danych, który naprawdę musi być zmieniany w jednej, atomowej transakcji (np. zamówienie + pozycje + rezerwacja stanu magazynowego). Całą resztę – notyfikacje, logi, raporty – wyciągnij do osobnych kroków lub kolejek asynchronicznych.

Krok 2: stosuj odpowiedni poziom izolacji transakcji. Domyślny REPEATABLE READ lub READ COMMITTED najczęściej wystarczy; zbyt wysoka izolacja (np. SERIALIZABLE) w połączeniu z długimi transakcjami szybko generuje konflikty i blokady.

Co sprawdzić: dla kluczowych operacji biznesowych wypisz, jakie tabele i rekordy są dotykane w ramach jednej transakcji. Jeżeli lista jest bardzo długa albo obejmuje „gorące” tabele (koszyki, salda, stany magazynowe), poszukaj sposobu, by ją skrócić lub rozbić na mniejsze, niezależne kroki.

Opracowano na podstawie

  • Transaction Processing: Concepts and Techniques. Morgan Kaufmann (1993) – Klasyczna teoria transakcji, blokad, izolacji i deadlocków
  • Designing Data-Intensive Applications. O’Reilly Media (2017) – Praktyczne omówienie ACID, izolacji, konkurencji i wzorców w aplikacjach web
  • PostgreSQL 16 Documentation – Explicit Locking. PostgreSQL Global Development Group – Rodzaje blokad, zachowanie transakcji, diagnostyka czekania na locki

Poprzedni artykułKod i emocje – jak twórczość programistyczna wpływa na nasz umysł
Następny artykułNowoczesne technologie w klawiaturach – optyczne, mechaniczne, hybrydowe
Cezary Kucharski

Cezary Kucharski to webmaster i programista PHP, który stawia na skuteczne rozwiązania i porządek w kodzie. Na porady-it.pl pokazuje, jak tworzyć nowoczesne skrypty: od bezpiecznych formularzy i paneli administracyjnych, przez pracę z bazami danych i plikami, po integracje z API oraz automatyzacje zadań (cron, webhooki). Zwraca uwagę na detale, które budują jakość: walidację danych, ochronę przed typowymi podatnościami, sensowną strukturę projektu i wydajność przy rosnącym ruchu. Jego poradniki są konkretne, „wdrażalne” i nastawione na praktykę – tak, aby webmaster mógł szybko poprawić działanie strony i uniknąć kosztownych błędów.

Kontakt: cezary_kucharski@porady-it.pl