Mert Özen Her Satırda Daha İleri
Veritabanı Redis — #05

Redis List ve Set: Basit Queue, Reliable Queue, Son Görüntülenenler ve Etiketler

Mert Özen 14 Kas 2026 11 dk 8 görüntülenme
Redis List ve Set: Basit Queue, Reliable Queue, Son Görüntülenenler ve Etiketler

RPOP ile kuyruktan iş alan bir worker çökerse iş kaybolur; LMOVE işi aynı atomik adımda bir processing list'ine taşır ve çökmeden sonra geri alınabilir kılar. Gerçek çıktılarla reliable queue ve sınırları, Redis 8.10'un LMOVEM'i, paylaşılan multiplexer'da BLPOP'un uygulamayı 3 saniye dondurması, LTRIM ile son görüntülenen 10 kitap, intset'teki kitap etiketleri ve SINTER, SUNIONCARD ile haftalık tekil ziyaretçi ve büyük set'te 67 ms'lik SMEMBERS.

Sürüm notu: Bu yazıdaki örnekler Redis 8.10.3 (redis:8), Valkey 9.1.3, .NET 10 ve StackExchange.Redis 3.4.35 ile çalıştırılmıştır.

Önce Tahmin Et

Bookstore'da (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) sipariş onay e-postaları bir Redis list'i üzerinden gidiyor: API sipariş numarasını LPUSH ile kuyruğa koyuyor, bir worker RPOP ile alıp e-postayı gönderiyor. Bir gün SMTP (e-posta gönderme protokolü) sunucusuna ulaşılamıyor ve worker, order-1002'yi kuyruktan aldıktan hemen sonra çöküyor:

RedisValue job = reliable
    ? await db.ListMoveAsync(Queue, Processing, ListSide.Right, ListSide.Left) // LMOVE
    : await db.ListRightPopAsync(Queue);                                      // RPOP
if (job.IsNull)
{
    break;
}
if (job == "order-1002")
{
    throw new InvalidOperationException("SMTP server unreachable, worker crashed");
}
await SendEmailAsync(job);
if (reliable)
{
    await db.ListRemoveAsync(Processing, job, count: 1); // ack: LREM
}

Kuyrukta üç iş vardı. Worker yeniden başladığında order-1002 nerede olur? Aynı senaryoyu RPOP yerine LMOVE ile çalıştırınca ne değişir?

dotnet run --project src/Bookstore.RedisLab -- queue-crash
      sent e-mail for order-1001
RPOP  worker: SMTP server unreachable, worker crashed
      queue: [order-1003]  processing: []
      sent e-mail for order-1001
LMOVE worker: SMTP server unreachable, worker crashed
      queue: [order-1003]  processing: [order-1002]

RPOP ile order-1002 hiçbir yerde yok: Redis işi worker'a verdiği anda list'ten sildi, worker da işi bitiremeden öldü. Müşteri onay e-postasını hiç almayacak ve kimse fark etmeyecek. LMOVE ile iş, kuyruktan alınırken aynı atomik adımda bir processing list'ine taşındı; çökmeden sonra orada duruyor ve tekrar işlenebilir. Bu yazıda list ve set'i Bookstore'un gerçek ihtiyaçlarıyla anlatıyoruz: basit ve güvenilir kuyruk, blocking komutların StackExchange.Redis'teki durumu, "son görüntülenen 10 kitap", kitap etiketleri ve günlük tekil ziyaretçi sayısı.

List Komutları

Redis list'i, iki ucundan ekleme ve çıkarma yapılabilen sıralı bir string dizisi. Uçlardaki işlemler O(1); ortadaki bir elemana erişmek ya da list'i taramak O(N):

127.0.0.1:6379> LPUSH bookstore:queue:emails order-1001 order-1002 order-1003 order-1004
(integer) 4
127.0.0.1:6379> LRANGE bookstore:queue:emails 0 -1
1) "order-1004"
2) "order-1003"
3) "order-1002"
4) "order-1001"
127.0.0.1:6379> RPOP bookstore:queue:emails
"order-1001"
127.0.0.1:6379> LLEN bookstore:queue:emails
(integer) 3
127.0.0.1:6379> LMOVE bookstore:queue:emails bookstore:queue:emails:processing RIGHT LEFT
"order-1002"
127.0.0.1:6379> LREM bookstore:queue:emails:processing 1 order-1002
(integer) 1

