• 使用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

  • 相关阅读:
    iOS开发之单例模式
    XCode 安装 Alcatraz包管理器失败的处理
    iOS "此证书由未知颁发机构签名"此问题的解决方法
    Android WebView 使用
    BaseActivity
    定时周期执行指定的任务 ScheduledExecutorService
    SQLite数据库浅谈
    android 图片缓存
    Android之drawable state各个属性详解
    Android应用中如何启动另一个应用
  • 原文地址:https://www.cnblogs.com/catcher1994/p/7635133.html
Copyright © 2020-2023  润新知