Redis笔记
@Author yu
@Data 2024/4/30
一、Redis
Redis,英文全称是Remote Dictionary Server(远程字典服务),是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。
与MySQL数据库不同的是,Redis的数据是存在内存中的。它的读写速度非常快,每秒可以处理超过10万次读写操作。因此redis被广泛应用于缓存,另外,Redis也经常用来做分布式锁。除此之外,Redis支持事务、持久化、LUA 脚本、LRU 驱动事件、多种集群方案。
二、说说Redis的基本数据结构类型
Redis有以下这五种基本类型:
- String(字符串)
- Hash(哈希)
- List(列表)
- Set(集合)
- zset(有序集合)
三、Redis常见命令
通用命令
- get
- set
- expire key time
- ttl
- exists key
- del key
String类型
- mset key value key value
- mget key key key
- incr
- incrby key 步长
- setnx key value (只有在 key 不存在时才设置)
- setex key seconds value (添加并设置有效期)
Hash类型
- hset key field value
- hget key field
- hmset key field value field value
- hmget key field field
- hgetall key
- hkeys key
- hvals key
- hincrby key field increment
- hsetnx key field value (只有在 field 不存在时才设置)
List类型
- lpush key value
- lpop key
- rpush key value
- rpop key
- blpop key timeout (阻塞式左侧弹出)
- brpop key timeout (阻塞式右侧弹出)
Set类型
- sadd key member
- srem key member
- sismember key member
- scard key
- sinter key1 key2 (交集)
- sunion key1 key2 (并集)
- smembers key
Sorted Set类型
- zadd key score member
- zrem key member
- zrank key member
- zrange key start stop
- zcount key min max
- zincrby key increment member
- zrangebyscore key min max
- zdiff key1 key2 (差集)
- zinter key1 key2 (交集)
- zunion key1 key2 (并集)
黑马点评项目收获
@Author yu
@Data 2024/4/30
实现Session登录功能
- 对于任何一个业务功能,先从Controller层下手,Controller层负责接收用户请求并调用Service层实现,接收Service层返回的数据,然后将数据返回给前端。一定要写注释。然后写Service层代码,Service层先理清业务思路,然后写注释,标注每一步应该做什么,最后编码。
登录Controller未完成代码:
/**
* 登出功能
* @return 无
*/
@PostMapping("/logout")
public Result logout(){
// TODO 实现登出功能
return Result.fail("功能未完成");
}
Service层代码示例(培养写注释的好习惯),反向 if (条件否定)减少if嵌套层数:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1. 校验手机号
String phone = loginForm.getPhone().toString();
if (RegexUtils.isPhoneInvalid(phone)) {
//2. 如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
//3.不一致报错
if(cacheCode == null || !cacheCode.equals(code)){//反向校验避免if嵌套
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//5. 判断用户是否存在
if (user == null) {
//6.不存在,创建新用户,并保存
user = createUserWithPhone(phone);
}
//7.保存用户信息到session
session.setAttribute("user",user);
//返回 ok
return Result.ok();
}
- 返回的数据格式需要统一,这个项目以Result项目返回,并且使用Lombok提供的注解,下面是Result类的代码这个Result类写的很好:
package com.hmdp.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private Boolean success;
private String errorMsg;
private Object data;
private Long total;
public static Result ok(){
return new Result(true, null, null, null);
}
public static Result ok(Object data){
return new Result(true, null, data, null);
}
public static Result ok(List<?> data, Long total){
return new Result(true, null, data, total);
}
public static Result fail(String errorMsg){
return new Result(false, errorMsg, null, null);
}
}
- 项目结构:项目中建立了一个dto包(数据传输对象用户数据、表单数据)、config(异常统一处理、配置文件)、另外项目中还建立了一个utils包,用于存放常量数据和一些工具。resources目录下还存放了项目依赖的sql文件。
统一异常处理类代码:
package com.hmdp.config;
import com.hmdp.dto.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class WebExceptionAdvice {
@ExceptionHandler(RuntimeException.class)
public Result handleRuntimeException(RuntimeException e) {
log.error(e.toString(), e);
return Result.fail("服务器异常");
}
}
- 拦截器:(拦截器和过滤器的区别)
在Java Web开发中,过滤器(Filter)和拦截器(Interceptor)都是用于对请求进行处理的组件,但它们在实现方式和作用范围上有一些区别:
过滤器(Filter):
运行在Servlet容器内部,是Servlet规范中定义的一种组件。
在请求到达Servlet之前,过滤器可以对请求进行预处理,也可以对响应进行后处理。
可以对所有的请求进行拦截,包括Servlet的请求、静态资源的请求等。
配置在web.xml文件中,属于Servlet规范的一部分。
可以实现诸如日志记录、字符编码转换、权限检查等功能。
拦截器(Interceptor):
运行在Spring框架内部,是Spring框架提供的一种拦截机制。
主要用于对Spring MVC中的请求进行处理,拦截Spring MVC的控制器方法的调用。
只能对Spring MVC中的请求进行拦截,不能对Servlet容器中的其他请求进行拦截。
配置在Spring的配置文件中,属于Spring框架的一部分。
可以实现诸如日志记录、性能监控、事务管理等功能。
综上所述,过滤器是Servlet规范定义的一种组件,作用范围更广,可以对所有请求进行处理;而拦截器是Spring框架提供的一种拦截机制,主要用于对Spring MVC中的请求进行处理。在实际开发中,可以根据具体需求选择使用过滤器还是拦截器来实现相应的功能。
拦截器类代码:对于每个请求,验证请求是否来源于合法用户,如果是合法用户,将其存入ThreadLocal中
在Java Web应用中,用户数据通常存储在Session中,而拦截器(Interceptor)用于在请求到达Controller之前或之后执行一些特定的操作。将用户信息从Session中取出后放入ThreadLocal中的主要原因是为了避免多线程并发访问时出现数据混乱或安全性问题。
ThreadLocal是Java中的一个线程级别的变量,它提供了线程局部变量,即每个线程都有自己的变量副本,在多线程环境下各个线程之间互不影响。在拦截器中,将用户信息存储在ThreadLocal中可以保证在当前线程范围内,各个方法都可以方便地访问到用户信息,避免了在方法之间频繁传递参数或从Session中反复获取用户信息的操作。
另外,直接从Session中获取用户信息可能会导致一些问题,==比如在多线程环境下,多个线程同时访问Session中的数据可能会引发线程安全问题。==通过将用户信息存储在ThreadLocal中,可以保证每个线程都有独立的用户信息副本,不会相互干扰,提高了数据访问的安全性和可靠性。
总的来说,将用户信息存储在ThreadLocal中是为了保证数据在多线程环境下的安全性和一致性,同时也提高了数据的访问效率和便捷性。
方法重写idea快捷键:ctrl+i
定义拦截器:继承HandlerInterceptor,Ctrl + i实现preHandle、afterCompletion方法,response.setStatus(401未授权),这个类放在until包下
/**
* ClassName: LoginInterceptor
* 拦截器,拦截请求
* @Author 雨
* @Create 2024/4/30 21:45
* @Version 1.0
*/
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取session
HttpSession session = request.getSession();
//2. 获取session的用户
UserDTO user = (UserDTO) session.getAttribute("user");
//3. 判断用户是否存在
if(user == null){
//4. 不存在,拦截,返回401,未授权
response.setStatus(401);
return false;
}
//5. 存在,保存到threadlocal
UserHolder.saveUser(user);
//6. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}
配置拦截器(指定拦截哪些请求):自定义类,@Configuration注解使类变为配置类,实现WebMvcConfigurer接口,重写addInterceptors方法,形参传入new的拦截器对象,设置排除拦截的路径。这个类放在config包下。
/**
* ClassName: MVCConfig
* 配置拦截器(拦截器属于SpringMVC层)
*
* @Author 雨
* @Create 2024/5/1 0:18
* @Version 1.0
*/
@Configuration
public class MVCConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",//放行以shop开头的所有
"/shop-type/**",//放行以shop开头的所有
"/upload/**",//放行以shop开头的所有
"/voucher/**",//放行以shop开头的所有
"/user/code",
"/blog/hot",
"/user/login"
);
}
}
- 隐藏用户敏感信息
如果用户登录就将用户的所有数据都放到session存在两个问题:
- 内容太多,消耗内存资源太大
- 出于安全考虑,需要隐藏掉用户的敏感信息
所以项目中使用了UserDTO类,只存储用户id、name、头像信息
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
-
@RequestParam 和 @PathVariable 注解是用于从request中接收请求的,两个都可以接收参数,关键点不同的是**@RequestParam** 是从request里面拿取值,而 @PathVariable 是从一个URI模板里面来填充
@RequestParam(一般的路径风格请求用这个)
看下面一段代码:http://localhost:8080/springmvc/hello/101?param1=10¶m2=20
根据上面的这个URL,你可以用这样的方式来进行获取
public String getDetails( @RequestParam(value="param1", required=true) String param1, @RequestParam(value="param2", required=false) String param2){ ... } 12345
@RequestParam 支持下面四种参数
defaultValue 如果本次请求没有携带这个参数,或者参数为空,那么就会启用默认值
name 绑定本次参数的名称,要跟URL上面的一样
required 这个参数是不是必须的
value 跟name一样的作用,是name属性的一个别名
@PathVariable
这个注解能够识别URL里面的一个模板,我们看下面的一个URLhttp://localhost:8080/springmvc/hello/101?param1=10¶m2=20 1
上面的一个url你可以这样写:
@RequestMapping("/hello/{id}") public String getDetails(@PathVariable(value="id") String id, @RequestParam(value="param1", required=true) String param1, @RequestParam(value="param2", required=false) String param2){ ....... } 123456
区别很明显了
@PathParam(Restful风格路径参数使用这个)
这个注解是和spring的pathVariable是一样的,也是基于模板的,但是这个是jboss包下面的一个实现,上面的是spring的一个实现,都要导包@ResponseBody
responseBody表示服务器返回的时候以一种什么样的方式进行返回, 将内容或对象作为 HTTP 响应正文返回,值有很多,一般设定为json@RequestBody
一般是post请求的时候才会使用这个请求,把参数丢在requestbody里面
实现Redis登录
UserHold类:(放在Util包下)中封装了向ThreadLocal中存取操作
package com.hmdp.utils;
import com.hmdp.dto.UserDTO;
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
Redis实现共享session登录原理:
前端拦截原理:
Redis缓存实现登录原理:
判断字符串为空和判断集合是否为空的标准做法:
@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中的UserDTO(map)
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//4. 判断用户是否存在
if(userMap.isEmpty()){//这里必须要用isEmpty()而不是用==null判断
//5. 不存在,拦截,返回401,未授权
return true;
}
//3. 将map转为UserDTO(BeanUtils)
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6. 存在,保存到threadlocal
UserHolder.saveUser(userDTO);
//7. 重置redis中的token有效期
stringRedisTemplate.expire(key,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
//8. 放行
return true;
}
Redis缓存
1.Redis缓存模型
2.缓存更新策略:
① 使用低一致性:使用Redis自带的内存淘汰机制
② 高一致性:主动更新,并以超时剔除为兜底
读(查询):命中缓存直接返回,未命中读数据库,并将结果存缓存,设置expire
写(修改):先写数据库,再删缓存,确保原子性(单体架构使用使用事务,分布式架构使用分布式事务)
缓存更新策略示例:
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null) {
Result.fail("店铺id不能为空");
}
//高一致性主动更新最佳方案
//1.先修改数据库
updateById(shop);
//2.再修改缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
//存入缓存时设定expire时间(逻辑过期时间)
3.缓存穿透
缓存穿透是指所查询的数据不在缓存中且不在数据库中,这样的请求最终都需要请求数据库服务器,给服务器造成压力。
解决方法:
-
缓存空对象:缺点浪费缓存空间,且有可能导致数据短期不一致。
-
布隆过滤:将数据库中的数据做多次hash,将hash结果存储于布隆过滤器中(存储的是比特位),当请求过来后,对请求数据做hash运算,将hash结果在布隆过滤器重寻找,如果不存在则拒绝。(并不是100%准确)
4.缓存雪崩
原因:统一时段大量缓存key同时失效,导致大量请求到达数据库,带来巨大压力
解决方案:
- 给不同的key的ttl加随机值
- 利用Redis集群提高服务可用性
- 降级限流
- 多级缓存
5.缓冲击穿
解决方案:
① 基于缓存互斥锁解决缓存穿透问题
/**
* 预防缓存击穿 之互斥锁
*
* @param id 商铺id
* @return 商铺信息
*/
public Shop queryWithMutex(Long id) {
//缓存穿透
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
//1.查询redis缓存
String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
//2.如果缓存查到了就返回
if (StrUtil.isNotBlank(cacheShop)) {
return JSONUtil.toBean(cacheShop, Shop.class);
}
//命中的是否是缓冲的空对象,返回空值
if (cacheShop == "") {
return null;
}
//*4. 实现缓存重建.如果缓存没查到就申请锁
//4.1 判断是否获得锁
if (!tryLock(lockKey)) {
//4.2 失败则休眠一段时间后重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.3 获取锁成功则再次查询redis缓存是否存在
cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
//4.4.如果缓存查到了就返回
if (StrUtil.isNotBlank(cacheShop)) {
return JSONUtil.toBean(cacheShop, Shop.class);
}
//4.5 获取到了锁并且缓存中不存在,查询数据库数据,写入缓存
shop = getById(id);
// 模拟延迟,导致多个并发线程访问,导致击穿
Thread.sleep(200);
//4.6.如果数据库没查到就返回错误
if (shop == null) {
//处理缓存击穿:缓存空对象
//将空值存入redis
stringRedisTemplate.opsForValue().set(shopKey, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//5.如果数据库查到了,则将数据缓存到redis中
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7. 释放锁
unlock(lockKey);
}
//6.返回查询得到的数据
return shop;
}
② 基于逻辑过期解决缓存穿透问题:直接将热点key存储在redis中,未命中,直接返回空和数据库没有关系,命中了,则需要判断命中的数据是否已经过期。如果过期返回旧的数据,申请上锁,另外开辟线程查数据库的最新数据,存储到redis中(缓存重建)。
- 静态代理的使用(使用RedisData封装原有的Shop类,并在次基础上新增了expire属性,设置过期时间)
- LocalDateTime的使用
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
-------------------------
public void saveShop2Redis(Long id,Long expireSeconds){
//1.查询店铺数据
Shop shop = getById(id);
//2.封装查询数据
RedisData redisData = new RedisData();
redisData.setData(shop);
//3.设置时间
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//4.存入redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+"id",JSONUtil.toJsonStr(redisData));
}
//向缓存中存入数据
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
}
- hootool工具类的使用
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
以下地方使用到了hootool工具类:
//2.如果未命中 直接返回空
if (StrUtil.isBlank(cacheShop)) {
return null;
}
//3.如果命中,将json反序列化为对象
RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
- 线程池的使用(Excutor)
/**
* 线程池:使用逻辑过期解决缓存击穿,如果命中且过期就申请锁,申请到锁后再次进行判断(双重检测)
* 如果未命中则返回空,命中且过期才进行缓存重建,避免重复进行缓存重建
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 使用线程池
*/
// TODO 开启独立线程,实现缓存重建(使用线程池,避免线程池频繁创建和销毁)
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//缓存重建
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
以下是基于逻辑过期解决缓存穿透的完整代码:
/***
* 逻辑锁解决缓存击穿
* @param id
* @return
*/
private Shop queryWithLogicalExpire(Long id) {
//缓存击穿
String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
//1.查询redis缓存
String cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
//2.如果未命中 直接返回空
if (StrUtil.isBlank(cacheShop)) {
return null;
}
//3.如果命中,将json反序列化为对象
RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
//4.判断是否过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//4.1 未过期,直接返回缓存数据
return shop;
}
//4.2 过期,缓存重建
//5. 缓存重建
boolean isLock = tryLock(lockKey);
//5.1 尝试获取锁
if (isLock) {
//5.2 获取锁成功
//获取锁成功后应该再次判断缓存中是否有数据,如果有数据且未过期,直接返回数据,无需建立缓存
//1.查询redis缓存
cacheShop = stringRedisTemplate.opsForValue().get(shopKey);
//2.如果未命中 直接返回空
if (StrUtil.isBlank(cacheShop)) {
return null;
}
//3.如果命中,将json反序列化为对象
redisData = JSONUtil.toBean(cacheShop, RedisData.class);
shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
//4.判断是否过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//4.1 未过期,直接返回缓存数据
return shop;
}
// TODO 开启独立线程,实现缓存重建(使用线程池,避免线程池频繁创建和销毁)
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//缓存重建
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
//Shop shop = getById(id);
//返回过期数据
return shop;
}
//5.3 获取锁失败,直接返回旧数据
return shop;
}
6. 缓存工具类的封装
一、泛型方法的使用
[访问权限] <泛型> 返回值类型 方法名([泛型标识 参数名称]) [抛出的异常]{
}
泛型声明:
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallBack,Long time, TimeUnit unit) {} //泛型:先定义泛型后使用泛型
不知道某个参数具体类型时,形参中使用 Class type 调用时传入响应的类型 =>泛型 + 反射
泛型调用:
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);//调用工具类 函数式编程 + lambda表达式 方法引用
二、函数式编程 + lambda表达式、方法引用的使用(jdk1.8新特性)
形参中的传入的是函数
工具类中需要从数据库中读取数据,但是不知道具体使用哪个方法读取数据,这时形参中声明函数式形参,调用时传入通过lambda表达式或方法引用或匿名实现类…传入具体的函数
对于有参有返回值使用的是Function,调用时只需要使用形参.apply(形参)即可
声明:
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallBack,Long time, TimeUnit unit) {} //泛型:先定义泛型后使用泛型
调用:
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);//调用工具类 函数式编程 + lambda表达式 方法引用
方法内调用函数式接口:
//查数据库
R r1 = dbFallBack.apply(id);
二、函数式编程 + lambda表达式、方法引用
工具类完整代码:
package com.hmdp.utils;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* ClassName: CacheUtils
*
* @Author 雨
* @Create 2024/5/3 16:30
* @Version 1.0
*/
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
//构造器注入
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//向缓存中存入数据set
/**
* 将任意java对象序列化为json并存储到String类型的key中,并设置ttl时间
* @param key
* @param value
* @param time 时间
* @param unit 时间单位 TimeUtil
*/
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
/**
* 将任意java对象序列化为json并存储到String类型的key中,并可以设置逻辑过期时间,用用户处理缓存击穿问题
* @param key
* @param value
* @param time 时间
* @param unit 时间单位 TimeUtil
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
*/
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallBack,Long time, TimeUnit unit) { //泛型:先定义泛型后使用泛型,函数式编程
//缓存穿透
String key = keyPrefix + id;
//1.查询redis缓存
String cacheShop = stringRedisTemplate.opsForValue().get(key);
//2.如果缓存查到了就返回
if (StrUtil.isNotBlank(cacheShop)) {
R r = JSONUtil.toBean(cacheShop, type);
return r;
}
//命中的是否是缓冲的空对象
if (cacheShop == "") {
return null;
}
//3.如果缓存没查到就查数据库
R r = dbFallBack.apply(id);//数据库查询逻辑==> 函数式编程,要求用户传入一个函数(有参有返回值,Function<>)
//4.如果数据库没查到就返回错误
if (r == null) {
//处理缓存击穿:缓存空对象
//将空值存入redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6.如果数据库查到了,则将数据缓存到redis中
this.set(key,r,time,unit);
//7.返回查询得到的数据
return r;
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用逻辑击穿解决缓存击穿问题
*/
private <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallBack,Long time, TimeUnit unit) {
//缓存击穿
String key = keyPrefix + id;
String lockKey = keyPrefix + id;
//1.查询redis缓存
String json = stringRedisTemplate.opsForValue().get(key);
//2.如果未命中 直接返回空
if (StrUtil.isBlank(json)) {
return null;
}
//3.如果命中,将json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
//4.判断是否过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//4.1 未过期,直接返回缓存数据
return r;
}
//4.2 过期,缓存重建
//5. 缓存重建
boolean isLock = tryLock(lockKey);
//5.1 尝试获取锁
if (isLock) {
//5.2 获取锁成功
//获取锁成功后应该再次判断缓存中是否有数据,如果有数据且未过期,直接返回数据,无需建立缓存
//1.查询redis缓存
json = stringRedisTemplate.opsForValue().get(key);
//2.如果未命中 直接返回空
if (StrUtil.isBlank(json)) {
return null;
}
//3.如果命中,将json反序列化为对象
redisData = JSONUtil.toBean(json, RedisData.class);
r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
//4.判断是否过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//4.1 未过期,直接返回缓存数据
return r;
}
// TODO 开启独立线程,实现缓存重建(使用线程池,避免线程池频繁创建和销毁)
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//缓存重建
//查数据库
R r1 = dbFallBack.apply(id);
//写入redis
this.setWithLogicalExpire(key,r1,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
//Shop shop = getById(id);
//返回过期数据
return r;
}
//5.3 获取锁失败,直接返回旧数据
return r;
}
private final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 获取锁函数
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁函数
* @param key
*/
private void unlock(String key) {
Boolean flag = stringRedisTemplate.delete(key);
}
}
Redis实现秒杀业务
1.全局ID生成器–分布式唯一id(全局唯一id)
使用Redis生成唯一id,不会在数据库表的主键字段设置自增,主键来源于Redis生成的全局唯一id
为什么不使用数据库表的主键自增作为id:
- 主键作为id规律性太强了:会暴露一些信息
- 主键作为id不利于表的扩展:不利于表拓展后需要设置id
必须保证:
- 唯一性:redis的String类型具有自增策略
- 高可用:集群、主从、哨兵
- 高性能
- 递增
- 安全:
全局唯一ID生成算法
- UUID(16进制)字符串
- Redis自增(64位)
- snowflake算法(雪花算法)
- 数据库自增
ID生成器代码:
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private final long START_TIMESTAMP = 1640995200l;
private final int COUNT_BITS = 32;
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
//1.生成时间戳
long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSecond - this.START_TIMESTAMP;
//2.生成序列号
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//3.拼接并返回
// return timeStamp << COUNT_BITS + count;
return timeStamp << COUNT_BITS | count;
}
}
测试代码(涉及到JUC内容):
注意:springBoot2.2后的@Test单元测试需要org.junit.jupiter.api.Test,而不是org.junit.@Test
package com.hmdp;
import com.hmdp.utils.RedisIdWorker;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@SpringBootTest
public class RedisIdWorkerTest {
@Autowired
private RedisIdWorker redisIdWorker;
private ExecutorService pool = Executors.newFixedThreadPool(500);
@Test
public void test1() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(300);
Runnable task = ()->{
for (int i = 0; i < 100; i++) {
long orderId = redisIdWorker.nextId("order");
System.out.println("id" + orderId);
}
countDownLatch.countDown();
};
long start = System.currentTimeMillis();
System.out.println("开始" + start);
for (int i = 0; i < 300; i++) {
pool.submit(task);
}
countDownLatch.await();
long end = System.currentTimeMillis();
System.out.println("结束" + end);
System.out.println("耗时" + (end - start));
}
}
2.优惠券秒杀
2.1 一人能购买多单
未考虑线程安全带代码:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
@Transactional //操作两张表:添加事务,异常自动回滚
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券是否充足
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
LocalDateTime beginTime = voucher.getBeginTime();
LocalDateTime endTime = voucher.getEndTime();
//2. 判断当前时间是否在优惠券的秒杀有效期内
//2.1 判断秒杀是否开始
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(beginTime)) {
return Result.fail("秒杀还未开始");
}
//2.2 判断渺少是否结束
if (now.isAfter(endTime)) {
return Result.fail("秒杀已经结束");
}
//3. 查看库存是否充足
Integer stock = voucher.getStock();
if (stock < 1) {
//库存不足
return Result.fail("库存不足");
}
//4. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).update();
if(!success){
//库存不足
return Result.fail("库存不足");
}
//5. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//订单id(id生成器生成)
long order = redisIdWorker.nextId("order");
voucherOrder.setId(order);
//用户id(ThreadLocal)
Long id = UserHolder.getUser().getId();
voucherOrder.setUserId(id);
//代金券id
voucherOrder.setVoucherId(voucherId);
//保存到数据库
save(voucherOrder);
return Result.ok(voucherOrder);
}
}
多线程并发导致的安全问题:
*【重要】解决线程并发安全问题:
1. 悲观锁:悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行化执行
如:Synchronized 、Lock都属于悲观锁
2. 乐观锁(更新数据时使用):乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新时判断有没有线程对数据做了修改
-
如果没有修改,则认为线程安全,才回去更新
-
如果数据已经被其他线程修改了,则说明发生了线程安全问题,此时可以重试或异常
乐观锁如何判断数据是否被其他线程修改?
乐观锁的关键是判断之前查询到的数据有没有被修改过。
- 版本号法(应用最广泛)数据添加版本字段(version),线程每次被修改版本号 +1
- CAS(CompareAndSet):不额外添加字段,而是使用修改前后的数据值判断插入前后数据是否一致
乐观锁存在成功率低的问题,需要对乐观锁条件进行适当调整,以增大成功率(或者通过分段锁思想)
2.2 一人最多只能购买一单
未考虑线程安全带代码(并发场景下,如果多个线程同时查询会导致线程安全问题):
//4. 一人只能下一单
//查询订单表,用户是否下单
//用户id(ThreadLocal)
Long userId = UserHolder.getUser().getId();
Integer count = this.query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count>0) {
//用户已经下过单
return Result.fail("用户已经购买过一次!");
}
由于这里要给订单表插入数据时加锁,无法使用乐观锁(乐观锁仅适用于修改的场景,插入时不适用),需要使用悲观锁实现。(Ctrl+ALT+M代码封装)
完整代码:
package com.hmdp.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
// @Transactional //操作两张表:添加事务,异常自动回滚
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券是否充足
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
LocalDateTime beginTime = voucher.getBeginTime();
LocalDateTime endTime = voucher.getEndTime();
//2. 判断当前时间是否在优惠券的秒杀有效期内
//2.1 判断秒杀是否开始
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(beginTime)) {
return Result.fail("秒杀还未开始");
}
//2.2 判断渺少是否结束
if (now.isAfter(endTime)) {
return Result.fail("秒杀已经结束");
}
//3. 查看库存是否充足
Integer stock = voucher.getStock();
if (stock < 1) {
//库存不足
return Result.fail("库存不足");
}
// <5> 最终,我们只能在这里加锁:
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// return this.addSeckillOrder(voucherId);
//方法执行完毕先提交事务?
// <6> 这里还存在问题!!!addSeckillOrder方法上添加了@Transactional注解,底层是通过AOP生成动态代理类实现了事务处理,这里使用this是
// 当前类对象而不是代理类对象,没有事务功能,存在数据不一致的风险
// 要想调用事务功能,需要得到AOP代理类对象
// 得到AOP代理类对象:需要注入动态代理依赖,并在配置类中暴露出代理类,只有这样才能正常访问
//获取事务代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// return this.addSeckillOrder(voucherId);
return proxy.addSeckillOrder(voucherId);
}
}
@Transactional //扣减库存和操作订单 操作两张表:添加事务,异常自动回滚,确保数据一致性
// <1>. public synchronized Result addSeckillOrder(Long voucherId) {//① 在此添加synchornized实现同步方法,此时默认锁为当前类的对象(单例的),
// 导致每个线程(请求)都需要申请拿到锁,才能执行,所有请求都串行化执行。而我们只需要确保同一个用户的不同请求实现线程串行执行,确保数据一致性,故只需要对用户id上锁即可。
// 所以同步方法的当前类锁的范围太大了,效率太低了,我们需要改为同步方法。
public Result addSeckillOrder(Long voucherId) {
//4. 一人只能下一单
//查询订单表,用户是否下单
//用户id(ThreadLocal)
Long userId = UserHolder.getUser().getId();
// synchronized (userId) {
// <2> 由于userId是存储在ThreadLocal里的,而ThreadLocal是线程独享的,每个线程的userId都不同,故不能作为锁
// synchronized (userId.toString()) {
// <3> 使用userId.toString(),虽然得到了userId的字符串类型,但线程调用toString,toString底层会在堆空间中创建字符串对象,每个线程顶层创建的是全新的对象,也不能作为锁
// synchronized (userId.toString().intern()) {
// <4> 使用userId.toString().intern(),会返回字符串常量池中的地址,虽然堆空间的地址不同,但常量池中的地址只有一份,所以这个可以作为锁
// <5> 虽然解决了锁的问题,但又出现了事务的问题:现在的逻辑是先开启事务,然后上锁,插入订单数据,释放锁,提交事务,但可能事务还未提交,锁已经释放,下一个线程到来,出现脏读,
// 存在并发安全的脏读问题,不能保证每人之下一单,所以需要先上锁(或者先开启事务,二者先后顺序没有要求),提交事务,然后释放锁。即提交事务需要保证在释放锁前执行。但整个
// 方法执行完毕才能提交事务,释放锁的操作无处放置,在这个方法中代码没法写了,所以只能在方法调用前上锁,在方法执行后释放锁。
Integer count = this.query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
//用户已经下过单
return Result.fail("用户已经购买过一次!");
}
//5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0).update();
if (!success) {
//库存不足
return Result.fail("库存不足");
}
//6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//订单id(id生成器生成)
long order = redisIdWorker.nextId("order");
voucherOrder.setId(order);
//用户id(ThreadLocal)
userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//代金券id
voucherOrder.setVoucherId(voucherId);
//保存到数据库
save(voucherOrder);
return Result.ok(voucherOrder);
}
}
Redis实现分布式锁
集群下秒杀业务存在的问题:
每个JVM都维护了一个锁监视器,当开启多个服务时(跨JVM、跨进程),不同服务使用的是不同的锁监视器,导致多个线程同时获取多个锁监视器,无法共享同一把锁,最终造成集群下,多线程并发的安全问题。使用synchornized无法多个进程的线程同步。
分布式锁解决原理:多个线程使用同一个锁监视器(不再使用JVM提供的锁监视器)
分布式锁:满足分布式或集群模式下多进程可见并且互斥的锁
分布式锁必须满足:
- 多进程可见
- 互斥
- 高可用
- 高性能
- 安全
Redis实现分布式锁的原理(setnx key value)如果对应的key不存在才能向其中插入数据,如果存在,则无法插入数据。
分布式锁需要实现的两个方法:
- 申请锁(tryLock()):互斥,确保只能有一个线程获取锁
#添加锁,利用sexnx的互斥特性
SETNX lock thread1
#添加锁过期时间,避免服务宕机引起的死锁
EXPIRE lock 10
!!!上述操作无法确保原子性,当加锁后宕机,但还未设置过期时间!!! ==> 最后改进为
#使用一条命令加锁并设置过期时间
SET lock thread EX 10 NX
- 释放锁(unLock()):
- 手动释放 ==异常情况:==释放锁之前服务器宕机,导致锁永远无法释放,陷入死锁。
- 超时释放:获取锁时添加一个超时时间
#释放锁,删除即可
DEL key
流程:
版本一:
锁的相关代码:
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 使之持有锁时间
* @return true 代表获取锁成功。false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
public class SimpleRedisLock implements ILock{
private String name;
private static final String KEY_PREFIX = "lock";
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标志
long threadId = Thread.currentThread().getId();//同一个JVM线程ID一般不会重复
String key = KEY_PREFIX + name;
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, threadId + "", timeoutSec, TimeUnit.SECONDS);
//获取锁
return Boolean.TRUE.equals(success);//避免空指针
}
@Override
public void unlock() {
stringRedisTemplate.delete( KEY_PREFIX + name);
}
}
调用锁的代码
// <7>使用synchronized只能保证同一进程内的线程安全,无法保证分布式系统的线程安全,这里我们进一步改造,使用分布式锁实现
//7.1获取分布式锁
ILock iLock = new SimpleRedisLock("order:" + userId,stringRedisTemplate);
//7.2尝试上锁
boolean success = iLock.tryLock(1200);
if(!success){
//7.3如果获取锁失败,则返回错误
return Result.fail("每人最多只能下一单");
//
}
//7.4捕获异常,释放锁
try {
//获取事务代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// return this.addSeckillOrder(voucherId);
return proxy.addSeckillOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
//7.5释放锁
iLock.unlock();
}
}
版本一存在的问题:当锁的超时时间设置太短、或业务阻塞时,线程可能会释放别人的锁
解决方法:释放锁时判断缓存中获得锁的线程是不是当前线程(key:lock value:线程对应的UUID),所以上锁时需要保证存储能够标识当前申请锁的线程的信息,释放锁时需要判断缓存中的线程信息是否是当前线程信息,如果是,才能释放锁。
UUID:UUID是指在一台机器上生成的数字,它保证对在同一时空中的所有机器都是唯一的。
UUID由以下几部分的组合:
(1)当前日期和时间,UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同。
(2)时钟序列。
(3)全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。
版本二
改进的锁操作代码:
public class SimpleRedisLock implements ILock{
private String name;
private static final String KEY_PREFIX = "lock";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标志
String threadId =ID_PREFIX + Thread.currentThread().getId();//同一个JVM线程ID一般不会重复
String key = KEY_PREFIX + name;
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
//获取锁
return Boolean.TRUE.equals(success);//避免空指针
}
@Override
public void unlock() {
//获取当前线程标志
String threadId =ID_PREFIX + Thread.currentThread().getId();
//获取当前持有锁的线程ID
String key = KEY_PREFIX + name;
String lockedThreadId = stringRedisTemplate.opsForValue().get(key);
if (threadId.equals(lockedThreadId)) {
//如果二者相等,才释放锁
stringRedisTemplate.delete( KEY_PREFIX + name);
}
}
}
版本二存在问题:判断缓存斥锁线程和当前线程是否一致的操作应该和释放锁的操作具有原子性,不可分割,所以需要考虑其中的事务。如果查询得到的结果为True但遇到FullGC阻塞,此时就会导致锁达到操作时间自动释放,其他线程获得锁,而阻塞结束后,前面的线程又会释放别线程的锁。
解决方法:通过Lua脚本实现Redis的事务
Lua脚本的语法:
- 定义变量 local xxx
- 拼接字符串 “hello:”…“world” 使用两个点“.”
- 调用Redis命令 redis.call
- 条件判断 if then
Lua释放锁脚本(实现原子性):
-- 这里的KEYS[1]就是传入锁的key
-- 这里的ARGV[1]就是线程标识
-- 比较锁中的线程标识与线程标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致则释放锁
return redis.call('del', KEYS[1])
end
return 0
public class SimpleRedisLock implements ILock{
private String name;
private static final String KEY_PREFIX = "lock";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private StringRedisTemplate stringRedisTemplate;
private static final DefaultRedisScript UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<Long>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));//放在静态代码块中,只加载一次,
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标志
String threadId =ID_PREFIX + Thread.currentThread().getId();//同一个JVM线程ID一般不会重复
String key = KEY_PREFIX + name;
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
//获取锁
return Boolean.TRUE.equals(success);//避免空指针
}
@Override
public void unlock() {
//获取当前线程标志
String threadId =ID_PREFIX + Thread.currentThread().getId();
//获取当前持有锁的线程ID
String key = KEY_PREFIX + name;
stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key),threadId);//通过lua脚本满足查询和删除原子性
}
}
Redisson框架实现分布式锁
Redisson可重入锁的原理
Redisson实现分布式锁解决了使用原生Redis的setnx + expire的三大问题
- 可重入问题。原生Redis实现分布式锁存在不可重入问题。即一个线程不能多次获得同一把锁。Redisson通过Redis的hash结构添加了锁计数功能,如果是同一个线程需要多次获得锁,仅需要在value的锁计数字段自增即可。当释放锁,将计数器减一。如果计数器为0则说明线程已释放锁
- 可重复问题。原生Redis实现分布式锁存在不可重复问题。即一个线程申请锁失败后就立即返回,不能再次申请锁,而Redisson通过信号量机制,当有线程释放锁后,之前申请锁失败的线程可以再次申请锁。
- 超时续约。原生Redis实现分布式锁存在锁过期问题。当Redis锁到达过期时间后,就会立即过期,如果一个线程申请了多把锁或者遇到阻塞问题,可能会导致业务还未执行完但锁已经释放。Redisson通过看门狗机制,每30秒进行超时续约,确保线程中的业务顺利执行。
Redis实现异步秒杀
Redis实现异步秒杀,提高并发性能
思路:目前业务存在的问题有判断库存是否充足、判断是客户是否已经下过单、减扣库存操作都需要和数据库交互,且存入订单到数据库与上面的是同一个线程,顺序执行。此外业务还添加了分布式锁,性能低下,难以满足高并发的需求。
Redis实现秒杀的Lua脚本:
-- 主要包含三部分内容:
-- 判断库存 判断是否下过单(set集合) 扣减库存
-- 1. 参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1];
-- 1.2 用户id
local userId = ARGV[2];
-- 2. key
-- 2.1 库存key
local stockKey = "seckill:stock:" .. voucherId;
-- 2.2 订单key
local orderKey = "seckill:order:" .. voucherId;
-- 3. 脚本业务
-- 判断库存是否充足
if (tonumber(redis.call('get',stockKey)) <= 0) then
-- 库存不足
return 1
end
-- 判断用户是否下单
if (redis.call('sismember',orderKey,userId) == 1) then
-- 用户下过单
return 2
end
-- 扣减库存
redis.call('incrby',stockKey,-1);
-- 下单,保存用户id到set集合
redis.call('sadd',orderKey,userId);
return 0
阻塞队列:
/**
* 阻塞队列:(生产者消费者)当线程尝试从队列中获取元素时,如果没有元素,就会阻塞,直到有元素线程才会被唤醒
*/
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
代码:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
/***
* 加载脚本
*/
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<Long>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));//放在静态代码块中,只加载一次,
SECKILL_SCRIPT.setResultType(Long.class);
}
/**
* 阻塞队列:(生产者消费者)当线程尝试从队列中获取元素时,如果没有元素,就会阻塞,直到有元素线程才会被唤醒
*/
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
/**
* 线程池
*/
private ExecutorService SAVE_ORDER_THREAD_POOL = Executors.newSingleThreadExecutor();
/*
* 线程任务
*/
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while(true){
//1.获取队列中的订单信息
VoucherOrder voucherOrder = null;
try {
voucherOrder = orderTasks.take();
//2.创建订单
handlerVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
log.error("处理订单异常" + e);
}
}
}
}
private IVoucherOrderService proxy;
private void handlerVoucherOrder(VoucherOrder voucherOrder) {
//获取用户:不能从ThreadLocal中取了,因为这是新开的线程
Long userId = voucherOrder.getId();
//获取锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean success = lock.tryLock();
if(!success){
//7.3如果获取锁失败,则返回错误
log.error("每人最多只能下一单");
}
//7.4捕获异常,释放锁
try {
//获取事务代理对象
proxy.addSeckillOrder(voucherOrder);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
//7.5释放锁
lock.unlock();
}
}
/**
* 提交线程任务(AOP--->Spring后处理器 类加载完毕后,立即提交任务
*/
private void init(){
SAVE_ORDER_THREAD_POOL.submit(new VoucherOrderHandler());
}
@Override
public void addSeckillOrder(VoucherOrder voucherOrder) {
Long userId = UserHolder.getUser().getId();
Integer count = this.query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
if (count > 0) {
//用户已经下过单
log.error("用户已经购买过一次!");
return;
}
//5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0).update();
if (!success) {
//库存不足
log.error("库存不足");
return;
}
//保存到数据库
save(voucherOrder);
}
基于JVM阻塞队列的问题:
- 存储内容有限:占用的是JVM的内存
- 不能保证安全性:数据存储在内容中,JVM挂掉后,数据丢失
Redis实现消息队列
1.阻塞队列存在的问题
- 存储内容有限:占用的是JVM的内存
- 不能保证安全性:数据存储在内容中,JVM挂掉后,数据丢失
- 不能保证消息队列中的消息至少被消费一次
2.Redis实现消息队列
2.1基于List实现消息队列
2.2基于pubsub(发布订阅)的消息队列
基于stream的消息队列
补充知识点
1.日期时间API
JDK1.8后java.util包下的Date被java.util.Calendar(日历类)取代了
Date是线程不安全的,在JDK1.8中增加了一组日期时间的API,在java.time
- LocalDate
- LocalTime
- LocalDateTime
2.Spring读取rescious资源目录下的问题:
-
使用Spring提供的ClassPathResource:
new ClassPathResource("unlock.lua")
-
查看类继承树关系:鼠标光标置于类名/接口名上,使用快捷键ctrl+h实现
-
将单个元素转为List形式:使用Collections.singletonList(key)实现
-
减少IO次数,加载资源的代码放到静态代码块中执行
static {
UNLOCK_SCRIPT = new DefaultRedisScript<Long>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));//放在静态代码块中,只加载一次,
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public void unlock() {
//获取当前线程标志
String threadId =ID_PREFIX + Thread.currentThread().getId();
//获取当前持有锁的线程ID
String key = KEY_PREFIX + name;
stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key),threadId);//通过lua脚本满足查询和删除原子性
}
}
return;
}
//保存到数据库
save(voucherOrder);
}
基于JVM阻塞队列的问题:
+ 存储内容有限:占用的是JVM的内存
+ 不能保证安全性:数据存储在内容中,JVM挂掉后,数据丢失
## Redis实现消息队列
### 1.阻塞队列存在的问题
- 存储内容有限:占用的是JVM的内存
- 不能保证安全性:数据存储在内容中,JVM挂掉后,数据丢失
- 不能保证消息队列中的消息至少被消费一次
### 2.Redis实现消息队列
[外链图片转存中...(img-8nMpgnTM-1714979553759)]
#### 2.1基于List实现消息队列
[外链图片转存中...(img-Y4Q7flhN-1714979553760)]
[外链图片转存中...(img-7sJJI5zG-1714979553761)]
#### 2.2基于pubsub(发布订阅)的消息队列
[外链图片转存中...(img-jngdIAts-1714979553761)]
[外链图片转存中...(img-DW5b6NEB-1714979553762)]
#### 基于stream的消息队列
# 补充知识点
## 1.日期时间API
> JDK1.8后java.util包下的Date被java.util.Calendar(日历类)取代了
[外链图片转存中...(img-ev0avbDu-1714979553763)]
> Date是线程不安全的,在JDK1.8中增加了一组日期时间的API,在java.time
+ LocalDate
+ LocalTime
+ LocalDateTime
[外链图片转存中...(img-Snsmv3OX-1714979553764)]
[外链图片转存中...(img-TzFQ63WV-1714979553765)]
## 2.Spring读取rescious资源目录下的问题:
+ 使用Spring提供的[ClassPathResource]():`new ClassPathResource("unlock.lua")`
+ 查看类继承树关系:鼠标光标置于类名/接口名上,使用快捷键ctrl+h实现
+ 将单个元素转为List形式:使用Collections.singletonList(key)实现
+ 减少IO次数,加载资源的代码放到静态代码块中执行
~~~java
static {
UNLOCK_SCRIPT = new DefaultRedisScript<Long>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));//放在静态代码块中,只加载一次,
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public void unlock() {
//获取当前线程标志
String threadId =ID_PREFIX + Thread.currentThread().getId();
//获取当前持有锁的线程ID
String key = KEY_PREFIX + name;
stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key),threadId);//通过lua脚本满足查询和删除原子性
}
}