LPUSH soldan eklediği için RPOP ile birlikte bir FIFO (ilk giren ilk çıkar) kuyruğu oluşur. LRANGE key 0 -1 bütün list'i getirir; küçük list'lerde sorun değil, büyüklerde 04'teki HGETALL ile aynı riski taşır. Küçük list'ler tek bir listpack'te durur; büyüyünce quicklist'e, yani listpack'lerden oluşan bağlı bir listeye geçer. Sınırı list-max-listpack-size belirler; varsayılan -2 her düğümün 8 KB'ı geçmemesi demek.

Güvenilir Kuyruk: LMOVE, Ack ve Reaper

Önce Tahmin Et'teki LMOVE akışı bilinen reliable queue pattern'i. Üç parçası var. Alma: worker işi LMOVE queue processing RIGHT LEFT ile alır; iş, Redis'in tek komutu içinde bir list'ten diğerine geçer, arada kaybolacağı bir an yoktur. Onay (ack): iş bitince worker onu LREM processing 1 job ile processing'den siler. Toparlama (reaper): ayrı bir görev, processing'de çok uzun kalan işleri kuyruğa geri taşır, çünkü onları alan worker büyük ihtimalle öldü.

Burada pattern'in sınırları da görünüyor. Bir işin processing'e ne zaman girdiği list'te yazmıyor; reaper'ın "çok uzun kaldı" diyebilmesi için zamanı ayrıca (örneğin bir hash'te) tutman gerekiyor. Kaç kez denendiği, hangi worker'da olduğu da yazmıyor; sürekli hata veren bir iş sonsuza kadar dönüp durabilir. Aynı mesajı birden fazla worker grubuna dağıtmak da mümkün değil. Bunların hepsini Redis Streams consumer group, pending entry list ve XAUTOCLAIM ile hazır veriyor; 10'da işliyoruz. List ile kuyruk, kaybın bir ölçüde tolere edildiği ya da çok basit iş akışları için uygun.

Redis 8.10 bu akışa toplu taşıma ekledi: LMOVEM tek komutla birden fazla elemanı taşıyor. Worker on işi tek seferde alabiliyor:

127.0.0.1:6379> RPUSH a j1 j2 j3 j4 j5
(integer) 5
127.0.0.1:6379> LMOVEM a b RIGHT LEFT COUNT 3 BULK
1) "j3"
2) "j4"
3) "j5"
127.0.0.1:6379> LMOVEM a b RIGHT LEFT EXACTLY 5 OBO
(nil)
127.0.0.1:6379> LMOVEM a b RIGHT LEFT COUNT 5 OBO
1) "j1"
2) "j2"
127.0.0.1:6379> EXISTS a
(integer) 0

COUNT n en fazla n eleman taşır, EXACTLY n ya tam n tane taşır ya hiç taşımaz. BULK elemanları blok hâlinde, kaynaktaki sıralarını koruyarak taşır; OBO (one by one) tek tek LMOVE yapılmış gibi davranır. Bu sürümde iki seçenekten birini yazmak zorunlu; COUNT 10'u sıra belirtmeden yazınca ERR syntax error aldım. StackExchange.Redis'te ListMoveAsync'in ListMoveCount ve ListMoveOrder parametreli overload'u bu komutu gönderiyor. LMOVEM Valkey'de yok.

Blocking Komutlar ve StackExchange.Redis

Kuyruk boşken worker ne yapacak? Redis'in cevabı blocking komutlar: BLPOP, BRPOP, BLMOVE (ve 8.10 ile BLMOVEM) kuyruk boşsa bağlantıyı bir eleman gelene ya da süre dolana kadar bekletir. Sorun şu ki StackExchange.Redis'te bütün uygulama tek bir bağlantıyı paylaşıyor (02). Paketin dokümanı blocking pop'ları bu yüzden sunmadığını ve sunmayacağını yazıyor. ExecuteAsync ile yine de gönderilebiliyor; ne olduğuna bakalım:

Task<RedisResult> blpop = db.ExecuteAsync("BLPOP", Queue, 3); // kuyruk boş: en fazla 3 saniye bekler
await Task.Delay(100);
TimeSpan ping = await db.PingAsync();                            // başka bir çağıran, aynı multiplexer
Console.WriteLine($"PING from another caller took {ping.TotalMilliseconds:N0} ms");
PING from another caller took 2,989 ms
BLPOP returned nil after 3,095 ms

