由一次线上问题引发的思考,本地缓存+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作为防止缓存击穿的方案

对于取值过程:

  1. 获取本地缓存
  2. 使用lua脚本请求redis
  3. 如果返回为nil则说明redis无缓存
  4. 如果只返回版本号则说明redis数据版本和本地一致,可以使用本地缓存
  5. 如果返回版本号和缓存对象,则更新本地缓存,返回缓存对象

存值的过程:

  1. 获取时间戳
  2. 保存本地缓存
  3. 使用lua设置redis缓存

把上述逻辑封装为一个通用缓存库可以进行复用