近年来,Redis凭借其出色的性能、稳定性和高扩展性,基本成为了互联网行业缓存中间件的标配,甚至很多传统行业也在使用Redis。那么我们在使用Redis等缓存中间件的时候,应该注意哪些问题呢?这篇文章我们就来说说我们在使用缓存中间件过程中遇到的坑吧!缓存穿透先看一个常见的缓存使用方法。当有请求到来时,首先检查缓存,如果缓存中有值则直接返回;如果缓存没有值,则查询数据库,然后将数据库中的值保存到缓存中,然后返回。如果缓存没有找到某个值,那么数据库就没有这个值,也就是说要查的值根本不存在,会导致每次查询这个值的请求都会穿透到数据库。这称为“缓存穿透”。如何避免缓存穿透?如果从数据库中查不到值,可以在缓存中记录一个空值,避免“缓存穿透”。并为这个空值设置一个较短的过期时间。比如我们经常在Redis中缓存用户信息。如果调用者传递了一个不存在的UserID,那么用户信息在缓存中是找不到的,在DB中也找不到。这样就会导致每次根据这个UserID去查用户信息,都会渗透到数据库中,给数据库造成压力。为了避免缓存穿透,当找不到数据库的时候,我们可以在缓存中记录一个空的数据,比如userID作为key,空的json作为value。如果程序获取到这个空的json,就会认为用户不存在。然后给这个key设置一个比较短的过期时间,比如30秒。缓存雪崩我们经常会遇到需要初始化缓存的情况。比如用户系统重构时,表结构发生变化,缓存信息也发生变化。上线前需要初始化缓存,将用户信息批量存储在缓存中。如果我们对这些用户信息设置相同的过期时间,所有用户信息的缓存记录会在过期时间同时失效,导致瞬间大量请求打到数据库,数据库很可能被挂断。这种集中式缓存失效导致大量请求同时渗透到数据库,也就是所谓的“雪崩效应”。因此,我们在向缓存初始化数据时,必须保证每条缓存记录的过期时间是离散的。可以使用一个大的固定值加上一个小的随机值。比如过期时间可以是:10小时+0到3600秒之间的一个随机值。缓存并发当系统并发度高,缓存数据特别是热点数据过期时,可能会有多个请求同时访问数据库并设置缓存,不仅对数据库造成压力,还会造成缓存更新频繁的问题.我们可以通过加锁来避免缓存并发问题。如果从缓存中找不到数据,则对查询数据加分布式锁,然后查询数据库,将数据库查询结果放入缓存中。其他线程等待锁释放后,直接从缓存中取值。比如电商系统会缓存商品SKU价格,一些热门商品的并发访问量会很高。当缓存过期,访问请求无法从缓存中找到记录时,可以以商品SKUID为key添加分布式锁,然后从数据库中查询价格并将价格放入缓存中,最后解锁.解锁后其他请求可以直接从缓存中取值。从而避免了对数据库的压力。分布式锁以我们之前做的5人团战为例。如果用户参与团购,我们需要验证参与人数是否达到上限5人。如果少于5人,用户可以加入群组。伪代码如下://根据pintuanID,获取当前参与成员数intnumOfMembers=pinTuanService.getNumOfMembersById(pinTuanID);if(numOfMembers<5){pinTuanService.pintuan();//执行,添加到拼团、创建订单等逻辑}在高并发场景下,上面的代码会出现严重的问题。如果一个群组当前的参与者人数为4,则有两个用户同时加入该群组。用户A和用户B的请求同时进入上面的代码块,A和B的请求同时执行到第二行代码。numOfMembers都是4,表达式numOfMembers<5成立,所以两个用户都可以执行到第4行的代码,也就是说用户A和用户B都可以成功加入群组。结果,参加人数超过了5人的上限。所以我们需要加锁来避免这个问题。同步好吗?决不。因为我们的服务部署在多个节点上,所以需要加分布式锁。代码如下:booleanaquired=distributedLock.aquireLock(pinTuanID,3000);if(aquired==true){try{//根据群ID获取当前参与成员数intnumOfMembers=pinTuanService.getNumOfMembersById(pinTuanID);if(numOfMembers<5){pinTuanService.pintuan();//执行,加入团战,生成命令等}}finally{distributedLock.releaseLock(pinTuanID);}}这样就好多了!接下来我们看一下基于Redis的分布式锁的实现,以及需要特别注意的问题。一般我们会基于setnx来实现Redis分布式锁。setnx命令可以检查key是否存在。如果key不存在,则在Redis中创建键值对(操作成功),如果key已经存在,则放弃执行(操作失败)。先看一段基于Springboot的加锁和释放锁的代码:@ComponentpublicclassDistributedLock{@AutowiredprivateStringRedisTemplateredisTemplate;/***lock*lockKey,rediskey*expireTime,过期时间,单位毫秒*注:setIfAbsent方法使用redissetnx*/publicbooleanaquireLock(StringlockKey,longexpireTime){longwaitTime=0;booleansuccess=redisTemplate.opsForValue().setIfAbsent(lockKey,"distributedLock",expireTime,TimeUnit.MILLISECONDS);if(success==true){returnsuccess;}else{//如果加锁失败,循环重试加锁while(success!=true&&waitTime<5000L){success=redisTemplate.opsForValue().setIfAbsent(lockKey,"distributedLock",expireTime,TimeUnit.MILLISECONDS);sleep100毫秒;waitTime+=100L;}}returnsuccess;}/***释放锁*lockKey,rediskey*/publicvoidreleaseLock(StringlockKey){redisTemplate.delete(lockKey);}}上面的代码。乍一看好像没什么问题!如果加锁失败,有循环重试加锁,并设置过期时间,同时也保证了创建Key-Value键值对和设置过期时间的原子性,这样当程序没有释放正常加锁,也可以保证锁过期后自动释放(注意:老版本的redis不支持setnx和设置过期时间的原子操作,但是可以使用lua脚本来保证原子性)。我们再考虑一下。一般场景下,我们都会给key设置一个很短的过期时间。当由于网络等原因导致操作耗时较长时,key会在操作完成前过期。这样会出现什么问题?下面以加入群组为例进行说明。我们先来看下图:如上图所示,用户A和用户B同时加入了同一个群组。锁的key,“distributedLock”作为固定值,过期时间为5秒。A先获取到分布式锁,但是由于网络等原因,A的入组操作在5秒内没有完成。此时Key过期,从Redis中清除,A的分布式锁失效。此时用户B拿到分布式锁,Key也是组ID001。在用户B的入组逻辑执行完成之前,先执行用户A的逻辑,然后A释放锁。但是A的锁已经过期了,B持有的key和A的完全一样,所以此时A释放的其实是B的锁。这样一来,整个团可能还是人满为患。如何解决?我们可以将分布式锁的Value设置为一个可以区分的值。例如,入群场景的值可以设置为userID。释放锁时,可以根据key和value判断当前锁是否是自己的。Redis中的userID和自己的userID一致,来释放锁。改进后的代码如下:@ComponentpublicclassDistributedLock{@AutowiredprivateStringRedisTemplateredisTemplate;/***lock*lockKey,rediskey*expireTime,过期时间,单位毫秒*注:setIfAbsent方法使用redissetnx*/publicbooleanaquireLock(StringlockKey,StringuserID,longexpireTime){longwaitTime=0;booleansuccess=redisTemplate.opsForValue().setIfAbsent(lockKey,userID,expireTime,TimeUnit.MILLISECONDS);if(success==true){returnsuccess;}else{//如果加锁失败,循环重试加锁while(success!=true&&waitTime<5000L){success=redisTemplate.opsForValue().setIfAbsent(lockKey,userID,expireTime,TimeUnit.MILLISECONDS);sleep100毫秒;waitTime+=100L;}}returnsuccess;}/***释放锁*lockKey,rediskey*/publicvoidreleaseLock(StringlockKey,StringuserID){StringuserIDFromRedis=redisTemplate.get(lockKey);if(userID.equals(userIDFromRedis)){redisTemplate.delete(lockKey);}}}还有一个场景需要经过考虑的。当RedisMaster发生故障时,主备切换往往会造成数据丢失,包括分布式锁的Key-Value也可能丢失。这会导致锁在操作完成之前被其他请求获取。Redis官方提供了Redlock算法和对应的开源实现Redisson。在使用分布式锁的场景下,可以直接使用Redisson,非常方便。如果系统对可靠性要求高,需要使用分布式锁,建议使用Zookeeper,etcd等。
