Redis'te Atomiklik: MULTI/EXEC, WATCH, Lua Script'leri ve Redis Functions
Redis'te MULTI/EXEC komutları araya kimse girmeden sırayla çalıştırır ama rollback yapmaz; okumaya göre karar veren çok adımlı işler için doğru araç çoğu zaman bir Lua script'idir. Gerçek çıktılarla 100 kopyalık stoğun 200 kez satılması, WATCH ile 14.620 retry, StackExchange.Redis condition'ları, LuaScript.Prepare ile stok rezervasyonu, script cache, restart'ta kaybolmayan Redis Functions, Redis 8.8'in INCREX'i ve sunucuyu kilitleyen uzun script.
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çı) Dune'un depoda son 100 kopyası kaldı ve bir kampanya duyurusundan sonra 200 müşteri aynı anda "satın al"a basıyor. Stok Redis'te bir sayaç: bookstore:stock:{9780441013593}. İlk akla gelen kod önce okuyup sonra düşürmek:
long stock = (long)await db.StringGetAsync(StockKey); // oku ...
if (stock < 1)
{
return false;
}
await db.StringDecrementAsync(StockKey); // ... sonra yaz
return true;
200 eşzamanlı istekten kaçı başarılı olur, stokta kaç kopya kalır? Aynı işi WATCH tabanlı bir transaction ile ve tek bir Lua script'iyle yapınca sonuç ve süre nasıl değişir?
dotnet run --project src/Bookstore.RedisLab -- stock-race
check-then-act sold 200 stock left -100 retries 0 5 ms
transaction sold 100 stock left 0 retries 14620 5245 ms
lua sold 100 stock left 0 retries 0 6 ms
Oku-sonra-yaz yaklaşımı 100 kopyalık stokla 200 kopya sattı ve stok -100'e düştü. Her DECR kendi başına atomik, ama okuma ile yazma arasındaki boşlukta diğer 199 istek de aynı "stok var" cevabını gördü; 03'teki GET + SET kaybının aynısı. Transaction doğru sonucu verdi ama 14.620 kez yeniden denedi ve 5,2 saniye sürdü. Lua script'i hem doğru hem 6 ms. Bu yazıda Redis'te birden fazla adımı tek adım gibi çalıştırmanın yollarını anlatıyoruz: MULTI/EXEC ve neden rollback yapmadığı, WATCH ile optimistic concurrency ve StackExchange.Redis'teki karşılığı, Lua script'leri, Redis Functions, Redis 8.8'in INCREX'i ve uzun süren script'lerin bedeli.
MULTI/EXEC: Kuyruğa Al, Sırayla Çalıştır, Rollback Yok
MULTI ile başlayan komutlar hemen çalışmaz, bağlantıya özel bir kuyruğa alınır (QUEUED); EXEC hepsini arka arkaya, araya başka bir client'ın komutu girmeden çalıştırır. Bunu iki hata türüyle deneyelim:
SET bookstore:stock:{9780441013593} 10
MULTI
DECRBY bookstore:stock:{9780441013593} 2
LPUSH bookstore:stock:{9780441013593} oops
INCR bookstore:order:id-seq
EXEC
GET bookstore:stock:{9780441013593}
MULTI
DECRBY bookstore:stock:{9780441013593} 2
DECRBY bookstore:stock:{9780441013593}
EXEC
GET bookstore:stock:{9780441013593}
EVAL "redis.call('DECRBY', KEYS[1], 2) redis.call('LPUSH', KEYS[1], 'oops') return 'unreachable'" 1 bookstore:stock:{9780441013593}
GET bookstore:stock:{9780441013593}
docker compose -f lab/compose.yaml exec -T redis redis-cli < lab/scripts/08-multi.txt
OK
OK
QUEUED
QUEUED
QUEUED
1) (integer) 8
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 1
"8"
OK
QUEUED
(error) ERR wrong number of arguments for 'decrby' command
(error) EXECABORT Transaction discarded because of previous errors.
"8"
(error) WRONGTYPE Operation against a key holding the wrong kind of value script: 03dca892964cb6b3fa64bf026533af8e3403e715, on @user_script:1.
"6"
İlk transaction'da ikinci komut çalışma anında hata verdi (string bir key'e LPUSH), ama birinci ve üçüncü komut uygulandı: stok 8 oldu, sipariş numarası arttı. Redis rollback yapmıyor; EXEC'in garantisi "hepsi ya da hiçbiri" değil, "araya kimse girmeden sırayla". İkinci transaction'da hata komut kuyruğa alınırken yakalandı (eksik argüman) ve EXEC bütün transaction'ı EXECABORT ile reddetti. Yani syntax hataları her şeyi iptal eder, çalışma anı hataları etmez. SQL Server'daki transaction'larla karıştırma: orada bir hata bütün işi geri alır, burada almaz. Bu yüzden transaction'a koyduğun komutların çalışma anında hata vermeyeceğinden (doğru tip, doğru key) emin olman gerekiyor.
Son iki satır aynı durumun Lua'daki karşılığı. Redis dokümanı script'lerin atomik çalıştığını ve script'in etkilerinin ya hiç olmamış ya tamamen olmuş olacağını söylüyor; ölçümde ise hata veren script'in ilk komutu (DECRBY) kaldı ve stok 8'den 6'ya düştü. Dokümandaki "atomik", script çalışırken başka hiçbir komutun araya girmemesi anlamında; hata durumunda geri alma yok. Kontrolleri script'in başında, yazmadan önce yap.
WATCH ve StackExchange.Redis'te Condition
MULTI/EXEC'in içinde okuma yapıp sonuca göre karar veremezsin, çünkü komutlar EXEC'e kadar çalışmaz. Redis'in buna çözümü WATCH: bir key'i izlemeye alırsın, değerini okursun, kararını verirsin ve MULTI/EXEC gönderirsin; WATCH'tan sonra key'i başka biri değiştirdiyse EXEC hiçbir şey yapmadan nil döner ve baştan denersin. Bu bir optimistic concurrency: kilit yok, çakışma olursa retry var.
StackExchange.Redis'te WATCH'u doğrudan gönderemezsin: bütün uygulama aynı bağlantıyı paylaştığı (02) için bir çağıranın WATCH'u diğerinin komutlarıyla karışırdı. Paket bunun yerine condition'lar sunuyor: AddCondition ile verdiğin koşul için WATCH, kontrol ve MULTI/EXEC'i kendisi, başka komutlar araya girmeyecek şekilde gönderiyor. Önce Tahmin Et'teki transaction kolu:
while (true)
{
RedisValue current = await db.StringGetAsync(StockKey);
if ((long)current < 1)
{
return false;
}
ITransaction tx = db.CreateTransaction();
tx.AddCondition(Condition.StringEqual(StockKey, current)); // WATCH + kontrol
Task<long> decremented = tx.StringDecrementAsync(StockKey); // kuyrukta, henüz gönderilmedi
if (await tx.ExecuteAsync()) // MULTI ... EXEC
{
await decremented;
return true;
}
Interlocked.Increment(ref retries); // başkası değiştirmiş
}
Doğru sonuç, ama 200 istek aynı key üzerinde yarıştığı için her başarılı satışa ortalama 146 başarısız deneme düştü. Optimistic concurrency çakışmanın nadir olduğu yerde iyi çalışır: bir müşterinin kendi sepeti, nadiren güncellenen bir ayar. Herkesin aynı key'e yazdığı yerde (son kopyalar, popüler bir sayaç) retry'lar hem Redis'i hem uygulamayı meşgul eder; orada tek komut ya da script daha doğru araç.
Kodda bir ayrıntı daha var: tx.StringDecrementAsync'in döndürdüğü task'ı ExecuteAsync'ten önce await etmiyoruz. Transaction'a eklenen komutlar ExecuteAsync çağrılana kadar gönderilmez; task'ı önce await edersen asla tamamlanmaz ve kod sessizce takılır. Paketin dokümanı bunu en sık yapılan hata olarak anlatıyor ve 3.x'teki analyzer bunu SER305 kodlu bir derleme hatası olarak işaretliyor. Koşul tutmazsa ExecuteAsync false döner ve kuyruktaki task'lar iptal edilir.
Bookstore: Haftalık Snapshot Tek Adımda
06'da haftalık çok satanlar snapshot'ını üretirken, aynı anda gelen iki isteğin birbirinin geçici key'lerini silmemesi için her hesaplamaya rastgele adlı geçici key'ler vermiştik. MULTI/EXEC ile buna gerek kalmıyor: bütün adımlar araya kimse girmeden çalıştığı için geçici key'leri başka bir client hiç görmüyor. Koşul olarak da "snapshot hâlâ yok mu?" kontrolünü ekledik; başka bir istek az önce ürettiyse transaction hiç çalışmıyor:
// skor = satılan adet * 10 + satış olan gün sayısı (0-7): eşit satışta daha istikrarlı satan önce
// MULTI/EXEC: bütün hesaplama tek birim olarak çalışır, geçici key'leri başka client görmez
private static async Task BuildWeekAsync(IDatabase db, DateOnly today, RedisKey week)
{
RedisKey[] days = [.. Enumerable.Range(0, 7).Select(i => RedisKeys.Bestsellers(today.AddDays(-i)))];
RedisKey sold = RedisKeys.BestsellersWeekPart(today, "sold");
RedisKey daysWithSales = RedisKeys.BestsellersWeekPart(today, "days");
ITransaction tx = db.CreateTransaction();
tx.AddCondition(Condition.KeyNotExists(week)); // başkası az önce üretti mi?
Task[] queued =
[
tx.SortedSetCombineAndStoreAsync(SetOperation.Union, sold, days), // ZUNIONSTORE (SUM)
tx.SortedSetCombineAndStoreAsync(SetOperation.Union, daysWithSales, days, null, Aggregate.Count), // AGGREGATE COUNT (8.8+)
tx.SortedSetCombineAndStoreAsync(SetOperation.Union, week, [sold, daysWithSales], [10, 1]), // WEIGHTS 10 1
tx.KeyDeleteAsync([sold, daysWithSales]),
tx.KeyExpireAsync(week, SnapshotLifetime),
];
if (await tx.ExecuteAsync())
{
await Task.WhenAll(queued);
}
}
06'daki gibi 8 eşzamanlı istekle denedim; liste doğru çıktı ve geriye yalnız bookstore:bestsellers:week:20261114 kaldı. Buradaki transaction'ın bir maliyeti var: yedi günlük birleştirme süresince sunucu başka bir şey yapmıyor. Günlük set'ler binlerce kitapla küçük olduğu için sorun değil; milyonlarca member'lı set'lerde bu adımı bir background job'a taşımak gerekir.
Lua Script'leri: EVAL, EVALSHA ve Script Cache
Lua script'i "oku, karar ver, yaz" akışının tamamını sunucuda, tek adımda çalıştırır; araya kimse giremez, retry gerekmez. Bookstore.Api'de stok rezervasyonu artık böyle. Script aynı siparişi iki kez rezerve etmiyor (idempotent) ve stok yetmezse hiçbir şey yazmıyor:
// Stoğu kontrol et ve tek atomik adımda rezerve et; aynı sipariş iki kez rezerve edilmez
private static readonly LuaScript Reserve = LuaScript.Prepare("""
if redis.call('HEXISTS', @reservationsKey, @orderId) == 1 then
return -2
end
local stock = tonumber(redis.call('GET', @stockKey) or '0')
if stock < tonumber(@quantity) then
return -1
end
redis.call('HSET', @reservationsKey, @orderId, @quantity)
return redis.call('DECRBY', @stockKey, @quantity)
""");
app.MapPost("/inventory/{isbn}/reservations", async (string isbn, ReservationRequest request, IConnectionMultiplexer redis) =>
{
RedisResult result = await Reserve.EvaluateAsync(redis.GetDatabase(), new
{
stockKey = RedisKeys.Stock(isbn),
reservationsKey = RedisKeys.StockReservations(isbn),
orderId = request.OrderId,
quantity = request.Quantity
});
long left = (long)result;
return left switch
{
-1 => Results.Conflict(new { error = "not enough stock" }),
-2 => Results.Ok(new { request.OrderId, alreadyReserved = true }),
_ => Results.Created($"/inventory/{isbn}/reservations/{request.OrderId}", new { request.OrderId, stockLeft = left })
};
});
curl -X PUT http://localhost:5080/inventory/9780441013593/stock -H "Content-Type: application/json" -d '{"quantity":3}'
{"isbn":"9780441013593","stock":3}
curl -s -w " %{http_code}" -X POST http://localhost:5080/inventory/9780441013593/reservations -H "Content-Type: application/json" -d '{"orderId":"order-1001","quantity":2}'
{"orderId":"order-1001","stockLeft":1} 201
curl -s -w " %{http_code}" -X POST http://localhost:5080/inventory/9780441013593/reservations -H "Content-Type: application/json" -d '{"orderId":"order-1001","quantity":2}'
{"orderId":"order-1001","alreadyReserved":true} 200
curl -s -w " %{http_code}" -X POST http://localhost:5080/inventory/9780441013593/reservations -H "Content-Type: application/json" -d '{"orderId":"order-1002","quantity":2}'
{"error":"not enough stock"} 409
curl -s -w " %{http_code}" -X POST http://localhost:5080/inventory/9780441013593/reservations -H "Content-Type: application/json" -d '{"orderId":"order-1003","quantity":1}'
{"orderId":"order-1003","stockLeft":0} 201
Key adlarındaki süslü parantezler bilinçli: {9780441013593} bir hash tag. Redis Cluster'da bir script'in dokunduğu bütün key'ler aynı slot'ta olmalı; hash tag, slot hesabına yalnız parantez içini kattığı için bir kitabın stok ve rezervasyon key'leri hep aynı slot'a düşüyor. Ayrıntısı 13'te.
LuaScript.Prepare, @isim parametrelerini anonim object'in property'lerinden dolduruyor. Ne gönderdiğini MONITOR ile izledim: script'teki bütün @ parametreleri ARGV[n]'ye çevrilmişti, RedisKey tipindeki parametreler ise ayrıca KEYS listesinde de gönderiliyordu (EVALSHA … 1 bookstore:stock:{9780441013593} bookstore:stock:{9780441013593} 2). Dokümana göre bir script'in eriştiği bütün key adları KEYS olarak bildirilmeli; Cluster yönlendirmesi ve ACL (Access Control List; kullanıcı bazında komut ve key izinleri) kontrolleri buna dayanıyor. Elle ScriptEvaluateAsync kullanıyorsan key'leri KEYS'ten, diğer değerleri ARGV'den geçir; script içinde key adı üretme.
Her EVAL script'in tamamını ağdan gönderir. Redis her script'i SHA1 (metinden sabit uzunlukta bir özet üreten hash algoritması) özetiyle bir script cache'inde tutar; EVALSHA yalnız özeti gönderir. Bu cache kalıcı değil: restart, failover ve SCRIPT FLUSH onu boşaltır.
dotnet run --project src/Bookstore.RedisLab -- script-cache
sha1: c781f53432865d71fd17cc47443e4bd1ee4cb0fb
EVALSHA: 3
cached on server: True
after SCRIPT FLUSH, cached: False
LoadedLuaScript: 1
LuaScript: -1
Cache boşaldıktan sonra bile LoadedLuaScript çağrısı çalıştı: MONITOR'da önce EVALSHA'nın NOSCRIPT hatası aldığını, ardından paketin script'i SCRIPT LOAD ve EVAL ile yeniden gönderdiğini gördüm. Son satırdaki -1 bir hata değil, stok 1'e düştüğü için 2 kopyalık rezervasyonun reddedilmesi. Yani StackExchange.Redis'le script cache'i elle yönetmen gerekmiyor; ama script'i her istekte string birleştirerek üretme. Her farklı metin ayrı bir SHA1 ve cache'te ayrı bir girdi demek; dokümanın uyardığı gibi bu, sunucu belleğini sonsuza kadar büyütür.
Redis Functions: Script'i Deploy'dan Ayırmak
Lua script'i uygulamanın parçası: kodla birlikte gelir, cache'te geçici durur. Redis 7.0 ile gelen Functions ise sunucunun parçası: bir kütüphane olarak bir kez yüklenir, adıyla çağrılır, RDB (verinin belirli anlarda diske yazılan snapshot'ı) ve AOF (yazma komutlarının sırayla eklendiği log dosyası) ile saklanır ve replica'lara gider. Bookstore'un rezervasyon mantığı Functions olarak:
#!lua name=bookstore
local function reserve_stock(keys, args)
local order_id = args[1]
local quantity = tonumber(args[2])
if redis.call('HEXISTS', keys[2], order_id) == 1 then
return -2
end
local stock = tonumber(redis.call('GET', keys[1]) or '0')
if stock < quantity then
return -1
end
redis.call('HSET', keys[2], order_id, quantity)
return redis.call('DECRBY', keys[1], quantity)
end
redis.register_function('reserve_stock', reserve_stock)
$ docker compose -f lab/compose.yaml exec -T redis redis-cli -x FUNCTION LOAD REPLACE < lab/functions/bookstore.lua
bookstore
$ dotnet run --project src/Bookstore.RedisLab -- fcall
FCALL reserve_stock order-1001 x2 -> 1
FCALL reserve_stock order-1001 x2 -> -2
FCALL reserve_stock order-1002 x2 -> -1
FCALL reserve_stock order-1003 x1 -> 0
stock 0, reservations: order-1001=2, order-1003=1
StackExchange.Redis'te FCALL için tipli bir method yok; ExecuteAsync("FCALL", "reserve_stock", 2, stockKey, reservationsKey, orderId, quantity) ile gönderiyorum. Farkı görmek için bir script'i cache'e yükleyip Redis container'ını yeniden başlattım:
127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"d3c21d0c2b9ca22f82737626a27bcaf5d288f99f"
$ docker compose -f lab/compose.yaml restart redis
Container bookstore-redis-lab-redis-1 Restarting
Container bookstore-redis-lab-redis-1 Started
127.0.0.1:6379> SCRIPT EXISTS d3c21d0c2b9ca22f82737626a27bcaf5d288f99f
1) (integer) 0
127.0.0.1:6379> FUNCTION LIST
1) 1) "library_name"
2) "bookstore"
3) "engine"
4) "LUA"
127.0.0.1:6379> SET bookstore:stock:{9780141439518} 5
OK
127.0.0.1:6379> FCALL reserve_stock 2 bookstore:stock:{9780141439518} bookstore:stock:{9780141439518}:reservations order-1004 2
(integer) 3
Script cache restart'ta boşaldı, kütüphane ise yerinde (container kapanırken RDB'ye yazıldı ve açılışta geri yüklendi). Bunun bir bedeli de var: Functions artık uygulama deploy'undan ayrı bir şey. Kütüphaneyi kim, ne zaman, hangi sürümle yüklüyor sorusunun bir cevabı olmalı; tipik çözüm deploy pipeline'ında FUNCTION LOAD REPLACE adımı ve kütüphane adına ya da function adına sürüm eklemek (reserve_stock_v2). Eski ve yeni uygulama sürümleri bir süre yan yana çalışacaksa iki function'ı da yüklü tut. Valkey 9.1.3'te aynı kütüphane FUNCTION LOAD ile sorunsuz yüklendi; Functions ve Lua iki tarafta da var.
INCREX: Sınırlı Sayaç ve Expire Tek Komutta
Bazı script'ler çok yaygın bir işi yapar: bir sayacı artır, bir sınırı aşmasın, ilk artışta süre ver. Redis 8.8 bunu INCREX komutuyla tek adım yaptı. Bookstore'da bir müşteri 10 dakikada en fazla 3 doğrulama kodu isteyebilsin:
127.0.0.1:6379> INCREX bookstore:verify:7:requests BYINT 1 UBOUND 3 EX 600 ENX
1) (integer) 1
2) (integer) 1
127.0.0.1:6379> INCREX bookstore:verify:7:requests BYINT 1 UBOUND 3 EX 600 ENX
1) (integer) 2
2) (integer) 1
127.0.0.1:6379> INCREX bookstore:verify:7:requests BYINT 1 UBOUND 3 EX 600 ENX
1) (integer) 3
2) (integer) 1
127.0.0.1:6379> INCREX bookstore:verify:7:requests BYINT 1 UBOUND 3 EX 600 ENX
1) (integer) 3
2) (integer) 0
127.0.0.1:6379> TTL bookstore:verify:7:requests
(integer) 600
Cevap iki sayı: sayacın yeni değeri ve gerçekten uygulanan artış. Dördüncü istekte sınır (UBOUND 3) aşılacağı için artış uygulanmadı ve ikinci sayı 0 döndü; SATURATE verseydik sayaç sınırda sabitlenirdi. ENX, süreyi yalnız key'in henüz bir TTL'i (key'in kalan yaşam süresi) yoksa koyuyor; 03'te gördüğümüz "her artışta pencereyi sıfırlama" hatası burada da geçerli. StackExchange.Redis 3.4.35'te StringIncrementAsync'in sınır ve Expiration alan overload'u bu komutu gönderiyor ve StringIncrementResult<long> döndürüyor:
IDatabase db = redis.GetDatabase();
// INCREX key BYINT 1 UBOUND 3 EX 600 ENX: 10 dakikada en fazla 3 kod, tek atomik adım (Redis 8.8+)
StringIncrementResult<long> requests = await db.StringIncrementAsync(RedisKeys.VerificationRequests(customerId), 1,
new Expiration(RequestWindow, ExpirationFlags.ExpireIfNotExists), lowerBound: null, upperBound: MaxCodesPerWindow);
if (requests.AppliedIncrement == 0)
{
return Results.Json(new { error = "too many verification codes requested, try again later" }, statusCode: 429);
}
curl -s -w " %{http_code}" -X POST http://localhost:5080/customers/7/verification-code
{"expiresInSeconds":300} 202
# iki istek daha: 202, 202
curl -s -w " %{http_code}" -X POST http://localhost:5080/customers/7/verification-code
{"error":"too many verification codes requested, try again later"} 429
Bu basit bir sabit pencere (fixed window) sayacı. Gerçek bir rate limiter'ın algoritma seçimi ve ASP.NET Core'daki karşılığı ASP.NET Core Güvenliği serisinin "Rate Limiting Derinlemesine" yazısında. INCREX Valkey 9.1.3'te yok (ERR unknown command); orada aynı işi küçük bir Lua script'iyle yaparsın.
Uzun Script'ler, Cluster ve Güvenlik
Script atomik çalıştığı için çalıştığı sürece sunucu başka hiçbir isteğe cevap vermez. Bir rapor işinin yanlışlıkla 8 saniyelik bir döngü çalıştırdığını düşün:
dotnet run --project src/Bookstore.RedisLab -- busy-script
5.0s PING failed: RedisServerException: BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
5.5s SCRIPT KILL: OK
5.5s script: ERR Script killed by user with SCRIPT KILL... script: 8d2e089ab779c9a37464addd8796b1df304bee16, on @user_script:1.
5.5s PING after kill: 0.7 ms
API'nin 0,5. saniyede gönderdiği PING cevapsız bekledi. Script busy-reply-threshold (varsayılan 5000 ms) sınırını geçince Redis bekleyen ve yeni gelen komutlara BUSY hatası döndürmeye başladı; bu noktada kabul ettiği neredeyse tek komut SCRIPT KILL. Önemli bir sınır: SCRIPT KILL yalnız henüz hiçbir şey yazmamış bir script'i durdurabilir. Yazmaya başlamış bir script'i durdurmak atomikliği bozacağı için tek seçenek SHUTDOWN NOSAVE, yani sunucuyu kaydetmeden kapatmak. Script'leri kısa tut: döngüleri sınırla, büyük key'leri script içinde baştan sona gezme.
Cluster'da bir script ya da transaction'ın bütün key'leri aynı hash slot'ta olmalı; değilse Redis CROSSSLOT hatası verir. Stok key'lerindeki {isbn} hash tag'i bu yüzden var; slot'lar, MOVED ve hash tag tasarımı 13'ün konusu. Replikasyon tarafında Redis 7.0'dan beri script'in kendisi değil, yaptığı yazma komutları replica'lara ve AOF'a gidiyor (effects replication); dokümana göre bu yüzden script içinde TIME ya da rastgele sayı kullanmanın eski kısıtları kalktı. Yine de aynı girdiyle farklı sonuç üreten bir script'i test etmek ve hata ayıklamak zordur; zamanı ve rastgele değeri mümkünse uygulamadan argüman olarak geçir.
Lua motoru Redis'in en çok güvenlik açığı çıkan parçalarından biri. Ekim 2025'te duyurulan CVE (Common Vulnerabilities and Exposures; güvenlik açıklarına verilen ortak kimlik numarası) kaydı CVE-2025-49844, Lua'daki bir use-after-free hatasıydı ve kimliği doğrulanmış bir kullanıcının özel hazırlanmış bir script'le uzaktan kod çalıştırmasına yol açabiliyordu. Redis'in blog'daki güvenlik duyurusu CVSS (açıkların ciddiyetini 0–10 arası puanlayan sistem) puanını 10,0 olarak veriyor; aynı açığın GitHub'daki resmî advisory'si (GHSA-4789-qfc9-5f9q) ise CVSS 3.1 ile 9,9 diyor. İkisi de "kritik". Düzeltme 8.2.2 ile geldi ve eski hatlara da taşındı: 8.0.4, 7.4.6, 7.2.11 ve 6.2.20. 8.8 sürüm notlarında da Lua kaynaklı başka bir use-after-free (CVE-2026-23631) düzeltmesi var. Bu sınıf açıklarda saldırganın script çalıştırabilmesi gerekiyor; korunmanın iki parçası güncel patch sürümü ve uygulama kullanıcılarına yalnız ihtiyaç duydukları komutları veren ACL'ler. İkisi de 14'ün konusu.
Üretimde Dikkat
Doğru aracı seçmek için sırayla sor: tek bir komut bu işi yapıyor mu (SET … NX, 03'teki SET … IFEQ, INCREX, ZADD … GT)? Yapıyorsa onu kullan; StackExchange.Redis 3.x'in analyzer'ı da gereksiz transaction'ları bu yönde uyarıyor. Okumaya göre karar veren çok adımlı bir iş ve çakışma yüksekse Lua ya da Functions. Çakışma düşükse ve mantık C#'ta kalsın istiyorsan condition'lı transaction. Kararı okumaya bağlı olmayan birden çok komutu birlikte çalıştırmak için koşulsuz MULTI/EXEC. Script'lerin süresini SLOWLOG ile izle; bir script her çağrıda birkaç milisaniye sürüyorsa bütün client'lara o kadar gecikme ekliyor demek. Functions kullanıyorsan kütüphane yüklemesini deploy'un bir adımı yap ve FUNCTION LIST'i sağlık kontrolüne ekle; uygulama yeni bir function'ı çağırıyor ama sunucuda yoksa her istek hata verir.
Sık Düşülen Tuzaklar
Oku-sonra-yaz ile stok düşmek: 100 kopyalık stok 200 kez satıldı.
MULTI/EXEC'i rollback yapan bir transaction sanmak: çalışma anı hatasında diğer komutlar yine uygulandı; aynısı Lua'da da geçerli.
Çok çakışan bir key'de WATCH ile retry döngüsü kurmak: 100 satış için 14.620 deneme ve 5,2 saniye.
Transaction'a eklenen komutun task'ını ExecuteAsync'ten önce await etmek: kod sessizce takılır.
Key adlarını script içinde üretmek ya da ARGV'den geçirmek: Cluster'da yanlış slot, ACL'de atlanan kontrol.
Uzun süren script: 5 saniye sonra bütün client'lar BUSY aldı; yazmaya başlamış bir script'i SCRIPT KILL durduramaz.
Script metnini her istekte değişen string birleştirmeyle üretmek: her biri script cache'te ayrı girdi olur.
Küçük Bir Deneme
★☆☆ stock-race'i 200 yerine 20 alıcıyla çalıştır. Transaction kolundaki retry sayısı ve süre nasıl değişiyor?
★★☆ Rezervasyon script'ine bir süre ekle: rezervasyon 15 dakika içinde siparişe dönüşmezse stok geri gelsin. Rezervasyonları skor olarak bitiş zamanını tutan bir sorted set'te (06) tut ve süresi dolanları stoğa geri ekleyen ikinci bir script yaz. İki script aynı rezervasyona aynı anda dokunursa ne olur?
★★★ bookstore kütüphanesine no-writes flag'li bir stock_level function'ı ekle ve FCALL_RO ile çağır. Bir replica'ya bağlanan bir client bu function'ı çağırabiliyor mu, reserve_stock'u çağırabiliyor mu?
Bir sonraki yazıda mesajlaşmaya geçiyoruz: pub/sub ve at-most-once teslimat, Cluster'da sharded pub/sub, keyspace notification'ları ve kitap fiyatı değişince bütün API instance'larına gönderilen bir cache invalidation sinyali. 05'te sözünü ettiğimiz "kuyrukta iş var" sinyalini de orada kuracağız.