---
# System prepended metadata

title: '[.NET 筆記 021] FusionCache：L1+L2 混合快取、Fail-safe、Backplane'

---

## [.NET 筆記 021] FusionCache：L1+L2 混合快取、Fail-safe、Backplane

###### 📅 2026-05-06

---

### 📌 介紹-1. 為什麼需要 FusionCache？

[上一篇 (020)](https://hackmd.io/@dada00321/S1wWC4v0bx) 用 `IDistributedCache` 把資料存進 Redis，每次存取都要經過網路，速度還是有限制

FusionCache 在 `IDistributedCache` 之上多了一層 **L1 Memory 快取**，讓最熱門的資料直接在記憶體裡拿，不需要每次都連 Redis

```
IDistributedCache (020) ：
  每次請求 → Redis (網路, ~1ms)

FusionCache (021) ：
  每次請求 → L1 Memory (本機記憶體, ~0.01ms)
           → L2 Redis (網路, ~1ms，L1 沒有才查)
           → 資料庫 (最慢，L2 也沒有才查)
```

---

### 📌 介紹-2. FusionCache 四大核心功能

#### 1. Hybrid Cache (L1 + L2)
同時管理 L1 (Memory) 和 L2 (Redis)，自動決定從哪一層取資料

#### 2. Fail-safe (失效保護)
資料庫或 Redis 掛掉時，FusionCache 不直接回錯誤，而是把**過期的舊資料先拿來用**，讓服務維持可用狀態

```
正常情況：
快取過期 → 查資料庫 → 更新快取 → 回傳新資料

Fail-safe 啟動 (資料庫掛掉) ：
快取過期 → 查資料庫 → 無回應 → 拿過期的舊資料先回傳
                                 (服務還活著，只是資料稍舊)
```

#### 3. Backplane (多節點同步)
有多台伺服器時，某一台更新了 L1 快取，其他台不會知道，資料會不一致

Backplane 透過 Redis 的 Pub/Sub 機制，讓所有節點的 L1 快取自動同步失效

```
節點 A 更新資料 → 通知 Backplane (Redis Pub/Sub)
                → 節點 B, C 的 L1 快取自動失效
                → 下次讀取時重新載入
```

#### 4. Cache Stampede 保護
同一瞬間大量請求同時發現快取失效，全部都去打資料庫，造成資料庫過載

FusionCache 會讓只有一個請求去查資料庫，其他請求等待結果，避免雪崩

---

### 📌 介紹-3. FusionCache vs IDistributedCache vs 進階範例專案

| | IDistributedCache (020) | FusionCache (021) | 進階範例專案 |
|:--|:----------------------|:-----------------|:-----------|
| L1 Memory | ❌ | ✅ | ✅ |
| L2 Redis | ✅ | ✅ | ✅ |
| Fail-safe | ❌ | ✅ | ✅ |
| Backplane | ❌ | ✅ | ✅ |
| Cache Profiles | ❌ | ✅ (自訂) | ✅ (Static, Default, Realtime, Volatile, and List) |
| Tags (批次失效) | ❌ | ✅ | ✅ |

> 💡 進階範例專案直接使用 FusionCache，020 的 `IDistributedCache` 是作為理解底層的前置知識

---

### 📌 介紹-4. 進階範例專案的 Cache Profiles

進階範例專案定義了 5 種快取設定檔，根據資料的變動頻率選不同的設定：

| Profile | 適用資料 | L1 時間 | L2 時間 | Fail-safe |
|:--------|:--------|:--------|:--------|:----------|
| `Static` | 系統設定、分類 | 10 分鐘 | 30 分鐘 | ✅ 4 小時 |
| `Default` | 商品、會員資料 | 1 分鐘 | 5 分鐘 | ✅ 2 小時 |
| `Realtime` | 餘額、庫存 | 30 秒 | 1 分鐘 | ✅ 30 分鐘 |
| `Volatile` | OTP, 驗證碼 | 15 秒 | 30 秒 | ❌ 不使用 |
| `List` | 分頁查詢結果 | 30 秒 | 2 分鐘 | ✅ 30 分鐘 |

> 💡 `Volatile` 不使用 Fail-safe，確保過期就是真的過期，不會拿舊的驗證碼來用

---

### 📌 套件版本

| 套件 | 版本 | 說明 |
|:----|:----|:----|
| `ZiggyCreatures.FusionCache` | 2.6.0 | FusionCache 核心 |
| `ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis` | 2.6.0 | 用 Redis 做 Backplane |
| `ZiggyCreatures.FusionCache.Serialization.SystemTextJson` | 2.5.0 | 序列化，把物件存入 Redis |
| `StackExchange.Redis` | 已安裝 2.12.14 | Redis 連線驅動，不需重複安裝 |
| `Microsoft.Extensions.Caching.StackExchangeRedis` | 已安裝 8.0.22 | L2 快取後端，不需重複安裝 |

---

### 📌 Step-0. 開啟專案

繼上一篇 [[.NET 筆記 020]](https://hackmd.io/@dada00321/S1wWC4v0bx)
➡️ 專案: API_test_260504

---

### 📌 Step-1. 安裝套件

鑒於範例專案指定 .NET 版本為 8.0，在終端機依序執行：

```
dotnet add package ZiggyCreatures.FusionCache --version 2.6.0
dotnet add package ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis --version 2.6.0
dotnet add package ZiggyCreatures.FusionCache.Serialization.SystemTextJson --version 2.5.0
```

> ℹ️ 安裝完後可在終端機: `dotnet list package` (或: 相依性 > 套件) 確認已經安裝

---

### 📌 Step-2. 更新 CacheSettings

打開 `Settings/CacheSettings.cs`，補上 FusionCache 需要的設定欄位 (仿照進階範例專案) ：

```csharp
namespace API_test_260504.Settings
{
    public class CacheSettings
    {
        public const string SectionName = "CacheSettings";

        /// <summary>Redis Key 前綴，避免不同專案的 Key 衝突</summary>
        public string KeyPrefix { get; set; } = "api_test";

        // ── L1 Memory 快取 ─────────────────────────────────────
        /// <summary>L1 預設快取時間 (分鐘)</summary>
        public int MemoryCacheDurationMinutes { get; set; } = 1;

        // ── L2 Redis 快取 ──────────────────────────────────────
        /// <summary>L2 預設快取時間 (分鐘)，通常比 L1 長</summary>
        public int DistributedCacheDurationMinutes { get; set; } = 5;

        // ── Fail-safe ──────────────────────────────────────────
        /// <summary>Fail-safe 最長可使用舊資料的時間 (小時)</summary>
        public double FailSafeMaxDurationHours { get; set; } = 2;

        /// <summary>Fail-safe 觸發後的節流時間 (秒)，避免頻繁觸發</summary>
        public int FailSafeThrottleDurationSeconds { get; set; } = 30;

        // ── 斷路器 ────────────────────────────────────────────
        /// <summary>Redis 掛掉時暫停嘗試連線的時間 (秒)</summary>
        public int DistributedCacheCircuitBreakerDurationSeconds { get; set; } = 10;

        // ── 向下相容 (020 筆記的欄位保留) ────────────────────
        /// <summary>020 筆記用的欄位，FusionCache 改用 KeyPrefix</summary>
        public string InstanceName { get; set; } = "api_test:";

        /// <summary>預設絕對過期時間 (分鐘)，020 向下相容</summary>
        public int DefaultAbsoluteExpirationMinutes { get; set; } = 5;

        /// <summary>預設滑動過期時間 (分鐘)，020 向下相容</summary>
        public int DefaultSlidingExpirationMinutes { get; set; } = 2;
    }
}
```

---

### 📌 Step-3. 更新 appsettings.json

在 `appsettings.json` 的 `CacheSettings` 補上新欄位：

```json
"CacheSettings": {
  "KeyPrefix": "api_test",
  "MemoryCacheDurationMinutes": 1,
  "DistributedCacheDurationMinutes": 5,
  "FailSafeMaxDurationHours": 2,
  "FailSafeThrottleDurationSeconds": 30,
  "DistributedCacheCircuitBreakerDurationSeconds": 10,
  "InstanceName": "api_test:",
  "DefaultAbsoluteExpirationMinutes": 5,
  "DefaultSlidingExpirationMinutes": 2
}
```

---

### 📌 Step-4. 建立 Cache Profiles

新增 `Caching/CacheProfiles.cs` (仿照進階範例專案的五種設定檔) ：

```csharp
using ZiggyCreatures.Caching.Fusion;

namespace API_test_260504.Caching
{
    /// <summary>
    /// 不同資料類型的快取設定檔
    /// 根據資料的變動頻率選擇不同的設定
    /// </summary>
    public static class CacheProfiles
    {
        /// <summary>靜態資料 — 變動極少 (系統設定、分類)</summary>
        public static FusionCacheEntryOptions Static => new()
        {
            Duration = TimeSpan.FromMinutes(10),
            DistributedCacheDuration = TimeSpan.FromMinutes(30),
            IsFailSafeEnabled = true,
            FailSafeMaxDuration = TimeSpan.FromHours(4),
            FailSafeThrottleDuration = TimeSpan.FromMinutes(1)
        };

        /// <summary>一般資料 — 中等變動頻率 (商品資訊、裝置列表)</summary>
        public static FusionCacheEntryOptions Default => new()
        {
            Duration = TimeSpan.FromMinutes(1),
            DistributedCacheDuration = TimeSpan.FromMinutes(5),
            IsFailSafeEnabled = true,
            FailSafeMaxDuration = TimeSpan.FromHours(2),
            FailSafeThrottleDuration = TimeSpan.FromSeconds(30),
            FactorySoftTimeout = TimeSpan.FromMilliseconds(500),
            FactoryHardTimeout = TimeSpan.FromSeconds(5),
            AllowTimedOutFactoryBackgroundCompletion = true
        };

        /// <summary>即時資料 — 高變動頻率 (庫存、狀態)</summary>
        public static FusionCacheEntryOptions Realtime => new()
        {
            Duration = TimeSpan.FromSeconds(30),
            DistributedCacheDuration = TimeSpan.FromMinutes(1),
            IsFailSafeEnabled = true,
            FailSafeMaxDuration = TimeSpan.FromMinutes(30),
            FailSafeThrottleDuration = TimeSpan.FromSeconds(10)
        };

        /// <summary>短暫資料 — 極高變動或敏感資料 (OTP, 驗證碼)</summary>
        public static FusionCacheEntryOptions Volatile => new()
        {
            Duration = TimeSpan.FromSeconds(15),
            DistributedCacheDuration = TimeSpan.FromSeconds(30),
            IsFailSafeEnabled = false   // 敏感資料不使用 Fail-safe
        };

        /// <summary>列表資料 — 分頁查詢結果</summary>
        public static FusionCacheEntryOptions List => new()
        {
            Duration = TimeSpan.FromSeconds(30),
            DistributedCacheDuration = TimeSpan.FromMinutes(2),
            IsFailSafeEnabled = true,
            FailSafeMaxDuration = TimeSpan.FromMinutes(30),
            FailSafeThrottleDuration = TimeSpan.FromSeconds(15)
        };
    }

    /// <summary>快取資料類型 Enum，對應 CacheProfiles</summary>
    public enum CacheDataType
    {
        Static,
        Default,
        Realtime,
        Volatile,
        List
    }
}
```

---

### 📌 Step-5. 更新 ICacheService 介面

打開 `Caching/ICacheService.cs`，改成和進階範例專案一致，加入 `CacheDataType`, `tags`, and `CancellationToken`：

```csharp
namespace API_test_260504.Caching
{
    public interface ICacheService
    {
        /// <summary>取得快取，沒有就執行 factory 查詢並存入快取</summary>
        Task<T?> GetOrSetAsync<T>(
            string key,
            Func<CancellationToken, Task<T?>> factory,
            CacheDataType dataType = CacheDataType.Default,
            string[]? tags = null,
            CancellationToken cancellationToken = default);

        /// <summary>直接取得快取</summary>
        Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default);

        /// <summary>直接設定快取</summary>
        Task SetAsync<T>(
            string key,
            T value,
            CacheDataType dataType = CacheDataType.Default,
            string[]? tags = null,
            CancellationToken cancellationToken = default);

        /// <summary>刪除快取</summary>
        Task RemoveAsync(string key, CancellationToken cancellationToken = default);

        /// <summary>標記快取為過期 (Fail-safe 仍可使用舊資料)</summary>
        Task ExpireAsync(string key, CancellationToken cancellationToken = default);

        /// <summary>根據 Tags 批次讓快取失效</summary>
        Task RemoveByTagsAsync(string[] tags, CancellationToken cancellationToken = default);
    }
}
```

---

### 📌 Step-6. 建立 FusionCacheService

新增 `Caching/FusionCacheService.cs` (取代 020 的 `DistributedCacheService`) ：

```csharp
using API_test_260504.Abstractions;
using API_test_260504.Settings;
using Microsoft.Extensions.Options;
using ZiggyCreatures.Caching.Fusion;

namespace API_test_260504.Caching
{
    /// <summary>
    /// FusionCache 實作
    /// 封裝 FusionCache 並提供統一的快取操作介面
    /// 仿照進階範例專案的 FusionCacheService 架構
    /// </summary>
    public sealed class FusionCacheService : ICacheService, IScopedService
    {
        private readonly IFusionCache _cache;
        private readonly CacheSettings _settings;
        private readonly ILogger<FusionCacheService> _logger;

        public FusionCacheService(
            IFusionCache cache,
            IOptions<CacheSettings> settings,
            ILogger<FusionCacheService> logger)
        {
            _cache = cache;
            _settings = settings.Value;
            _logger = logger;
        }

        public async Task<T?> GetOrSetAsync<T>(
            string key,
            Func<CancellationToken, Task<T?>> factory,
            CacheDataType dataType = CacheDataType.Default,
            string[]? tags = null,
            CancellationToken cancellationToken = default)
        {
            var fullKey = BuildKey(key);
            var options = GetOptions(dataType);

            try
            {
                return await _cache.GetOrSetAsync<T?>(
                    key: fullKey,
                    factory: async (ctx, ct) =>
                    {
                        _logger.LogInformation(
                            "Cache miss: {Key}, HasStaleValue: {HasStale}",
                            fullKey, ctx.HasStaleValue);
                        return await factory(ct);
                    },
                    options: options,
                    tags: tags,
                    token: cancellationToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogError(ex, "Cache GetOrSet error for key {Key}", fullKey);
                // 快取完全失效時，直接執行 factory 確保服務不中斷
                return await factory(cancellationToken);
            }
        }

        public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
        {
            var fullKey = BuildKey(key);
            try
            {
                var result = await _cache.TryGetAsync<T>(fullKey, token: cancellationToken);
                if (result.HasValue)
                    _logger.LogInformation("Cache hit: {Key}", fullKey);
                return result.HasValue ? result.Value : default;
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogWarning(ex, "Cache get error for key {Key}", fullKey);
                return default;
            }
        }

        public async Task SetAsync<T>(
            string key,
            T value,
            CacheDataType dataType = CacheDataType.Default,
            string[]? tags = null,
            CancellationToken cancellationToken = default)
        {
            var fullKey = BuildKey(key);
            var options = GetOptions(dataType);
            try
            {
                await _cache.SetAsync(
                    key: fullKey,
                    value: value,
                    options: options,
                    tags: tags,
                    token: cancellationToken);
                _logger.LogInformation("Cache set: {Key}, Profile: {Profile}", fullKey, dataType);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogWarning(ex, "Cache set error for key {Key}", fullKey);
            }
        }

        public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
        {
            var fullKey = BuildKey(key);
            try
            {
                await _cache.RemoveAsync(fullKey, token: cancellationToken);
                _logger.LogInformation("Cache removed: {Key}", fullKey);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogWarning(ex, "Cache remove error for key {Key}", fullKey);
            }
        }

        public async Task ExpireAsync(string key, CancellationToken cancellationToken = default)
        {
            var fullKey = BuildKey(key);
            try
            {
                // Expire 標記為過期，但 Fail-safe 仍可使用舊資料
                // Remove 則是完全刪除，Fail-safe 也無法使用
                await _cache.ExpireAsync(fullKey, token: cancellationToken);
                _logger.LogInformation("Cache expired: {Key}", fullKey);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogWarning(ex, "Cache expire error for key {Key}", fullKey);
            }
        }

        public async Task RemoveByTagsAsync(string[] tags, CancellationToken cancellationToken = default)
        {
            try
            {
                await _cache.RemoveByTagAsync(tags, token: cancellationToken);
                _logger.LogInformation("Cache removed by tags: {Tags}", string.Join(", ", tags));
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogWarning(ex, "Cache remove by tags error: {Tags}", string.Join(", ", tags));
            }
        }

        // 仿照進階範例專案：前綴由 Service 層統一管理
        private string BuildKey(string key) =>
            string.IsNullOrWhiteSpace(_settings.KeyPrefix)
                ? key
                : $"{_settings.KeyPrefix}:{key}";

        private static FusionCacheEntryOptions GetOptions(CacheDataType dataType) => dataType switch
        {
            CacheDataType.Static   => CacheProfiles.Static,
            CacheDataType.Realtime => CacheProfiles.Realtime,
            CacheDataType.Volatile => CacheProfiles.Volatile,
            CacheDataType.List     => CacheProfiles.List,
            _                      => CacheProfiles.Default
        };
    }
}
```

---

### 📌 Step-7. 建立 FusionCacheExtensions

新增 `Caching/FusionCacheExtensions.cs`，把 FusionCache 的 DI 註冊邏輯包成擴充方法 (仿照進階範例專案) ：

```csharp
using API_test_260504.Settings;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using StackExchange.Redis;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis;
using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson;

namespace API_test_260504.Caching
{
    public static class FusionCacheExtensions
    {
        /// <summary>
        /// 註冊 FusionCache 服務
        /// 包含 L1 (Memory) + L2 (Redis) + Backplane
        /// </summary>
        public static IServiceCollection AddFusionCacheServices(
            this IServiceCollection services,
            IConfiguration configuration)
        {
            var cacheSettings = configuration
                .GetSection(CacheSettings.SectionName)
                .Get<CacheSettings>() ?? new CacheSettings();

            // 1. 綁定 CacheSettings
            services.Configure<CacheSettings>(
                configuration.GetSection(CacheSettings.SectionName));

            // 2. 建立 Redis 連線 (Singleton，全程共用一個連線)
            services.AddSingleton<IConnectionMultiplexer>(sp =>
            {
                var logger = sp.GetRequiredService<ILogger<IConnectionMultiplexer>>();
                var redisConn = configuration.GetConnectionString("Redis") ?? "localhost:6379";

                var options = ConfigurationOptions.Parse(redisConn);
                options.AbortOnConnectFail = false;  // 連線失敗不立即中止，讓 Fail-safe 接管
                options.ConnectRetry = 3;
                options.ConnectTimeout = 5000;

                var multiplexer = ConnectionMultiplexer.Connect(options);

                // 訂閱連線事件，記錄 Log
                multiplexer.ConnectionFailed += (_, args) =>
                    logger.LogWarning("Redis connection failed: {EndPoint}, {FailureType}",
                        args.EndPoint, args.FailureType);

                multiplexer.ConnectionRestored += (_, args) =>
                    logger.LogInformation("Redis connection restored: {EndPoint}", args.EndPoint);

                return multiplexer;
            });

            // 3. 註冊 FusionCache (L1 + L2 + Backplane)
            services.AddFusionCache()
                .WithDefaultEntryOptions(options =>
                {
                    // 預設使用 Default Profile 的設定
                    options.Duration =
                        TimeSpan.FromMinutes(cacheSettings.MemoryCacheDurationMinutes);
                    options.DistributedCacheDuration =
                        TimeSpan.FromMinutes(cacheSettings.DistributedCacheDurationMinutes);

                    options.IsFailSafeEnabled = true;
                    options.FailSafeMaxDuration =
                        TimeSpan.FromHours(cacheSettings.FailSafeMaxDurationHours);
                    options.FailSafeThrottleDuration =
                        TimeSpan.FromSeconds(cacheSettings.FailSafeThrottleDurationSeconds);
                })
                // JSON 序列化，把物件存進 Redis
                .WithSerializer(new FusionCacheSystemTextJsonSerializer(
                    new System.Text.Json.JsonSerializerOptions
                    {
                        PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
                        PropertyNameCaseInsensitive = true
                    }))
                // L2：Redis 分散式快取
                .WithDistributedCache(sp =>
                {
                    var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
                    return new RedisCache(new RedisCacheOptions
                    {
                        ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer)
                    });
                })
                // Backplane：多節點 L1 同步
                .WithBackplane(sp =>
                {
                    var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
                    return new RedisBackplane(new RedisBackplaneOptions
                    {
                        ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer)
                    });
                });

            return services;
        }
    }
}
```

---

### 📌 Step-8. 更新 Program.cs

移除 020 的 `AddStackExchangeRedisCache`，改用 FusionCache：

```csharp
using API_test_260504.Caching;

// [.NET 筆記 021] FusionCache 取代 020 的 IDistributedCache 設定
// 020 的設定已被取代，改在 AddFusionCacheServices 裡統一處理
// [已移除] builder.Services.Configure<CacheSettings>(...);
// [已移除] builder.Services.AddStackExchangeRedisCache(...);
services.AddFusionCacheServices(builder.Configuration);
```

完整的 Program.cs 修改段落位置 (放在 Scrutor 掃描之前) ：

```csharp
// [.NET 筆記 021] FusionCache：L1 + L2 (Redis) + Backplane
builder.Services.AddFusionCacheServices(builder.Configuration);

// Scrutor 自動掃描
builder.Services.Scan(scan => scan
    .FromAssemblyOf<Program>()
    .AddClasses(c => c.AssignableTo<IScopedService>())
    .AsImplementedInterfaces()
    .AsSelf()
    .WithScopedLifetime());
```

> 💡 `FusionCacheService` 掛上了 `IScopedService`，Scrutor 會自動登記，不需要額外寫 `AddScoped`

---

### 📌 Step-9. 更新 DeviceService

`ICacheService` 的 `GetOrSetAsync` 簽名改了 (新增 `CancellationToken` 參數)，需要更新呼叫方式：

```csharp
public async Task<List<DeviceDto>> GetAllAsync()
{
    return await _cache.GetOrSetAsync(
        key: AllDevicesCacheKey,
        factory: async ct =>
        {
            _logger.LogInformation("Cache miss，查詢資料庫取得所有裝置");
            using var conn = _db.CreateConnection();
            var devices = await conn.QueryAsync<Device>("SELECT * FROM Devices");
            return devices.Select(d => d.Adapt<DeviceDto>()).ToList();
        },
        dataType: CacheDataType.List)    // 指定 Cache Profile
        ?? [];
}

public async Task<DeviceDto?> GetByIdAsync(int deviceId)
{
    return await _cache.GetOrSetAsync(
        key: DeviceCacheKey(deviceId),
        factory: async ct =>
        {
            using var conn = _db.CreateConnection();
            var device = await conn.QueryFirstOrDefaultAsync<Device>(
                "SELECT * FROM Devices WHERE Id = @Id", new { Id = deviceId });
            return device?.Adapt<DeviceDto>();
        },
        dataType: CacheDataType.Default);  // 指定 Cache Profile
}
```

再修改 Log 對應的 CacheService 介面 (下面兩處)
(1) 將 `private readonly ILogger<DistributedCacheService> _logger;`
改為 `private readonly ILogger<FusionCacheService> _logger;`

(2) 將 `ILogger<DistributedCacheService> logger,`
改為 `ILogger<FusionCacheService> logger,`

---

### 📌 Step-10. 驗證結果

確認 Redis 已啟動，執行 `https`，用 Swagger 測試：
- **第一次** `GET /api/Device`

    - ➡️ Result
![image](https://hackmd.io/_uploads/r1CEJt_Abl.png)

    - ➡️ Explanation
１２４

- **第二次 (1 分鐘內，L1 快取有效)** `GET /api/Device` 

    - ➡️ Result
![image](https://hackmd.io/_uploads/B1fvJtdRWg.png)

    - ➡️ Explanation
資料從 L1 Memory 拿，不走網路，速度最快



- **第 3 次及以後 (超過 1 分鐘後)** `GET /api/Device` (L1 過期，L2 還有效)

    - ![image](https://hackmd.io/_uploads/B1X5ytORWe.png)

    - ➡️ Explanation
FusionCache 自動去 L2 (Redis) 查，並重新暖起 L1，無需開發者手動處理

- **用 redis-cli 驗證 Key**：
➡️ Result
    - ![image](https://hackmd.io/_uploads/B1v7zKu0Ze.png)
    
- 補充：[FusionCache Log 解讀：三次呼叫的完整流程](https://hackmd.io/@dada00321/ryABfYu0Wx)

---

### 📌 總結：020 vs 021 對比

| | IDistributedCache (020) | FusionCache (021) |
|:--|:----------------------|:-----------------|
| 快取層級 | 只有 L2 Redis | L1 Memory + L2 Redis |
| Fail-safe | ❌ | ✅ |
| Backplane | ❌ | ✅ |
| Cache Profile | ❌ (只有固定過期時間) | ✅ (Static, Default, Realtime, Volatile, List) |
| Tags 批次失效 | ❌ | ✅ |
| 使用難度 | 較低 | 較高 |

> 💡 020 的 `DistributedCacheService` 和 `ICacheService` 介面已被 `FusionCacheService` 取代，但架構概念相同 (都有 `BuildKey`, `GetOrSetAsync`, `RemoveAsync`)
> 進階範例專案還有 `FusionCacheAlertingService` (告警服務) 和 Cache Tags 的進階應用，這些屬於進階用法，熟悉基本操作後可以參考進階範例專案的實作