欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

数据一致性问题、缓存雪崩(缓存击穿)及缓存穿透

程序员文章站 2022-07-14 15:13:37
...

在我们实际项目中,如果其性能达到了瓶颈,特别是对于数据库的访问压力来说,那么我们首先想到的肯定是使缓存来解决,但是一旦使用到了缓存,就可能会涉及到有数据一致性的问题。


数据一致性问题

对于其数据一致性问题,一般主要有从下图中的四个方案来进行分析处理,如下:
数据一致性问题、缓存雪崩(缓存击穿)及缓存穿透

方案名称 技术特点 优点 缺点 使用场景
缓存失效机制 弱一致性,基于缓存本身的失效机制 实现简单 有一定延迟,不保证强一致性,存在缓存雪崩问题 适合读多写少的场景,能接受一定数据延时
任务调度更新 最终一致性,采用任务调度框架,按照一定频率更新 不影响正常业务 不保证一致性,依赖定时任务,容易堆积垃圾数据 适合复杂统计类数据缓存更新,对数据一致实时性要求低的场景;如:统计类数据,BI分析等
数据实时同步更新 强一致性,更新数据库同时更新缓存,使用缓存工具类和或编码实现 数据一致性强 代码耦合,运行期耦合,影响正常业务 数据一致实时性要求比较高的场景,如:银行业务、证券交易
数据准实时更新 准一致性,更新数据库后,异步更新缓存,使用观察者模式/发布订阅/MQ实现 数据同步有较短延迟,与业务解耦,不影响正常业务 有较短延迟,需要补偿机制 不适合写操作频繁并且数据一致实时性要求严格的场景



缓存雪崩

在实际项目中,可能会使用上述中的缓存失效机制,但是如果当缓存服务器重启或者大量缓存集中在某一个时间段失效,而这时又有大量的查询请求,这时所有的请求都会到达数据库,就有可能引起数据库压力过大甚至宕机,这就是缓存雪崩


那么我们应该如何避免了,我们可以从一下几点来

  1. 在缓存服务器重启后进行缓存预热,比如对于常用值可以使用http接口预热错峰加载
  2. 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  3. 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
  4. 设置热点数据永远不过期。
  5. 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。

这里我们来看看最后一点,通过加锁或者队列来控制读数据库写缓存的线程数量,应该如何实现,假设我们采用了Spring Cache对数据进行缓存(有关Spring Cache相关可见Spring Cache缓存),处理如下:

@Service
public class UserCacheServiceImpl extends UserServiceImpl {

    private static final String CACHE_NAME = "userService";

    private final ConcurrentHashMap<Object, ReentrantLock> locks = new ConcurrentHashMap<>();


    @Resource
    private CacheManager cacheManager;

    @Override
    public User findById(Long id) {
        // 1.从缓存中取数据
        Cache.ValueWrapper valueWrapper = cacheManager.getCache(CACHE_NAME).get(id);
        if (valueWrapper != null) {
            return (User) (valueWrapper.get());
        }

        //2.加锁排队,阻塞式锁
        acquireLock(id);
        try {
            //双重校验,为了避免缓存击穿
            valueWrapper = cacheManager.getCache(CACHE_NAME).get(id);
            if (valueWrapper != null) {
                return (User) (valueWrapper.get());
            }

            //3.从数据库查询的结果不为空,则把数据放入缓存中,方便下次查询
            User user = super.findById(id);
            if (null != user) {
                cacheManager.getCache(CACHE_NAME).put(id, user);
            }
            return user;
        } catch (Exception e) {
            return null;
        } finally {
            //4.解锁
            releaseLock(id);
        }
    }

    private void acquireLock(Object key) {
        Lock lock = getLockForKey(key);
        lock.lock();
    }

    private ReentrantLock getLockForKey(Object key) {
        ReentrantLock lock = new ReentrantLock();
        ReentrantLock previous = locks.putIfAbsent(key, lock);
        return previous == null ? lock : previous;
    }

    private void releaseLock(Object key) {
        ReentrantLock lock = locks.get(key);
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

对于上述代码我们先来看一下我们对其加锁的处理,这里我们没有使用synchronized,而是使用了Lock锁,这里我们使用Lock锁来减少锁的粒度,即上述我们使用了多把锁,对于不同的userId使用了不同的锁,这样对其性能会有较大的提高。


其实上述对资源的加锁解锁的操作,在MyBatis的源码也有所体现,可见 MyBatis缓存模块分析



缓存击穿

另外上述在代码中,还体现了避免缓存击穿的问题,就是我们在加锁代码块的第一步操作,会再次查询缓存,如下:
数据一致性问题、缓存雪崩(缓存击穿)及缓存穿透


我们只需明白缓存击穿的概念,就明白了上述代码的作用了,缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,所以大量请求就同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。




缓存穿透

还有一个和上述介绍的缓存击穿类似的概念,叫缓存穿透缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为"-1"的数据或id为特别大不存在的数据。 这时的用户很可能是攻击者,攻击会导致数据库压力过大,甚至宕机。


常见解决方案:

  1. 接口层增加校验,如用户鉴权校验,id做基础校验,比如id<=0的直接拦截
  2. 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击

但是如果攻击者采用大量不存在key来访问的话,上述的解决方案就无法解决了,这里就需要采用布隆过滤器了,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。


这里我们使用了Guava实现的布隆过滤器,首先需要引入其依赖

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>21.0</version>
</dependency>

@Service
@CacheConfig(cacheNames = "userService")
public class UserCacheServiceImpl extends UserServiceImpl {

    private BloomFilter<Long> bloomFilter = null;

    @PostConstruct
    public void init() {
        List<User> userList = super.searchAll();

        bloomFilter = BloomFilter.create(Funnels.longFunnel(), userList.size(), 0.05);
        for (User user : userList) {
            bloomFilter.put(user.getId());
        }
    }

    @Override
    public User findById(Long id) {
        //先判断布隆过滤器中是否存在该值,值存在才允许访问缓存和数据库
        if (!bloomFilter.mightContain(id)) {
            return null;
        }

        User user = super.findById(id);
        return user;
    }
}
相关标签: 面试高频