Uygulamanın geri kalanı 3 saniye boyunca Redis'ten tek bir cevap alamadı; PING bile BLPOP'un süresinin dolmasını bekledi. Bir API'de bu, bütün isteklerin aynı anda donması demek. Dokümanın önerisi blocking pop yerine pub/sub kullanmak: veri yine list'te durur, pub/sub yalnız worker'lara "kuyrukta iş var" sinyali gönderir; worker'lar sinyal gelince normal LMOVE ile alır. Daha basit bir yol, kuyruk boşken artan aralıklarla yoklamak (polling). Ama yukarıdaki sınırlarla birlikte düşününce, worker'lı bir kuyruk ihtiyacında doğru araç genelde Streams.

Bookstore: Son Görüntülenen 10 Kitap

List'in kuyruk dışındaki en yaygın kullanımı sınırlı uzunlukta bir geçmiş: müşterinin son baktığı kitaplar. Her kitap sayfası açıldığında kitabı listenin başına koyuyoruz; aynı kitap daha önce varsa önce onu çıkarıyoruz ve list'i 10 elemanla sınırlıyoruz:

IDatabase db = redis.GetDatabase();
RedisKey key = RedisKeys.RecentlyViewed(customerId);

// Kitap zaten varsa çıkar, başa ekle, yalnız en yeni 10'u tut (pipeline, atomik değil)
await Task.WhenAll(
    db.ListRemoveAsync(key, request.Isbn),               // LREM key 0 isbn
    db.ListLeftPushAsync(key, request.Isbn),             // LPUSH
    db.ListTrimAsync(key, 0, MaxItems - 1),              // LTRIM key 0 9
    db.KeyExpireAsync(key, Lifetime));
# 9780441013593, 9780451524935, 9780141439518 ve tekrar 9780441013593 görüntülendi
curl http://localhost:5080/customers/7/recently-viewed
["9780441013593","9780141439518","9780451524935"]

# 12 farklı kitap daha görüntülendi
curl http://localhost:5080/customers/7/recently-viewed
["9780000000012","9780000000011","9780000000010","9780000000009","9780000000008","9780000000007","9780000000006","9780000000005","9780000000004","9780000000003"]
docker compose -f lab/compose.yaml exec redis redis-cli LLEN bookstore:customer:7:recent
(integer) 10

İkinci kez görüntülenen kitap listede tekrar etmedi, başa geçti; 15 görüntülemeden sonra list 10 elemanda kaldı. LTRIM, capped list (uzunluğu sınırlı list) için temel araç: LPUSH'tan hemen sonra çağrıldığında list hiçbir zaman sınırın bir fazlasından uzun olmaz. Dört komut aynı pipeline'da sırayla çalışıyor, ama atomik değil: aynı müşterinin iki sekmesinden aynı anda gelen iki istek arasına girebilir. Burada en kötü sonuç bir an için tekrar eden bir kitap; bir sonraki istek düzeltir. Tam atomiklik gereken yerde MULTI/EXEC veya Lua (08) var. LREM list'i baştan sona tarar; 10 elemanlı bir list için önemsiz, ama binlerce elemanlı list'lerde LREM ve LPOS'un O(N) olduğunu unutma.

Set Komutları ve Kitap Etiketleri

Set, sırasız ve tekrarsız bir string kümesi. Üyelik kontrolü O(1), kümeler arası işlemler (kesişim, birleşim, fark) sunucuda yapılıyor. Bookstore'da her etiket bir set: bookstore:tag:classic, içinde o etiketi taşıyan kitapların ISBN'leri (kitabın 13 haneli uluslararası numarası):

127.0.0.1:6379> SADD bookstore:tag:classic 9780141439518 9780451524935 9780547928227 9780141439549
(integer) 4
127.0.0.1:6379> SADD bookstore:tag:science-fiction 9780441013593 9780451524935 9780553293357
(integer) 3
127.0.0.1:6379> SADD bookstore:tag:dystopia 9780451524935 9780060850524
(integer) 2
127.0.0.1:6379> SMISMEMBER bookstore:tag:classic 9780141439518 9780441013593
1) (integer) 1
2) (integer) 0
127.0.0.1:6379> SINTER bookstore:tag:classic bookstore:tag:science-fiction
1) "9780451524935"
127.0.0.1:6379> SINTERCARD 2 bookstore:tag:classic bookstore:tag:science-fiction
(integer) 1
127.0.0.1:6379> SUNIONCARD 2 bookstore:tag:science-fiction bookstore:tag:dystopia
(integer) 4
127.0.0.1:6379> SDIFF bookstore:tag:science-fiction bookstore:tag:classic
1) "9780441013593"
2) "9780553293357"
127.0.0.1:6379> SDIFFCARD 2 bookstore:tag:science-fiction bookstore:tag:classic
(integer) 2
127.0.0.1:6379> OBJECT ENCODING bookstore:tag:classic
"intset"

