什么时候应该使用缓存?

所有高耗时,需要吞吐量,而不太严格依赖强一致性的场景-不管是计算密集型还是 io 密集型,都可以使用缓存加速。

多级缓存问题

大多数情况下不要使用多级缓存。
多级缓存要严格设计差异化的冷热数据分离策略,还要考虑分布式的缓存失效+更新的问题,很复杂。

勉强可用的多级缓存应该是远端一级缓存,近端二级缓存。

本地多级缓存非常容易出一致性问题-慎用 MyBatis 和 Hibernate 的二级缓存。

外部缓存设计思路

外部缓存通常指的是分布式缓存组件或者中间件。

内文直接参考《缓存更新的套路》

缓存更新的设计模式.xmind
缓存更新的设计模式.png

Redis

内部缓存的用法

内部缓存通常指的是进程内缓存,in-memory-cache。

Spring Cache 依靠一个 CacheManager SPI 机制,来跟不同的 cache 实现打交道。大多数时候我们应该用 CacheManager 封装好的 wrapper api 来跟缓存打交道,极少数情况下我们应该 getNativeCache 来使用专有 API。

  1. Guava Cache 就是给 ConcurrentHashMap 加上了大量的 LRU 等 evict 操作和相应的管理策略。Guava在每次访问缓存的时候判断cache数据是否过期,如果过期,这时才将其删除,并没有另起一个线程专门来删除过期数据-类似 WeakHashMap。内部维护了2个队列 AccessQueue 和 WriteQueue 来记录缓存中数据访问和写入的顺序。访问缓存时,先用key计算出hash,从而找出所在的segment,然后再在segment中寻找具体问题,类似于使用ConcurrentHashMap数据结构来存放缓存数据。
  2. Guava在每次访问缓存的时候判断cache数据是否过期,如果过期,这时才将其删除,并没有另起一个线程专门来删除过期数据。
  3. ECache 支持本地磁盘缓存,甚至支持集群模式-使用到集群的时候,是不是可以考虑改用 Redis 更好。
  4. Ecache 有内存占用大小统计,Guava Cache 没有。
  5. Ecache 提供全面的缓存支持,Guava Cache 提供基本的缓存支持。
  6. Ecache 允许 value 为 null;而 Guava Cache 允许 value 为 null,因为它根据value的值是否为null来判断是否需要load,所以不允许返回为null。这就意味着 Guava Cache 不易处理缓存穿透的问题,需要使用使用空对象替换 null。
  7. Caffeine 是用 Java8 对 Guava Cache 的一种重写。
  8. 在更多的时候,一个简单的全局的 ConcurrentHashMap(注意,它作为全局可访问的状态,天然就应该做线程安全设计)就可以解决大部分缓存问题。只有加上各种细致的操作的时候,才有必要专门引入特定的缓存包。Guava Cache 实际上是由一个开源的 one-man project concurrentlinkedhashmap 衍生出来,交给一个 dedicated team 维护的。

Guava

spring 的 cache 用的是 cachemanager。
guava 的 cache 用的是 cachebuilder。

Spring + Guava 一般的短路器式的用法

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
@Configuration
@ComponentScan("com.concretepage")
@EnableCaching
public class AppConfigA {
@Bean
public CacheManager cacheManager() {
GuavaCacheManager cacheManager = new GuavaCacheManager("mycache");
CacheBuilder<Object, Object> cacheBuilder = CacheBuilder.newBuilder()
.maximumSize(100)
.expireAfterWrite(10, TimeUnit.MINUTES);
cacheManager.setCacheBuilder(cacheBuilder);
return cacheManager;
}
}

@Configuration
@ComponentScan("com.concretepage")
@EnableCaching
public class AppConfigB {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
GuavaCache guavaCache1 = new GuavaCache("book", CacheBuilder.newBuilder()
.maximumSize(50).build());
GuavaCache guavaCache2 = new GuavaCache("bookstore", CacheBuilder.newBuilder()
.maximumSize(100).expireAfterAccess(5, TimeUnit.MINUTES).build());
cacheManager.setCaches(Arrays.asList(guavaCache1, guavaCache2));
return cacheManager;
}
}

@Service
public class BookAppA {
Book book = new Book();
@Cacheable(value = "mycache")
public Book getBook() {
System.out.println("Executing getBook method...");
book.setBookName("Mahabharat");
return book;
}
}

