Redis缓存三大问题

前言

日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题。一旦涉及大数据量的需求,如一些商品抢购的情景,或者主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘,磁盘读/写速度问题有严重的性能弊端,在这一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。
为了克服上述的问题,项目通常会引入NoSQL技术,这是一种基于内存的数据库,并且提供一定的持久化功能。但同时,它也带来了一些问题。其中,最要害的问题,就是**数据的一致性**问题,从严格意义上讲,这个问题无解。如果对数据的一致性要求很高,那么就不能使用缓存。
另外的一些典型问题就是,缓存穿透、缓存击穿和缓存雪崩。本篇文章从实际代码操作,来提出解决这三个缓存问题的方案,毕竟Redis的缓存问题是实际面试中高频问点,理论和实操要兼得。

缓存穿透

缓存穿透是指查询一条**数据库和缓存都没有**的一条数据,就会一直查询数据库,对数据库的访问压力就会增大,缓存穿透的解决方案,有以下两种:

  • 缓存空对象:代码维护较简单,但是效果不好
  • 布隆过滤器:代码维护复杂,效果很好。
  • 接口层增加校验,比如id<0等。

缓存空对象

在这里插入图片描述

func (s *userImpl) FindById(ctx context.Context, id int) (user entity.AppUser) {
	var (
		key = "user:" + gconv.String(id)
	)
	value, _ := g.Redis().Do(ctx, "get", key)
	// 缓存中存在,直接返回
	if !value.IsEmpty() {
		value.Struct(user)
	} else {
		// 缓存中不存在,查询数据库
		if result, err := dao.AppUser.Ctx(ctx).Where(dao.AppUser.Columns().Id, id).One(); err != nil {
			return
		} else {
			result.Struct(&user)
		}
		// 存入缓存
		if user != (entity.AppUser{}) {
			g.Redis().Do(ctx, "set", key, user)
		} else {
			// 将空对象存进缓存
			g.Redis().Do(ctx, "setex", key, 60, struct{}{})
		}
	}
	return
}

缓存空对象的实现代码很简单,但是缓存空对象会带来比较大的问题,就是缓存中会存在很多空对象,占用内存的空间,浪费资源,一个解决的办法就是设置空对象的较短的过期时间

布隆过滤器

布隆过滤器是一种基于概率的数据结构,主要用来判断某个元素是否在集合内,它具有运行速度快(时间效率),占用内存小的优点(空间效率),但是有一定的误识别率和删除困难的问题。它只能告诉你某个元素一定不在集合内或可能在集合内。
布隆过滤器的特点如下:

  1. 一个非常大的二进制位数组 (数组里只有0和1)
  2. 若干个哈希函数
  3. 空间效率和查询效率高
  4. 不存在漏报(False Negative):某个元素在某个集合中,肯定能报出来。
  5. 可能存在误报(False Positive):某个元素不在某个集合中,可能也被爆出来。
  6. 不提供删除方法,代码维护困难。
  7. 位数组初始化都为0,它不存元素的具体值,当元素经过哈希函数哈希后的值(也就是数组下标)对应的数组位置值改为1。
    在这里插入图片描述
    算法原理:
    1、初始化:
    (1)二进制数组初始化长度n,所有值默认为0;
    (2)多个哈希函数初始化,可把传入key映射为一个整数,然后对数组长度求余,得到一个小于n的索引;
    2、添加元素:
    用多个哈希函数分别计算传入key在数组中的索引值i,将数组中对应i位置设为1;
    3、判断元素是否存在:
    (1)用多个哈希函数分别计算传入key在数组中的索引值i1、i2…;
    (2)依次查询数组中对应i1、i2…位置是否为1,若有某一个不为1,则直接返回不存在;否则认为存在。

优点:
时间和空间上相比于其他数据结构有很大优势,和普通哈希表相比不需要存储元素本身
缺点:
存在误算,即认为本不存在的元素存在
不能删除元素

对于要手写一个布隆过滤器,首先要明确布隆过滤器的核心:

  • 若干哈希函数
  • 存值的Api
  • 判断值得Api

实现的代码如下:

package utility

import (
	"github.com/willf/bitset"
)

//设置哈希数组默认大小为2<<24
const DefaultSize = 2 << 24

