ASP.NET Core - 快取之分散式快取

啊晚發表於2023-04-19

分散式快取是由多個應用伺服器共享的快取,通常作為訪問它的應用伺服器的外部服務進行維護。 分散式快取可以提高 ASP.NET Core 應用的效能和可伸縮性,尤其是當應用由雲服務或伺服器場託管時。

與其他將快取資料儲存在單個應用伺服器上的快取方案相比,分散式快取具有多個優勢。

當分發快取資料時,資料:

  • 在多個伺服器的請求之間保持一致(一致性)。
  • 在進行伺服器重啟和應用部署後仍然有效。
  • 不使用本地記憶體。

1. 分散式快取的使用

.NET Core 框架下對於分散式快取的使用是基於 IDistributedCache 介面的,透過它進行抽象,統一了分散式快取的使用方式,它對快取資料的存取都是基於 byte[] 的。

IDistributedCache 介面提供以下方法來處理分散式快取實現中的項:

  • Get、GetAsync:如果在快取中找到,則接受字串鍵並以 byte[] 陣列的形式檢索快取項。
  • Set、SetAsync:使用字串鍵將項(作為 byte[] 陣列)新增到快取。
  • Refresh、RefreshAsync:根據鍵重新整理快取中的項,重置其可調到期超時(如果有)。
  • Remove、RemoveAsync:根據字串鍵刪除快取項。

使用的時候只需要將其透過容器注入到相應的類中即可。

2. 分散式快取的接入

分散式快取是基於特定的快取應用實現的,需要依賴特定的第三方應用,在接入特定的分散式快取應用時,需要應用對於的 Nuget 包,微軟官方提供了基於 SqlServer 、Redis 實現分散式快取的 Nuget 包,還推薦了基於 Ncache 的方案,除此之外還有像 Memcache 之類的方案,微軟雖然沒有提供相應的 Nuget 包,但是社群也有相關開源的專案。

這裡只講 .NET Core 下兩種分散式快取的接入和使用,一種是分散式記憶體快取,一種是使用得比較廣泛的 Redis。其他的在 .NET Core 框架下的使用是差不多的,僅僅只是接入的時候有點區別。當然,Redis 除了作為分散式快取來使用,還有其他更加豐富的一些功能,後續也會找時間進行一些介紹。

2.1 基於記憶體的分散式快取

分散式記憶體快取 (AddDistributedMemoryCache) 是框架提供的 IDistributedCache 實現,用於將項儲存在記憶體中,它就在 Microsoft.Extensions.Caching.Memory Nuget 包中。 分散式記憶體快取不是真正的分散式快取。 快取項由應用例項儲存在執行該應用的伺服器上。

分散式記憶體快取是一個有用的實現:

  • 在開發和測試場景中。

  • 當在生產環境中使用單個伺服器並且記憶體消耗不重要時。 實現分散式記憶體快取會抽象快取的資料儲存。 如果需要多個節點或容錯,它允許在未來實現真正的分散式快取解決方案。

當應用在 Program.cs 的開發環境中執行時,我們可以透過以下方式使用分散式快取,以下示例程式碼基於 .NET 控制檯程式:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var host = Host.CreateDefaultBuilder(args)
	.ConfigureServices(services =>
	{
		services.AddDistributedMemoryCache();
	})
	.Build();

host.Run();

之後還是和記憶體快取差不多的例子,演示一下快取的存取、刪除、重新整理。

public interface IDistributedCacheService
{
	Task PrintDateTimeNow();
}
public class DistributedCacheService : IDistributedCacheService
{
	public const string CacheKey = nameof(DistributedCacheService);
	private readonly IDistributedCache _distributedCache;
	public DistributedCacheService(IDistributedCache distributedCache)
	{
		_distributedCache = distributedCache;
	}

	public async Task FreshAsync()
	{
		await _distributedCache.RefreshAsync(CacheKey);
	}

	public async Task PrintDateTimeNowAsync()
	{
		var time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
		var cacheValue = await _distributedCache.GetAsync(CacheKey);
		if(cacheValue == null)
		{
			// 分散式快取對於快取值的存取都是基於 byte[],所以各種物件必須先序列化為字串,之後轉換為 byte[] 陣列
			cacheValue = Encoding.UTF8.GetBytes(time);
			var distributedCacheEntryOption = new DistributedCacheEntryOptions()
			{
				//AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(20),
				AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(20),
				SlidingExpiration = TimeSpan.FromSeconds(3)
			};
			// 存在基於字串的存取擴充套件方法,內部其實也是透過 Encoding.UTF8 進行了編碼
			// await _distributedCache.SetStringAsync(CacheKey, time, distributedCacheEntryOption);
			await _distributedCache.SetAsync(CacheKey, cacheValue, distributedCacheEntryOption);
		}
		time = Encoding.UTF8.GetString(cacheValue);
		Console.WriteLine("快取時間:" + time);
		Console.WriteLine("當前時間:" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
	}

	public async Task RemoveAsync()
	{
		await _distributedCache.RemoveAsync(CacheKey);
	}
}

之後,在入口檔案新增以下程式碼,檢視控制檯結果是否與預想的一致:

using DistributedCacheSample;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var host = Host.CreateDefaultBuilder(args)
	.ConfigureServices(services =>
	{
		services.AddDistributedMemoryCache();
		services.AddTransient<IDistributedCacheService, DistributedCacheService>();
	})
	.Build();

var distributedCache = host.Services.GetRequiredService<IDistributedCacheService>();
// 第一次呼叫,設定快取
Console.WriteLine("第一次呼叫,設定快取");
await distributedCache.PrintDateTimeNowAsync();
await Task.Delay(TimeSpan.FromSeconds(1));
// 未過滑動時間,資料不變
Console.WriteLine("未過滑動時間,資料不變");
await distributedCache.PrintDateTimeNowAsync();
await Task.Delay(TimeSpan.FromSeconds(3));
// 已過滑動時間,資料改變
Console.WriteLine("已過滑動時間,資料改變");
await distributedCache.PrintDateTimeNowAsync();
await Task.Delay(TimeSpan.FromSeconds(1));
// 未過滑動時間,手動重新整理過期時間
Console.WriteLine("未過滑動時間,手動重新整理過期時間");
await distributedCache.FreshAsync();
await Task.Delay(TimeSpan.FromSeconds(2));
// 距離上一次呼叫此方法,已過滑動時間,但由於手動重新整理過過期時間,過期時間重新計算,資料不變
Console.WriteLine("距離上一次呼叫此方法,已過滑動時間,但由於手動重新整理過過期時間,過期時間重新計算,資料不變");
await distributedCache.PrintDateTimeNowAsync();
await Task.Delay(TimeSpan.FromSeconds(2));
// 移除快取
Console.WriteLine("移除快取");
await distributedCache.RemoveAsync();
// 原有的快取已移除,呼叫此方法是重新設定快取,資料改變
Console.WriteLine("原有的快取已移除,呼叫此方法是重新設定快取,資料改變");
await distributedCache.PrintDateTimeNowAsync();

host.Run();

image

結果和預想的是一致的。

2.2 基於 Redis 的分散式快取

Redis 是一種開源的基於記憶體的非關係型資料儲存,通常用作分散式快取。在 .NET Core 框架中使用 Redis 實現分散式快取,需要引用 Microsoft.Extensions.Caching.StackExchangeRedis Nuget 包,包中透過 AddStackExchangeRedisCache 新增 RedisCache 例項來配置快取實現,該類基於 Redis 實現了 IDistributedCache 介面。

(1) 安裝 Redis

這裡我在雲伺服器上透過 Docker 快速安裝了 Redis ,對映容器內 Redis 預設埠 6379 到主機埠 6379,並且設定了訪問密碼為 123456 。

docker run -d --name redis -p 6379:6379 redis --requirepass "123456"

(2) 應用新增依賴包,並且透過配置服務依賴關係

Install-Package Microsoft.Extensions.Caching.StackExchangeRedis

或者透過 VS 的 Nuget 包管理工具進行安裝

依賴關係配置如下:

var host = Host.CreateDefaultBuilder(args)
	.ConfigureServices(services =>
	{
		// services.AddDistributedMemoryCache();
		services.AddStackExchangeRedisCache(opyions =>
		{
			opyions.Configuration = "xxx.xxx.xxx.xxx:6379,password=123456";
		});
	})
	.Build();

這裡只需要將原來的分散式記憶體快取服務的配置切換為分散式 Redis 快取的配置即可,其他的什麼都不用改,就可以從記憶體快取切換到 Redis 分散式快取了。所以我們在日常工作的應用搭建中推薦使用基於分散式快取方案,前期或者開發環境中可以使用基於記憶體的分散式快取,後面專案的效能要求高了,可以很方便地切換到真正的分散式快取,只需改動一行程式碼。

之後基於前面的例子執行應用,可以看到輸出的結果是一樣的。

image

而在 Redis 上也可以看得到我們快取上去的資料。

image

這裡還有一個要注意的點,理論上使用分散式快取是能夠增強應用的效能和體驗性的,但是像 Redis 這樣的分散式快取一般情況下是和應用部署在不同的伺服器,每一次快取的獲取會存在一定的網路傳輸消耗,當快取的資料量比較大,而且快取存取頻繁的時候,也會有很大的效能消耗。之前在專案中就遇到過這樣的問題,由於一個查詢功能需要實時進行計算,計算中需要進行迴圈,而計算依賴於基礎資料,這部分的資料是使用快取的,當初直接使用 Redis 快取效能並不理想。當然可以說這種方式是有問題的,但是當時由於業務需要,封裝的計算方法中需要在應用啟動的時候由外部初始化基礎資料,為基礎資料能夠根據前端改動而重新整理,所以用了快取的方式。

下面是一個示例進行記憶體快取和 Redis 快取的對比:

這裡利用 BenchmarkDotNet 進行效能測試,需要先對原有的程式碼進行一下改造,這裡調整了一下建構函式,自行例項化相關快取的物件,之後有三個方法,分別使用 Redis 快取、記憶體快取、記憶體快取結合 Redis 快取,每個方法中模擬業務中的1000次迴圈,迴圈中快取資料進行存取。

點選檢視效能測試程式碼
[SimpleJob(RuntimeMoniker.Net60)]
public class DistributedCacheService : IDistributedCacheService
{
	public const string CacheKey = nameof(DistributedCacheService);
	private readonly IDistributedCache _distributedCache;
	private readonly IDistributedCache _distributedMemoryCache;
	private readonly IMemoryCache _memoryCache;

