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

Redis Hash'leri: Object Saklamak, Field Expiration ve Bellek Verimliliği

Mert Özen 14 Kas 2026 10 dk 7 görüntülenme
Redis Hash'leri: Object Saklamak, Field Expiration ve Bellek Verimliliği

Küçük bir Redis hash'i listpack encoding'inde durur ve aynı sepeti JSON string'in yarısı, ayrı key'lerin 2,4'te biri kadar bellekle tutar; 513. field ya da 65 byte'lık tek bir value ise hash'i hashtable'a çevirip belleği 3,4 katına çıkarır. Gerçek çıktılarla HashEntry ile Bookstore sepeti, hash ile JSON kararı, HEXPIRE ve HSETEX ile field bazında fiyat kilidi, Valkey'in encoding farkı, Redis 8.10'un HIMPORT ile compact hash'leri ve dev hash'te 44 ms'lik HGETALL.

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çı) sepeti Redis'e taşınıyor. Her müşterinin sepetinde ortalama beş kitap var: ISBN (kitabın 13 haneli uluslararası numarası) ve adet. Ekipte üç öneri çıkıyor: sepet başına bir hash (field = ISBN, value = adet), sepet başına bir JSON string ya da her sepet satırı için ayrı bir key (bookstore:cart:7:9780441013593). 10.000 sepeti üç şekilde de yazıp used_memory farkına bakalım:

await MeasureAsync("hash per cart", customerId =>
    db.HashSetAsync($"bookstore:cart:{customerId}", [.. Isbns.Select((isbn, i) => new HashEntry(isbn, i + 1))]));

await MeasureAsync("JSON string per cart", customerId =>
    db.StringSetAsync($"bookstore:cart:{customerId}",
        JsonSerializer.Serialize(Isbns.Select((isbn, i) => new { isbn, quantity = i + 1 }))));

await MeasureAsync("key per cart item", customerId =>
    Task.WhenAll(Isbns.Select((isbn, i) => db.StringSetAsync($"bookstore:cart:{customerId}:{isbn}", i + 1))));

Hangisi en az, hangisi en çok bellek kullanır? JSON metni zaten kompakt; hash'in field adları da metin. Fark büyük mü?

dotnet run --project src/Bookstore.RedisLab -- cart-storage
hash per cart          keys=10,000  memory= 1.41 MB
JSON string per cart   keys=10,000  memory= 2.75 MB
key per cart item      keys=50,000  memory= 3.37 MB

Hash, JSON'un yaklaşık yarısı; ayrı key'ler ise hash'in 2,4 katı. Hash'in avantajı küçükken listpack encoding'inde durmasından geliyor: field'lar ve value'lar tek bir bellek bloğunda art arda, tam sayılar sıkıştırılmış olarak saklanıyor. JSON string'de tırnaklar, iki nokta üst üste ve "quantity" gibi tekrar eden adlar da byte tutuyor. Ayrı key'lerde ise her key'in kendi meta verisi var. Bu yazıda hash'i kullanırken bilmen gerekenlere bakıyoruz: komutlar, .NET'te mapping, hash ile JSON arasındaki karar, field bazında expiration, encoding eşikleri ve Redis 8.10'un compact hash'leri.

Hash Komutları

Hash, bir key'in altında field-value çiftleri tutar; bir object'in property'lerini ya da bir sözlüğü saklamak için doğal yapı. Komutlar tek tek field'lar üzerinde çalışır:

127.0.0.1:6379> HSET bookstore:cart:7 9780441013593 1 9780451524935 2
(integer) 2
127.0.0.1:6379> HGET bookstore:cart:7 9780451524935
"2"
127.0.0.1:6379> HMGET bookstore:cart:7 9780441013593 9780000000000
1) "1"
2) (nil)
127.0.0.1:6379> HINCRBY bookstore:cart:7 9780441013593 2
(integer) 3
127.0.0.1:6379> HGETALL bookstore:cart:7
1) "9780441013593"
2) "3"
3) "9780451524935"
4) "2"
127.0.0.1:6379> HLEN bookstore:cart:7
(integer) 2
127.0.0.1:6379> HDEL bookstore:cart:7 9780451524935
(integer) 1
127.0.0.1:6379> HEXISTS bookstore:cart:7 9780451524935
(integer) 0
127.0.0.1:6379> OBJECT ENCODING bookstore:cart:7
"listpack"

HSET eklediği yeni field sayısını döndürür (mevcut field'ı güncellemek sayılmaz). HINCRBY, 01'de gördüğümüz INCR'in field karşılığı: sepetteki adedi okuyup yazmadan, atomik olarak artırır. HGETALL'un sonucu düz bir dizi; RESP3'te (Redis 6 ile gelen, zengin tipleri taşıyan protokol sürümü) client bunu map olarak alır. Field sırası garanti değildir; sıraya ihtiyacın varsa 06'daki sorted set'e bak.

.NET'te HashEntry ve Bookstore Sepeti

StackExchange.Redis'te hash field'ları HashEntry (ad ve değer) olarak gider ve gelir. Bookstore.Api'nin sepet endpoint'leri iki hash kullanıyor: bookstore:cart:{customerId} ISBN başına adet tutuyor, bookstore:cart:{customerId}:price-locks ise kampanya fiyatı kilitlenen kitapların fiyatını kuruş cinsinden. Sepete ekleme HINCRBY ile yapılıyor; adet sıfıra inerse field siliniyor:

// Bir kitabın adetini artır (negatif adetle azalt): HINCRBY, sonra sepetin süresini yenile
app.MapPost("/carts/{customerId:int}/items", async (int customerId, AddToCartRequest request, IConnectionMultiplexer redis) =>
{
    IDatabase db = redis.GetDatabase();
    RedisKey cart = RedisKeys.Cart(customerId);
    long quantity = await db.HashIncrementAsync(cart, request.Isbn, request.Quantity);
    if (quantity <= 0)
    {
        await db.HashDeleteAsync(cart, request.Isbn);
    }
    await db.KeyExpireAsync(cart, CartLifetime); // kayan süre: her değişiklik sepete 30 gün daha verir
    return Results.Ok(new { request.Isbn, quantity = Math.Max(quantity, 0) });
});

Sepetin süresi bilerek her yazıda yenileniyor. 03'te pencereli sayaçta bunun bir hata olduğunu söylemiştik; burada istediğimiz tam olarak bu: 30 gün boyunca dokunulmayan sepet, terk edilmiş sayılıp kendiliğinden siliniyor. Okuma tarafında iki hash ve sepetin TTL'i (Time To Live; key'in silinmesine kalan süre) tek round trip'te alınıyor ve bir record'a dönüştürülüyor:

Task<HashEntry[]> itemsTask = db.HashGetAllAsync(cart);
Task<HashEntry[]> locksTask = db.HashGetAllAsync(locks); // süresi dolmuş field'lar hiç dönmez
Task<TimeSpan?> ttlTask = db.KeyTimeToLiveAsync(cart);
await Task.WhenAll(itemsTask, locksTask, ttlTask);

Dictionary<string, long> lockedPrices = locksTask.Result.ToDictionary(e => (string)e.Name!, e => (long)e.Value);
RedisValue[] lockedIsbns = [.. lockedPrices.Keys.Select(k => (RedisValue)k)];
long[] lockTtls = lockedIsbns.Length == 0 ? [] : await db.HashFieldGetTimeToLiveAsync(locks, lockedIsbns); // HPTTL, milisaniye
Dictionary<string, double> secondsLeft = lockedIsbns.Zip(lockTtls).ToDictionary(p => (string)p.First!, p => p.Second / 1000.0);

CartItem[] items = [.. itemsTask.Result.Select(e => new CartItem(
    Isbn: e.Name!,
    Quantity: (int)e.Value,
    LockedPriceInKurus: lockedPrices.TryGetValue(e.Name!, out long price) ? price : null,
    PriceLockSecondsLeft: secondsLeft.TryGetValue(e.Name!, out double left) ? Math.Round(left) : null))];
curl -X POST http://localhost:5080/carts/7/items -H "Content-Type: application/json" -d '{"isbn":"9780441013593","quantity":1}'
{"isbn":"9780441013593","quantity":1}
curl -X POST http://localhost:5080/carts/7/items -H "Content-Type: application/json" -d '{"isbn":"9780441013593","quantity":2}'
{"isbn":"9780441013593","quantity":3}
curl -X POST http://localhost:5080/carts/7/items -H "Content-Type: application/json" -d '{"isbn":"9780141439518","quantity":1}'
{"isbn":"9780141439518","quantity":1}
curl -X POST http://localhost:5080/carts/7/price-locks -H "Content-Type: application/json" -d '{"isbn":"9780441013593","priceInKurus":29990}'
{"isbn":"9780441013593","expiresInSeconds":900}
curl http://localhost:5080/carts/7
{"customerId":7,"items":[{"isbn":"9780441013593","quantity":3,"lockedPriceInKurus":29990,"priceLockSecondsLeft":897},{"isbn":"9780141439518","quantity":1,"lockedPriceInKurus":null,"priceLockSecondsLeft":null}],"expiresInDays":30}

Bu örnekte mapping elle yazıldı, çünkü field adları veri (ISBN). Field adları sabit olan bir object'i (örneğin bir kitabın title, author, price alanları) saklıyorsan, property'leri HashEntry[]'e çeviren küçük bir extension method yeterli; reflection tabanlı genel bir mapper yazmaya genelde gerek yok.

Hash mi, JSON String mi?

Önce Tahmin Et'teki bellek farkı tek başına karar sebebi değil. Asıl soru, veriye nasıl eriştiğin:

  • Tek bir field'ı okuyup değiştiriyorsan (sepete bir kitap eklemek, adet artırmak) hash açık ara iyi: HINCRBY atomik, JSON string'de ise her değişiklik "oku, deserialize et, değiştir, serialize et, yaz" demek ve 03'teki kayıp güncelleme sorunu geri gelir.
  • Field bazında süre gerekiyorsa (aşağıdaki fiyat kilidi) yalnız hash'te var.
  • Veri iç içeyse (kitabın yazar listesi, boyutları, formatları) hash tek seviyeli kalır; value'lar string'dir. İç içe yapıyı hash'e sığdırmak için field adlarını dimensions.width gibi düzleştirmek gerekir, bu da çabuk dağılır. Bu durumda JSON string ya da 11'de anlatacağımız JSON tipi daha doğal.
  • Object'i hep bütün olarak okuyup yazıyorsan ve hiç kısmi güncelleme yapmıyorsan JSON string basit ve yeterli; Caching serisinde cache entry'lerini bu yüzden serialize edilmiş string olarak tuttuk.

Field Bazında Expiration

Redis 7.4'e kadar süre yalnız key'e verilebiliyordu. 7.4 hash field'larına süre veren HEXPIRE, HPEXPIRE, HTTL, HPERSIST ailesini getirdi; Redis 8.0 de okuma veya yazmayla birlikte süre ayarlayan HGETEX, HSETEX ve HGETDEL'i ekledi. Bookstore'da kampanya fiyatını 15 dakika kilitlemek için kullanıyoruz:

127.0.0.1:6379> HSET bookstore:cart:7:prices 9780441013593 34990 9780451524935 19990
(integer) 2
127.0.0.1:6379> HEXPIRE bookstore:cart:7:prices 900 FIELDS 1 9780441013593
1) (integer) 1
127.0.0.1:6379> HTTL bookstore:cart:7:prices FIELDS 3 9780441013593 9780451524935 9780000000000
1) (integer) 900
2) (integer) -1
3) (integer) -2
127.0.0.1:6379> OBJECT ENCODING bookstore:cart:7:prices
"listpackex"
127.0.0.1:6379> HEXPIRE bookstore:cart:7:prices 900 NX FIELDS 1 9780441013593
1) (integer) 0
127.0.0.1:6379> HEXPIRE bookstore:cart:7:prices 0 FIELDS 1 9780451524935
1) (integer) 2
127.0.0.1:6379> HGETALL bookstore:cart:7:prices
1) "9780441013593"
2) "34990"
127.0.0.1:6379> HSETEX bookstore:cart:7:prices EX 900 FIELDS 2 9780441013593 34990 9780451524935 19990
(integer) 1
127.0.0.1:6379> HGETEX bookstore:cart:7:prices PERSIST FIELDS 1 9780451524935
1) "19990"
127.0.0.1:6379> HGETDEL bookstore:cart:7:prices FIELDS 1 9780451524935
1) "19990"

