首頁

目前文章總數:172 篇

  

最後更新:2025年 03月 22日

0085. 分布式 Session 實戰:使用 Redis 解決部署期間的用戶會話遺失問題

日期:2025年 02月 22日

標籤: C# Redis Asp.net Core Web MVC Session

摘要:C# 學習筆記


應用所需:1. Visual Studio 2022 以上,支援 .net Core 8.0 以上
     2. Redis
解決問題:一個網站使用 Session 管理用戶的一些操作配置,但在部署站台時,由於 Session 遺失,使用者資料會不見。透過使用 Redis 來保存 Session,可以有效解決這個問題。
範例檔案:Redis 持久化 Session 範例代碼
基本介紹:本篇分為四大部分。
第一部分:問題重現過程
第二部分:解決方案
第三部分:實現過程
第四部分:Demo 驗證成果






第一部分:問題重現過程

Step 1:Web 網站

通常 Web 網站,會有 3 個部分:

1. 用戶連線 用戶訪問網站時,瀏覽器向伺服器發送請求,伺服器接收請求並返回響應。這通常涉及 HTTP 請求和響應。
2. 建立 Session 伺服器為每個用戶創建一個 Session,用於存儲用戶的臨時數據,如登入狀態、用戶偏好等。這些數據保存在伺服器端,並與該用戶的會話綁定。
3. 用戶 Cookie 伺服器會向用戶的瀏覽器發送一個 Cookie,這通常包含一個 Session ID,讓伺服器能夠識別後續請求來自同一個用戶。




Step 2:重啟時遺失 Session

但是伺服器需要更新時、Docker Container 刪除重建後,伺服器的 Session 就會遺失,導致用戶登入狀態不見。


Step 3:範例專案說明 - 更新餘額

當啟動範例專案,並且輸入金額 120 並更新
※指尚未實作 Redis 持久化 Session 前的代碼

這時會更新成 120


Step 4:範例專案說明 - 重啟伺服器

這時關閉程式,再重新啟動,剛剛的 Session 就會遺失歸 0


第二部分:解決方案

Step 1:Redis 持久化保存 Session

以架構來看,可以知道解決方案的關鍵在 Session 保存時一併寫到 Redis 中
並且當取 Session 時,若無法取得 Sessoin 則從 Redis 取得。
這部分 Redis.StackExchange 套件與 Asp.net Core 已整合得很好,可以快速實現




第三部分:實現過程

Step 1:範例專案架構

打開範例專案:Redis 持久化 Session 範例代碼,架構基本分成以下:

1. Service 實現 Reids + Session 的方法、更新餘額的商業邏輯
2. Web控制器 提供前端呼叫的 API、檢視
3. Html 畫面 使用 JQuery 呼叫 API,進行取餘額、更新餘額
4. Redis 配置 Redis 的連線位置
5. 初始化配置 依賴注入相關的Service ,並且啟用 Redis 與 Session 的綁定



Step 2:[Service] - CacheService.cs

實現 Session + Redis 雙寫入的泛型方法
完成取得更新刪除 三種實作

public class CacheService : ICacheService
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IDistributedCache _cache;
    private readonly double _expireTime = 1.5;

    public CacheService(
        IHttpContextAccessor httpContextAccessor,
        IDistributedCache cache)
    {
        _httpContextAccessor = httpContextAccessor;
        _cache = cache;
    }

    /// <summary>
    /// 1. 保存 Session、Redis
    /// </summary>                
    public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null)
    {
        var jsonData = JsonConvert.SerializeObject(value);

        // 存到 Session
        _httpContextAccessor.HttpContext?.Session.SetString(key, jsonData);

        // 存到 Redis
        await _cache.SetStringAsync(key, jsonData, new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = expiry ?? TimeSpan.FromHours(_expireTime)
        });
    }

    /// <summary>
    /// 2. 讀取 Session、Redis
    /// </summary>
    public async Task<T> GetAsync<T>(string key)
    {
        // 先從 Session 取
        var sessionData = _httpContextAccessor.HttpContext?.Session.GetString(key);
        if (!string.IsNullOrEmpty(sessionData))
        {
            return JsonConvert.DeserializeObject<T>(sessionData);
        }

        // 從 Redis 取
        var redisData = await _cache.GetStringAsync(key);
        return string.IsNullOrEmpty(redisData)
            ? default(T)
            : JsonConvert.DeserializeObject<T>(redisData);
    }

    /// <summary>
    /// 3. 移除 Session, Redis 資料
    /// </summary>
    public async Task RemoveAsync(string key)
    {
        await _cache.RemoveAsync(key);
    }
}


Step 3:[Service] - UserBalanceService.cs

管理用戶餘額的商業邏輯,核心在於管理自己的方法名稱,檢核

public class UserBalanceService : IUserBalanceService
{
    private readonly ICacheService _cacheService;
    private readonly string _balanceName = "Balance_";

