Kolejka Redis w Lua: Gwarantowany porządek w partycjach ze wspólną pulą workerów
W systemach o wysokim obciążeniu przetwarzanie wiadomości wymaga ścisłej kolejności wewnątrz logicznych partycji przy setkach tysięcy kluczy. Ograniczona pula workerów musi równomiernie rozkładać obciążenie, bez blokowania przez ciężkie partycje. Obsługa ponownych prób i zadań odroczonych jest realizowana przez Redis ze skryptami Lua w bibliotece smart-redis-queue.
Rozwiązanie wykorzystuje atomowość Lua do operacji na ZSET, HASH i STRING bez wyścigów. Klucze są tworzone z prefiksem queue:{queueName}:. Partycje uporządkowane (kod z prefiksem !) są przetwarzane wyłącznie z blokadą, zwykłe — równolegle.
Porównanie z Kafka i RabbitMQ
Kafka zapewnia porządek w partycjach tematów i skalowanie grup konsumentów, ale ustala przypisanie partycji do konsumentów. Dynamiczna pula workerów, pobierająca zadania z dowolnych gotowych partycji, wymaga innej semantyki. TTL w Kafka nie zastępuje precyzyjnego odłożenia do czasu T.
RabbitMQ obsługuje TTL i DLQ, ale ścisła kolejność przy wielu konsumentach jest naruszana przez round-robin. Oddzielne kolejki na miliony kluczy nie skalują się.
Redis + Lua daje lekki wariant: jedno round-trip na operację atomową, bez klastra i skomplikowanego monitoringu.
Struktura kluczy
| Klucz | Struktura | Przeznaczenie |
|------|-----------|------------|
| queue:{name}:partitions | SET | Aktywne partycje |
| queue:{name}:partition:{code}:{priority} | ZSET | Kolejka zadań według priorytetu, score = czas dostępności (ms) |
| queue:{name}:partition:{code}:priorities | ZSET | Poziomy priorytetów |
| queue:{name}:payload:{taskId} | STRING | Ładunek |
| queue:{name}:partition:{taskId} | STRING | Kod partycji |
| queue:{name}:consumer:{id}:tasks | HASH | Zadania w trakcie przetwarzania |
| queue:{name}:partition:{code}:lock | STRING | Blokada partycji uporządkowanej |
| queue:{name}:partition:{code}:block | STRING (TTL) | Tymczasowa blokada (rate limit) |
Cykl życia zadania
- Publish: Idempotentne dodanie z
SET NX, zapis do ZSET z score = scheduled (ms). Partycja dodawana do SET.
local ok = redis.call('SET', payloadKey, payload, 'NX')
if ok then
redis.call('ZADD', partitionQueueKey, scheduled, taskId)
redis.call('SADD', partitionsKey, partitionCode)
end
- Get: Heartbeat, iteracja po partycjach, sprawdzenie lock/block dla uporządkowanych. Pobieranie według malejących priorytetów (
ZREVRANGE), głowa ZSET jeśli scheduled <= now. Przejście do HASH konsumenta.
- Ack: Usunięcie z HASH, payload, kluczy. Zdjęcie lock jeśli licznik uporządkowanych = 0.
- Reject: Zwrot do ZSET z nowym score. Dla uporządkowanych — priorytet +1, reject_seq dla tie-break. Consume odrzuca całą partię partycji uporządkowanej przy reject pierwszego zadania.
Benchmarki wydajności
Testy na Go 1.22+, Redis 5, Intel i7-13700H.
Publikacja (zadań/s):
- 1 zadanie: 17–23 tys.
- Partia 10: 80–92 tys.
- Partia 100: 100–117 tys.
Partiowanie zwiększa przepustowość dzięki jednemu wywołaniu Lua.
Pobieranie przez jednego workera (zadań/s):
| Prefetch | ns/op | Zadania/s |
|----------|--------|---------|
| 1 | 80–103 µs | 9.7–12.5 tys. |
| 5 | 57–67 µs | 15–17.5 tys. |
Równolegle:
- 2 workery: 28–30 tys./s
- 8 workerów: 23–26 tys./s
Skalowanie nieliniowe z powodu konkurencji w Redis.
Obsługa awarii
- Heartbeat z TTL ~120 s.
- Żywy konsument skanuje martwe przez distributed lock.
- Zadania z HASH martwego wracają do kolejki (uporządkowane z +priorytetem).
Rate limit: RejectWithDelay ustawia block z TTL.
Rekomendacja: krok priorytetów 100+ dla uniknięcia kolizji z inkrementami reject.
Co jest ważne
- Atomowość Lua zapewnia brak wyścigów przy przenoszeniu zadań między ZSET i HASH.
- Partycje uporządkowane zachowują kolejność przez wyłączny lock i logikę reject.
- Partiowanie w Publish/Get podnosi przepustowość do 100+ tys. zadań/s.
- Automatyczne odzyskiwanie po upadku workerów bez utraty zadań.
- Nadaje się do scenariuszy z milionami partycji bez overhead klastra.
— Editorial Team
Brak komentarzy.