In the difficult employment situation, we need to set a good goal and then do our own thing
参考书籍:“凤凰架构”
缓存在分布式系统是可选,在使用缓存之前需要确认你的系统是否真的需要缓存,因为从开发角度来说,引入缓存会提高系统复杂度,因为你要考虑缓存的失效、更新、一致性等问题(硬件缓存也有这些问题,只是不需要由你去考虑,主流的 ISA 也都没有提供任何直接操作缓存的指令);从运维角度来说,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;从安全角度来说,缓存可能泄漏某些保密数据,也是容易受到攻击的薄弱点。如果上诉的几种引入缓存所带来的困难你都可以接受,那就说明目前你的系统目前碰到的问题所带来的影响远远比上诉的几种困难更加难以接受,而引入缓存的理由,总结起来无外乎以下两种:
而最常见的一种也是使用较多的进程中的内存缓存之一就是jdk提供的HashMap,虽然HashMap拥有缓存数据的能力,但是功能比较单一,并且这个HashMap缓存容器的操作并不是一个并发安全的容器。虽然jdk提供了Collections.synchronizedMap()工具类去获取并发安全容器,这在一定程度上解决了并发安全的问题,但是由于Collections工具类创建的容器是通过在方法里面加synchronize的方法保证并发安全,这种细粒度的锁无法保证在高并发的情况的操作容器数据的一个高吞吐量(OPS)
//以下是使用Collections.synchronizedMap()方法创建容器的常见api,都是通过synchronized关键字保证的并发安全,但是无法保证高并发下的高吞吐量
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
为了保证高并发下的高吞吐量,jdk提供了ConcurrentHashMap并发容器给开发人员使用,此容器相比较Collections工具类创建的容器最大的区别在于使用更细粒度的锁去保证高并发情况操作数据的安全(jdk1.7采用的是分段锁、jdk1.8则是采用cas+synchronize锁单个元素去保证并发安全),以此来提高吞吐量。
尽管jdk已经提供了基础缓存能力的一些容器给我们使用,但是也无法满足日益增长的业务场景需要,因此Caffeine、Guave等一系列进程内缓存实现方案相继出现,而开发人员如何抉择缓存方案则一般需要从以下四个维度去考量:
并发读写的场景中,吞吐量受多方面因素的共同影响,譬如,怎样设计数据结构以尽可能避免数据竞争,存在竞争风险时怎样处理同步(主要有使用锁实现的悲观同步和使用CAS实现的乐观同步)、如何避免伪共享现象(False Sharing,这也算是典型缓存提升开发复杂度的例子)发生,等等。其中第一点是格外重要的一点,无论如何实现同步都不会比直接无须同步更快。那么如果是我们自己去设计一个缓存容器,应该如何避免竞争、提高吞吐量呢?
缓存中最主要的数据竞争源于读取数据的同时,也会伴随着对数据状态的写入操作,写入数据的同时,也会伴随着数据状态的读取操作。譬如,读取时要同时更新数据的最近访问时间和访问计数器的状态(后文会提到,为了追求高效,可能不会记录时间和次数,譬如通过调整链表顺序来表达时间先后、通过 Sketch 结构来表达热度高低),以实现缓存的淘汰策略;又或者读取时要同时判断数据的超期时间等信息,以实现失效重加载等其他扩展功能。对以上伴随读写操作而来的状态维护,有两种可选择的处理思路,一种是以 Guava Cache 为代表的同步处理机制,即在访问数据时一并完成缓存淘汰、统计、失效等状态变更操作,通过分段加锁等优化手段来尽量减少竞争。另一种是以 Caffeine 为代表的异步日志提交机制,这种机制参考了经典的数据库设计理论,将对数据的读、写过程看作是日志(即对数据的操作指令)的提交过程。尽管日志也涉及到写入操作,有并发的数据变更就必然面临锁竞争,但异步提交的日志已经将原本在 Map 内的锁转移到日志的追加写操作上,日志里腾挪优化的余地就比在 Map 中要大得多。
在 Caffeine 的实现中,设有专门的环形缓存区(Ring Buffer,也常称作 Circular Buffer)来记录由于数据读取而产生的状态变动日志。为进一步减少竞争,Caffeine 给每条线程(对线程取 Hash,哈希值相同的使用同一个缓冲区)都设置一个专用的环形缓冲。
所谓环形缓冲,并非 Caffeine 的专有概念,它是一种拥有读、写两个指针的数据复用结构,在计算机科学中有非常广泛的应用。举个具体例子,譬如一台计算机通过键盘输入,并通过 CPU 读取“HELLO WIKIPEDIA”这个长 14 字节的单词,通常需要一个至少 14 字节以上的缓冲区才行。但如果是环形缓冲结构,读取和写入就应当一起进行,在读取指针之前的位置均可以重复使用,理想情况下,只要读取指针不落后于写入指针一整圈,这个缓冲区就可以持续工作下去,能容纳无限多个新字符。否则,就必须阻塞写入操作去等待读取清空缓冲区。
从 Caffeine 读取数据时,数据本身会在其内部的 ConcurrentHashMap 中直接返回,而数据的状态信息变更就存入环形缓冲中,由后台线程异步处理。如果异步处理的速度跟不上状态变更的速度,导致缓冲区满了,那此后接收的状态的变更信息就会直接被丢弃掉,直至缓冲区重新富余。通过环形缓冲和容忍有损失的状态变更,Caffeine 大幅降低了由于数据读取而导致的垃圾收集和锁竞争,因此 Caffeine 的读取性能几乎能与 ConcurrentHashMap 的读取性能相同。
向 Caffeine 写入数据时,将使用传统的有界队列(ArrayQueue)来存放状态变更信息,写入带来的状态变更是无损的,不允许丢失任何状态,这是考虑到许多状态的默认值必须通过写入操作来完成初始化,因此写入会有一定的性能损失。根据 Caffeine 官方给出的数据,相比ConcurrentHashMap,Caffeine 在写入时大约会慢 10%左右。
命中率和淘汰策略放在一起说明是因为缓存的淘汰策略会直接影响缓存的命中效率,当然,如果你的缓存没有淘汰策略命中率肯定是最高的,但是有限的物理存储决定了任何缓存的容量都不可能是无限的,所以缓存需要在消耗空间与节约时间之间取得平衡,这要求缓存必须能够自动或者由人工淘汰掉缓存中的低价值数据。
而什么叫做“低价值”数据呢?由于缓存的通用性,这个问题的答案必须是与具体业务逻辑是无关的,只能从缓存工作过程收集到的统计结果来确定数据是否有价值,通用的统计结果包括但不限于数据何时进入缓存、被使用过多少次、最近什么时候被使用,等等。由此决定了一旦确定选择何种统计数据,及如何通用地、自动地判定缓存中每个数据价值高低,也相当于决定了缓存的淘汰策略是如何实现的。目前,最基础的淘汰策略实现方案有以下三种:
FIFO(First In First Out):优先淘汰最早进入被缓存的数据。FIFO 实现十分简单,但一般来说它并不是优秀的淘汰策略,越是频繁被用到的数据,往往会越早被存入缓存之中。如果采用这种淘汰策略,很可能会大幅降低缓存的命中率。(此缓存相比较简单,因此)
LRU(Least Recent Used):优先淘汰最久未被使用访问过的数据。LRU 通常会采用 HashMap 加 LinkedList 双重结构(如 LinkedHashMap)来实现,以 HashMap 来提供访问接口,保证常量时间复杂度的读取性能,以 LinkedList 的链表元素顺序来表示数据的时间顺序,每次缓存命中时把返回对象调整到 LinkedList 开头,每次缓存淘汰时从链表末端开始清理数据。对大多数的缓存场景来说,LRU 都明显要比 FIFO 策略合理,尤其适合用来处理短时间内频繁访问的热点对象。但相反,它的问题是如果一些热点数据在系统中经常被频繁访问,但最近一段时间因为某种原因未被访问过,此时这些热点数据依然要面临淘汰的命运,LRU 依然可能错误淘汰价值更高的数据。案例如下:
/**
* LRUCache implementation
*
* @author disaster
* @version 1.0
*
* @param
* @param
*/
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
public static final LRUCache<String,ReentrantLock> LOCK_CACHE_INSTANCE = new LRUCache<String,ReentrantLock>(10000);
private static final long serialVersionUID = -5167631809472116969L;
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
private static final int DEFAULT_MAX_CAPACITY = 1000;
private final Lock lock = new ReentrantLock();
private volatile int maxCapacity;
public LRUCache() {
this(DEFAULT_MAX_CAPACITY);
}
public LRUCache(int maxCapacity) {
super(16, DEFAULT_LOAD_FACTOR, true);
this.maxCapacity = maxCapacity;
}
@Override
protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
return size() > maxCapacity;
}
@Override
public boolean containsKey(Object key) {
try {
lock.lock();
return super.containsKey(key);
} finally {
lock.unlock();
}
}
@Override
public V get(Object key) {
try {
lock.lock();
return super.get(key);
} finally {
lock.unlock();
}
}
@Override
public V put(K key, V value) {
try {
lock.lock();
return super.put(key, value);
} finally {
lock.unlock();
}
}
@Override
public V remove(Object key) {
try {
lock.lock();
return super.remove(key);
} finally {
lock.unlock();
}
}
@Override
public int size() {
try {
lock.lock();
return super.size();
} finally {
lock.unlock();
}
}
@Override
public void clear() {
try {
lock.lock();
super.clear();
} finally {
lock.unlock();
}
}
public int getMaxCapacity() {
return maxCapacity;
}
public void setMaxCapacity(int maxCapacity) {
this.maxCapacity = maxCapacity;
}
}
/**
* 哈希链表实现LRU
*
* @author disaster
* @version 1.0
* @param
* @param
*/
public class ILRUCache<K, V> {
private HashMap<K, Node<K, V>> map;
private BidirectionalLinkedList<K, V> cache;
// 最大容量
private int cap;
public ILRUCache(int cap) {
this.cap = cap;
this.map = new HashMap<K, Node<K, V>>();
this.cache = new BidirectionalLinkedList<K, V>();
}
public void put(K key, V value) {
if (map.containsKey(key)) {
remove(key);
addRecently(key, value);
return;
}
if (map.size() == cap) {
removeLeastRecently();
}
addRecently(key, value);
}
public V get(K key) {
if (!map.containsKey(key)) {
return null;
}
makeRecently(key);
return map.get(key).getValue();
}
public void makeRecently(K key) {
Node<K, V> kvNode = map.get(key);
cache.remove(kvNode);
cache.addLast(kvNode);
}
public void removeLeastRecently() {
Node<K, V> kvNode = cache.removeFirst();
// 同时别忘了从 map 中删除它的 key
assert kvNode != null;
K deletedKey = kvNode.key;
map.remove(deletedKey);
}
public void remove(K key) {
Node<K, V> x = map.get(key);
cache.remove(x);
map.remove(key);
cap--;
}
public void addRecently(K key, V value) {
Node<K, V> kvNode = new Node<>(key, value);
cache.addLast(kvNode);
map.put(key, kvNode);
cap++;
}
private static class BidirectionalLinkedList<K, V> {
private Node<K, V> head, tail;
private int size;
public BidirectionalLinkedList() {
this.head = new Node<K, V>((K) new Object(), (V) new Object());
this.tail = new Node<K, V>((K) new Object(), (V) new Object());
this.size = 0;
}
public void addLast(Node<K, V> x) {
x.prev = tail.prev;
x.next = tail;
tail.prev.next = x;
tail.prev = x;
size++;
}
// 删除链表中的 x 节点(x 一定存在)
// 由于是双链表且给的是目标 Node 节点,时间 O(1)
public void remove(Node<K, V> x) {
x.prev = x.next;
x.next.prev = x.prev;
size--;
}
// 删除链表中第一个节点,并返回该节点,时间 O(1)
public Node<K, V> removeFirst() {
if (head.next == tail) {
return null;
}
Node<K, V> first = head.next;
remove(first);
return first;
}
public int size() {
return size;
}
}
private static class Node<K, V> {
private K key;
private V value;
private Node<K, V> prev;
private Node<K, V> next;
public Node(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public void setKey(K key) {
this.key = key;
}
public V getValue() {
return value;
}
public void setValue(V value) {
this.value = value;
}
public Node<K, V> getPrev() {
return prev;
}
public void setPrev(Node<K, V> prev) {
this.prev = prev;
}
public Node<K, V> getNext() {
return next;
}
public void setNext(Node<K, V> next) {
this.next = next;
}
}
}
LFU(Least Frequently Used):优先淘汰最不经常使用的数据。LFU 会给每个数据添加一个访问计数器,每访问一次就加 1,需要淘汰时就清理计数器数值最小的那批数据。LFU 可以解决上面 LRU 中热点数据间隔一段时间不访问就被淘汰的问题,但同时它又引入了两个新的问题,首先是需要对每个缓存的数据专门去维护一个计数器,每次访问都要更新,在上一节“吞吐量”里解释了这样做会带来高昂的维护开销;另一个问题是不便于处理随时间变化的热度变化,譬如某个曾经频繁访问的数据现在不需要了,它也很难自动被清理出缓存。案例:
/**
* LFU implementation
*
* @author wangwei
* @version 1.0
* @param
* @param
*/
public class LFUCache<K, V> {
public final static LFUCache<String,Object> CONDITIONS_RESULT_CACHE = new LFUCache<String,Object>(10000,0.75f);
public final static LFUCache<String,ReentrantLock> LOCK_CACHE_INSTANCE = new LFUCache<String,ReentrantLock>(10000,0.75f);
public final static String PREFIX = "LFU_PREFIX";
private Map<K, CacheNode<K, V>> map;
private CacheDeque<K, V>[] freqTable;
private final int capacity;
private int evictionCount;
private int curSize = 0;
private final ReentrantLock lock = new ReentrantLock();
private static final int DEFAULT_INITIAL_CAPACITY = 1000;
private static final float DEFAULT_EVICTION_FACTOR = 0.75f;
public LFUCache() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_EVICTION_FACTOR);
}
/**
* Constructs and initializes cache with specified capacity and eviction
* factor. Unacceptable parameter values followed with
* {@link IllegalArgumentException}.
*
* @param maxCapacity cache max capacity
* @param evictionFactor cache proceedEviction factor
*/
@SuppressWarnings("unchecked")
public LFUCache(final int maxCapacity, final float evictionFactor) {
if (maxCapacity <= 0) {
throw new IllegalArgumentException("Illegal initial capacity: " +
maxCapacity);
}
boolean factorInRange = evictionFactor <= 1 && evictionFactor > 0;
if (!factorInRange) {
throw new IllegalArgumentException("Illegal eviction factor value:"
+ evictionFactor);
}
this.capacity = maxCapacity;
this.evictionCount = (int) (capacity * evictionFactor);
this.map = new HashMap<K, CacheNode<K, V>>();
this.freqTable = new CacheDeque[capacity + 1];
for (int i = 0; i <= capacity; i++) {
freqTable[i] = new CacheDeque<K, V>();
}
for (int i = 0; i < capacity; i++) {
freqTable[i].nextDeque = freqTable[i + 1];
}
freqTable[capacity].nextDeque = freqTable[capacity];
}
public int getCapacity() {
return capacity;
}
public V put(final K key, final V value) {
CacheNode<K, V> node;
lock.lock();
try {
node = map.get(key);
if (node != null) {
CacheNode.withdrawNode(node);
node.value = value;
freqTable[0].addLastNode(node);
map.put(key, node);
} else {
node = freqTable[0].addLast(key, value);
map.put(key, node);
curSize++;
if (curSize > capacity) {
proceedEviction();
}
}
} finally {
lock.unlock();
}
return node.value;
}
public V remove(final K key) {
CacheNode<K, V> node = null;
lock.lock();
try {
if (map.containsKey(key)) {
node = map.remove(key);
if (node != null) {
CacheNode.withdrawNode(node);
}
curSize--;
}
} finally {
lock.unlock();
}
return (node != null) ? node.value : null;
}
public V get(final K key) {
CacheNode<K, V> node = null;
lock.lock();
try {
if (map.containsKey(key)) {
node = map.get(key);
CacheNode.withdrawNode(node);
node.owner.nextDeque.addLastNode(node);
}
} finally {
lock.unlock();
}
return (node != null) ? node.value : null;
}
/**
* Evicts less frequently used elements corresponding to eviction factor,
* specified at instantiation step.
*
* @return number of evicted elements
*/
private int proceedEviction() {
int targetSize = capacity - evictionCount;
int evictedElements = 0;
FREQ_TABLE_ITER_LOOP:
for (int i = 0; i <= capacity; i++) {
CacheNode<K, V> node;
while (!freqTable[i].isEmpty()) {
node = freqTable[i].pollFirst();
remove(node.key);
if (targetSize >= curSize) {
break FREQ_TABLE_ITER_LOOP;
}
evictedElements++;
}
}
return evictedElements;
}
/**
* Returns cache current size.
*
* @return cache size
*/
public int getSize() {
return curSize;
}
static class CacheNode<K, V> {
CacheNode<K, V> prev;
CacheNode<K, V> next;
K key;
V value;
CacheDeque<K, V> owner;
CacheNode() {
}
CacheNode(final K key, final V value) {
this.key = key;
this.value = value;
}
/**
* This method takes specified node and reattaches it neighbors nodes
* links to each other, so specified node will no longer tied with them.
* Returns united node, returns null if argument is null.
*
* @param node note to retrieve
* @param key
* @param value
* @return retrieved node
*/
static <K, V> CacheNode<K, V> withdrawNode(
final CacheNode<K, V> node) {
if (node != null && node.prev != null) {
node.prev.next = node.next;
if (node.next != null) {
node.next.prev = node.prev;
}
}
return node;
}
}
/**
* Custom deque implementation of LIFO type. Allows to place element at top
* of deque and poll very last added elements. An arbitrary node from the
* deque can be removed with {@link CacheNode#withdrawNode(CacheNode)}
* method.
*
* @param key
* @param value
*/
static class CacheDeque<K, V> {
CacheNode<K, V> last;
CacheNode<K, V> first;
CacheDeque<K, V> nextDeque;
/**
* Constructs list and initializes last and first pointers.
*/
CacheDeque() {
last = new CacheNode<K, V>();
first = new CacheNode<K, V>();
last.next = first;
first.prev = last;
}
/**
* Puts the node with specified key and value at the end of the deque
* and returns node.
*
* @param key key
* @param value value
* @return added node
*/
CacheNode<K, V> addLast(final K key, final V value) {
CacheNode<K, V> node = new CacheNode<K, V>(key, value);
node.owner = this;
node.next = last.next;
node.prev = last;
node.next.prev = node;
last.next = node;
return node;
}
CacheNode<K, V> addLastNode(final CacheNode<K, V> node) {
node.owner = this;
node.next = last.next;
node.prev = last;
node.next.prev = node;
last.next = node;
return node;
}
/**
* Retrieves and removes the first node of this deque.
*
* @return removed node
*/
CacheNode<K, V> pollFirst() {
CacheNode<K, V> node = null;
if (first.prev != last) {
node = first.prev;
first.prev = node.prev;
first.prev.next = first;
node.prev = null;
node.next = null;
}
return node;
}
/**
* Checks if link to the last node points to link to the first node.
*
* @return is deque empty
*/
boolean isEmpty() {
return last.next == first;
}
}
}
随着缓存淘汰策略的不断发展,针对上诉的三种常见淘汰策略的一些缺陷,大神们又提出许多相对性能要更好的,也更为复杂的新算法。以 LFU 分支为例,针对它存在的两个问题,近年来提出的 TinyLFU 和 W-TinyLFU 算法就往往会有更好的效果。
当然除了LFU淘汰策略的算法优化,其他的淘汰策略也有相应的改进,如果对这方面感兴趣的小伙伴可以去看看Cache Replacement Policies
不同的缓存方案支持的扩展能力不同,下面列举一下博主所了解的一些扩展能力:
上诉对于缓存的介绍都是基于进程内的缓存,如果是巨石系统我们可以随意选择上面介绍了任意一种高效的缓存方案,但是如果是微服务/分布式的系统下,这些方案就无法满足我们的需求了。举个例子,提供订单能力的服务一共有三台服务器,如果采用上诉的guava或者caffine缓存方案,当一个请求打到A服务器,那么相应的订单数据会缓存到A服务器的进程中,此时B、C服务器中是没有缓存到此次请求的订单数据的,如果下次请求没有打到A服务器而是打到B或者C服务器,此时B、C服务器中是没有此次请求的缓存数据的,那么就会去访问数据库,如果说你的系统QPS不高,这种方案在一定程度上也是可以缓解数据库的压力,但如果是比较极端的情况下或者QPS比较高的场景,在那一时刻,几百上千万的请求打过来,由于B、C服务器中没有缓存相应的订单信息,所有请求全部去查询DB,这会给DB造成极大的压力,甚至会打挂DB。这种情况对于高可用的系统是无法容忍的,那么如何去保证一次请求过来所有服务器都能够感知缓存数据呢?因此我们引入分布式缓存方案来解决这种问题
相比起缓存数据在进程内存中读写的速度,一旦涉及网络访问,由网络传输、数据复制、序列化和反序列化等操作所导致的延迟要比内存访问高得多,所以对分布式缓存来说,处理与网络有相关的操作是对吞吐量影响更大的因素,往往也是比淘汰策略、扩展功能更重要的关注点,这决定了尽管也有 Ehcache、Infinispan 这类能同时支持分布式部署和进程内嵌部署的缓存方案,但通常进程内缓存和分布式缓存选型时会有完全不同的候选对象及考察点。
分布式缓存与进程内缓存各有所长,也有各有局限,它们是互补而非竞争的关系,如有需要,完全可以同时把进程内缓存和分布式缓存互相搭配,构成透明多级缓存(Transparent Multilevel Cache,TMC),如下图所示。先不考虑“透明”的话,多级缓存是很好理解的,使用进程内缓存做一级缓存,分布式缓存做二级缓存,如果能在一级缓存中查询到结果就直接返回,否则便到二级缓存中去查询,再将二级缓存中的结果回填到一级缓存,以后再访问该数据就没有网络请求了。如果二级缓存也查询不到,就发起对最终数据源的查询,将结果回填到一、二级缓存中去。
尽管多级缓存结合了进程内缓存和分布式缓存的优点,但它的代码侵入性较大,需要由开发者承担多次查询、多次回填的工作,也不便于管理,如超时、刷新等策略都要设置多遍,数据更新更是麻烦,很容易会出现各个节点的一级缓存、以及二级缓存里数据互相不一致的问题。必须“透明”地解决以上问题,多级缓存才具有实用的价值。一种常见的设计原则是变更以分布式缓存中的数据为准,访问以进程内缓存的数据优先。大致做法是当数据发生变动时,在集群内发送推送通知(简单点的话可采用 Redis 的 PUB/SUB,求严谨的话引入 ZooKeeper 或 Etcd 来处理),让各个节点的一级缓存自动失效掉相应数据。当访问缓存时,提供统一封装好的一、二级缓存联合查询接口,接口外部是只查询一次,接口内部自动实现优先查询一级缓存,未获取到数据再自动查询二级缓存的逻辑。
按照“没有银弹”理论的角度来看,任何技术栈都是有利有弊,同样的使用缓存也会带来一些问题,如果:缓存一致性、缓存穿透、缓存击穿、缓存雪崩等。
缓存一致性问题指的就是如何保证db的数据和缓存中数据的一致性,而常见的解决方案有如:Cache Aside、Read Through、Write Through、Write Behind、Double Delete等,由于篇幅问题,这里博主就不过多介绍这几种方案了,如果对这几种方案感兴趣的话可以去看Consistency between Redis Cache and SQL Database
这三种问题已经有很多博主也做过详细说明,这里贴一篇文章how to solve the problem of redis cache avalanche breakdown and peneration,感兴趣的小伙伴可以去看看