    public UserBalanceService(ICacheService cacheService)
    {
        _cacheService = cacheService;
    }

    /// <summary>
    /// 更新餘額
    /// </summary>                
    public async Task UpdateBalance(int userId, decimal amount)
    {
        await _cacheService.SetAsync($"{_balanceName}{userId}", amount);
    }

    /// <summary>
    /// 取得當前餘額
    /// </summary>                
    public async Task<decimal> GetBalance(int userId, decimal amount)
    {
        return await _cacheService.GetAsync<decimal>($"{_balanceName}{userId}");
    }
}


Step 4:Web控制器

提供呼叫更新餘額、取得餘額 API
※範例關係直接使用 Get 有傳遞參數,且重要資料仍建議使用 POST

public IActionResult Index()
{
    return View();
}

/// <summary>
/// 1. 更新用戶金額
/// </summary>
[HttpGet]
public async Task<IActionResult> UpdateBalance(decimal amount)
{
    await _userBalance.UpdateBalance(1, amount);
    return Ok();
}

/// <summary>
/// 2. 取得用戶金額
/// </summary>        
[HttpGet]
public async Task<IActionResult> GetBalance(decimal amount)
{
    var currentAmount = await _userBalance.GetBalance(1, amount);
    return Ok(currentAmount);
}


Step 5:Html 畫面

呼叫 API 更新餘額、取得餘額

@{
    ViewData["Title"] = "Session 持久化";
}

<!DOCTYPE html>
<div class="container mt-4">
    <div class="card">
        <div class="card-header">
            <h3>用戶餘額管理</h3>
        </div>
        <div class="card-body">
            <div class="row mb-4">
                <div class="col">
                    <h4>當前餘額:<span id="currentBalance">0</span></h4>
                </div>
            </div>

            <div class="row">
                <div class="col">
                    <div class="input-group mb-3">
                        <input type="number" id="amountInput" class="form-control" placeholder="請輸入金額">
                        <button class="btn btn-primary" type="button" id="updateBtn">更新金額</button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

@section Scripts {
    <script>
        $(document).ready(function () {
            // 載入時取得當前餘額
            getBalance();

            // 更新按鈕點擊事件
            $("#updateBtn").click(function () {
                const amount = $("#amountInput").val();
                if (!amount) {
                    alert("請輸入金額");
                    return;
                }

                // 呼叫更新金額 API
                updateBalance(parseFloat(amount));
            });
        });

        // 取得餘額
        function getBalance() {
            $.ajax({
                url: '@Url.Action("GetBalance", "Balance")',
                type: 'GET',
                data: { amount: 0 },
                success: function (response) {
                    $("#currentBalance").text(response);
                },
                error: function () {
                    alert("取得餘額失敗");
                }
            });
        }

        // 更新餘額
        function updateBalance(amount) {
            $.ajax({
                url: '@Url.Action("UpdateBalance", "Balance")',
                type: 'GET',
                data: { amount: amount },
                success: function () {
                    alert("更新成功");
                    getBalance(); // 重新取得餘額
                    $("#amountInput").val(''); // 清空輸入框
                },
                error: function () {
                    alert("更新失敗");
                }
            });
        }
    </script>
}



Step 6:Redis 配置

取得 Redis 連線字串 RedisConnection

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "RedisConnection": "127.0.0.1:6379,abortConnect=False,connectRetry=3,connectTimeout=3000,defaultDatabase=1,syncTimeout=3000,responseTimeout=3000"
  }
}


Step 7:初始化配置

關鍵在 2. 配置 Session + Redis 這段代碼實現了 Redis + Session 的整合。
後續呼叫 .SetStringAsync() 方法操作都會寫入到 Redis 中

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

// 1. 取得 Redis 連線配置
var redisConnection = builder.Configuration.GetConnectionString("RedisConnection");

// 2. 配置 Session + Redis
// ※ Nuget 安裝 => Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = redisConnection;
    options.InstanceName = "MySession_";
});

// 3. 配置 Session 初始配置
builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromHours(1.5);
    options.Cookie.IsEssential = true;
});


// 4. 註冊 HttpContextAccessor
builder.Services.AddHttpContextAccessor();

// 5. 依賴性注入相關 Scope Singleton
builder.Services.AddSingleton<ICacheService, CacheService>();
builder.Services.AddScoped<IUserBalanceService, UserBalanceService>();


var app = builder.Build();

// 6. 應用程式啟用 Session
app.UseSession();

//... 略

app.Run();




第四部分:Demo 驗證成果

Step 1:DEMO - 驗證成功

再次啟動程式後,輸入金額 2000 ,然後重啟機器,可以發現金額不變

Step 2:檢查 Redis

實際上 Redis 中已經保存好此 Session 只要不到期(Session 與 Redis),用戶的瀏覽器都可以抓到該值