生產級Redis 高併發分佈式鎖實戰2:緩存架構設計問題優化

對於大多數高併發場景,都是讀多寫少。比如商品信息,醫生掛號信息等。提交訂單頁只有一個操作。

對於一個普通的緩存架構設計,實現商品的增刪改查功能,代碼如下:

Controller 層

@RestController("/api/product")
public class ProductController{
    @Autowired
    private ProductService productService;
    
    @RequestMapping(value="/add",method=RequestMethod.POST)
    public Product addProduct(@RequestBody Product productParam){
        return productService.addProduct(productParam);
    }
    
    @RequestMapping(value="/update",method=RequestMethod.POST)
    public Product updateProduct(@RequestBody Product productParam){
        return productService.updateProduct(productParam);
    }
    
    @RequestMapping(value="/get/{productId}")
    public Product getProduct(@PathVariable Long productId){
        return productService.getProduct(productId);
    }
}

Service層


@Autowired
private RedisUtil redisUtil;

@Autowired
private Redisson redisson;

public static final Integer PRODUCT_CACHE_TIMEOUT = 60 * 60 * 24;

@Transactional
public Product addProduct(Product product){
    Product productResult = productDao.addProduct(product);
    
    redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(),JSON.toJSONString(productResult)   );
    return productResult;
}

@Transactional
public Product updateProduct(Product product){
    Product productResult = productDao.updateProduct(product);
    
    redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(),JSON.toJSONString(productResult)   );
    return productResult;
}


public Product getProduct(Long productId){
    
    Product product = null;
    
    String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
    
    String productStr = redisUtil.get(productCacheKey);
    
    if(!StringUtils.isEmpty(productStr)){
        product = JSON.parseObject(productStr,Product.class);
        return product;
    }
    
    product = productDao.get(productId);
    
    if(product != null){
        redisUtil.set(productCacheKey, JSON.toJSONString(product));
    }
    return product;
}

優化架構問題1: 冷熱數據

問題分析:假設幾十億的商品,所有商品數據將存入緩存。每天需要訪問的數據,我們稱爲熱數據。僅佔內存不到 1%。熱數據放緩存,冷數據放數據庫,可以設置緩存超時時間,如24小時失效。

冷熱數據分離方案:添加緩存失效時間,做到冷熱數據分離

// 冷熱數據分離,給緩存設置過期時間
redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(),JSON.toJSONString(productResult),PRODUCT_CACHE_TIMEOUT,TimeUnit.SECONDS );

// 查到緩存,則添加過期時間。稱之爲緩存讀延期
String productStr = redisUtil.get(productCacheKey); 
if(!StringUtils.isEmpty(productStr)){
        product = JSON.parseObject(productStr,Product.class);
        
        redisUtil.expire(productCacheKey,PRODUCT_CACHE_TIMEOUT,TimeUnit.SECONDS );
        return product;
}

優化架構問題2:緩存失效

參考:緩存擊穿,緩存穿透,緩存雪崩問題分析及優化

  • 緩存擊穿:大批緩存在同一時間失效,導致大量請求同時穿透緩存直達數據庫,可能會造成數據庫瞬間壓力過大甚至掛掉。
//設置隨機時間

private Integer genProductCacheTimeOut(){
    return PRODUCT_CACHE_TIMEOUT + new Random().nextInt(5) * 60 * 60;
}

  • 緩存穿透:
    被黑客攻擊,後臺發送大量數據庫中不存在請求;
    或後臺誤操作商品在數據庫中被刪除。此時存在緩存無數據,數據庫中無數據。

或使用布隆過濾器。或設置空緩存。


    
public static final String EMPTY_CACHE = "{}";
    
public Product getProduct(Long productId){
    
    Product product = null;
    
    String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
    
    String productStr = redisUtil.get(productCacheKey);
    
    if(!StringUtils.isEmpty(productStr)){
        // 如果查到緩存是我們設置的空緩存,則直接返回。
        if(EMPTY_CACHE.equals(productStr)){
            // 設置讀延期
            redisUtils.expire(productCackeKey,genEmptyCacheTimeOut(), TimeUnit.SECONDS);
            return null;
        }
        
        product = JSON.parseObject(productStr,Product.class);
        
        redisUtil.expire(productCacheKey,genProductCacheTimeOut(),TimeUnit.SECONDS );
        return product;
    }
    
    
    product = productDao.get(productId);
    
    if(product != null){
        redisUtil.set(productCacheKey, JSON.toJSONString(product),genProductCacheTimeOut(), TimeUnit.SECONDS);
    } else {
    // 設置空緩存
        redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeOut(), TimeUnit.SECONDS);
    }
    return product;
}
    

給空緩存設置一個過期時間

//設置隨機時間

private Integer genEmptyCacheTimeOut(){
    return 60 + new Random().nextInt(30);
}

優化架構問題3:突發性熱點緩存重建

假設幾百萬用戶,幾十萬用戶請求商品鏈接。冷門商品在緩存中不存在,則請求數據庫。
假設此時幾萬請求到數據庫,執行相同的設置緩存操作,這是典型的突發性熱點緩存重建,導致系統壓力暴增問題。

DCL: double check lock 雙重檢測鎖。

方案:1 加鎖,則不會讓數據庫宕機。2 雙重檢測鎖DCL(Double Check Lock)。

  • 緩存雪崩:指緩存支撐不住或宕機後,大量請求打到存儲層,造成存儲層也會級聯宕機。

private Product getProductCache(String productCacheKey){
    Product product = null;
    String productStr = redisUtil.get(productCacheKey);
    if(!StringUtils.isEmpty(productStr)){
        // 如果查到緩存是我們設置的空緩存,則直接返回。
        if(EMPTY_CACHE.equals(productStr)){
            // 設置讀延期
            redisUtils.expire(productCackeKey,genEmptyCacheTimeOut(), TimeUnit.SECONDS);
            return null;
        }
        
        product = JSON.parseObject(productStr,Product.class);
        
        redisUtil.expire(productCacheKey,genProductCacheTimeOut(),TimeUnit.SECONDS );
        
    }
    return product;
}

public Product getProduct(Long productId){
    
    Product product = null;
    
    String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
    
    product = getProductCache(productCacheKey);
    
    if(product != null){
        return product;
    }
    
    // 雙重檢測 DCL
    sysnchronized(this){
        // 雙重檢測,當緩存重建後,直接返回。
        product = getProductCache(productCacheKey);
        if(product != null){
            return product;
        }
        
        
        product = productDao.get(productId);
        if(product != null){
            redisUtil.set(productCacheKey, JSON.toJSONString(product),genProductCacheTimeOut(), TimeUnit.SECONDS);
        } else {
        // 設置空緩存
            redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeOut(), TimeUnit.SECONDS);
        }
    }
     return product;
    
}
    

優化架構問題4:JVM鎖與分佈式鎖

商品1 和商品2 的商品鏈接互不影響, 不需要相互加互斥鎖,所以要優化爲分佈式鎖。

public Product getProduct(Long productId){
    
    Product product = null;
    
    String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
    
    product = getProductCache(productCacheKey);
    
    if(product != null){
        return product;
    }
    
    // 雙重檢測 DCL
    sysnchronized(this){
        // 雙重檢測,當緩存重建後,直接返回。
        product = getProductCache(productCacheKey);
        if(product != null){
            return product;
        }
        
        
        product = productDao.get(productId);
        if(product != null){
            redisUtil.set(productCacheKey, JSON.toJSONString(product),genProductCacheTimeOut(), TimeUnit.SECONDS);
        } else {
        // 設置空緩存
            redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeOut(), TimeUnit.SECONDS);
        }
    }
     return product;
    
}

優化分佈式鎖:

public static final String HOT_CACHE_PREFIX_LOCK = "hot_cache:lock";

public Product getProduct(Long productId){
    
    Product product = null;
    
    String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
    
    product = getProductCache(productCacheKey);
    
    if(product != null){
        return product;
    }
    
    // 雙重檢測 DCL
    RLock redissonLock = redisson.getLock(HOT_CACHE_PREFIX_LOCK + productId);
    redissonLock.lock(); // 用lua 腳本實現的setnx分佈式鎖
    
    try{
         product = getProductCache(productCacheKey);
        if(product != null){
            return product;
        }
        
        
        product = productDao.get(productId);
        if(product != null){
            redisUtil.set(productCacheKey, JSON.toJSONString(product),genProductCacheTimeOut(), TimeUnit.SECONDS);
        } else {
        // 設置空緩存
            redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeOut(), TimeUnit.SECONDS);
        }
    } finally {
       redissonLock.unlock(); 
    }

     return product;
    
}


優化架構問題5:緩存數據庫雙寫不一致

image

問題描述:

  • 線程1寫數據庫product=10, -- 更新緩存爲10.
  • 線程2 --- --- --- --- 寫數據庫product=6,更新緩存6.
  • 線程3--查緩存,爲空。查數據庫10, --- --- 更新緩存爲10.

更新緩存和刪除緩存是一樣的問題。

public static final String UPDATE_PRODUCT_LOCK = "update_product:lock";

//getProduct 加鎖

    try{
         product = getProductCache(productCacheKey);
        if(product != null){
            return product;
        }
        
       //加鎖 
        RLock updateProductLock = redisson.getLock(UPDATE_PRODUCT_LOCK+productId);
        
        updateProductLock.lock();
        
        try{
           product = productDao.get(productId);
            if(product != null){
                redisUtil.set(productCacheKey, JSON.toJSONString(product),genProductCacheTimeOut(), TimeUnit.SECONDS);
            } else {
            // 設置空緩存
                redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeOut(), TimeUnit.SECONDS);
            } 
        } finally {
            updateProductLock.unlock();
        }
    } finally {
       redissonLock.unlock(); 
    }



