Caching Patterns
Redis Doc: How to use Redis for Query Caching
这里有图片!
Cache-Aside (Lazy Loading)
只有我们想查看数据的时候,Redis Cache才有可能被更新。当我们发出一个Query,后端先判断数据是否在Redis Cache里面。如果不在,则查数据库,并且把查到的数据放到Redis Cache里。
适用场景:读密集。
优点:云服务花费小,因为 Cache size小,占用空间小。对cache provider的要求小,只需要它负责基本的get和 set.
Read-Through
我的 code 不需要去操心数据是否在 Cache 里面,我只要找cache provider要数据就好了。之于它是否需要从数据库里拿数据,以及一致性问题,我不关心。
Write-Through
我调用cache provider提供的set功能,不关心一致性问题。cache provider在同步数据库之后,我的 set功能结束
Write-Behind
我调用cache provider提供的set功能,数据被迅速更新到Cache中,我的set功能很快结束。至于同步数据库这个操作,由cache provider的其它线程来异步操作。
缺点:可能会存在短暂的缓存不一致。如果服务突然宕机,缓存里的新数据就没了。
Cache-Aside 例子
1
2
3
4
5
6
7
8
9
10
11
12
13
| @Transactional
@Override
public Result update(Shop shop) {
Long id=shop.getId();
if(id==null){
return Result.fail("店铺id不能为空");
}
//1. 更新数据库
updateById(shop);
//2. 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+ id);
return Result.ok();
}
|
如果更新数据库操作成功了,但是删除cache 操作失败了,那么会导致数据不一致,即 cache 里面仍然存放着旧的、错误的数据。
所以这种情况下,数据库操作也得回滚,因此加上@Transactional 注解
为什么先更新数据库,后删除缓存?这样可以最大程度上降低不一致情况出现的概率。
右图发生的概率很小,因为数据库操作很慢。
缓存穿透
缓存穿透:发出大量读请求给 Redis,然而这些数据不存在 Redis 里面,也不存在数据库里面,给数据库造成巨大压力。
缓存null值
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
| @Override
public Result queryById(Long id) {
String key=CACHE_SHOP_KEY+id;
//1. 从 Redis 查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在.null和""都会被算作 blank
if(StrUtil.isNotBlank(shopJson)){
//3. 存在。直接返回。
Shop shop= JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否是空值
if(shopJson!=null){
return Result.fail("店铺信息不存在。");
}
//4. shopJson为null,说明缓存里不存在。根据 id茶数据库
Shop shop=getById(id);
if(shop==null){
//防止缓存穿透,将空值写入 redis
stringRedisTemplate.opsForValue().set(key,"", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
|
布隆过滤器
TODO
缓存击穿
cache problems by Alex Xu
缓存击穿也叫hot key问题。当一个 hot key(频繁被访问的 key)突然 expire,有大量的请求到达数据库。

互斥锁
为查询数据库的操作加锁,只允许一个请求线程去查数据库并且重建缓存。其他线程发现缓存里没数据,想要去数据库查数据时,发现有锁,因此休眠一会儿,再去缓存里查数据,直到它从缓存里拿到数据为止。
优点:实现简单,一致性强。没有额外的内存消耗(过期时间)
逻辑过期
Hotspot data never expires,但它真正的有效期是存在它自己的数据结构里面。
一个线程A去数据库查数据,它占了锁,然后返回过期数据。它让线程 B 去重建缓存,并且重设逻辑过期时间。线程 B 重建完了之后,释放锁。
如果其中线程 C 查缓存,发现它已经过期,它尝试获取锁但是失败,线程 C 马上返回过期数据。
优点:线程无需等待,性能好。
互斥锁的实现
SETNX key value: set the value of a key, only if the key doesn’t exist.
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
| public Shop queryWithMutex(Long id) {
//解决缓存击穿
String key=CACHE_SHOP_KEY+ id;
//1. 从 Redis 查询商铺缓存
String shopJson= stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在.null和""都会被算作 blank
if(StrUtil.isNotBlank(shopJson)){
//3. 存在。直接返回。
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否是空值
if(shopJson!=null){
return null;
}
//4. shopJson为null,说明缓存里不存在。实现缓存重建。
//4.1 获取互斥锁
String lockKey="lock:shop:"+id;
Shop shop= null;
try {
boolean isLock=tryLock(lockKey);
//4.2 判断是否获取成功
if(!isLock){
//4.3 失败。休眠并且重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 获取锁成功 根据 ID 查询数据
shop = getById(id);
//模拟查询数据库延时
Thread.sleep(200);
if(shop==null){
//防止缓存穿透,将空值写入 redis
stringRedisTemplate.opsForValue().set(key,"", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
finally {
//释放互斥锁
unlock(lockKey);
}
}
|
这段代码解决了缓存穿透(缓存空值)和缓存击穿(循环等待的互斥锁)
逻辑过期+互斥锁

1
2
3
4
5
| @Data
public class RedisData {
private LocalDateTime expireTime;//逻辑过期
private Object data; //shop之类
}
|
创建一个新的类,data字段放想要存进 Redis 的数据结构,expireTime 代表逻辑过期时间
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 Shop queryWithLogicalExpire(Long id) {
String key=CACHE_SHOP_KEY+ id;
//1. 从 Redis 查询商铺缓存
String shopJson= stringRedisTemplate.opsForValue().get(key);
//2. null和""都会被算作 blank
if(StrUtil.isBlank(shopJson)){
return null;
}
//3. 命中,需要先把 JSON 反序列化为对象
RedisData redisData= JSONUtil.toBean(shopJson,RedisData.class);
JSONObject data= (JSONObject)redisData.getData();
Shop shop= JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime=redisData.getExpireTime();
//4. 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//4.1 没有过期,直接返回店铺信息
return shop;
}
//5 已经过期,需要重建缓存。
String lockKey=LOCK_SHOP_KEY+id;
boolean isLock=tryLock(lockKey);
if(isLock) {
//获取lock 成功,则开启独立线程去实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try{
this.saveShop2Redis(id, 20L);
}catch(Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
//返回旧缓存里的店铺信息
return shop;
}
|
首先缓存预热,把店铺 1 的数据加载到缓存中,并设置逻辑缓存时间为很短。接着,在数据库中将店铺 1 的名字从 A 改成 B。
此时店铺 1 在数据库里名字为 B,在缓存里名字为 A.
在 JMeter里设置 1 秒钟有 100 个请求去查店铺 1。第一个请求发现缓存已经过期,它去抢占锁,并返回名字 A,它也启动线程去重设缓存。这里故意将重建缓存的时间拖长,让它睡眠 200 毫秒。
测试发现,从第30 个请求开始,查到的店铺名字为 B.这说明,第二个请求到第 29 个请求都缓存命中,发现缓存已经过期,并且抢占锁失败。第 30 个请求缓存命中,发现缓存没有过期,因此返回了正确的数据。(这是因为缓存重建已经成功,店铺名字和过期时间都在缓存中被更新了)。
说明缓存过期策略会有不一致的风险,缓存重建耗时越长,不一致的情况就越多。
同时控制台里只有一个 SQL 语句,说明只有一个请求成功抢到了互斥锁,进行了缓存重建。
缓存雪崩
同一时段,大量Key过期:给不同的Key TTL设置为不同的随机值
Redis宕机:利用 Redis 集群,提高服务的可用性
其它解决方案:降级限流