只需要覆盖 load 方法

load 和 evict 逻辑是解耦的。

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
@Test
public void whenCacheMiss_thenValueIsComputed() {
    CacheLoader<String, String> loader;
    loader = new CacheLoader<String, String>() {
        @Override
        public String load(String key) {
            return key.toUpperCase();
        }
    };
 
    LoadingCache<String, String> cache;
    cache = CacheBuilder.newBuilder().build(loader);
 
    assertEquals(0, cache.size());
    assertEquals("HELLO", cache.getUnchecked("hello"));
    assertEquals(1, cache.size());
}

// load 的用法,下次不会再计算 createExpensiveGraph 了。
CacheLoader<Key, Graph> loader = new CacheLoader<Key, Graph>() {
public Graph load(Key key) throws AnyException {
return createExpensiveGraph(key);
}
};
LoadingCache<Key, Graph> cache = CacheBuilder.newBuilder().build(loader);}

带权重的缓存配置

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
@Test
public void whenCacheReachMaxWeight_thenEviction() {
    CacheLoader<String, String> loader;
    loader = new CacheLoader<String, String>() {
        @Override
        public String load(String key) {
            return key.toUpperCase();
        }
    };
 
    Weigher<String, String> weighByLength;
    weighByLength = new Weigher<String, String>() {
        @Override
        public int weigh(String key, String value) {
            return value.length();
        }
    };
 
    LoadingCache<String, String> cache;
    cache = CacheBuilder.newBuilder()
      .maximumWeight(16)
      .weigher(weighByLength)
      .build(loader);
 
    cache.getUnchecked("first");
    cache.getUnchecked("second");
    cache.getUnchecked("third");
    cache.getUnchecked("last");
    assertEquals(3, cache.size());
    assertNull(cache.getIfPresent("first"));
    assertEquals("LAST", cache.getIfPresent("last"));

指定过期时间

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

@Test
public void whenEntryIdle_thenEviction()
throws InterruptedException {
CacheLoader<String, String> loader;
loader = new CacheLoader<String, String>() {
@Override
public String load(String key) {
return key.toUpperCase();
}
};

LoadingCache<String, String> cache;
cache = CacheBuilder.newBuilder()
// remove records that have been idle for 2ms:
.expireAfterAccess(2,TimeUnit.MILLISECONDS)
.build(loader);

cache.getUnchecked("hello");
assertEquals(1, cache.size());

cache.getUnchecked("hello");
Thread.sleep(300);

cache.getUnchecked("test");
assertEquals(1, cache.size());
assertNull(cache.getIfPresent("hello"));
}

@Test
public void whenEntryLiveTimeExpire_thenEviction()
throws InterruptedException {
CacheLoader<String, String> loader;
loader = new CacheLoader<String, String>() {
@Override
public String load(String key) {
return key.toUpperCase();
}
};

LoadingCache<String, String> cache;
cache = CacheBuilder.newBuilder()
// 这个策略更保守
// evict records based on their total live time
.expireAfterWrite(2,TimeUnit.MILLISECONDS)
.build(loader);

cache.getUnchecked("hello");
assertEquals(1, cache.size());
Thread.sleep(300);
cache.getUnchecked("test");
assertEquals(1, cache.size());
assertNull(cache.getIfPresent("hello"));
}

弱引用和软引用 key

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
@Test
public void whenWeakKeyHasNoRef_thenRemoveFromCache() {
    CacheLoader<String, String> loader;
    loader = new CacheLoader<String, String>() {
        @Override
        public String load(String key) {
            return key.toUpperCase();
        }
    };
 
    LoadingCache<String, String> cache;
    cache = CacheBuilder.newBuilder().weakKeys().build(loader);
}

@Test
public void whenSoftValue_thenRemoveFromCache() {
CacheLoader<String, String> loader;
loader = new CacheLoader<String, String>() {
@Override
public String load(String key) {
return key.toUpperCase();
}
};

LoadingCache<String, String> cache;
cache = CacheBuilder.newBuilder().softValues().build(loader);
}

定时触发 load 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void whenLiveTimeEnd_thenRefresh() {
CacheLoader<String, String> loader;
loader = new CacheLoader<String, String>() {
@Override
public String load(String key) {
return key.toUpperCase();
}
};

LoadingCache<String, String> cache;
cache = CacheBuilder.newBuilder()
.refreshAfterWrite(1,TimeUnit.MINUTES)
.build(loader);
}

主动预热

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void whenPreloadCache_thenUsePutAll() {
    CacheLoader<String, String> loader;
    loader = new CacheLoader<String, String>() {
        @Override
        public String load(String key) {
            return key.toUpperCase();
        }
    };
 
    LoadingCache<String, String> cache;
    cache = CacheBuilder.newBuilder().build(loader);
 
    Map<String, String> map = new HashMap<String, String>();
    map.put("first", "FIRST");
    map.put("second", "SECOND");
    cache.putAll(map);
 
    assertEquals(2, cache.size());
}

必须使用 optional 来应对 null 值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void whenNullValue_thenOptional() {
CacheLoader<String, Optional<String>> loader;
loader = new CacheLoader<String, Optional<String>>() {
@Override
public Optional<String> load(String key) {
return Optional.fromNullable(getSuffix(key));
}
};

LoadingCache<String, Optional<String>> cache;
cache = CacheBuilder.newBuilder().build(loader);

assertEquals("txt", cache.getUnchecked("text.txt").get());
assertFalse(cache.getUnchecked("hello").isPresent());
}
private String getSuffix(final String str) {
int lastIndex = str.lastIndexOf('.');
if (lastIndex == -1) {
return null;
}
return str.substring(lastIndex + 1);
}

订阅删除事件

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
@Test
public void whenEntryRemovedFromCache_thenNotify() {
CacheLoader<String, String> loader;
loader = new CacheLoader<String, String>() {
@Override
public String load(final String key) {
return key.toUpperCase();
}
};

RemovalListener<String, String> listener;
listener = new RemovalListener<String, String>() {
@Override
public void onRemoval(RemovalNotification<String, String> n){
if (n.wasEvicted()) {
String cause = n.getCause().name();
assertEquals(RemovalCause.SIZE.toString(),cause);
}
}
};

LoadingCache<String, String> cache;
cache = CacheBuilder.newBuilder()
.maximumSize(3)
.removalListener(listener)
.build(loader);

cache.getUnchecked("first");
cache.getUnchecked("second");
cache.getUnchecked("third");
cache.getUnchecked("last");
assertEquals(3, cache.size());
}

Cache Statistic

可以 logging cache statistic data

Cache Stats= CacheStats{hitCount=3296628, missCount=1353372,
loadSuccessCount=1353138, loadExceptionCount=0,
totalLoadTime=2268064327604, evictionCount=1325410} Cache Stats=
CacheStats{hitCount=3334167, missCount=1365834,
loadSuccessCount=1365597, loadExceptionCount=0,
totalLoadTime=2287551024797, evictionCount=1337740} Cache Stats=
CacheStats{hitCount=3371463, missCount=1378536,
loadSuccessCount=1378296, loadExceptionCount=0,
totalLoadTime=2309012047459, evictionCount=1350990} Cache Stats=
CacheStats{hitCount=3407719, missCount=1392280,
loadSuccessCount=1392039, loadExceptionCount=0,
totalLoadTime=2331355983194, evictionCount=1364535} Cache Stats=
CacheStats{hitCount=3443848, missCount=1406152,
loadSuccessCount=1405908, loadExceptionCount=0,
totalLoadTime=2354162371299, evictionCount=1378654}

参考:recordStats

ECache

Spring4 + ECache 2

整个 namespace 的说明见这里

具体的配置选项见:

  • name:缓存名称。
  • maxElementsInMemory:缓存最大个数。
  • eternal:缓存中对象是否为永久的,如果是,超时设置将被忽略,对象从不过期。
  • timeToIdleSeconds:置对象在失效前的允许闲置时间(单位:秒)。仅当eternal=false对象不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。
  • timeToLiveSeconds:缓存数据的生存时间(TTL),也就是一个元素从构建到消亡的最大时间间隔值,这只能在元素不是永久驻留时有效,如果该值是0就意味着元素可以停顿无穷长的时间。
  • maxEntriesLocalDisk:当内存中对象数量达到maxElementsInMemory时,Ehcache将会对象写到磁盘中。
  • overflowToDisk:内存不足时,是否启用磁盘缓存。
  • diskSpoolBufferSizeMB:这个参数设置DiskStore(磁盘缓存)的缓存区大小。默认是30MB。每个Cache都应该有自己的一个缓冲区。
  • maxElementsOnDisk:硬盘最大缓存个数。
  • diskPersistent:是否在VM重启时存储硬盘的缓存数据。默认值是false。
  • diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认是120秒。
  • memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU(最近最少使用)。你可以设置为FIFO(先进先出)或是LFU(较少使用)。
  • clearOnFlush:内存数量最大时是否清除。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="ehcache.xsd"
updateCheck="true"
monitoring="autodetect"
dynamicConfig="true">
<diskStore path="java.io.tmpdir"/>
<cache name="movieFindCache"
maxEntriesLocalHeap="10000"
maxEntriesLocalDisk="1000"
eternal="false"
diskSpoolBufferSizeMB="20"
timeToIdleSeconds="300" timeToLiveSeconds="600"
memoryStoreEvictionPolicy="LFU"
transactionalMode="off">
<persistence strategy="localTempSwap" />
</cache>
</ehcache>

对应的 java code:

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
package com.mkyong.test;

import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.ehcache.EhCacheCacheManager;
import org.springframework.cache.ehcache.EhCacheManagerFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

@Configuration
@EnableCaching
@ComponentScan({ "com.mkyong.*" })
public class AppConfig {

@Bean
public CacheManager cacheManager() {
return new EhCacheCacheManager(ehCacheCacheManager().getObject());
}

@Bean
public EhCacheManagerFactoryBean ehCacheCacheManager() {
EhCacheManagerFactoryBean cmfb = new EhCacheManagerFactoryBean();
cmfb.setConfigLocation(new ClassPathResource("ehcache.xml"));
cmfb.setShared(true);
return cmfb;
}
}


package com.mkyong.movie;

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Repository;

@Repository("movieDao")
public class MovieDaoImpl implements MovieDao{

//This "movieFindCache" is delcares in ehcache.xml
@Cacheable(value="movieFindCache", key="#name")
public Movie findByDirector(String name) {
slowQuery(2000L);
System.out.println("findByDirector is running...");
return new Movie(1,"Forrest Gump","Robert Zemeckis");
}

private void slowQuery(long seconds){
try {
Thread.sleep(seconds);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}

}

Spring Boot 2 + ECache3

ECache 是 Hibernate 中的默认缓存框架。

要引入 javax 的 cache api(JSR-107):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
    <version>2.2.2.RELEASE</version></dependency>
<dependency>
    <groupId>javax.cache</groupId>
    <artifactId>cache-api</artifactId>
    <version>1.1.1</version>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.8.1</version>
</dependency>

对应的缓存注解:

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
@Service
public class NumberService {

// 配置全都在 Cacheable 接口上
@Cacheable(
value = "squareCache",
key = "#number",
condition = "#number>10")
public BigDecimal square(Long number) {
BigDecimal square = BigDecimal.valueOf(number)
.multiply(BigDecimal.valueOf(number));
log.info("square of {} is {}", number, square);
return square;
}
}

@Configuration
// 注意,这里的配置,不需要自己再生成 cachemanager
@EnableCaching
public class CacheConfig {
}

public class CacheEventLogger
implements CacheEventListener<Object, Object> {

// ...

@Override
public void onEvent(
CacheEvent<? extends Object, ? extends Object> cacheEvent) {
log.info(/* message */,
cacheEvent.getKey(), cacheEvent.getOldValue(), cacheEvent.getNewValue());
}
}
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
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.ehcache.org/v3"
xmlns:jsr107="http://www.ehcache.org/v3/jsr107"
xsi:schemaLocation="
http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd
http://www.ehcache.org/v3/jsr107 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">

<cache alias="squareCache">
<key-type>java.lang.Long</key-type>
<value-type>java.math.BigDecimal</value-type>
<expiry>
<ttl unit="seconds">30</ttl>
</expiry>

<listeners>
<listener>
<class>com.baeldung.cachetest.config.CacheEventLogger</class>
<event-firing-mode>ASYNCHRONOUS</event-firing-mode>
<event-ordering-mode>UNORDERED</event-ordering-mode>
<events-to-fire-on>CREATED</events-to-fire-on>
<events-to-fire-on>EXPIRED</events-to-fire-on>
</listener>
</listeners>

<resources>
<heap unit="entries">2</heap>
<offheap unit="MB">10</offheap>
</resources>
</cache>

</config>

具体的其他 cache 操作的注解:

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
@Service
public class UserService {
// @Cacheable可以设置多个缓存,形式如:@Cacheable({"books", "isbns"})
@Cacheable({"users"})
public User findUser(User user) {
return findUserInDB(user.getId());
}

@Cacheable(value = "users", condition = "#user.getId() <= 2")
public User findUserInLimit(User user) {
return findUserInDB(user.getId());
}

@CachePut(value = "users", key = "#user.getId()")
public void updateUser(User user) {
updateUserInDB(user);
}

@CacheEvict(value = "users")
public void removeUser(User user) {
removeUserInDB(user.getId());
}

@CacheEvict(value = "users", allEntries = true)
public void clear() {
removeAllInDB();
}
}

@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)


// 与前面的缓存注解不同,这是一个类级别的注解。
如果类的所有操作都是缓存操作,你可以使用@CacheConfig来指定类,省去一些配置。
@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {
@Cacheable
public Book findBook(ISBN isbn) {...}
}

可以考虑,定义自己的 KeyGenerator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class MyKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
return method.getName()+Arrays.toString(params);
}
}

