文章目录
1.Schedule(定时任务)
-
Spring的Schedule依赖包含在spring-boot-starter模块中,无需引入其他依赖。
-
在启动类增加注解(开启定时任务):@EnableScheduling
-
Cron表达式,当方法的执行时间超过任务调度频率时,调度器会在下个周期执行
-
注意: Spring的Schecule默认是单线程执行的,如果你定义了多个任务,那么他们将会被串行执行,会严重不满足你的预期。(如果要解决可以通过线程池的方式解决)
-
示例: 每秒获取Redis中存储的消息(消息队列确认机制的保证,可以通过Redis保证实现),将失败的消息重新投递
@Component
public class MySchedul {
@Autowired
RedisTemplate redisTemplate;
@Autowired
RabbitMessageUtils rabbitMessageUtils;
private static Logger logger = LoggerFactory.getLogger(MySchedul.class);
//该定时任务就是从redis 中获取到未投递成功的消息,并且进行重新投递
@Scheduled(cron = "* * * * * ?")
public void getMessageAndSender() {
logger.debug("===进入消息重新投递的定时任务===");
//进入定时任务,获取到redis中所有的信息
Set keys = redisTemplate.boundHashOps(RabbitKey.MESSAGE_KEY).keys();
//获取到redis中所有的消息的key,循环所有的key
if (keys != null && keys.size() > 0) {
for (Object id : keys) {
//通过该key获取到 消息本身
Object o = redisTemplate.boundHashOps(RabbitKey.MESSAGE_KEY).get(id.toString());
//将o转为message对象
MyMessage message = JSONObject.parseObject(JSON.toJSONString(o), MyMessage.class);
//判断message中的status是否为fail状态
if (message.getStatus().equals("fail")) {
//调用工具类重新投递
rabbitMessageUtils.sendMessage(message);
}
}
}
}
}
2.高并发线程安全的解决方案
2.1为什么不适用同步锁(Synchronized)?
- 对代码块添加同步锁(Synchronized)可以保证单个服务,在多线程状况下不能同时执行,如果项目部署集群,同一台服务器会部署多个服务,则同步锁会失效,多个服务间会出现并发
2.2 Redis的分布式锁setnx
“锁”就是一个存储在redis里的key-value对,key是把一组操作用字符串来形成唯一标识,value其实并不重要,因为只要这个唯一的key-value存在,就表示这个操作已经上锁,redis的分布式锁技术,使用的就是redis的setnx操作(在java中表现为setIfAbsent),如果有key 则加锁失败,如果没有则加锁成功,需要注意的是在加分布式锁时需要设置超时时间,用来防止死锁的产生
(1)加锁:
- “锁”就是一个存储在redis里的key-value对,key是把一组操作用字符串来形成唯一标识,value其实并不重要,因为只要这个唯一的key-value存在,就表示这个操作已经上锁。
- redis的分布式锁技术,使用的就是redis的setnx操作,如果有key 则加锁失败,如果没有则加锁成功
- setIfAbsent 是java中的方法
- setnx 是 redis命令中的方法
(2)解锁:
- 既然key-value对存在就表示上锁,那么释放锁就自然是在redis里删除key-value对
(3)阻塞、非阻塞:
- 阻塞式的实现,若线程发现已经上锁,会在特定时间内轮询锁。
- 非阻塞式的实现,若发现线程已经上锁,则直接返回。
(4)处理异常情况(防止死锁的产生):
- 假设当投资操作调用其他平台接口出现等待时,自然没有释放锁,这种情况下加入锁超时机制,用redis的expire命令为key设置超时时长,过了超时时间redis就会将这个key自动删除,即强制释放锁,进而防止死锁的产生
(5)示例 (以秒杀防止超卖问题为例)
//为了安全性继续加入redis的分布式锁 使用的就是redis的setnx操作,如果有key 则加锁失败,如果没有则加锁成功
//设置锁的失效时间,防止死锁产生,例如设置10s的过期时间,如果到期还未释放锁,则直接将该锁进行移除
//解决的是超卖问题
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1", 10, TimeUnit.SECONDS);
if(lock) {
logger.debug("当前用户抢到了该锁,进行扣减库存操作!");
//如果有库存,则需要进行扣减库存操作,注意要保证原子性 会返回当前减1后的剩余库存量
Long stockConut = redisTemplate.opsForValue().decrement(RedisKey.SECKILL_KEY + goodsId);
if (stockConut <= 0) {
//库存没有了 删除商品信息
redisTemplate.boundHashOps(RedisKey.SECKILL_KEY + redisKey).delete(goodsId.toString());
//移除掉对应的库存信息
redisTemplate.delete(RedisKey.SECKILL_KEY + goodsId);
return new BaseResp().FAIL("商品已被抢购完!");
} else {
//修改剩余库存量设置到 redis中
TbSeckillGoods tbSeckillGoods = JSONObject.parseObject(JSON.toJSONString(obj), TbSeckillGoods.class);
tbSeckillGoods.setStockCount(stockConut.intValue());
redisTemplate.boundHashOps(RedisKey.SECKILL_KEY + redisKey).put(goodsId.toString(), tbSeckillGoods);
//将数据库修改为0 但是这个逻辑是错误的,因为用户点击完抢购,如果没有付款,则该商品的数量是不能被真正的排除掉的。就会出现少卖的情况,所以真正的扣减数据库的库存,需要用户支付完成后,才能真正的扣钱
//在这里不能直接释放库存,因为用户一直不付钱,那么我们就需要进行 后续业务处理,如果用户30S不付款,则直接将该用户抢购的商品进行移除操作,预扣减的库存+1,可以通过RabbitMQ的死信队列来实现
//防止少卖问题
}
//执行完逻辑后要将该锁释放
logger.debug("抢购完成!");
//释放分布式锁
redisTemplate.delete("lock");
}else{
logger.debug("没有抢到锁,抢购失败!");
}
2.3 redisson分布式锁(看门狗机制)
2.3.1 Redis的分布式锁setnx产生的问题
- 使用Redis的分布式锁setnx,需要设置超时时间来防止死锁的产生,若某个线程抢到该锁但由于调用其他接口出现了等待,导致其业务执行总时间超过了setnx设置的超时时间,此时锁就会被释放,就会出现并发问题(例如秒杀时出现超卖问题)
2.3.2 redisson 实现锁续命
- redisson 通过 watch dog(看门狗机制),当业务执行超时,会进行锁续命
- redisson实现锁续命的原理: 设置了超时时间,如果当前业务代码超过了锁的失效时间,会进行锁续命,但不是无限次的续命,而是达到一定的次数、一定的时间,redisson会认为当前出现了死锁状况,会自动将该锁进行释放。(第一次默认延长30S时间)
2.3.2 getLock(“lock”) 方法
RedissonClient 是一个在 Java 中实现的 Redis 客户端,它提供了许多 Redis 操作的功能。其中的 getLock 方法用于获取一个分布式锁。当你在 RedissonClient 中调用 getLock(“lock”) 时,以下是该方法执行的主要步骤:
- 创建锁对象: 首先,RedissonClient 会根据传入的字符串参数 “lock” 在 Redis 中创建一个锁对象。这个锁对象在 Redis 中是一个键,其值为一个特定的锁标识符。
- 获取锁: 然后,RedissonClient 会尝试获取这个锁。这是通过向 Redis 发送一个 BLPOP 命令实现的。BLPOP 是一个阻塞的列表弹出原语,它从一个列表中弹出并返回一个元素。如果列表为空,那么 BLPOP 会阻塞等待,直到有元素可以弹出。在这种情况下,RedissonClient 将使用一个随机的 Redis 键(不是由 RedissonClient 管理的)作为阻塞列表,以避免与其他正在等待获取锁的客户端冲突。
- 设置锁的属性: 如果成功获取了锁,那么 RedissonClient 会设置一些关于锁的属性。这包括设置锁的超时时间(默认为 30 秒),以及一个用于标识锁已经被人持有的标记。
- 返回锁对象: 最后,RedissonClient 会返回这个锁对象,以便你可以使用它来控制对资源的访问。
这就是 getLock 方法的基本原理。需要注意的是,RedissonClient 还提供了其他一些高级功能,例如在获取锁时设置优先级,以及在释放锁时自动刷新锁的超时时间等。
2.3.3 tryLock方法
(1)方法参数
tryLock(5, TimeUnit.SECONDS)
-
第一个参数表示尝试获取锁的等待时间长度,即如果当前线程无法立即获取到锁,它最多会等待这么长的时间。如果在这个时间内锁被释放了,当前线程会尝试再次获取锁。如果超过这个时间锁仍然没有被释放,当前线程就会放弃获取锁。
-
第二个参数是时间单位,用于指定第一个参数所表示的时间长度的单位。在这个例子中,我们使用了
TimeUnit.SECONDS
,表示等待时间的单位是秒。还可以使用其他的时间单位,例如TimeUnit.MILLISECONDS
表示毫秒,TimeUnit.MICROSECONDS
表示微秒等。
(2)超时获取业务处理
tryLock(5, TimeUnit.SECONDS)
返回false时,这通常意味着没有获取到锁。在这种情况下,您可能需要考虑以下几种处理方式:
- 重试机制:您可以编写一个循环,在一段时间内不断尝试获取锁,直到成功为止。在每次尝试之间,您可以设置一定的延迟,以避免对锁的过度争用。
- 超时处理:您可以定义一个超时时间,如果超过这个时间仍然无法获取到锁,就认为请求超时,并采取相应的处理措施(例如返回错误信息、记录日志等)。
- 异常处理:您可以捕获
java.util.concurrent.TimeoutException
异常(如果使用了java.util.concurrent
包中的Lock
实现),或者在您自己的锁实现中定义类似的异常处理逻辑。这样可以让您更灵活地处理超时和其他异常情况。
无论您选择哪种处理方式,重要的是确保您的代码能够正确地处理无法获取锁的情况,以避免潜在的死锁和其他并发问题。
// 尝试获取锁,等待超时时间为5秒
boolean acquired = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (acquired) {
try {
// 成功获取到锁,执行需要同步的代码逻辑
System.out.println("执行同步代码...");
} finally {
// 释放锁
lock.unlock();
}
}
else {
// 没有获取到锁,处理未获取到锁的情况
System.out.println("未获取到锁,等待超时...");
}
(3)关闭资源
- 可以单独抽出来,在某个Spring容器管理的类中进行关闭资源,结合
@PreDestroy
注解进行使用
// 关闭RedissonClient实例
redisson.shutdown();
2.3.4 redisson 的代码实现
(1)依赖
<!-- 加入redisson依赖,解决分布式锁问题-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.7</version>
</dependency>
(2)通过配置文件,将redisson交由Spring容器进行管理
- 需要结合redis
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Value("${spring.redis.database}")
private Integer database;
//创建redission连接客户端 并且交给spring管理
@Bean
public RedissonClient createRedisson(){
//1.声明Redisson的配置
Config config = new Config();
//2.使用单机模式 其中也有集群模式
config.useSingleServer()
//redisson的连接 必须以redis://开头
.setAddress("redis://"+host+":"+port)
//设置使用的redis的库
.setDatabase(database);
//3.使用redisson的config创建出redissonClient客户端
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
(3)加锁
@RestController
@RequestMapping("/lock")
public class TestLockController {
@Autowired
RedisTemplate redisTemplate;
@Autowired
ShopRepository shopRepository;
//获取到配置文件中的Bean对象
@Autowired
RedissonClient redissonClient;
//1.加锁
//2.判断数据库的数量是否大于1
//3.如果大于等于1 则修改数量 进行减1操作
//4.释放锁资源
//使用自定义注解 ,我们需要定义该注解的作用是什么
@AccessLimit
@RequestMapping("/sekill/{id}")
@Transactional
public String testRedis(@PathVariable("id")Integer id) throws InterruptedException {
// 进行加锁,并且修改数据库的库存 模拟秒杀
//1.key value setIfAbsent 特点为:如果已经存在该key值,则不会设置成功
// Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent("lock", "lock");
//2.使用redisson进行加锁的操作
RLock lock = redissonClient.getLock("lock");
//3.判断锁是否可用尝试加锁 并且设置1秒钟的失效时间 如果加锁成功则设置为true
try {
boolean b = lock.tryLock(1, TimeUnit.SECONDS);
if (b) {
System.out.println("获取锁开始执行");
//2.如果获取到这把锁,设置失效时间 失效时间设置为5秒时间
//redisTemplate.expire("lock", 1, TimeUnit.SECONDS);
//3.设置完成后执行自己的业务逻辑。从数据库查询该商品的数量是否大于等于1
Optional<TbShop> byId = shopRepository.findById(id);
if (byId.isPresent()) {
//在执行当前逻辑是,超过了默认的锁失效时间,那么就会出现超卖
//将当前的线程休眠两秒钟的时间 业务执行的时间越长,则超卖的商品就越多
//当使用到Ression后,休眠两秒钟的时间,默认设置的该锁的时间为1秒,那么redssion
//会自动的将该锁进行续命操作,防止出现并发操作。只有当前线程执行完成,才会讲该锁进行释放,不管
//线程执行的时间长短,当达到固定时间时,才会释放防止死锁的产生
Thread.sleep(2000);
if (byId.get().getNum() >= 1) {
shopRepository.updateNum(id);
}
}
//释放锁
//redisTemplate.delete("lock");
//使用Redisson释放锁.先获取到锁,才能进行解锁操作
lock.unlock();
}
}catch (Exception e){
return "秒杀失败!";
}
return "秒杀失败";
}
}
3.限流处理操作(并发量过大)
- 使用RabbitMQ设置队列的最大存储量,对请求进行限流
- 使用限流技术,比如令牌桶
3.1 令牌桶的实现
- 推荐使用google提供的guava工具包中的RateLimiter进行实现,其内部是基于令牌桶算法进行限流计算
(1)依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.0-jre</version>
</dependency>
(2)测试
public static void main(String[] args) {
//允许每秒通过三个请求
RateLimiter rateLimiter = RateLimiter.create(3.0);
//获取令牌,如果获取到则为true 否则为false
boolean b = rateLimiter.tryAcquire();
//开启线程池进行测试
ExecutorService executor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 10; i++) {
executor.execute(new Runnable() {
@Override
public void run() {
//获取令牌桶中一个令牌,最多等待10秒
if (rateLimiter.tryAcquire(1, 10, TimeUnit.SECONDS)) {
System.out.println(Thread.currentThread().getName()+" "+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
}
}
});
}
executor.shutdown();
}
3.2 RateLimiter 细节
(1)rateLimiter.tryAcquire(1, 10, TimeUnit.SECONDS) 作用
-
guava的RateLimiter是一个令牌桶算法的实现,用于限制某一时间窗口内的事件发生频率。
rateLimiter.tryAcquire(1, 10, TimeUnit.SECONDS)
是RateLimiter的一个方法,其作用是在给定的时间窗口内尝试获取一个令牌。 -
具体来说,
tryAcquire(1, 10, TimeUnit.SECONDS)
方法会尝试在接下来的10秒内获取一个令牌。如果在这个时间窗口内,有足够的令牌可供获取,那么方法会立即返回一个true,否则它会立即返回false。 -
这种机制在多线程并发环境中非常有用。例如,假设你有一个服务,这个服务每秒可以处理10个请求。如果你有大量的请求过来,你不希望这个服务在短时间内处理大量的请求,因为这可能会使服务过载,导致处理质量下降或者服务崩溃。使用RateLimiter,你可以限制服务的请求处理速率,以保证服务的稳定性和质量。
(2)时间窗口内若一直未获取到令牌,会作何操作?
-
当请求在时间窗口内无法获取到令牌时,RateLimiter会阻塞等待,直到下一个令牌可用或超过预定的等待时间。如果请求一直没有获取到令牌,它将继续等待,直到获取到令牌或超过预定的等待时间。
-
如果请求在时间窗口内无法获取到令牌,
tryAcquire(long timeout, TimeUnit unit)
方法会一直等待直到获取到令牌或超过预定的等待时间。在这个等待过程中,请求会不断尝试去获取令牌。在等待期间,每次尝试获取令牌时,请求都会检查RateLimiter的当前状态,如果仍然没有可用的令牌,请求就会等待一段时间后再次尝试。这个等待时间是由Guava的线程调度器控制的,通常会以最小的时间粒度进行等待,以减少CPU资源的浪费。
需要注意的是,如果请求在等待期间一直无法获取到令牌,就会一直处于等待状态。因此,在使用RateLimiter时,需要合理设置等待时间和RateLimiter的配置参数,以避免出现死锁或其他问题。同时,也需要考虑其他限流策略和超时处理机制,以满足实际业务需求。
(3)RateLimiter创建配置
-
在创建RateLimiter时,你可以传递四个参数:
// 创建一个每秒发放5个令牌的RateLimiter,预热时间为10秒,使用非公平算法 RateLimiter rateLimiter = RateLimiter.create(5.0, 10, TimeUnit.SECONDS, true);
- permitsPerSecond:每秒发放的令牌数量,表示你希望每秒处理多少个请求。
- warmupPeriod:预热时间,表示RateLimiter进入稳定状态前需要等待的时间。如果在预热时间内,RateLimiter的申请次数超过了permitsPerSecond,那么RateLimiter会一直处于warmup状态,直到预热时间结束或申请次数降低到permitsPerSecond以下。
- unit:时间单位,用于指定预热时间的长度。
- isNonFair:一个布尔值,表示是否使用非公平算法。如果为true,则表示在获取令牌时,等待时间最长的线程将优先获取令牌;如果为false,则表示在获取令牌时,所有线程机会均等。
(4)RateLimiter的定位
-
Guava的RateLimiter不会直接返回请求接口调用超时。它是一个限流工具,用于控制某一时间窗口内的事件发生频率,而不是处理请求的超时机制。
-
使用Guava的RateLimiter的
tryAcquire(1, 10, TimeUnit.SECONDS)
方法尝试获取一个令牌,如果10秒内无法获取到令牌,就会返回false。返回false后,你可以根据实际需求采取相应的操作。通常情况下,返回false表示请求未获得令牌,你可以根据业务需求进行以下操作:
- 记录日志或统计未获取到令牌的请求数量,以便分析和监控。
- 根据业务需求,可以选择重试获取令牌或放弃处理该请求。例如,你可以使用循环结构在一定时间内多次尝试获取令牌,或者根据具体情况决定是否需要延迟处理请求。
- 如果请求具有时效性或需要尽快处理,而RateLimiter的限制导致请求无法及时处理,你可以考虑调整RateLimiter的配置或使用其他限流策略以满足业务需求。
需要注意的是,RateLimiter只是一个用于限制事件发生频率的工具,它并不直接处理请求的超时或重试机制。你需要结合其他工具或代码来实现请求的超时处理和重试机制,例如使用Java的Future和Callable接口,或者使用Spring框架的@Async和@Timeout注解等。
3.3 自定义注解实现接口限流
-
基于令牌桶
-
实现方式: 自定义注解 + AOP
(1)依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.0-jre</version>
</dependency>
(2)自定义限流注解
注解定义用
@interface
关键字修饰
@Target
注解,是专门用来限定某个自定义注解能够被应用在哪些Java元素上面的
@Retention
注解,翻译为持久力、保持力。即用来修饰自定义注解的生命周期
@Documented
注解,是被用来指定自定义注解是否能随着被定义的java文件生成到JavaDoc文档当中
@Inherited
注解,是指定某个自定义注解如果写在了父类的声明部分,那么子类的声明部分也能自动拥有该注解
@Inherited
@Documented
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLimit {}
(2)自定义切面类,扫描加入了该注解的方法
import com.alibaba.fastjson.JSONObject;
import com.google.common.util.concurrent.RateLimiter;
import com.qf.springbootrediscrud.pojo.BaseResp;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
/**
* 1.aop的业务使用:
* 1.1 日志记录
* 1.2 事务
* 1.3 扫描自定义注解,进行业务增强
*/
@Aspect
@Component
public class AccessLimitAop {
@Autowired
private HttpServletResponse httpServletResponse;
//声明令牌桶,每秒放行20个请求
private RateLimiter rateLimiter=RateLimiter.create(1.0);
private static final Logger logger = LoggerFactory.getLogger(AccessLimitAop.class);
//1.定义对于哪些方法进行增强。声明切点。我们现在需要的是扫描自定义注解
//excution 定义切点对于哪些方法进行增强
@Pointcut(value = "@annotation(com.qf.springbootrediscrud.annoration.AccessLimit)")
public void pt1(){}
//我们对接口进行增强方法,拦截到加了自定义注解的接口,首先来执行增强方法
//判断当前的请求是否可以放行,是否可以从令牌桶中获取到令牌
@Around("pt1()")
public Object arround(ProceedingJoinPoint proceedingJoinPoint){
//判断是否该请求获取到了令牌
logger.debug("进入了令牌桶的判断是否放行");
//尝试从令牌桶中获取令牌
boolean b = rateLimiter.tryAcquire();
String name = Thread.currentThread().getName();
Object proceed = null;
try {
//判断是否获取到该令牌,则继续执行业务逻辑
if (b){
logger.debug("===获取到令牌继续执行业务逻辑==当前线程:{}"+name);
proceed = proceedingJoinPoint.proceed();
return proceed;
}else{
logger.debug("===获取令牌失败!=="+name);
//没有获取到令牌需要返回 ,不再继续请求
String result = JSONObject.toJSONString(new BaseResp().FAIL("请求量过大,请稍后再试!"));
//通过httpservletResponse进行返回
httpServletResponse.setContentType("application/json");
httpServletResponse.setCharacterEncoding("utf-8");
//使用response进行返回数据
ServletOutputStream outputStream = httpServletResponse.getOutputStream();
//将错误结果进行返回操作
outputStream.write(result.getBytes(StandardCharsets.UTF_8));
outputStream.close();
}
} catch (Throwable throwable) {
throwable.printStackTrace();
}
return proceed;
}
}
(3)在接口中加入@AccessLimit注解,进行限流
/**
* 开始秒杀
*/
//在秒杀中加入 限流注解 对当前接口进行保护
@AccessLimit
@RequestMapping("/add")
public BaseResp add(@RequestParam("id")Integer goodsId, @RequestParam("date")String date, HttpServletRequest request){
return sekillService.add(goodsId,date,request);
}