Dönüş kodları alışkın olduğun EXPIRE'dan biraz farklı ve her field için ayrı bir dizi elemanı geliyor: 1 süre ayarlandı, 0 koşul (NX, XX, GT, LT) sağlanmadı, 2 süre sıfır ya da geçmiş bir zaman olduğu için field hemen silindi, -2 field yok. HTTL'de -1 süresiz, -2 yok anlamına geliyor. Süreli bir field eklenince encoding listpackex'e geçti: field'ların süresini de taşıyan listpack.

.NET tarafında HSETEX'in karşılığı HashFieldSetAndSetExpiryAsync; fiyat kilidi endpoint'i bu kadar:

// Kampanya fiyatı kitap başına 15 dakika tutulur: field bazında TTL ile HSETEX
app.MapPost("/carts/{customerId:int}/price-locks", async (int customerId, LockPriceRequest request, IConnectionMultiplexer redis) =>
{
    await redis.GetDatabase().HashFieldSetAndSetExpiryAsync(
        RedisKeys.CartPriceLocks(customerId), request.Isbn, request.PriceInKurus, PriceLockLifetime);
    return Results.Ok(new { request.Isbn, expiresInSeconds = (int)PriceLockLifetime.TotalSeconds });
});

Hash'in son field'ı da süresi dolup silinince key de silinir. Fiyat kilidinin süresini redis-cli ile sıfıra çekip sepete tekrar baktım:

127.0.0.1:6379> HEXPIRE bookstore:cart:7:price-locks 0 FIELDS 1 9780441013593
1) (integer) 2
127.0.0.1:6379> EXISTS bookstore:cart:7:price-locks
(integer) 0

curl http://localhost:5080/carts/7
{"customerId":7,"items":[{"isbn":"9780441013593","quantity":3,"lockedPriceInKurus":null,"priceLockSecondsLeft":null}],"expiresInDays":30}

Valkey 9.0 aynı komut ailesini getirdi ve yukarıdaki oturum Valkey 9.1.3'te aynı cevapları verdi; tek fark encoding'deydi: süreli field eklenince Valkey hash'i hashtable'a çevirdi, Redis listpackex'te tuttu. Küçük ve süreli field'lı çok sayıda hash'in olduğu bir sistemde iki sunucunun bellek kullanımı bu yüzden farklı çıkabilir.

Encoding: listpack ve hashtable

Küçük hash'ler listpack'te durur: tek bir bellek bloğu, field ve value'lar art arda. Arama doğrusal, ama blok küçük olduğu için CPU cache'inde kalır ve hızlıdır. Hash iki eşikten birini geçince hashtable'a çevrilir: hash-max-listpack-entries (field sayısı) ve hash-max-listpack-value (en uzun field ya da value byte'ı). Redis 8.10.3 ve Valkey 9.1.3'te ikisinin değeri de 512 ve 64. Eşiği gerçek sayılarla geçelim:

dotnet run --project src/Bookstore.RedisLab -- hash-encoding
128 fields                 encoding=listpack   memory= 2,615 bytes
512 fields                 encoding=listpack   memory=10,295 bytes
513 fields                 encoding=hashtable  memory=35,485 bytes
1 field, value 64 bytes    encoding=listpack   memory=   128 bytes
1 field, value 65 bytes    encoding=hashtable  memory=   227 bytes

