🚀 Redis 效能優化與監控
📌 Pipeline 批量操作
每個 Redis 命令都是一次網路往返(RTT),大量命令時網路延遲會成為瓶頸。
沒有 Pipeline:
Client → SET key1 → Server → OK → Client
Client → SET key2 → Server → OK → Client
Client → SET key3 → Server → OK → Client
= 3 次 RTT
有 Pipeline:
Client → SET key1, SET key2, SET key3 → Server
Server → OK, OK, OK → Client
= 1 次 RTT
.NET Pipeline 實作
var db = redis.GetDatabase();
// ❌ 逐一操作(慢)
for (int i = 0; i < 1000; i++)
await db.StringSetAsync($"key:{i}", $"value:{i}");
// ✅ Pipeline 批量操作(快 10 倍以上)
var batch = db.CreateBatch();
var tasks = new List<Task>();
for (int i = 0; i < 1000; i++)
{
tasks.Add(batch.StringSetAsync($"key:{i}", $"value:{i}"));
}
batch.Execute();
await Task.WhenAll(tasks);
// ✅ 或使用 FireAndForget(不需等待結果)
for (int i = 0; i < 1000; i++)
{
db.StringSet($"key:{i}", $"value:{i}",
flags: CommandFlags.FireAndForget);
}
📌 Lua Script 原子操作
Redis 執行 Lua 腳本時是 原子性 的,不會被其他命令打斷。
// 範例:限流器(每分鐘最多 100 次請求)
public class RateLimiter
{
private readonly IDatabase _db;
private const string LuaScript = @"
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current < limit then
redis.call('INCR', key)
if current == 0 then
redis.call('EXPIRE', key, window)
end
return 1 -- 允許
else
return 0 -- 拒絕
end";
public async Task<bool> IsAllowed(string clientId)
{
var result = await _db.ScriptEvaluateAsync(
LuaScript,
new RedisKey[] { $"ratelimit:{clientId}" },
new RedisValue[] { 100, 60 }); // 每 60 秒 100 次
return (int)result == 1;
}
}
// 在 Middleware 中使用
app.Use(async (context, next) =>
{
var clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var limiter = context.RequestServices.GetRequiredService<RateLimiter>();
if (!await limiter.IsAllowed(clientIp))
{
context.Response.StatusCode = 429;
await context.Response.WriteAsync("Too Many Requests");
return;
}
await next();
});
📌 大 Key 問題與解法
大 Key 會導致:記憶體不均、網路阻塞、刪除時阻塞其他操作。
# 找出大 Key
redis-cli --bigkeys
# 查看特定 Key 的記憶體用量
MEMORY USAGE user:sessions
解法
// ❌ 一個 key 存大量資料
await db.StringSetAsync("all_products",
JsonSerializer.Serialize(allProducts)); // 可能 50MB!
// ✅ 拆分成多個小 key
foreach (var product in allProducts)
{
await db.StringSetAsync(
$"product:{product.Id}",
JsonSerializer.Serialize(product));
}
// ❌ 一個 Hash 存百萬欄位
await db.HashSetAsync("user_scores",
scores.Select(s => new HashEntry(s.UserId, s.Score)).ToArray());
// ✅ 分桶(Bucket)
foreach (var score in scores)
{
var bucket = score.UserId % 100; // 分成 100 個桶
await db.HashSetAsync(
$"user_scores:{bucket}",
score.UserId.ToString(), score.Score);
}
📌 慢查詢日誌 SLOWLOG
# 設定慢查詢閾值(微秒,10000 = 10ms)
CONFIG SET slowlog-log-slower-than 10000
# 設定保留的慢查詢數量
CONFIG SET slowlog-max-len 128
# 查看慢查詢
SLOWLOG GET 10
# 查看慢查詢數量
SLOWLOG LEN
# 清除
SLOWLOG RESET
常見慢操作
| 操作 | 時間複雜度 | 建議 |
|---|---|---|
KEYS * |
O(N) | 用 SCAN 替代 |
HGETALL (大 Hash) |
O(N) | 只取需要的欄位 |
SMEMBERS (大 Set) |
O(N) | 用 SSCAN 替代 |
DEL (大 key) |
O(N) | 用 UNLINK 非同步刪除 |
FLUSHDB |
O(N) | 用 FLUSHDB ASYNC |
// ✅ 用 SCAN 替代 KEYS(不阻塞)
var server = redis.GetServer("localhost:6379");
await foreach (var key in server.KeysAsync(pattern: "product:*"))
{
Console.WriteLine(key);
}
// ✅ 用 UNLINK 替代 DEL(非同步刪除大 key)
await db.KeyDeleteAsync("big_key", CommandFlags.FireAndForget);
📌 Redis INFO 監控指標
# 查看所有資訊
INFO
# 查看特定區段
INFO memory
INFO stats
INFO clients
INFO replication
重要監控指標
| 指標 | 說明 | 警戒值 |
|---|---|---|
used_memory |
已用記憶體 | 接近 maxmemory |
connected_clients |
連線數 | > 1000 |
instantaneous_ops_per_sec |
每秒操作數 | 接近瓶頸 |
hit_rate |
命中率 | < 90% |
evicted_keys |
被淘汰的 key 數 | > 0 |
blocked_clients |
阻塞的客戶端 | > 0 |
// .NET 中取得 Redis 資訊
var server = redis.GetServer("localhost:6379");
var info = await server.InfoAsync();
foreach (var group in info)
{
Console.WriteLine($"=== {group.Key} ===");
foreach (var pair in group)
Console.WriteLine($" {pair.Key}: {pair.Value}");
}
// 取得特定指標
var memoryInfo = (await server.InfoAsync("memory"))
.SelectMany(g => g)
.ToDictionary(p => p.Key, p => p.Value);
Console.WriteLine($"已用記憶體: {memoryInfo["used_memory_human"]}");
📌 持久化策略:RDB vs AOF
| 特性 | RDB | AOF |
|---|---|---|
| 方式 | 定時快照 | 追加寫入日誌 |
| 檔案大小 | 較小 | 較大 |
| 恢復速度 | 快 | 慢 |
| 資料安全 | 可能丟失最近快照後的資料 | 最多丟 1 秒 |
| 效能影響 | fork 時短暫阻塞 | 持續寫入 |
# RDB 設定(redis.conf)
save 900 1 # 900 秒內至少 1 次修改則快照
save 300 10 # 300 秒內至少 10 次修改則快照
save 60 10000 # 60 秒內至少 10000 次修改則快照
# AOF 設定
appendonly yes
appendfsync everysec # 每秒同步(推薦)
# appendfsync always # 每次寫入都同步(最安全但慢)
# appendfsync no # 由 OS 決定(最快但不安全)
推薦: 同時開啟 RDB + AOF,兼顧效能和安全。
📌 Azure Cache for Redis
// appsettings.json
{
"ConnectionStrings": {
"Redis": "your-cache.redis.cache.windows.net:6380,password=xxx,ssl=True,abortConnect=False"
}
}
Azure 方案比較
| 方案 | 記憶體 | 價格/月 | 適用 |
|---|---|---|---|
| Basic C0 | 250MB | ~$16 | 開發測試 |
| Standard C1 | 1GB | ~$60 | 小型生產 |
| Premium P1 | 6GB | ~$200 | 企業級 |
| Enterprise E10 | 12GB | ~$400 | 大規模 |
📌 .NET 效能最佳實踐
// 1. ConnectionMultiplexer 必須是 Singleton
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(config));
// 2. 避免使用 KEYS,改用 SCAN
// ❌ db.Execute("KEYS", "*");
// ✅ server.Keys(pattern: "prefix:*");
// 3. 序列化用 System.Text.Json(比 Newtonsoft.Json 快)
var json = JsonSerializer.Serialize(obj);
// 4. 大量讀取用 Pipeline
var batch = db.CreateBatch();
var tasks = keys.Select(k => batch.StringGetAsync(k)).ToArray();
batch.Execute();
var results = await Task.WhenAll(tasks);
// 5. 不需要結果時用 FireAndForget
await db.StringSetAsync(key, value, flags: CommandFlags.FireAndForget);
// 6. 合理設定 TTL,避免記憶體膨脹
await db.StringSetAsync(key, value, TimeSpan.FromMinutes(30));
// 7. 使用 Hash 存物件(比整個 JSON 更省記憶體)
await db.HashSetAsync("user:1001", entries);
// 8. 監控連線池狀態
var status = redis.GetStatus();
Console.WriteLine(status);
🔑 重點整理
- Pipeline 把多個命令合併成一次網路往返,大幅提升效能
- Lua Script 保證原子性,適合限流器、分散式鎖等場景
- 避免 大 Key,用拆分或分桶策略
- 用 SCAN 替代 KEYS,用 UNLINK 替代 DEL
- 同時開啟 RDB + AOF 持久化
- Azure Cache for Redis 是雲端最方便的方案