Sonuçtaki *CARD komutları kümeyi oluşturup göndermeden yalnız eleman sayısını döndürüyor. SINTERCARD Redis 7.0'dan beri var ve LIMIT ile "en az 3 ortak kitap var mı?" gibi soruları sayım bitmeden cevaplayabiliyor. Birleşim ve farkın karşılıkları SUNIONCARD ve SDIFFCARD ise Redis 8.10 ile geldi; Valkey 9.1.3'te ikisi de ERR unknown command döndürdü. Encoding'deki intset küçük bir sürpriz: ISBN-13'ler 64 bit tam sayıya sığdığı için Redis bu küçük set'i sıralı bir tam sayı dizisi olarak tutuyor. Sınırlar set-max-intset-entries (512), set-max-listpack-entries (128) ve set-max-listpack-value (64); sayı olmayan bir üye ya da sınırı aşan bir boyut set'i önce listpack'e, sonra hashtable'a çevirir.

Bookstore.Api'de iki endpoint bu set'leri kullanıyor: verilen etiketlerin hepsini taşıyan kitaplar (SINTER ve sayısı için SINTERCARD) ve "bunlar da ilgini çekebilir" kutusu için rastgele öneriler (SRANDMEMBER):

// Verilen etiketlerin hepsini taşıyan kitaplar: SINTER; yalnız sayısı: SINTERCARD
app.MapGet("/books/by-tags", async (string[] tag, IConnectionMultiplexer redis) =>
{
    IDatabase db = redis.GetDatabase();
    RedisKey[] keys = [.. tag.Select(RedisKeys.Tag)];
    Task<RedisValue[]> books = db.SetCombineAsync(SetOperation.Intersect, keys);
    Task<long> count = db.SetIntersectionLengthAsync(keys);
    await Task.WhenAll(books, count);
    return Results.Ok(new { tags = tag, count = count.Result, isbns = books.Result.Select(b => b.ToString()) });
});

// "Bunlar da ilgini çekebilir" kutusu için bir etiketten rastgele birkaç kitap: SRANDMEMBER
app.MapGet("/books/suggestions", async (string tag, int count, IConnectionMultiplexer redis) =>
{
    RedisValue[] isbns = await redis.GetDatabase().SetRandomMembersAsync(RedisKeys.Tag(tag), count);
    return Results.Ok(isbns.Select(i => i.ToString()));
});
curl "http://localhost:5080/books/by-tags?tag=classic&tag=science-fiction"
{"tags":["classic","science-fiction"],"count":1,"isbns":["9780451524935"]}
curl "http://localhost:5080/books/suggestions?tag=classic&count=2"
["9780141439518","9780451524935"]
curl "http://localhost:5080/books/suggestions?tag=classic&count=2"
["9780547928227","9780451524935"]

Pozitif count ile SRANDMEMBER birbirinden farklı üyeler döndürür; negatif count aynı üyenin tekrar gelmesine izin verir. Üyeyi kümeden çıkararak rastgele almak istersen (örneğin bir çekiliş) SPOP var.

Günlük Tekil Ziyaretçi

Set'in en doğal kullanımlarından biri tekil sayım: aynı müşteri gün içinde 20 kez gelse de bir kez sayılsın. Her gün için bir set tutuyoruz ve SADD'in dönüş değeri bize "bugünkü ilk ziyaret mi?" sorusunun cevabını da veriyor. Haftalık tekil ziyaretçi için yedi günün birleşiminin büyüklüğü gerekiyor; SUNIONCARD bunu birleşimi oluşturmadan veriyor:

// Son 7 günün tekil ziyaretçisi, birleşimi oluşturmadan: SUNIONCARD (Redis 8.10+)
app.MapGet("/visits/weekly", async (IConnectionMultiplexer redis, TimeProvider clock) =>
{
    DateOnly today = DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime);
    RedisKey[] days = [.. Enumerable.Range(0, 7).Select(i => RedisKeys.DailyVisitors(today.AddDays(-i)))];
    long unique = await redis.GetDatabase().SetCombineLengthAsync(SetOperation.Union, days);
    return Results.Ok(new { from = today.AddDays(-6), to = today, uniqueVisitors = unique });
});

