使用SQLite做本地数据缓存的思考
前言
在一个分布式缓存遍地都是的环境下,还讲本地缓存,感觉有点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
使用SQLite做本地数据缓存的思考的更多相关文章
- iOS - LocalCache 本地数据缓存
1.自定义方式本地数据缓存 1.1 自定义缓存 1 沙盒路径下的 Library/Caches 用来存放缓存文件,保存从网络下载的请求数据,后续仍然需要继续使用的文件,例如网络下载的离线数据,图片,视 ...
- Android清除本地数据缓存代码案例
Android清除本地数据缓存代码案例 直接上代码: /* * 文 件 名: DataCleanManager.java * 描 述: 主要功能有清除内/外缓存,清除数据库,清除shar ...
- Xamarin android使用Sqlite做本地存储数据库
android使用Sqlite做本地存储非常常见(打个比方就像是浏览器要做本地存储使用LocalStorage,貌似不是很恰当,大概就是这个意思). SQLite 是一个软件库,实现了自给自足的.无服 ...
- 微信小程序开发:学习笔记[9]——本地数据缓存
微信小程序开发:学习笔记[9]——本地数据缓存 快速开始 说明 本地数据缓存是小程序存储在当前设备上硬盘上的数据,本地数据缓存有非常多的用途,我们可以利用本地数据缓存来存储用户在小程序上产生的操作,在 ...
- 【Android】Android清除本地数据缓存代码
最近做软件的时候,遇到了缓存的问题,在网上看到了这个文章,感觉不错.分享给大家看看 文章出处:http://www.cnblogs.com/rayray/p/3413673.html /* * 文 件 ...
- Android清除本地数据缓存代码
/* * 文 件 名: DataCleanManager.java * 描 述: 主要功能有清除内/外缓存,清除数据库,清除sharedPreference,清除files和清除自定义目 ...
- [2]项目创建-使用C#.NET开发基于本地数据缓存的PC客户端
1.新建项目->已安装->模板->Visual c#->Windows桌面->Windows窗体应用程序,截图如下: 图中1:输入项目名称-“MoneyNotes”,图中 ...
- [1]开发准备-使用C#.NET开发基于本地数据缓存的PC客户端
小记:本人是PHPer,对C#.NET的开发只能说看得懂,也写得了功能略简单的PC客户端程序,下面的是本人开发一款名叫“理财速记”的PC客户端软件的全过程记录,期间包括比较繁琐的C#.NET资料查询等 ...
- SQLite做为本地缓存的应用需要注意的地方
原文:SQLite做为本地缓存的应用需要注意的地方 今天看到了园友陆敏计的一篇文章<<C#数据本地存储方案之SQLite>>, 写到了SQLite的诸多优点,尤其适应于本地数据 ...
随机推荐
- Redis订阅和发布模式和Redis事务
-------------------Redis订阅和发布模式------------------- 1.概念 Redis 发布订阅(pub/sub)是一种消息通信模式: 发送者(pu ...
- 利用 FormData 对象和 Spring MVC 配合可以实现Ajax文件上载功能
Ajax文件上载 利用 FormData 对象和 Spring MVC 配合可以实现Ajax文件上载功能: 步骤 导入组件并准备静态脚本 <dependency> <groupId& ...
- 区块链下的io域名到底有多神秘?
不知大家发现没有 一些科技创业公司首选域名后缀 是.IO WHY? 因为给人一种很酷的感觉啊~ 譬如,极客届享誉盛名的盘古越狱团队官网:http://pangu.io 国内优质社区,简书--创作你的创 ...
- 详解session
详见:http://blog.yemou.net/article/query/info/tytfjhfascvhzxcytp30 一.术语session 在我的经验里,session这个词被滥用的程度 ...
- 基础知识(C#语法、数据库SQL Server)回顾与总结
前言 已经有大概一个多月没有更新博客,可能是开始变得有点懒散了吧,有时候想写,但是又需要额外投入更多的时间去学习,感觉精力完全不够用啊,所以为了弥补这一个多月的潜水,决定写一篇,衔接9月未写博客的空缺 ...
- 【★】SPF(Dijkstra)算法完美教程
- 英语APP体验
第一部分 1.下载并使用,描述最简单直观的个人第一次上手体验. 感觉不是很好用,可能是个人习惯吧,之前用的都是扇贝单词和有道词典,所以不是特别顺手. 2.找出几个功能性的比较严重的 bug 在口语挑战 ...
- 【Beta阶段】第三次scrum meeting
Coding/OSChina 地址 1. 会议内容 学号 主要负责的方向 昨日任务 昨日任务完成进度 接下去要做 99 PM 打包上传团队代码 100% 查找适合的素材模块,和105一起把手势功能连接 ...
- 201521123007《Java程序设计》第5周学习总结
1. 本周学习总结 1.1 尝试使用思维导图总结有关多态与接口的知识点. 2. 书面作业 作业参考文件下载 1. 代码阅读:Child压缩包内源代码 1.1 com.parent包中Child.jav ...
- 201521123037 《Java程序设计》第12周学习总结
1. 本周学习总结 1.1 以你喜欢的方式(思维导图或其他)归纳总结多流与文件相关内容. I/O流.本质上是一个数据序列:最基本的可处理数据单位为byte. 1.1 分类:输入流(读数据),输出流(写 ...