Redis fronta v Lua: zaručené pořadí v oddílech se sdíleným fondem workerů
Ve vysoce zatížených systémech zpracování zpráv je nutný striktní pořádek uvnitř logických oddílů při statisících klíčů. Omezený fond workerů musí rovnoměrně rozdělovat zátěž, bez blokování těžkými oddíly. Podpora opakování a odložených úloh je implementována přes Redis s Lua skripty v knihovně smart-redis-queue.
Řešení využívá atomičnost Lua pro operace nad ZSET, HASH a STRING bez závodů. Klíče jsou tvořeny s prefixem queue:{queueName}:. Řazené oddíly (kód s prefixem !) jsou zpracovávány exkluzivně se zámkem, běžné – paralelně.
Srovnání s Kafka a RabbitMQ
Kafka zajišťuje pořadí v oddílech témat a škálování skupin konzumentů, ale fixuje rozdělení oddílů mezi konzumenty. Dynamický fond workerů, který bere úlohy z jakýchkoli připravených oddílů, vyžaduje jinou sémantiku. TTL v Kafka nenahrazuje přesné odložení do času T.
RabbitMQ podporuje TTL a DLQ, ale striktní pořadí při více konzumentech je narušeno round-robin. Samostatné fronty na miliony klíčů neškálují.
Redis + Lua nabízí lehkou variantu: jeden round-trip na atomickou operaci, bez clusteru a složitého monitoringu.
Struktura klíčů
| Klíč | Struktura | Účel |
|------|-----------|------------|
| queue:{name}:partitions | SET | Živé oddíly |
| queue:{name}:partition:{code}:{priority} | ZSET | Fronta úloh podle priority, score = čas dostupnosti (ms) |
| queue:{name}:partition:{code}:priorities | ZSET | Úrovně priorit |
| queue:{name}:payload:{taskId} | STRING | Zátěž |
| queue:{name}:partition:{taskId} | STRING | Kód oddílu |
| queue:{name}:consumer:{id}:tasks | HASH | Úlohy ve zpracování |
| queue:{name}:partition:{code}:lock | STRING | Zámek řazeného oddílu |
| queue:{name}:partition:{code}:block | STRING (TTL) | Dočasný blok (rate limit) |
Životní cyklus úlohy
- Publikování: Idempotentní přidání s
SET NX, zápis do ZSET s score = naplánováno (ms). Oddíl je přidán 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
- Získání: Heartbeat, iterace přes oddíly, kontrola lock/block pro řazené. Brát podle klesající priority (
ZREVRANGE), hlava ZSET pokud scheduled <= now. Přechod do HASH konzumenta.
- Potvrzení: Odstranění z HASH, payload, klíčů. Uvolnění lock pokud čítač řazených = 0.
- Odmítnutí: Návrat do ZSET s novým score. Pro řazené – priorita +1, reject_seq pro tie-break. Consume odmítá celou dávku řazeného oddílu při reject první úlohy.
Benchmarky výkonu
Testy na Go 1.22+, Redis 5, Intel i7-13700H.
Publikování (úloh/s):
- 1 úloha: 17–23 tis.
- Dávka 10: 80–92 tis.
- Dávka 100: 100–117 tis.
Dávkování zvyšuje throughput díky jednomu Lua volání.
Spotřeba jedním workerem (úloh/s):
| Prefetch | ns/op | Úloh/s |
|----------|--------|---------|
| 1 | 80–103 µs | 9.7–12.5 tis. |
| 5 | 57–67 µs | 15–17.5 tis. |
Paralelně:
- 2 workeři: 28–30 tis./s
- 8 workerů: 23–26 tis./s
Škálování není lineární kvůli konkurenci v Redis.
Zpracování selhání
- Heartbeat s TTL ~120 s.
- Živý konzument skenuje mrtvé přes distributed lock.
- Úlohy z HASH mrtvého jsou vráceny do fronty (řazené s +prioritou).
Rate limit: RejectWithDelay nastaví block s TTL.
Doporučení: krok priorit 100+ pro vyhnutí kolizím s reject-inkrementy.
Co je důležité
- Atomičnost Lua zajišťuje absenci závodů při přesunu úloh mezi ZSET a HASH.
- Řazené oddíly zachovávají pořadí přes exkluzivní lock a reject-logiku.
- Dávkování v Publish/Get zvyšuje propustnost na 100+ tis. úloh/s.
- Automatické obnovení po pádu workerů bez ztráty úloh.
- Vhodné pro scénáře s miliony oddílů bez overhead clusteru.
— Editorial Team
Zatím žádné komentáře.