157

使用SQLite做本地数据缓存的思考 - Catcher8

 6 years ago
source link: http://www.cnblogs.com/catcher1994/p/7635133.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在一个分布式缓存遍地都是的环境下,还讲本地缓存,感觉有点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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK