Redis缓存常见问题

words: 2.4k    views:    time: 9min

引入缓存是一种非常常见的策略,可以提高上层的查询性能,同时减轻下层存储的压力。但同时也引入了更多的问题,主要围绕命中率一致性两个方面,如果命中率过低,那么缓存就形同虚设,如果不能保证缓存与真实数据一致,那么就会造成错误,不如不用缓存。下面主要总结下使用redis作为缓存时常见的问题和解决办法

缓存击穿

针对数据库中存在,但缓存中没有的数据(一般是热点数据缓存时间到期),如果此时请求并发很高,由于缓存没有读到,会同时去查询数据库,造成数据库压力陡增,影响系统稳定性;

解决办法:

  • 热点数据不设置过期时间,根据一致性要求和业务场景进行评估;

  • 添加互斥锁

处理并发请求时,只有第一个请求线程能拿到锁并执行数据库查询操作,其他线程阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。

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
private RedissonClient redissonClient;

public List<String> getData(String key) throws InterruptedException {
for (int retry = 0; retry < 10; retry++) {
// 查询缓存
     List<String> result = getDataFromCache(key);
if (result != null) {
return result;
}

RLock lock = redissonClient.getLock("prefix:lock:" + key);
// 尝试获取锁,等待0秒,持有10秒
if (lock.tryLock(0, 10, TimeUnit.SECONDS)) {   
try {
// 再次查询缓存,可能别的线程已经写入
result = getDataFromCache(key);
if (result == null) {
                 // 查询数据库
                 result = getDataFromDB(key);
if (result == null) {
result = new ArrayList<>();
}
                 // 写入缓存
                 setDataToCache(key, result);
}
return result;
            } finally {
                lock.unlock();
            }
}

// 获取锁失败,稍后重试
Thread.sleep(100);
}

// 重试超过限制,直接查询数据库
return getDataFromDB(key);
}

缓存雪崩

可以理解成升级版的缓存击穿,大量热点key同时失效(可能是缓存重启或者大量key设置了相同的过期时间),此时数据库压力暴增,可能会崩溃;

解决办法:

  • 对缓存进行预热;

  • 分散key的过期时间,可以给每个key单独设置一个随机的过期时间;

  • 使用互斥锁,与上面的解决办法一样;

缓存穿透

缓存穿透的危害最大,因为它的目标是根本不存在的数据,此时缓存形同虚设,如果恶意攻击,制造大量非法请求,很容易将数据库压垮;

解决办法:

  • 参数校验,在API入口层进行严格校验,直接屏蔽非法参数的请求,这种办法成本最低,效果明显;

  • 布隆过滤器,清求的key可能合法但不存在,可以再用布隆过滤器过滤下

布隆过滤器思路:通过多个hash函数,对存在的key刷一遍,将hash值放到一个bitmap中,这样对于一个请求key,用同样的hash函数对它进行计算,结果放入bitmap进行比较,只要有1位不存在,那么这个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
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
@Component
public class BloomFilter<T> {
    
    private final BitSet bitSet;
    private final int bitSetSize;
    private final int expectedNumberOfItems;
    private final int numberOfHashFunctions;
    
    private final HashFunction[] hashFunctions;
    
    public BloomFilter(int expectedNumberOfItems, double falsePositiveProbability) {
        this.expectedNumberOfItems = expectedNumberOfItems;
// bit位数 m = -n × ln p / (ln 2)²,n是期望个数,p是误判率
double ln2 = Math.log(2);
double ln2Squared = ln2 * ln2;
this.bitSetSize = (int) Math.ceil(-expectedNumberOfItems * Math.log(falsePositiveProbability) / ln2Squared);
this.bitSet = new BitSet(bitSetSize);
        
// hash函数个数 k = (m/n) × ln 2
this.numberOfHashFunctions = (int) Math.ceil(ln2 * bitSetSize / expectedNumberOfItems);

        // 初始化哈希函数
        this.hashFunctions = new HashFunction[numberOfHashFunctions];
        for (int i = 0; i < numberOfHashFunctions; i++) {
            hashFunctions[i] = new SimpleHash(bitSetSize, i + 1);
        }
    }
    
    public void add(T element) {
        if (element == null) {
            return;
        }

int hash1 = element.hashCode();
int hash2 = hash1 >>> 16;
for (int i = 0; i < numberOfHashFunctions; i++) {
int combinedHash = hash1 + i * hash2;
int position = Math.abs(combinedHash) % bitSetSize;
bitSet.set(position);
}
    }
    
    public boolean contains(T element) {
        if (element == null) {
            return false;
        }

int hash1 = element.hashCode();
int hash2 = hash1 >>> 16;
for (int i = 0; i < numberOfHashFunctions; i++) {
int combinedHash = hash1 + i * hash2;
int position = Math.abs(combinedHash) % bitSetSize;
if (!bitSet.get(position)) {
// 一定不存在
return false;
}
}
// 可能存在
        return true
    }
}

对于分布式环境,可以使用Redis的布隆过滤器模块:

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
@Component
public class RedisBloomFilter {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String BLOOM_FILTER_KEY = "bloom_filter:user_ids";
    
    public boolean contains(Long id) {
        try {
            return (Boolean) redisTemplate.execute(
(RedisCallback<Boolean>) con -> con.executeCommand(
new CommandObject<>(RedisCommands.BF_EXISTS, BLOOM_FILTER_KEY, String.valueOf(id))));
        } catch (Exception e) {
            // 过滤器不可用时降级处理
            return true;
        }
    }
    
    public void add(Long id) {
        try {
            redisTemplate.execute(
                (RedisCallback<Void>) con -> con.executeCommand(
                    new CommandObject<>(RedisCommands.BF_ADD, BLOOM_FILTER_KEY, String.valueOf(id))));
        } catch (Exception e) {
            // 记录错误日志
        }
    }
}
  • 缓存空值,如果请求的key通过了校验和过滤,可以考虑缓存空值来拦住请求,具体办法与上面缓存击穿的处理一样;

缓存一致性问题

旁路缓存 Cache Aside

  • 读操作:缓存命中则返回,如果未命中则读取数据库,并回填缓存;
  • 写操作:先更新数据库,然后删除缓存;

这是最常用和方便的模式,基本可以满足大部分场景。通过惰性加载保证高性能,并通过删除而非更新缓存来避免并发写的不一致,实现最终一致性。

但是需要防范缓存击穿、雪崩等问题,至于出现数据不一致的概率基本可以忽略。只有在一个线程A读完数据库写入Redis的期间,另一个线程B完成了数据库更新和删除Redis,才会发生。而且,也可以通过延迟双删进一步降低这种概率,就是在删除之后等待一小段时间后再删一次。

  • 同步更新

有的业务场景可能容忍不了惰性加载缓存,进行写操作时,需要同时更新数据库和缓存。这里问题就是先写数据库还是先写缓存,推荐是先写数据库(毕竟数据库才是真正的数据持久化,而且写缓存失败还可以回滚前面的数据库操作),但不管是先写哪个,都存在数据不一致的可能,毕竟写库和写缓存是两件独立的操作。或者你可以通过互斥锁来将两个操作变成一个原子操作,但这样明显会影响性能(在强一致性与并发性能之间无法做到既要又要)。

读/写穿透 Read/Write Through

  • 读穿透:应用只找缓存要数据,如果缓存里没有,缓存组件自己去数据库加载数据;
  • 写穿透:应用只负责将数据写入缓存,然后由缓存组件自己去更新数据库;

这里应用只需面对缓存进行操作即可,因为缓存组件承担了更多的工作,所以重点就是对缓存组件的操作封装。主要就是载入刷库操作,具体操作方式可以再分为同步和异步。

比较稳一点选择是:同步写库,然后异步刷新缓存。这样虽然读操作的即时性稍微会差一点,但可以避免写操作时丢数据(毕竟难以容忍)。

写回 Write Behind

  • 读操作,读取缓存,如果没有则读取数据库并回填;
  • 写操作,只更新缓存,然后直接返回,数据库的更新被推迟到缓存数据过期或被逐出时触发;

这里缓存组件需要做更多的事情,要监听缓存中的key,在其过期时将最新数据刷到数据库。这种方式也很常见,比如mysql对Buffer pool中脏页的处理。但代价是可能丢数据,比如缓存突然宕机,那么所有未刷到数据库的脏数据将全部丢失。(其实这种方式反而不存在数据不一致的问题,因为对应用来说只面对缓存一个存储,但前提是能解决数据丢失问题)

注意,这里读操作回填缓存,或写操作载入缓存时不能直接SET覆盖,需要使用SETNX(Set if Not Exists),因为缓存的数据版本永远高于数据库中的版本,可能有别的线程已经更新了缓存数据,如果直接覆盖可能造成修改丢失的问题。

预刷新 Refresh Ahead

  • 读操作,读取缓存,如果没有则读取数据库并回填;
  • 写操作,只更新数据库,然后直接返回,后面通过监听数据库Binlog方式异步刷新缓存;

这里同样面临数据不一致的问题,比如在数据写入数据库之后,Binlog被消费并刷新到缓存之前,读取的缓存数据将是旧版本数据。另外,这种方式中的数据是以数据库为准的,也就是来自Binlog的数据更新一定是可信的,所以读操作在回填缓存是也要使用SETNX。(比如读线程A读取了一个数据旧值,但是在它回填缓存之前,写线程B完成了更新,并将Binlog的变化刷到了缓存,此时线程A拿到的值就不应该再写入缓存了)


参考: