Background Workers: Die stillen Helden die deine Anwendung am Leben halten
Lass mich dir von dem Mal erzählen als ein einzelner Background Job eine komplette E-Commerce Plattform für sechs Stunden am Black Friday lahmgelegt hat. Ein Job. Sechs Stunden. Millionen an verlorenem Umsatz. Der Job verarbeitete Bestellbestätigungen, traf auf eine fehlerhafte Email Adresse, warf eine unbehandelte Exception, und weil er so konfiguriert war dass er unendlich oft mit keinem Circuit Breaker wiederholt, blockierte er die gesamte Queue. Jeder andere Job, Versandbenachrichtigungen, Inventar Updates, Zahlungsbestätigungen, alles saß einfach da und wartete. Das ist kein Hypothetikum. Das ist passiert. Und genau deshalb schreibe ich diesen Post.
Lass mich dir von dem Mal erzählen als ein einzelner Background Job eine komplette E-Commerce Plattform für sechs Stunden am Black Friday lahmgelegt hat. Ein Job. Sechs Stunden. Millionen an verlorenem Umsatz. Der Job verarbeitete Bestellbestätigungen, traf auf eine fehlerhafte Email Adresse, warf eine unbehandelte Exception, und weil er so konfiguriert war dass er unendlich oft mit keinem Circuit Breaker wiederholt, blockierte er die gesamte Queue. Jeder andere Job, Versandbenachrichtigungen, Inventar Updates, Zahlungsbestätigungen, alles saß einfach da und wartete. Das ist kein Hypothetikum. Das ist passiert. Und genau deshalb schreibe ich diesen Post.
Background Workers sind die stillen Helden moderner Webanwendungen. Sie erledigen all das Zeug das deine User sonst auf Ladespinner starren lassen würde: Emails senden, Bilder verarbeiten, Daten synchronisieren, Reports generieren, Kreditkarten belasten. Aber sie sind auch eine massive Quelle von Produktions Incidents wenn sie nicht richtig designt sind.
Ich betreibe Sidekiq in Produktion seit über einem Jahrzehnt quer durch dutzende Anwendungen. Von kleinen Startups bis zu Enterprise Systemen die Millionen von Jobs pro Tag verarbeiten. Die Patterns in diesem Post sind kampferprobt und repräsentieren hart erarbeitete Lektionen aus echten Ausfällen.
Warum Background Workers existieren
Lass uns mit den Basics anfangen. Wenn ein User auf einen Button auf deiner Website klickt, erwartet er dass etwas schnell passiert. Forschung zeigt dass User nach etwa 100 Millisekunden Verzögerung frustriert werden, und sie werden eine Seite verlassen die länger als 3 Sekunden zum Laden braucht.
Aber manche Operationen brauchen Zeit. Eine Email zu senden könnte 500ms dauern weil du dich mit einem SMTP Server verbinden musst. Ein hochgeladenes Bild zu verarbeiten könnte 2 Sekunden dauern. Einen PDF Report zu generieren könnte 10 Sekunden dauern. Daten mit einer Third Party API zu synchronisieren könnte wer weiß wie lange dauern abhängig von deren Servern.
Wenn du diese Operationen synchron machst (also der User wartet während sie abgeschlossen werden), fühlt sich deine Anwendung träge an. Schlimmer noch, wenn du sie innerhalb eines Web Requests machst, blockierst du einen Serverprozess der andere Requests bearbeiten könnte. Unter Last führt das zu Request Queuing, Timeouts, und schließlich zum Stillstand deiner gesamten Anwendung.
Background Workers lösen das indem sie langsame Operationen aus dem Request/Response Zyklus verschieben. Statt die Arbeit sofort zu erledigen, reihst du sie ein und gibst eine Antwort an den User zurück. Ein separater Prozess holt die eingereihte Arbeit ab und erledigt sie asynchron.
Der User klickt "Bestellung aufgeben" und sieht sofort "Bestellung bestätigt!" Währenddessen, im Hintergrund, senden Worker Bestätigungs Emails, aktualisieren Inventar, benachrichtigen das Lager, und belasten die Kreditkarte. Der User wartet auf nichts davon.
Der Stack: Sidekiq, Redis und Valkey
In der Ruby Welt ist Sidekiq der unbestrittene König der Background Job Verarbeitung. Es ist schnell, zuverlässig, und wurde über ein Jahrzehnt in Produktion von tausenden Firmen kampferprobt.
Sidekiq nutzt Redis als seine Job Queue. Redis ist ein In Memory Data Store der unglaublich schnell ist für die Art von Operationen die Sidekiq braucht: Jobs auf Queues schieben, Jobs von Queues holen, und Job Status tracken.
Eine kurze Anmerkung zu Valkey
In 2024 hat Redis seine Lizenzierung von der permissiven BSD Lizenz zu einer Dual Lizenz geändert die einschränkt wie Cloud Anbieter Redis als Service anbieten können. Als Reaktion darauf hat die Linux Foundation Redis geforkt und Valkey erstellt, das unter der ursprünglichen BSD Lizenz weiterläuft.
Für unsere Zwecke ist Valkey ein Drop in Ersatz für Redis. Alles was ich in diesem Post über Redis sage gilt genauso für Valkey. Wenn du ein neues Projekt startest oder deine Organisation Bedenken wegen Redis Lizenzierung hat, nutze Valkey. Die Commands sind identisch, das Protokoll ist identisch, und Sidekiq funktioniert mit beiden.
# Gemfile
gem 'sidekiq', '~> 7.2'
# Für Redis
gem 'redis', '~> 5.0'
# Oder für Valkey (gleiches Gem, anderer Server)
# gem 'redis', '~> 5.0' # Valkey spricht das Redis Protokoll
# config/initializers/sidekiq.rb
# Funktioniert mit Redis und Valkey
Sidekiq.configure_server do |config|
config.redis = {
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
network_timeout: 5,
pool_timeout: 5
}
end
Sidekiq.configure_client do |config|
config.redis = {
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
network_timeout: 5,
pool_timeout: 5
}
end
Dein erster Background Job
Lass uns einen einfachen Job schreiben der eine Willkommens Email sendet:
# app/jobs/send_welcome_email_job.rb
# Simplerster Job um Willkommens Emails zu senden
# Nix Fancy, erledigt einfach den Job
class SendWelcomeEmailJob
include Sidekiq::Job
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
end
end
Und reihe ihn von deinem Controller ein:
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
# Job einreihen - kehrt sofort zurück
# User wartet nicht bis Email tatsächlich gesendet wird
SendWelcomeEmailJob.perform_async(@user.id)
redirect_to dashboard_path, notice: 'Willkommen an Bord!'
else
render :new
end
end
end
Der Schlüssel hier ist perform_async. Das sendet nicht die Email. Es serialisiert den Job (den Klassennamen und die Argumente) nach Redis und kehrt sofort zurück. Der User bekommt seine Antwort in Millisekunden.
Währenddessen holt ein Sidekiq Worker Prozess der separat läuft den Job aus Redis und führt die perform Methode aus. Wenn die Email 500ms zum Senden braucht, ist das okay. Der User schaut sich bereits sein Dashboard an.
Das Problem: Jobs sind keine Inseln
Hier wird es gefährlich. Der einfache Job den ich gerade gezeigt habe? Er hat mehrere kritische Fehler die deine gesamte Anwendung lahmlegen könnten.
Fehler 1: Was wenn der User nicht existiert?
def perform(user_id)
user = User.find(user_id) # BUMM! RecordNotFound wenn User gelöscht wurde
UserMailer.welcome(user).deliver_now
end
Zwischen dem Zeitpunkt als der Job eingereiht wurde und wann er läuft, könnte der User gelöscht worden sein. Vielleicht hat er Kontolöschung angefordert. Vielleicht hat ein Admin ihn entfernt. Vielleicht gab es einen Datenbank Rollback.
User.find wird ActiveRecord::RecordNotFound werfen, was Sidekiq fängt und wiederholt. Und wiederholt. Und wiederholt. Standardmäßig wiederholt Sidekiq fehlgeschlagene Jobs 25 mal über etwa 21 Tage. Das sind 25 Exceptions in deinem Error Tracker, 25 verschwendete Verarbeitungszyklen, und potenziell 25 Alert Benachrichtigungen.
Fehler 2: Was wenn die Email fehlschlägt?
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now # BUMM! SMTP Fehler, Timeout, whatever
end
SMTP Server gehen down. Netzwerkverbindungen haben Timeouts. Rate Limits werden erreicht. Email Adressen sind fehlerhaft. Irgendwas davon wird eine Exception werfen.
Wieder, Sidekiq wiederholt. Aber hier ist der tückische Teil: wenn dein Email Provider einen Ausfall hat, wird jeder einzelne Email Job fehlschlagen. Sie gehen alle in die Retry Queue. Wenn der Provider wieder da ist, hast du plötzlich tausende Jobs die alle gleichzeitig wiederholen und den Provider potenziell wieder überlasten.
Die goldene Regel: Jobs müssen unabhängig sein
Das ist die wichtigste Lektion in diesem gesamten Post. Tätowiere es auf deinen Arm wenn nötig:
Jeder Job muss unabhängig sein. Jeder Job muss idempotent sein. Jeder Job muss annehmen dass er fehlschlagen und wiederholt werden wird.
Unabhängigkeit
Ein Job sollte nicht vom Zustand abhängen den ein anderer Job hinterlassen hat. Er sollte nicht annehmen dass Jobs in einer bestimmten Reihenfolge laufen. Er sollte alle Daten die er braucht zur Ausführungszeit holen, nicht auf Daten von als er eingereiht wurde vertrauen.
# SCHLECHT: Hängt davon ab das vorheriger Job ein Flag gesetzt hat
class SendShippingNotificationJob
include Sidekiq::Job
def perform(order_id)
order = Order.find(order_id)
# Nimmt an das ProcessOrderJob schon lief und shipped_at gesetzt hat
# Was wenn nicht? Was wenn er fehlgeschlagen ist?
raise 'Noch nicht versandt!' unless order.shipped_at
OrderMailer.shipped(order).deliver_now
end
end
# GUT: Prüft Status und handhabt gnädig
class SendShippingNotificationJob
include Sidekiq::Job
def perform(order_id)
order = Order.find_by(id: order_id)
# User oder Order existiert vielleicht nicht mehr
return unless order
# Noch nicht versandt? Das ist okay, schick halt keine Benachrichtigung
# Vielleicht reiht der Shipment Job uns später nochmal ein
return unless order.shipped_at
# Schon benachrichtigt? Spam den Kunden nicht zu
return if order.shipping_notification_sent_at
OrderMailer.shipped(order).deliver_now
order.update!(shipping_notification_sent_at: Time.current)
end
end
Idempotenz
Ein Job ist idempotent wenn ihn mehrmals auszuführen denselben Effekt hat wie ihn einmal auszuführen. Das ist entscheidend weil Sidekiq deinen Job mehrmals ausführen könnte wegen Retries, Netzwerkproblemen oder Worker Abstürzen.
# SCHLECHT: Nicht idempotent - wird mehrmals belasten bei Retry
class ChargeOrderJob
include Sidekiq::Job
def perform(order_id)
order = Order.find(order_id)
Stripe::Charge.create(
amount: order.total_cents,
customer: order.user.stripe_customer_id
)
order.update!(paid: true)
end
end
# GUT: Idempotent - prüft ob schon belastet wurde
class ChargeOrderJob
include Sidekiq::Job
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
# Schon bezahlt? Nichts zu tun
return if order.paid?
# Nutze Idempotency Key damit Stripe nicht doppelt belastet
# Selbst wenn wir nach dem Belasten aber vor dem Update abstürzen
charge = Stripe::Charge.create(
amount: order.total_cents,
customer: order.user.stripe_customer_id,
idempotency_key: "order_#{order.id}_charge"
)
order.update!(
paid: true,
stripe_charge_id: charge.id,
paid_at: Time.current
)
end
end
Monolithische Jobs aufbrechen
Erinnerst du dich an diesen furchtbaren ProcessOrderJob von vorhin? Lass uns ihn fixen indem wir ihn in unabhängige Jobs aufbrechen:
# app/jobs/process_order_job.rb
# Orchestrator Job - koordiniert nur die anderen
# Jeder Schritt ist sein eigener unabhängiger Job
class ProcessOrderJob
include Sidekiq::Job
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
return if order.processing_started?
# Markieren dass wir gestartet haben - verhindert doppelte Verarbeitung
order.update!(processing_started_at: Time.current)
# Jeden Schritt als separaten Job einreihen
# Sie laufen unabhängig und können unabhängig fehlschlagen
ChargeOrderJob.perform_async(order_id)
UpdateInventoryJob.perform_async(order_id)
NotifyWarehouseJob.perform_async(order_id)
SendOrderConfirmationJob.perform_async(order_id)
end
end
# app/jobs/charge_order_job.rb
# Behandelt nur Zahlung - nix anderes
class ChargeOrderJob
include Sidekiq::Job
sidekiq_options queue: 'critical', retry: 10
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
return if order.paid?
charge = Stripe::Charge.create(
amount: order.total_cents,
customer: order.user.stripe_customer_id,
idempotency_key: "order_#{order.id}_v1"
)
order.update!(
paid: true,
stripe_charge_id: charge.id,
paid_at: Time.current
)
rescue Stripe::CardError => e
# Karte abgelehnt - nicht wiederholen, User benachrichtigen
order.update!(payment_failed: true, payment_error: e.message)
PaymentFailedMailer.notify(order).deliver_later
end
end
Jetzt wenn die Warehouse API down ist, schlägt nur NotifyWarehouseJob fehl und wiederholt. Die Zahlung geht durch, Inventar aktualisiert, und der Kunde bekommt seine Bestätigungs Email. Die Warehouse Benachrichtigung wird schließlich erfolgreich sein wenn deren API sich erholt.
Das ist die Power von unabhängigen Jobs. Fehler sind isoliert. Eine kaputte Sache kaskadiert nicht zu allem ist kaputt.
Queue Design: Kritische Jobs in Bewegung halten
Nicht alle Jobs sind gleich erschaffen. Eine Passwort Reset Email ist dringender als ein wöchentlicher Analytics Rollup. Eine Zahlungsbestätigung ist kritischer als das Synchronisieren von Daten zu einem CRM.
Sidekiq lässt dich mehrere Queues mit unterschiedlichen Prioritäten definieren:
# config/sidekiq.yml
# Queue Konfiguration - Reihenfolge ist wichtig!
# Sidekiq verarbeitet Queues in der aufgelisteten Reihenfolge
:concurrency: 10
:queues:
- [critical, 6] # 6 Threads für critical dediziert
- [default, 3] # 3 Threads für default
- [mailers, 2] # 2 Threads für Emails
- [low, 1] # 1 Thread für niedrige Priorität
- [external_apis, 2] # 2 Threads für externe API Calls
Für wirklich kritische Jobs bevorzuge ich separate Sidekiq Prozesse:
# Prozess 1: Nur kritische Jobs
bundle exec sidekiq -q critical -c 5
# Prozess 2: Default und Mailer
bundle exec sidekiq -q default -q mailers -c 10
# Prozess 3: Niedrige Priorität und externe APIs
bundle exec sidekiq -q low -q external_apis -c 5
So, selbst wenn die external_apis Queue sich aufstaut weil ein Drittanbieter langsam ist, fließen deine kritischen Jobs weiter.
Monitoring und Alerting
Du kannst nicht fixen was du nicht sehen kannst. Sidekiq bietet ein Web UI das du unbedingt deployen solltest:
# config/routes.rb
require 'sidekiq/web'
Rails.application.routes.draw do
# Mit Authentifizierung schützen
authenticate :user, ->(user) { user.admin? } do
mount Sidekiq::Web => '/sidekiq'
end
end
Lektionen aus der Produktion
Lass mich einige spezifische Lektionen aus echten Ausfällen teilen:
Lektion 1: Argumente müssen serialisierbar sein
# SCHLECHT: ActiveRecord Objekte übergeben
SendEmailJob.perform_async(@user) # Serialisiert das ganze Objekt!
# GUT: IDs übergeben
SendEmailJob.perform_async(@user.id)
Sidekiq serialisiert Job Argumente nach JSON. ActiveRecord Objekte werden riesige Datenklumpen. Schlimmer, bis der Job läuft, könnte das Objekt veraltet sein. Übergebe immer IDs und hole frische Daten im Job.
Lektion 2: Jobs sollten schnell einzureihen sein
# SCHLECHT: Langsam einzureihen
def create
@order = Order.create!(order_params)
# Das trifft die Datenbank 1000 mal beim Einreihen!
@order.line_items.each do |item|
UpdateInventoryJob.perform_async(item.id)
end
end
# GUT: Bulk einreihen
def create
@order = Order.create!(order_params)
# Einzelner Job der alle Items behandelt
UpdateOrderInventoryJob.perform_async(@order.id)
end
Lektion 3: Teste deine Jobs
# spec/jobs/send_welcome_email_job_spec.rb
require 'rails_helper'
RSpec.describe SendWelcomeEmailJob, type: :job do
describe '#perform' do
let(:user) { create(:user) }
it 'sendet Willkommens Email' do
expect {
described_class.new.perform(user.id)
}.to change { ActionMailer::Base.deliveries.count }.by(1)
end
it 'handhabt fehlenden User gnädig' do
expect {
described_class.new.perform(999999)
}.not_to raise_error
end
it 'ist idempotent' do
described_class.new.perform(user.id)
expect {
described_class.new.perform(user.id)
}.not_to change { ActionMailer::Base.deliveries.count }
end
end
end
Fazit
Background Workers sind nicht optional für moderne Webanwendungen. Aber sie sind auch eine signifikante Quelle von Komplexität und potenziellen Fehlern. Der Unterschied zwischen einem robusten System und einem Kartenhaus kommt darauf an wie gut du deine Jobs designst.
Denk dran:
Jobs müssen unabhängig sein. Verlasse dich nicht darauf dass andere Jobs vorher gelaufen sind.
Jobs müssen idempotent sein. Zweimal laufen sollte denselben Effekt haben wie einmal laufen.
Jobs müssen Fehler gnädig behandeln. Weil Fehler keine Frage des Ob sind, sondern des Wann.
Queue Design ist wichtig. Trenne kritische Jobs von Massenverarbeitung.
Monitore alles. Du kannst nicht fixen was du nicht sehen kannst.
Ich habe zu viele Anwendungen gesehen die von schlecht designten Background Jobs lahmgelegt wurden. Der Black Friday Ausfall den ich am Anfang erwähnt habe? Er war komplett vermeidbar. Ein paar Guards, einige Retry Limits, und ordentliche Queue Isolation hätten das System am Laufen gehalten.
Lerne diese Lektionen nicht auf die harte Tour. Designe deine Jobs von Anfang an richtig.
Ich betreibe Sidekiq in Produktion seit über einem Jahrzehnt quer durch Anwendungen die Millionen von Jobs pro Tag verarbeiten. Die Patterns in diesem Post sind kampferprobt. Die Horror Stories sind echt.
Brauchst du Hilfe beim Designen einer robusten Background Job Architektur? Oder beim Debuggen mysteriöser Job Fehler? Ich habe alles gesehen und kann dir helfen Systeme zu bauen die oben bleiben wenn Dinge schief gehen. Lass uns quatschen.