Skip to content
← Wszystkie wpisy
5 min czytania Dawid Skłodowski

Zadania w tle z Sidekiq i Redis

Sidekiq przetwarza zadania w tle wątkami i Redisem, i jest szybki — ale szybkość przychodzi z regułami. Deep-dive o tym, jak działa, dlaczego twoje zadania muszą być małe i idempotentne i jak przeżyć ponawiania oraz współbieżność.

Cokolwiek wolnego, na co użytkownik nie powinien czekać — wysyłanie maila, skalowanie obrazu, wołanie API strony trzeciej, generowanie raportu — należy do zadania w tle. W świecie Ruby Sidekiq stał się domyślnym narzędziem do tego, i zasłużenie: jest szybki, wydajny i przyjemny w obsłudze. Ale ta wydajność bierze się z modelu współbieżności, który nakłada realne reguły na to, jak piszesz zadania, a większość bólu z Sidekiqiem bierze się z łamania ich nieświadomie. Oto jak działa i jak pisać zadania, które się zachowują.

Jak Sidekiq działa: wątki i Redis

Dwie decyzje projektowe definiują Sidekiqa. Po pierwsze, używa wątków, nie procesów, do współbieżności. Tam, gdzie Resque forkuje cały proces na zadanie, pojedynczy proces Sidekiqa uruchamia wiele wątków roboczych — często 10 lub 25 — dzielących jedną maszynę wirtualną Ruby. To czyni go dramatycznie wydajniejszym pamięciowo: jeden proces z 25 wątkami używa ułamka RAM-u 25 sforkowanych workerów Resque. Haczyk, do którego wrócimy, jest taki, że wątki dzielą pamięć, więc twój kod zadań musi być bezpieczny wątkowo.

Po drugie, używa Redisa jako składu zadań. Gdy kolejkujesz zadanie, Sidekiq je serializuje (nazwa klasy workera plus jego argumenty) i wpycha na listę Redisa. Wątki robocze zdejmują zadania z Redisa i je uruchamiają. Redis jest tu idealnym dopasowaniem: jest szybki, atomowy, a jego struktury danych (listy, sorted sety) mapują się dokładnie na kolejki i zaplanowane zadania.

class ThumbnailWorker
  include Sidekiq::Worker
  sidekiq_options queue: :images, retry: 5

  def perform(upload_id)
    upload = Upload.find(upload_id)
    upload.generate_thumbnail!
  end
end

ThumbnailWorker.perform_async(upload.id)          # zakolejkuj teraz
ThumbnailWorker.perform_in(1.hour, upload.id)     # zaplanuj na później

(W aplikacji Rails 4.2 możesz też napisać to jako ActiveJob i wskazać adapter na Sidekiqa — ten sam silnik pod spodem, z niezależnym od backendu API na wierzchu.)

Reguła 1: przekazuj ID, nie obiekty

Przyjrzyj się temu workerowi — bierze upload_id, nie upload. To pierwsza reguła i wynika wprost z tego, że Redis jest składem. Argumenty zadania są serializowane do JSON i siedzą w Redisie, aż worker je podejmie, być może sekundy lub minuty później. Dwie rzeczy psują się, jeśli przekażesz cały obiekt:

  • Serializowana kopia to przestarzała migawka zrobiona w czasie kolejkowania. Zanim zadanie się uruchomi, rekord może się zmienić; chcesz, by worker wczytał bieżący stan.
  • Złożone obiekty i tak nie przechodzą czysto przez JSON w obie strony.

Więc przekazuj id i pozwól zadaniu find rekord, gdy się uruchamia. Worker zawsze operuje na świeżych danych, a payload siedzący w Redisie pozostaje malutki.

Reguła 2: zadania muszą być idempotentne

To reguła, która zignorowana powoduje najwięcej szkód — i wynika z tego, jak Sidekiq gwarantuje dostarczenie. Sidekiq jest at-least-once: gwarantuje, że zadanie się uruchomi, nie że uruchomi się dokładnie raz. Worker może umrzeć w trakcie zadania (deploy, awaria, OOM) po wykonaniu części pracy, lecz przed zaraportowaniem sukcesu, a Sidekiq ponownie uruchomi zadanie. Ponawiania (niżej) też je uruchamiają. Więc każde zadanie musi być bezpieczne do uruchomienia więcej niż raz.

# NIEBEZPIECZNE — uruchamia się dwa razy, obciąża dwa razy
def perform(order_id)
  order = Order.find(order_id)
  PaymentGateway.charge(order.total)
end

# IDEMPOTENTNE — drugi przebieg to no-op
def perform(order_id)
  order = Order.find(order_id)
  return if order.charged?
  order.with_lock do
    return if order.charged?
    PaymentGateway.charge(order.total, idempotency_key: "order-#{order.id}")
    order.update!(charged: true)
  end
end

Techniki: sprawdź, czy praca jest już wykonana, zanim ją wykonasz, użyj blokady bazy, by zamknąć wyścig między równoległymi przebiegami, i oprzyj się na własnym kluczu idempotencji usługi zewnętrznej, gdy go oferuje. Dla miniatury ledwie to ma znaczenie; dla wszystkiego, co rusza pieniędzmi, wysyła wiadomość albo ma skutki uboczne w świecie zewnętrznym, idempotencja nie jest opcjonalna — to różnica między odpornym systemem a takim, który podwójnie obciąża klientów przy twoim następnym złym deployu.

Reguła 3: szanuj ponawiania

Domyślnie Sidekiq ponawia zawiedzione zadanie, z wykładniczym backoffem, do ~25 razy przez około trzy tygodnie, zanim podda się do dead setu. To funkcja — przejściowa porażka (czkawka API strony trzeciej, deadlock) sama się naprawia przy ponowieniu — ale ostro współgra z idempotencją. Zadanie prawie idempotentne, lecz obciążające na szczęśliwej ścieżce, obciąży ponownie przy każdym ponowieniu. Dostrój politykę per worker:

sidekiq_options retry: 5            # ogranicz ponawiania dla tego workera
sidekiq_options retry: false        # nigdy nie ponawiaj (tylko jeśli naprawdę bezpiecznie porzucić)

Rezerwuj retry: false dla zadań, gdzie pominięty przebieg jest nieszkodliwy albo wyzwalany ponownie gdzie indziej. Dla reszty ustaw rozsądny limit i upewnij się, że zadanie jest naprawdę idempotentne, by ponawiania były bezpieczne, a nie groźne. I obserwuj dead set — zadania, które wyczerpały ponawiania, siedzą tam, mówiąc ci, że coś jest zepsute.

Reguła 4: bezpieczeństwo wątkowe jest na tobie

Ponieważ workery to wątki w jednym procesie, cokolwiek dzielą, musi być bezpieczne wątkowo. W praktyce rzadko gryzie to nowoczesny kod Rails — pula połączeń ActiveRecord jest bezpieczna wątkowo, a większość gemów też — ale gryzie mocno, gdy gryzie. Zwykli winowajcy:

  • Zmienny stan na poziomie klasy. @@counter albo memoizowana zmienna klasowa, do której zadania zapisują, to wyścig czekający, by się zdarzyć.
  • Biblioteki niebezpieczne wątkowo. Niektóre starsze gemy trzymają globalny zmienny stan; sprawdź przed użyciem jednej wewnątrz workera.
  • Rozmiar puli połączeń. Twoja pula bazy musi być co najmniej tak duża jak współbieżność Sidekiqa, inaczej wątki będą czekać w kolejce na połączenie. Ustaw pulę, by pasowała (lub przekraczała) liczbę workerów.

Dobre operowanie

Kilka praktyk trzymających Sidekiqa zdrowym na produkcji:

  • Rozdzielaj kolejki według celu i priorytetu i waż je, by zalew zadań niskiego priorytetu (powiedzmy analityka) nie głodził pilnych (maile resetu hasła). sidekiq -q critical,2 -q default przetwarza critical dwa razy częściej.
  • Trzymaj zadania małe i jednoprzeznaczeniowe. Zadanie robiące pięć rzeczy to pięć rzeczy, które muszą być wszystkie idempotentne i wszystkie ponawiać się razem. Mniejsze zadania ponawiają się czysto i lepiej zrównoleglają.
  • Używaj Web UI. Dashboard Sidekiqa pokazuje głębokości kolejek, ponawiania i dead set — zamontuj go (za uwierzytelnieniem) i faktycznie patrz; rosnąca kolejka albo zapełniający się dead set to wczesne ostrzeżenie.
  • Uczyń kolejkowanie tanim, a zadanie niech wykonuje pracę. Kontrolery powinny kolejkować i zwracać; cała wolna praca dzieje się w workerze.

Werdykt

Sidekiq to właściwy domyślny wybór do zadań w tle w Ruby: model wątkowy czyni go szybkim i wydajnym pamięciowo, a Redis daje mu solidny, obserwowalny skład. Ale jego szybkość jest nierozłączna z kontraktem — dostarczenie at-least-once, współbieżność wątkowa, automatyczne ponawiania — a ten kontrakt to naprawdę jedno żądanie wobec ciebie: pisz małe, idempotentne, bezpieczne wątkowo zadania, które biorą ID i przeładowują swoje dane. Przyswój to, a Sidekiq jest koniem roboczym, któremu możesz powierzyć najważniejsze skutki uboczne w swoim systemie. Zignoruj to, a ostatecznie uruchomi coś dwa razy w najgorszym możliwym momencie.