咖啡汪日誌——實際開發中如何避免快取穿透和快取雪崩(程式碼示例實際展示)

咖啡汪發表於2020-12-01

本汪作為一名資深的哈士奇
每天除了閒逛,拆家,就是啃部落格了
作為不是在戲精,就是在戲精的路上的二哈
今天就來給大家說說在實際工作中,如何預防快取穿透

一、 開篇有益

1、什麼是快取穿透?
通常快取系統,都是按照key去進行快取查詢,如果不存在對應的value,就應該去資料庫查詢。一些惡意的請求會故意大量查詢不存在的key(例如使用“-1”,“#”,或者UUID生成100萬個Key進行查詢),就會對資料庫造成很大的壓力。我們把這種情況稱之為快取穿透。

2.什麼是快取雪崩?
快取雪崩(快取失效)的兩個原因,從廣義上來講:

第一種,快取系統本身不可用,導致大量請求直接回源到資料庫
第二種,應用層面大量的 Key 在同一時間過期,導致大量的資料回源

3、快取穿透有什麼具體的防護方法?
(1)採用布隆過濾器,將所有可能存在的資料存到一個bitMap中,不存在的資料就會進行攔截。
(2)對查詢結果為空的情況也進行快取,快取時間設定短一點,不超過5分鐘。

4、如何有效避免快取雪崩?(失效時間擾動)
確保大量 Key , 不在同一時間過期:
簡單方案:差異化快取過期時間,不讓大量 Key 在同一時間過期。比如,在初始化快取的時候,設定快取的過期時間為 應設過期時間30min + 30秒以內的隨機延遲(擾動值)。(待支付訂單的有效時間均為30min,網際網路企業定律) ,這樣,這些 Key 不會在 30min 這個時刻過期,而是分散在 30min ~ 30min+30second 之間過期。

在這裡插入圖片描述

二、大家隨本汪,一起來看看實際工作中的程式碼實現

1、布隆過濾器,資料預載入,預防快取穿透。
(1)在基礎controller引入了bloomfilter

/**
 * @author Yuezejian
 * @date 2020年 08月22日 16:04:01
 */
public class AbstractController {

    protected final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 通用的基礎過濾器,如有使用,請在此註明
     * 1.BloomFilter 內資料需要預熱(固定時間內有效token的過濾,正在使用此過濾器)
     * The current three parties request the current system and need to obtain the token,
     * which is using the filter {@Modified by yuezejian }
     */
    protected static final BloomFilter<String> filter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1000,0.01);

}

(2)專案啟動進行資料載入


import com.slzhkj.smartworksite.model.dto.RequestDto;
import com.slzhkj.smartworksite.model.mapper.RequestMapper;
import com.slzhkj.smartworksite.server.controller.AbstractController;
import com.slzhkj.smartworksite.server.controller.ResponseController;
import com.slzhkj.smartworksite.server.controller.TokenAndSignController;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.env.Environment;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * This loader is used for data preheating of redis and bloom filter.
 * The current preheating data mainly includes:
 * The first, Three parties request obtain time effective token {@link TokenAndSignController },
 * When the bloom filter and redis are loaded,
 * they need to be updated when I start the system
 * The second,Three party request for data access,
 * we judge the token {@link ResponseController} if it's enable
 *
 * @author Yuezejian
 * @date 2020年 11月 10日 14:00:18
 */
@Component
public class RedisAndBloomFilterRecordRunner extends AbstractController implements ApplicationRunner, Ordered {

    @Autowired
    RequestMapper requestMapper;

    @Autowired
    RedisTemplate redisTemplate;

    @Autowired
    private Environment env;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        ValueOperations<String,String> tokenOpera = redisTemplate.opsForValue();
        List<RequestDto> requestDtos = requestMapper.selectTokenRecord();
        AtomicInteger count = new AtomicInteger(0);
        requestDtos.stream()
                .parallel()
                .forEach( dto -> {
                    filter.put(dto.getToken());
                    filter.put(dto.getAppId());
                    if (filter.mightContain(dto.getToken())) {
                        count.getAndAdd(1);
                    }
                    String key = dto.getAppId();
                    //TODO:- Math.abs((System.currentTimeMillis() - dto.getUpdateTime().getTime())/60000 )
                    tokenOpera.set(key,dto.getToken(),env.getProperty("token.enable.time",Long.class), TimeUnit.MINUTES);
                } );
        logger.info("==========total is "+ count +", The data preloading of redis and bloom filter is completed!===========");
    }

    /**
     * Makes the current class load with a higher priority
     * @return
     */
    @Override
    public int getOrder() {
        return 2;
    }
}

(3)在合適的時機,將新資料加入bloomfilter

 private void updateRedisAndBloomFilter(String appId, ValueOperations<String, String> tokenOpera,
                                           String token, int res) {
        if ( res > 0 ) {
            //TODO: update redis ,insert BloomFilter
            String key = appId;
            tokenOpera.set(key,token,env.getProperty("token.enable.time",Long.class), TimeUnit.MINUTES);
            filter.put(token);
            filter.put(appId);
            logger.info("appId為:{} 的使用者,更新了token: {}, 已存入Redis和基類布隆過濾器", appId, token);
        } else {
            logger.error("appId為:{} 的使用者,更新了token: {}, Database update success, but Redis or BloomFilter update fail!",appId,token);
            throw new IllegalStateException("Database update success, but Redis or BloomFilter update fail!");
        }
    }

2、設定無效資料型別,預防快取穿透。每當發生快取穿透時,即快取和資料庫都沒有該條資料,在資料庫返回 null 後,也應該在快取中放置相應得 無效型別返回。

