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

Redis Persistence ve Bellek Yönetimi: RDB, AOF, fsync, Fork Maliyeti, maxmemory ve Büyük/Sıcak Key'ler

Mert Özen 14 Kas 2026 11 dk 10 görüntülenme
Redis Persistence ve Bellek Yönetimi: RDB, AOF, fsync, Fork Maliyeti, maxmemory ve Büyük/Sıcak Key'ler

Resmî redis:8 image'ı varsayılan olarak yalnız aralıklı RDB snapshot'ı alır; container SIGKILL ile ölürse son snapshot'tan sonraki her şey gider, AOF everysec ile ise ölçümde hiçbir sipariş kaybolmadı. Gerçek çıktılarla fork ve copy-on-write maliyeti, AOF'u veri kaybetmeden açmak, Redis 8.10'un BACKUP komutu, noeviction'ın stream'i koruması, OOMKilled, UNLINK, HOTKEYS ve activedefrag eşiği.

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 (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) sipariş olaylarını 10'dan beri bir stream'de, sipariş numaralarını bir sayaçta tutuyor. Bu yazı için lab/persistence.yaml'da iki geçici Redis var: rdb resmî image'ın varsayılan ayarlarıyla çalışıyor, aof ise --appendonly yes --appendfsync everysec ile. İkisine de 5.000 sipariş yazıp container'ları hemen SIGKILL ile öldürüyor ve yeniden başlatıyoruz:

docker compose -f lab/persistence.yaml up -d
$env:BOOKSTORE_REDIS="localhost:6390"; dotnet run --project src/Bookstore.RedisLab -- write-orders 5000
$env:BOOKSTORE_REDIS="localhost:6391"; dotnet run --project src/Bookstore.RedisLab -- write-orders 5000
docker compose -f lab/persistence.yaml kill rdb aof
docker compose -f lab/persistence.yaml start rdb aof
$env:BOOKSTORE_REDIS="localhost:6390"; dotnet run --project src/Bookstore.RedisLab -- check-orders
$env:BOOKSTORE_REDIS="localhost:6391"; dotnet run --project src/Bookstore.RedisLab -- check-orders

Yeniden başlayan iki Redis'te kaç sipariş kalır? aof'taki "her saniye fsync" en fazla bir saniyelik veriyi kaybettirebilir; bu ölçümde kaybettirdi mi?

wrote 5,000 orders, bookstore:order:id-seq = 5000, XLEN = 5000
wrote 5,000 orders, bookstore:order:id-seq = 5000, XLEN = 5000
 Container bookstore-persistence-rdb-1 Killed
 Container bookstore-persistence-aof-1 Killed
 Container bookstore-persistence-rdb-1 Started
 Container bookstore-persistence-aof-1 Started
bookstore:order:id-seq = (nil), XLEN = 0
bookstore:order:id-seq = 5000, XLEN = 5000

Varsayılan ayarlardaki Redis bütün siparişleri kaybetti: sayaç yok, stream boş. aof container'ında hepsi duruyor; son saniyenin yazdıkları da. Bu yazıda nedenlerini anlatıyoruz: RDB (Redis Database; verinin belirli anlarda diske yazılan snapshot'ı) ve AOF (Append Only File; yazma komutlarının sırayla eklendiği log dosyası), fork'un bellek maliyeti, lab'ı AOF'a geçirirken veri kaybetmemek, Redis 8.10'un BACKUP komutu, maxmemory ve eviction policy'lerinin cache dışındaki anlamı, container'ın bellek sınırı, büyük ve sıcak key'ler.

RDB: Snapshot, Fork ve Copy-on-Write

Resmî image config dosyası olmadan başlıyor ve Redis'in varsayılanlarını kullanıyor: save 3600 1 300 100 60 10000 (bir saatte en az 1, 5 dakikada en az 100 ya da 1 dakikada en az 10.000 değişiklik olduysa snapshot al), AOF kapalı. Deneme birkaç saniye sürdü; hiçbir kural tetiklenmedi ve son snapshot boş veritabanına aitti. Dokümanın RDB için söylediği tam bu: düzgün kapanmayan bir Redis son snapshot'tan sonraki her şeyi kaybeder ve snapshot'lar genelde dakikalar arayla alınır.

