基本概念

作用:将数据按需从数据库加载到缓存中。

此模式可以提高性能,并能保持缓存和数据库中数据的一致性。

问题背景

应用程序使用缓存来优化对数据库中存储信息的重复访问。但是,期望缓存和数据库中的数据完全一致是不切实际的。

应用程序应该实现一种有助于确保缓存中的数据尽量保持最新,同时当缓存中的数据过期时可以检测和处理的策略。

解决方案

很多商业缓存系统提供read-throughwrite-through/write-behind操作。在这些系统中,应用程序通过引用缓存来检索数据。如果数据不在缓存中,则从数据库中透明地查询数据并将其添加至缓存中;对缓存中保存的数据的任何修改也会自动写回到数据库中。

对于不提供此功能的缓存,由使用缓存来维护数据的应用程序负责。

应用程序可以通过实现缓存备用模式来模拟read-through缓存的功能。该模式可根据需要将数据高效地加载到缓存中。下图总结了此过程中的步骤。

Cache-Aside Pattern

通俗的讲,分为以下步骤:

  1. 先查询缓存,判断缓存是否存在。
  2. 如果缓存不存在,则从数据库中查询。
  3. 然后将数据中查出来的数据设置到缓存中。

如果应用程序需要修改数据库中的数据,可使用以下write-through策略:

  1. 修改数据库。
  2. 让缓存失效。

当下一次请求到来时,由于使用了缓存备用模式,将从数据库中查询出最新的数据并将其设置回缓存中。

问题与思考

在决定如何实现此模式时,应考虑以下几点:

  • 缓存数据的生命周期:很多缓存实现了过期策略,如果在指定的时间内未访问缓存数据,缓存会自动失效并进行删除。为了使缓存备用模式高效,请确保过期策略与使用该缓存的应用程序的访问模式相匹配。不要将过期时间设置的太短,否则可能导致应用程序不断从数据库中查询数据并将其添加至缓存中;同样不要设置的太长,以免缓存的数据不是最新的。请记住,对于相对静态的数据或经常读取的数据,使用缓存最有效。
  • 淘汰数据:即内存淘汰策略。与数据库相比,大多数高速缓存只有有限的内存空间大小,在必要的时候会进行内存淘汰。大多数缓存都使用LRU(最近最少使用)算法来实现,但这可以进行自定义,可配置缓存的全局过期属性以及每个缓存key的过期属性,以帮助缓存具有成本效益。将内存淘汰策略应用于所有缓存key并不总是合适的,例如,如果从数据中查询并添加至缓存中的代价非常昂贵,那么将其保留在缓存中可能是有益的。
  • 启动缓存:即缓存预热。许多解决方案会在应用程序启动过程中预先加载可能需要的数据至缓存中。如果这部分缓存过期或被淘汰,缓存备用模式仍然有效。
  • 一致性:实现缓存备用模式并不能保证数据库和缓存之间的数据一致性。数据库中的数据可能在任意时刻被外部修改,并且在下次该数据被加载到缓存中之前,此修改可能不会反映在缓存中。在跨数据存储复制数据的系统中,如果非常频繁的发生同步,则此问题可能会变得特别严重。
  • 本地缓存:缓存可以存在于应用程序实例的本地内存中,如果应用程序重复访问相同的数据,缓存备用模式会非常有用。但是本地缓存是每个应用程序实例私有的,在分布式系统中,本地缓存之间的数据可能很快变得不一致,我们需要非常频繁的刷新本地缓存并且设置较短的过期时间。在这种情况下,应该考虑使用分布式缓存。

什么场景使用该模式?

适用场景:

  • 缓存系统不提供read-throughwrite-through操作。
  • 资源需求无法进行预测。此模式可使应用程序按需加载数据至缓存,它不会预先假设应用程序需要哪些数据。

不适合使用的场景:

  • 当缓存的数据为静态的时候,如果缓存空间足够存储数据,请在启动时进行缓存预热,并让其永不过期。
  • Web应用程序中缓存会话状态信息。此场景下应避免引入基于客户端-服务端相似性的依赖关系。

使用示例

在日常开发中,我们一般会使用Redis作为多个应用程序实例之间共享的分布式缓存。以下代码中的selectCacheOrDb方法展示了基于Redis的缓存备用模式的模板方法实现。该方法使用read-through从缓存中读取数据。

  • 首先从缓存中读取数据;
  • 如果缓存数据不存在,则从数据库进行查询;
  • 如果数据库中的数据存在,则将其设置到缓存中,设置一个过期时间,然后返回;
  • 如果缓存数据存在,则直接返回缓存中的数据;
  • 整个过程如果出现异常,返回默认值进行降级。
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
package com.sunchaser.sparrow.designpatterns.cloud;

import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.TypeReference;
import com.sunchaser.sparrow.designpatterns.common.model.request.CacheAsideRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2021/1/3
*/
@Component
@Slf4j
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class CacheAsidePattern {
private final RedisTemplate<String,String> redisTemplate;

public <T> T selectCacheOrDb(CacheAsideRequest<T> cacheAsideRequest) {
try {
String cacheKey = cacheAsideRequest.getCacheKey();
// random 防止雪崩
int expiredTime = cacheAsideRequest.getExpiredTime() + ThreadLocalRandom.current().nextInt(60);
TimeUnit timeUnit = cacheAsideRequest.getTimeUnit();
String cacheValueString = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isEmpty(cacheValueString)) {
T dbValue = cacheAsideRequest.getDbSelector().get();
log.info("cache miss, query db and the db value is: {}",dbValue);
if (Objects.nonNull(dbValue)) {
redisTemplate.opsForValue().set(
cacheKey,
JSONObject.toJSONString(dbValue),
expiredTime,
timeUnit
);
}
return dbValue;
} else {
T t = JSONObject.parseObject(cacheValueString, new TypeReference<T>() {});
log.info("hit cache, the cache value is: {}",cacheValueString);
return t;
}
} catch (Exception e) {
log.info("query cache or db error!");
return cacheAsideRequest.getDefaultValue();
}
}
}

其中方法入参CacheAsideRequest的具体字段如下:

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
package com.sunchaser.sparrow.designpatterns.common.model.request;

import lombok.Data;

import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

/**
* 缓存备用模式请求入参
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2021/1/3
*/
@Data
public class CacheAsideRequest<T> {

/**
* 缓存key
*/
private String cacheKey;

/**
* 缓存过期时间
*/
private Integer expiredTime;

/**
* 过期时间的单位
*/
private TimeUnit timeUnit;

/**
* DB查询器
*/
private Supplier<T> dbSelector;

/**
* 发生异常进行降级的默认值
*/
private T defaultValue;
}

我们使用HashMap模拟一个数据库,当需要查询时,以下代码中的testSelectCacheOrDb方法展示了我们的使用方式,testUpdate方法模拟了数据库的更新。

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.sunchaser.sparrow.designpatterns.cloud;

import com.google.common.collect.Maps;
import com.sunchaser.sparrow.designpatterns.common.model.request.CacheAsideRequest;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2021/1/3
*/
@SpringBootTest
@RunWith(SpringRunner.class)
public class CacheAsidePatternTest {
@Autowired
private CacheAsidePattern cacheAsidePattern;
@Autowired
private RedisTemplate<String,String> redisTemplate;

private static final Map<String,String> db = Maps.newHashMap();

@Before
public void setUp() throws Exception {
db.put("defaultUser","defaultValue");
db.put("user1","value1");
db.put("user2","value2");
db.put("user3","value3");
db.put("user4","value4");
}

@Test
public void testSelectCacheOrDb() {
CacheAsideRequest<String> cacheAsideRequest = new CacheAsideRequest<>();
cacheAsideRequest.setCacheKey("test-key");
cacheAsideRequest.setExpiredTime(100);
cacheAsideRequest.setTimeUnit(TimeUnit.SECONDS);
cacheAsideRequest.setDbSelector(() -> db.get("user1"));
cacheAsideRequest.setDefaultValue(db.get("defaultUser"));
String s = cacheAsidePattern.selectCacheOrDb(cacheAsideRequest);
System.out.println(s);
}

@Test
public void testUpdate() {
db.put("user1","updateValue1");
redisTemplate.delete("test-key");
}
}

请注意,我们在更新数据时,应该先更新数据库,再让缓存失效。这样带来的数据延迟只有一个redis操作的时间。

如果先让缓存失效,后更新数据库。在这两步操作的窗口期间,有很小的可能客户端会先读取到数据库中旧的数据并将其重新设置到缓存中,然后对数据库的更新才正式提交。这会导致缓存中的数据不是最新。

扩展部分

有时候我们希望对从数据库中查询出的数据进行一定的校验后才决定是否存入缓存。例如查询出的数据包含的字段全部不为零值才进行缓存,或者查询出的数据包含的字段只要有一个为零值就进行缓存。另外一方面,我们从数据库中查询出来的数据可能是一个数据表实体对象,也可能是一个包含数据表实体对象的ArrayList集合,所以我们需要进行分类讨论。对外我们提供一个可选的策略,对内我们兼容单个实体类和集合两种情况。根据数据库查询返回的数据类型执行不同的解析方式,我们利用反射来解析单个对象或集合中的每一个对象的字段是否为零值。

零值:例如Integer的零值为0Long的零值为0LDouble的零值为0.00等等,还可根据具体的业务场景进行自定义。

下面给出我们的策略枚举类CacheStrategyEnum实现:

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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
package com.sunchaser.sparrow.designpatterns.common.enums;

import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import lombok.Getter;

import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;

/**
* 缓存策略枚举及策略对应的实现
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2021/1/3
*/
@Getter
public enum CacheStrategyEnum {
ALL_NO_ZERO("ALL_NO_ZERO","全部字段不为零则进行缓存") {
@Override
public boolean analysisList(List<?> list) throws IllegalAccessException {
for (Object el : list) {
// 获取包含父类super的所有字段
Field[] allFields = getAllFields(el);
for (Field field : allFields) {
field.setAccessible(true);
// 只要有一个字段为零,返回false
if (analysisZero(field.get(el))) return false;
}
}
return true;
}

@Override
public boolean analysisCustomObject(Object object) throws IllegalAccessException {
Field[] allFields = getAllFields(object);
for (Field field : allFields) {
field.setAccessible(true);
// 只要有一个字段为零,返回false
if (analysisZero(field.get(object))) return false;
}
return true;
}
},
ANY_NO_ZERO("ANY_NO_ZERO","任意一个字段不为零则进行缓存") {
@Override
public boolean analysisList(List<?> list) throws IllegalAccessException {
for (Object el : list) {
// 获取包含父类super的所有字段
Field[] allFields = getAllFields(el);
for (Field field : allFields) {
field.setAccessible(true);
// 任意一个字段不为零,返回true
if (!analysisZero(field.get(el))) return true;
}
}
return false;
}

@Override
public boolean analysisCustomObject(Object object) throws IllegalAccessException {
Field[] allFields = getAllFields(object);
for (Field field : allFields) {
field.setAccessible(true);
// 任意一个字段不为零,返回true
if (!analysisZero(field.get(object))) return true;
}
return false;
}
};

/**
* 缓存策略
*/
private final String strategy;

/**
* 描述
*/
private final String desc;

/**
* flyweight
*/
private static final Map<String,CacheStrategyEnum> enumMap = Maps.newHashMap();

static {
for (CacheStrategyEnum cacheStrategyEnum : CacheStrategyEnum.values())
enumMap.put(cacheStrategyEnum.strategy,cacheStrategyEnum);
}

public CacheStrategyEnum getEnumByStrategy(String strategy) {
return enumMap.get(strategy);
}

CacheStrategyEnum(String strategy, String desc) {
this.strategy = strategy;
this.desc = desc;
}

/**
* 抽象方法:解析List中的自定义对象
* @param list list对象
* @return false:不进行缓存;true:进行缓存
* @throws IllegalAccessException 上层进行异常处理
*/
public abstract boolean analysisList(List<?> list) throws IllegalAccessException ;

/**
* 抽象方法:解析自定义对象
* @param object 待解析的自定义对象
* @return false:不进行缓存;true:进行缓存
* @throws IllegalAccessException 上层进行异常处理
*/
public abstract boolean analysisCustomObject(Object object) throws IllegalAccessException ;

/**
* 解析字段是否为零值
* @param o 待解析字段
* @return true:为零值;false:不为零。
*/
private static boolean analysisZero(Object o) {
if (o instanceof Long) {
return (Long) o == 0L;
} else if (o instanceof Integer) {
return (Integer) o == 0;
} else if (o instanceof Double) {
return (Double) o == 0.00;
} else {
// do other biz zero
}
return false;
}

/**
* 获取包含super父类的所有字段
* @param object 需要获取字段的对象
* @return 对象的所有字段
*/
private static Field[] getAllFields(Object object) {
Class<?> clazz = object.getClass();
List<Field> fieldList = Lists.newArrayList();
while (clazz != null) {
fieldList.addAll(Lists.newArrayList(clazz.getDeclaredFields()));
clazz = clazz.getSuperclass();
}
Field[] fields = new Field[fieldList.size()];
fieldList.toArray(fields);
return fields;
}
}