//设置种子,保证不同哈希函数有不同的计算方式
var seeds = []uint{7, 11, 13, 31, 37, 61}

type BloomFilter struct {
	//使用第三方库
	Set *bitset.BitSet
	//指定长度为6
	Funcs [6]SimpleHash
}

// 初始化哈希函数
func NewBloomFilter() *BloomFilter {
	bf := new(BloomFilter)
	for i := 0; i < len(bf.Funcs); i++ {
		bf.Funcs[i] = SimpleHash{DefaultSize, seeds[i]}
	}
	bf.Set = bitset.New(DefaultSize)
	return bf
}

// 初始化哈希函数
func (bf BloomFilter) Add(value string) {
	for _, f := range bf.Funcs {
		bf.Set.Set(f.hash(value))
	}
}

// 判断是否存在该值得Api
func (bf BloomFilter) Contains(value string) bool {
	if value == "" {
		return false
	}
	ret := true
	for _, f := range bf.Funcs {
		ret = ret && bf.Set.Test(f.hash(value))
	}
	return ret
}

type SimpleHash struct {
	Cap  uint //过滤器数组长度
	Seed uint //种子
}

// 哈希函数
func (s SimpleHash) hash(value string) uint {
	var result uint = 0
	for i := 0; i < len(value); i++ {
		result = result*s.Seed + uint(value[i])
	}
	return (s.Cap - 1) & result
}

缓存击穿