"Düzgün kapanma" önemli bir ayrıntı. Aynı denemeyi kill yerine docker compose stop ile yaptığımda 5.000 sipariş korundu; log'da Received SIGTERM scheduling shutdown, ardından Saving the final RDB snapshot before exiting. vardı. Planlı restart'larda veri kaybolmadığı için RDB'nin riski fark edilmeden kalabiliyor; çökme, OOMKilled (bellek sınırını aşan container'ın işletim sistemi tarafından öldürülmesi) ya da sunucunun kapanması ise SIGKILL ile aynı sonucu verir.

Snapshot'ı Redis'in kendisi değil, fork ile oluşturduğu child process yazar. Child, parent'ın belleğini kopyalamadan paylaşır; parent bir sayfayı değiştirince işletim sistemi o sayfanın kopyasını çıkarır (copy-on-write). Yani snapshot sırasında ne kadar yazarsan o kadar ek bellek gerekir. 2 milyon session key'iyle (547 MB) ölçtüm:

$env:BOOKSTORE_REDIS="localhost:6390"; dotnet run --project src/Bookstore.RedisLab -- fork-cost
2,000,000 keys, used_memory 547.26M
BGSAVE with 0% of keys rewritten: latest_fork_usec 3213, rdb_last_cow_size 1 MB, took 836 ms
BGSAVE with 50% of keys rewritten: latest_fork_usec 3473, rdb_last_cow_size 163 MB, took 1,707 ms

fork'un kendisi 3,2 ms sürdü; bu süre boyunca Redis hiçbir komuta cevap vermez ve veri büyüdükçe uzar. Snapshot sırasında yazma yokken ek bellek 1 MB; anahtarların yarısını yeniden yazarken 163 MB. Bu yüzden Redis'e makinenin ya da container'ın bütün belleğini verme: snapshot ve AOF rewrite anında yazma yoğunluğuna göre ek bellek gerekir.

AOF: appendfsync ve "En Fazla 1 Saniye" Ne Demek?

AOF her yazma komutunu dosyanın sonuna ekler, açılışta bu komutları tekrar çalıştırarak veriyi kurar. appendfsync verinin ne sıklıkla diske zorla yazılacağını belirler: always her yazmada (en güvenli, en yavaş), everysec saniyede bir (varsayılan), no işletim sistemine bırakır. Dokümana göre everysec bir felakette son bir saniyelik veriyi kaybettirebilir. Önce Tahmin Et'te ise hiçbir şey kaybolmadı. Çelişki yok: Redis her komutu hemen write ile işletim sistemine veriyor; fsync yalnız işletim sisteminin bellekteki tamponunu diske indiriyor. Redis process'i ölse de işletim sistemi ayaktaysa tampondaki veri diske yazılır. Bir saniyelik kayıp, makinenin kendisi çöktüğünde ya da elektrik kesildiğinde gerçekleşir; container'ı öldürerek bunu deneyemezsin.

Redis 7.0'dan beri AOF tek dosya değil, bir klasör (multi-part AOF): bir base dosyası, bir ya da daha fazla incr dosyası ve hangisinin geçerli olduğunu söyleyen bir manifest. Deneme sonrasında aof container'ında 958.086 byte'lık bir incr dosyası vardı; BGREWRITEAOF verinin o anki hâlinden yeni bir base üretti ve klasör şöyle oldu:

-rw------- 1 redis redis 29983 Nov 14 11:53 appendonly.aof.2.base.rdb
-rw------- 1 redis redis     0 Nov 14 11:53 appendonly.aof.2.incr.aof
-rw------- 1 redis redis   107 Nov 14 11:53 appendonly.aof.manifest

Base'in uzantısı .rdb: aof-use-rdb-preamble yes varsayılanıyla base RDB formatında yazılıyor, incr dosyaları ise komut log'u. 10.000 komut 30 KB'lık bir snapshot'a indi. Açılışta Redis base'i hızlıca yükleyip yalnız sonraki komutları tekrar çalıştırıyor. 08'de yüklediğimiz Functions kütüphanesi de bu dosyalarla birlikte saklanıyor; script cache'i ise saklanmıyor.

Lab'ı AOF'a Geçirmek: Yanlış ve Doğru Yol

10'daki stream ve 08'deki stok rezervasyonları kaybolmaması gereken veri; lab'daki Redis'i de AOF'la çalıştıralım. İlk akla gelen lab/compose.yaml'a bir command satırı ekleyip container'ı yeniden oluşturmak:

  redis:
    image: redis:8
    # 12. yazı: RDB'nin yanında AOF, bir bellek sınırı ve noeviction (stream ve queue'lar asla silinmemeli)
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "noeviction"]

11'deki sekiz katalog document'ı yüklüyken bunu iki yoldan denedim:

### wrong
DBSIZE before: 8
 Container bookstore-redis-lab-redis-1 Recreated
DBSIZE after: 0
### right
DBSIZE before: 8
OK
aof_rewrite_in_progress:0
aof_last_bgrewrite_status:ok
 Container bookstore-redis-lab-redis-1 Recreated
DBSIZE after: 8

Yanlış yolda (yalnız compose'u değiştirip docker compose up -d redis) veritabanı boş açıldı. AOF açıkken Redis açılışta dump.rdb'ye bakmıyor; AOF klasörü olmadığı için boş bir base oluşturdu (log'da Creating AOF base file appendonly.aof.1.base.rdb on server start). Eski container kapanırken dump.rdb'yi yazmıştı, ama yeni container'ın ilk düzgün kapanışı onu da boş veriyle ezecek. Doğru yol dokümandaki sıra: önce çalışan sunucuda CONFIG SET appendonly yes, INFO persistence'ta aof_rewrite_in_progress:0 ve aof_last_bgrewrite_status:ok'u gör, sonra compose'u değiştirip container'ı yeniden oluştur.

Backup ve Restore: BACKUP Komutu

RDB dosyası yazılırken geçici adla yazılıp tamamlanınca atomik olarak yeniden adlandırıldığı için çalışan sunucudan kopyalanabilir. AOF klasörünü kopyalamak ise bir rewrite'a denk gelirse tutarsız bir yedek verebilir; dokümandaki çözüm rewrite'ları geçici olarak kapatmak. Redis 8.10 bunun için BACKUP komut ailesini ekledi; yeni bir özellik, ben yalnız tek node'da denedim:

docker compose -f lab/compose.yaml exec redis redis-cli
127.0.0.1:6379> BACKUP START
OK
127.0.0.1:6379> BACKUP LIST
1) "/data/backupdir/appendonly.aof.2.base.rdb"
127.0.0.1:6379> XADD bookstore:orders:events * type order-created isbn 9780141439518 quantity 1
"1794657412351-0"
127.0.0.1:6379> BACKUP SEAL
OK
127.0.0.1:6379> BACKUP LIST
1) "/data/backupdir/appendonly.aof.2.base.rdb"
2) "/data/backupdir/appendonly.aof.2.incr.aof"
3) "/data/backupdir/appendonly.aof.manifest"

START bir base snapshot üretiyor, yazmalar incr dosyasına eklenmeye devam ediyor; SEAL yedeği o noktada donduruyor. Yedek, START'tan sonraki XADD'i de içeriyor. Dosyaları SEAL ile CLEANUP arasında kopyalamak gerekiyor. Komutları redis-cli'ye bir dosyadan art arda gönderdiğimde SEAL ERR No backup ready to seal (must be in the incrementing state) hatası verdi: START base snapshot'ı arka planda üretiyor ve BACKUP STATUS'ta state incrementing olmadan yedek mühürlenemiyor. Otomatikleştirirken bu durumu bekle. Restore için yeni bir Redis'i preload-file ayarıyla başlattım:

docker compose -f lab/compose.yaml cp redis:/data/backupdir/. ./backup/
docker compose -f lab/compose.yaml exec redis redis-cli BACKUP CLEANUP
docker run -d --name bookstore-restore -v ./backup:/restore redis:8 redis-server --preload-file aof:/restore/appendonly.aof.manifest

Log'da DB loaded from base file, DB loaded from incr file ve DB pre-loaded from append only file satırları çıktı ve stream'deki bütün olaylar yerindeydi. Bir yedeği gerçekten geri yükleyip doğrulamadıysan elinde yedek olduğunu bilemezsin; bu denemeyi periyodik bir iş yap. Valkey 9.1.3'te BACKUP yok (ERR unknown command).

maxmemory ve Eviction: Cache Dışında noeviction

maxmemory Redis'in kullanacağı bellek sınırı; sınıra gelince ne olacağını maxmemory-policy belirliyor. volatile-* policy'leri yalnız TTL'i (kalan yaşam süresi) olan key'leri, allkeys-* policy'leri bütün key'leri aday sayar. Cache için hangi policy'nin seçileceği Caching serisinin "IDistributedCache ve Redis'i Cache Olarak İşletmek: Serialization, Key Tasarımı, TTL ve Eviction Policy" yazısında; burada aynı Redis'te cache key'leriyle bir stream'i birlikte tutarsan ne olduğuna bakıyoruz. Sınırı 20 MB yapıp önce 1.000 olaylık sipariş stream'ini, sonra 1 KB'lık cache key'lerini yazdım:

$env:BOOKSTORE_REDIS="localhost:6390"; dotnet run --project src/Bookstore.RedisLab -- eviction
allkeys-lru: cache keys written 50,000, evicted_keys 32166, stream exists False
noeviction: cache keys written 17,862, evicted_keys 0, stream exists True
  OOM command not allowed when used memory > 'maxmemory'.
  GET still works: True, DEL works: True

allkeys-lru (LRU, Least Recently Used; en uzun süredir erişilmeyen key'i sil) cache yazmalarına hiç hata vermedi, ama bunu sipariş stream'ini sessizce silerek yaptı: stream bir kez yazılıp bir daha okunmadığı için en eski erişilen key'di. Uygulama hiçbir hata görmedi; worker'lar yalnızca yeni olay gelmediğini düşünecekti. noeviction'da ise sınıra gelince yazma komutları OOM (Out Of Memory; bellek sınırına gelindiği için komutun reddedilmesi) hatası aldı; okuma ve silme çalışmaya devam etti. Stream, queue, stok sayacı gibi kaybolmaması gereken verinin olduğu Redis'te doğru policy noeviction; hata görünür, izlenebilir ve alarm üretilebilir. Cache'i ayrı bir Redis'e (ya da ayrı bir veritabanı yerine ayrı bir instance'a) koymak iki ihtiyacı çatıştırmadan çözüyor. Redis 8.10.3'te varsayılan policy zaten noeviction, ama managed servisler farklı varsayılanla gelebiliyor; kendi ortamında CONFIG GET maxmemory-policy ile kontrol et. Redis 8.6'nın eklediği allkeys-lrm/volatile-lrm (en uzun süredir değiştirilmeyen key) yalnız okunan key'leri öncelikle sildiği için de bir stream'i korumaz; Valkey 9.1.3 bu iki policy'yi kabul etmiyor.

Container Limiti ve OOMKilled

maxmemory verilmezse sınır yok; Redis container'ın bellek sınırına kadar büyür. lab/persistence.yaml'daki limited servisi 128 MB sınırlı:

docker compose -f lab/persistence.yaml --profile oom up -d limited
$env:BOOKSTORE_REDIS="localhost:6392"; dotnet run --project src/Bookstore.RedisLab -- fill 300
docker inspect -f "State={{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}" bookstore-persistence-limited-1
32 MB written, used_memory 42.89M
64 MB written, used_memory 82.89M
96 MB written, used_memory 122.90M
after 120 MB: RedisConnectionException: SocketClosed on localhost:6392/Interactive
State=exited OOMKilled=true ExitCode=137

Redis hata dönmedi, process öldürüldü; client yalnız kopan bir bağlantı gördü ve yeniden başlayan container boş açıldı. Aynı container'da CONFIG SET maxmemory 96mb ile tekrar denediğimde 74 MB'tan sonra OOM command not allowed hatası geldi ve container ayakta kaldı. used_memory yazılan verinin yaklaşık 1,25 katı; üstüne fork'un copy-on-write ihtiyacı, client buffer'ları ve fragmentation eklenince maxmemory'yi container sınırının belirgin şekilde altında tutman gerekiyor. Kubernetes'te aynı durumun karşılığı Kubernetes serisinin "Resource Request ve Limit'ler: QoS, OOMKilled, CPU Throttling ve Container'da .NET GC" yazısında.

Bir key'in boyutu tek komutun süresini belirliyor. redis-cli --bigkeys eleman sayısına, --memkeys belleğe göre en büyük key'leri bulur; ikisi de SCAN kullandığı için production'da çalıştırılabilir. 100.000 session'ın yanında 200.000 member'lı bir ziyaretçi set'i olan veride --memkeys set'i 9.634.725 byte, 50.000 olaylık stream'i 1.385.637 byte olarak buldu. Böyle bir key'i silmek de pahalı:

$env:BOOKSTORE_REDIS="localhost:6390"; dotnet run --project src/Bookstore.RedisLab -- big-key-delete
DEL    client   33.3 ms, slowlog 30.8 ms, max PING meanwhile 31.0 ms
UNLINK client    0.5 ms, slowlog -, max PING meanwhile 1.7 ms

1 milyon member'lı set'i DEL 30,8 ms'de sildi ve o süre boyunca başka bir client'ın PING'i bekledi. UNLINK key'i hemen keyspace'ten çıkarıp belleği arka plandaki bir thread'de serbest bıraktı. Redis 8.10.3'te lazyfree-lazy-user-del varsayılanı no, yani DEL hâlâ senkron; StackExchange.Redis'in KeyDeleteAsync'i de DEL gönderiyor. Büyük olabilecek key'ler için UNLINK kullan ya da bu ayarı yes yap.

Sıcak key, çok sık erişilen key: Cluster'da (13) bütün yükü tek bir node'a bindirir. Redis 8.6 bunun için HOTKEYS'i ekledi. HOTKEYS START METRICS 2 CPU NET COUNT 3 ile izlemeyi başlatıp redis-benchmark ile fiyat hash'ine 200.000 HGET gönderdiğimde HOTKEYS GET CPU süresinde bookstore:prices'ı 15.417 µs ile ilk sıraya, 1.283 µs'lik bir session key'inin önüne koydu. Valkey 9.1.3'te HOTKEYS yok; orada redis-cli --hotkeys (LFU, Least Frequently Used policy'si gerektirir) kullanılıyor.

Fragmentation, Redis'in kullandığı bellek (used_memory) ile process'in işletim sisteminden aldığı bellek (used_memory_rss) arasındaki fark. 600.000 küçük key yazıp yarısını silince mem_fragmentation_ratio 2,27 oldu: 59 MB veri için 134 MB. CONFIG SET activedefrag yes sonrası 15 saniye bekledim, hiçbir şey değişmedi. Nedeni active-defrag-ignore-bytes varsayılanı: fragmentation 100 MB'ı geçmeden defrag başlamıyor ve buradaki fark 57 MB'tı. Eşiği 10 MB'a indirince 15 saniyede RSS (Resident Set Size; process'in fiziksel bellekte kapladığı alan) 90 MB'a, oran 1,53'e düştü.

Üretimde Dikkat

Kalıcı veri tutan Redis için başlangıç noktası: AOF açık (everysec), RDB snapshot'ları da açık (dokümanın önerdiği gibi ikisi birlikte), maxmemory container ya da makine belleğinin belirgin şekilde altında ve noeviction. İzlenecek metric'ler: INFO persistence'ta rdb_last_bgsave_status ve aof_last_write_status (ok değilse disk sorunu var; RDB yazılamadığında Redis varsayılan olarak yazmaları reddediyor), INFO stats'ta latest_fork_usec ve evicted_keys (kalıcı veri Redis'inde 0 olmalı), INFO memory'de used_memory/maxmemory oranı ve mem_fragmentation_ratio. Yedekleri başka bir makineye taşı ve geri yüklemeyi düzenli dene. Managed servislerde (Azure Managed Redis, ElastiCache) persistence ve backup servis ayarı; ama noeviction ve bellek payı kararı yine senin.

Sık Düşülen Tuzaklar

Resmî image'ı varsayılan ayarlarla kalıcı veri için kullanmak: SIGKILL'den sonra 5.000 siparişin hepsi kayboldu.

AOF'u yalnız config'i değiştirip restart ederek açmak: 8 key 0'a indi.

Cache ile stream'i aynı Redis'te allkeys-lru ile tutmak: stream hiçbir hata vermeden silindi.

Container'a bellek sınırı verip maxmemory vermemek: Redis hata yerine OOMKilled ile öldü ve boş açıldı.

Büyük key'leri DEL ile silmek: 30 ms boyunca bütün client'lar bekledi.

activedefrag yes'in hemen çalışacağını sanmak: 100 MB'lık varsayılan eşik aşılmadan başlamıyor.

Küçük Bir Deneme

★☆☆ aof servisinde appendfsync always ile write-orders 5000'i çalıştır ve süreyi everysec ile karşılaştır. Fark ne kadar?

★★☆ fork-cost'u anahtarların %100'ünü yeniden yazacak şekilde değiştir. rdb_last_cow_size veri boyutuna yaklaşıyor mu? Bu sonuca göre 547 MB'lık bir Redis'e ne kadar bellek ayırırdın?

★★★ BACKUP START, SEAL ve CLEANUP'ı çalıştırıp dosyaları kopyalayan ve kopyayı preload-file ile ayrı bir container'da açıp XLEN'i karşılaştıran küçük bir .NET konsol uygulaması yaz.

Bir sonraki yazıda tek bir Redis'in ötesine geçiyoruz: replication ve WAIT, failover'da kaybolabilen yazmalar, Sentinel ve Cluster'da hash slot'lar, hash tag'ler ve CROSSSLOT. 08'deki stok key'lerindeki süslü parantezlerin neden orada olduğunu da göreceğiz.