重学springboot系列之集群多节点应用session共享,redis分布式锁

x33g5p2x  于2021-12-05 转载在 Spring  
字(10.0k)|赞(0)|评价(0)|浏览(1009)

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共享。

集成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>

配置启用RedishttpSession

在启动类上方加上注解,启动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

测试

依次访问,看看效果.通过返回值session.getId()即:sessionid来判断,如果sessionid一致,则证明session共享成功了。

用浏览器访问下面的地址,自己看一下效果,再理解一下。

因为我们在同一台机器上启动多个实例,ip相同所以session是共享的。如果你在不同的服务器上启动多个实例(IP)不同,你需要在应用前方加上负载均衡逆向代理才可以实现session共享。

Spring session推荐文章

spring-session简介、使用及实现原理

【第一篇】Spring-Session实现Session共享入门教程

Spring 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进行续期?

所以实现一个分布式锁,不是我们想的那么简单,在高并发的环境下需要考虑的问题会复杂得多。怎么办?实际上分布式锁的细节时间有很多的现成的解决方案,不用我们去自己实现。比较完整优秀的分布式锁实现包括:

  • 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单例模式的配置方法如下:

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:

https://github.com/redisson/redisson/wiki/8.-分布式锁和同步器

相关文章