前言
日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题。一旦涉及大数据量的需求,如一些商品抢购的情景,或者主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘,磁盘读/写速度问题有严重的性能弊端,在这一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。
为了克服上述的问题,项目通常会引入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
}
缓存空对象的实现代码很简单,但是缓存空对象会带来比较大的问题,就是缓存中会存在很多空对象,占用内存的空间,浪费资源,一个解决的办法就是设置空对象的较短的过期时间。
布隆过滤器
布隆过滤器是一种基于概率的数据结构,主要用来判断某个元素是否在集合内,它具有运行速度快(时间效率),占用内存小的优点(空间效率),但是有一定的误识别率和删除困难的问题。它只能告诉你某个元素一定不在集合内或可能在集合内。
布隆过滤器的特点如下:
- 一个非常大的二进制位数组 (数组里只有0和1)
- 若干个哈希函数
- 空间效率和查询效率高
- 不存在漏报(False Negative):某个元素在某个集合中,肯定能报出来。
- 可能存在误报(False Positive):某个元素不在某个集合中,可能也被爆出来。
- 不提供删除方法,代码维护困难。
- 位数组初始化都为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 的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来了。