Redis Sorted Set: Leaderboard, Zaman Pencereli Sıralama ve Sayfalama
Redis sorted set'i her member'ı bir skorla tutar ve her an sıralı durur; leaderboard'da rank ve sayfa sorguları bir milyon member'da bile mikrosaniyeler sürer, ama canlı listede rank'e göre sayfalama iki istek arasında kayar. Gerçek çıktılarla ZADD seçenekleri, ZRANGE ile sayfalama, günlük set'lerden ZUNIONSTORE ve Redis 8.8'in COUNT'uyla haftanın çok satanları, eşit skorların sırası, 2^53 hassasiyet sınırı ve zaman damgasını skor yapan kayan pencereler.
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'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) ana sayfasında "bugünün çok satanları" listesi var ve sayfa sayfa geziliyor. Liste bir Redis sorted set'inden okunuyor: her kitap bir member, sattığı adet skoru. Altı kitap var, sayfa başına üç kitap gösteriliyor. Müşteri birinci sayfayı açıyor; o sırada book-5 toplu bir siparişle 50 adet satıyor; müşteri ikinci sayfaya geçiyor:
RedisValue[] page1 = await db.SortedSetRangeByRankAsync(source, 0, 2, Order.Descending);
await db.SortedSetIncrementAsync(live, "book-5", 50); // iki istek arasında bir satış
RedisValue[] page2 = await db.SortedSetRangeByRankAsync(source, 3, 5, Order.Descending);
Skorlar başta book-1 için 60'tan başlayıp onar azalıyor, book-5'in skoru 20. Müşteri iki sayfada toplam hangi kitapları görür? Aynı akışı listenin o anki bir kopyası (snapshot) üzerinden çalıştırınca ne değişir?
dotnet run --project src/Bookstore.RedisLab -- page-drift
live page 1: book-1, book-2, book-3 page 2: book-3, book-4, book-6
snapshot page 1: book-1, book-2, book-3 page 2: book-4, book-5, book-6
Canlı listede book-3 iki kez göründü, book-5 ise hiç görünmedi. 70 skorla birinci sıraya çıktı, ama birinci sayfa çoktan okunmuştu. Rank'e göre sayfalama, iki istek arasında sıralama değişirse kayar. Snapshot'ta liste sabit kaldığı için iki sayfa birlikte altı kitabın hepsini bir kez gösterdi. Bu yazıda sorted set'i bu tür gerçek sorularla anlatıyoruz: skor ve member modeli, ZADD seçenekleri, sıralama ve sayfalama, günlük listeleri birleştirerek kayan 7 günlük çok satanlar, eşit skorların sırası, skorun hassasiyet sınırı ve zaman damgasını skor olarak kullanan zaman pencereleri.
Skor ve Member: Sorted Set'in Modeli
Sorted set, 05'teki set gibi tekrarsız bir string kümesi; farkı her member'ın yanında bir skor (64 bit kayan noktalı sayı) tutması ve kümenin her an bu skora göre sıralı durması. Bir member'ın skorunu değiştirdiğinde Redis onu hemen doğru yere taşır; okurken sıralama yapılmaz, sıra zaten hazırdır:
127.0.0.1:6379> ZADD bookstore:bestsellers:20261101 12 9780441013593 7 9780451524935 7 9780141439518 3 9780547928227
(integer) 4
127.0.0.1:6379> ZINCRBY bookstore:bestsellers:20261101 2 9780547928227
"5"
127.0.0.1:6379> ZRANGE bookstore:bestsellers:20261101 0 2 REV WITHSCORES
1) "9780441013593"
2) "12"
3) "9780451524935"
4) "7"
5) "9780141439518"
6) "7"
127.0.0.1:6379> ZRANK bookstore:bestsellers:20261101 9780441013593 WITHSCORE
1) (integer) 3
2) "12"
127.0.0.1:6379> ZREVRANK bookstore:bestsellers:20261101 9780441013593
(integer) 0
127.0.0.1:6379> ZSCORE bookstore:bestsellers:20261101 9780141439518
"7"
Kitaplar ISBN'leriyle (kitabın 13 haneli uluslararası numarası) member, satış adetleriyle skor. ZINCRBY skoru artırır ve member yoksa 0'dan başlatır; bir satışı kaydetmek için tek komut yeter, 03'teki INCR gibi atomik. ZRANK küçükten büyüğe sıradaki yeri (0'dan başlayarak) verir, ZREVRANK büyükten küçüğe. Leaderboard'da istenen genelde ikincisi: Dune (9780441013593) 0. sırada, yani birinci. WITHSCORE seçeneği Redis 7.2'den beri rank ile skoru tek seferde döndürüyor. Bu yazıdaki redis-cli komutları snapshot'ta lab/scripts/ altında; docker compose -f lab/compose.yaml exec -T redis redis-cli < lab/scripts/06-zset.txt ile hepsini sırayla çalıştırabilirsin.
Çıktıda dikkat edilecek bir yer var: 9780451524935 ile 9780141439518 aynı skora (7) sahip. Redis eşit skorlu member'ları byte byte karşılaştırarak sıralar (lexicographic sıralama); REV ile bu sıra da ters döner, bu yüzden büyük olan 9780451524935 önce geldi. Sonuç deterministik, her seferinde aynı; ama iş kuralına göre değil, member'ın metnine göre. Buna aşağıda Bookstore'un haftalık listesinde döneceğiz.
İçeride sorted set iki yapıyla tutuluyor: member'dan skora O(1) erişim için bir hash table ve sıralı gezinme için bir skip list (her düğümün birkaç seviyeli ileri bağlantısı olan, O(log N) arama yapan bağlı liste). Skip list her bağlantıda kaç eleman atlandığını da tuttuğu için rank'e göre erişim de O(log N). Küçük sorted set'ler ise 04'teki hash'ler gibi tek bir listpack'te durur. Sınırlar zset-max-listpack-entries (128) ve zset-max-listpack-value (64 byte); Redis 8.10.3 ve Valkey 9.1.3'te varsayılanlar aynı.
ZADD Seçenekleri
ZADD varsayılan olarak member yoksa ekler, varsa skorunu günceller. Seçenekler bu davranışı daraltır:
127.0.0.1:6379> ZADD bookstore:bestsellers:20261101 NX 99 9780441013593
(integer) 0
127.0.0.1:6379> ZADD bookstore:bestsellers:20261101 GT CH 10 9780441013593 15 9780451524935
(integer) 1
127.0.0.1:6379> ZRANGE bookstore:bestsellers:20261101 0 -1 REV WITHSCORES
1) "9780451524935"
2) "15"
3) "9780441013593"
4) "12"
5) "9780141439518"
6) "7"
7) "9780547928227"
8) "5"
NX yalnız yeni member ekler; Dune zaten olduğu için 99 yazılmadı. XX tersi: yalnız var olanı günceller, yeni member eklemez. GT skoru yalnız yeni değer büyükse, LT yalnız küçükse günceller. Dune'un 12'si 10 ile ezilmedi, 9780451524935 ise 7'den 15'e çıktı. GT ve LT'nin yeni member eklemeyi engellemediğini unutma; yalnız var olanların güncellemesini sınırlıyorlar. CH dönüş değerini "eklenen" yerine "değişen" member sayısına çevirir; burada 1, çünkü yalnız bir skor değişti. GT, "en yüksek skor" tutan tablolarda kullanışlı: bir müşterinin bir kampanya oyunundaki en iyi skorunu, okuyup karşılaştırmadan tek komutla saklarsın.
StackExchange.Redis'te bunların karşılığı SortedSetAddAsync'in SortedSetWhen parametresi (NotExists, Exists, GreaterThan, LessThan); ZINCRBY ise SortedSetIncrementAsync.
ZRANGE ile Sıralama ve Sayfalama
Redis 6.2'den beri tek bir ZRANGE komutu rank'e göre, skora göre (BYSCORE) ve member metnine göre (BYLEX) aralık okuyabiliyor; REV sırayı ters çeviriyor, LIMIT offset count sonucu kesiyor:
127.0.0.1:6379> ZRANGE bookstore:bestsellers:20261101 +inf 6 BYSCORE REV LIMIT 0 2 WITHSCORES
1) "9780451524935"
2) "15"
3) "9780441013593"
4) "12"
127.0.0.1:6379> ZCOUNT bookstore:bestsellers:20261101 6 +inf
(integer) 3
127.0.0.1:6379> OBJECT ENCODING bookstore:bestsellers:20261101
"listpack"
"Bugün 6 adetten fazla satan kitaplar, en çok satan önce, ilk iki tanesi" sorusu tek komut. REV ile birlikte aralık da ters yazılır: önce büyük sınır (+inf), sonra küçük (6). Bir sınırın önüne ( koyarsan o değer hariç tutulur: (6, "6'dan büyük" demek. ZCOUNT aynı aralıktaki member sayısını listeyi göndermeden verir. Eski ZREVRANGE, ZRANGEBYSCORE ve ZREVRANGEBYSCORE komutları dokümanda deprecated olarak işaretli ama hâlâ çalışıyor. Bunu görmenin bir yolu: StackExchange.Redis 3.4.35'te Order.Descending ile rank'e göre okuma, sunucunun INFO commandstats çıktısında cmdstat_zrevrange olarak görünüyor; yani paket bu çağrı için hâlâ eski komutu gönderiyor.
Sayfalamanın iki yolu var. Rank'e göre sayfalama (ZRANGE key 30 39 REV) basit ve ucuz: skip list rank'e O(log N)'de ulaştığı için 500.000. sıradaki sayfa da hızlı. Ama Önce Tahmin Et'te gördüğün gibi, iki istek arasında bir kitabın sırası değişirse sayfalar kayar. Skora göre "cursor" ile sayfalama (son gördüğün skordan devam etmek: ZRANGE key (son-skor -inf BYSCORE REV LIMIT 0 10) yeni satışlardan daha az etkilenir, ama eşit skorlu kitaplarda takılır: son skor 7 ise ve 7 skorlu beş kitabın yalnız ikisi önceki sayfaya sığdıysa, (7 diğer üçünü atlar. Bunu aşmak için skor + member çiftini cursor olarak tutup eşit skorluları uygulamada ayıklamak gerekir. BYSCORE ile büyük bir LIMIT offset'i ayrıca maliyetli: Redis offset kadar elemanı gezmek zorunda. Bookstore'da seçtiğimiz yol üçüncüsü: değişmeyen bir snapshot üzerinde rank'e göre sayfalama. Sıralama snapshot'ın ömrü boyunca sabit olduğu için sayfalar kaymaz.
Günleri Birleştirmek: ZUNIONSTORE, WEIGHTS ve COUNT
"Haftanın çok satanları" için yedi günün satışlarını toplamak gerekiyor. Her gün için ayrı bir sorted set tutup bunları sunucuda birleştirmek, tek bir haftalık set'i her gün temizlemeye çalışmaktan çok daha basit: eski gün kendi TTL'iyle (key'in kalan yaşam süresi) silinir, pencere kendiliğinden kayar. Üç günlük küçük bir örnek:
127.0.0.1:6379> ZADD bookstore:bestsellers:20261102 40 9780441013593 5 9780451524935
(integer) 2
127.0.0.1:6379> ZADD bookstore:bestsellers:20261103 6 9780451524935 4 9780141439518
(integer) 2
127.0.0.1:6379> ZADD bookstore:bestsellers:20261104 7 9780451524935 9 9780141439518
(integer) 2
127.0.0.1:6379> ZUNIONSTORE bookstore:bestsellers:3days 3 bookstore:bestsellers:20261102 bookstore:bestsellers:20261103 bookstore:bestsellers:20261104
(integer) 3
127.0.0.1:6379> ZRANGE bookstore:bestsellers:3days 0 -1 REV WITHSCORES
1) "9780441013593"
2) "40"
3) "9780451524935"
4) "18"
5) "9780141439518"
6) "13"
127.0.0.1:6379> ZUNION 3 bookstore:bestsellers:20261102 bookstore:bestsellers:20261103 bookstore:bestsellers:20261104 AGGREGATE COUNT WITHSCORES
1) "9780441013593"
2) "1"
3) "9780141439518"
4) "2"
5) "9780451524935"
6) "3"
127.0.0.1:6379> ZUNION 3 bookstore:bestsellers:20261102 bookstore:bestsellers:20261103 bookstore:bestsellers:20261104 WEIGHTS 1 2 3 AGGREGATE COUNT WITHSCORES
1) "9780441013593"
2) "1"
3) "9780141439518"
4) "5"
5) "9780451524935"
6) "6"
127.0.0.1:6379> ZINTER 3 bookstore:bestsellers:20261102 bookstore:bestsellers:20261103 bookstore:bestsellers:20261104 AGGREGATE MIN WITHSCORES
1) "9780451524935"
2) "5"
127.0.0.1:6379> ZRANGESTORE bookstore:bestsellers:3days:top2 bookstore:bestsellers:3days 0 1 REV
(integer) 2
ZUNIONSTORE varsayılan olarak skorları toplar (AGGREGATE SUM); MIN ve MAX en küçük ya da en büyüğü alır. Redis 8.8 bunlara COUNT'u ekledi: skorlar tamamen yok sayılır ve her member'ın skoru, içinde bulunduğu set sayısı olur. "Bu kitap son yedi günün kaçında satıldı?" sorusu bununla tek komut. WEIGHTS ile birlikte kullanınca her set kendi ağırlığı kadar katkı veriyor: 9780141439518 ikinci ve üçüncü günde var, sonuç 2 + 3 = 5. ZINTER yalnız bütün set'lerde bulunan member'ları döndürür; AGGREGATE MIN ile "üç günün hepsinde satan ve en kötü gününde 5 adet satan kitap" sonucu çıkıyor. ZRANGESTORE bir aralığı yeni bir key'e kopyalıyor; Önce Tahmin Et'teki snapshot buydu.
Burada resmî dokümanla ölçüm arasında küçük bir fark var. ZUNIONSTORE doküman sayfası ve sunucunun kendi COMMAND DOCS çıktısı WEIGHTS argümanının tipini "integer" olarak gösteriyor. Ama WEIGHTS 0.25 0.5 1 denediğimde Redis 8.10.3 hata vermedi ve ondalıklı skorlar üretti (9780451524935 için 5 × 0,25 + 6 × 0,5 + 7 × 1 = 11,25). StackExchange.Redis'in ilgili parametresi de zaten double[]. Ondalıklı ağırlıklar çalışıyor; dokümandaki "integer" ifadesi gerçek davranışı yansıtmıyor.
Valkey 9.1.3'te AGGREGATE COUNT içeren iki komut da ERR syntax error döndürdü; SUM, MIN, MAX ve WEIGHTS aynı sonucu verdi. Valkey'de ya da 8.8'den eski bir Redis'te "kaç günde satıldı" sayısını, günlük set'lerin skorlarını önceden 1'e çeviren ayrı set'lerle ya da uygulama tarafında hesaplaman gerekir.
Daha gerçekçi bir veriyle üç farklı birleştirmeyi yan yana koydum: 2.000 kitap, yedi gün, düzenli satan 20 kitap ve altı gün önce bir kampanyayla tek günde 300 adet satan Dune. StackExchange.Redis'te birleştirmeler SortedSetCombineAndStoreAsync ile yapılıyor:
await db.SortedSetCombineAndStoreAsync(SetOperation.Union, week, days); // ZUNIONSTORE ... (SUM)
await db.SortedSetCombineAndStoreAsync(SetOperation.Union, weekDays, days, null, Aggregate.Count); // AGGREGATE COUNT
double[] weights = [.. Enumerable.Range(1, 7).Select(i => i / 7.0)]; // dün daha ağır basar
await db.SortedSetCombineAndStoreAsync(SetOperation.Union, trending, days, weights);
dotnet run --project src/Bookstore.RedisLab -- bestsellers
SUM (copies in 7 days)
1. 9780441013593 score= 300.0 days=1
2. 9780000000002 score= 194.0 days=7
3. 9780000000015 score= 186.0 days=7
4. 9780000000011 score= 172.0 days=7
5. 9780000000016 score= 162.0 days=7
weighted (recent days count more)
1. 9780000000002 score= 113.9 days=7
2. 9780000000015 score= 108.1 days=7
3. 9780000000011 score= 106.3 days=7
4. 9780000000001 score= 102.9 days=7
5. 9780000000003 score= 94.7 days=7
books sold on all 7 days: 1,100 of 2,001
Düz toplamda Dune, yalnız bir gün satmasına rağmen açık ara birinci. Günlere 1/7'den 7/7'ye artan ağırlıklar verince altı gün önceki kampanya etkisini kaybediyor ve liste "şu an satan" kitaplara dönüyor. Hangisinin doğru olduğu bir iş kararı; Redis'in yaptığı, iki listeyi de günlük set'lerden tek komutla üretmek. days sütunu COUNT ile üretilen set'ten ZMSCORE (SortedSetScoresAsync) ile tek seferde okundu; ZCOUNT key 7 7 ise yedi günün hepsinde satan 1.100 kitabı saydı.
Bookstore: Haftanın Çok Satanları
Bookstore.Api'de her sipariş, 03'teki idempotency kontrolünden geçtikten sonra satışı iki yere yazıyor: günün çok satanlar set'ine ve son siparişler set'ine (bir sonraki bölümün konusu):
// Günlük çok satanlar (ZINCRBY) ve zamana göre sıralı son siparişler (skor = zaman damgası)
private static async Task RecordSaleAsync(IDatabase db, CreateOrderRequest request, long orderId, DateTimeOffset now)
{
RedisKey daily = RedisKeys.Bestsellers(DateOnly.FromDateTime(now.UtcDateTime));
await Task.WhenAll(
db.SortedSetIncrementAsync(daily, request.Isbn, request.Quantity), // ZINCRBY
db.KeyExpireAsync(daily, TimeSpan.FromDays(8), ExpireWhen.HasNoExpiry), // EXPIRE ... NX
db.SortedSetAddAsync(RedisKeys.RecentOrders, orderId, now.ToUnixTimeMilliseconds())); // ZADD
}
Günlük key bookstore:bestsellers:{yyyyMMdd}, 03'teki günlük görüntülenme sayacı gibi ilk yazımda 8 günlük TTL alıyor; yedi günlük pencere için bir günlük pay bırakıyoruz. Haftalık liste her istekte yeniden hesaplanmıyor; ilk istek bir snapshot üretiyor ve snapshot 10 dakika yaşıyor. Snapshot'ın skoru iki bilgiyi birleştiriyor: satılan adet × 10 + satış olan gün sayısı. Neden böyle olduğunu birazdan göreceğiz:
// skor = satılan adet * 10 + satış olan gün sayısı (0-7): eşit satışta daha istikrarlı satan önce
private static async Task BuildWeekAsync(IDatabase db, DateOnly today, RedisKey week)
{
RedisKey[] days = [.. Enumerable.Range(0, 7).Select(i => RedisKeys.Bestsellers(today.AddDays(-i)))];
string buildId = Guid.NewGuid().ToString("N"); // her hesaplamanın kendi geçici key'leri
RedisKey sold = RedisKeys.BestsellersWeekPart(today, "sold", buildId);
RedisKey daysWithSales = RedisKeys.BestsellersWeekPart(today, "days", buildId);
await Task.WhenAll(
db.SortedSetCombineAndStoreAsync(SetOperation.Union, sold, days), // ZUNIONSTORE (SUM)
db.SortedSetCombineAndStoreAsync(SetOperation.Union, daysWithSales, days, null, Aggregate.Count)); // AGGREGATE COUNT (8.8+)
await db.SortedSetCombineAndStoreAsync(SetOperation.Union, week, [sold, daysWithSales], [10, 1]); // WEIGHTS 10 1
await Task.WhenAll(
db.KeyDeleteAsync([sold, daysWithSales]),
db.KeyExpireAsync(week, SnapshotLifetime));
}
Endpoint snapshot yoksa onu üretiyor, sonra rank'e göre sayfa okuyor ve skoru iki parçasına ayırıyor:
app.MapGet("/bestsellers/week", async (IConnectionMultiplexer redis, TimeProvider clock, int page = 1, int pageSize = 10) =>
{
IDatabase db = redis.GetDatabase();
DateOnly today = DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime);
RedisKey week = RedisKeys.BestsellersWeek(today);
if (!await db.KeyExistsAsync(week))
{
await BuildWeekAsync(db, today, week);
}
long start = (page - 1) * pageSize;
SortedSetEntry[] entries = await db.SortedSetRangeByRankWithScoresAsync(week, start, start + pageSize - 1, Order.Descending);
return Results.Ok(entries.Select((e, i) => new
{
rank = start + i + 1,
isbn = e.Element.ToString(),
sold = Math.Floor(e.Score / 10),
daysWithSales = e.Score % 10
}));
});
Denemek için önceki altı günün satışlarını bugünün tarihine göre yazan bir komut ekledim, sonra API'ye beş sipariş gönderdim (siparişler 03'teki gibi Idempotency-Key header'ıyla):
dotnet run --project src/Bookstore.RedisLab -- seed-week
curl -X POST http://localhost:5080/orders -H "Content-Type: application/json" -H "Idempotency-Key: k-06-1" -d '{"customerId":7,"isbn":"9780441013593","quantity":2}'
{"orderId":1,"replayed":false}
# 9780451524935 x1, 9780141439518 x3, 9780441013593 x1, 9780547928227 x2 için aynısı
curl "http://localhost:5080/bestsellers/today?page=1&pageSize=3"
[{"rank":1,"isbn":"9780441013593","sold":3},{"rank":2,"isbn":"9780141439518","sold":3},{"rank":3,"isbn":"9780547928227","sold":2}]
curl "http://localhost:5080/bestsellers/week?page=1&pageSize=3"
[{"rank":1,"isbn":"9780441013593","sold":27,"daysWithSales":7},{"rank":2,"isbn":"9780451524935","sold":27,"daysWithSales":6},{"rank":3,"isbn":"9780547928227","sold":15,"daysWithSales":3}]
curl "http://localhost:5080/bestsellers/week?page=2&pageSize=3"
[{"rank":4,"isbn":"9780141439518","sold":5,"daysWithSales":2},{"rank":5,"isbn":"9780060850524","sold":3,"daysWithSales":1},{"rank":6,"isbn":"9780553293357","sold":2,"daysWithSales":1}]
docker compose -f lab/compose.yaml exec redis redis-cli TTL bookstore:bestsellers:week:20261114
(integer) 600
Haftalık listede iki kitap 27'şer adetle berabere. Skor yalnız satılan adet olsaydı ne olurdu? Yedi günü tmp:sold'a (SUM) ve tmp:days'e (COUNT) birleştirdim, sonra ikisini birleşik skorla tek set'e koydum (komutların tamamı snapshot'taki lab/scripts/06-ties.txt dosyasında; tarihleri kendi gününe göre değiştir):
127.0.0.1:6379> ZRANGE tmp:sold 0 2 REV WITHSCORES
1) "9780451524935"
2) "27"
3) "9780441013593"
4) "27"
5) "9780547928227"
6) "15"
127.0.0.1:6379> ZMSCORE tmp:days 9780451524935 9780441013593
1) "6"
2) "7"
127.0.0.1:6379> ZUNIONSTORE tmp:ranked 2 tmp:sold tmp:days WEIGHTS 10 1
(integer) 7
127.0.0.1:6379> ZRANGE tmp:ranked 0 2 REV WITHSCORES
1) "9780441013593"
2) "277"
3) "9780451524935"
4) "276"
5) "9780547928227"
6) "153"
Yalnız adetle sıralayınca berabere kalan iki kitaptan ISBN'i "büyük" olan önde: kural yok, tesadüf var. Birleşik skorla (277'ye karşı 276) yedi günün hepsinde satan Dune öne geçti. Eşitlikte hangi kitabın önce geleceği bir iş kuralıysa, Redis'in lexicographic sırasına bırakma; kuralı skora kodla. Bu teknik skoru bir sayı olarak tuttuğu için bir sınırı var.
Skorun Hassasiyeti: 2^53 Sınırı
Skor, 64 bit bir double; biçimini IEEE (uluslararası bir mühendislik standartları kuruluşu) 754 standardı tanımlıyor. ZADD dokümanına göre bu biçim yalnız -(2^53) ile +(2^53) arasındaki tam sayıları kayıpsız tutabiliyor; ötesindeki değerler yaklaşık saklanıyor. Birleşik skoru biraz büyük kurunca ne olduğuna bakalım: satılan adet × 10^13 + ilk satışın Unix milisaniyesi. Fikir, eşit satışta "daha önce satmaya başlayan" ayrımını yapmak:
// Birleşik skor: satılan adet * 10^13 + ilk satışın Unix milisaniyesi
long firstSaleA = 1_763_000_000_000, firstSaleB = 1_763_000_000_001;
foreach (long sold in new long[] { 12, 900, 901 })
{
long a = sold * 10_000_000_000_000 + firstSaleA;
long b = sold * 10_000_000_000_000 + firstSaleB;
await db.SortedSetAddAsync(key, [new SortedSetEntry("book-a", a), new SortedSetEntry("book-b", b)]);
double? storedA = await db.SortedSetScoreAsync(key, "book-a");
double? storedB = await db.SortedSetScoreAsync(key, "book-b");
Console.WriteLine($"sold={sold,3} sent a={a} b={b} stored a={storedA:F0} b={storedB:F0} equal={storedA == storedB}");
}
sold= 12 sent a=121763000000000 b=121763000000001 stored a=121763000000000 b=121763000000001 equal=False
sold=900 sent a=9001763000000000 b=9001763000000001 stored a=9001763000000000 b=9001763000000001 equal=False
sold=901 sent a=9011763000000000 b=9011763000000001 stored a=9011763000000000 b=9011763000000000 equal=True
2^53 = 9007199254740992
900 adete kadar her şey doğru. 901. adette skor 2^53'ü geçiyor; bu aralıkta double yalnız çift sayıları gösterebildiği için ...001 değeri ...000'a yuvarlandı ve iki farklı kitap aynı skoru aldı. Hiçbir hata ya da uyarı yok; tie-break sessizce kayboldu. Sorun Redis'te bile değil: C#'taki long değer double'a çevrildiği anda bilgi gidiyor. Birleşik skor kuracaksan iki parçanın en büyük değerlerini çarp ve 2^53'ün (yaklaşık 9 × 10^15) altında kaldığından emin ol. Bookstore'daki × 10 + gün sayısı, haftada milyarlarca satışa kadar güvenli. Zaman damgası gibi büyük bir değer gerekiyorsa onu saniyeye indir, bir başlangıç tarihinden itibaren say ya da tie-break'i skorda değil member'da yap.
Zaman Damgası Skor Olarak: Kayan Pencereler
Sorted set'in sık kullanılan ikinci yüzü zaman: skor Unix milisaniyesi, member olayın kimliği. Bookstore'un operasyon ekranında "son 5 dakikada kaç sipariş geldi?" sorusu böyle cevaplanıyor. RecordSaleAsync her siparişi bookstore:orders:recent'a zaman damgasıyla ekliyor; endpoint pencerenin başlangıcından bu yana olanları sayıyor ve bir saatten eski kayıtları siliyor:
// Son N dakikadaki siparişler: skor = zaman damgası, eski kayıtlar her okumada silinir
app.MapGet("/orders/recent", async (IConnectionMultiplexer redis, TimeProvider clock, int minutes = 5) =>
{
IDatabase db = redis.GetDatabase();
DateTimeOffset now = clock.GetUtcNow();
long windowStart = now.AddMinutes(-minutes).ToUnixTimeMilliseconds();
long keepFrom = now.Add(-RecentOrdersKept).ToUnixTimeMilliseconds();
Task<long> trimmed = db.SortedSetRemoveRangeByScoreAsync(RedisKeys.RecentOrders,
double.NegativeInfinity, keepFrom, Exclude.Stop); // ZREMRANGEBYSCORE key -inf (keepFrom
Task<long> count = db.SortedSetLengthAsync(RedisKeys.RecentOrders, windowStart); // ZCOUNT key windowStart +inf
await Task.WhenAll(trimmed, count);
return Results.Ok(new { minutes, orders = count.Result });
});
curl "http://localhost:5080/orders/recent?minutes=5"
{"minutes":5,"orders":5}
Daha kalabalık bir veriyle: son 10 dakikaya saniyede bir yayılmış 600 sipariş.
dotnet run --project src/Bookstore.RedisLab -- recent-orders
orders in the last 5 minutes: 300, removed older entries: 300, left: 300
newest: order-5599, 1000 ms ago
İki ayrıntı önemli. Member tekil olmalı: aynı member'ı ikinci kez eklersen yeni olay eklenmez, eskinin skoru güncellenir. Sipariş numarası burada doğal bir kimlik; kimliği olmayan olaylarda (örneğin bir istek) zaman damgasına rastgele bir ek yapılır. İkincisi, set kendiliğinden küçülmez; ZREMRANGEBYSCORE ile silmezsen sonsuza kadar büyür. Aynı yapı sliding window rate limiter'ın temeli, ama tasarımı, bellek maliyeti ve alternatifleri System Design serisinin "Rate Limiting Tasarımı: Token Bucket, Sliding Window ve Distributed Rate Limiter" yazısında, ASP.NET Core tarafı ASP.NET Core Güvenliği serisinin "Rate Limiting Derinlemesine" yazısında; burada tekrar etmiyorum. Bu ekleme, sayma ve silme adımları tek bir atomik adım değil; atomik hâli 08'in konusu.
Üretimde Dikkat
Bellek ve hız için bir ölçüm yaptım: 128 ve 129 member'lı iki küçük set ile bir milyon member'lı bir set:
dotnet run --project src/Bookstore.RedisLab -- zset-encoding
128 members: 1,593 bytes, encoding=listpack
129 members: 11,681 bytes, encoding=skiplist
1,000,000 members: 77,730,753 bytes, encoding=skiplist
ZRANGE 0 -1: 1,000,000 members in 183 ms (client side), page at rank 500,000: 50 entries
cmdstat_zrevrange: calls=1,usec=17,usec_per_call=17.00,rejected_calls=0,failed_calls=0
cmdstat_zrange: calls=1,usec=18665,usec_per_call=18665.00,rejected_calls=0,failed_calls=0,slowlog_count=1,slowlog_time_ms_sum=18.66,slowlog_time_ms_max=18.66
cmdstat_zrevrank: calls=10000,usec=37033,usec_per_call=3.70,rejected_calls=0,failed_calls=0
slowlog: ZRANGE bookstore:bestsellers:all-time 0 -1 18.7 ms
129. member set'i listpack'ten skiplist'e çevirdi ve bellek 7,3 katına çıktı. Bir milyon member yaklaşık 74 MB tutuyor; member başına 78 byte civarı, bunun büyük kısmı skip list düğümü ve hash table girdisi. ZREVRANK bir milyon member'da ortalama 3,7 µs, 500.000. sıradaki 50 elemanlık sayfa 17 µs sürdü; yani büyük bir leaderboard'da "benim sıram ne?" ve "şu sayfa" soruları ucuz. ZRANGE 0 -1 ise sunucuyu 18,7 ms kilitledi ve SLOWLOG'a düştü; bu sürede diğer bütün client'lar bekledi (01). Sayfa sayfa oku, büyük bir set'i dolaşman gerekiyorsa ZSCAN (SortedSetScanAsync) kullan.
Günlük set'lere TTL vermeyi unutma; burada 8 gün. Snapshot'ı üreten kodda geçici key'lerin her hesaplamaya özel olmasının bir nedeni var: snapshot'ın süresi dolduğu anda iki istek aynı anda gelirse ikisi de hesaplamaya başlar. Ortak geçici key'ler kullansaydık biri işini bitirip geçici key'leri silerken diğeri o key'lerden ZUNIONSTORE yapabilir ve haftalık listeyi boş bir set'le ezebilirdi. Bunu 8 eşzamanlı istekle denedim; isteğe özel key'lerle liste her seferinde doğru çıktı. Aynı işi tek atomik adımda yapmak (MULTI/EXEC veya bir Lua script'i) 08'in konusu. Snapshot'ın ömrü de bir tasarım kararı: 10 dakika boyunca liste değişmiyor; çok satanlar listesinde bu kabul edilebilir, stok sayısında edilemez. Haftalık liste yalnız hesaplanmış bir sonuç; kaynağı günlük set'ler, onlar da siparişin kendisi değil. Siparişin asıl kaydı veritabanında.
Sık Düşülen Tuzaklar
Rank'e göre sayfalamayı canlı ve sık değişen bir listede kullanmak: Önce Tahmin Et'te bir kitap iki kez göründü, bir kitap hiç görünmedi. Snapshot ya da skor + member cursor'ı kullan.
Eşit skorlu member'ların sırasını iş kuralı sanmak: Redis eşitliği member metnine göre çözüyor. Kuralın varsa skora kodla.
Birleşik skorda 2^53'ü aşmak: 901 adetten sonra iki farklı değer aynı skora yuvarlandı, hata da uyarı da çıkmadı.
Büyük sorted set'te ZRANGE 0 -1: bir milyon member sunucuyu 18,7 ms bekletti. LIMIT, rank aralığı ya da ZSCAN kullan.
Zaman damgalı set'te eski kayıtları silmemek ya da aynı member'ı tekrar eklemek: ilki sınırsız büyüme, ikincisi kaybolan olay demek.
Redis 8.8'in AGGREGATE COUNT'unu Valkey'de ya da eski bir managed Redis'te varsaymak: komut ERR syntax error ile reddediliyor.
Küçük Bir Deneme
★☆☆ bookstore:bestsellers:20261101'e 64 byte'tan uzun bir member ekle (örneğin 70 karakterlik bir başlık) ve OBJECT ENCODING ile MEMORY USAGE'a tekrar bak. Member'ı sildiğinde encoding geri dönüyor mu?
★★☆ /bestsellers/today endpoint'ine skor cursor'lı bir sürüm ekle: cevap nextCursor olarak son kitabın skorunu ve ISBN'ini döndürsün, sonraki istek eşit skorlu kitapları atlamadan devam etsin. Beş kitabın 7 skorda berabere olduğu bir veriyle dene.
★★★ "Trend olan kitaplar" için dakikalık sorted set'ler (bookstore:trending:{yyyyMMddHHmm}) tut ve son 60 dakikayı, yakın dakikalar daha ağır basacak şekilde WEIGHTS ile birleştir. 60 key'lik birleştirme kaç milisaniye sürüyor? Saatlik bir ara toplam tutmak bu süreyi nasıl değiştirir?
Bir sonraki yazıda yaklaşık cevapla yetinebildiğin yerlere geçiyoruz: bitmap ile günlük aktif kullanıcı, HyperLogLog ile 12 KB'ta tekil sayım (05'teki ziyaretçi set'leriyle bellek karşılaştırması), en yakın mağaza için Geo ve Redis 8'de çekirdeğe giren Bloom, Cuckoo, Top-K gibi probabilistic yapılar.