由一次线上问题引发的思考,本地缓存+redis缓存的多级缓存方案。
一次线上发生OOM, CPU占用高排查,之前所有的缓存都是放在redis中,并发高时,大量请求去redis,服务反序列化,导致CPU占用高,内存占用高,最终达到资源上线被k8s杀掉。
后来想到了使用本地缓存,这种公共对象保存一份数据,不用反复序列化,减少CPU占用,减少内存占用,但是本地缓存有一个问题,多个副本在同一时间可能缓存数据不一致,虽然在我们这个场景下,这份公共数据更新不频繁,但是也有可能发生这个情况,所以想到了使用多级缓存,本地缓存+redis缓存,本地缓存作为一级缓存,redis作为二级缓存,当redis更新时会设置数据版本号(时间戳),本地获取时会比对版本号,如果相同redis就不返回数据,如果不同就返回数据,这样就可以保证数据一致性。
用go描述一下取值和设置值的逻辑,其他逻辑都比较简单,这里使用lua脚本来实现,版本和数据的原子性和比较版本和返回相应的返回值,还可以减少网络开销
package main
import (
"fmt"
"time"
"github.com/go-redis/redis"
)
//这段脚本是用来设置值的,设置值的时候会设置版本号,设置过期时间
const setval = `
local key_val = KEYS[1]
local key_version = key_val .. "_version"
local val = ARGV[1]
local expire = ARGV[2]
local version = ARGV[3]
redis.call('SET', key_version, version)
redis.call('EXPIRE', key_version, expire)
redis.call('SET', key_val, val)
redis.call('EXPIRE', key_val, expire)
return nil
`
//这段脚本是用来取值的,取值的时候会比较版本号,如果版本号不一致就返回值
const getval = `
local key_val = KEYS[1]
local key_version = key_val .. "_version"
local givenVersion = ARGV[1]
local version = redis.call('GET', key_version)
if not version then
return nil
end
if version ~= givenVersion then
local value1 = redis.call('GET', key_val)
return {version, value1}
end
return {version}
`
func main() {
redisClient := redis.NewClient(&redis.Options{
Addr: "xxx",
Password: "xxx",
DB: 0,
})
cmd := redisClient.Eval(getval, []string{"ab"}, "1")
if cmd.Err() != nil {
fmt.Println(cmd.Err())
}
val := cmd.Val()
fmt.Println(val)
cmd = redisClient.Eval(setval, []string{"ab"}, time.Now().UnixNano(), 100, time.Now().Unix())
if cmd.Err() != nil && cmd.Err() != redis.Nil {
fmt.Println(cmd.Err())
}
}
说一下总体的思路 使用一个localstorage, redis, redis lua, singleflight
localstorage作为一级缓存,redis作为二级缓存,redis lua作为设置值和取值的脚本,singleflight作为防止缓存击穿的方案
对于取值过程:
- 获取本地缓存
- 使用lua脚本请求redis
- 如果返回为nil则说明redis无缓存
- 如果只返回版本号则说明redis数据版本和本地一致,可以使用本地缓存
- 如果返回版本号和缓存对象,则更新本地缓存,返回缓存对象
存值的过程:
- 获取时间戳
- 保存本地缓存
- 使用lua设置redis缓存
把上述逻辑封装为一个通用缓存库可以进行复用