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


前言

在一個分布式緩存遍地都是的環境下,還講本地緩存,感覺有點out了啊!可能大家看到標題,就沒有想繼續看下去的欲望了吧。但是,本地緩存的重要性也是有的!

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

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

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

所以上面的圖在應用服務器內還可以進一步細化,從而得到下面的一張圖:

這里也就是本文要講述的重點了。

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

常見的本地緩存

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

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

一個依賴於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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM