重学springboot系列之集群多节点应用session共享,redis分布式锁
Posted 大忽悠爱忽悠
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了重学springboot系列之集群多节点应用session共享,redis分布式锁相关的知识,希望对你有一定的参考价值。
重学springboot系列之集群多节点应用session共享,redis分布式锁
spring session 共享的实现原理
单个应用的session应用
- 用户登陆之后,将状态信息保存到session里面。服务端自动维护sessionid,即将sessionid写入cookie。
- cookie随着HTTP响应,被自动保存到浏览器端。
- 当用户再次发送HTTP请求,sessionid随着cookies被带回服务器端
- 服务器端根据sessionid,可以找回该用户之前保存在session里面的数据。
集群应用的Session共享
- 同一IP(域名),不同端口,在同一个浏览器cookies是共享的。不同IP(域名)的Cookies,在同一个浏览器Cookies肯定不共享的。对于这种情况需要在集群应用的前面加上负载均衡器逆向代理,如:nginx,haproxy。让客户端看上去访问的是同一个IP(代理IP),从而浏览器认为基于这个IP的Cookies是共享的。
- SESSION正常是由Servlet容器来维护的(内存里面,每个服务器内存是不共享的),这样SESSION就无法共享。如果希望Session共享,就需要把sessionID的存储放到一个统一的地方,如:redis。SessionID的维护交给Spring session则更加方便。
- 除了Cookies可以维持Sessionid,Spring Session还提供了了另外一种方式,就是使用header传递SESSIONID。目的是为了方便类似于手机这种没有cookies的客户端进行session共享。
不要把跨域请求和cookies跨域名的概念搞混了。同源策略是要求IP、端口、协议全一致,不一致的请求就是跨域请求。但cookies是可以跨域共享的,但是不能跨域名(IP)共享。
集成Spring session
引入spring-session-redis的maven依赖
项目内引入spring-session-data-redis
,配合spring-boot-starter-data-redis
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置启用Redis
的httpSession
在启动类上方加上注解,启动SpringSession
管理应用的session
,并设置session
数据的有效期30分钟
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 30 * 60 * 1000)
配置redis链接信息(application.yml)
spring:
redis:
database: 0
host: 192.168.161.3
password: 4rfv$RFV
port: 6379
测试
@Controller
public class SessionController
@RequestMapping(value="/uid",method = RequestMethod.GET)
public @ResponseBody
String uid(HttpSession session)
return "sessionId:" + session.getId();
一个项目多个端口启动
- 点击edit configuration ,取消勾选single instance only(只允许单节点运行)。在比较新的版本中这个勾选框变成了Allow parallel run(允许多实例并发运行),那你就给它勾选上。总之我们是要运行多实例。
- 复制一份当前配置,在environment选项中的vm options 中设置不同的端口号
-Dserver.port=8889 -Dserver.httpPort=89 -Dspring.profiles.active=dev -Ddebug
-Dserver.port=8888 -Dserver.httpPort=88 -Dspring.profiles.active=dev -Ddebug
这里需要注意的是,因为之前的章节我们为当前测试应用配置了SSL证书,所以不只是端口8888、8889。还有HTTPS的端口会影响我们的测试,对于自定义配置server.httpPort我们也需要配置不同的端口,否则会发生冲突。
测试
依次访问,看看效果.通过返回值session.getId()
即:sessionid
来判断,如果sessionid
一致,则证明session
共享成功了。
用浏览器访问下面的地址,自己看一下效果,再理解一下。
- https://localhost:8888/uid
- https://localhost:8889/uid
- https://127.0.0.1:8888/uid
- https://127.0.0.1:8889/uid
因为我们在同一台机器上启动多个实例,ip相同所以session是共享的。如果你在不同的服务器上启动多个实例(IP)不同,你需要在应用前方加上负载均衡逆向代理才可以实现session共享。
Spring session推荐文章
【第一篇】Spring-Session实现Session共享入门教程
介绍redis分布式锁
什么是分布式锁?
在我们写Java程序的时候,多线程争取同一个资源的时候,经常会使用到诸如syncchronize或Lock来实现锁操作,这种锁通常被称为“本地锁”。但是本地锁只能适用于在同一个进程内(同一个应用内的线程之间锁定资源),如果应用是分布式部署的,彼此之间是独立的进程,进程之间又存在需要争夺的资源,那么该如何对资源进行锁定?这就需要使用到分布式锁。
其实分布式锁和本地锁的基本原理是一样的,举个例子:上厕所
- 4人去上厕所,厕所只有2个坑位
- 先到坑位的人先占,占有后锁门(也就是上锁)
- 后到的人没有占到坑位,只能等待
- 先使用“坑位”的人,使用完资源,进行锁释放。
- 锁释放之后,后到的人就可以获得坑位并上锁,如此循环往复。
上面的逻辑可以使用下面的代码来体现。
@Resource
RedisTemplate<String, String> redisTemplate;
public void updateUserWithRedisLock(SysUser sysUser) throws InterruptedException
// 占分布式锁,去redis占坑
Boolean lock = redisTemplate.opsForValue()
.setIfAbsent("SysUserLock" + sysUser.getId(),
"value");
if(lock)
//加锁成功... 执行业务
redisTemplate.delete("SysUserLock" + sysUser.getId()); //删除key,释放锁
else
Thread.sleep(100); // 加锁失败,重试
updateUserWithRedisLock(sysUser);
setIfAbsent
方法的作用是在某一个lock key
不存在的时候,才能返回true
;如果这个key
已经存在了就返回false
,返回false
就是获取锁失败。setIfAbsent
函数功能类似于redis
命令行setnx
。
分布式锁实现过程中的问题
问题一:异常导致锁没有释放
这个问题形成的原因就是程序在获取到锁之后,执行业务的过程中出现了异常,导致锁没有被释放。通俗的话说:上厕所的人死在了厕所里面,导致“坑位”资源死锁无法被释放。(当然这种情况出现的概率很小,但概率小不等于不存在。)
解决方案: 为redis的key设置过期时间,程序异常导致的死锁,在到达过期时间之后锁自动释放。也就说厕所门是电子锁,锁定的最长时间是有限制的,超过时长锁就会自动打开释放"坑位"资源。
// 设置过期时间
redisTemplate.expire("SysUserLock" + sysUser.getId(), timeout: 30, TimeUnit.SECONDS) ;
问题二:获取锁与设置过期时间操作不是原子性的
上文中我们虽然获取到锁,也设置了过期时间,看似完美。但是在高并发的场景下仍然会出问题,因为“获取锁”与“设置过期时间”是两个redis操作,两个redis操作不是原子性的。
可能出现这种情况:就在获取锁之后,设置过期时间之前程序宕机了。锁被获取到了但没有设置过期时间,最后又成为死锁。
解决方案
: 获取锁的同时设置过期时间
// 1. 分布式锁占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("SysUserLock" + sysUser.getId(), "value", 30, TimeUnit.SECONDS);
问题三:锁过期之后被别的线程重新获取与释放
这个问题出现的场景是:假如某个应用集群化部署存在多个进程实例,实例A、实例B。实例A获取到锁,但是执行过程超时了(数据库层面或其他层面导致操作执行超时)。超时之后锁被自动释放了,实例B获取到锁,并执行业务程序,执行完成之后把锁删除了。
实际上这里还涉及到一个锁的续期的问题,我们后续再说,我们先来看下锁的释放的问题。
解决方案: 在释放锁之前判断一下,这把锁是不是自己的那一把,如果是别人的锁你就不要动。怎么判断这把锁是不是自己的?加锁时为value赋随机值,加锁的随机值等于解锁时的获取到的值,才能证明这把锁是你的。代码如下:
问题四:锁的释放不是原子性的
大家仔细看代码,锁的释放时三个操作,这三个操作不是原子性的。也就是说在高并发的场景下,你刚get到的redis key有可能也被别的线程get了,你刚要删除别的线程可能已经把这个key删除了。
为了解决这个问题,我们可以使用redis lua脚本(lua脚本是在一个事务里面执行的,可以保证原子性)。在Java代码中可以以字符串的形式存在。
String script =
"if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1])
else
return 0
end";
问题五:其他的问题?
上面我们分析了很多使用redis实现分布式锁可能出现的问题及解决方案,其实在实际的开发应用中还会有更多的问题。比如:
- 目前我们的程序获取不到锁,就无限的重试,是不是应该在重试一定的次数之后就抛出异常?在有限的时间内通过异常给用户一个友好的响应。比如:程序太忙,请您稍后再试!
- 程序A没有执行完成,锁定的key就过期了。虽然过期之后会自动释放锁,但是我的程序A的确没有执行完成啊,也没有异常抛出,就是执行的时间比较长,这个时候是不是应该对锁定的key进行续期?
笔者对于分布式锁自动续期的这个功能也不是特别感冒,我觉得程序超过了我们设置的过期时间(比如说60s)一定是出现了问题,如果不是离线大数据批处理,一个程序执行60秒还没完成那一定是出问题了,你给我抛出异常就可以了。对于一个出问题的程序一直续期和死锁没什么区别。
所以实现一个分布式锁,不是我们想的那么简单,在高并发的环境下需要考虑的问题会复杂得多。怎么办?实际上分布式锁的细节时间有很多的现成的解决方案,不用我们去自己实现。比较完整优秀的分布式锁实现包括:
- RedisLockRegistry是spring-integration-redis中提供redis分布式锁实现类
- 基于Redisson实现分布式锁原理(Redission是一个独立的redis客户端,是与Jedis、Lettuce同级别的存在)
对比:
- RedisLockRegistry通过本地锁(ReentrantLock)和redis锁,双重锁实现;Redission通过Netty
Future机制、Semaphore (jdk信号量)、redis锁实现。 - RedisLockRegistry和Redssion都是实现的可重入锁。(可重入锁是什么?下节再说)
- RedisLockRegistry对锁的刷新没有处理(续期),Redisson通过Netty的TimerTask、Timeout
工具完成锁的定期刷新任务。
RedisLockRegistry分布式锁
集成spring-integration-redis
前提项目里面已经正确的集成了spring-boot-starter-data-redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-redis</artifactId>
</dependency>
注册RedisLockRegistry
@Configuration
public class RedisLockConfig
@Bean
public RedisLockRegistry redisLockRegistry(RedisConnectionFactory redisConnectionFactory)
//第一个参数redisConnectionFactory
//第二个参数registryKey,分布式锁前缀,设置为项目名称会好些
//该构造方法对应的分布式锁,默认有效期是60秒.可以自定义
return new RedisLockRegistry(redisConnectionFactory, "boot-launch");
//return new RedisLockRegistry(redisConnectionFactory, "boot-launch",60);
使用RedisLockRegistry
@Resource
private RedisLockRegistry redisLockRegistry;
public void updateUser(String userId)
String lockKey = "config" + userId;
Lock lock = redisLockRegistry.obtain(lockKey); //获取锁资源
try
lock.lock(); //加锁
//这里写需要处理业务的业务代码
finally
lock.unlock(); //释放锁
RedisLockRegistry解读
org.springframework.integration.redis.util.RedisLockRegistry的核心源码非常简单,就RedisLockRegistry这一个类。源码我就不贴在这里了,我给大家总结一下要点:
- 基于StringRedisTemplate实现,所以与spring-boot-starter-data-redis天然融合。
- RedisLockRegistry可以结合Spring data redis实现分布式锁,registryKey是锁key的前缀。
- 默认的锁过期时间是60秒,提供了自定义RedisLockRegistry(redisConnectionFactory,
registryKey,expiredAfter)的构造函数可以使用 - 当尝试去unlock已经过期的锁的时候,会抛出异常IllegalStateException,即RedisLockRegistry不支持锁的续期。
- RedisLockRegistry实现的分布式锁是“可重入”的,可重入就是说某个线程已经获得某个锁,该线程可以再次获取锁而不会出现死锁。基于java.util.concurrent.locks.ReentrantLock实现可重入锁
要不要使用注解实现分布式锁?
现在有很多的博文里面给出了一种非常简单的实现,就是在方法上面加注解,比如:
@RedisLock("lock-key")
public void save()
这种实现使用上非常简单,但是笔者不建议使用这种方式,有几个原因
- 不管是什么锁,锁定的范围应该越小越好,琐能保证数据操作正确性安全性,但是会降低应用性能。能对1行代码加锁就完成的需求,就不要锁定2行。把注解加在方法上,是锁定了方法里面所有的代码执行,高并发场景会影响执行效率。(有的同学说可以把需要锁定的代码单独抽取函数,这的确是一个方法,但抽取的粒度过细会破坏代码的可维护性)
- 使用注解的方式其核心原理是使用AOP面向切面编程的实现。异常及事务的处理、分布式锁在我们的应用里面都是面向切面编程的,混合到一起有的时候很难处理。我的建议是分布式锁它并不是一个“常用项”,如果你的项目里面到处都是分布式锁,你要思考一下是不是你的设计出了问题。所以对于非常用项我们没有必要过度封装,我们使用try-finally的方法来使用它就可以了,代码封装少可读性强,如果出现异常处理也都非常灵活,改动锁相关的代码影响面积小。
使用redisson实现分布式锁
介绍Redisson
Redisson是Redis官方推荐的Java版的Redis客户端(Jedis、letture也是官方推荐的java版本redis客户端程序)。它提供的功能非常多,也非常强大,特别是它默认提供的分布式锁支持功能。其github源码仓库地址:https://github.com/redisson/redisson,包含多个子项目,对于我们本节比较有用的是
- 集成redisson-spring-data-2x之后能够支持Spring Data redis及RedisTemplate
- 集成redisson-spring-boot-starter能够支持Spring
Cache(前提是已经集成spring-boot-starter-cache)
也就是说我们可以使用redisson无缝、无损的替换Spring Boot 2.x官方默认支持的redis客户端letture。也就是说我们之前学过的RedisTemplate、Redis Repository、Cache缓存该怎么用还怎么用,不受影响。
但是需要说明的是Redisson并不在Spring Boot官方默认支持的redis客户端的范围之内,所以redisson向Spring Boot 或者 Spring Data的集成方案,都是由redisson自己来维护的。
Spring Boot 集成Redisson
先从IDEA-maven管理Tab中查看,要确保自己的项目里面已经引入了下图所示的spring-boot-starter-data-redis。如何集成spring-boot-starter-data-redis
如上所示,我们使用的是spring data 2.2.4版本,所以artifactId为redisson-spring-data-22。如果你使用的其他的版本,以此类推。
<dependency>
<groupId>org.redisson</groupId>
<!-- for Spring Data Redis v.2.2.x -->
<artifactId>redisson-spring-data-22</artifactId>
<version>3.15.0</version>
</dependency>
除此之外,还要加入核心jar包redisson-spring-boot-starter,我们使用的是3.15.0版本,其默认包含redisson-spring-data-23,和我们Spring Data Redis v.2.2.x不匹配,所以我们用exclusion把它排除掉。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.15.0</version>
<exclusions>
<exclusion>
<groupId>org.redisson</groupId>
<!-- 默认是 Spring Data Redis v.2.3.x ,所以排除掉-->
<artifactId>redisson-spring-data-23</artifactId>
</exclusion>
</exclusions>
</dependency>
下面的这一步从我的实验来看,可做可不做,不影响。但是既然我们使用redisson替换lettuce,就不要把lettuce的jar留在项目里面了,把它也排除掉。
两种配置方法
配置方法一
redisson-spring-boot-starter默认支持application全局配置文件,redis配置以前怎么配置,现在还怎么配置,把lettuce段的配置去掉就可以了。
配置方法二
首先把全局配置文件中spring.redis下面的配置全都删除掉,然后加上redisson独立配置文件的指向位置及文件名称
spring:
redis:
redisson:
file: classpath:redisson.yaml
在resource目录下新建一个文件redisson.yaml,比如:redis单例模式的配置方法如下:
哨兵模式、集群模式等更多配置参考:https://github.com/redisson/redisson/wiki/目录
singleServerConfig:
idleConnectionTimeout: 10000
connectTimeout: 10000
timeout: 3000
retryAttempts: 3
retryInterval: 1500
password: 123456
subscriptionsPerConnection: 5
clientName: null
address: "redis://192.168.161.3:6379"
subscriptionConnectionMinimumIdleSize: 1
subscriptionConnectionPoolSize: 50
connectionMinimumIdleSize: 32
connectionPoolSize: 64
database: 0
dnsMonitoringInterval: 5000
threads: 0
nettyThreads: 0
codec: !<org.redisson.codec.JsonJacksonCodec>
transportMode: "NIO"
大家可以看到第二种配置方案比第一种配置方案,多出很多细节方面的配置,更适合有经验的高手进行性能优化使用。
分布式锁的实现
仍然是老套路,获取锁、上锁锁定、业务代码执行完成释放锁。
@Resource
private RedissonClient redissonClient;
public void updateUser(String userId)
String lockKey = "config" + userId;
RLock lock = redissonClient.getLock(lockKey); //获取锁资源
try
lock.lock(10, TimeUnit.SECONDS); //加锁,可以指定锁定时间
//这里写需要处理业务的业务代码
finally
lock.unlock(); //释放锁
- 相对于RedisLockRegistry另一个小优点是:我们可以为每一个锁指定锁定的超时时间。RedisLockRegistry目前只能针对所有的锁设定统一的超时时间
- 如果业务执行超时之后,再去unlock会抛出java.lang.IllegalMonitorStateException
上面的用法可以满足你绝大部分的分布式锁的业务场景,更多的用法参考官方wiki:
以上是关于重学springboot系列之集群多节点应用session共享,redis分布式锁的主要内容,如果未能解决你的问题,请参考以下文章