多级缓存原理

本地缓存

由线上缓存 bug 引起的对本地缓存的思考

LoadingCache 是 Guava 提供的一个本地缓存组件,但是我对它是又爱又恨,一方面因为 LoadingCache 比较完善,免去很多应用层缓存的细节问题(如何写出 GC 友好的缓存?),而另一方面如果对 LoadingCache 了解不够深入又容易出现奇奇怪怪的问题。下面就来描述一下之前碰到过的两个 LoadingCache 的坑,首先给出最简单的缓存配置:

1
2
3
4
5
6
7
8
9
LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
.expireAfterAccess(60 * 1000, MILLISECONDS) // 1
.maximumSize(500)
.build(new CacheLoader<String, Object>() {
@Override
public Object load(String id) throws Exception {
return query(id); // 2
}
});
  1. 在构建 LoadingCache 的时候可以配置缓存的过期策略,LoadingCache 其实有三种常用的过期策略:
    • expireAfterAccess:在最后一次访问后空闲一段时间才过期;
    • expireAfterWrite:在最后一次写后空闲一段时间才过期;
    • refreshAfterWrite:同样是写后空闲一段时间过期,和上一个的区别是不会阻塞过期时到达的请求,因为刷新一般需要请求远程服务来获取数据,会有比较长的延迟,refreshAfterWrite 会先返回旧数据,而 expireAfterWrite 会先阻塞这些请求。
      如果是为了吞吐量起见,一般使用 refreshAfterWrite 更多,如果是为了保证同步性,则是使用 expireAfterWrite 更多。
      因为我们使用缓存的场景是“读多写少的场景”,读端是提供给用户的,而写端由甲方客户控制,当它们更新了某个 id 的数据后,希望能够马上展示到用户眼前,换句话说,缓存应当能够被马上刷新,但是前面的配置中使用的是 expireAfterAccess,因为用户的访问非常频繁,所以缓存一直不能过期,上线的数据不能及时地生效,导致甲方爸爸非常生气。
  2. 缓存更新的时候一般会从远程服务或数据库查询数据,这里没有考虑返回值为空的情况,为了保险起见,一般都是需要进行空值校验的,而且如果这里返回了空值,LoadingCache 会直接抛出异常。

所以更合理的配置方式应该是下面这样的:

1
2
3
4
5
6
7
8
9
10
LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
.refreshAfterWrite(60 * 1000, MILLISECONDS) // 1
.maximumSize(500)
.build(new CacheLoader<String, Object>() {
@Override
public Object load(String id) throws Exception {
Object res = query(id); // 2
return res == null ? DEFAULT_OBJ : res;
}
});

所以,当我们在使用缓存时,一般是事先考虑:

  • dataSource:当没有命中时,从哪里获取数据?一般请求下会调用其他服务的远程接口,也可以直接从数据库、缓存中间件查询,但是要注意数据隔离性,比如权限服务就不适合直接到缓存或数据库中查询订单数据,因为这样不利于后期扩容,而且入口越多越不安全;
  • expire:定义淘汰策略,比如有一段时间没有访问就淘汰,比如容量限制,当超出容量的时候如何淘汰一般有 LRU(Least Recently Used)、LFU(Least Frequently Used),可以使用 weigher 设置每个 key 的权重;

如果是一些要求不高的内部信息管理系统,这些属性不需要太关注,但是如果是对并发量有一定要求的系统,对自己所使用的工具知根知底是最低的要求。

本地缓存是什么

本地缓存的英文是 Local Cache,Cache、Buffer、Pool 是经常出现但又容易混淆的一组概念,它们都能存取数据,但是有本质上的区别:

  • Cache 的主要功能是“将东西放到更容易拿到的地方”、从而加快速度,具有随机存取的功能,一般为了不导致内存溢出会设置数据的过期回收策略,比如计算机体系结构中的 L1、L2、L3 缓存;
  • Buffer 是为了缓冲、减少对脆弱系统的冲击,具有顺序访问的特点,比如每个 TCP Socket 都有的接收发送缓存区;
  • Pool 是为了缓存资源,它和 Cache 的主要区别是 Pool 中缓存的对象往往是同构而没有特殊价值的数据,比如连接池中存储的数据库连接,所以 Pool 不需要随机存取功能、随取随用即可。

本地缓存的优点

  • 节省了了内⽹带宽。
  • 响应时延会更低。

本地缓存的缺点