@Cacheable(keyGenerator = "myKeyGenerator")
public User getUserById(Long id) {
User user = new User();
user.setId(id);
user.setUsername("lisi");
System.out.println(user);
return user;
}

另外,可以用的 key 的缓存专用 SPEL 表达式,在这里

Caffeine

它几个特别有意思的特性:time-based eviction、size-based eviction、异步加载、弱引用 key(不考虑 referenceQueue 的特性,WeakReference 是最适合我们用的)。

  • automatic loading of entries into the cache, optionally asynchronously
  • size-based eviction when a maximum is exceeded based on frequency and recency
  • time-based expiration of entries, measured since last access or last write
  • asynchronously refresh when the first stale request for an entry occurs
  • keys automatically wrapped in weak references
  • values automatically wrapped in weak or soft references
  • notification of evicted (or otherwise removed) entries
  • writes propagated to an external resource
  • accumulation of cache access statistics

不搭配 Spring

1
2
3
4
5
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.5.5</version>
</dependency>
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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
Cache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterWrite(1, TimeUnit.MINUTES)
  .maximumSize(100)
  .build();
  
String key = "A";
DataObject dataObject = cache.getIfPresent(key);

assertNull(dataObject);

cache.put(key, dataObject);
dataObject = cache.getIfPresent(key);