Tek bir field fazlası belleği 3,4 kat artırdı. 65 byte'lık tek bir value de küçük hash'i hashtable'a itti. Dönüşüm tek yönlü: 513 field'lı bir hash'ten 510 field silip 3 field bıraktığımda encoding hâlâ hashtable'dı. Redis 8.6 sürüm notları hashtable encoding'li hash'lerde ciddi bir bellek azaltımı bildiriyor (field adı ve value'yu tek bir yapıda birleştirerek); yukarıdaki sayılar bu iyileştirmeden sonraki durum. Eşikleri artırmak mümkün, ama listpack'te arama doğrusal olduğu için çok büyük listpack'ler CPU maliyetini artırır. Varsayılanlarla başla, değiştirmek için ölç.

Redis 8.10'un Compact Hash'leri ve HIMPORT

Katalogdaki her kitabı bir hash olarak tutuyorsan field adları her key'de tekrar eder: 100.000 kitap, 100.000 kez title, author, price, stock. Redis 8.10'un compact hash'leri bu tekrarı kaldırıyor: aynı field setini paylaşan hash'ler field adlarını ortak bir template'te tutuyor. Bu encoding'e şimdilik yeni HIMPORT komut ailesiyle giriliyor: önce bağlantıya özel bir fieldset tanımlanıyor (HIMPORT PREPARE), sonra değerler aynı sırayla gönderiliyor (HIMPORT SET). StackExchange.Redis bunu HashImport tipiyle sarıyor; PREPARE'i bağlantı başına kendisi gönderiyor:

using HashImport bookFields = HashImport.Create(fields);
await Task.WhenAll(Enumerable.Range(1, 100_000).Select(id => db.HashImportAsync($"bookstore:book:{id}", bookFields,
    new RedisValue[] { $"Dune #{id}", "Frank Herbert", 34990, id % 40 })));
HSET     memory=12.88 MB
HIMPORT  memory= 9.85 MB, encoding=template-listpack

Aynı 100.000 kitap yüzde 24 daha az bellek tuttu. INFO stats'taki hash_templates ve hash_template_keys, INFO memory'deki used_memory_hash_templates bu yapıyı izlemek için geldi. Template'li bir hash'e sonradan farklı bir field eklediğimde (HSET … isbn …) Redis yeni bir template oluşturdu ve encoding template-listpack kaldı. Ne zaman fark eder? Aynı şemada çok sayıda küçük hash'in olduğu ve bellek maliyetinin konuşulduğu yerlerde. Birkaç bin sepet için zahmete değmez. Komut yeni ve Valkey'de yok; RDB (Redis'in snapshot dosyası) yükleme sırasında dönüşümü yöneten hash-rdb-load-*-template-* ayarları gibi ayrıntıları kullanmadan önce sürüm notlarından takip et.

Üretimde Dikkat

Hash'in boyutunu sınırlı tut. Sepet gibi doğal olarak küçük kalan yapılarda sorun yok; ama "her kitabın görüntülenme sayısı tek bir hash'te" gibi sınırsız büyüyen hash'ler bir gün milyon field'a ulaşır. Böyle bir hash'te HGETALL tek başına main thread'i bekletir; bir milyon field'lı bir hash ile ölçtüm:

HGETALL: 1,000,000 fields in 291 ms
HSCAN:   1,000,000 fields in 540 ms
slowlog: HGETALL 44.2 ms

HGETALL sunucuda 44 ms çalıştı; o sürede başka hiçbir komut çalışmadı. HSCAN (.NET'te HashScanAsync) toplamda daha uzun sürdü ama sayfa sayfa ilerlediği için SLOWLOG'a hiç düşmedi. Büyük bir key'i silmek de aynı şekilde pahalıdır; DEL yerine arka planda serbest bırakan UNLINK kullan. Field bazında TTL kullanıyorsan süreli field'ların da aktif expire döngüsüyle silindiğini ve encoding'i değiştirdiğini unutma. Valkey ile birlikte çalışıyorsan HIMPORT'a bağımlı kod yazma.

Sık Düşülen Tuzaklar

Dev hash'te HGETALL: bir milyon field 44 ms boyunca sunucuyu kilitledi. HSCAN, HMGET ya da hash'i bölmek daha güvenli.

Field'ları ayrı key'lere bölmek: 10.000 sepette bellek 2,4 kat arttı; üstelik sepetin tamamını silmek veya süresini ayarlamak için artık 5 key'i birlikte yönetmen gerekiyor ve bunu atomik yapmak zorlaşıyor.

Hash'i JSON string ile karıştırmak: sık kısmi güncelleme yapılan bir object'i JSON string'de tutup client'ta güncellemek, 03'teki kayıp güncelleme sorununu geri getirir.

Uzun bir value'nun encoding'i değiştirdiğini fark etmemek: 64 byte'ı geçen tek bir value (örneğin bir açıklama metni) küçük hash'i hashtable'a çevirir ve geri dönmez.

HEXPIRE'ın dönüş değerini EXPIRE gibi okumak: 2 "süre ayarlandı" değil, "field silindi" demek.

Küçük Bir Deneme

★☆☆ cart-storage deneyini Valkey'e karşı çalıştır (BOOKSTORE_REDIS=localhost:6380). Üç yöntemin sıralaması aynı mı?

★★☆ Sepet endpoint'ine kitap başına not alanı ekleyecek gibi düşün: aynı hash'e note:{isbn} field'ı yazıp 100 karakterlik bir not kaydet. OBJECT ENCODING ve MEMORY USAGE ne oldu? Notları ayrı bir hash'e taşıyınca toplam bellek nasıl değişiyor?

★★★ compact-hash deneyini 4 yerine 12 field'lı kitaplarla tekrarla. Field sayısı arttıkça HIMPORT'ın kazancı büyüyor mu? Sonra hash'lerden birine HSET ile yeni bir field ekle ve INFO stats'taki hash_templates değerini izle.

Bir sonraki yazıda list ve set'e geçiyoruz: basit ve güvenilir kuyruk, "son görüntülenen 10 kitap", kitap etiketleri ile kesişim sorguları ve günlük tekil ziyaretçi sayısı.