前言

如今的互联网时代,每天的信息量与计算量都是巨大的,为了应对日益复杂的计算与访问需求,我们的应用自然需要支撑更多的并发量,这也就意味着我们的应用服务器和数据库服务器所做的计算也越来越多,但是往往我们的应用服务器的资源是有限的,利用缓存能够帮助我们有效的利用有限的资源来提供更大的吞吐量,降低服务器响应时间,提高用户体验,且服务于更多的用户。

总的来说,我们为什么引入缓存,主要两个用途:高性能、高并发

高性能

服务的高性能直接的反馈便是用户操作后服务的响应时间,这里举个例子,如果用户的操作在后台服务需要MySQL一些复杂查询,查出的结果常用且长时间不会改变,我们就可以将其丢进缓存里,这样相比于每次查询运行各种各样的麻烦的SQL语句(这里假设处理时间为600ms),在缓存中取(2ms),性能直接提高300倍。

高并发

单机的MySQL也就最多支持上千的QPS(已经开始报警了),如果在流量高时段的时期一次性打来10万个请求,全部打到数据库,MySQL可能在报警后直接就和我们说再见了。这个时候能用上缓存的话,一秒钟几万十几万的需求量问题都是不大的,整个服务能够承受的并发量就增加了几十倍。

缓存特征

缓存也是一种数据模型对象,那么必然也会有它的特征

命中率

命中率=返回正确结果数/请求缓存次数,命中率问题是缓存中的一个非常重要的问题,它是衡量缓存有效性的重要指标。命中率越高,表明缓存的使用率越高。

最大空间

缓存中可以存放的最大元素的数量,毕竟一台服务器上,虽然硬盘空间能有几十几百个TB,但是一般内存最多也就上百G,所以相比硬盘存储空间来说,内存空间珍贵很多,因为它非常快,说回来,一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略,根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,从而更有效的利用缓存。

这里展开说一下内存的淘汰机制

缓存淘汰机制
  • FIFO(first in first out)

先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。

  • LFU(less frequently used)

最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。在保证高频数据有效性场景下,可选择这类策略。

  • LRU(least recently used)

最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。在热点数据场景下较适用,优先保证热点数据的有效性。

除此之外,还有一些简单策略比如:

  • 根据过期时间判断,清理过期时间最长的元素;
  • 根据过期时间判断,清理最近要过期的元素;
  • 随机清理;
  • 根据关键字(或元素内容)长短清理等。

举个例子,我们常用的redis中有一下几个清理策略

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

展开多说一下我们常用的LRU淘汰机制

LRU(Least Recently Used):淘汰近期最不会访问的数据,这个一般来说是最常用的,特别是当内存中有出现热点数据,这个淘汰算法效率是很高的,而且实现的方式也很简单:类似于一个链表,当我们每次在缓存中插入数据以及查询数据的时候,将该数据移动到表头,每次触发清理机制的时候则将链表最后的数据清除,这样经常被查询的”热点数据“便一直在链表前列保留了下来,而不常用的”冷数据“则及时被清除,由于链表查询慢,所以我们优化一下,用数组加链表组合的方式,Java中就有类似的数据结构,也就是HashMap。

说了那么多,上点代码直观一点:转载LRU算法及Java实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
class LRUCache {
// 双向链表节点定义
class Node {
int key;
int val;
Node prev;
Node next;
}
private int capacity;
//保存链表的头节点和尾节点
private Node first;
private Node last;

private Map<Integer, Node> map;

public LRUCache(int capacity) {
this.capacity = capacity;
map = new HashMap<>(capacity);
}

//获取节点
public int get(int key) {
Node node = map.get(key);
//为空返回-1
if (node == null) {
return -1;
}
//将节点移动到表头
moveToHead(node);
return node.val;
}

//将节点移动到表头的方法
private void moveToHead(Node node) {
if (node == first) {
return;
} else if (node == last) {
last.prev.next = null;
last = last.prev;
} else {
node.prev.next = node.next;
node.next.prev = node.prev;
}
node.prev = first.prev;
node.next = first;
first.prev = node;
first = node;
}
//插入数据
public void put(int key, int value) {
Node node = map.get(key);

if (node == null) {
node = new Node();
node.key = key;
node.val = value;
//如果缓存到达空间临界,触发淘汰清理机制
if(map.size() == capacity) {
removeLast();
}
//将插入的数据加到表头
addToHead(node);
map.put(key, node);
} else {
node.val = value;
moveToHead(node);
}
}

//将节点加到表头
private void addToHead(Node node) {
if (map.isEmpty()) {
first = node;
last = node;
} else {
node.next = first;
first.prev = node;
first = node;
}
}

//清理map中最后的节点
private void removeLast() {
map.remove(last.key);
Node prevNode = last.prev;
if (prevNode != null) {
prevNode.next = null;
last = prevNode;
}
}

@Override
public String toString() {
return map.keySet().toString();
}
}