// update 加鎖

@Transactional
public Product updateProduct(Product product){
    Product productResult =null;
    //與查詢方法中的鎖是同一把鎖,互斥
    RLock updateProductLock = redisson.getLock(UPDATE_PRODUCT_LOCK+productId);
    updateProductLock.lock();
    
    // 串行執行,沒有併發安全問題
    try{
       productDao.updateProduct(product);
    
        redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(),JSON.toJSONString(productResult),genProductCacheTimeout(),TimeUnit.SECONDS); 
    } finally {
        updateProductLock.unlock();
    }
    
    return productResult;
}

優化架構問題6:優化緩存數據庫雙寫不一致下的分佈式鎖的優化

讀寫鎖。讀鎖的時候是共享鎖,並行執行。

// 讀鎖
        //RLock updateProductLock = redisson.getLock(UPDATE_PRODUCT_LOCK+productId);
        //updateProductLock.lock();
        
        RReadWriteLock readWriteLock = redisson.getLock(UPDATE_PRODUCT_LOCK+productId);
        RLock rLock = readWriteLock.readLock();
        rLock.lock();
        
        try{
           product = productDao.get(productId);
            if(product != null){
                redisUtil.set(productCacheKey, JSON.toJSONString(product),genProductCacheTimeOut(), TimeUnit.SECONDS);
            } else {
            // 設置空緩存
                redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeOut(), TimeUnit.SECONDS);
            } 
        } finally {
            rLock.unlock();
        }

同理,更新操作改爲寫鎖,代碼略。讀寫鎖也是基於lua 腳本實現,

優化架構問題7:空發熱點緩存環境下的分佈式鎖的優化

DCL 雙重檢測鎖。

串行轉併發思想

串行轉併發:保證第一次線程在3s 內加鎖完畢並且把邏輯走完,其他等待的幾萬的請求直接並行查緩存。

問題:假如第一個請求重建邏輯緩存需要4s,大量在等待的線程會在3s 的時候查緩存,此時緩存沒有重建完成,會導致請求到數據庫,相當於緩存失效。

// 原有DCL 的邏輯:
RLock redissonLock = redisson.getLock(""+productId);

redissonLock.lock();


優化爲:
redissonnLock.tryLock(3, TimeUnit.SECONDS);

3s 後,假設有幾萬在等待的請求,則直接並行查緩存返回。

問題:假設第一個線程4s,大量數據會擊穿緩存查數據庫,又會導致數據庫壓力爆增。要提前想好,做好權衡。

做架構,要根據業務場景,選擇更適合的技術。

系統邏輯增加,會不會也會導致系統變慢,那麼加鎖的意義又沒意義了?

如果系統沒有高併發的情況,可採用我們初始的代碼。但在大型生產環境下,就會存在併發的問題。

代碼邏輯增加,系統執行會有影響嗎?
90% 的問題得到解決,不會請求到代碼邏輯。10% 的性能用來處理小概率場景,高併發下的小概率場景是不允許出錯的。

架構的設計思想:要根據具體的業務場景來設計技術架構。

優化架構問題8:商品大促環境下的緩存血崩

多級緩存架構。此時可以加一個JVM 進程級別的緩存


public static Map<String, Product> productMap = new ConcurrentHashMap();


private Product getProductCache(String productCacheKey){
   
   // 1 先查jvm 進程級別的緩存,每秒可支持百萬併發
    Product product = productMap.get(productCacheKey);
    if (null != product){
        return product;
    }
   
   // 2 在查redis 緩存 ,redis 支持分片操作
    String productStr = redisUtil.get(productCacheKey);
    if(!StringUtils.isEmpty(productStr)){
        // 如果查到緩存是我們設置的空緩存,則直接返回。
        if(EMPTY_CACHE.equals(productStr)){
            // 設置讀延期
            redisUtils.expire(productCackeKey,genEmptyCacheTimeOut(), TimeUnit.SECONDS);
            return null;
        }
        
        product = JSON.parseObject(productStr,Product.class);
        
        redisUtil.expire(productCacheKey,genProductCacheTimeOut(),TimeUnit.SECONDS );
        
    }
    return product;
}


// 更新redis 數據時,也要更新jvm 緩存

productMap.put(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(),product);


//問題1: 如果部署多臺服務器,此時會導致jvm 緩存不一致

//問題2:map 越來越大,可能導致內存溢出。

解決了一個問題,創造了兩個問題。那麼這些問題該如何處理呢?

by:一隻阿木木

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