⽆法保证⼀致性,解决办法是:

  1. 单节点通知其他节点,但是会导致同⼀服务的多个节点相互耦合;
  2. 利用 mq 通知其他节点,但系统会变得更复杂;
  3. 使用 timer 定时从后端拉取更新内存缓存,但在更新数据后、访问其他节点会得到脏数据,直到其他节点 timer 拉取数据。

本地缓存如何保证一致性

现在基本没有对外应用会是单机部署的,本地缓存是将数据保存到实例本身的内存中,所以一致性问题就是:我们怎么保证同一时间从每台实例上获取到的数据都是相同的?

  • 集群广播:当数据源变更时,发消息通知所有实例
    优点:实现一致性
    缺点:不适合更新特别频繁的场景,可能产生消息堆积。
  • 定时拉取:每台实例定时从数据源拉取数据更新本地缓存
    优点:实现简单,使用Guava就可以实现;
    缺点:无法控制每台实例同时去拉取,可能有的拉到了,有的还没有到定时拉取的时间。
  • zk同步:将机器注册到zk,所有机器都注册一个watcher,当有数据变更时通知所有实例。
    优点:类似消息同步的方式实现一致性。
    缺点:引入zk提升复杂度,zk并不保证高可用(CP)。

什么时候需要本地缓存

分层架构设计,有⼀条准则:站点层、服务层要做到⽆数据⽆状态,这样才能任意的加节点⽔平扩展,数据和状态尽量存储到后端的数据存储服务,例如数据库服务或者缓存服务。
可以看到,站点与服务的进程内缓存,实际上违背了分层架构设计的⽆状态准则,故一般情况下并不推荐使用
在分布式缓存存在的情况下,一般本地缓存都是不必要的,一方面本地缓存会占用大量的堆空间,容易引起频繁的 GC;另一方面,因为是在局域网内,所以访问分布式缓存的网络开销不会太大。

那么,什么时候可以使⽤进程内缓存?以下情况,可以考虑使用进程内缓存,并且应该注意对过期策略、并发安全等的定义。

  1. 只读数据,可以考虑在进程启动时加载到内存。

    当然此时也可以把数据加载到 redis / memcache 等缓存中间件,进程外缓存同样能解决这个问题。

  2. 性能敏感、极其⾼并发的、如果透传对后端压力极大的场景,可以考虑使用进程内缓存。例如,首页列表、秒杀业务,并发量极高,需要站点层挡住流量,可以使⽤内存缓存。
  3. 一定程度上允许数据不一致的业务。
    例如,有一些计数场景,运营场景,⻚面对数据⼀致性要求较低,可以考虑使⽤进程内⻚面缓存。

避免过早优化

后端开发基本都是完美主义者(粗心导致留下 Bug 可是会被产品、测试鄙视的),但是完美主义也有一个缺点——容易过早优化。
比如,项目早期使用者不多、订单只有 10W~100W 的量级,但是开发刚上来在对行业知识、产品使用场景都没有深刻理解的情况下,直接决定对 id 散列来进行分表,这个对我们来说当然是无可厚非的,但是随着业务扩大、订单量增加到 100W~1000W,发现线上数据库中近期的订单被使用得更多(即热数据)、而老订单一般不被问津,所以原来那种新老混杂的分表就不合适了,但是现在再重构成按时间分表的方式就费事了。因此,更好的方式是刚开始仅用单表存就足够了,之后时刻关注线上使用的反馈,即时地进行优化。

Java 引用

Java 中除了基本类型外所有对象都是通过引用来使用的,引用分为强引用、软引用、弱引用和虚引用。
对于一般的缓存场景来说,软引用是更好的选择,因为软引用可以避免内存用完而 GC 又回收不了内存进而导致的服务宕机,又不会像弱引用那样每次 GC 都会被回收掉、连带导致缓存被击穿。

应用 - 失败次数统计

一般调用 RPC 接口都会有重试逻辑,最简单的重试可以用一个局部变量记录失败次数:

1
2
3
4
5
6
7
8
9
10
int failedCount = 0;
while(failedCount < 3) {
try {
rpcService.hello();
break;
} catch (Exception e) {
logger.warn("调用失败 " + failedCount + " 次", e);
failedCount++;
}
}

如果不是需要实时响应的功能,可以用一个队列缓存请求,然后用一个线程轮询,因为失败后需要重新丢进队列中等待,这时就不能单纯使用局部变量来保存失败次数了,可以使用一个 Cache<string, AtomicInteger>软引用缓存失败调用记录,成功后再使其失效。这种方式能控制失败重试次数,而且当内存不足时,缓存数据可以被 GC 回收以腾出一些空间。
以 Guava 中的 LoadingCache 为例:

1
2
3
4
5
6
7
8
9
10
private LoadingCache<String, AtomicInteger> failedCache = 
CacheBuilder.newBuilder()
.softValues()
.maximumSize(10000)
.build(new CacheLoader<String, AtomicInteger>() {
@Override
public AtomicInteger load(String id) throws Exception {
return new AtomicInteger(0);
}
});
  • 当失败时,调用 failedCache.getUnchecked(id).incrementAndGet()增加失败次数。
  • 当成功时,调用 failedCache.invalidate(id)使缓存失效。

GC 友好的缓存

diff(在不等的情况下才 put 或直接修改已有对象,提高内存利用率,不然每次刷新缓存都要放到年轻代。不能用分离链接法实现,因为老的会引用年轻的(每次 put 到头部不就可以了?),导致年轻代不能转移到老年代)

如何避免 OOM(弱引用)

缓存淘汰机制
过期策略

如何实现一个本地缓存 - ConcurrentHashMap

  1. 线程安全的 Map
  2. 回收机制
    如 LRU
  3. 软引用

分布式缓存

本地缓存和分布式缓存

缓存一般分为本地缓存和分布式缓存两种。本地缓存指的是将数据存储在本机内存中,操作缓存数据的速度很快,但是缺点也很明显:第一,缓存数据的数量与大小受限于本地内存;第二,如果有多台应用服务器,可能所有应用服务器都要维护一份缓存,这样就占用了很多的内存。
分布式缓存正好解决了这两个问题。首先,数据存储在了另外的机器上,理论上由于可以不断添加缓存机器,所以缓存的数据的数量是无限的;其次,缓存集中设置在远程的缓存服务器上,应用服务器不需要耗费空间来维护缓存。但是,分布式缓存也是有缺点的,比如由于是远程操作,所以操作缓存数据的速度相较于本地缓存慢很多。
当前用得最多的本地缓存是 GoogleGuavache,用得最多的分布式缓存是 Memcached 和 Redis

缓存穿透

概念及场景:缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。
解决方案:有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
总而言之,当通过一个 key 去数据库查询出来的数据结果为 null,缓存系统就不会缓存该数据,每次该 key 查询都会经过数据库层,造成没有必要的 DB 开销。这种情况下,我们可以将该 key 缓存至缓存系统中,value 为一个特殊值(^^,&&…)。

缓存雪崩

概念及场景

缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重雪崩。
key 缓存过期失效而新缓存未到期间,该 key 的查询所有请求都会去查询数据,造成 DB 压力上升,产生不必要的 DB 开销

解决方案

缓存失效时的雪崩效应对底层系统的冲击非常可怕。大多数系统设计者考虑用加锁或者队列的方式保证缓存的单线程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上。这里分享一个简单方案就是将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
解决方案总结:

  1. 加锁排队重建,使请求可以串行化,而不用全部的请求都去查询数据库
  2. 假设 key 的过期时间是 A,创建一个 key_sign,它的过期时间比 A 小,查询 key 的时候检查 key_sign 是否已经过期,如果过期则加锁后台起一个线程异步去更新 key 的值,而实际的缓存没有过期(如果实际缓存已经过期,需要加锁排队重建),但是会浪费双份缓存
  3. 在原有的 value 中存一个过期值 B,B 比 A 小,取值的时候根据 B 判断 value 是否过期,如果过期,解决方案同上
  4. 牺牲用户体验,当发现缓存中没有对应的数据直接返回失败,并且把需要的数据放入一个分布式队列,后台通过异步线程更新队列中需要更新的缓存

缓存污染

概念和场景

一些非正常操作(比如导出 excel 文件、运营偶发性访问)而导致内存中出现很多冷数据

解决方案

选取合适的缓存算法(LUR-N 算法)。

缓存首次上线

概念及场景

缓存首次上线,如果网站的访问量很大,所有的请求都经过数据库(如果访问量比较少,可以由用户访问自行缓存)

解决方案

缓存预热,在系统上线之前,所有的缓存都预先加载完毕(增加一个刷新缓存程序,上线后手动刷新或发布时自动调用刷用)

