(三)Redis &分布式锁
1 Redis使用中的常见问题和解决办法
1.1 缓存穿透
定义:缓存系统都是按照key去缓存查询,如果不存在对应的value,就应该去DB查找。一些恶意的请求会故意查询不存在的key,请求量很大,就会对DB造成很大的压力,甚至压垮数据库。
解决方案:对查询结果为空的情况也进行缓存,TTL设置短一点。
1.2 缓存雪崩
定义:在某个时间点,缓存中的key集体发生过期失效致使大量查询数据库的请求都落在DB上,导致DB负载过高、压力暴增,甚至可能压垮数据库
解决方案:该问题产生的原因在于大量的key在某个时间点或者某个时间段失效导致的,为了更好的避免这种问题的发生,一般更好的做法是为这些key设置不同的、随机的TTL(过期失效时间),从而错开缓存中的key的失效时间点。
1.3 缓存击穿
定义:缓存中某个频繁被访问的key(也称为热点key),在不停的扛着前端的高并发请求,当这个key突然在某个瞬间过期失效时,持续的高并发请求就击穿缓存,直接请求数据库,导致数据库压力在某一瞬间暴增。
解决方案:出现这个问题的原因在于热点的key过期失效了,而在实际情况中,既然这个key可以被作为热点频繁访问,那么就应该设置这个Key永不过期,这样前端的高并发请求将几乎永远不会落在数据库上。
2 高并发问题&Redis解决方案
在传统的单体Java应用中,为了解决多线程的并发安全问题,最常见的做法是在核心的业务逻辑代码中加锁操作进行同步控制如synchronized关键字,然而在微服务、分布式系统架构时代,这种做法是行不通的,因为synchronized关键字是跟单一服务节点所在的JVM相关联的,而分布式系统架构下的服务一般是部署在不同的节点(服务器)下,从而当出现高并发请求时,Synchronized同步操作将显得力不从心。因而我们需要寻找一种高效的解决方案,这种方案既要保证单一节点核心业务代码的同步控制,也要保证当扩展到多个节点部署时同样能实现核心逻辑代码的同步控制,由此分布式锁应运而生。它的出现主要是为了解决分布式系统中高并发请求时并发访问共享资源导致并发安全问题,目前关于分布式锁的实现有许多种,典型的包括基于数据库级别的乐观锁和悲观锁,以及关于Redis的原子操作实现分布式锁和基于ZooKeeper实现分布式锁等。
在Redis的知识体系和底层基础架构中,其实并没有直接提供所谓的分布式锁组件,而是间接的借助其原子操作来实现。之所以原子操作可以实现分布式锁的功能,主要是得益于Redis的单线程机制,即不管外层应用系统并发了N个线程,当每个线程都需要Redis的某个原子操作时,是需要进行排队等待的,原因在于其底层系统架构中,同一时刻、同一个部署节点中只有一个线程执行某种原子操作。
3 Redis缺陷&Redisson解决方案
3.1 分析
Redis的原子操作实现的分布式锁具有一定的缺陷,包括:
(1)执行Redis的原子操作EXPIRE时,需要设置Key的过期时间TTL,不同的业务场景设置的过期时间是不同的,但是如果设置不当,将很有可能影响系统和Redis服务的性能。
(2)采用Redis的原子操作SETNX获取分布式锁时,不具备可重入性,即当高并发产生多线程时,同一时刻只有一个线程可以获取到锁,从而操作共享资源,而其他的线程将获取锁失败,而且是永远失败下去,而有一些业务需要要求线程"可重入",则需要在应用程序里添加while(true){}的代码块,即不断的循环等待获取分布式锁,这种方式既不优雅,又很可能造成应用系统性能卡顿。典型的场景如商城有些秒杀活动中,商家为了饥饿营销会故意将库存量设置为很小,但是没货时又及时补货,因此这种情况下就要求用户秒杀过程中的分布式锁的获取具有重入性。但显然Redis不适合这类场景。
(3)在执行Redis的原子操作SETNX之后EXPIRE操作之前,如果此时Redis的服务节点发生宕机,由于Key没有及时被释放而导致最终很有可能出现死锁,即永远不会有其他的线程能够获取到锁。
Redisson分布式锁的出现能够很好的解决以上问题,Redisson提供了多种分布式锁供开发者使用,包括可重入锁、一次性锁、联锁、读写锁等等,每一种分布式锁实现方式和使用的场景各不相同,而应用较多的当属Redission的可重入锁和一次性锁。可重入锁指的是当前线程如果没有获取到对共享资源的锁,将会在允许的时间范围内等待获取,超时则放弃等待,主要通过调用tryLock()方法时进行指定。一次性锁指的是当前线程获取分布式锁时,如果成功则执行后续对共享资源的操作,否则将永远失败下去,其主要通过调用lock()方法获取锁。
3.2 一次性锁与可重入锁的实现
(1)依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.12.3</version> </dependency>
(2)application.properties配置:
redisson.host.config=redis://127.0.0.1:6379
(3)客户端实例注入:
import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; /** * 自定义注入配置操作Redisson的客户端实例 * @author huaiheng **/ @Configuration public class RedissonConfig { @Autowired private Environment environment; @Bean public RedissonClient config(){ Config config = new Config(); config.useSingleServer().setAddress(environment.getProperty("redisson.host.config")).setKeepAlive(true); return Redisson.create(config); } }
(4)一次性锁 & 可重入锁
import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import java.util.concurrent.TimeUnit; /** * 可重入锁与一次性锁框架 * * @author huaiheng */ public class RedissonLock { @Autowired private RedissonClient redissonClient; /** * 针对用户级别的一次性锁 */ public void lockOnlyOnce(String UserId) throws Exception { final String lockName = "redissionOneLock-" + UserId; RLock rLock = redissonClient.getLock(lockName); try { // 上锁后,不管何种情况,10s后主动释放 rLock.lock(10, TimeUnit.SECONDS); /** 资源操作代码部分 **/ } catch (Exception e) { throw e; } finally { if (rLock != null) { rLock.unlock(); } } } /** * 针对用户级别的可重入锁 */ public void repeatLock(String UserId) throws Exception { final String lockName = "redissionOneLock-" + UserId; RLock rLock = redissonClient.getLock(lockName); try { // 尝试获取锁,时间最长为100s,如果获取到锁,则不管何种情况,最长10s必须释放 rLock.tryLock(100, 10, TimeUnit.SECONDS); /** 资源操作代码部分 **/ } catch (Exception e) { throw e; } finally { if (rLock != null) { rLock.unlock(); } } } }
4 其他解决方案
除了Redis和Redisson提供的分布式锁的功能外,最常见的还包括数据库级别的分布锁机制,数据库的分布式锁主要包括乐观锁和悲观锁,在这里仅做原理和应用上的分析和讲解:
- 乐观锁:乐观锁是一种很佛系的实现方式,它总是认为不会产生并发问题,因为每次从数据库中获取数据时总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新数据时其会判断其他线程在之前是否对数据进行了修改(通常采用版本号version机制进行实现),如果没有进行修改则本次修改生效并更新版本号,如果已经被修改则本次修改无效,从而避免了多并发线程访问共享数据时出现数据不一致的现象,这种实现方法对应的核心SQL的伪代码写法如下所示:
update table set key=value, version=version+1 where id=#{id} and version=#{version};
- 悲观锁:悲观锁是一种消极的处理方式,它总是假设事情的发生在最坏的情况,即每次并发线程在获取数据的时候认为其他线程对数据进行了修改,因而每次在获取数据时都会上锁,而其他线程访问该数据时就会发生阻塞的现象,最终只有当前线程释放了该共享资源的锁,其他线程才能获取到锁,并对共享资源进行操作。数据库中的行锁、表锁、读锁、写锁都是这种方式,java中的synchronized和ReentrantLock也是悲观锁的思想。
应用场景分析:
- 乐观锁:由于采用version版本号机制实现,因而在高并发产生多线程时,同一时刻只有一个线程能够获取到锁并成功操作共享资源,而其他线程将获取失败并且是永远失败下去,从这个角度来看,这种方式虽然可以控制并发线程对共享资源的访问,但是却牺牲了系统的吞吐性能,因此适用于读多写少的场景。
- 悲观锁:由于建立在数据库底层搜索引擎的基础之上,并采用select... for update方式对共享资源加锁,因而当产生高并发多线程请求,特别是读请求时,将对数据库的性能带来更严重的影响,特别是同一时刻产生的多线程中将只有一个线程能够获取到锁,而其他线程将处于阻塞的状态,直到该线程释放了锁,从这一角度来看,基于数据级别的悲观锁适用于并发量不大的情况,特别是读请求数据量不大的情况,因此适用于读少写多的场景。