Ölçeği görmek için 50.000 müşterilik bir havuzdan günde 20.000 ziyaret ürettim ve yedi günlük set'leri doldurdum:

dotnet run --project src/Bookstore.RedisLab -- visitors
bookstore:visitors:20261108: 16,429 unique visitors, 622 KB, encoding=hashtable
7 days, SUNIONCARD        : 46,925 unique visitors (11.9 ms)
7 days, SUNIONCARD APPROX : 47,131 unique visitors (4.3 ms)

Tek bir günün set'i 622 KB tutuyor; 16.429 üyeyle intset sınırını çoktan aşmış ve hashtable'da. Kesin haftalık sayı 46.925. APPROX seçeneği sayımı HyperLogLog (küçük ve sabit bellekle yaklaşık tekil sayım yapan veri yapısı) tabanlı bir tahminle yapıyor: 47.131, yani yüzde 0,44 hata ile üçte bir sürede. StackExchange.Redis'te SetCombineLengthAsync'in approximate: true parametresi bunu gönderiyor. Günde milyonlarca ziyaretçide set'lerin belleği ciddi bir maliyete dönüşür; aynı sayımı 12 KB'lık HyperLogLog'larla yapmayı ve aradaki bellek farkını 07'de ölçeceğiz.

Üretimde Dikkat

List ve set'lerin büyüklüğünü sınırla ya da en azından izle. Bir milyon üyeli bir set'te ölçtüm:

SMEMBERS: 1,000,000 members in 287 ms
SSCAN:    1,000,000 members in 596 ms
slowlog: SMEMBERS 66.8 ms

SMEMBERS sunucuyu 67 ms kilitledi; SSCAN (SetScanAsync) SLOWLOG'a hiç düşmedi. Aynı uyarı büyük kümeler üzerinde SINTER, SUNION ve SDIFF için de geçerli: karmaşıklıkları eleman sayısıyla büyür. Yalnız sayı gerekiyorsa *CARD sürümlerini ve LIMIT'i kullan. Kuyruk olarak kullanılan list'lerde kuyruk uzunluğunu (LLEN) ve processing list'inin boyutunu metric olarak izle; büyüyen bir processing list'i ölen worker'ların işaretidir. Kuyruk veya ziyaretçi set'i tutan bir Redis'te eviction policy'nin bu key'leri silmeyecek şekilde ayarlandığından emin ol; bunu 12'de ayrıntılı işleyeceğiz.

Sık Düşülen Tuzaklar

List'i dayanıklı iş kuyruğu sanmak: RPOP ile alınan iş, worker çökerse kaybolur. LMOVE ile processing list'i bir adım ileri, ama retry sayısı, zaman aşımı ve consumer takibi yok; bunlar Streams'te (10).

BLPOP'u paylaşılan multiplexer üzerinden göndermek: bütün uygulama 3 saniye boyunca Redis'ten cevap alamadı.

Büyük set'te SMEMBERS: bir milyon üye 67 ms boyunca sunucuyu bekletti. SSCAN ya da *CARD komutlarını kullan.

Capped list'te LTRIM'i unutmak: "son görüntülenenler" list'i her müşteri için sınırsız büyür ve bellek sızıntısına dönüşür.

Redis 8.10'un yeni komutlarını (LMOVEM, SUNIONCARD, SDIFFCARD) Valkey'de ya da daha eski bir managed Redis'te varsaymak.

Küçük Bir Deneme

★☆☆ bookstore:tag:classic set'ine sayı olmayan bir üye ekle (örneğin draft-42) ve OBJECT ENCODING'e tekrar bak. Sonra 130 üye daha ekle: encoding hangi noktada bir kez daha değişiyor?

★★☆ Reliable queue'ya bir reaper ekle: işi processing'e alırken alındığı zamanı bir hash'e yaz (HSET bookstore:queue:emails:taken order-1002 <unix-ms>), reaper 60 saniyeden eski işleri LMOVE ile kuyruğa geri taşısın. Reaper ile worker aynı anda aynı işe dokunursa ne olur?

★★★ visitors deneyini günde 200.000 ziyaretle tekrarla. Tek bir günün set'i ne kadar bellek tutuyor? SUNIONCARD ile SUNIONCARD … APPROX arasındaki süre ve hata farkı nasıl değişiyor?

Bir sonraki yazıda sorted set'e geçiyoruz: haftanın çok satanları, skora göre sayfalama, eşit skorda deterministik sıralama ve zaman damgasını skor olarak kullanan sliding window'lar.