Redis缓存

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 注解

为什么先更新数据库,后删除缓存?这样可以最大程度上降低不一致情况出现的概率。 Alt text 右图发生的概率很小,因为数据库操作很慢。

缓存穿透

缓存穿透:发出大量读请求给 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,有大量的请求到达数据库。 Alt text

互斥锁

为查询数据库的操作加锁,只允许一个请求线程去查数据库并且重建缓存。其他线程发现缓存里没数据,想要去数据库查数据时,发现有锁,因此休眠一会儿,再去缓存里查数据,直到它从缓存里拿到数据为止。

优点:实现简单,一致性强。没有额外的内存消耗(过期时间)

逻辑过期

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);
    }
}

这段代码解决了缓存穿透(缓存空值)和缓存击穿(循环等待的互斥锁)

逻辑过期+互斥锁

Alt text

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 集群,提高服务的可用性

其它解决方案:降级限流