assertNotNull(dataObject);

dataObject = cache
.get(key, k -> DataObject.get("Data for A"));

assertNotNull(dataObject);
assertEquals("Data for A", dataObject.getData());

// 同步加载
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(100)
  .expireAfterWrite(1, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

DataObject dataObject = cache.get(key);

assertNotNull(dataObject);
assertEquals("Data for " + key, dataObject.getData());

Map<String, DataObject> dataObjectMap
  = cache.getAll(Arrays.asList("A", "B", "C"));
 
assertEquals(3, dataObjectMap.size());
  
// 异步加载
AsyncLoadingCache<String, DataObject> cache = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(1, TimeUnit.MINUTES)
.buildAsync(k -> DataObject.get("Data for " + k));

String key = "A";
cache.get(key).thenAccept(dataObject -> {
assertNotNull(dataObject);
assertEquals("Data for " + key, dataObject.getData());
});
cache.getAll(Arrays.asList("A", "B", "C"))
.thenAccept(dataObjectMap -> assertEquals(3, dataObjectMap.size()));

// 基于 size 的淘汰
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(1)
  .build(k -> DataObject.get("Data for " + k));
 
assertEquals(0, cache.estimatedSize());

// 等待异步淘汰完成才同步返回
cache.cleanUp();

// 基于权重的淘汰
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumWeight(10)
  .weigher((k,v) -> 5)
  .build(k -> DataObject.get("Data for " + k));
 
assertEquals(0, cache.estimatedSize());
 
cache.get("A");
assertEquals(1, cache.estimatedSize());
 
cache.get("B");
assertEquals(2, cache.estimatedSize());

// 基于访问时间的淘汰
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(k -> DataObject.get("Data for " + k));

// 基于写时间的淘汰
cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .weakKeys()
  .weakValues()
  .build(k -> DataObject.get("Data for " + k));

// 自定义基于时间的淘汰策略
cache = Caffeine.newBuilder().expireAfter(new Expiry<String, DataObject>() {
@Override
public long expireAfterCreate(
String key, DataObject value, long currentTime) {
return value.getData().length() * 1000;
}
@Override
public long expireAfterUpdate(
String key, DataObject value, long currentTime, long currentDuration) {
return currentDuration;
}
@Override
public long expireAfterRead(
String key, DataObject value, long currentTime, long currentDuration) {
return currentDuration;
}
}).build(k -> DataObject.get("Data for " + k));

// key 和 value 使用不同的引用。value 只能使用 softValues。
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .weakKeys()
  .weakValues()
  .build(k -> DataObject.get("Data for " + k));
 
cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .softValues()
  .build(k -> DataObject.get("Data for " + k));

// 自动 populate
Caffeine.newBuilder()
  .refreshAfterWrite(1, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

// 获取统计数据
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(100)
  .recordStats()
  .build(k -> DataObject.get("Data for " + k));
cache.get("A");
cache.get("A");

assertEquals(1, cache.stats().hitCount());
assertEquals(1, cache.stats().missCount());

这里的 get 类操作的原子性特别重要:

method invocation is performed atomically, so the function is applied
at most once per key. Some attempted update operations on this cache
by other threads may be blocked while the computation is in progress,
so the computation should be short and simple, and must not attempt to
update any other mappings of this cache.

这样可以保证线性一致性,实现立即可见(类似强广播),但中间插入的这个原子操作必须短,类似 redis 的 set 才可以。

搭配 Spring

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.6.0</version>
</dependency>

相关的配置文件:

  • initialCapacity: # 初始的缓存空间大小
  • maximumSize: # 缓存的最大条数
  • maximumWeight: # 缓存的最大权重
  • expireAfterAccess: # 最后一次写入或访问后经过固定时间过期
  • expireAfterWrite: # 最后一次写入后经过固定时间过期
  • refreshAfterWrite: # 创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存
  • weakKeys: # 打开 key 的弱引用
  • weakValues: # 打开 value 的弱引用
  • softValues: # 打开 value 的软引用
  • recordStats: # 开发统计功能
1
2
3
4
5
6
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=1024
cache-names: cache1,cache2
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

@Configuration
public class CaffeineCacheConfig {

public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("customer");
cacheManager.setCaffeine(caffeineCacheBuilder());
return cacheManager;
}

Caffeine < Object, Object > caffeineCacheBuilder() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(500)
.expireAfterAccess(10, TimeUnit.MINUTES)
.weakKeys()
.recordStats();
}
}