缓存击穿

概念及场景:对于一些设置了过期时间的 key,如果这些 key 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一 key 缓存,前者则是很多 key。缓存在某个时间点过期的时候,恰好在这个时间点对这个 Key 有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端 DB 压垮。

解决方案

1.使用互斥锁(mutex key)
业界比较常用的做法,是使用 mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如 Redis 的 SETNX 或者 Memcache 的 ADD)去 set 一个 mutex key,当操作返回成功时,再进行 load db 的操作并回设缓存;否则,就重试整个 get 缓存的方法。
SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。在 redis2.6.1 之前版本未实现 setnx 的过期时间,所以这里给出两种版本代码参考:
//2.6.1 前单机版本锁
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, “1”)) {
// 3 min timeout to avoid mutex holder crash
redis.expire(key_mutex, 3 * 60)
value = db.get(key);
redis.set(key, value);
redis.delete(key_mutex);
} else {
//其他线程休息 50 毫秒后重试
Thread.sleep(50);
get(key);
}
}
}
最新版本代码:
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
//设置 3min 的超时,防止 del 操作失败的时候,下次缓存过期一直不能 load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //这个时候代表同时候的其他线程已经 load db 并回设到缓存了,这时候重试获取缓存值即可
sleep(50);
get(key); //重试
}
} else {
return value;
}
}
memcache 代码:
if (memcache.get(key) == null) {
// 3 min timeout to avoid mutex holder crash
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
value = db.get(key);
memcache.set(key, value);
memcache.delete(key_mutex);
} else {
sleep(50);
retry();
}
}

除了使用Redis加锁,zk也是常见的分布式锁实现方案:

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
try {
value = redis.get(key);
if (Objects.isNull(value)) {
long start = System.currentTimeMillis();

InterProcessLock lock = ZKSpringFactory.get().opsForLock(key.toString());
try {
// 获取zk分布式锁
acquire(lock, key);
if (Objects.isNull(value = redis.get(key))) {
// 利用客户端传入的callback回源,一般是查数据库
value = callback.call();
redis.setnx(key,value.toString(),expireTime,timeUnit);
return value;
}
} catch (Throwable throwable) {
logger.error("加载redisson key异常,key={}", key, throwable);
// TODO 报警
throw UnsafeUtil.throwException(throwable);
} finally {
try {
lock.release();
M.zk_lock_time.timer().get().update(System.currentTimeMillis() - start, TimeUnit.MILLISECONDS);
} catch (Throwable throwable) {
logger.error("释放lock异常", throwable);
// TODO 报警
try {
lock.release();
} catch (Exception e) {
logger.error("释放lock异常", throwable);
}
}
}
}
} catch (Exception ex) {
logger.error("redisson 获取值异常,key:"+key,ex);
}

private static void acquire(InterProcessLock lock, String key) throws Exception {
if (!lock.acquire(LOCK_EXPIRE_SECOND, TimeUnit.SECONDS)) {
logger.error("获取lock超时,key={}", key);
// 报警
throw new BizException("网络繁忙,请您稍后再试");
}
}

2.”提前”使用互斥锁(mutex key):
在 value 内部设置 1 个超时值(timeout1), timeout1 比实际的 memcache timeout(timeout2)小。当从 cache 读取到 timeout1 发现它已经过期时候,马上延长 timeout1 并重新设置到 cache。然后再从数据库加载数据并设置到 cache 中。伪代码如下:
v = memcache.get(key);
if (v == null) {
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
value = db.get(key);
memcache.set(key, value);
memcache.delete(key_mutex);
} else {
sleep(50);
retry();
}
} else {
if (v.timeout <= now()) {
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
// extend the timeout for other threads
v.timeout += 3 * 60 * 1000;
memcache.set(key, v, KEY_TIMEOUT * 2);

        // load the latest value from dbplainplainplainplainplainplainplainplainplainplainplainplainplainplainplainplainplain
        v = db.get(key);    
        v.timeout = KEY_TIMEOUT;    
        memcache.set(key, value, KEY_TIMEOUT * 2);    
        memcache.delete(key_mutex);    
    } else {    
        sleep(50);    
        retry();    
    }    
}    

}

