kratos中使用rockscache介绍
✅ 项目地址
kratos_rockscache 主要功能在这里还有一些其他的测试用例
kratosRocksCache 使用go-redis的v9版本
✅ 简介
在实际的业务场景中中,我们常常会使用redis将数据库的数据做一份缓存,以提高程序的查询效率。但是引入了redis同时也会带来一些问题,比如redis与数据库数据的一致性、缓存穿透、缓存雪崩等,还有redis挂了需要手动做缓存降级,rockscache包帮我们提供了很多好用的方法解决这些问题。
✅ 包版本问题
注意包版本的问题,因为rockscache需要使用go-redis v8版本的Client,但是如果项目使用的是v9版本的话会有问题(文档中有),我是把官网的项目下载
下来了,(改动说明文档中有),其实就是少了一个Context方法多了一个其他的方法~
# 改动说明
rockscache官方地址:https://github.com/dtm-labs/rockscache
rockscache官方源码使用go-redis v8版本,拉到本地使用v9版本的go-redis并对其中的几个方法(Fetch、FetchBatch与TagAsDeletedBatch)做了简单的修改:
需要在调用的时候加上业务中的ctx。
之前只有 FetchBatch与TagAsDeletedBatch 这2个方法需要用 rdb.Client.Context() 初始化一下ctx,
现在将这2个方法也改成了需要传业务的ctx ,不影响原包的基本功能。
go-redis v9版本与v8版本的 redis.UniversalClient 接口不一样:
v9版本没有 Context 方法,多了一个 SSubscribe 方法,两个interface不一样。
✅ 强一致性与最终一致性
强一致性:当业务中对数据实时性要求比较高的情况下开启强一致性的开关;
最终一致性:像文章列表这样对数据实时性要求不高的场景可以不开启强一致性开关,程序性能会好一些。
✅ 简单的用法及存储的改变
1、初始化及用法:
- 项目中封装初始化,需要用到go-redis Client
- 项目中使用:简单的get接口,用Fetch2那个方法讲解
2、存储的改变~在里面都存成了hash —— 因为内部使用lua一次执行,执行的过程中其他脚本或命令无法执行,而且同时保存 key/value
与 key/lock
// rockscache将string数据存成了hash类型,比如说,我们之前在redis中存的数据是string,用get方法获取:
```
get countValue1
"{\"id\":\"1\",\"count\":100,\"createTime\":\"2023-02-13T11:42:42+08:00\",\"updateTime\":\"2023-02-13T11:42:42+08:00\"}"
```
// 使用rockescache的话变成了hash,用hgetall方法获取:
```
hgetall countCache1
1) "value"
2) "{\"id\":\"1\",\"count\":100,\"createTime\":\"2023-02-13T11:42:42+08:00\",\"updateTime\":\"2023-02-13T11:42:42+08:00\"}"
```
✅ 配置项说明
1-1、配置项说明
func NewRocksCache(c *conf.Data, logger log.Logger, rdb *redis.Client) *rockscache_local.Client {
var dc = rockscache_local.NewClient(rdb, rockscache_local.NewDefaultOptions())
// 常用参数设置
// 1、强一致性(默认关闭强一致性,如果开启的话会影响性能)
dc.Options.StrongConsistency = false
// 2、redis出现问题需要缓存降级时设置为true
dc.Options.DisableCacheRead = false // 关闭缓存读,默认false;如果打开,那么Fetch就不从缓存读取数据,而是直接调用fn获取数据
dc.Options.DisableCacheDelete = false // 关闭缓存删除,默认false;如果打开,那么TagAsDeleted就什么操作都不做,直接返回
// 3、其他设置
// 标记删除的延迟时间,默认10秒,设置为3秒表示:被删除的key在3秒后才从redis中彻底清除
dc.Options.Delay = time.Second * time.Duration(3)
// 防穿透: 若fn返回空字符串,空结果在缓存中的缓存时间,默认60秒
dc.Options.EmptyExpire = time.Second * time.Duration(120)
// 防雪崩: 默认0.1,当前设置为0.1的话,如果设定为600的过期时间,那么过期时间会被设定为540s - 600s中间的一个随机数,避免数据出现同时到期
dc.Options.RandomExpireAdjustment = 0.1 // 设置为默认或不设置就行
/*
锁相关参数,这里配置的默认值,没有特殊情况建议默认
rockscache使用lua脚本一次性执行,执行过程中其他脚本或命令无法执行,
并且使用hash存储,同时保存 key/value 与 key/lock
*/
// 更新缓存时分配的锁的过期时间。默认为 3s。注意设置为下级计算数据时间的最大值。
dc.Options.LockExpire = time.Second * time.Duration(3)
// 锁失败后的重试等待时间 100ms
dc.Options.LockSleep = time.Millisecond * time.Duration(100)
log.NewHelper(logger).Infow("kind", "rocksCache", "status", "enable")
return dc
}
1-2、rockscache原理解读
1、参考(这个文章用的rockscache的版本有点低,参考一下就行):
https://xboom.github.io/2022/07/31/Microservices/微服务-缓存一致性/
2、lua脚本
使用脚本进行redis操作,lua的好处是一次性执行,执行过程其他脚本或命令无法执行(注意不确定参数)。
这里使用hash
进行数据存储,同时保存 key/value
与 key/lock
✅ 配置项演示
1、简单的get请求
FindCountById这个接口,使用Fetch2方法,传ctx。
数据库中的数据:
id count create_time update_time
1 100 2023-02-13 11:42:42 2023-02-13 11:42:42
2 200 2023-02-13 11:42:46 2023-02-13 11:42:46
一开始缓存中没有数据。
查询id为1的数据库中有的数据:
// 127.0.0.1:19000
{
"id": 1
}
可以看到打印出来:查询数据库了~~
查看下缓存:
hgetall countCache1
1) "value"
2) "{\"id\":\"1\",\"count\":100,\"createTime\":\"2023-02-13T11:42:42+08:00\",\"updateTime\":\"2023-02-13T11:42:42+08:00\"}"
再查询一下,就不会打印查询数据库了~此时会从redis中查数据
看一下缓存的TTL:
ttl countCache1
(integer) 89
2、缓存穿透(查id不存在数据的写法)
1、演示一下:缓存穿透,db中没有数据的情况~ 会生成一个空值(key对应的是countValue3,value里面的值是空字符串) ttl是自己设置的,❗️特别注意一下,需要代码中做“无记录”特殊的处理——不要返回错误!返回空字符串!
dc.Options.EmptyExpire = time.Second * time.Duration(120)
业务代码的写法:
func (r *layoutRepo) FindCountById(ctx context.Context, id int64) (*biz.Count, error) {
db := r.data.DB(ctx).Table(biz.CountTableName)
cacheKey := fmt.Sprintf(countCacheFormatKey, id)
value, errValue := r.data.rocksCache.Fetch2(ctx, cacheKey, time.Second*100, func() (string, error) {
// 缓存中没有数据默认从数据库查
fmt.Println("查数据库了~~~~~~~~~~~~~~~~~~~~~~~~")
model := &biz.Count{}
errFirst := db.Where("id = ?", id).First(model).Error
// ❗️❗️ ❗️ 数据库中无记录,返回一个空字符串~~redis中会记录一个这个key对应空字符串数据的记录,防止缓存穿透!
if errFirst == gorm.ErrRecordNotFound {
return "", nil
}
if errFirst != nil {
return "", errFirst
}
// Marshal
ma, errMa := json.Marshal(model)
if errMa != nil {
return "", errMa
}
return convertor.ToString(ma), nil
})
if errValue != nil {
return nil, errValue
}
ret := &biz.Count{}
// value在数据库没有数据时返回报错!
if value == ""{
return errors.New("数据库中没有记录!")
}
// “未穿透” 有数据做json反序列化
body, errBody := convertor.ToBytes(value)
if errBody != nil {
return nil, errBody
}
errUnmarshal := json.Unmarshal(body, ret)
if errUnmarshal != nil {
return nil, errUnmarshal
}
return ret, nil
}
演示:发送一个id不存在的请求:
// 127.0.0.1:19000
{
"id": 333
}
一开始查询了一下数据库,但是后面就不再从数据库中查数据了,可以看到缓存中有了相对应的key:
hgetall countCache333
1) "value"
2) ""
3、redis降级场景
需要设置参数:
dc.Options.DisableCacheRead = true // 关闭缓存读,默认false;如果打开,那么Fetch就不从缓存读取数据,而是直接调用fn获取数据
dc.Options.DisableCacheDelete = true // 关闭缓存删除,默认false;如果打开,那么TagAsDeleted就什么操作都不做,直接返回
然后把docker中的redis关了,试试:
// redis关了
hgetall countCache333
Could not connect to Redis at 127.0.0.1:6379: Connection refused
发送下请求,可以看到,所有的查询都走了数据库~
4🌟、标记删除的坑
实际在写业务代码的时候,我们在修改完数据库数据的时,会采取直接将对应缓存删除的操作,在rockscache中使用了“标记删除”机制,也就是说当我们调用TagAsDeleted2方法删除一个key的时候,rockscache并不会立刻将数据从redis中删掉,而是加了一个标记lockUntil,对应的值是0,在一段时间后才将这个key从redis中删掉,而“一段时间”的配置是Delay这个属性控制的:
// 标记删除的延迟时间,默认10秒,设置为13秒表示:被删除的key在13秒后才从redis中彻底清除,但是会把lockUntil设置为0
dc.Options.Delay = time.Second * time.Duration(13)
业务代码如下:
func (l *layoutRepo) DelCountCacheById(ctx context.Context, id int64) error {
// Notice 修改数据库并标记删除
// 修改数据库
db := l.data.DB(ctx).Table(biz.CountTableName)
err := db.Where("id = ?", id).Update("count", gorm.Expr("count + ?", 1)).Error
if err != nil {
return err
}
// 标记删除
cacheKey := fmt.Sprintf(countCacheFormatKey, id)
errDel := l.data.rocksCache.TagAsDeleted2(ctx, cacheKey)
if errDel != nil {
return errDel
}
return nil
}
但是,如果强一致性开关如果设置为false的话,标记删除以后,如果Delay属性设置的比较长(没有设置默认10秒),此时再调用get接口会读到旧的没有修改以后的数据:
// 先获取一下id=1的数据,可以看到缓存中有数据了:
hgetall countCache1
1) "value"
2) "{\"id\":\"1\",\"count\":100,\"createTime\":\"2023-02-13T11:42:42+08:00\",\"updateTime\":\"2023-02-13T11:42:42+08:00\"}"
// 然后调用删除接口,再看看:
hgetall countCache1
1) "lockUntil"
2) "0"
3) "value"
4) "{\"id\":\"1\",\"count\":100,\"createTime\":\"2023-02-13T11:42:42+08:00\",\"updateTime\":\"2023-02-13T11:42:42+08:00\"}"
// 当时再调用FindCouuntById接口的话,获取到的还是修改之前数据的值,❗️但是此时数据库中的数据已经自增了1,变成101了!
{
"id": "1",
"count": "100",
"createTime": "2023-02-13 11:42:42 +0800 CST",
"updateTime": "2023-02-14 11:14:44 +0800 CST"
}
✨解决方案:可以将强一致性的开关打开:
dc.Options.StrongConsistency = true
打开这个开关以后,再根据上面的操作演示一下,标记删除之后,再重新获取数据,redis中存的数据会跟数据库中的数据保持一致!
✅✨ “防穿透”的一个坑
上面演示了防穿透机制:查询一个id不存在的数据时代码中返回空字符串,会在缓存中给这个id对应的hash的value这个key的值设置为空字符串。
问题
在实际业务中还会有这样的情况: 有2个任务同时操作一张表的数据。比如服务A与服务B同时会操作status这张表,针对一个任务(比如任务id为99),服务A会查一下status表或Redis中是否有数据,没有数据的话会做一个推送,推送一条消息到服务B。服务B拿到这条推送消息后,做一些业务处理,然后将id为99的任务数据写入相同的status表中(❗️前提:假设我们在创建数据库数据时没有创建缓存,而是在查询的时候使用Fetch2方法让rockscahce自动帮我们创建缓存数据)——此时就可能会出现一个问题:如果我们用rockscache做了“防穿透”处理,服务A从status表中没有查到数据,会在Redis中创建一条id为99这条记录value为空字符串的数据,TTL如果设置的比较长,在服务B往status表中写入数据时只往status表中写但是没有往Redis中写数据,就会造成Redis中数据与DB中数据不一致的情况(Redis中数据为空但是实际上数据库中已经有数据了)。
解决方案1✨
❗️该方法不需要开启强一致性开关!创建数据的时候使用RawSet方法在Redis中Set一下,这样下次再从缓存查的时候会查到最新的值!
func (l *layoutRepo) CreateCount(ctx context.Context) (*biz.Count, error) {
// 创建数据库数据
db := l.data.DB(ctx).Table(biz.CountTableName)
currModel := &biz.Count{
Count: 1,
}
errCreate := db.Create(currModel).Error
if errCreate != nil {
return nil, errCreate
}
// ❗️Notice 创建完成 在缓存中写入一条数据!
// ❗️数据库Id是自增的,所以Create方法后返回的model会携带最新的id!
id := currModel.Id
cacheKey := fmt.Sprintf(countCacheFormatKey, id)
// ❗️Notice 使用 RawSet 方法在Redis中创建一条数据
marshalRet, errMarshal := json.Marshal(currModel)
if errMarshal != nil {
return nil, errMarshal
}
errSet := l.data.rocksCache.RawSet(ctx, cacheKey, string(marshalRet), time.Second*120)
if errSet != nil {
// 缓存操作错误只记录log
log.Errorf("创建Count缓存失败! Id: %v, err: %v ", currModel.Id, errSet)
}
return currModel, nil
}
解决方案2
❗️注意该方案需要开启强一致性的开关!
具体就是在数据库创建数据的时候,不是RawSet而是使用TagAsDeleted2方法标记删除,标记删除方法返回的错误可以忽略或只打一个log,但是,这个方案不太好,因为在业务逻辑上我们在创建数据的时候是不会有缓存的,在业务代码中加一个删除缓存的逻辑会让人觉得很奇怪!❗️而且这个方案还得开启强一致性开关,如果业务中不需要的话,还会影响业务的性能!