public interface CustomerService {
Customer getCustomer(final Long customerID);
}
// Implementation
@Service
@CacheConfig(cacheNames = {"customer"})
public class DefaultCustomerService implements CustomerService {

private static final Logger LOG = LoggerFactory.getLogger(DefaultCustomerService.class);

@Cacheable
@Override
public Customer getCustomer(Long customerID) {
LOG.info("Trying to get customer information for id {} ",customerID);
return getCustomerData(customerID);
}

private Customer getCustomerData(final Long id){
Customer customer = new Customer(id, "testemail@test.com", "Test Customer");
return customer;
}
}

如何应对缓存 miss 的问题

cache miss 在中文的语境里经常被人分为缓存击穿、缓存雪崩和缓存穿透,这三种类型并不完全互斥穷举,在概念上极其容易造成混淆。在这一段总结的时候姑且依照这三种类型分别加以论述。

如何应对缓存击穿(breakthrough)

缓存击穿,指的是某个 key 应该访问缓存,却没有访问到,导致缓存通过兜底的策略去更下游的冷存储加载内容,给下游的系统造成了读压力。

应对这个问题的基本思路有:

  1. 事前:

    1. 在可能热点数据的访问高峰到达以前,提前把数据预热。
    2. 永远不要让缓存失效-这样缓存 stale 以后,怎么保鲜是个巨大的问题,只能靠一个后端的主动更新机制来尽最大努力来更新缓存。这种方案是一致性最差的。
    3. 在缓存击穿以前,主动更新缓存,即不让缓存击穿发生,即同时才有 expireAfter(t1) + loadAfter(t2) 的策略。
    4. 极端热的数据,不允许缓存被动失效,必须使用主动更新的模式。
    5. 并发操作下更新缓存一定要注意顺序!如果有消息来更新更要注意顺序!
    6. 定时任务和广播刷新有时候可以互相补充-定时任务是超时的补充。
  2. 事中:

    1. 在缓存击穿的时候,严格限制读冷存储 + 预热缓存的流量,即有限降级,有损服务。
    2. 如果缓存更新是同步读写(Cache Aside 或者 Read/Write Through)的模式,则引入各种限流工具(限制线程数的线程池/信号量/SLA/Rhino/Redis 计数器/线程内计数器/Hystrix/Web 容器的限流器),保障可用性的同时保障吞吐量。
    3. 如果缓存更新可以异步主动更新,则考虑单线程执行或者使用消息队列进行低流量更新。能怎样在事中限制这个问题,取决于缓存和读写接入层之间本来的架构关系是如何设计的。
    4. 某类特别热的 key 可能一旦失效会导致大量的读,这种 key 的实际更新流程还要加上分布式锁-而且还要使用试锁而不能使用阻塞锁-facebook 的论文里没有提到这种策略,不知道是不是数据很均匀。
  3. 事后:
    1. 如果系统无法自愈,熔断拒绝服务以后(所以熔断、降级限流每一手准备都要准备好,可以用限流为 0 来制造熔断),手工预热缓存。

