Niepopularne problemy ze współbieżnością
Chyba każdy programista obcujący z Rails dłużej niż 3 miesiące miał do czynienia z aplikacją uruchamianą w wielu (w sensie więcej niż jednej) instancjach za pośrednictwem np. Mongrela lub FastCGI. Za to niestety bardzo niewielu zdaje sobie sprawę z problemów, jakie wielość instacji przysparza - a także, jak wiele wygodnych, skrótowych metod w Rails jest potencjalnie niebezpiecznych.
Houston, czy naprawdę mamy problem?
Przykład pierwszy: unikalność
Na początek weźmy naprawdę trywialny przykład:
category = Category.find_or_create_by_name( "Tips and tricks" )
Czytelne? Tak. Eleganckie? Tak. Może spowodować jakiś błąd? Niestety odpowiedź również brzmi “tak”. Zobaczmy, jakie kwerendy SQL “lecą do bazy” po wywołaniu powyższej linii kodu:
SELECT * FROM categories WHERE (categories."name" = 'Tips and tricks') LIMIT 1
INSERT INTO categories ("name") VALUES('Tips and tricks')
Na razie dobrze - nie ma się czego czepić. A teraz załóżmy, że mamy dwa Mongrele i że inkryminowana linia kodu jest wykonywana przez oba (np. dlatego, że użytkownik znudził się czekaniem na odpowiedź serwera i nacisnął przycisk “Submit” formularza tworzącego kategorię drugi raz). Oto jak mogą wyglądać zapytania SQL (cyfra na początku to numer serwera):
1: SELECT * FROM categories WHERE (categories."name" = 'Tips and tricks') LIMIT 1
2: SELECT * FROM categories WHERE (categories."name" = 'Tips and tricks') LIMIT 1
2: INSERT INTO categories ("name") VALUES('Tips and tricks')
1: INSERT INTO categories ("name") VALUES('Tips and tricks')
i Mongrel pierwszy zwraca błąd, bo próba stworzenia kategorii o nazwie już istniejącej spowoduje obiekcje naszego indeksu UNIQUE na polu name (bo przecież zawsze zakładamy takie indeksy, prawda? :).
Przykład drugi: zmiana atrybutu
Załóżmy, że mamy model Post (tak, wiem, niezbyt to oryginalne :)) z atrybutem view_count. Działanie zwiększające licznik odsłon posta na poziomie Rails wygląda typowo mniej więcej tak:
post = Post.find 1
post.update_attribute( :view_count, post.view_count + 1 )
co tłumaczy się na następujące linie SQL:
SELECT * FROM post WHERE id = 1
UPDATE post SET view_count = 1 WHERE id = 1
Wstawka nie na temat - co sprytniejsi czytelnicy w tym momencie zauważą, że wystarczyłoby po prostu trochę się pobawić i wywołać “ręcznie” SQL: UPDATE post SET view_count = view_count + 1 WHERE id = 1, ale po pierwsze wymaga to ręcznej zabawy (a nie po to piszemy w Rails, żeby cokolwiek robić ręcznie, prawda?), a po drugie nie pasuje mi to do koncepcji przykładu :)
Teraz ponownie weźmy nasze 2 Mongrele i obejrzyjmy zapytania SQL powstałe wskutek oparcia łokcia naszego użytkownika na przycisku F5 (oczywiście gdy otwarta jest przeglądarka na stronie z postem), co skutkuje serią szybko następujących po sobie requestów zwiększających nasz licznik view_count:
1: SELECT * FROM post WHERE id = 1
2: SELECT * FROM post WHERE id = 1
1: UPDATE post SET view_count = 1 WHERE id = 1
2: UPDATE post SET view_count = 1 WHERE id = 1
Tym razem Jedynka wykonała UPDATE przed Dwójką, ale efekt jest błędny - mimo 2 odsłon postu nasz licznik ma wartość 1! Dlaczego? Po prostu oba Mongrele pobrały post z poprzednią wartością licznika (czyli 0), zwiększyły go w pamięci i zaktualizowały w bazie. Nie wiedziały jednak biedaczki sprawy o sobie nawzajem, więc jeden z nich miał de facto nieaktualną wartość licznika.
To się naprawdę zdarza
Powyższe przykłady nie są wyssane z palca, ale trafiają się w rzeczywistych, poważnych aplikacjach. Lista potencjalnie niebezpiecznych sytuacji jest o wiele dłuższa, np. eleganckie validates_uniqueness_of wbrew pozorom nie gwarantuje nam unikalności, jeśli nie zadbamy o nią sami na poziomie bazy danych; praktycznie wszystkie sytuacje wymagające unikalności czegokolwiek narażają nas przynajmniej na błędy ze strony bazy danych (zamiast ładnych i eleganckich komunikatów Railsowych); itd.
“I co teraz, i co teraz, co z denatem?”
Możliwych rozwiązań jest kilka - najpierw omówię najogólniejsze, a potem przejdę do takich, które mają zastosowanie w specyficznych wypadkach. Na pewno zdecydowanie najgorszą możliwością jest zignorowanie problemu z nadzieją, że “jakoś to będzie”. Uwierzcie mi - nie będzie, a jeśli nie zadbaliśmy o właściwie indeksy, klucze itp. na poziomie bazy danych, to w końcu obudzimy się z ręką w nocniku i np. pięcioma kategoriami o nazwie “Tips and tricks” mimo obecności linii validates_uniqueness_of :name…
Transakcja i lock
Zdecydowanie najbardziej ogólnym, a często również najprostszym rozwiązaniem jest użycie transakcji, a następnie zapewnienie, że będą się one wykonywać po kolei (a nie jednocześnie). Można to osiągnąć ustawiając poziom izolacji transakcji bezpośrednio w naszej bazie danych, ale jako że brzydzimy się (przynajmniej na razie) brzydkimi słowami typu set transaction isolation level serializable oraz umieszczaniem SQL bezpośrednio w kodzie naszej aplikacji pokażę sposób bardziej “railsowaty”.
Weźmy drugi przykład (ten z Post i view_count). Podany kod z użyciem transakcji i locka wyglądałby mniej więcej tak:
Post.transaction do
post = Post.find( 1, :lock => true )
post.update_attribute( :view_count, post.view_count + 1 )
end
I już. Niepoliczona odsłona posta już się nam nie zdarzy. Prawda, że proste?
Niestety, obiecałem, że rozwiązanie będzie ogólne, a powyższy kod nie daje się zastosować do pierwszego przykładu z początku artykułu (Category.find_or_create...), ponieważ nie mamy rekordu, który potencjalnie moglibyśmy zalockować (tzn. kategoria zostanie potencjalnie stworzona, a na razie być może nie istnieje). Na szczęście z reguły nasz obiekt nie występuje sam, ale jest powiązany z innymi obiektami już istniejącymi (a co istnieje, może zostać potraktowane lockiem :). Załóżmy, że kategoria, którą chcemy znaleźć lub stworzyć ma być przypisana do postu. Wtedy nasz kod mógłby wyglądać np. tak:
post = Post.find 1
transaction do
post.lock!
category = Category.find_or_create_by_name( "Tips and tricks" )
...
end
Tutaj niewątpliwe przyda się kilka słów wyjaśnienia:
Post.findjest umieszczone przed blokiem transakcji, ponieważ nie istnieje konieczność posiadana aktualnych informacji o jakimś atrybucie posta (jak było w przypadkuview_count) - nawet jeśli ustawiamy np.post.category_id, to poprzednia wartość nas w sumie nie obchodzi- użyłem
transactionzamiastPost.transaction, ale przyznam się bez bicia, że nie widzę specjalnej różnicy między nimi. Prawdopodobnie zauważyłbym, gdyby jeden z modeli biorących udział był w innej bazie danych :) Ja osobiście stosuję taką zasadę: jeśli transakcja dotyczy tylko jednego modelu, to stosuję np.Post.transaction, jeśli kilku różnych - gołetransaction. Ale nie upieram się, że to idealne rozwiązanie i jestem otwarty na sugestie :) - clue całej sprawy (i różnicą w porównaniu z “prostym” lockowaniem przedstawionym wyżej) jest fakt, że kod zadziała dobrze nawet jeśli nie zmienamy nic w obiekcie
post! Innymi słowy jeśli w kodzie w miejsce kropek nie wstawimy nic, to transakcje będą i tak wykonywane po kolei. Dzieje się tak dlatego, że metodalock!powoduje wykonanie odpowiedniej, specyficznej dla danej bazy danych operacji (np. w Postgresie będzie to coś w styluSELECT * FROM post WHERE id = 1 FOR UPDATE), która spowoduje, że inne transakcje chcące wykonać tę samą kwerendę będą musiały poczekać, aż pierwsza transakcja się zakończy. Obiekt zalockowany w ten sposób zostanie odblokowany automatycznie po zakończeniu transakcji.
Opisana metoda jest niewątpliwie najbardziej uniwersalna, natomiast czasami rzeczywiście nie da się znaleźć obiektu, który moglibyśmy zalockować (można w tym celu użyć np. aktualnie zalogowanego użytkownika, bo raczej nie wykona on zbyt wiele operacji jednocześnie, ale użycie np. kategorii używanej przy co drugiej akcji fatalnie wpłynie na wydajność). Wtedy pozostaje nam użycie jednej z pozostałych, mniej uniwersalnych, za to czasami szybszych metod.
Uproszczenie do jednego zapytania SQL
O tym było już w drugim przykładzie. Zamiast:
SELECT * FROM post WHERE id = 1
UPDATE post SET view_count = 1 WHERE id = 1
możemy użyć:
UPDATE post SET view_count = view_count + 1 WHERE id = 1
Trzeba to zrobić albo ręcznie (nie lubimy…) albo zrobić sobie metodę w stylu:
def increase_attribute( attribute, step = 1 )
self.class.connection.execute( self.class.sanitize_sql(["UPDATE #{self.class.table_name} SET #{attribute} = #{attribute} + ? WHERE id = ?", step, self.id]) )
end
(tak naprawdę, żeby uniknąć tego powtarzanego self.class, robię z tego z reguły metodę klasy).
Polecam tę metodę, bo jej skutkiem jest bardzo prosty (i szybki) kod SQL.
Użycie funkcji/procedury bazodanowej
Ta metoda jest dość mało elegancka, ale czasami jest jedynym wyjściem, kiedy chcemy stworzyć nowy obiekt lub zupdateować stary, a nie mamy żadnego “jelenia” do zalockowania. Wróćmy do przykładu z kategorią - załóżmy, że zamiast kategorii mamy model SearchString, gdzie nie tylko przechowujemy wszystkie przeszukiwania naszych użytkowników, ale również ilość ich wystąpień. W takim wypadku funkcja dla PostgreSQL mogłaby wyglądać następująco:
CREATE FUNCTION add_search_string(str varchar) RETURNS boolean AS $$
BEGIN
LOCK TABLE search_strings IN ACCESS EXCLUSIVE MODE;
UPDATE search_strings SET string_count = string_count + 1, updated_at = current_timestamp WHERE string = str;
IF NOT FOUND THEN
INSERT INTO search_strings (string, created_at, updated_at) VALUES (str, current_timestamp, current_timestamp);
END IF;
RETURN true;
END;
$$ LANGUAGE 'plpgsql';
Mam nadzieję, że jest ona zrozumiała, choć niewątpliwie wyjaśnić należy linię “LOCK TABLE…”. Otóż linia ta lockuje całą tabelę (trzeba z tym uważać - akurat w tym przykładzie jedynie ta funkcja dobiera się do tabeli search_strings, ale w innych wypadkach pozostałe transakcje będą czekać, aż odblokujemy tabelę), żebyśmy mogli dodać/zupdateować naszego search_stringa bez ryzyka, że ktoś w tym czasie doda nam takiego samego stringa do tabeli.
Wywołanie funkcji w Rails wygląda tak:
class SearchString < ActiveRecord::Base
def self.store( string )
self.connection.execute( sanitize_sql( ["SELECT add_search_string (?)", string] ))
end
end
Nie polecam tej metody, ponieważ jest skomplikowana, ale czasami naprawdę nie mamy innego wyjścia. I jeszcze 2 uwagi:
- w PostgreSQL w funkcjach/procedurach nie działają transakcje, więc nie liczcie na nie!
- w MySQL jest coś o wiele wygodniejszego, czyli dyrektywa
REPLACE, która tworzy nowy wiersz lub zmienia istniejący
Podsumowanie
Jak widać, właściwe rozwiązanie problemów współbieżności nie jest tak proste, jak byśmy chcieli, ale na szczęście nie tak skomplikowane, jak byśmy się obawiali :)
Ponieważ artykuł się “nieco” rozrósł, kwestię testowania kodu pod względem współbieżności opiszę wkrótce w osobnych artykule.