W tym tekście postaram się przybliżyć jeden z problemów e-commerce oraz możliwe jego rozwiązania – a mianowicie: mamy ostatni produkt, dwóch chętnych, którzy (o zgrozo!) dokonali zakupu w tym samym momencie.

Jak to w programowaniu, informatyce i ogólnie życiu bywa: jeden problem może mieć wiele różnorakich rozwiązań - tak samo jest w tym przypadku. Oczywiście sytuacja jest czysto teoretyczna, mało prawdopodobna – ale nie, nie możliwa... a dobry algorytm powinien być odporny także na takie wyjątki.

Najprostsze rozwiązanie – a zarazem niestety w tym wypadku najgorsze, bo podane na tzw. ‘wyścig’ (czyli to co chcemy uniknąć - jest zwykły update w bazie danych.


Przykład w MySQL / MariaDB:

UPDATE products 
SET stock = stock - 1 
WHERE id = 123 AND stock > 0;

 

Założenie jest banalne w swojej prostocie:

  • Jeżeli stock > 0 – operacja się powiedzie
  • Jeżeli stock = 0 – operacja zakupu się nie powiedzie


Problem z tym rozwiązaniem jest oczywisty: przy dużym ruchu (przy założeniu idealnej latencji itd.) dwóch użytkowników może dostać stock = 0 i kupić produkt jednocześnie... a co potem się stanie? Jak cała machina pójdzie w ruch? Zwłaszcza jak mamy zintegrowane w całym procesie zakupowym systemy typu ERP i inne? To już ciężko przewidzieć... a znalezienie tego błędu i jego naprawa też może nie być proste.


Możliwe rozwiązanie 1: jeżeli posiadamy tabele rezerwacji zamówień (tj. w bazie danych każda 1 sztuka 1 produktu jest osobnym rekordem)

Możemy wykorzystać mechanizm unikalnego klucza - przykładowe zapytanie w MySQL / MariaDB z wykorzystaniem IGNORE

 

INSERT IGNORE INTO orders (user_id, product_id) VALUES (1, 123);

 

Oczywiście z kluczem unikalnym dla pola product_id

 

ALTER TABLE orders ADD UNIQUE (product_id);


Mamy w tym wypadku dwie możliwości:

  • Jeżeli produkt został już zarezerwowany – zapytanie zostanie zignorowane
  • Jeżeli nie - zamówienie zostanie utworzone


Jest to proste rozwiązanie, jednak wymaga osobnej tabeli i rozbicia poszczególnych wystąpień tego samego produktu na sztuki – a przy dużych ilościach produktu to nie ma większego sensu (zwłaszcza, że w czasie będzie problem narastać i będzie to odbijać się na wydajności samej bazy danych / tabeli + dochodzi kwestia archiwalnych zamówień, produktów... lepiej nie).


Możliwe rozwiązanie 2 - obsługa transakcji

Rozwiązanie to sprawdzi się jedynie, kiedy używamy silnika InnoDB na naszej bazie danych – ale za to jest także stosunkowo proste i bezpieczne.

 

Przykład w MySQL / MariaDB:

START TRANSACTION;

-- Pobranie produktu z blokadą (blokuje wiersz do końca transakcji)
SELECT stock FROM products WHERE id = 123 FOR UPDATE;

-- Sprawdzenie dostępności i aktualizacja
UPDATE products 
SET stock = stock - 1 
WHERE id = 123 AND stock > 0;

COMMIT;

 

Przykład dla PostgreSQL:

BEGIN;

-- Pobranie produktu z blokadą na poziomie wiersza
SELECT id, stock FROM products WHERE id = 123 FOR UPDATE;

-- Sprawdzenie, czy produkt jest dostępny
UPDATE products
SET stock = stock - 1
WHERE id = 123 AND stock > 0;

-- Sprawdzenie, czy operacja była skuteczna
SELECT stock FROM products WHERE id = 123;

COMMIT;

 

Jak to działa? Ano prosto – wszystko jest obrane w jedną transakcję złożoną z dwóch zapytań do bazy danych. W tym wypadku wiersz: SELECT ... FOR UPDATE blokuje cały wiersz tabeli aż do zakończenia owej transakcji. Zatem w przypadku, kiedy, dwóch użytkowników jednocześnie wykona operację zakupu to:

  • Pierwszy użytkownik zablokuje wiersz
  • Drugi będzie musiał poczekać
  • Gdy pierwszy dokończy transakcję - drugi sprawdzi stocki nie dojdzie do zakończenia transakcji.


Wadą tego rozwiązania jest niestety fakt, że może powodować kolejki – ale jest to dość proste rozwiązanie do implementacji w istniejących systemach. Na pewno najbardziej bezpieczne – ale nie najbardziej wydajne. Zastosowanie znajdzie z powodzeniem w standardowych e-commercowych systemach, gdzie nie ma bardzo wielkiego ruchu (marketplace’y w czarne czwartki raczej odpadają - ale z doświadczenia wiem, że wtedy raczej lubi siadać ogólnie infrastruktura i same usługi niż jeżeli dochodzi do takich incydentów).


Możliwe rozwiązanie 3 – Flash-sale

Rozwiązanie dobre dla dużego ruchu i mniej rozbudowanej infrastuktury – opiera się na Redis czy Memcached. Redis jest tak powszechny obecnie jak MySQL / MariaDB więc to nie jest duży problem.

Na czym polega to rozwiązanie? Wykorzystanie Redisa do tymczasowej rezerwacji produktu – zanim sama transakcja trafi do bazy danych. Oto przykład:

Użytkownik próbuje kupić produkt:

SETNX "reserved_product_123" user_id

  • Jeżeli operacja się udała - użytkownik ma X sekund na dokończenie zakupu.
  • Jeżeli nie – produkt jest zajęty, nie dojdzie do zakupu.


Zalety i wady tego rozwiązania:

  • ✅ Bardzo szybkie, idealne dla systemów o dużym ruchu (e-commerce z milionami użytkowników) - Redis w końcu działa w pamięci.
  • ✅ Można dodać TTL (czas rezerwacji) - ustalając np. 30 sekundowy zakres czasu na dokończenie transakcji
  • ❌ Redis w końcu działa w pamięci... co przy awarii implikuje utratę danych
  • ❌ Potrzebne są osobne mechanizmy do synchronizacji z docelową bazą danych


Jednak jest to rozwiązanie dość szybkie, które powinno bez większych problemów dać sobie z obsługą nawet bardzo dużego ruchu. Pożądane także, kiedy chcemy mieć krótkotrwałe rezerwacje produktu przed samym zakupem.


Możliwe rozwiązanie 4 – system kolejowy (zastosowanie Event-Driven Architekture)

Założenie oparte na kolejkach (RabbitMQ, Kafka, od biedy Redis) - gdzie problem równoczesnego zakupu ostatniej sztuki produktu jest rozwiązywany przez samą kolejkę. Podejście w stylu: ‘First Come, First Serve’


Przykład działania:

  1. Klient wysyła żądanie zakupu – zdarzenie jest wrzucane na kolejkę
  2. Konsumer konsumuje pierwszy event z kolejki i go przetwarza
  3. Każde zamówienie jest obsługiwane sekwencyjne jeden po drugim.


Oczywiście w przypadku istnienia jednej instancji konsumera na tą daną kolejkę - jeżeli zechcemy ‘zoptymalizować’ ten proces (np. aby odciążyć tą kolejkę z zalegających eventów), dokładając kolejną instancję tego konsumera na tą kolejkę... To problem może powrócić


Zalety i wady takiego podejścia:

  • ✅ Gwarancja kolejności wykonywanych zdarzeń - tylko jeden użytkownik może zdobyć produkt jako pierwszy
  • ✅ Dobrze działa w architekturze rozproszonej opartej na mikro-serwisach
  • ❌ Potrzeba dedykowanego serwera / usługi pod Rabbita / Kafke


Jest to jednak dobre rozwiązanie dla systemów, gdzie wydajność i kolejność zachodzących operacji jest kluczowa – np. systemy rezerwacji biletów.

Są także możliwe inne rozwiązania - choć z nimi nie miałem styczności w swojej pracy. Takim rozwiązaniem jest np. oparcie transakcji na blockchain’ach - choć to bardziej czysto teoretyczne rozwiązanie, możliwe raczej przy NFT i pokrewnych niż e-commerce. W przypadku baz NoSQL można także zastosować mechanizm transakcji – z tego co mi wiadomo Firestore (Google Firebase) daję możliwość obsługi transakcji.

 

Szybko i rzetelnie podsumowując:

  • Najlepszym i najbezpieczniejszym rozwiązaniem dla e-commerce o niewielkim ruchu i rozmiarach jest rozwiązanie problemu oparte na transakcjach na bazie danych.
  • Bardzo dobrym rozwiązaniem jest oparcie procesu o system kolejkowy - gwarantuje nam to kolejność wykonywania zdarzeń w zależności od wejścia danego eventu na kolejkę.