如何应对缓存雪崩

雪崩问题,指的是:大规模的缓存失效,再加上大规模的访问流量,造成对后端非高可用的冷存储(通常是 RDBMS)的大规模读写,导致 RDBMS 可用性下降,甚至整个系统级联崩溃。

从某种意义上,单一缓存的击穿并不可怕,缓存雪崩才是最可怕的。

应对缓存雪崩问题,基本思路是大规模使用应对缓存击穿的基础策略的基础上,把缓存预热的行为模式打散。

基于超时时间的思路是:不同的 key 设置不同的超时时间,让缓存失效不同时到来。但这样并不能完全解决问题,因为缓存并不是失效以后就直接可以被加载上,除非缓存自带异步自加载的机制(很多 in-memory cache 有,但 Redis 没有),否则不均匀的流量还是可能到达缓存后导致大规模击穿。对超时时间的方案的加强版是,采用一套主动更新缓存的机制。

基于预热的思路是:缓存一开始分好集群。允许某些集群的上游准备好熔断,然后集体停下流量以后,使用脚本批量预热整个集群数据。

如何应对缓存穿透(penetration)

缓存穿透不同于缓存击穿。

缓存穿透指的是试图查询不存在的缓存数据。

可以针对缓存穿透来刷冷数据,导致整个集群频繁查询冷存储而崩溃。

