redis-缓存架构并发问题分析- 未完成
Redis缓存架构以及线上问题分析publicProductupdate1(Productproduct){ProductproductResultproductDao.update(product);redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHEproductResult.getId(),JSON.toJSONString(productResult));returnproductResult;}publicProductget1(LongproductId)throwsInterruptedException{Productproductnull;StringproductCacheKeyRedisKeyPrefixConst.PRODUCT_CACHEproductId;StringproductStrredisUtil.get(productCacheKey);if(!StringUtils.isEmpty(productStr)){productJSON.parseObject(productStr,Product.class);returnproduct;}productproductDao.get(productId);if(product!null)redisUtil.set(productCacheKey,JSON.toJSONString(product));returnproduct;}上述代码问题所有数据都缓存至redis内存缓存存储容量浪费高频访问数据数据少在电商系统中热点数据与冷门数据比例差距很大如京东商品非常多但热点商品少冷门数据多。上述代码会将所有数据都进行缓存优化方式尽可能将热点数据放入缓存冷门数据不放。大规模商品缓存数据冷热分离实战解决商品维护进缓存时添加过期时间冷门数据最多存活过期时间内经常访问的数据每次查询时进行缓存过期时间延期读延期简单数据冷热分离读延期publicProductupdate1(Productproduct){ProductproductResultproductDao.update(product);redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHEproductResult.getId(),JSON.toJSONString(productResult),PRODUCT_CACHE_TIMEOUT,TimeUnit.SECONDS);//读延期returnproductResult;}publicProductget1(LongproductId)throwsInterruptedException{Productproductnull;StringproductCacheKeyRedisKeyPrefixConst.PRODUCT_CACHEproductId;StringproductStrredisUtil.get(productCacheKey);if(!StringUtils.isEmpty(productStr)){productJSON.parseObject(productStr,Product.class);//读延期redisUtil.expire(productCacheKey,PRODUCT_CACHE_TIMEOUT,TimeUnit.SECONDS);returnproduct;}productproductDao.get(productId);if(product!null)redisUtil.set(productCacheKey,JSON.toJSONString(product),PRODUCT_CACHE_TIMEOUT,TimeUnit.SECONDS);returnproduct;}问题如果电商平台批量导入大量商品上千万当前代码设置的缓存key过期时间相同会导致大批量商品同时到期缓存同时失效此时大量请求访问这些商品直接到达数据库进行查询造成数据瞬间压力过大甚至直接宕机。缓存击穿缓存击穿/缓存失效场景由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库可能会造成数据库瞬间压力过大甚至挂掉。此时可以在批量添加缓存时给这批数据的缓存时间设置为一个时间段内的随机时间解决方式添加随机时间privateIntegergenProductCacheTimeout(){returnPRODUCT_CACHE_TIMEOUTnewRandom().nextInt(5)*60*60;//根据业务系统设置随机过期时间}缓存穿透缓存穿透指查询一个根本不存在的数据 缓存层和存储层都不会命中。 如 -秒杀商品时如商品信息意外从系统删除缓存与数据库中都被删除。此时大量请求查询缓存后在查询数据库都没有数据。 -黑客攻击时访问一个不存在商品id解决方式-缓存空对象并设置过期时间防止多次访问。 -布隆过滤器某个值存在时这个值可能不存在当它说不存在时那就肯定不存在。缓存空对象代码publicstaticfinalStringEMPTY_CACHE{};publicProductget1(LongproductId)throwsInterruptedException{Productproductnull;StringproductCacheKeyRedisKeyPrefixConst.PRODUCT_CACHEproductId;StringproductStrredisUtil.get(productCacheKey);if(!StringUtils.isEmpty(productStr)){if(EMPTY_CACHE.equals(productStr)){//空缓存延期防止多次访问进行缓存重构。redisUtil.expire(productCacheKey,60newRandom().nextInt(30),TimeUnit.SECONDS);returnnull;// 返回空与返回商品区分}productJSON.parseObject(productStr,Product.class);redisUtil.expire(productCacheKey,genProductCacheTimeout(),TimeUnit.SECONDS);returnproduct;}productproductDao.get(productId);if(product!null){redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);}else{//放空缓存。。TTL防止大量空缓存信息放入缓存redisUtil.set(productCacheKey,EMPTY_CACHE,60newRandom().nextInt(30),TimeUnit.SECONDS);}returnproduct;}突发性热点缓存重建导致系统压力暴增问题分析当存在某个冷门商品缓存已经过期突然变成热点数据需要重构缓存时大量请求进行访问所有请求会直接访问至数据库。造成数据库性能下降从轻微延迟到完全崩溃。解决方式添加分布式锁 并且 使用 DCL 双重检测查询publicstaticfinalStringLOCK_PRODUCT_HOT_CACHE_PREFIXlock:product:hot_cache:;publicProductget1(LongproductId)throwsInterruptedException{Productproductnull;StringproductCacheKeyRedisKeyPrefixConst.PRODUCT_CACHEproductId;productgetProductFromCache1(productCacheKey);if(product!null){// 返回空对象与存在商品returnproduct;}//添加分布式锁解决热点缓存并发重建问题无缓存时只有持有锁请求进行缓存构建RLockhotCreateCacheLockredisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIXproductId);hotCreateCacheLock.lock();try{productgetProductFromCache1(productCacheKey);if(product!null){returnproduct;}productproductDao.get(productId);if(product!null){redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);}else{redisUtil.set(productCacheKey,EMPTY_CACHE,60newRandom().nextInt(30),TimeUnit.SECONDS);}}finally{hotCreateCacheLock.unlock();}returnproduct;}//重复代码提出privateProductgetProductFromCache1(StringproductCacheKey){Productproductnull;StringproductStrredisUtil.get(productCacheKey);if(!StringUtils.isEmpty(productStr)){if(EMPTY_CACHE.equals(productStr)){//空缓存延期防止多次访问。redisUtil.expire(productCacheKey,60newRandom().nextInt(30),TimeUnit.SECONDS);returnnewProduct();// 返回空对象商品不存在与返回商品区分与返回null区分}productJSON.parseObject(productStr,Product.class);//读延期redisUtil.expire(productCacheKey,genProductCacheTimeout(),TimeUnit.SECONDS);}returnproduct;}代码问题try catch 代码块获取缓存为空继续执行开始同步重构缓存数据 可能会出现缓存与数据库双写不一致问题。如果此时线程执行到此处获取数据库数据进行缓存重建同时存在另一个线程调用update方法执行数据更新此时可能造成缓存与数据库双写不一致问题。Redis分布式锁解决缓存与数据库双写不一致问题双写不一致线程3执行查数据库stock10 与更新缓存中间时出现异常执行延迟线程2此时执行写数据库并更新缓存线程3恢复后又继续执行更新缓存导致数据库与缓存之间数据不一致。如下图所示读写并发不一致线程3执行查数据库stock10 与更新缓存中间时出现异常执行延迟线程2此时执行写数据库并删除缓存线程3恢复后又继续执行更新缓存导致数据库与缓存之间数据不一致。如下图所示解决方式Redis分布式锁publicstaticfinalStringLOCK_PRODUCT_UPDATE_PREFIXlock:product:update:;publicProductget2(LongproductId)throwsInterruptedException{Productproductnull;StringproductCacheKeyRedisKeyPrefixConst.PRODUCT_CACHEproductId;productgetProductFromCache1(productCacheKey);if(product!null){returnproduct;}RLockhotCreateCacheLockredisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIXproductId);hotCreateCacheLock.lock();try{productgetProductFromCache1(productCacheKey);if(product!null){returnproduct;}// 解决缓存与数据库双写不一致问题 锁要一致RLockproductUpdateLockredisson.getLock(LOCK_PRODUCT_UPDATE_PREFIXproductId);productUpdateLock.lock();try{productproductDao.get(productId);if(product!null){redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);}else{redisUtil.set(productCacheKey,EMPTY_CACHE,60newRandom().nextInt(30),TimeUnit.SECONDS);}}finally{productUpdateLock.unlock();}}finally{hotCreateCacheLock.unlock();}returnproduct;}publicProductupdate1(Productproduct){ProductproductResultnull;// 锁要一致RLockproductUpdateLockredisson.getLock(LOCK_PRODUCT_UPDATE_PREFIXproduct.getId());productUpdateLock.lock();try{productResultproductDao.update(product);redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHEproductResult.getId(),JSON.toJSONString(productResult),genProductCacheTimeout(),TimeUnit.SECONDS);}finally{productUpdateLock.unlock();}returnproductResult;}代码臃肿问题查询没有缓存是代码执行路径很长需要查询到数据库。但实际可能只有1%的热点数据,经常被访问。绝大数请求查询到缓存就直接返回了。并且热点数据存在读延期。用大量代码解决小概率事件。但90%的场景只会执行一小块代码效率依然高。分布式锁优化1 电商网站读多写少场景。可以使用分布式读写锁publicProductget3(LongproductId)throwsInterruptedException{Productproductnull;StringproductCacheKeyRedisKeyPrefixConst.PRODUCT_CACHEproductId;productgetProductFromCache1(productCacheKey);if(product!null){returnproduct;}RLockhotCreateCacheLockredisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIXproductId);hotCreateCacheLock.lock();try{productgetProductFromCache1(productCacheKey);if(product!null){returnproduct;}// 解决缓存与数据库双写不一致问题 读锁多个请求同时读 可以同时加锁同步执行RReadWriteLockreadWriteLockredisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIXproductId);RLockrLockreadWriteLock.readLock();rLock.lock();try{productproductDao.get(productId);if(product!null){redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);}else{redisUtil.set(productCacheKey,EMPTY_CACHE,60newRandom().nextInt(30),TimeUnit.SECONDS);}}finally{rLock.unlock();}}finally{hotCreateCacheLock.unlock();}returnproduct;}publicProductupdate(Productproduct){ProductproductResultnull;RReadWriteLockreadWriteLockredisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIXproduct.getId());RLockwriteLockreadWriteLock.writeLock();writeLock.lock();try{productResultproductDao.update(product);redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHEproductResult.getId(),JSON.toJSONString(productResult),genProductCacheTimeout(),TimeUnit.SECONDS);}finally{writeLock.unlock();}returnproductResult;}分布式读写锁当读请求并行执行锁重入 1 读写锁加锁判断逻辑先判断锁模式mode 如果是read 则重入次数1 如果是write 等待锁释放排队执行。 当写锁被持有时后续的读锁请求会等待写锁释放 当读锁被持有时后续的写锁请求会等待所有读锁释放 根据数据库读写操作确定使用读或写锁2 锁优化 - 阻塞转有限等待当有上万线程重建缓存时存在大量线程排队除第一线程重构缓存外其他线程都时经过加锁读取redis缓存数据返回数据此时可以使用 boolean tryLock(long time, TimeUnit unit) 优化time时间内未加锁成功返回false。如果可以确定第一个线程重构缓存执行时间设置线程的尝试加锁等待时间预估缓存重建时间 缓冲时间不在锁上无限等待失败后直接去执行超时后的重试逻辑。减少线程阻塞/唤醒的开销避免无意义的锁等待队列让大部分线程快速进入等待重试的逻辑。11、超大规模访问导致系统崩溃假如出现热点事件达到上亿规模redis并发基本只能达到十万无法扛住超大规模访问 可能直接打垮redis,并且导致系统其他环节陆续崩溃最终导致整个服务瘫痪。缓存雪崩缓存雪崩指的是缓存层支撑不住或宕掉后 流量会像奔逃的野牛一样 打向后端存储层。解决方式1限流 针对redis最高访问做限流 2多加一层缓存/多级缓存架构。 - jvm进程级别缓存框架 Ehcache、Caffeine - 代码层面使用jvm内存缓存将热点商品放入缓存。 - jvm内存级别可以抗住百万级并发。同时集群部署时存在多个节点分担流量。 - redis只有一个集群jvm进程缓存可以在多个节点同步分担压力在代码层面使用jvm内存缓存方式设置redis缓存时同时给jvm级设置缓存获取缓存优先从jvm级缓存获取。// map有容量限制实际使用时不可取缓存必须有限制这是生产系统的基本要求。publicstaticMapString,ProductproductMapnewConcurrentHashMap();// jvm进程级缓存privateProductgetProductFromCache1(StringproductCacheKey){Productproductnull;productproductMap.get(productCacheKey);if(product!null){// 直接查询jvm进程缓存returnproduct;}StringproductStrredisUtil.get(productCacheKey);if(!StringUtils.isEmpty(productStr)){if(EMPTY_CACHE.equals(productStr)){redisUtil.expire(productCacheKey,60newRandom().nextInt(30),TimeUnit.SECONDS);returnnewProduct();}productJSON.parseObject(productStr,Product.class);redisUtil.expire(productCacheKey,genProductCacheTimeout(),TimeUnit.SECONDS);}returnproduct;}publicProductget5(LongproductId)throwsInterruptedException{Productproductnull;StringproductCacheKeyRedisKeyPrefixConst.PRODUCT_CACHEproductId;productgetProductFromCache(productCacheKey);if(product!null){returnproduct;}RLockhotCacheLockredisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIXproductId);hotCacheLock.lock();try{productgetProductFromCache(productCacheKey);if(product!null){returnproduct;}RReadWriteLockreadWriteLockredisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIXproductId);RLockrLockreadWriteLock.readLock();rLock.lock();try{productproductDao.get(productId);if(product!null){redisUtil.set(productCacheKey,JSON.toJSONString(product),genProductCacheTimeout(),TimeUnit.SECONDS);// 设置jvm进程级缓存productMap.put(productCacheKey,product);}else{redisUtil.set(productCacheKey,EMPTY_CACHE,genEmptyCacheTimeout(),TimeUnit.SECONDS);}}finally{rLock.unlock();}}finally{hotCacheLock.unlock();}returnproduct;}问题- 内存泄漏map缓存不及时清除可能导致内存泄漏最终内存溢出 - web应用集群部署存在多个web应用节点时一个请求只会访问单个节点导致只有单个节点有缓存数据。其他节点jvm内存无此次请求的缓存数据。解决使用mp或zk 发送到其他节点。但可能出现短时间数据不一致。 备注一般来说不建议直接在增删改查代码中进行mp操作jvm进程级缓存应该存储热点中的热点商品并且需要实时维护所以可以通过外部系统如热点缓存计算系统单独对jvm进程级缓存进行维护然后通知web应用更新本地jvm进程级缓存。