🔐 Redis Session 管理與分散式鎖
📌 ASP.NET Core 分散式 Session
為什麼需要分散式 Session?
單機 Session(In-Memory):
User → Server A (Session 在這) ✅
User → Server B (沒有 Session) ❌ ← 負載平衡切換後 Session 不見了
分散式 Session(Redis):
User → Server A → Redis (Session) ✅
User → Server B → Redis (Session) ✅ ← 所有 Server 共用 Session
📌 設定 Redis Session Provider
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// 1. 註冊 Redis 分散式快取
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration
.GetConnectionString("Redis");
options.InstanceName = "DevLearn:Session:";
});
// 2. 註冊 Session 服務
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
var app = builder.Build();
// 3. 啟用 Session 中介軟體
app.UseSession();
使用 Session
// 設定 Session
public IActionResult Login(string username)
{
HttpContext.Session.SetString("Username", username);
HttpContext.Session.SetInt32("LoginCount",
(HttpContext.Session.GetInt32("LoginCount") ?? 0) + 1);
return Ok("登入成功");
}
// 讀取 Session
public IActionResult Profile()
{
var username = HttpContext.Session.GetString("Username");
if (username == null)
return Unauthorized("請先登入");
return Ok(new { Username = username });
}
// 存物件(需要 Extension Method)
public static class SessionExtensions
{
public static void SetObject<T>(
this ISession session, string key, T value)
{
session.SetString(key, JsonSerializer.Serialize(value));
}
public static T? GetObject<T>(
this ISession session, string key)
{
var value = session.GetString(key);
return value == null ? default : JsonSerializer.Deserialize<T>(value);
}
}
// 使用
HttpContext.Session.SetObject("Cart", new ShoppingCart { Items = items });
var cart = HttpContext.Session.GetObject<ShoppingCart>("Cart");
📌 Session vs JWT 的取捨
| 特性 | Session (Redis) | JWT |
|---|---|---|
| 狀態 | 有狀態(存在 Server) | 無狀態(存在 Client) |
| 儲存 | Redis | Cookie / LocalStorage |
| 撤銷 | 容易(刪除 Session) | 困難(需黑名單) |
| 效能 | 每次要查 Redis | 不需查 Server |
| 大小 | Cookie 只有 Session ID | Token 可能很大 |
| 適用 | 傳統 Web | API / 微服務 |
建議: 傳統 MVC 用 Session,SPA/API 用 JWT,也可以混合使用。
📌 分散式鎖(Distributed Lock)
為什麼需要分散式鎖?
沒有鎖的情況:
User A: 讀取庫存(10) → 扣減 → 寫入庫存(9)
User B: 讀取庫存(10) → 扣減 → 寫入庫存(9) ← 應該是 8!
→ 超賣問題!
有分散式鎖:
User A: 取得鎖 → 讀取(10) → 扣減 → 寫入(9) → 釋放鎖
User B: 等待鎖... → 取得鎖 → 讀取(9) → 扣減 → 寫入(8) → 釋放鎖
→ 正確!
基本實作:SETNX
public class RedisDistributedLock
{
private readonly IDatabase _db;
public RedisDistributedLock(IConnectionMultiplexer redis)
{
_db = redis.GetDatabase();
}
// 取得鎖
public async Task<bool> AcquireLockAsync(
string lockKey, string lockValue, TimeSpan expiry)
{
// SETNX:只在 key 不存在時設定
return await _db.StringSetAsync(
lockKey, lockValue, expiry, When.NotExists);
}
// 釋放鎖(用 Lua 確保原子性)
public async Task<bool> ReleaseLockAsync(
string lockKey, string lockValue)
{
// 只有持有者才能釋放(防止誤刪別人的鎖)
var script = @"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end";
var result = await _db.ScriptEvaluateAsync(
script,
new RedisKey[] { lockKey },
new RedisValue[] { lockValue });
return (int)result == 1;
}
}
使用範例:庫存扣減
public class InventoryService
{
private readonly RedisDistributedLock _lock;
private readonly AppDbContext _db;
public async Task<bool> DeductStock(int productId, int quantity)
{
var lockKey = $"lock:inventory:{productId}";
var lockValue = Guid.NewGuid().ToString();
var lockExpiry = TimeSpan.FromSeconds(10);
// 嘗試取得鎖
if (!await _lock.AcquireLockAsync(lockKey, lockValue, lockExpiry))
{
// 取不到鎖,可以重試或回傳失敗
return false;
}
try
{
var product = await _db.Products.FindAsync(productId);
if (product == null || product.Stock < quantity)
return false;
product.Stock -= quantity;
await _db.SaveChangesAsync();
return true;
}
finally
{
// 一定要釋放鎖!
await _lock.ReleaseLockAsync(lockKey, lockValue);
}
}
}
📌 RedLock 演算法
在 Redis Cluster 環境中,單個 Redis 節點的鎖不夠可靠。 RedLock 要在多數節點上取得鎖才算成功。
RedLock 流程:
1. 取得當前時間 T1
2. 在 N 個 Redis 節點上嘗試取得鎖(短超時)
3. 在多數節點(N/2 + 1)成功取得 → 鎖定成功
4. 有效時間 = 初始 TTL - (T2 - T1)
5. 如果失敗 → 在所有節點釋放鎖
// 使用 RedLock.net 套件
// dotnet add package RedLock.net
using RedLockNet.SERedis;
using RedLockNet.SERedis.Configuration;
var endPoints = new List<RedLockMultiplexer>
{
ConnectionMultiplexer.Connect("redis1:6379"),
ConnectionMultiplexer.Connect("redis2:6379"),
ConnectionMultiplexer.Connect("redis3:6379")
};
var redlockFactory = RedLockFactory.Create(endPoints);
// 取得分散式鎖
await using var redLock = await redlockFactory.CreateLockAsync(
resource: "inventory:product:1001",
expiryTime: TimeSpan.FromSeconds(30));
if (redLock.IsAcquired)
{
// 安全地執行庫存扣減
await DeductStock(1001, 1);
}
else
{
Console.WriteLine("無法取得鎖,請稍後重試");
}
📌 避免死鎖的技巧
| 技巧 | 說明 |
|---|---|
| 設定 TTL | 鎖一定要有過期時間,避免持有者掛掉永遠不釋放 |
| 唯一 lockValue | 用 GUID 標識持有者,避免誤刪別人的鎖 |
| Lua 原子釋放 | 用 Lua 腳本確保「檢查 + 刪除」是原子操作 |
| 重試機制 | 取不到鎖時,用指數退避重試 |
| 看門狗 | 長任務自動延長鎖的 TTL |
// 指數退避重試
public async Task<bool> AcquireWithRetry(
string lockKey, string lockValue,
TimeSpan expiry, int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
if (await _lock.AcquireLockAsync(lockKey, lockValue, expiry))
return true;
// 指數退避:100ms, 200ms, 400ms...
await Task.Delay(TimeSpan.FromMilliseconds(100 * Math.Pow(2, i)));
}
return false;
}
🔑 重點整理
- Redis Session 讓多台 Server 共享 Session 資料
- Session vs JWT 各有優缺,按場景選擇
- 分散式鎖 用 SETNX + TTL + Lua 實作
- RedLock 適用於 Redis Cluster 的可靠鎖定
- 鎖一定要設 TTL 和 唯一標識,避免死鎖