1. 缓存雪崩、缓存击穿与缓存穿透
1.1 缓存雪崩
缓存雪崩是指在缓存中的大量数据同时失效或过期,导致大量的请求直接打到数据库或后端系统,造成系统压力骤增,甚至导致系统崩溃。
原因:
- 缓存服务器宕机: 如果缓存服务器宕机,导致所有缓存失效,会造成大量请求直接打到数据库。
- 缓存的 key 设置了相同的过期时间: 如果缓存中的大量数据的过期时间设置得太相近,当这些数- 据同时过期时,会导致大量的请求直接落到数据库上。
- 大量热点数据的过期时间一致: 如果某些热点数据的过期时间设置得一致,一旦这些数据过期,会导致大量请求集中到数据库,造成雪崩效应。
- 缓存数据量过大: 如果缓存的数据量非常庞大,当缓存同时失效时,会导致大量的请求直接落到数据库上,增加了数据库的压力。
- 系统并发访问量激增: 当系统的并发访问量激增时,如果缓存中的数据失效过于集中,可能会导致缓存雪崩。
解决方法:
- 设置不同的过期时间: 为不同的缓存数据设置不同的过期时间,避免大量数据同时过期。
- 使用缓存预热: 在系统启动或者缓存失效前,提前加载热点数据到缓存中,减少缓存雪崩的发生。
- 使用多级缓存: 将缓存分为多级,如本地缓存、分布式缓存等,提高系统的稳定性和可靠性。
- 限流降级: 对于并发访问量过大的情况,可以通过限流或者降级等策略,减少对数据库的访问压力。
- 监控和报警: 设置监控系统,实时监控缓存的状态和访问量,及时发现问题并采取应对措施。
- 分布式锁(有趣): 在缓存失效时,使用分布式锁来保证只有一个请求去加载数据到缓存中,避免同时有大量请求去访问数据库。
1.2 缓存击穿
一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。
缓存击穿指的是针对某个特定的key,由于缓存中没有该key的数据,所有的请求都直接落到了后端数据库,这样的请求可能会触发对数据库等存储系统的频繁查询,增加了系统的负载。
解决办法:
- 设置热点数据永不过期: 对于一些热点数据,可以设置其永不过期,这样即使缓存中的其他数据过期,热点数据仍然可以被命中,减少了对数据库的直接访问。
- 使用互斥锁或分布式锁: 在缓存失效时,使用互斥锁或分布式锁来保证只有一个请求去加载数据到缓存中,避免同时有大量请求去访问数据库。
- 缓存空对象: 在缓存中设置一个空对象来表示该key对应的数据不存在,这样即使缓存没有命中,也不会直接访问数据库。这种方法可以防止恶意攻击或者非法请求造成的缓存穿透问题。
- 限流策略: 对于大量请求同时访问的情况,可以采用限流等策略,控制并发访问量,减少对数据库的压力。
- 提前异步加载数据: 在缓存失效前,提前异步加载数据到缓存中,避免缓存失效时大量请求同时击穿到数据库
1.3 缓存穿透
查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。
缓存穿透指的是恶意用户发送的请求,这些请求所查询的数据在缓存中不存在,也不会存在于后端数据库中,但是由于缓存无法命中,请求会直接访问数据库,这样的请求可能会导致数据库负载过高,甚至引起数据库宕机。
解决办法:
- 使用布隆过滤器(Bloom Filter): 布隆过滤器是一种高效的数据结构,可以用来快速判断一个元素是否存在于一个集合中。可以在缓存层前使用布隆过滤器,用来过滤掉一些不存在的请求,减少对数据库的直接访问。
- 缓存空对象: 在缓存中设置一个空对象来表示该key对应的数据不存在,即使查询的数据在数据库中不存在,也可以在缓存中命中空对象,避免对数据库的直接访问。
- 限制请求频率: 对于频繁查询不存在数据的请求,可以设置限制频率,减少对数据库的访问次数,从而降低数据库的负载。
- 使用预加载: 在系统启动时或者定期更新时,可以预先加载一些热点数据到缓存中,避免因为热点数据缓存失效而导致的大量请求直接落到数据库。
- 使用缓存穿透保护策略: 一些缓存系统提供了缓存穿透保护策略,可以通过设置异常数据缓存、黑名单过滤等方式来保护数据库免受缓存穿透的影响。
2. singleflight的实现
通过singleflight可以缓解缓存雪崩、缓存击穿与缓存穿透等现象
import "sync"// call 代表正在进行中,或已经结束的请求。使用 sync.WaitGroup 锁避免重入。
type call struct {wg sync.WaitGroupval interface{}err error
}// 管理不同 key 的请求(call)
type Group struct {mu sync.Mutex // 保护m这个mapm map[string]*call
}// 同一时间只有
// m中不存在key的情况下,先锁map,之后新建call,call信号量+1,将call加入到map中,之后解锁map,
// 此时即使再有请求,也会等待map解锁之后再进入map的查询,此时会命中g.m[key],不会请求远端数据库,而是等待返回。等待fn函数获取完之后,call信号量-1.此时并发的请求都能获得返回值
// 删除m中的key
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {g.mu.Lock()if g.m == nil {g.m = make(map[string]*call)}// 如果可以在Group中查询到,代表该请求正在处理中或已经处理结束if c, ok := g.m[key]; ok {g.mu.Unlock()// 等待这个call请求完成c.wg.Wait()return c.val, c.err}// 还没有请求服务器,新建一个callc := new(call)c.wg.Add(1)g.m[key] = cg.mu.Unlock()// 通过fn函数来获取数据库的值c.val, c.err = fn()c.wg.Done() //表示获取完毕// 从map中删除对应的key\g.mu.Lock()delete(g.m, key)g.mu.Unlock()return c.val, c.err
}