缓存数据库一致性

关于缓存与数据库一致性,可以见我另一篇学习笔记缓存与数据库一致性这里我们便不再过多赘述

缓存雪崩,穿透和击穿

缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很有可能是攻击者,攻击会导致数据库压力过大。这个时候由于缓存查找不到数据(id为负数咋找?),所以大量的请求打到数据库直接把数据库打死。

解决方案

  1. 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
  2. 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-UNKNOWN(例 set -999 UNKNOWN),缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。
缓存雪崩

所谓缓存雪崩就是在某一个时刻,缓存集大量失效。所有流量直接打到数据库上,对数据库造成巨大压力;

对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。

解决方案

  • 事前:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃。
  • 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
  • 事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

用户发送一个请求,系统 A 收到请求后,先查本地 ehcache 缓存,如果没查到再查 Redis。如果 ehcache 和 Redis 都没有,再查数据库,将数据库中的结果,写入 ehcache 和 Redis 中。

限流组件,可以设置每秒的请求,有多少能通过组件,剩余的未通过的请求,怎么办?走降级!可以返回一些默认的值,或者友情提示,或者空值。

缓存击穿

缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间(有效时间到了),大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。

不同场景下的解决方式可如下:

  • 若缓存的数据是基本不会发生更新的,则可尝试将该热点数据设置很长的有效时间(甚至永不过期)。
  • 若缓存的数据更新不频繁,且缓存刷新的整个流程耗时较少的情况下,则可以采用基于 Redis、zookeeper 等分布式中间件的分布式互斥锁,或者本地互斥锁以保证仅少量的请求能请求数据库并重新构建缓存,其余线程则在锁释放后能访问到新缓存。
  • 若缓存的数据更新频繁或者在缓存刷新的流程耗时较长的情况下,可以利用定时线程在缓存过期前主动地重新构建缓存或者延后缓存的过期时间,以保证所有的请求能一直访问到对应的缓存。

双写不一致

在使用数据库缓存的时候,读和写的流程往往是这样的:

  • 读取的时候,先读取缓存,如果缓存中没有,就直接从数据库中读取,然后取出数据后放入缓存
  • 更新的时候,先删除缓存,再更新数据库

所谓的双写不一致,就是在发生写操作(更新)的时候或写操作之后,可能会存在数据库里面的值和缓存中的值不同的情况。

为什么这样并不能完全避免双写不一致问题,假设再并发量比较大的情况,一个线程先删除缓存,然后更新数据库,这个时候又出现另一个线程去这个数据,先去取缓存,发现没有值,于是去读数据库,让后把数据库旧的值设置就缓存,这个时候上一个线程刚刚更新完数据库的值,发生了数据不一致的问题。

我们仔细分析上述的情况,可以发现,读请求和写请求是并行的,这是导致数据一致性的根本原因,并行的请求会导致数据一致性的问题,那么解决此类问题的思路就有了——将请求串行!

具体的业务逻辑如下:

  1. 写请求过来,将写请求缓存到缓存队列中,并且开始执行写请求的具体操作(删除缓存中的数据,更新数据库,更新缓存)。
  2. 如果在更新数据库过程中,又来了个读请求,将读请求再次存入到缓存队列中,等待队列前的写请求执行完成,才会执行读请求。
  3. 之前的写请求删除缓存失败,直接返回,此时数据库中的数据是旧值,并且与缓存中的数据是一致的,不会出现缓存一致性的问题。
  4. 写请求删除缓存成功,则更新数据库,如果更新数据库失败,则直接返回,写请求结束,此时数据库中的值依旧是旧值,读请求过来后,发现缓存中没有数据, 则会直接向数据库中请求,同时将数据写入到缓存中,此时也不会出现数据一致性的问题。
  5. 更新数据成功之后,再更新缓存,如果此时更新缓存失败,则缓存中没有数据,数据库中是新值 ,写请求结束,此时读请求还是一样,发现缓存中没有数据,同样会从数据库中读取数据,并且存入到缓存中,其实这里不管更新缓存成功还是失败, 都不会出现数据一致性的问题。

上述的解决方案是将异步请求串行化,这样做的好处呢就是队列上的工作线程完成之后上一个操作数据库的修改之后,才会执行下一个操作。

上述的解决方案中还有个可以优化的地方,如果在修改数据库更新缓存的过程中,不断有读请求过来怎么处理,队列中都一次防止每次的读请求么,不是的,存放大多的队列只会占用队列的资源,我们这里可以判断过滤下读请求,直接返回,提示用户刷新下页面,重新请求数据,这个过程足够队列中写操作执行完成了,读请求再次请求过来时,可以直接返回缓存即可。