對於大多數高併發場景,都是讀多寫少。比如商品信息,醫生掛號信息等。提交訂單頁只有一個操作。
對於一個普通的緩存架構設計,實現商品的增刪改查功能,代碼如下:
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:緩存數據庫雙寫不一致
問題描述:
- 線程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:一隻阿木木