缓存击穿是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,瞬间对数据库的访问压力增大。
缓存击穿这里强调的是并发,造成缓存击穿的原因有以下两个:

  • 该数据没有人查询过 ,第一次就大并发的访问。(冷门数据
  • 添加到了缓存,reids有设置数据失效的时间 ,这条数据刚好失效,大并发访问(热点数据

解决方案就有两种:
1.使用互斥锁方案:缓存失效时,不是立即去加载db数据,而是先使用某些带成功返回的原子操作命令,如(Redis的setnx)去操作,成功的时候,再去加载db数据库数据和设置缓存。否则就去重试获取缓存。
2. 永不过期:是指没有设置过期时间,但是热点数据快要过期时,异步线程去更新和设置过期时间。
对于缓存击穿的解决方案就是加锁,具体实现的原理图如下:
在这里插入图片描述

package utility

import (
	"context"
	"fmt"
	"math/rand"
	"strconv"
	"strings"
	"time"

	"github.com/go-redis/redis/v8"
	"github.com/gogf/gf/v2/frame/g"
	"github.com/gogf/gf/v2/os/gtime"
	"github.com/gogf/gf/v2/util/grand"
)

var rdb = new(redis.Client)

func init() {
	ctx := context.Background()
	addr, _ := g.Cfg().Get(ctx, "redis.default.address")
	password, _ := g.Cfg().Get(ctx, "redis.default.pass")
	db, _ := g.Cfg().Get(ctx, "redis.default.db")
	fmt.Println(addr.String())
	rdb = redis.NewClient(&redis.Options{
		Addr:     addr.String(),
		Password: password.String(), // no password set
		DB:       db.Int(),          // use default DB
	})
}

//随机字符串 保证不重复
func getRandomString() string {
	return strings.ToLower(strconv.FormatInt(gtime.TimestampNano(), 36) + grand.S(6))
}

var DEFAULT_EXPIRE_TIME = 100 * time.Millisecond

type RedisLock struct {
	Key    string
	Value  string
	Expire time.Duration
}

//设置锁对象
func NewRedisLock(key string, expire time.Duration) RedisLock {
	var duration time.Duration
	if expire == 0 {
		duration = DEFAULT_EXPIRE_TIME
	} else {
		duration = expire
	}
	return RedisLock{
		Key:    "keyLock:" + key,
		Value:  getRandomString(),
		Expire: duration,
	}
}

//加锁
func GetKeyLock(ctx context.Context, redisLock RedisLock) (bool, error) {
	setLock, err := rdb.SetNX(ctx, redisLock.Key, redisLock.Value, redisLock.Expire).Result()
	if err != nil {
		return false, err
	}
	return setLock, nil
}

//解锁
func UnKeyLock(ctx context.Context, redisLock RedisLock) (bool, error) {
	lua := `
	if redis.call('GET', KEYS[1]) == ARGV[1] then
		 return redis.call('DEL', KEYS[1])
	else
	   return 0
	end
	`
	scriptKeys := []string{redisLock.Key}
	val, err := rdb.Eval(ctx, lua, scriptKeys, redisLock.Value).Result()
	if err != nil {
		return false, err
	}
	return val == int64(1), nil

}

//守护线程 延迟锁的过期时间
func WatchDog(ctx context.Context, rdb *redis.Client, redisLock RedisLock) {
	for {
		select {
		// 业务完成
		case <-ctx.Done():
			fmt.Printf("任务完成,关闭%s的自动续期\n", redisLock.Key)
			return
		// 业务未完成
		default:
			// 自动续期
			rdb.PExpire(ctx, redisLock.Key, redisLock.Expire)
			// 继续等待
			time.Sleep(redisLock.Expire / 2)
			fmt.Printf("任务未完成,%s自动续期\n", redisLock.Key)
		}
	}
}

// 重试
var retryTimes = 5

// 重试频率
var retryInterval = time.Millisecond * 50

func retry(ctx context.Context, rdb *redis.Client, redisLock RedisLock, tag string) bool {
	i := 1
	for i <= retryTimes {
		fmt.Printf(tag+"第%d次尝试加锁中...\n", i)
		set, err := rdb.SetNX(ctx, redisLock.Key, redisLock.Value, redisLock.Expire).Result()
		if err != nil {
			panic(err.Error())
		}
		if set == true {
			return true
		}
		time.Sleep(retryInterval)
		i++
	}
	return false
}

// 模拟分布式业务加锁场景
func MockTest(tag string) {
	var ctx, cancel = context.WithCancel(context.Background())

	defer func() {
		// 停止goroutine
		cancel()
	}()

	lockK := "product:1"
	expiration := time.Millisecond * 200
	redisLock := NewRedisLock(lockK, expiration)

	fmt.Println(tag + "尝试加锁")

	set, err := GetKeyLock(ctx, redisLock)

	if err != nil {
		panic(err.Error())
	}

	// 加锁失败,重试
	if set == false && retry(ctx, rdb, redisLock, tag) == false {
		fmt.Println(tag + " server unavailable, try again later")
		return
	}

	fmt.Println(tag + "成功加锁")

	// 加锁成功,新增守护线程
	go WatchDog(ctx, rdb, redisLock)

	// 处理业务(通过随机时间延迟模拟)
	fmt.Println(tag + "等待业务处理完成...")
	time.Sleep(getRandDuration())

	// 业务处理完成
	// 释放锁
	val, _ := UnKeyLock(ctx, redisLock)
	fmt.Println(tag+"释放结果:", val)
}

// 生成随机时间
func getRandDuration() time.Duration {
	rand.Seed(time.Now().UnixNano())
	min := 500
	max := 1000
	return time.Duration(rand.Intn(max-min)+min) * time.Millisecond
}

缓存雪崩

缓存雪崩 是指在某一个时间段,缓存集中过期失效。此刻无数的请求直接绕开缓存,直接请求数据库。
造成缓存雪崩的原因,有以下两种:

  • reids宕机
  • 大部分数据失效

对于缓存雪崩有以下解决方案:
(1)redis高可用
redis有可能挂掉,多增加几台redis实例,(一主多从或者多主多从),这样一台挂掉之后其他的还可以继续工作,其实就是搭建的集群。
(2)限流降级
在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量,对某个key只允许一个线程查询数据和写缓存,其他线程等待。
(3)数据预热
数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key
(4)不同的过期时间
设置不同的过期时间,让缓存失效的时间点尽量均匀。
在这里插入图片描述

缓存雪崩的事前事中事后的解决方案如下:
事前:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃。
事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
限流组件,可以设置每秒的请求,有多少能通过组件,剩余的未通过的请求,怎么办?走降级!可以返回一些默认的值,或者友情提示,或者空值。
好处:
数据库绝对不会死,限流组件确保了每秒只有多少个请求能通过。
只要数据库不死,就是说,对用户来说,2/5 的请求都是可以被处理的。
只要有 2/5 的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值