数据库+缓存的正确姿势

项目规模或者并发访问量较小的时候,使用数据库就可以满足查询的需要。当并发量逐渐增大的时候,数据库可能就扛不住访问压力了。这个时候可以加入缓存提高查询速度,但是加入缓存是一项比较有技术含量的工作,如果姿势不对,可能造成数据不一致或者不起作用的问题。

一般的套路都是,先查缓存,缓存中没有则去查数据库,将数据放入缓存并返回。伪代码就是:

    public Object get(Object param){
        Object o = getFromCache(param);
        if(null == o){
            o = getFromDb(param);
            save2Cache(param , o);
        }
        return o;
    }

    void save2Cache(Object param , Object o){
        //往缓存中保存
    }
    
    Object getFromCache(Object param){
        return "从缓存中查询";
    }
    Object getFromDb(Object param){
        return "从数据库查询";
    }

这样基本上就能提升很多查询性能。

常见地,使用缓存会出现三个问题:缓存穿透、缓存雪崩、缓存击穿。

所谓缓存穿透是指,指定一个绝不存在的参数去查询,缓存中肯定没有,都会打到db上,缓存就跟不存在一样。解决这个问题,可以使用bloomfilter校验参数,bloomfilter的特点是如果返回false,就一定不存在。另外一种解决方式是将这个参数的value指定为一个特殊值。

所谓缓存雪崩是指,大量的key在同一时刻都失效了,所有的流量全部打到db上,造成db的瞬时压力过大。解决这个问题可以给缓存加一个随机的ttl,不让所有的key同时失效。

所谓缓存击穿是指,存在一个非常热点的key,如果某一个时刻失效了,所有的请求就都会打到db上。可以使用锁来解决这个问题。即getFromDb方法加上锁,方法变为getFromCacheAndDb。

   public Object get(Object param){
        Object o = getFromCache(param);
        if(null == o){
            o = getFromCacheAndDb(param);
            save2Cache(param , o);
        }
        return o;
    }

    void save2Cache(Object param , Object o){
        //往缓存中保存
    }

    Object getFromCacheAndDb(Object param){
        //syncronized/juc.lock
        locker
        {
            Object fromCache = getFromCache(param);
            if(null != fromCache){
                return fromCache;
            }

            return getFromDb(param);
        }

    }

锁可以使用syncronized或者juc.lock,这样所有请求中只有一个请求会去查db,压力大大减小。为什么在锁内部,还需要去查询一次缓存呢?因为所有等待的线程阻塞完成后,可能其他线程已经完成查询操作并将结果设置回缓存了。如果不去查缓存,相当于所有的请求还是打到了db上。注意syncronized会自动释放锁,juc.lock不会,需要在finally中释放锁。locker.unlock()。

但是syncronized或者juc.lock属于本地锁,在单机情况下,没问题,但是在分布式环境下,可能就不是那么完美了。比如有100台提供同样服务的机器,所有的流量由这100台机器承担,那么同一时刻可能最多就有100个请求打到db。完美的解决方案是使用分布式锁,可以使用mysql、redis、zookeeper等实现。其基本原理就是竞争同一个共享的资源,比如在redis就是使用set nx命令,不存在才设置成功,存在就设置不成功,不成功可以重试,相当于自旋。

加入分布式锁毕竟增加了系统复杂度,而db的压力在于多少台机器去同时请求它,如果机器没那么大规模,是可以不用分布式锁的;如果规模达到了一定量级,增加分布式锁就是很有必要的了。

   Object getFromCacheAndDb(Object param){
        ///distribute.lock
        //locker加锁
        //加锁成功
        if(locker.isSuccess())
        {
            Object fromCache = getFromCache(param);
            if(null != fromCache){
                return fromCache;
            }

            Object o = getFromDb(param);
            //删除锁
            jck.lock.unlock()/redis.delete(key);
            return o;
        }else{
            //自旋
            return getFromCacheAndDb(param);
        }

    }

当前写法可能存在一个问题,save2Cache方法存在网络请求,如果去查db的线程正在去保存缓存而没完成的时候,其他的请求由于查不到缓存而又会去查db。所以保存数据到缓存的逻辑应该一并锁住。

    public Object get(Object param){
        Object o = getFromCache(param);
        if(null == o){
            o = getAndSaveFromCacheAndDb(param);
        }
        return o;
    }

    void save2Cache(Object param , Object o){
        //往缓存中保存
    }

    Object getAndSaveFromCacheAndDb(Object param){
        ///distribute.lock
        //加锁成功
        if(locker.isSuccess())
        {
            Object fromCache = getFromCache(param);
            if(null != fromCache){
                return fromCache;
            }

            Object o = getFromDb(param);
            //在锁方法中就保存到缓存
            save2Cache(param , fromDb);
            return o;
        }else{
            //自旋,可以等待一段时间
            return getFromCacheAndDb(param);
        }

    }

    Object getFromCache(Object param){
        return "从缓存中查询";
    }
    Object getFromDb(Object param){
        return "从数据库查询";
    }

为了防止getFromDb的过程中抛异常导致该锁永不删除造成的死锁,我们还应该给改锁加一个过期时间,即使发生了异常,过期时间后该锁还是能释放,不会造成死锁。要注意,加锁和设置过期时间必须是原子的,否则也容易导致死锁。删锁也不能直接就删了,因为如果自己的业务时间很长,自己设置的key已经过期,此时如果直接删除锁,可能会导致删除了别的线程设置的锁。可以在设置锁的时候将value设置为一个uuid,删除的时候匹配此uuid才删除。

    public Object get(Object param){
        Object o = getFromCache(param);
        if(null == o){
            o = getAndSaveFromCacheAndDb(param);
        }
        return o;
    }

    void save2Cache(Object param , Object o){
        //往缓存中保存
    }

    Object getAndSaveFromCacheAndDb(Object param){
        ///distribute.lock
        //locker加锁设置uuid并设置过期时间
        //对于redis及时setnxex命令或者可以用lua脚本执行
        //locker.lock()
        //加锁成功
        String uuid = uuid();
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock" , uuid , 300 , TimeUnit.SECONDS);
        if(lock)
        {
            try{
                Object fromCache = getFromCache(param);
                if(null != fromCache){
                    return fromCache;
                }

                Object o = getFromDb(param);
                //在锁方法中就保存到缓存
                save2Cache(param , fromDb);
                return o;
             }finally{
                //删除锁
                //删除不能直接删除,判断是自己的锁(可以根据value)才删除
                //redis.delete(key);
                //非原子性可能导致删除别人加的锁
                //String lockValue = redisTemplate.opsForValue().get("lock");
                //if(uuid.equals(lockValue)){
                //    redisTemplate.delete("lock");
                //}
                //使用lua脚本保证
             }
            
        }else{
            //自旋,可以等待一段时间
            return getFromCacheAndDb(param);
        }

    }

    Object getFromCache(Object param){
        return "从缓存中查询";
    }
    Object getFromDb(Object param){
        return "从数据库查询";
    }

自旋等待,原子获取锁,原子删除锁这些可以使用更加专业的redis客户端redisson来实现。

//1、获取一把锁,锁名字一样就是同一把锁
RLock lock = redisson.getLock("lock");

//2、加锁,阻塞式等待,直到拿到锁。默认的加锁时间为30s
//2.1 锁的自动续期,如果业务时间超长,运行期间自动给锁续期30s,不用担心业务时间太长锁自动过期
//2.2 加锁的业务只要运行完成,就不会给当前锁续期,即使不释放锁,锁会在30s后自动删除
lock.lock();

//10秒自动解锁,如果业务在10秒内没有结束,看门狗不会自动续期。锁时间必须大于业务的执行时间。
//2.3 如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,锁的超时时间就是我们传递的
//2.4 如果我们我们没有传递锁的超时时间,就使用看门狗的超时时间,默认30s。并且设置一个定时器,1/3看门狗时间定时刷新锁的超时时间。
lock.lock(10 , TimeUnit.SECOND);


try{
    //todo your business
}finally{
    lock.unlock();
}

更多redisson的用法请请参照redisson的官方文档。https://github.com/redisson/redisson/wiki/Table-of-Content

 

使用缓存还有一个缓存数据一致性问题,即如何保证缓存数据和数据库的数据的一致性?

可以有两种模式,双写模式和失效模式。

所谓双写,就是在更新数据的时候同时更新数据库和缓存。

所谓失效,就是在更新数据的时候只更新数据库,删除缓存,下一次查询的时候就可以自动更新缓存了。

这两种模式都有一定机率导致数据不一致,多个实例同时更新可能存在问题。

1、如果是用户维度数据,并发机率非常小,可以不考虑这个问题,缓存数据加上过期时间,每个一段时间触发读的主动更新即可。

2、如果是菜单,商品介绍等基础数据,可以使用canal去订阅mysql的binlog。

3、缓存数据+过期时间足够解决大部分业务对于缓存的需求。

4、通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。适合读写锁。

由以上可知,我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。在缓存数据的时候加上过期时间,一定时间范围内的脏数据要能容忍。系统不应该过度设计,增加复杂性。对于实时性、一致性要求高的数据,就应该查询数据库,即使慢点儿,因为通过各种手段绕个弯路回来效果不一定就有单纯地使用数据库效果好。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章