在Coupon 類中,加一個獲取ID 為“-1”的無效coupon 方法

 /**
     * <h2>返回一個無效的 Coupon 物件</h2>
     * */
    public static Coupon invalidCoupon() {

        Coupon coupon = new Coupon();
        coupon.setId(-1);
        return coupon;
    }
    /** redis 客戶端,redis 的 key 肯定是 String 型別,而 StringRedisTemplate 是 value 也都是 String 的一個簡化 */
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * save List<Coupon> which are null to Cache
     * 目的: 避免快取穿透
     * @param userId user id
     * @param status coupon status
     */
    @Override
    public void saveEmptyCouponListToCache(Long userId, List<Integer> status) {
        log.info("Save empty list to cache for user: {}, status: {}",userId, JSON.toJSONString(status));
        Map<String, String> invalidCouponMap = new HashMap<>();
        invalidCouponMap.put("-1",JSON.toJSONString(Coupon.invalidCoupon()));
        //使用 SessionCallback 把資料命令放入到 Redis 的 pipeline
        //redis 的 pipeline 可以讓我們一次性執行多個命令,統一返回結果,而不用每一個命令去返回;
        //redis 本身是單程式單執行緒的,你傳送一個命令,他給你一個返回,然後你才可以發生下一個命令給他。
        // 單執行緒指的是網路請求模組使用了一個執行緒(所以不需考慮併發安全性),即一個執行緒處理所有網路請求,其他模組仍用了多個執行緒
        //我們都知道Redis有兩種持久化的方式,一種是RDB,一種是AOF。
        //拿RDB舉例,執行bgsave,就意味著 fork 出一個子程式在後臺進行備份。
        //這也就為什麼執行完bgsave命令之後,還能對該Redis例項繼續其他的操作。
        SessionCallback<Object> sessionCallback = 
        new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) 
            throws DataAccessException {
               status.forEach( s -> {
    //TODO: 把使用者 ID 和 優惠券使用 status 進行拼接,作為 redisKey
                    String redisKey = status2RedisKey(s,userId);
                    operations.opsForHash()
                    .putAll(redisKey,invalidCouponMap);
               });
                return null;
            }
        };
        log.info("Pipeline exe result: {}", 
        JSON.toJSONString(sessionCallback));

    }
    
    

  /**
     * Get Redis Key According to  status
     * @param status
     * @param userId
     * @return
     */
    private String status2RedisKey(Integer status, Long userId) {
        String redisKey = null;
        CouponStatus couponStatus = CouponStatus.of(status);
        switch (couponStatus) {
            case USABLE:
                redisKey = String.format("%s%s", Constant.RedisPrefix.USER_COUPON_USABLE, userId);
                break;
            case USED:
                redisKey = String.format("%s%s",Constant.RedisPrefix.USER_COUPON_USED,userId);
                break;
            case EXPIRED:
                redisKey = String.format("%s%s",Constant.RedisPrefix.USER_COUPON_EXPIRED,userId);
        }
        return redisKey;
    }

當查詢快取沒有時,設定快取失效資料,返回空;有時,直接反序列化然後返回(存的時候,是coupon 的 JSON.toJSONString(coupon))

import org.apache.commons.collections4.CollectionUtils;

 @Override
    public List<Coupon> getCacheCoupons(Long userId, Integer status) {
        log.info("Get Coupons From Cache: {}, {}", userId, status);
        String redisKey = status2RedisKey(status,userId);
        List<String> couponStrs = redisTemplate
        .opsForHash().values(redisKey)
                .stream().map( o -> Objects.toString(o,null))
                .collect(Collectors.toList());
        if (CollectionUtils.isEmpty(couponStrs)) {
            saveEmptyCouponListToCache(userId, 
            Collections.singletonList(status));
            return Collections.emptyList();
        }
        return couponStrs.stream().map(
        cs -> JSON.parseObject(cs,Coupon.class))
        s.collect(Collectors.toList());
    }

3、時間擾動,預防快取雪崩
以秒為單位時,getRandomExpirationTime(1,2) 會返回1~2小時之間的隨機時間
redisTemplate.expire(redisKey, getRandomExpirationTime(1,2) , TimeUnit.SECONDS);

 /**
     *  get one Random Expiration Time
     *  快取雪崩:key 在同一時間失效
     * @param min 最小小時數
     * @param max 最大小時數
     * @return 返回 【min, max】之間的隨機秒數
     */
    private long getRandomExpirationTime(Integer min, Integer max) {
        return RandomUtils.nextLong(min * 60 * 60, max * 60 * 60);
    }
    

/**
     * insert coupon to Cache
     * @param userId
     * @param coupons
     * @return
     */
    private Integer addCouponToCacheForUsable(Long userId, List<Coupon> coupons) {
        // 如果 status 是 USABLE, 代表是新增的優惠券
        // 只會影響到一個 cache : USER_COUPON_USABLE
        log.debug("Add Coupon To Cache For Usable");
        Map<String, String> needCacheObject = new HashMap<>();
        coupons.forEach( coupon -> {
            needCacheObject.put(coupon.getId().toString(),JSON.toJSONString(coupon));
        });
        String redisKey = status2RedisKey(
                CouponStatus.USABLE.getCode(), userId
        );
        //TODO: redis 中的 Hash key 不能重複,needCacheObject 對應HashMap ,
        // coupon id 不可能重複,所以直接 putAll 不會有問題
        redisTemplate.opsForHash().putAll(redisKey, needCacheObject);
        log.info("Add {} Coupons TO Cache: {} , {}", needCacheObject.size(), userId, redisKey);
        //TODO: set Expiration Time, 1h - 2h ,random time
        redisTemplate.expire(redisKey, getRandomExpirationTime(1,2) , TimeUnit.SECONDS);
        return needCacheObject.size();
    }



相關文章