	[Params(1000)]
	public int N;

	public DistributedCacheService()
	{
		_distributedCache = new RedisCache(Options.Create(new RedisCacheOptions()
		{
			Configuration = "1.12.64.68:6379,password=123456"
		}));
		_distributedMemoryCache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
		_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
	}

	public async Task FreshAsync()
	{
		await _distributedCache.RefreshAsync(CacheKey);
	}

	public async Task PrintDateTimeNowAsync()
	{
		var time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
		var cacheValue = await _distributedCache.GetAsync(CacheKey);
		if (cacheValue == null)
		{
			// 分散式快取對於快取值的存取都是基於 byte[],所以各種物件必須先序列化為字串,之後轉換為 byte[] 陣列
			cacheValue = Encoding.UTF8.GetBytes(time);
			var distributedCacheEntryOption = new DistributedCacheEntryOptions()
			{
				//AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(10),
				AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(20),
				SlidingExpiration = TimeSpan.FromSeconds(3)
			};
			// 存在基於字串的存取擴充套件方法,內部其實也是透過 Encoding.UTF8 進行了編碼
			// await _distributedCache.SetStringAsync(CacheKey, time, distributedCacheEntryOption);
			await _distributedCache.SetAsync(CacheKey, cacheValue, distributedCacheEntryOption);
		}
		time = Encoding.UTF8.GetString(cacheValue);
		Console.WriteLine("快取時間:" + time);
		Console.WriteLine("當前時間:" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
	}

	[Benchmark]
	public async Task PrintDateTimeNowWithRedisAsync()
	{
		for(var i =0; i< N; i++)
		{
			var time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
			var cacheValue = await _distributedCache.GetAsync(CacheKey);
			if (cacheValue == null)
			{
				// 分散式快取對於快取值的存取都是基於 byte[],所以各種物件必須先序列化為字串,之後轉換為 byte[] 陣列
				cacheValue = Encoding.UTF8.GetBytes(time);
				var distributedCacheEntryOption = new DistributedCacheEntryOptions()
				{
					//AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(10),
					AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
					SlidingExpiration = TimeSpan.FromMinutes(5)
				};
				// 存在基於字串的存取擴充套件方法,內部其實也是透過 Encoding.UTF8 進行了編碼
				// await _distributedCache.SetStringAsync(CacheKey, time, distributedCacheEntryOption);
				await _distributedCache.SetAsync(CacheKey, cacheValue, distributedCacheEntryOption);
			}
			time = Encoding.UTF8.GetString(cacheValue);
		}
	}
	[Benchmark]
	public async Task PrintDateTimeWithMemoryAsync()
	{
		for (var i = 0; i < N; i++)
		{
			var time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
			var cacheValue = await _distributedMemoryCache.GetAsync(CacheKey);
			if (cacheValue == null)
			{
				// 分散式快取對於快取值的存取都是基於 byte[],所以各種物件必須先序列化為字串,之後轉換為 byte[] 陣列
				cacheValue = Encoding.UTF8.GetBytes(time);
				var distributedCacheEntryOption = new DistributedCacheEntryOptions()
				{
					//AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(10),
					AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
					SlidingExpiration = TimeSpan.FromMinutes(5)
				};
				// 存在基於字串的存取擴充套件方法,內部其實也是透過 Encoding.UTF8 進行了編碼
				// await _distributedCache.SetStringAsync(CacheKey, time, distributedCacheEntryOption);
				await _distributedMemoryCache.SetAsync(CacheKey, cacheValue, distributedCacheEntryOption);
			}
			time = Encoding.UTF8.GetString(cacheValue);
		}
	}

	[Benchmark]
	public async Task PrintDateTimeWithMemoryAndRedisAsync()
	{
		for (var i = 0; i < N; i++)
		{
			var cacheValue = await _memoryCache.GetOrCreateAsync(CacheKey, async cacheEntry =>
			{
				var time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
				var redisCacheValue = await _distributedCache.GetAsync(CacheKey);
				if (redisCacheValue == null)
				{
					// 分散式快取對於快取值的存取都是基於 byte[],所以各種物件必須先序列化為字串,之後轉換為 byte[] 陣列
					redisCacheValue = Encoding.UTF8.GetBytes(time);
					var distributedCacheEntryOption = new DistributedCacheEntryOptions()
					{
						//AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(10),
						AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
						SlidingExpiration = TimeSpan.FromMinutes(5)
					};
					// 存在基於字串的存取擴充套件方法,內部其實也是透過 Encoding.UTF8 進行了編碼
					// await _distributedCache.SetStringAsync(CacheKey, time, distributedCacheEntryOption);
					await _distributedCache.SetAsync(CacheKey, redisCacheValue, distributedCacheEntryOption);
				}
				time = Encoding.UTF8.GetString(redisCacheValue);
				cacheEntry.SlidingExpiration = TimeSpan.FromSeconds(20);
				return time;
			});
		}
	}

	public async Task RemoveAsync()
	{
		await _distributedCache.RemoveAsync(CacheKey);
	}
}

Program.cs 檔案中只保留以下程式碼:

Summary summary = BenchmarkRunner.Run<DistributedCacheService>();
Console.ReadLine();

測試結果如下:

image

可以看到這種情況下使用 Redis 快取效能是慘不忍睹的,但是另外兩種方式就不一樣了。

我們在業務中的快取最終就是第三種方法的方式,結合記憶體快取和 Redis 快取,根本的思路就是在使用時將資料臨時儲存在本地,減少網路傳輸的消耗,並且根據實際業務情況控制記憶體快取的超時時間以保持資料的一致性。



參考文章:
ASP.NET Core 中的分散式快取



ASP.NET Core 系列:

目錄:ASP.NET Core 系列總結
上一篇:ASP.NET Core - 快取之記憶體快取(下)

相關文章