解决方案有:

  1. 对明显不符合要求的请求,直接返回 null。
  2. 使用一个大的 bitmap 或者布隆过滤器来拦截可能不存在的请求,直接返回 null。
  3. 缓存穿透一次,就在 cache 中存上 null - 允许使用 null 的缓存能够天然抵挡缓存穿透问题。Guava 的缺点就在这里被体现出来了

以上措施混合使用的话,必须考虑缓存里的 null。 必须有超时时间,而且应该有对应的无 null。 以后主动更新的机制,否则这个空值就被污染了。

远端缓存与近端缓存的辨析

缓存在哪端,哪端就能定制它的行为,但要供应它消耗的资源。近端缓存通常简单,但也就意味着没有什么功能。

远端缓存的好处

自带广播、同步和共识功能,能够对接写入服务。
自带独立的集群,有专业的运维人员,适合存储海量数据。

远端缓存的坏处

制造了复杂的依赖,比如接入变复杂、流程变复杂。
所有的服务都依赖于一个服务,配置和流程不易于差异化,冲突比例增多。

近端缓存的好处

接入简单。
自己可以把控自己的缓存使用逻辑。

近端缓存的坏处

相对于广播同步一致性难度大,通信成本高-易引起通信风暴。
占用内存变大,无法解决海量数据存储。

参考文献:

  1. 《Guava Cache》
  2. 美团技术团队的《缓存那些事》
  3. 例子很多:《caffeine vs ehcache》