前言
在一個分布式緩存遍地都是的環境下,還講本地緩存,感覺有點out了啊!可能大家看到標題,就沒有想繼續看下去的欲望了吧。但是,本地緩存的重要性也是有的!
本地緩存相比分布式緩存確實是比較out和比較low,這個我也是同意的。但是嘛,總有它存在的意義,存在即合理。
先來看看下面的圖,它基本解釋了緩存最基本的使用。
關於緩存的考慮是多方面,但是大部分情況下的設計至少應該要有兩級才算是比較合適的,一級是關於應用服務器的(本地緩存),一級是關於緩存服務器的。
所以上面的圖在應用服務器內還可以進一步細化,從而得到下面的一張圖:
這里也就是本文要講述的重點了。
注:本文涉及到的緩存沒有特別說明都是指的數據緩存!
常見的本地緩存
在介紹自己瞎折騰的方案之前,先來看一下目前用的比較多,也是比較常見的本地緩存有那些。
在.NET Framework 時代,我們最為熟悉的本地緩存應該就是HttpRuntime.Cache和MemoryCache這兩個了吧。
一個依賴於System.Web,一個需要手動添加System.Runtime.Caching的引用。
第一個很明顯不能在.NET Core 2.0的環境下使用,第二個貌似要在2.1才會有,具體的不是很清楚。
在.NET Core時代,目前可能就是Microsoft.Extensions.Caching.Memory。
當然這里是沒有說明涉及到其他第三方的組件!現在應該也會有不少。
本文主要是基於SQLite做了一個本地緩存的實現,也就是我瞎折騰搞的。
為什么會考慮SQLite呢?主要是基於下面原因:
- In-Memory Database
- 並發量不會太高(中小型應該都hold的住)
- 小巧,操作簡單
- 在嵌入式數據庫名列前茅
簡單設計
為什么說是簡單的設計呢,因為本文的實現是比較簡單的,還有許多緩存應有的細節並沒有考慮進去,但應該也可以滿足大多數中小型應用的需求了。
先來建立存儲緩存數據的表。
CREATE TABLE "main"."caching" (
"cachekey" text NOT NULL,
"cachevalue" text NOT NULL,
"expiration" integer NOT NULL,
PRIMARY KEY("cachekey")
);
這里只需要簡單的三個字段即可。
字段名 | 描述 |
---|---|
cachekey | 緩存的鍵 |
cachevalue | 緩存的值,序列化之后的字符串 |
expiration | 緩存的絕對過期時間 |
由於SQLite的列並不能直接存儲完整的一個對象,需要將這個對象進行序列化之后 再進行存儲,由於多了一些額外的操作,相比MemoryCache就消耗了多一點的時間,
比如現在有一個Product類(有id,name兩個字段)的實例obj,要存儲這個實例,需要先對其進行序列化,轉成一個JSON字符串后再進行存儲。當然在讀取的時候也就需要進行反序列化的操作才可以。
為了方便緩存的接入,統一了一下緩存的入口,便於后面的使用。
/// <summary>
/// Cache entry.
/// </summary>
public class CacheEntry
{
/// <summary>
/// Initializes a new instance of the <see cref="T:SQLiteCachingDemo.Caching.CacheEntry"/> class.
/// </summary>
/// <param name="cacheKey">Cache key.</param>
/// <param name="cacheValue">Cache value.</param>
/// <param name="absoluteExpirationRelativeToNow">Absolute expiration relative to now.</param>
/// <param name="isRemoveExpiratedAfterSetNewCachingItem">If set to <c>true</c> is remove expirated after set new caching item.</param>
public CacheEntry(string cacheKey,
object cacheValue,
TimeSpan absoluteExpirationRelativeToNow,
bool isRemoveExpiratedAfterSetNewCachingItem = true)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
throw new ArgumentNullException(nameof(cacheKey));
}
if (cacheValue == null)
{
throw new ArgumentNullException(nameof(cacheValue));
}
if (absoluteExpirationRelativeToNow <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(AbsoluteExpirationRelativeToNow),
absoluteExpirationRelativeToNow,
"The relative expiration value must be positive.");
}
this.CacheKey = cacheKey;
this.CacheValue = cacheValue;
this.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow;
this.IsRemoveExpiratedAfterSetNewCachingItem = isRemoveExpiratedAfterSetNewCachingItem;
}
/// <summary>
/// Gets the cache key.
/// </summary>
/// <value>The cache key.</value>
public string CacheKey { get; private set; }
/// <summary>
/// Gets the cache value.
/// </summary>
/// <value>The cache value.</value>
public object CacheValue { get; private set; }
/// <summary>
/// Gets the absolute expiration relative to now.
/// </summary>
/// <value>The absolute expiration relative to now.</value>
public TimeSpan AbsoluteExpirationRelativeToNow { get; private set; }
/// <summary>
/// Gets a value indicating whether this <see cref="T:SQLiteCachingDemo.Caching.CacheEntry"/> is remove
/// expirated after set new caching item.
/// </summary>
/// <value><c>true</c> if is remove expirated after set new caching item; otherwise, <c>false</c>.</value>
public bool IsRemoveExpiratedAfterSetNewCachingItem { get; private set; }
/// <summary>
/// Gets the serialize cache value.
/// </summary>
/// <value>The serialize cache value.</value>
public string SerializeCacheValue
{
get
{
if (this.CacheValue == null)
{
throw new ArgumentNullException(nameof(this.CacheValue));
}
else
{
return JsonConvert.SerializeObject(this.CacheValue);
}
}
}
}
在緩存入口中,需要注意的是:
- AbsoluteExpirationRelativeToNow , 緩存的過期時間是相對於當前時間(格林威治時間)的絕對過期時間。
- IsRemoveExpiratedAfterSetNewCachingItem , 這個屬性是用於處理是否在插入新緩存時移除掉所有過期的緩存項,這個在默認情況下是開啟的,預防有些操作要比較快的響應,所以要可以將這個選項關閉掉,讓其他緩存插入操作去觸發。
- SerializeCacheValue , 序列化后的緩存對象,主要是用在插入緩存項中,統一存儲方式,也減少要插入時需要進行多一步的有些序列化操作。
- 緩存入口的屬性都是通過構造函數來進行初始化的。
然后是緩存接口的設計,這個都是比較常見的一些做法。
/// <summary>
/// Caching Interface.
/// </summary>
public interface ICaching
{
/// <summary>
/// Sets the async.
/// </summary>
/// <returns>The async.</returns>
/// <param name="cacheEntry">Cache entry.</param>
Task SetAsync(CacheEntry cacheEntry);
/// <summary>
/// Gets the async.
/// </summary>
/// <returns>The async.</returns>
/// <param name="cacheKey">Cache key.</param>
Task<object> GetAsync(string cacheKey);
/// <summary>
/// Removes the async.
/// </summary>
/// <returns>The async.</returns>
/// <param name="cacheKey">Cache key.</param>
Task RemoveAsync(string cacheKey);
/// <summary>
/// Flushs all expiration async.
/// </summary>
/// <returns>The all expiration async.</returns>
Task FlushAllExpirationAsync();
}
由於都是數據庫的操作,避免不必要的資源浪費,就把接口都設計成異步的了。這里只有增刪查的操作,沒有更新的操作。
最后就是如何實現的問題了。實現上借助了Dapper來完成相應的數據庫操作,平時是Dapper混搭其他ORM來用的。
想想不弄那么復雜,就只用Dapper來處理就OK了。
/// <summary>
/// SQLite caching.
/// </summary>
public class SQLiteCaching : ICaching
{
/// <summary>
/// The connection string of SQLite database.
/// </summary>
private readonly string connStr = $"Data Source ={Path.Combine(Directory.GetCurrentDirectory(), "localcaching.sqlite")}";
/// <summary>
/// The tick to time stamp.
/// </summary>
private readonly int TickToTimeStamp = 10000000;
/// <summary>
/// Flush all expirated caching items.
/// </summary>
/// <returns></returns>
public async Task FlushAllExpirationAsync()
{
using (var conn = new SqliteConnection(connStr))
{
var sql = "DELETE FROM [caching] WHERE [expiration] < STRFTIME('%s','now')";
await conn.ExecuteAsync(sql);
}
}
/// <summary>
/// Get caching item by cache key.
/// </summary>
/// <returns></returns>
/// <param name="cacheKey">Cache key.</param>
public async Task<object> GetAsync(string cacheKey)
{
using (var conn = new SqliteConnection(connStr))
{
var sql = @"SELECT [cachevalue]
FROM [caching]
WHERE [cachekey] = @cachekey AND [expiration] > STRFTIME('%s','now')";
var res = await conn.ExecuteScalarAsync(sql, new
{
cachekey = cacheKey
});
// deserialize object .
return res == null ? null : JsonConvert.DeserializeObject(res.ToString());
}
}
/// <summary>
/// Remove caching item by cache key.
/// </summary>
/// <returns></returns>
/// <param name="cacheKey">Cache key.</param>
public async Task RemoveAsync(string cacheKey)
{
using (var conn = new SqliteConnection(connStr))
{
var sql = "DELETE FROM [caching] WHERE [cachekey] = @cachekey";
await conn.ExecuteAsync(sql , new
{
cachekey = cacheKey
});
}
}
/// <summary>
/// Set caching item.
/// </summary>
/// <returns></returns>
/// <param name="cacheEntry">Cache entry.</param>
public async Task SetAsync(CacheEntry cacheEntry)
{
using (var conn = new SqliteConnection(connStr))
{
//1. Delete the old caching item at first .
var deleteSql = "DELETE FROM [caching] WHERE [cachekey] = @cachekey";
await conn.ExecuteAsync(deleteSql, new
{
cachekey = cacheEntry.CacheKey
});
//2. Insert a new caching item with specify cache key.
var insertSql = @"INSERT INTO [caching](cachekey,cachevalue,expiration)
VALUES(@cachekey,@cachevalue,@expiration)";
await conn.ExecuteAsync(insertSql, new
{
cachekey = cacheEntry.CacheKey,
cachevalue = cacheEntry.SerializeCacheValue,
expiration = await GetCurrentUnixTimestamp(cacheEntry.AbsoluteExpirationRelativeToNow)
});
}
if(cacheEntry.IsRemoveExpiratedAfterSetNewCachingItem)
{
// remove all expirated caching item when new caching item was set .
await FlushAllExpirationAsync();
}
}
/// <summary>
/// Get the current unix timestamp.
/// </summary>
/// <returns>The current unix timestamp.</returns>
/// <param name="absoluteExpiration">Absolute expiration.</param>
private async Task<long> GetCurrentUnixTimestamp(TimeSpan absoluteExpiration)
{
using (var conn = new SqliteConnection(connStr))
{
var sql = "SELECT STRFTIME('%s','now')";
var res = await conn.ExecuteScalarAsync(sql);
//get current utc timestamp and plus absolute expiration
return long.Parse(res.ToString()) + (absoluteExpiration.Ticks / TickToTimeStamp);
}
}
}
這里需要注意下面幾個:
- SQLite並沒有嚴格意義上的時間類型,所以在這里用了時間戳來處理緩存過期的問題。
- 使用SQLite內置函數 STRFTIME('%s','now') 來獲取時間戳相關的數據,這個函數獲取的是格林威治時間,所有的操作都是以這個時間為基准。
- 在插入一條緩存數據的時候,會先執行一次刪除操作,避免主鍵沖突的問題。
- 讀取的時候就做了一次反序列化操作,簡化調用操作。
- TickToTimeStamp , 這個是過期時間轉化成時間戳的轉換單位。
最后的話,自然就是如何使用的問題了。
首先是在IServiceCollection中注冊一下
service.AddSingleton<ICaching,SQLiteCaching>();
然后在控制器的構造函數中進行注入。
private readonly ICaching _caching;
public HomeController(ICaching caching)
{
this._caching = caching;
}
插入緩存時,需要先實例化一個CacheEntry對象,根據這個對象來進行相應的處理。
var obj = new Product()
{
Id = "123" ,
Name = "Product123"
};
var cacheEntry = new CacheEntry("mykey", obj, TimeSpan.FromSeconds(3600));
await _caching.SetAsync(cacheEntry);
從緩存中讀取數據時,建議是用dynamic去接收,因為當時沒有考慮泛型的處理。
dynamic product = await _caching.GetAsync("mykey");
var id = product.Id;
var name = product.Name;
從緩存中移除緩存項的兩個操作如下所示。
//移除指定鍵的緩存項
await _caching.RemoveAsync("mykey");
//移除所有過期的緩存項
await _caching.FlushAllExpirationAsync();
總結
經過在Mac book Pro上簡單的測試,從幾十萬數據中並行讀取1000條到10000條記錄也都可以在零點幾ms中完成。
這個在高讀寫比的系統中應該是比較有優勢的。
但是並行的插入就相對要慢不少了,並行的插入一萬條記錄,直接就數據庫死鎖了。1000條還勉強能在20000ms搞定!
這個是由SQLite本身所支持的並發性導致的,另外插入緩存數據時都會開一個數據庫的連接,這也是比較耗時的,所以這里可以考慮做一下后續的優化。
移除所有過期的緩存項可以在一兩百ms內搞定。
當然,還應該在不同的機器上進行更多的模擬測試,這樣得到的效果比較真實可信。
SQLite做本地緩存有它自己的優勢,也有它的劣勢。
優勢:
- 無需網絡連接
- 讀取數據快
劣勢:
- 高一點並發的時候就有可能over了
- 讀寫都需要進行序列化操作
雖說並發高的時候可以會有問題,但是在進入應用服務器的前已經是經過一層負載均衡的分流了,所以這里理論上對中小型應用影響不會太大。
另外對於緩存的滑動過期時間,文中並沒有實現,可以在這個基礎上進行補充修改,從而使其能支持滑動過期。
本文示例Demo