此时我们需要在模板方法的入参CacheAsideRequest中添加该枚举字段。同时模板方法selectCacheOrDb的实现也要做对应修改,具体可看以下代码。

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
public <T> T selectCacheOrDb(CacheAsideRequest<T> cacheAsideRequest) {
try {
String cacheKey = cacheAsideRequest.getCacheKey();
// random 防止雪崩
int expiredTime = cacheAsideRequest.getExpiredTime() + ThreadLocalRandom.current().nextInt(60);
TimeUnit timeUnit = cacheAsideRequest.getTimeUnit();
CacheStrategyEnum cacheStrategyEnum = cacheAsideRequest.getCacheStrategyEnum();
String cacheValueString = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isEmpty(cacheValueString)) {
T dbValue = cacheAsideRequest.getDbSelector().get();
log.info("cache miss, query db and the db value is: {}",dbValue);
if (Objects.nonNull(dbValue)) {
// flag的含义:false:不进行缓存;true:进行缓存。
boolean flag;
// 解析List中的每一个对象的零值
if (dbValue instanceof List) flag = cacheStrategyEnum.analysisList((List<?>) dbValue);
// 解析单个对象的零值
else flag = cacheStrategyEnum.analysisCustomObject(dbValue);
if (flag) redisTemplate.opsForValue().set(
cacheKey,
JSONObject.toJSONString(dbValue),
expiredTime,
timeUnit
);
}
return dbValue;
} else {
T t = JSONObject.parseObject(cacheValueString, new TypeReference<T>() {});
log.info("hit cache, the cache value is: {}",cacheValueString);
return t;
}
} catch (Exception e) {
log.info("query cache or db error!");
return cacheAsideRequest.getDefaultValue();
}
}

总结

本文介绍了云设计模式之缓存备用模式的基本概念和原理,然后使用了一个较为通用的模板方法实现了缓存备用模式,最后我们还对模板方法中一些可能出现的业务细节进行了扩展,这对现有的业务系统是一个很好的实现参考。

参考

https://docs.microsoft.com/en-us/previous-versions/msp-n-p/dn589799(v=pandp.10)