目录
Redis概述:
缓存数据的分类:
实时同步数据:要求缓存中的数据必须与DB中的数据保持一致。如何保持?只要DB中的数据发生了变更,缓存中的数据立即消失。
阶段性同步数据:其没有必要与DB中的数据保持一致,只要大差不差就行。如何实现?为缓存数据添加生存时长属性。
缓存在使用的时候有一个预热的过程,就是提前加载一些常见的数据到缓存中。阶段性同步数据就可以在预热中进行缓存。
Redis特性:
- 性能极高:Redis读的速度可以达到11w次/s,写的速度可以可以达到8w次/s。之所以具有这么高的性能,因为有以下几点原因:1.Redis的所有操作都是在内存中发生的。2.Redis是用C语言开发的。3.Redis的源码非常精细。
- 简单稳定:Redis源码很少,早期版本只有2w行左右。从3.0版本开始,增加了集群功能,代码变为了5w行左右。
- 持久化:Redis内存中的数据可以进行持久化。其有两种方式:RDB与AOF。
- 高可用集群:Redis提供了高可用的主从集群功能,可以确保系统的安全性。
- 丰富的数据类型:Redis 是一个 key-value 存储系统。支持存储的 value 类型很多,包括String(字符串)、List(链表)、Set(集合)、Zset(sorted set --有序集合)和 Hash(哈希类型)
等,还有 BitMap、HyperLogLog、Geospatial 类型。BitMap:一般用于大数据量的二值性统计。HyperLogLog:其是 Hyperlog Log,用于对数据量超级庞大的日志做去重统计。Geospatial:地理空间,其主要用于地理位置相关的计算
- 强大的功能:Redis提供了数据过期功能、发布/订阅功能、简单事务功能。还支持Lua脚本扩展功能。
- 客户端语言广泛:Redis提供了简单的 TCP 通信协议,编程语言可以方便地的接入 Redis。所以,有很多的开源社区、大公司等开发出了很多语言的 Redis 客户端。
- 支持ACL权限控制:之前的权限控制非常笨拙。从 Redis6 开始引入了 ACL 模块,可以
为不同用户定制不同的用户权限。ACL,Access Control List,访问控制列表,是一种细粒度的权限管理策略,可以针对任意用户与组进行权限控制。目前大多数 Unix 系统与 Linux 2.6 版本已经支持 ACL 了。 Zookeeper 早已支持 ACL 了。Unix 与 Linux 系统默认使用是 UGO(User、Group、Other)权限控制策略,其是一种粗粒度的权限管理策略。
- 支持多线程IO模型:Redis 之前版本采用的是单线程模型,从 6.0 版本开始支持了多线
程模型。
Redis IO模型
单线程模型:
混合线程模型
多线程模型
单线程模型与多线程模型优缺点
Redis安装与启用
安装gcc
安装指令yum -y install gcc gcc-c++
安装Redis
官网下载redis安装包
Linux上传安装包
rz指令进行上传
Linux解压安装包
tar -zxvf redis安装包 -C 解压路径
Linux安装Redis
进入redis文件输入Linux指令 make 进行安装
安装完后使用Linux指令 make install 进行安装
启动Redis
<1>直接启动
Linux指令 redis-server 这种方式有一个弊端,占用了命令行,当我们CTRL+C发现Redis也退出了
<2>命令式后台启动
Linux指令 nohup redis-server & 进行后台启动,启动成功后会在当前目录多一个文件
<3>配置后台启动
需要配置redis.conf文件,Linux指令 vim redis.conf
输入 / 使用命令模式搜索daemonize,
输入 i 使用输入模式,将no改为yes
输入 redis-server /opt/apps/redis/redis.conf
查看Redis是否启动成功
Linux指令 ps aux | grep redis
停止Redis
输入 redis-cli shutdown
在redis里面输入shutdown
连接前的配置
绑定客户端ID
需要配置redis.conf文件,Linux指令 vim redis.conf
输入 / 使用命令模式搜索bind,
输入 i 使用输入模式,修改目标为以下格式
关闭保护模式
需要配置redis.conf文件,Linux指令 vim redis.conf
输入 / 使用命令模式搜索protected,
输入 i 使用输入模式,将yes改为no
设置密码
需要配置redis.conf文件,Linux指令 vim redis.conf
输入 / 使用命令模式搜索requirepass,
输入 i 使用输入模式,修改目标为以下格式,后面foobared修改为你想改的密码
设置以后进入redis输入无法完成得先输入 auth 密码
禁止/重命名命令
需要配置redis.conf文件,Linux指令 vim redis.conf
输入 / 使用命令模式搜索rename-command,
输入 i 使用输入模式,修改目标为以下格式,表示禁止flushall命令
Redis客户端
命令行客户端
命令行启动客户端
redis-cli -h IP地址 -p 端口号 -a 密码
-h输入客户端的IP地址
-p端口号,一般为6379
-a如果设置了密码,这里得输入密码
如果是本机,无需-h,如果端口号是6379无需-p,如果无密码无需-a
图形界面客户端
1.Redis Desktop Manager
官网为:https://resp.app/(原来是 http://redisdesktop.com)。
2.RedisPlus
RedisPlus 的官网地址为 https://gitee.com/MaxBill/RedisPlus。
Java代码客户端
所谓 Java 代码客户端就是一套操作 Redis 的 API,其作用就像 JDBC 一样,所以 Java 代码客户端其实就是一个或多个 Jar 包,提供了对 Redis 的操作接口。
对 Redis 操作的 API 很多,例如 jdbc-redis、jredis 等,但最常用也是最有名的是 Jedis。
Redis命令
Redis基本命令
心跳命令ping
输入ping命令,会看到pong响应,说明该客户端与Redis的连接时正常的,该命令成为心跳命令
select命令
Redis通用命令
通用指令是部分数据类型的,都可以使用的指令,常见的有
KEYS:查看符合模板的所有key
DEL :删除一个指定的key
EXISTS:判断key是否存在
EXPIRE:给一个key设置有效期,有效期到期时该key会被自动删除
TTL:查看一个key的剩余有效期
key的格式
key允许有多个单词形成层级结构,当额单词之间用“:”隔开,格式如:
项目名:业务名:类型:
这个格式并非固定,也可以根据自己的需求来删除或添加词条
例如:
user相关: admin:user:1
product相关:admin:product:1
String类型
string类型,也就是字符串类型,是Redis最简单的存储类型,其value是字符串,不过根据字符串的格式不同,又可以分为3类:
string:普通字符串
int:整数类型,可以做自增、自减操作
float:浮点类型,可以做自增、自减操作
不管是哪种格式,底层都是字节数组形式存储,只不过是编码方式不同。字符串类型的最大空间不能超过512M
常见命令
SET:添加或修改已经存在的一个String类型的键值对
GET:根据key获取String类型的value
MSET:批量添加多个String类型的键值对
MGET:根据多个key获取多个String类型的value
INCR:让一个整形的key自增1
INCRBY:让一个整形的key自增并指定步长
INCRBYFLOAT:让一个浮点类型的数字自增并指定步长
SETNX:添加一个String类型的键值对,前提是这个key不存在,否则不执行
SETEX:添加一个String类型 的键值对,并且指定有效期
Hash类型
hash类型也叫散列,其value是一个无序字典,类似于Java中的HashMap结构
常见命令
HSET key field value:添加或者修改hash类型key的field的值
HGET key field:获取一个hash类型key 的field的值
HMSET:批量添加多个hash类型key的field的值
HMGET:批量获取多个hash类型key的field的值
HGETALL:获取一个hash类型的key的所有field和value
HKEYS:获取一个hash类型的key中所有的field
HVALS:获取一个hash类型的key中所有的value
HINCRBY:让一个hash类型的key的字段值自增并指定步长
HSETNX:添加一个hash类型的key的field值,前提是这个field不存在,否则不执行
List类型
list类型与Java中的LinkedList类似,可以看作一个双向链表结构。既可以支持正向检索,也可以支持反向检索。特征也与LinkedList类似:
- 有序
- 元素可以重复
- 插入和删除快
- 查询速度一般
常用来存储一个有序数据,例如:朋友圈点赞列表,评论列表等。
常见命令
LPUSH key element . . . :向列表左侧插入一个或多个元素
LPOP key:移除并返回列表左侧的第一个元素
RPUSH key element . . . :向列表右侧插入一个或多个元素
RPOP key:移除并返回列表右侧的第一个元素
LRANGE key star end:返回一段角标范围内的所有元素
BLPOP和BRPOP:与LPOP和RPOP类似,只不过在没有元素时等待指定时间,而不是直接返回nil
Set类型
set类型与Java中的HashSet类似,可以看作一个value为null的HashMap。也可以是一个hash表,因此具备与HashSet类似的特征
- 无序
- 元素不可重复
- 查找快
- 支持交集并集差集等功能
常见命令
SADD key member . . . :向set中添加一个或多个元素
SREM key member . . . :移除set中的指定元素
SCARD key:返回set中元素的个数
SISMEMBER key member:判断一个元素是否存在与set中
SMEMBERS:获取set中的所有元素
SINTER key1 key2 . . . :求key1与key2的交集
SDIFF key1 key2 . . . :求key1与key2的差集
SUNION key1 key2 . . . :求key1与key2的并集
Zset类型
是一个可排序的set集合,与Java中的TreeSet类似,但底层数据结构却差别很大。Zset中的每一个元素都带一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加hash表,具有以下特性
- 可排序
- 元素不可重复
- 查询速度快
常见命令
ZADD key score member:添加一个或多个元素到Zset,如果寂静存在则更新其score值
ZREM key member:删除Zset中的一个指定元素
ZSCORE key member:获取Zset中指定元素的score值
ZRANK key member:获取Zset中指定元素的排名
ZCARD key:获取Zset中的元素个数
ZCOUNT key min max:统计score值在给定范围内的所有元素的个数
ZINCRBY key increment member:让Zset中的指定元素自增,步长为指定的increment的值
ZRANGE key min max:按照score排序后,获取指定排名范围内的元素
ZRANGEBYSCORE key min max:按照score排序后,获取指定score范围内的元素
ZDIFF、ZINTER、ZUNION:求差集、交集、并集
注:所有的排名默认都是升序,如果要降序则在命令的Z后面添加REV即可
Java客户端
Jedis
导入依赖GitHub - redis/jedis: Redis Java client designed for performance and ease of use.
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.3.0</version>
</dependency>
导入junit
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.1</version>
<scope>test</scope>
</dependency>
1.进入test,引入Jedis,
- 建立与Reids的链接,
- 输入密码,
- 选择数据库
2.调用Jedis,使用Redis命令
3.释放资源
public class jedis {
private Jedis jedis;
@BeforeEach
void setUp() {
//1.建立连接
jedis = new Jedis("192.168.80.135", 6379);
//2.设置密码
//jedis.auth("");
//3.选择数据库
jedis.select(0);
}
@Test
void testString(){
String setnames = jedis.set("name", "wangwu");
System.out.println(setnames);
String getname = jedis.get("name");
System.out.println(getname);
}
@AfterEach
void tearDown(){
if (jedis!=null){
jedis.close();
}
}
}
Jedis连接池
Jedis本身是线程不安全的,并且频繁的创建和销毁连接会有性能损耗,因此推荐使用Jedis连接池代替Jedis的直连方式。
public class JedisConnectionFactory {
private static final JedisPool jedisPool;
static {
//配置连接池
JedisPoolConfig poolConfig = new JedisPoolConfig();
//最大连接数
poolConfig.setMaxTotal(8);
//最大空闲连接
poolConfig.setMaxIdle(8);
//最小空闲连接
poolConfig.setMinIdle(0);
//设置最长等待时间
poolConfig.setMaxWaitMillis(1000);
//创建连接池对象
jedisPool = new JedisPool(poolConfig, "192.168.80.135", 6379, 1000);
}
public static Jedis getJedis() {
return jedisPool.getResource();
}
}
StringDataRedis
SpringData是Spring中数据操作的模块,包含对各种数据库的集成,其中对Redis的集成模块就叫做SpringDataRedis,官网地址:https://spring.io/projects/spring-data-redis
- 提供了对不同Redis客户端的整合(Lettuce和Jedis)
- 提供了RedisTemplate统一API来操作Redis 支持Redis的发布订阅模型
- 支持Redis哨兵和Redis集群
- 支持基于Lettuce的响应式编程
- 支持基于JDK、JSON、字符串、Spring对象的数据序列化及反序列化
- 支持基于Redis的JDKCollection实现
快速入门
1.依赖,有StringDataRedis依赖以及连接池的依赖(idea版本为2.5.7)
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--redis连接池-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
2.设置Reids配置(我是用的yml格式)
spring:
redis:
port: 6379
host: 192.168.80.135
lettuce:
pool:
max-wait: 1000
max-idle: 8
max-active: 8
min-idle: 0
3.自动注入redistemplate
@Autowired
private RedisTemplate redisTemplate;
@Test
void contextLoads() {
redisTemplate.opsForValue().set("name","wangwu");
Object name = redisTemplate.opsForValue().get("name");
System.out.println(name);
}
序列化方式
RedisTemplate可以接收任意Object作为值写入Redis,只不过写入前会把Object序列化为字节形式,默认是采用JDK序列化。缺点是
- 可读性差
- 内存占用较大
采用自定义的序列化方式
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
//创建RedisTempalte对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
//设置连接工厂
template.setConnectionFactory(connectionFactory);
//创建JSON序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
//设置Key的序列化
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 设置Value的序列化
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);
// 返回
return template;
}
}
在使用序列化的时候要加入jackson依赖
<!--Jackson依赖-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
使用自动序列化有一个问题,储存的Redis数据里面夹带了反序列化时需要的对象路径的数据,这个数据有时候比储存的数据还要大,Redis数据储存在内存中,这样会导致浪费空间。
为了节省内存空间,我们不会使用JSON序列化器来处理value,而是同意使用String序列化器,要求只能存储String类型的key和value。当需要存储Java对象时,手动完成对象的序列化和反序列化
String默认提供了一个StringRedisTemplare类,它的key和value的序列化方式默认就是String方式。省区了我们自定义RedisTemplate的过程
@SpringBootTest
class StringRedisApplicationTests {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
void contextLoads() {
stringRedisTemplate.opsForValue().set("name", "wangwu");
Object name = stringRedisTemplate.opsForValue().get("name");
System.out.println(name);
}
private static final ObjectMapper mapper=new ObjectMapper();
@Test
void user() throws JsonProcessingException {
// stringRedisTemplate.opsForValue().set("user:name",new User("lisi",21));
// User user = (User) stringRedisTemplate.opsForValue().get("user:name");
// System.out.println("user = " + user);
//创建对象
User user = new User("lisi",31);
//手动序列化
String userjson = mapper.writeValueAsString(user);
//导入数据
stringRedisTemplate.opsForValue().set("user:name:2",userjson);
//获取数据
String usergetjson = stringRedisTemplate.opsForValue().get("user:name:2");
//手动反序列化
User user1 = mapper.readValue(usergetjson,User.class);
System.out.println("user1 = " + user1);
}
}
Redis实战
短信登录
发送短信验证码
- 校验手机号是否符合正确的手机号的格式,采用正则表达式
- 校验失败返回失败信息
- 校验成功生成验证码
- 保存验证码
- 发送验证码
public Result sendCode(String phone, HttpSession session) {
// 1.验证手机号
boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
log.debug("手机号验证{}", phoneInvalid);
if (phoneInvalid) {
// 2.验证失败
return Result.fail("手机号格式不对");
}
// 3.验证成功生成验证码
String code = RandomUtil.randomNumbers(6);
log.debug("生成的验证码{}", code);
// 4.保存验证码到session
session.setAttribute("code", code);
// 5.发送验证码
log.debug("验证码发送成功,验证码{}", code);
// 6.返回
return Result.ok();
}
短信登录验证、注册
- 验证手机号格式是否正确(每一步都要验证一次)
- 格式错误,返回错误信息
- 比对验证码是否相同
- 不相同,返回错误信息
- 相同,通过手机号查找用户是否存在
- 不存在,创建新用户
- 保存用户到Session
/**
* 登录验证
*
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
// 检验手机号是否是正确格式
if (RegexUtils.isPhoneInvalid(phone)) {
// 格式错误返回错误信息
log.debug("登录验证手机号格式不正确");
return Result.fail("您输入的手机号格式不正确");
}
String loginCode = loginForm.getCode();
// 根据手机号获取Redis里面的code
String RedisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
// 比对验证吗是否一致
if (loginCode == null || !RedisCode.equals(loginCode)) {
// 比对失败返回错误信息
log.debug("登录验证码不正确");
return Result.fail("您未输入验证码或您输入的验证码不正确");
}
// 验证该手机号用户是否存在
User user = query().eq("phone", phone).one();
if (user == null) {
// 不存在创建新用户
user = createUserWithPhone(phone);
}
// 保存用户到Redis
// 生成随机的token当作令牌
String token = UUID.randomUUID().toString(true);
// 将user复制给UserDto,目的是不要将用户的全部信息返回给前端
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 将user对象转换为map集合
Map<String, Object> UserMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),CopyOptions
.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldaValue)->fieldaValue.toString()));
// 保存到redis中
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,UserMap);
// 设置登录过期时间
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);
}
/**
* 创建新用户
* @param phone
* @return
*/
private User createUserWithPhone(String phone) {
// 创建User
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(5));
// 保存用户
save(user);
log.debug("用户保存成功");
return user;
}
基于Session实现登录
登陆前要进行拦截,来判断用户的账号是否存在,以及一些需要登录才能访问的页面也需要拦截器进行拦截。
- 编写拦截器
- 获取session
- 获取session中的用户
- 判断用户是否存在
- 不存在,拦截,返回状态码401
- 保存用户信息到ThreadLocal
- 放行
- 开启拦截器
public class LoginInterceptorXiang implements HandlerInterceptor {
/**
* 在control前进行
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取session
HttpSession session = request.getSession();
// 获取session里的用户
Object user = session.getAttribute("user");
// 判断用户是否存在
if (user == null) {
response.setStatus(401);
return false;
}
// 不存在返回错误状态
// 存在保存用户到ThreadLocald
UserHolder.saveUser((UserDTO) user);
return true;
}
/**
* 在control方法后进行
* @param request
* @param response
* @param handler
* @param modelAndView
* @throws Exception
*/
// @Override
// public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
// }
/**
* 在渲染后进行
* @param request
* @param response
* @param handler
* @param ex
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolderXiang.removeUser();
}
}
@Configuration
public class MvcConfigXiang implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptorXiang())
.excludePathPatterns(
"/user/code",
"/user/login"
);
}
}
使用Redis共享Session
发送短信登录验证码
- 校验手机号是否符合正确的手机号的格式,采用正则表达式
- 校验失败返回失败信息
- 生成随机验证码
- 将验证码作为String类型存入Redis,key是手机号,value是验证码
- 发送验证码
/**
* 验证手机号格式是否正确
*
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 验证手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 手机号不符合规范
log.debug("手机号格式不对");
return Result.fail("手机号格式不对");
}
// 手机号符合规范,生成验证码
String code = RandomUtil.randomNumbers(6);
log.debug("生成的验证码{}", code);
// 保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TIME, TimeUnit.MINUTES);
// 发送验证码d
log.debug("发送验证码成功,验证码{}", code);
return Result.ok();
}
短信登录验证、注册
- 验证手机号格式是否正确
- 格式错误,返回错误信息
- 根据手机号取出Redis里面存储的验证码
- 比对验证码是否与输入的验证码一致
- 比对失败,返回错误信息
- 比对成功,验证该手机号用户是否存在
- 不存在,创建新用户,保存到数据库
- 存在,保存用户到Redis里面(这里用到一个时间,30分钟将会自动删除Redis用户,以此来模仿Session30分钟没操作自动退出登录)
- 生成随机的token当作令牌
- 将User复制给UserDto,目的是不要将用户的全部信息返回给前端
- 将User对象转换为Map集合
- 保存到Redis中(key为token,value为map集合)
- 设置登录有效期
/**
* 登录验证
*
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
// 检验手机号是否是正确格式
if (RegexUtils.isPhoneInvalid(phone)) {
// 格式错误返回错误信息
log.debug("登录验证手机号格式不正确");
return Result.fail("您输入的手机号格式不正确");
}
String loginCode = loginForm.getCode();
// 根据手机号获取Redis里面的code
String RedisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
// 比对验证吗是否一致
if (loginCode == null || !RedisCode.equals(loginCode)) {
// 比对失败返回错误信息
log.debug("登录验证码不正确");
return Result.fail("您未输入验证码或您输入的验证码不正确");
}
// 验证该手机号用户是否存在
User user = query().eq("phone", phone).one();
if (user == null) {
// 不存在创建新用户
user = createUserWithPhone(phone);
}
// 保存用户到Redis
// 生成随机的token当作令牌
String token = UUID.randomUUID().toString(true);
// 将user复制给UserDto,目的是不要将用户的全部信息返回给前端
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 将user对象转换为map集合
Map<String, Object> UserMap = BeanUtil.beanToMap(userDTO);
// 保存到redis中
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,UserMap);
// 设置登录过期时间
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);
}
/**
* 创建新用户
*
* @param phone
* @return
*/
private User createUserWithPhone(String phone) {
// 创建User
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(5));
// 保存用户
save(user);
log.debug("用户保存成功");
return user;
}
校验登录状态
- 编写拦截器
- 获取请求投中的token
- 如果token不存在,进行拦截,返回状态码401
- 根据Token获取Redis中的用户
- 判断用户是否存在
- 不存在,进行拦截,返回状态码401
- 将查询到的Hash数据转化为UserDto对象
- 将对象保存到ThreadLocal中
- 自动延长登录有效期
- 开启拦截器
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求投中的token
String token = request.getHeader("authorization");
if (token == null) {
//如果token不存在,进行拦截,返回状态码401
response.setStatus(401);
return false;
}
//根据Token获取Redis中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
//判断用户是否存在
if (userMap == null) {
//不存在,进行拦截,返回状态码401
response.setStatus(401);
return false;
}
//将查询到的Hash数据转化为UserDto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//将对象保存到ThreadLocal中
UserHolder.saveUser(userDTO);
//自动延长登录有效期
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
}
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
拦截器优化
在以上的拦截器前面再加一层拦截器,原因是,在原来的拦截器中,自动延长用户登录时间的时候,只有当用户访问了需要登录拦截的时候才会延长,再加一个拦截器,就可以做到只要用户有操作就可以延长登陆时间
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.判断是否需要拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null) {
// 没有,需要拦截,设置状态码
response.setStatus(401);
// 拦截
return false;
}
// 有用户,则放行
return true;
}}
order属性是可以规定那个拦截器先执行(越小越先执行)
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
商户查询缓存
根据商户id查询商铺缓存
- 提交商铺id
- 从Redis里面查询商铺缓存
- 判断缓存是否存在
- 存在,返回缓存数据,得到商铺信息(返回的时候要记得反序列化,因为缓存查到的数据是JSON数据)
- 不存在,根据商铺id进入数据库查询
- 数据库不存在,返回错误信息
- 存在,将商铺信息存储在Redis中,建立该商铺缓存(将商铺信息进行序列化)
- 返回商铺信息
@Override
public Result queryById(Long id) {
//从Redis里面查询商铺缓存
String cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_XIANG + id);
//判断缓存是否存在
if (StrUtil.isNotBlank(cacheShop)) {
//存在,返回缓存数据,得到商铺信息
Shop shop = JSONUtil.toBean(cacheShop, Shop.class);
return Result.ok(shop);
}
//不存在,根据商铺id进入数据库查询
Shop shop = query().eq("id", id).one();
//数据库不存在,返回错误信息
if (shop==null) {
return Result.fail("查询的商铺不存在");
}
//存在,将商铺信息存储在Redis中,建立该商铺缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_XIANG + id, JSONUtil.toJsonStr(shop));
//返回商铺信息
return Result.ok(shop);
}
将店铺分类缓存到Redis中
- 查询Redis的分类信息(按照分类排序)
- 存在,返回分类信息
- 不存在取数据库里查找
- 不存在,返回分类不存在
- 存在,将分类信息缓存到Redis
- 返回分类信息
@Override
public Result queryTypeList() {
//查询Redis的分类信息(按照分类排序)
String shopTypeJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOPTYPE);
//存在,返回分类信息
if (StrUtil.isNotBlank(shopTypeJSON)) {
List<ShopType> shopType = JSONUtil.toList(shopTypeJSON, ShopType.class);
return Result.ok(shopType);
}
//不存在取数据库里查找
List<ShopType> shopTypesList = query().orderByAsc("sort").list();
//不存在,返回分类不存在
if (shopTypesList==null){
return Result.fail("分类信息不存在");
}
//存在,将分类信息缓存到Redis
shopTypeJSON = JSONUtil.toJsonStr(shopTypesList);
stringRedisTemplate.opsForValue().set(CACHE_SHOPTYPE,shopTypeJSON);
//返回分类信息
return Result.ok(shopTypesList);
}
缓存更新策略
操作缓存和数据库时由三个问题需要考虑
- 删除缓存还是更新缓存
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(一般采取这个的比较多)
- 如何保证缓存与数据库的操作的同时成功或失败
- 单体系统:将缓存与数据库操作放在一个事务
- 分布式系统:利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存(一般采取这个的比较多)
缓存更新策略的最佳实践方案:
- 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
- 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
- 读操作
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
实现店铺缓存与数据库一致
- 在加入Redis缓存的时候加入过期时间
- 更新店铺信息时,先更新数据库,再删除缓存
@Override
public Result queryById(Long id) {
//从Redis里面查询商铺缓存
String cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_XIANG + id);
//判断缓存是否存在
if (StrUtil.isNotBlank(cacheShop)) {
//存在,返回缓存数据,得到商铺信息
Shop shop = JSONUtil.toBean(cacheShop, Shop.class);
return Result.ok(shop);
}
//不存在,根据商铺id进入数据库查询
Shop shop = query().eq("id", id).one();
//数据库不存在,返回错误信息
if (shop == null) {
return Result.fail("查询的商铺不存在");
}
//存在,将商铺信息存储在Redis中,建立该商铺缓存,加入TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_XIANG + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回商铺信息
return Result.ok(shop);
}
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺不存在");
}
//先更新数据库
updateById(shop);
//再删除缓存
stringRedisTemplate.delete(CACHE_SHOP_XIANG + shop.getId());
return Result.ok();
}
缓存穿透
缓存穿透是指客户端请求的数据再缓存中和数据库中都不存在,这样缓存永远不会失效,这些请求都会打到数据库。
常见的解决方案有两种
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
- 布隆过滤
- 优点:内存占用较少,没有多余KEY
- 缺点:
- 实现复杂
- 存在误判可能
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
根据id查询店铺信息有缓存穿透的风险
解决根据id查询店铺信息缓存穿透问题
- 提交商铺id
- 从Redis里面查询商铺缓存
- 判断缓存是否存在
- 存在,返回缓存数据,得到商铺信息(返回的时候要记得反序列化,因为缓存查到的数据是JSON数据)
- 不存在但是不等于NULL
- 返回错误信息
- 不存在,根据商铺id进入数据库查询
- 不存在,将空值写入Redis(空值为 "" )
- 存在,将商铺信息存储在Redis中,建立该商铺缓存(将商铺信息进行序列化)
- 返回商铺信息
@Override
public Result queryById(Long id) {
//从Redis里面查询商铺缓存
String cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_XIANG + id);
//判断缓存是否存在
if (StrUtil.isNotBlank(cacheShop)) {
//存在,返回缓存数据,得到商铺信息
Shop shop = JSONUtil.toBean(cacheShop, Shop.class);
return Result.ok(shop);
}
//不存在,但缓存不为NULL
if (cacheShop != null) {
return Result.fail("您要查找的店铺不存在");
}
//不存在,根据商铺id进入数据库查询
Shop shop = query().eq("id", id).one();
//数据库不存在,返回错误信息
if (shop == null) {
//不存在,将空值写入Redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_XIANG + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
//存在,将商铺信息存储在Redis中,建立该商铺缓存,加入TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_XIANG + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回商铺信息
return Result.ok(shop);
}
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
解决方案:
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的高可用
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
常见的解决方案有两种
- 互斥锁
- 逻辑过期
利用互斥锁解决缓存击穿问题
- 1.获取商铺id
- 2.从Redis中查询商铺缓存
- 2.1有缓存,直接返回数据
- 2.2没有缓存,也不会NULL
- 3尝试获取互斥锁
- 3.1没有获取到,休眠一段时间
- 3.2再次查询缓存是否存在
- 4获取到互斥锁,根据店铺id查询数据库
- 5判断数据库是否存在
- 5.1数据库不存在,返回空
- 5.2给Redis缓存一个 "" ,防止缓存穿透
- 6数据库存在,将数据缓存到Redis中
- 6.1释放互斥锁
- 7返回数据
/**
* 依靠互斥锁解决缓存击穿问题
*
* @param id
* @return
*/
public Shop queryWithShopMutual(Long id) {
// 1.获取商铺id
//2.从Redis中查询商铺缓存
String shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.1有缓存,直接返回数据
if (StrUtil.isNotBlank(shopJSON)) {
Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
return shop;
}
//2.2没有缓存,也不会NULL
if (shopJSON!=null){
return null;
}
Shop shop=null;
try {
//3尝试获取互斥锁
boolean tryLock = tryLock(LOCK_SHOP_KEY + id);
//3.1没有获取到,休眠一段时间,再次查询缓存是否存在
if (!tryLock) {
Thread.sleep(30);
return queryWithShopMutual(id);
}
//4获取到互斥锁,根据店铺id查询数据库
shop = query().eq("id", id).one();
//5判断数据库是否存在
//5.1数据库不存在,返回空
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6数据库存在,将数据缓存到Redis中
String Shop2JSON = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, Shop2JSON, CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//6.1释放互斥锁
unlock(LOCK_SHOP_KEY + id);
}
//7返回数据
return shop;
}
/**
* 尝试获得互斥锁
* @param key
* @return
*/
public boolean tryLock(String key) {
Boolean tryLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 30, TimeUnit.MILLISECONDS);
return BooleanUtil.isTrue(tryLock);
}
/**
* 释放互斥锁
* @param key
*/
public void unlock(String key) {
stringRedisTemplate.delete(key);
}
利用逻辑过期解决缓存击穿问题
- 1.获取商铺id
- 2.从Redis中查询商铺缓存
- 3.判断缓存是否存在
- 3.1缓存不存在,返回空
- 3.2缓存存在,需要判断缓存是否过期
- 4判断缓存是否过期
- 4.1缓存未过期,返回商铺信息
- 4.2缓存过期,尝试获取互斥锁
- 5获取互斥锁
- 6判断互斥锁是否成功
- 6.1获取锁失败,返回商铺信息
- 6.2获取锁成功,再次判断缓存是否过期(这里是避免在获取锁的时间内,缓存已经被重建)
- 6.3缓存未过期,返回商铺信息
- 6.4缓存过期,缓存重建
- 7.开启缓存重建
- 7.1开启独立线程,根据id查询数据库
- 7.2将商铺数据写入Redis建立缓存,并设置逻辑过期时间
- 8释放互斥锁
- 9返回过期的商铺信息
首先要进行数据预热,就是先将热点数据缓存到Redis缓存中。这里建立的缓存就已经是有逻辑时间的缓存
- 1.查询店铺信息
- 2.封装逻辑过期时间
- 3.写入Redis
/**
* 提前将数据存入Redis(也叫缓存预热)
* @param id
*/
public void saveShop2Redis(Long id,Long expireSeconds) {
Shop shop = query().eq("id", id).one();
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_XIANG+id,JSONUtil.toJsonStr(redisData));
}
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
/**
* 逻辑过期解决缓存击穿问题
*
* @param id
* @return
*/
public Shop queryWithShopLogicalExpiration(Long id) {
//1.获取商铺id
//2.从Redis中查询商铺缓存
String shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_XIANG + id);
//3.判断缓存是否存在
if (StrUtil.isBlank(shopJSON)) {
//3.1缓存不存在,返回空
return null;
}
//3.2缓存存在,需要判断缓存是否过期
RedisData redisData = JSONUtil.toBean(shopJSON, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
JSONObject shopJSONObject = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(shopJSONObject, Shop.class);
//4判断缓存是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//4.1缓存未过期,返回商铺信息
return shop;
}
//4.2缓存过期,尝试获取互斥锁
//5获取互斥锁
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
//6判断互斥锁是否成功
if (!isLock) {
//6.1获取锁失败,返回商铺信息
return shop;
}
//6.2获取锁成功,再次判断缓存是否过期(这里是避免在获取锁的时间内,缓存已经被重建)
if (expireTime.isAfter(LocalDateTime.now())) {
//6.3缓存未过期,返回商铺信息
return shop;
}
//6.4缓存过期,缓存重建
// 7.开启缓存重建
//7.1开启独立线程,根据id查询数据库
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//7.2将商铺数据写入Redis建立缓存,并设置逻辑过期时间
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//8释放互斥锁
unlock(LOCK_SHOP_KEY + id);
}
});
//9返回商铺信息
return shop;
}
优惠卷秒杀
全局唯一ID
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足以下特性
- 唯一性
- 高可用
- 递增行
- 安全性
- 高性能
ID组成部分:
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,可以支持2^32个不同ID
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
- 全局唯一ID生成策略
- UUID
- Redis自增
- 雪花(snowflake)算法
- 数据库自增
- Redis自增ID策略
- 每天一个key,方便统计订单量
- ID构造是时间戳+计数器
实现秒杀下单
- 下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
- 1.获取优惠卷id
- 2.查询优惠卷信息
- 3.判断秒杀是否开始
- 4.判断秒杀是否结束
- 5.判断库存是否充足
- 6.扣减库存
- 7.创建订单
- 7.1获取订单id
- 7.2获取用户id
- 7.3获取代金卷id
- 8.返回订单
@Override
@Transactional
public Result seckillVoucherXiang(Long voucherId) {
//1.获取优惠卷id
//2.查询优惠卷信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//3.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀活动尚未开始");
}
//4.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀活动已经结束");
}
//5.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
//6.扣减库存
boolean istrue = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherId).update();
if (!istrue){
return Result.fail("库存不足");
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//7.1获取订单id
long orederID = redisIdWorker.nextId("voucherId");
voucherOrder.setId(orederID);
//7.2获取用户id
Long userID = UserHolder.getUser().getId();
voucherOrder.setUserId(userID);
//7.3获取代金卷id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//8.返回订单
return Result.ok(orederID);
}
关于超卖
超卖问题就是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
悲观锁:
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行
- 例如Synchronized、Lock都属于悲观锁
乐观锁:
认为线程安全不一定会发生,因此不加锁,只是在更新数据时取判断有没有其他线程对数据做了修改
- 如果没有修改则认为时安全的,自己才更新数据
- 如果已经被其他线程修改说明发生了安全问题,此时可以重试或异常
关于超卖我们可以加一个乐观锁,乐观锁的关键是判断之前查询得到的数据有被修改过,常见的方式有两种:
- 1.版本号法:顾名思义,就是在修改数据时加入一个version,如果修改的时候version与自己得到的version 不相同,那么就修改失败,可以尝试重试或异常
- 2.CAS法:就是更改数据,或者删除库存的时候,判断库存是否大于0,如果大于零,则扣除成功
-
//6.扣减库存 boolean istrue = seckillVoucherService .update() .setSql("stock=stock-1") .eq("voucher_id", voucherId) .gt("stock",0) .update(); if (!istrue){ return Result.fail("库存不足"); }
超卖这样的线程安全问题,解决方案有哪些
1.悲观锁:添加同步锁,让线程串行执行
- 优点:简单粗暴
- 缺点:性能一般
2.乐观锁:不加锁,在更新时判断是否有其他线程在修改
- 优点:性能好
- 缺点:存在成功率低的问题
一人一单
秒杀业务,一人只能成功一单
- 1.获取优惠卷id
- 2.查询优惠卷信息
- 3.判断秒杀是否开始
- 3.1没有开始,返回异常结果
- 4.判断秒杀是否结束
- 4.1已经结束,返回异常结果
- 5.判断库存是否充足
- 5.1不足,返回异常结果
- 6.根据优惠卷和用户查询订单
- 7.判断订单是否存在
- 7.1存在,返回异常结果
- 8.不存在.扣减库存
- 9.创建订单
- 9.1获取订单id
- 9.2获取用户id
- 9.3获取代金卷id
- 10.将订单储存在数据库中
- 11.返回订单
@Override
@Transactional
public Result seckillVoucherXiang(Long voucherId) {
//1.获取优惠卷id
//2.查询优惠卷信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//3.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
//3.1没有开始,返回异常结果
return Result.fail("活动尚未开始");
}
//4.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
//4.1已经结束,返回异常结果
return Result.fail("活动已经结束");
}
//5.判断库存是否充足
if (voucher.getStock()<1){
//5.1不足,返回异常结果
return Result.fail("库存不足");
}
UserDTO user = UserHolder.getUser();
Long userID = user.getId();
//6.根据优惠卷和用户查询订单
int count = query().eq("user_id", userID).eq("voucher_id", voucherId).count();
//7.判断订单是否存在
if (count>0){
//7.1存在,返回异常结果
return Result.fail("订单已存在,请勿重复下单");
}
//8.不存在.扣减库存
boolean update = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherId).gt("stock", 0).update();
if (!update){
return Result.fail("库存不足");
}
//9.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//9.1获取订单id
long voucherId1 = redisIdWorker.nextId("voucherId");
voucherOrder.setId(voucherId1) ;
//9.2获取用户id
voucherOrder.setUserId(userID);
//9.3获取代金卷id
voucherOrder.setVoucherId(voucherId);
//10.将订单储存在数据库中
save(voucherOrder);
//11.返回订单
return Result.ok(voucherId1);
}
以上操作,存在线程安全问题,比如多线程下,会在判断订单是否存在的同时,多个线程同时进行,造成一个用户下了不止一单
这时候就要用悲观锁来完成。让获取订单的时候只有一个线程进行,就是在获取UserID的时候加上synchronized。
@Override
public Result seckillVoucherXiang(Long voucherId) {
//1.获取优惠卷id
//2.查询优惠卷信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//3.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
//3.1没有开始,返回异常结果
return Result.fail("活动尚未开始");
}
//4.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
//4.1已经结束,返回异常结果
return Result.fail("活动已经结束");
}
//5.判断库存是否充足
if (voucher.getStock() < 1) {
//5.1不足,返回异常结果
return Result.fail("库存不足");
}
UserDTO user = UserHolder.getUser();
Long userID = user.getId();
synchronized (userID.toString().intern()) {
return createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
UserDTO user = UserHolder.getUser();
Long userID = user.getId();
//6.根据优惠卷和用户查询订单
int count = query().eq("user_id", userID).eq("voucher_id", voucherId).count();
//7.判断订单是否存在
if (count > 0) {
//7.1存在,返回异常结果
return Result.fail("订单已存在,请勿重复下单");
}
//8.不存在.扣减库存
boolean update = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherId).gt("stock", 0).update();
if (!update) {
return Result.fail("库存不足");
}
//9.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//9.1获取订单id
long voucherId1 = redisIdWorker.nextId("voucherId");
voucherOrder.setId(voucherId1);
//9.2获取用户id
voucherOrder.setUserId(userID);
//9.3获取代金卷id
voucherOrder.setVoucherId(voucherId);
//10.将订单储存在数据库中
save(voucherOrder);
//10.返回订单
return Result.ok(voucherId1);
}
以上的做法,在逻辑上是没有问题的,但是忽略了@Transactional事务在Service里面方法内部调用会失效的问题Spring事务失效的场景。我这里采用的是通过AopContent类来解决问题。
采用AopContent类解决问题要先完成两步
- 1.导入aspectJ依赖
-
<dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
- 2.启动暴露代理对象
-
@EnableAspectJAutoProxy(exposeProxy = true) @MapperScan("com.hmdp.mapper") @SpringBootApplication public class HmDianPingApplication { public static void main(String[] args) { SpringApplication.run(HmDianPingApplication.class, args); } }
@Override
public Result seckillVoucherXiang(Long voucherId) {
//1.获取优惠卷id
//2.查询优惠卷信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//3.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
//3.1没有开始,返回异常结果
return Result.fail("活动尚未开始");
}
//4.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
//4.1已经结束,返回异常结果
return Result.fail("活动已经结束");
}
//5.判断库存是否充足
if (voucher.getStock() < 1) {
//5.1不足,返回异常结果
return Result.fail("库存不足");
}
UserDTO user = UserHolder.getUser();
Long userID = user.getId();
synchronized (userID.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Override
@Transactional
public Result createVoucherOrder(Long voucherId) {
UserDTO user = UserHolder.getUser();
Long userID = user.getId();
//6.根据优惠卷和用户查询订单
int count = query().eq("user_id", userID).eq("voucher_id", voucherId).count();
//7.判断订单是否存在
if (count > 0) {
//7.1存在,返回异常结果
return Result.fail("订单已存在,请勿重复下单");
}
//8.不存在.扣减库存
boolean update = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherId).gt("stock", 0).update();
if (!update) {
return Result.fail("库存不足");
}
//9.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//9.1获取订单id
long voucherId1 = redisIdWorker.nextId("voucherId");
voucherOrder.setId(voucherId1);
//9.2获取用户id
voucherOrder.setUserId(userID);
//9.3获取代金卷id
voucherOrder.setVoucherId(voucherId);
//10.将订单储存在数据库中
save(voucherOrder);
//10.返回订单
return Result.ok(voucherId1);
}
分布式锁
以上的一人一单是在单机模式下可以完成,但是在多机模式就会发生错误,原因是新的一个会有新的JVM,会有不同的锁监视器来监视锁。采用分布式锁可以解决这种问题。
分布式锁:满足分布式系统或集群模式下多进程可见并互斥的锁
有以下几个基本特点
- 多进程可见
- 高可用
- 安全性
- 互斥
- 高性能
分布式锁的实现
基于Redis实现分布式锁
原来加锁是:
UserDTO user = UserHolder.getUser();
Long userID = user.getId();
synchronized (userID.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
现在加锁:
public interface ILockXiang {
boolean tryLock(Long timestamp);
void unlock();
}
public class ILockXiangImpl implements ILockXiang{
private StringRedisTemplate stringRedisTemplate;
private Long name;
public ILockXiangImpl(StringRedisTemplate stringRedisTemplate, Long name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(Long timestamp) {
String threadName = Thread.currentThread().getName();
Boolean istrue = stringRedisTemplate.opsForValue().setIfAbsent("lock" + name, threadName, timestamp, TimeUnit.SECONDS);
return BooleanUtil.isTrue(istrue);
// return Boolean.TRUE.equals(istrue);
}
@Override
public void unlock() {
stringRedisTemplate.delete("lock" + name);
}
}
UserDTO user = UserHolder.getUser();
Long userID = user.getId();
ILockXiangImpl iLockXiang = new ILockXiangImpl(stringRedisTemplate, userID);
boolean tryLock = iLockXiang.tryLock(1200L);
if (!tryLock) {
return Result.fail("您已经下过单了,请到下单界面查看详情");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
iLockXiang.unlock();
}
这样在两个客户端同时发请求,也会锁的住.但是存在一个问题,就是锁误删的情况。比如A处理业务,加锁但是业务时间超过了加锁时间,锁超时会自动释放,A业务并不知道仍旧在处理A业务,这时B业务过来,因为上一个锁已经被释放,所以B业务同样可以获得锁,如果A业务在B业务处理前释放锁的话,这里A业务释放的锁就是B业务的锁。
所以释放锁的时候要进行判断,这个锁是不是自己的锁。在尝试获取锁已经释放锁的地方加入判断满足:
- 1.在获取锁时存入线程标识(可以使用UUID表示)
- 2.在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致
- 如果一致则释放锁
- 如果不一致则不释放锁
public class ILockXiangImpl implements ILockXiang {
private StringRedisTemplate stringRedisTemplate;
private Long name;
private static final String LOCK_PREFIX = UUID.randomUUID().toString(true);
public ILockXiangImpl(StringRedisTemplate stringRedisTemplate, Long name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(Long timestamp) {
String threadName = LOCK_PREFIX + Thread.currentThread().getName();
Boolean istrue = stringRedisTemplate.opsForValue().setIfAbsent("lock" + name, threadName, timestamp, TimeUnit.SECONDS);
return BooleanUtil.isTrue(istrue);
// return Boolean.TRUE.equals(istrue);
}
@Override
public void unlock() {
String threadName = LOCK_PREFIX + Thread.currentThread().getName();
String id = stringRedisTemplate.opsForValue().get("lock" + name);
if (id.equals(threadName)){
stringRedisTemplate.delete("lock" + name);
}
}
}
基于Redis分布式锁优化
基于setnx实现的分布式锁存在以下问题:
- 1.不可重入
- 同一线程无法多次获取同一把锁
- 2.不可重试
- 获取锁只尝试一次就返回false,没有重试机制
- 3.超时释放
- 锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁释放,存在安全隐患
- 4.主从一致性
- 如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现。
Redisson
Redisson是一个在Redis的基础上实现的Java驻内存数据网络。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
官方地址https://redisson.org GitHub地址https://github.com/redisson/redisson
实现Redisson
- 1.导入依赖
-
<!--redisson--> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13.6</version> </dependency>
-
- 2.配置Redisson客户端
-
@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient(){ // 配置 Config config = new Config(); config.useSingleServer().setAddress("redis://192.168.80.135:6379"); // 创建RedissonClient对象 return Redisson.create(config); } }
-
- 3.引入Redisson
-
@Resource private RedissonClient redissonClient;
-
使用Redisson锁后如何加锁
UserDTO user = UserHolder.getUser();
Long userID = user.getId();
RLock lock = redissonClient.getLock(LOCK_SHOP_KEY + userID);
boolean tryLock = lock.tryLock();
if (!tryLock) {
return Result.fail("您已经下过单了,请到下单界面查看详情");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
lock.unlock();
}
Redisson可重入锁原理
秒杀优化
原本为以下构造,串行化一条龙进行,但是减库存以及创建订单的操作是针对于数据库,而且是写入操作,效率会比较低的,如果一条龙的进行,一次的时间过于漫长
这是优化后的样子,将其拆分为两个部分,一个复杂前面的,一个负责后面的数据库的操作,这样,前面的进行完了就可以直接返回
改进秒杀业务,提高并发性能
- 1.新增秒杀优惠卷的同时,将优惠卷信息保存到Redis中
- 2.基于LUA脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
- 3.如果抢购成功,将优惠卷id和用户id封装后存入阻塞队列
- 4.开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
1.新增秒杀优惠卷的同时,将优惠卷信息保存到Redis中
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
}
2.基于LUA脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
2.1执行lua脚本
private static final DefaultRedisScript<Long> SECKILXAINGL_SCRIPT;
static {
SECKILXAINGL_SCRIPT = new DefaultRedisScript<>();
SECKILXAINGL_SCRIPT.setLocation(new ClassPathResource("seckillxiang.lua"));
SECKILXAINGL_SCRIPT.setResultType(Long.class);
}
@Override
public Result seckillVoucherXiang(Long voucherId) {
Long userId = UserHolder.getUser().getId();
/**
execute需要传三个参数
1.lua脚本
2.KEYS[] 如果为null不可以直接传null,要传一个空的List串Collections.emptyList(),
3.ARGV[]
*/
Long lua = stringRedisTemplate.execute(
SECKILXAINGL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int i = lua.intValue();
if (i!=0){
if (i==1){
return Result.fail("库存不足");
}
else {
return Result.fail("请不要重复下单");
}
}
return Result.ok(0);
}
2.2lua脚本
- 1.我按部就班编写lua脚本
-
--1.判断库存是否充足 --1.1获取优惠卷id local voucherId=ARGV[1] --1.2查询优惠卷id的库存是否充足 --redis.call("get",voucherId) --1.3获取库存key local stockKey="seckillxiang:stock" ..voucherId --1.4库存充足,判断用户是否下单 if(tonumber(redis.call("get",stockKey))<=0) then --1.5库存不足返回1 return 1 end --2.判断用户是否下单 --2.1获取用户id local userID=ARGV[2] --2.2获取订单Key local orderKey="seckillxiang:order" .. userID --2.2根据用户id查询是否在set集合中存在 --redis.call("sismember",orderKey,userID) --2.3用户已下单返回2 if(redis.call("sismember",orderKey,userID)==1) then return 2 end --3.扣减库存 redis.call("incrby",stockKey,-1) --3.1将userID存入当前优惠卷的Set集合 redis.call("sadd",orderKey,userID) --3.2返回0 return 0
-
- 2.比较规范的编写脚本
-
-- 1.参数列表 -- 1.1.优惠券id local voucherId = ARGV[1] -- 1.2.用户id local userId = ARGV[2] -- 1.3.订单id local orderId = ARGV[3] -- 2.数据key -- 2.1.库存key local stockKey = 'seckill:stock:' .. voucherId -- 2.2.订单key local orderKey = 'seckill:order:' .. voucherId -- 3.脚本业务 -- 3.1.判断库存是否充足 get stockKey if(tonumber(redis.call('get', stockKey)) <= 0) then -- 3.2.库存不足,返回1 return 1 end -- 3.2.判断用户是否下单 SISMEMBER orderKey userId if(redis.call('sismember', orderKey, userId) == 1) then -- 3.3.存在,说明是重复下单,返回2 return 2 end -- 3.4.扣库存 incrby stockKey -1 redis.call('incrby', stockKey, -1) -- 3.5.下单(保存用户)sadd orderKey userId redis.call('sadd', orderKey, userId) -- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ... redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId) return 0
-
3.如果抢购成功,将优惠卷id和用户id封装后存入阻塞队列
4.开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
stream消息队列