本文用用代码演示Redis实现分布式缓存、分布式锁、接口幂等性、接口防刷的功能。
课程地址:Redis实战系列-课程大纲_哔哩哔哩_bilibili
目录
一. 新建springBoot项目整合Redis
二. Redis实现分布式缓存
2.1 原理及好处
2.2 数据准备
2.3 Redis实现分布式缓存
2.4 优雅实现分布式缓存(Redis+AOP+自定义注解)
第0步:准备RedisTool工具类
第一步:导入AOP依赖
第二步:自定义注解
第三步:业务类代码
第四步:编写切面类MyCacheAop
三、Redis实现分布式锁
3.1 原理
3.2 初始化库存
3.3 Redis实现分布式锁
3.4 JMeter工具测试
3.5 优雅实现分布式锁(Redis+AOP+自定义注解)
第一步:自定义注解
第二步:抽取加锁释放锁的公共代码
四、Redis+Token机制实现接口幂等性校验
4.1 接口幂等性校验使用场景
4.2 原理图
4.3 编写一般业务代码
4.4 接口幂等性实现步骤
第一步:自定义注解
第二步:定义拦截器
第三步:注册拦截器
第四步:测试
幂等性总结★★★
五、接口防刷功能
5.1 防刷概述
5.2 自定义注解
5.3 拦截器
5.4 配置拦截器
5.5 业务接口&测试
5.6 延伸:@Resource和@Autowired的区别
一. 新建springBoot项目整合Redis
新建一个基于maven构建的项目,加入SpringBoot和Redis相关依赖,写一个接口进行测试,看是否可以对Redisi进行存值和取值。
项目结构:
pom文件内容如下:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.wuya</groupId>
<artifactId>springbootRedisDemo</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<!-- springboot相关的jar包 -->
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-parent -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.14</version>
</parent>
<dependencies>
<!-- web依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- fastjson-->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
</dependencies>
</project>
启动类:
package org.wuya;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class,args);
}
}
测试类:
package org.wuya.controller;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/mytest")
public class FirstController {
@Resource
private RedisTemplate redisTemplate;
/**
* 测试Redis是否可以正常存取值
*/
@GetMapping("/redisTest/{value}")
public String redisTest(@PathVariable String value) {
redisTemplate.opsForValue().set("food", value, 20, TimeUnit.MINUTES);
return (String) redisTemplate.opsForValue().get("food");
}
/**
* 测试SpringBoot环境
*/
@GetMapping("/test")
public String testSpringBoot() {
return "SpringBoot项目搭建成功";
}
}
application.yaml配置文件:
server:
port: 8081
spring:
redis:
#Redis服务器IP地址(centos105虚拟机)
host: 192.168.6.105
port: 6379
#Redis服务器连接密码(默认为空)
#password: 123456
#Redis数据库索引(默认为0)
database: 0
#连接超时时间(毫秒)
timeout: 2000000
jedis:
pool:
#连接池最大连接数(使用负值表示没有限制)
max-active: 20
#连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1
#连接池中的最大空闲连接
max-idle: 10
#连接池中的最小空闲连接
min-idle: 0
CacheConfig配置类(非必需):
package org.wuya.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
/**
* Redis配置类,目的是做序列化(Redis会默认使用JdkSerializationRedisSerializer序列化器)
*/
@Configuration
public class CacheConfig extends CachingConfigurerSupport {
@Autowired
private RedisConnectionFactory factory;
/**
* 向Spring容器注入一个RedisTemplate对象,采用GenericJackson2JsonRedisSerializer这个序列化器进行序列化
*/
@Bean
public RedisTemplate<Object, Object> redisTemplate() {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
//序列化器
GenericJackson2JsonRedisSerializer myRedisSerializer = new GenericJackson2JsonRedisSerializer();
//String类型数据key、value的序列化
redisTemplate.setKeySerializer(myRedisSerializer);
redisTemplate.setValueSerializer(myRedisSerializer);
//hash结构key、value的序列化
redisTemplate.setHashKeySerializer(myRedisSerializer);
redisTemplate.setHashValueSerializer(myRedisSerializer);
return redisTemplate;
}
}
启动Redis服务端,再运行SpringBoot启动类App.java,然后在浏览器进行访问:
http://localhost:8081/mytest/test
localhost:8081/mytest/redisTest/张三333
二. Redis实现分布式缓存
2.1 原理及好处
优点:
- 使用Redis作为共享缓存,解决缓存不同步问题
- Redis是独立的服务,缓存不用占应用本身的内存空间
什么样的数据适合放到缓存中呢?(同时满足以下两个条件)
- 经常要查询的数据
- 不经常改变的数据
2.2 数据准备
创建domain包,并创建SystemInfo实体类
package org.wuya.domain;
import lombok.Data;
@Data
public class SystemInfo {
private Long id;
private String key;
private String value;
}
创建SystemController
package org.wuya.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.wuya.domain.SystemInfo;
import org.wuya.service.SystemService;
import java.util.List;
@RestController
@RequestMapping("/system")
public class SystemController {
@Autowired
private SystemService systemService;
//访问 http://localhost:8081/system/querySystemInfo
@GetMapping("/querySystemInfo")
public List<SystemInfo> querySystemInfo() {
//模拟从数据库中查询数据
List<SystemInfo> systemInfoList = systemService.querySystemInfo();
//TODO 页面多次访问上面地址,只要打印一次这句话,表示数据是查询的MySQL数据库
System.out.println("从数据库中查询到数据~");
return systemInfoList;
}
}
创建service包,并创建SystemService,用于准备数据
package org.wuya.service;
import org.springframework.stereotype.Service;
import org.wuya.domain.SystemInfo;
import java.util.ArrayList;
import java.util.List;
@Service
public class SystemService {
public List<SystemInfo> querySystemInfo() {
//造10条数据,模拟从数据库中查询数据
List<SystemInfo> list = new ArrayList<>();
for (long i = 1; i <= 10; i++) {
SystemInfo systemInfo = new SystemInfo();
systemInfo.setId(i);
systemInfo.setKey("key" + i);
systemInfo.setValue("波哥" + i);
list.add(systemInfo);
}
return list;
}
}
测试:
访问上面controller中地址,每刷新一次,控制台打印一次“从数据库中查询到数据~”这句话,表示都是查询的数据库。
2.3 Redis实现分布式缓存
只改动SystemController中的代码即可,具体如下:
package org.wuya.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.wuya.domain.SystemInfo;
import org.wuya.service.SystemService;
import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/system")
public class SystemController {
@Autowired
private SystemService systemService;
@Autowired
private RedisTemplate redisTemplate;
//访问 http://localhost:8081/system/querySystemInfo
@GetMapping("/querySystemInfo")
public List<SystemInfo> querySystemInfo() {
//1.查询Redis缓存,存在数据直接返回
List<SystemInfo> systemInfoList = (List<SystemInfo>) redisTemplate.opsForValue().get("system:info");
if (systemInfoList != null) {
System.out.println("从Redis中取数据");
return systemInfoList;
}
//2.Redis没有数据,查询数据库,往Redis缓存写一份,再返回
List<SystemInfo> dBsystemInfoList = systemService.querySystemInfo();
redisTemplate.opsForValue().set("system:info", dBsystemInfoList, 2, TimeUnit.HOURS);
System.out.println("从数据库中查询到数据~");
return dBsystemInfoList;
}
}
测试效果:
思考:为什么以上的代码可以解决分布式缓存?
因为上面的代码,即使同时在多台服务器部署,也都是先去Redis中查数据,实际查询数据库次数只有一次。
2.4 优雅实现分布式缓存(Redis+AOP+自定义注解)
在上面 2.3 中功能已经实现了,但是有个问题,那就是每个需要做缓存的接口都需要redisTemplate去取和存一下,会产生大量重复代码,这样太不优雅了,下面我们就是
用AOP+自定义注解来消除这些重复代码。
为了避免每次都用redisTemplate操作,创建RedisTool工具类。
第0步:准备RedisTool工具类
创建utils包,将它下面创建RedisTool类:
package org.wuya.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.concurrent.TimeUnit;
@Component
public class RedisTool {
@Autowired
private RedisTemplate redisTemplate;
/**
* 根据key删除对应的value
* @param key
* @return
*/
public boolean remove(final String key) {
if (exists(key)) {
Boolean delete = redisTemplate.delete(key);
return delete;
}
return false;
}
/**
* 根据key删除缓存中是否有对应的value
*/
public boolean exists(final String key) {
return redisTemplate.hasKey(key);
}
/**
* 获取锁
*
* @param lockKey 锁
* @param value 身份标识(保证锁不会被其他人释放)
* @return 获取锁成功返回true,获取锁失败返回false
*/
public boolean lock(String lockKey, String value) {
//如有多个线程同时操作的话,只会保证有一个线程把key设置到Redis中成功
return redisTemplate.opsForValue().setIfAbsent(lockKey, value);
}
/**
* 释放锁
*
* @param key
* @param value
* @return 释放成功返回true,失败返回false
*/
public boolean unlock(String key, String value) {
Object currentValue = redisTemplate.opsForValue().get(key);
boolean result = false;
if (StringUtils.hasLength(String.valueOf(currentValue)) && currentValue.equals(value)) {
result = redisTemplate.opsForValue().getOperations().delete(key);
}
return result;
}
/**
* 根据key获得缓存的基本对象
*
* @param key
* @param <T>
* @return
*/
public <T> T getCacheObject(final String key) {
ValueOperations<String, T> valueOperations = redisTemplate.opsForValue();
return valueOperations.get(key);
}
/**
* 写入缓存设置失效时间
*
* @param key
* @param value
* @param expireTime
* @return
*/
public boolean setEx(final String key, Object value, Long expireTime) {
boolean result = false;
try {
ValueOperations valueOperations = redisTemplate.opsForValue();
valueOperations.set(key, value);
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key
* @param value
* @param timeout
* @param timeUnit
* @param <T>
*/
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) {
if (timeout == -1) {
//不设置过期时间,表示永久有效
redisTemplate.opsForValue().set(key, value);
} else {
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
}
}
第一步:导入AOP依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
第二步:自定义注解
创建annotation包,在包中定义注解MyCache
package org.wuya.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyCache {
String cacheNames() default "";
String key() default "";
//缓存时间(单位秒,默认是无限期)
int time() default -1;
}
第三步:业务类代码
//访问 http://localhost:8081/system/querySystemInfo2
@GetMapping("/querySystemInfo2")
@MyCache(cacheNames = "system",key = "systeminfo")
public List<SystemInfo> querySystemInfo2() {
List<SystemInfo> dBsystemInfoList = systemService.querySystemInfo();
System.out.println("querySystemInfo2从数据库中查询到数据~");
return dBsystemInfoList;
}
第四步:编写切面类MyCacheAop
创建aop包,在包下编写切面类。
package org.wuya.aop;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.wuya.annotation.MyCache;
import org.wuya.utils.RedisTool;
import java.util.concurrent.TimeUnit;
@Component
@Aspect
public class MyCacheAop {
@Autowired
private RedisTool redisTool;
/**
* 定义切点(含义:拦截被 @MyCache 标记的方法)
*/
@Pointcut("@annotation(myCache)")
public void pointCut(MyCache myCache) {
}
/**
* 环绕通知
*/
@Around("pointCut(myCache)")
public Object around(ProceedingJoinPoint joinPoint, MyCache myCache) {
String cacheNames = myCache.cacheNames();
String key = myCache.key();
int time = myCache.time();
String redisKey = new StringBuilder(cacheNames).append(":").append(key).toString();
Object redisData = redisTool.getCacheObject(redisKey);
if (redisData != null) {
System.out.println("优雅地从Redis分布式缓存中查到数据");
return redisData;
}
Object dbData = null;
try {
//Redis缓存中没有数据时,joinPoint执行目标方法
dbData = joinPoint.proceed();
//将数据库中查询到的数据存入Redis缓存
redisTool.setCacheObject(redisKey, dbData, time, TimeUnit.SECONDS);
} catch (Throwable e) {
throw new RuntimeException(e);
}
return dbData;
}
}
注意:切面类上除了@Component注解,切得要加上@Aspect注解。
AOP+自定义注解实现分布式缓存的优点:
三、Redis实现分布式锁
解决高并发库存超卖等问题。
先介绍一下场景:我现在有3台最新款Phone拿出来做秒杀活动,回馈新老客户,只要9.9元,今晚8点开抢,那肯定有很多人来抢。这就是典型的高并发场景,8点会有很多请求进来,可能1秒钟就抢光了,就没有余量了,这种场景我们怎么保证商品不超卖呢?分布式锁!下面我就来模拟一下上面所说的场景,库存我就不用MySQL做了,我就放到Rdis中了,做个缓存预热。
3.1 原理
setnx实现分布式锁原理(见上图):它的特点是设置key到Redis成功,返回true,表示拿到了锁;设置key到Redis失败,返回false,表示没拿到了锁。(对应setIfAbsent这个API)
库存预热:因为秒杀(高并发)场景下,瞬间访问可能倍增,所以需在秒杀活动开始前设置库存到Redis,这样就不会查询数据库了,起到保护数据库的效果。
/**
* 获取锁
*
* @param lockKey 锁
* @param value 身份标识(保证锁不会被其他人释放)
* @return 获取锁成功返回true,获取锁失败返回false
*/
public boolean lock(String lockKey, String value) {
//如有多个线程同时操作的话,只会保证有一个线程把key设置到Redis中成功
return redisTemplate.opsForValue().setIfAbsent(lockKey, value);
}
3.2 初始化库存
初始化库存,即库存预热,往Redis存数据(存三台手机),在FirstController类中添加如下代码:
@Resource
private RedisTool redisTool;
/**
* 初始化phone库存为3台
* @return
*/
// http://localhost:8081/mytest/lock/stockInit
@GetMapping("/stockInit")
public String stockInit() {
redisTool.setCacheObject("phone", "3", -1, TimeUnit.SECONDS);
return "初始化库存成功!";
}
3.3 Redis实现分布式锁
编写秒杀类SeckillController,实现分布式锁。
package org.wuya.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.wuya.utils.RedisTool;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private RedisTool redisTool;
/**
* 用户下单接口
*/
// http://localhost:8081/seckill/saveOrder
@GetMapping("/saveOrder")
public ResponseEntity<String> saveOeder() {
//假如用户下单的商品ID是1001,就是秒杀这个商品(实际应该是用户从前端从过来的)
String productId = "1001";
String threadName = Thread.currentThread().getName();
try {
//既然是秒杀场景,肯定会有很多请求,即会有很多线程。为了不超卖,这里需要去尝试获取锁
boolean locked = getLock(productId, threadName);
//获取到了锁,就可以开始扣减库存了
if (locked) {
//这里应该从DB查询得到商品的库存,这里只是模拟,直接从Redis中获取到剩余库存
Object phone = redisTool.getCacheObject("phone");
if (phone == null) {
ResponseEntity.status(HttpStatus.NOT_FOUND).body("lock_error");
}
int phoneStockNum = Integer.parseInt(phone.toString());
//拿到了锁,不一定就能下单成功,还必须有库存才行,故须加个判断,否则会超卖
if (phoneStockNum > 0) {
System.out.println("线程:" + threadName + " 获取到了锁,还有库存量:" + phoneStockNum);
int currentPhoneStockNum = phoneStockNum - 1;
redisTool.setCacheObject("phone", currentPhoneStockNum, -1, TimeUnit.SECONDS);
System.out.println("线程:" + threadName + "下单成功,扣减之后的剩余量:" + currentPhoneStockNum);
return ResponseEntity.status(HttpStatus.OK).body("save phone stock success,current stock:" + currentPhoneStockNum);
} else {
System.out.println("线程:" + threadName + " 获取到了锁,库存已经为0");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("stock is zero");
}
}
//代码走到这里,表示没有抢到锁,那就直接返回友好提示
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("保存订单失败");
} finally {
System.out.println("线程:" + threadName + "释放了锁");
//TODO 释放锁是productId !!!!!! 不是phone !!!(导致测试一直失败)
//再次测试时,要把Redis中上次出错的key=1001的key删掉,否则上锁时不能成功!
//因为上锁的原理是setIfAbsent(lockKey, value),如果存在productId="1001"的key,线程是拿不到锁的!
redisTool.unlock(productId, threadName);
}
}
//获取锁
private boolean getLock(String key, String value) {
boolean lock = redisTool.lock(key, value);
if (lock) {
return true;
} else {
//递归!!!没有拿到锁的线程继续递归,自旋
return getLock(key, value);
}
}
}
延伸:
- ResponseEntity是org.springframework.http.ResponseEntity包中的类,以后可以使用;
- HttpStatus也是org.springframework.http.HttpStatus包中的类,以后可以使用;
- Assert是org.springframework.util包中的类,以后可以使用;
org.springframework.util包中还有Base64Utils、CollectionUtils、StringUtils、JdkIdGenerator、FileCopyUtils等工具类,都可以直接使用哦。
3.4 JMeter工具测试
总结:锁的是商品ID(productId),抢到锁之后调用Redis的API扣减库存时可以是商品的名称如“phone”,这两个不能是同一个值。加锁时用的API是setIfAbsent,扣库存用的是普通的set方法。
3.5 优雅实现分布式锁(Redis+AOP+自定义注解)
分布式锁的功能上面已经实现了,但如果一个项目中很多地方都需要使用到分布式锁解决一些并发问题的话,那么这这些接口中就都需要写获取锁、释放锁等代码了,非常冗余,此时我们可以利用AOP的思想将重复代码抽取出来。
第一步:自定义注解
package org.wuya.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 用于标记加Redis分布式锁
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
}
第二步:抽取加锁释放锁的公共代码
抽取后,别忘记业务代码上加@RedisLock注解,切面类上加@Component和@Aspect注解。
业务代码:
/**
* 用户下单接口(优雅实现Redis分布式锁)
*/
// http://localhost:8081/seckill/saveOrder2
@GetMapping("/saveOrder2")
@RedisLock
public ResponseEntity<String> saveOeder2() {
//这里应该从DB查询得到商品的库存,这里只是模拟,直接从Redis中获取到剩余库存
Object phone = redisTool.getCacheObject("phone");
if (phone == null) {
ResponseEntity.status(HttpStatus.NOT_FOUND).body("lock_error");
}
int phoneStockNum = Integer.parseInt(phone.toString());
//拿到了锁,不一定就能下单成功,还必须有库存才行,故须加个判断,否则会超卖
if (phoneStockNum > 0) {
int currentPhoneStockNum = phoneStockNum - 1;
redisTool.setCacheObject("phone", currentPhoneStockNum, -1, TimeUnit.SECONDS);
return ResponseEntity.status(HttpStatus.OK).body("save phone stock success,current stock:" + currentPhoneStockNum);
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("stock is zero");
}
}
切面类代码:
package org.wuya.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.wuya.annotation.RedisLock;
import org.wuya.utils.RedisTool;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
/**
* 被@RedisLock所注解的方法,会被RedisLockAspect进行切面管理
*/
@Slf4j //这个注解是lombok的
@Component
@Aspect
public class RedisLockAspect {
@Resource
private RedisTool redisTool;
//@Around(value = "@annotation(redisLock)", argNames = "joinPoint,redisLock")
@Around("@annotation(redisLock)") //这两种注解的写法都行的。MyCacheAop.java中定义切点那两行代码可以删掉
public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
//获取request对象
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = sra.getRequest();
String requestURI = request.getRequestURI();
//获取入参商品ID
String productId = requestURI.substring(requestURI.lastIndexOf("/") + 1);
//TODO 实际开发中是根据上面的方式获取商品ID,这里模拟商品名是1002
productId = "1002";
//获取线程名
String threadName = Thread.currentThread().getName();
Object result = null;
try {
boolean lock = getLock(productId, threadName);
if (lock) {
//执行业务逻辑
log.info("线程:{},获取到了锁,开始处理业务", threadName);
result = joinPoint.proceed();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
redisTool.unlock(productId, threadName);
log.info("线程:{},业务代码处理完毕,锁已释放", threadName);
}
return result;
}
//获取锁
private boolean getLock(String key, String value) {
boolean lock = redisTool.lock(key, value);
if (lock) {
return true;
} else {
//递归!!!没有拿到锁的线程继续递归,自旋
return getLock(key, value);
}
}
}
经测试,没问题的。
四、Redis+Token机制实现接口幂等性校验
常见的接口幂等性实现方案有多种方法:
- 数据库唯一主键;
- 数据库乐观锁-版本号机制;
- 防重Token令牌;
- 分布式锁等等;
Redis+Token机制实现接口幂等性的优点:它的实现方式最优雅,使用比较广泛,简单易于扩展。所以在此介绍防重Token令牌的实现——使用Redis+拦截器+自定义注解,进行实现接口幂等性。
4.1 接口幂等性校验使用场景
4.2 原理图
4.3 编写一般业务代码
下面是有问题的代码,用JMeter并发访问用户下单接口saveOrder(),模拟用户连续点击多次,看到控制台输出N次结果都成功了。这肯定是有问题的!
package org.wuya.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.JdkIdGenerator;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.wuya.utils.RedisTool;
@RestController
@RequestMapping("/order")
public class CheckIdempotentController {
@Autowired
private RedisTool redisTool;
/**
* 获取token
*/
//访问路径:http://127.0.0.1:8081/order/token
@GetMapping("/token")
public ResponseEntity<String> getToken() {
//得到token
String token = new JdkIdGenerator().generateId().toString();
//存入Redis(设置5分钟后过期)(token对应的值不重要)
boolean result = redisTool.setEx(token, token, 300L);
if (result) {
return ResponseEntity.ok(token);
}
return ResponseEntity.ok("token error");
}
/**
* 用户下单接口
*/
//访问路径:http://127.0.0.1:8081/order/saveOrder
@GetMapping("/saveOrder")
public ResponseEntity<String> saveOrder() {
System.out.println("******用户下单成功******");
//将数据保存在数据库中
//........
return ResponseEntity.ok("saveOrder success");
}
}
4.4 接口幂等性实现步骤
第一步:自定义注解
记得在业务方法上面添加此注解,用于标识该方法需要幂等性校验。
package org.wuya.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 接口幂等性校验的自定义注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckIdempotent {
}
第二步:定义拦截器
创建interceptor包,在包中创建幂等性校验的拦截器类CheckIdempotentInterceptor
package org.wuya.interceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.wuya.annotation.CheckIdempotent;
import org.wuya.utils.RedisTool;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Method;
/**
* 接口幂等性校验的拦截器
*/
@Component
public class CheckIdempotentInterceptor implements HandlerInterceptor {
@Autowired
private RedisTool redisTool;
/**
* 前置处理,该方法将在处理之前进行调用
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断:如果拦截到的请求的目标资源不是方法,那就直接返回true放行即可,我们这里只拦截方法的请求
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
CheckIdempotent checkIdempotentAnnotation = method.getAnnotation(CheckIdempotent.class);
//判断拦截的目标方法是否被@CheckIdempotent注解标记
if (checkIdempotentAnnotation != null) {
//被@CheckIdempotent注解标记时,说明需要幂等性校验,于是就要校验token
try {
return checkToken(request);
} catch (Exception e) {
writeReturnJson(response, e.getMessage());
return false;
}
}
//没有被@CheckIdempotent注解标记时,返回true
return true;
}
//返回提示信息给前端
private void writeReturnJson(HttpServletResponse response, String message) {
response.reset();
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=utf-8");
response.setStatus(404);
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
outputStream.print(message);
outputStream.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* token校验
*
* @param request
* @return
*/
private boolean checkToken(HttpServletRequest request) throws Exception {
//从请求头中获取token的值
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
//请求头中不存在token,那就是非法请求,直接抛异常
throw new Exception("illegal request");
}
//删除Redis中的token
boolean remove = redisTool.remove(token);
if (!remove) {
//删除失败了,说明有其他请求抢先一步删除过了,那么此次请求就不能放行了,属于重复请求
throw new Exception("token delete error");
}
return true;
}
}
第三步:注册拦截器
只有注册(配置)了拦截器,才能生效。
package org.wuya.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import org.wuya.interceptor.CheckIdempotentInterceptor;
import javax.annotation.Resource;
/**
* 统一拦截器配置类
*/
@Configuration
public class WebConfiguration extends WebMvcConfigurationSupport {
@Resource
private CheckIdempotentInterceptor checkIdempotentInterceptor;
//条件拦截器
@Override
protected void addInterceptors(InterceptorRegistry registry) {
//checkIdempotentInterceptor拦截器只对/order/saveOrder请求拦截
registry.addInterceptor(checkIdempotentInterceptor).addPathPatterns("/order/saveOrder");
//这里还可以配置(注册)其他类型的拦截器
//registry.addInterceptor(xxxInterceptor).addPathPatterns("url");
super.addInterceptors(registry);
}
}
第四步:测试
- 首先访问路径:http://127.0.0.1:8081/order/token 生成一个token,同时把这个生成的UUID的token作为key存在了Redis(key对应的value不重要);
- 然后,选中JMeter“线程组“”下面的“HTTP请求”,右键→添加→配置原件→HTTP信息头管理器,在其中添加token参数,值为刚刚存在Redis中的那个uuid值;
- 输入请求路径http://127.0.0.1:8081/order/saveOrder等参数,点击测试,效果如上图。
幂等性总结★★★
核心是token校验对token的删除操作(Redis删除key具有原子性),如果删除成功则放行进行执行业务代码,如果失败则进行拦截不会执行业务代码,所以在Redis中存的token(key)的有效期内,同一个用户只能操作一次。
实际开发中如何操作:
- 在用户首次进入页面,还没有任何操作之前,前端vue就会回调后端的一个方法【这个方法用于生成UUID并将生成的uuid作为Redis的key保存在Redis数据库】,然后给到前端进行解析保存;
- 当用户填完页面信息点击“提交”按钮时,前端会将token封装在请求参数中向后端发起请求;
- 后端接收到请求后,先解析请求参数中是否有刚刚存的那个token(token在Redis中存的key为那个uuid),如果有的话,会执行 redisTemplate.delete(uuid);这个方法,如果执行成功才会放行执行业务方法,因为只有一次请求会删除成功,所以就保证了接口幂等性。
五、接口防刷功能
5.1 防刷概述
- 顾名思义,就是要实现某个接口在某段时间内只能让某人访问指定次数,超出次数,就不让访问了
- 原理:在请求的时候,服务器通过Rdis记录下你请求的次数,如果次数超过限制就不让访问
具体应用:如发短信验证码,如果无限制让发的话,会产生费用,所以进行限制次数比较好。
实现方法:用Redis+拦截器/AOP+自定义注解,实现接口防刷功能。我们这里用拦截器。
5.2 自定义注解
package org.wuya.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
/**
* 限流的key
*/
String key() default "limit:";
/**
* 周期,单位是秒
*/
int cycle() default 5;
/**
* 一个周期内允许的请求次数
*/
int count() default 1;
/**
* 默认提示信息
*/
String msg() default "operation is too fast";
}
5.3 拦截器
package org.wuya.interceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.wuya.annotation.RateLimit;
import javax.annotation.Resource;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
/**
* 限流的拦截器
*/
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
//@Autowired //这里使用会报错,报错信息和改错见下面图片
@Resource
private RedisTemplate<String, Integer> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//如果请求的是方法,则需要做校验
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
//拦截的请求的目标方法没有RateLimit注解
return true;
}
//方法上有RateLimit注解,需校验是否在刷接口
String ip = request.getRemoteAddr();
String uri = request.getRequestURI();
String key = "RateLimit:" + ip + ":" + uri;
if (redisTemplate.hasKey(key)) {
//如果缓存中存在key,则访问次数+1
redisTemplate.opsForValue().increment(key, 1);
if (redisTemplate.opsForValue().get(key) > rateLimit.count()) {
System.out.println("操作太频繁了,当前时间:" + getCurrentTime());
writeReturnJson(response, rateLimit.msg());
return false;
}
//未超出访问次数限制,不进行拦截操作,返回true
} else {
//第一次设置数据,过期时间为注解确定的访问周期
redisTemplate.opsForValue().set(key, 1, rateLimit.cycle(), TimeUnit.SECONDS);
System.out.println("设置过期时间,当前时间:" + getCurrentTime());
}
return true;
}
//如果请求的不是方法,直接放行
return true;
}
private static String getCurrentTime() {
LocalDateTime localDateTime = LocalDateTime.now();
return localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss SSS"));
}
//返回提示信息给前端
private void writeReturnJson(HttpServletResponse response, String message) {
response.reset();
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=utf-8");
response.setStatus(404);
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
outputStream.print(message);
outputStream.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
上面代码中,使用@Autowired注解自动注入RedisTemplate<String, Integer> redisTemplate;时会报错(见下图),而使用@Resource时不会报错。
如果非要使用@Autowired时,可以在任意一个配置类中注入一个redisTemplate的Bean,如下:
@Bean
public RedisTemplate<String, Integer> redisTemplate2() {
RedisTemplate<String, Integer> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
//序列化器
GenericJackson2JsonRedisSerializer myRedisSerializer = new GenericJackson2JsonRedisSerializer();
//String类型数据key、value的序列化
redisTemplate.setKeySerializer(myRedisSerializer);
redisTemplate.setValueSerializer(myRedisSerializer);
//hash结构key、value的序列化
redisTemplate.setHashKeySerializer(myRedisSerializer);
redisTemplate.setHashValueSerializer(myRedisSerializer);
return redisTemplate;
}
5.4 配置拦截器
在WebConfiguration配置类中,添加上这个防刷功能的拦截器:
/**
* 统一拦截器配置类
*/
@Configuration
public class WebConfiguration extends WebMvcConfigurationSupport {
@Resource
private CheckIdempotentInterceptor checkIdempotentInterceptor;
@Resource
private RateLimitInterceptor rateLimitInterceptor;
//条件拦截器
@Override
protected void addInterceptors(InterceptorRegistry registry) {
//checkIdempotentInterceptor拦截器只对/order/saveOrder请求拦截
registry.addInterceptor(checkIdempotentInterceptor).addPathPatterns("/order/saveOrder");
//注册防刷功能的拦截器
registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/sendSms");
super.addInterceptors(registry);
}
}
5.5 业务接口&测试
package org.wuya.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.wuya.annotation.RateLimit;
@RestController
public class RateLimitController {
/**
* 发送短信接口:6秒内只能访问两次
*/
// http://localhost:8081/sendSms
@GetMapping("/sendSms")
@RateLimit(cycle = 6,count = 2)
public ResponseEntity<String> sendSms() {
System.out.println("成功发送一条短信");
return ResponseEntity.ok("sendSms success");
}
}
这样做的好处:如果需要实现防刷功能,添加@RateLimit(cycle = 6,count = 2)自定义注解即可,若不需要,直接去掉该注解即可。与业务代码没有耦合,代码简单。
测试结果没问题(6秒内只能访问两次)。
完整的项目结构:
5.7 延伸:@Resource和@Autowired的区别
以下由GitCode AI回答的。
@Resource
和 @Autowired
都是Spring框架用于依赖注入(DI, Dependency Injection)的注解,但它们有一些关键区别:
注解来源:
@Autowired
是Spring框架自带的核心注解。@Resource
来源于JSR 250规范,被Spring框架支持。默认按类型注入:
@Autowired
默认按照类型进行自动装配。如果有多个相同类型的bean,需要指定@Qualifier
来区分。@Resource
默认按名称进行注入。如果没有指定名称,则会寻找字段名相同的bean。属性:
@Autowired
提供了required
属性,可以设置为false
,表示该依赖不是必需的,如果没有找到匹配的bean,Spring不会抛出异常。@Resource
的name
属性用来指定需要注入的bean的名字。使用位置:
@Autowired
更灵活,可以用于字段、构造函数、setter方法以及参数。@Resource
主要用于字段和setter方法,较少用在构造函数参数上。
如何在没有 `@Qualifier` 的情况下解决多个相同类型 Bean 的注入冲突?
在没有 @Qualifier
注解的情况下,处理相同类型 Bean 的注入冲突可以通过以下几种方式:
- 使用 Bean 名称作为属性名
如果 Bean 的名称(默认是方法名)与需要注入的字段名相同,Spring 将自动将该 Bean 注入到字段中,无需任何额外注解。例如:@Configuration public class AppConfig { @Bean public MyService myService() { return new MyServiceImpl(); } } @Component public class SomeClass { private MyService myService; // Spring 会自动注入 'myService' Bean // ... }
- 使用
@Primary
注解
在可能的候选 Bean 中标记一个为@Primary
,Spring 将优先选择这个 Bean 进行自动注入。如果没有其他配置,这可以避免注入冲突。例如:@Configuration public class AppConfig { @Bean @Primary public MyService primaryMyService() { return new MyServiceImpl(); } @Bean public MyService secondaryMyService() { return new MyServiceImpl(); } }
- 使用构造函数注入
创建一个带有所需 Bean 类型参数的构造函数,并让 Spring 自动装配。这样可以选择性地提供所需的 Bean。例如:@Component public class SomeClass { private final MyService myService; @Autowired public SomeClass(MyService myService) { this.myService = myService; } // ... }
- 使用
@Resource
注解@Resource
默认按名称进行注入,因此可以避免注入冲突。如果名称不匹配,它会尝试按类型注入。例如:@Component public class SomeClass { @Resource(name = "primaryMyService") private MyService myService; // ... }
- 使用
@Inject
或 JSR-330 提供的注解
如果你有一个模块化项目并想遵循 Java EE 标准,你可以使用javax.inject.Inject
注解。这种方式也会考虑 Bean 名称,但如果名称未指定则按类型注入。例如:@Component public class SomeClass { @Inject private MyService myService; // ... }
请注意,在这些方法中,你可能需要更改配置以确保每个 Bean 都有一个独特的名字或者设置一个首选的 Bean(如使用 @Primary
)。