使用SQLite作本地數據緩存的思考

前言

在一個分佈式緩存遍地都是的環境下,還講本地緩存,感受有點out了啊!可能你們看到標題,就沒有想繼續看下去的慾望了吧。可是,本地緩存的重要性也是有的!git

本地緩存相比分佈式緩存確實是比較out和比較low,這個我也是贊成的。可是嘛,總有它存在的意義,存在即合理。github

先來看看下面的圖,它基本解釋了緩存最基本的使用。sql

關於緩存的考慮是多方面,可是大部分狀況下的設計至少應該要有兩級纔算是比較合適的,一級是關於應用服務器的(本地緩存),一級是關於緩存服務器的。數據庫

因此上面的圖在應用服務器內還能夠進一步細化,從而獲得下面的一張圖:緩存

這裏也就是本文要講述的重點了。服務器

注:本文涉及到的緩存沒有特別說明都是指的數據緩存網絡

常見的本地緩存

在介紹本身瞎折騰的方案以前,先來看一下目前用的比較多,也是比較常見的本地緩存有那些。併發

在.NET Framework 時代,咱們最爲熟悉的本地緩存應該就是HttpRuntime.CacheMemoryCache這兩個了吧。app

一個依賴於System.Web,一個須要手動添加System.Runtime.Caching的引用。負載均衡

第一個很明顯不能在.NET Core 2.0的環境下使用,第二個貌似要在2.1纔會有,具體的不是很清楚。

在.NET Core時代,目前可能就是Microsoft.Extensions.Caching.Memory

固然這裏是沒有說明涉及到其餘第三方的組件!如今應該也會有很多。

本文主要是基於SQLite作了一個本地緩存的實現,也就是我瞎折騰搞的。

爲何會考慮SQLite呢?主要是基於下面緣由:

  1. In-Memory Database
  2. 併發量不會過高(中小型應該都hold的住)
  3. 小巧,操做簡單
  4. 在嵌入式數據庫名列前茅

簡單設計

爲何說是簡單的設計呢,由於本文的實現是比較簡單的,還有許多緩存應有的細節並無考慮進去,但應該也能夠知足大多數中小型應用的需求了。

先來創建存儲緩存數據的表。

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

LocalDataCachingDemo

相關文章
相關標籤/搜索