Współbieżność - testowanie
W poprzednim poscie opisałem typowe problemy współbieżnościowe i zaproponowałem ich rozwiązania, natomiast teraz chciałbym uzupełnić ten opis o sposoby testowania pod kątem współbieżności w sposób jak najbardziej zgodny z filozofią Rails.
Na początku zastrzeżenie - opisuję coś, co napisałem sam na własne (tzn. tworzonego projektu) potrzeby. Na pewno wiele elementów dałoby się ulepszyć i może uda mi się z tego zrobić kiedyś plugina, ale na razie mój “system testowania współbieżnego” pozostaje w pierwszej, oryginalnej wersji zgodnie z zasadą, że prowizorki są najbardziej trwałe :)
Warto stworzyć osobny katalog na testy współbieżności, ja swój nazwałem test/concurrent.W tym katalogu tworzymy bazową klasę dla testów, np. ConcurrentTest:
require 'test/unit/ui/console/testrunner'
class ConcurrentTest < Test::Unit::TestCase
def concurrent( count )
saved_config = ActiveRecord::Base.remove_connection
(1..count).collect { |i|
fork do
ActiveRecord::Base.establish_connection( saved_config )
yield i
end
}.each {|process_id| Process.waitpid process_id}
end
def self.run
Test::Unit::UI::Console::TestRunner.run(self)
end
endJeśli chcemy korzystać z istniejącego test helpera, to dodajemy na początku pliku linię require File.dirname( __FILE__ ) + '/../test_helper'.
Myślę, że kod jest zrozumiały - w samym teście wywołujemy metodę concurrent_test zadając jej ilość współbieżnych procesów, a metoda ta forkuje odpowiednią ich ilość i dla każdego wykonuje blok. Bardzo ważne jest prawidłowe zarządzanie połączeniami do bazy danych - jeśli nie będziemy tworzyli osobnego połączenia dla każdego procesu potomnego, to ten, który zakończy pierwszy nieźle namiesza zamykając połączenie używane przez pozostałe.
Teraz przykład testu:
#!/usr/bin/env ruby
require File.dirname( __FILE__ ) + '/concurrent_test_helper'
class PostTest < ConcurrentTest
fixtures :posts
def test_view_count
count = 10
post = Post.find 1
assert_equal 0, post.view_count
concurrent( count ) do
post = Post.find 1
sleep 1
post.increase_view_count
end
post = Post.find 1
assert_equal count, post.view_count
end
end
PostTest.runDziesięciokrotnie zwiększamy licznik odsłon posta (w 10 współbieżnych procesach), a następnie sprawdzamy, czy wartość licznika wynosi 10. Prawda, że proste?
Wątpliwości może budzić użycie funkcji sleep - otóż jedną z najbardziej wkurzających cech błędów związanych ze współbieżnością jest trudność ich powtórzenia. W podanym wyżej przykładzie trzeba by liczyć na to, że któryś proces wykona find i increase_view_count naprzemiennie z innym, a nie że każdy z procesów wykona po kolei obie te metody, a dopiero potem pozwoli się wykonać następnemu. Użycie sleep praktycznie gwarantuje nam najpierw wykonanie find przez wszystkie procesy, a dopiero potem increase_view_count, co (przy założeniu, że increase_view_count nie dba o współbieżność) pozwoli nam na zreprodukowanie błędu za każdym razem.
Nie w każdym przypadku umieszczenie
sleepw ciele testu pozwoli na odtworzenie błędu. Czasami trzeba tę funkcję wywołać w inkryminowanej metodzie albo nawet w procedurze bazodanowej (dla Postgresa byłoby topg_sleep). Pamiętajcie u usunięciu tego wywołania po zakończeniu testowania!
Teraz wystarczy odpalić nasz plik z testem i czekać na wynik.
Integracja
Możemy zintegrować nasze współbieżne testy nieco bardziej z infrastrukturą testową Rails. W tym celu tworzymy następującego taska Rake:
namespace :test do
task :concurrency => 'db:test:prepare' do
Dir.glob( "test/concurrent/*test.rb" ).each { |test| system test }
end
endI teraz możemy odpalić wszystkie nasze testy używając rake test:concurrency.
Mam jeszcze taki pomysł, żeby wyprodukować ładny raport dot. ilości błędów (taki jak podaje rake test) i użyć kodu wyjścia programu w celu podliczenia ilości testów, które oblały, ale na razie pozostawiam to jako ćwiczenie dla czytelnika :)
Uwagi
- jako że aplikacje Rails nie oszczędzają specjalnie pamięci, a my odpalamy ich np. 10 naraz, należy zaopatrzyć się w odpowiednią ilość RAM. Moja praktyka pokazuje, że przy 10 współbieżnych procesach laptop z 1GB RAM potrafi swapować (oczywiście uruchomione były też różne inne programy) - specjalnie do testów współbieżnych dorzuciłem jeszcze 1GB :)
- ilość współbieżnych procesów można zdefiniować tak: minimalna ilość, która pozwala na w miarę regularne odtworzenie błędu. W powyższym przykładzie właściwie wystarczyłoby uruchomić 2 procesy, ale czasami i 10 to za mało.
- jeśli błąd nie chce się pojawić mimo wielokrotnego uruchamiania testów, to należy jeszcze raz dokładnie przeanalizować kod: aplikacji, że potwierdzić, że błąd naprawdę może wystąpić i testu, by sprawdzić, że stworzone zostały odpowiednie warunki do jego wystąpienia (patrz uwaga nt.
sleep). Pamiętajcie, że błędy “współbieżnościowe” są mało deterministyczne i kilkukrotny bezbłędny przebieg testów może świadczyć zarówno o poprawności programu jak i niskiej jakości testów - zamiast testować ponownie podobne kawałki kodu (np. licznik odsłon posta, licznik odsłon artykułów użytkownika, licznik odsłon komentarza) lepiej jest użyć sprawdzonego kawałka kodu jako wzorca, a czas przeznaczony na testowanie poświęcić na przetestowanie innego przypadku
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.