3.”永远不过期”:
这里的“永远不过期”包含两层意思:
(1) 从 redis 上看,确实没有设置过期时间,这就保证了,不会出现热点 key 过期问题,也就是“物理”不过期。
(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期
从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
String get(final String key) {
V v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
if (v.timeout <= System.currentTimeMillis()) {
// 异步更新后台异常执行
threadPool.execute(new Runnable() {
public void run() {
String keyMutex = “mutex:” + key;
if (redis.setnx(keyMutex, “1”)) {
// 3 min timeout to avoid mutex holder crash
redis.expire(keyMutex, 3 * 60);
String dbValue = db.get(key);
redis.set(key, dbValue);
redis.delete(keyMutex);
}
}
});
}
return value;
}

  1. 资源保护:
    采用 netflix 的 hystrix,可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。
解决方案 优点 缺点
简单分布式互斥锁(mutex key) 1. 思路简单;2. 保证一致性 1. 代码复杂度增大;2. 存在死锁的风险;3. 存在线程池阻塞的风险
“提前”使用互斥锁 1. 保证一致性 同上
不过期(本文) 1. 异步构建缓存,不会阻塞线程池 1. 不保证一致性;2. 代码复杂度增大(每个 value 都要维护一个 timekey);3. 占用一定的内存空间(每个 value 都要维护一个 timekey)。
资源隔离组件 hystrix(本文) 1. hystrix 技术成熟,有效保证后端;2. hystrix 监控强大。 1. 部分访问存在降级策略。

设计总结

针对业务系统,永远都是具体情况具体分析,没有最好,只有最合适。
最后,对于缓存系统常见的缓存满了和数据丢失问题,需要根据具体业务分析,通常我们采用 LRU 策略处理溢出,Redis 的 RDB 和 AOF 持久化策略来保证一定情况下的数据安全。

  1. 缓存失效策略
    添加 key 的时候要设置一个过期时间,采用惰性删除和定时删除相结合的策略删除过期键
  2. 多级缓存
    线程级->内存级->进程级->文件(静态资源)->分布式(redis)->Db 结果.
  3. 二级缓存
    二级缓存更多的解决是,缓存穿透与程序的健壮性,当集中式缓存出现问题的时候,我们的应用能够继续运行;一些热点数据做成内存缓存,这些数据是在上线之前是已知的(比如说秒杀,大促商品),通过配置定时任务定时刷新内存缓存,完成和分布式缓存的数据置换;更加自动化的方案,可以根据上游自动发现热点数据,广播消息替换现在集群中内存缓存的数据(但在整个集群中广播,成本比较高,并且二级缓存的管理的成本也很大);

实现一个简单的多级缓存

多级数据来源

  • 本地缓存
    在并发量不大的系统内,本地缓存的意义不大,反而增加维护的困难。但在高并发系统中,本地缓存可以大大节约带宽。但是要注意本地缓存不是银弹,它会引起多个副本间数据的不一致,还会占据大量的内存,所以不适合保存特别大的数据,而且需要严格考虑刷新机制。
  • 缓存 / 搜索服务器
    TODO:
  • 数据库服务器
    TODO:
  • 同机房的其他业务服务器
    TODO:
  • 不同机房的其他业务服务器
    TODO:

多级缓存组件的主要执行流程
详细描述 TODO:

缓存时机

  • 5 分钟法则
    5 分钟法则即:如果一个数据的访问周期在 5 分钟以内则存放在内存中,否则应该存放在硬盘中。
    引申到缓存中,可以表述为:如果一个数据访问吞吐率大于 1 次 / 5 分钟,就可以考虑放到缓存中。
  • 局部性原理
    局部性原理原指 CPU 访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。
    反过来说,如果访问存在热点,就完全可以把这些热点数据放到缓存里。

算法 - FIFO

算法 - LRU

算法 - LFU

回收策略

  • 空间
  • 容量
    条⽬数
  • 时间
    存活期活太久淘汰
    空闲期太久没访问淘汰

缓存监控

命中率 = 缓存读取次数 / (缓存读取次数 + 慢速设备读取次数)

过期时间

本地缓存过期时间比分布式缓存小至少一半,以防止本地缓存太久造成多实例数据不一致。

  • 不过期缓存
    场景:长尾访问的数据、访问频率⾼、缓存空间⾜够
    使⽤Cache-Aside 模式
    不要放事务⾥,因为⽹络抖动可能导致写缓存响应时间慢,阻塞数据库事务。但是同样存在事务成功但缓存失败⽆法回滚的情况。解决办法是使用 canal 实现缓存同步。
    若对⼀致性要求不高且数据量不⼤可改成定期全量同步
  • 过期缓存
    场景:热点数据、来⾃自其他系统的数据、空间有限、访问频率低

SoR(Source-of-Resource)

数据的来源,一般称为记录系统或数据源
回源即回到源头获取数据,Cache 没有命中时,需要从 SoR 获取数据,即回源。

大 Value

如果有⼤Value 最好切换到多线程实现的缓存如 MC,或者拆成多个小 Value 由客户端聚合。

热点缓存

频繁访问的热点数据,如果每次都要从缓存服务器获取,可能导致缓存服务器负载过高、或者带宽过⾼。
解决办法是加缓存服务器,或者加本地缓存。

缓存更新

原⼦更新:

  • 版本号
  • 如果是 redis,因为单线程机制本身就是⽀持原⼦更新的
  • 使用 canal 订阅数据库 binlog 将更新请求按规则路由到多个队列,每个队列进⾏单线程的更新
  • 加分布式锁

异步写

写本地缓存后异步更新分布式缓存,尽快返回用户请求,最好不要同步写分布式缓存。

维度化与增量缓存

场景:一个商品包含多个属性,其中部分属性如上下架这种可能频繁更新的,最好做维度化并增量更新。

缓存策略 - 分区读

读缓存时划分分区异步批量读:

  • 分区可以防⽌出现慢查询;
  • 异步可以把各批 key 并⾏化。

缓存策略 - nullobj 防缓存击穿

当 db 中本身就没有该数据时,会产生每次请求都击穿的现象,解决办法是引入一个 nullobj
db 不不存在时写一个 nullobj 到缓存,下次读到 null 对象 就不去 db 读了。

缓存策略 - Cache-aside 模式

Cache-Aside 即业务代码围绕着 Cache 写,是由业务代码直接维护缓存。

什么时候使用

  • 当 Cache不提供原⽣的 Read-Through 和 Write-Through 操作的时候
  • 资源的需求是不可预测的时候。Cache-Aside 模式令应用可以根据需求来加载数据,对于应⽤需求什么数据,不需要提前做出假设。

Read 模式

先从缓存获取数据,如果没有命中,则回源到 SoR 并将源数据放入缓存供下次读取使用。

Write 模式

类似 Write-Through 策略。

  1. 先将数据写入 SoR,写入成功后立即将数据同步写入缓存;
  2. 或先将数据写入 SoR,写入成功后将缓存数据过期,下次读取时再加载缓存。

读优化 - 一致性哈希

读可以⽤⼀致性哈希减少并发。

写优化 - 使用 Canal 订阅更新

更新可以⽤Canal 订阅 binlog。

缓存数据的⽣存时间

很多 Cache 实现了过期的策略的,这些过期的策略可以实现数据的更新,将旧数据失效化,同时也令⼀定时间没有访问的数据失效。
为了让 Cache-Aside 模式能够⽣效,开发者必须确保过期策略能够正确匹配应用所访问的数据。同时,注意不能让过期时间太短,因为太短的过期时间会令应⽤频繁地从数据仓库中获取数据来添加到 Cache 之中。当然,也不要配置超时的时间太⻓,过⻓的超时时间会让缓存的数据冗余。Cache 的性能是跟其相关的数据的读取周期等信息⾼度相关的。

去除数据

绝⼤多数的缓存跟数据仓库⽐起来,容量是很有限的,所以,如果可以的话,Cache 会移除数据。
多数的 Cache 会采用 LRU 的策略来移除缓存中的数据,当然,移除的策略也是可以⾃定义的。配置全局的过期属性和缓存的其他属性,可以确保 Cache 消耗的内存资源是高效的。当然,通常不会只配置⼀个全局的过期策略。比如,某些特别昂贵、访问特别频繁而又不常更新的数据,完全可以延长其过期时间。

一致性

实现 Cache-Aside 模式并不能保证 Cache 和数据仓库之间的数据⼀致性。因为数据仓库中的数据可能在任何时候被其他程序所修改,⽽这个修改不会及时的反映到 Cache 上,直到下一次 Cache 被刷新为止。如果数据仓库中数据频繁由⾮Cahce 程序更新的话,这种一致性问题会变得更加明显。

本地(内存)缓存

Cache 也是可以做到应⽤本身里⾯的。Cache-Aside 模式在⼀些应⽤频繁访问相同的数据的时候尤其有效。然⽽,本地 Cache 都是应⽤私有 的,是属于每个应用中独有的额外的拷⻉。所以这个数据可能很快在不同的应⽤中就不一致了,所以刷新的频率最好更快些以保证⼀致性。在 有些情况下可以使⽤共享的缓存,有的时候也可以使⽤本地 Cache,具体使⽤哪⼀种就需要根据实际的场景来判断了。

缓存策略 - Cache-as-SoR 模式

由 Cache 委托给 SoR 进⾏真实的读写

缓存策略 - Read-Through

读 miss 则由 cache 回源到 SoR(需要防⽌dog-pile effect 即 miss 时只允许⼀个请求回源而不是所有请求都回源)。

缓存策略 - Write-Through

由 cache 组件负责写缓存和 SoR(一般是先 SoR 再缓存)

缓存策略 - Write-Behind

异步(队列+线程池)写 SoR

缓存策略 - Copy-Pattern

Copy-On-Read
Copy-On-Write
本地缓存的是引⽤,被擅⾃修改可能引起不可预测的问题

参考

所有参考文献的归档,集中管理,方便随时查阅。

多级缓存架构

  1. 一篇文章让你明白你多级缓存的分层架构
  2. 一个牛逼的多级缓存实现方案
  3. 日访问量百亿级的微博如何做缓存架构设计
  4. 分布式内存缓存系统设计
  5. 那些年我们一起追过的缓存写法(一)

内存池

  1. 设计模式之争:新分配内存还是内存池?(含评测)

本地缓存

不得不承认本地缓存不比分布式缓存更简单,当我们讨论分布式缓存时,更多的是在讨论如何节省带宽、如何平滑扩容,但在本地缓存的范畴内,我们更多的需要关注所使用语言的内存管理机制、甚至需要向下探索到硬件层面(其实分布式缓存的基础一般也是本地缓存)。

  1. 146. LRU Cache
    460. LFU Cache
    LeetCode 上有几道缓存相关的问题,可以拿来作热身。
  2. Java 内存模型
    Java 中的垃圾回收技术已经比较完善了,开发人员能做的除了给出合理的配置外,就是要做到对自己使用的垃圾回收技术知根知底、能写出 GC 友好的代码。
    The Java Memory Model - William Pugh
    JSR 133 (Java Memory Model) FAQ
    Java 内存模型 FAQ
    上面这篇的中文翻译,适合我这种英语渣对照阅读。
    GC(GC 友好编程)
    Doug Lea’s Home Page
    Doug Lea 并发编程文章全部译文
  3. NonBlocking HashTable
    HashTable 是实现缓存的常用数据结构,而在操作时进行普通的加锁又非常影响性能,所以一般会做一些 NonBlocking 的优化。除了 JUC 的 ConcurrentHashMap,还有其他的一些相似实现。
    stephenc/high-scale-lib
    JCTools/JCTools
  4. guava - cache
    github - google/guava - Caches
  5. ehcache
    github - ehcache3
    玩转 EhCache 之最简单的缓存框架
  6. J2Cache
    J2Cache 是一个国产的本地缓存框架,同时也提供了二级缓存、多机同步等特性。
    红薯 / J2Cache
  7. Netty 中的对象池
    netty/netty - Reference counted objects
    Netty 源码 Recycler 对象池全面解析
    netty 源码分析 4 - Recycler 对象池的设计
    Netty 为了提高性能,IO 时直接使用非堆内存来缓存收发的内容(Buf 对象),在非堆内存中 GC 效率会比 JVM 的堆内存效率低(只能通过 FullGC 回收或 CMS GC),所以 Netty 内部维护了一个对象池(Recycler),使用引用计数法来回收不用的对象到对象池中,而不是直接回收,减少了 GC 的频率。
  8. C / C++ 内存模型
    C / C++ 只保证最基本的内存管理(malloc / free),因为其贴近操作系统的特性,很多框架都会封装一套自己的内存管理库(包括 memcached、MySQL、Cocos2d-x 等),甚至是 GC。
    《C 语言接口与实现》 - 第 2、4、5、6 章
    dlmalloc - Doug Lea
    内存管理(Memory) - 许式伟
    C++ Memory Management Innovation: GC Allocator
    《STL 源码剖析》 - 第 2 章
    《深入探索 C++对象模型》
  9. bangerlee/mempool
  10. 应用层内存管理
    Memory Management Reference
    一个神奇的网站,内存管理相关的概念、综述、深入参考文献基本都能在这里找到,而且偏应用层,讲解方式友好、适合扫盲。
  11. 操作系统层内存管理
    The Unix and Internet Fundamentals HOWTO
    非常精炼地解释了 Unix 系统和网络的基本原理。
  12. 硬件层内存管理

分布式缓存

  1. 分布式缓存设计及解决方案(后端)
    大型分布式网站架构
    浅谈缓存(一)
    那些年我们一起追过的缓存写法(一)
    缓存穿透、并发和失效,来自一线架构师的解决方案
  2. 《分布式缓存——原理、架构及 Go 语言实现》
  3. 荐书:《深入分布式缓存》
  4. Redis 教程及手册
    《Redis 设计与实现》
    《Redis 深度历险:核心原理与应用实践》
    Redis 命令参考
    Redis Command Reference
    Redis Documentation
  5. Redis 及客户端源码
    github - antirez/redis
    github - redisson/redisson
    github - xetorthio/jedis
    github - lettuce-io/lettuce-core
  6. Redis - 数据结构
    Redis 为何这么快
    Redis strings vs Redis hashes to represent JSON: efficiency?
    如果业务里需要使用到对象的单个域则使用 hash 类型保存 JSON,否则使用 string。
  7. Redis - Sentinel
    Sentinel 是 Redis 提供的一种高可用集群方案,它能实现自动的故障转移。
    Redis Sentinel Documentation
    Sentinel Clients
    Jepsen: Redis
    Reply to Aphyr attack to Sentinel
    Asynchronous replication with failover
  8. Redis - Cluster
    Redis Cluster 不是使用一致性 hash、而是利用哈希槽的模式来分配数据,相对来说更简单,但是数据迁移的时候成本也会更大。
    Redis cluster tutorial
    Redis Cluster Specification
  9. 在 Spring 架构后台中使用 Redis
    spring-framework-reference - 8. Cache Abstraction
    Spring Data Redis
    Spring Boot Reference Guide
  10. Redis 最佳实践
    阿里云 Redis 开发规范
    你所不知道的 Redis 热点问题以及如何发现热点
    史上最全 50 道 Redis 面试题(含答案),以后面试再也不怕问 Redis 了
  11. Redis 应用
    一文看透 Redis 分布式锁进化史(解读 + 缺陷分析)
    How to do distributed locking
    刷新 Redis 缓存时需要加分布式锁,保证只有一个线程能够写入缓存。
    INCR key (分布式限流器的例子)
    布隆过滤器实战【防止缓存击穿】
    NOSQL 数据建模技术
  12. github - memcached
    memcached 是经常和 Redis 一并提起的一个分布式缓存中间件,了解其原理能对分布式缓存的实现模式有更好的认识。

Web 缓存

Web 缓存指的是在服务器和客户端之间的缓存,不同于我们上边提到的都是服务器(本地缓存)及服务器之后的缓存(分布式缓存)。
Web 缓存其实不仅仅指浏览器内的缓存,在剖析从客户端发送请求到服务器接收为止的一系列链路之后,可以发现 Web 缓存主要包括浏览器缓存、代理服务器缓存、网关缓存。

  1. Web 开发人员需知的 Web 缓存知识

测试

测试是发现问题的手段,最好的解决问题方式是避免问题。

  1. 《Java 并发编程实战》 - 第三部分(并发安全及性能)
  2. Redis 有多快?
  3. 《构建高性能 Web 站点》 - 第 3 章
  4. 《Java 程序性能优化》
  5. 《Web 性能权威指南》
  6. 《OptimizingLinux(R)PerformanceAHands-OnGuidetoLinux(R)PerformanceTools》
  7. 《性能之巅》

运维

  1. 《The Art of Capacity Planning》
  2. 探寻 Redis 内存诡异增长的元凶

其他

  1. How To Ask Questions The Smart Way
  2. Software Release Practice HOWTO
  3. github - hyperoslo/Cache
    这个项目同样在缓存上做文章,不过是用 swift 写的。
  4. Learn X in Y minutes - Where X=Lua
  5. 《设计模式》 - Factory, Builder, Proxy, Chain of Responsibility, Command, Iterator, template method
  6. 《Spring 源码深度解析》 - 第 5、6、7 章
  7. 使用 AI 生成图标