Redis项目实战:
短信登录:
基于Redis缓存:
(1)发送短信验证码:
实现逻辑 :
- 先校验手机号 ,
- 不符合
- 直接返回错误信息 , 使用的是封装的返回前端的方法
- 符合 ,
- 生成一个随机验证码 , 使用的是huTool中的RandomUtil工具类 , 生成随机的六位验证码
- 保存验证码到Redis中 , 使用手机号加前缀作为key值保存 , 保证key值唯一性 , 同时设置验证码有效时间
- 返回前端数据
- 不符合
代码实现 :
@Override
public Result sendCode(String phone) {
//TODO 1.校验手机号:不符合是true
if (RegexUtils.isPhoneInvalid(phone)) {
//2.如果不符合 , 返回错误信息
return Result.fail("手机号格式错误");
}
// TODO 3.符合 , 生成一个随机验证码 , 使用的是huTool中的工具类 ,
String code = RandomUtil.randomNumbers(6);
// TODO 4.保存验证码到Redis当中 , 使用手机号加前缀作为key来保存 , 保证可以的唯一性 , 同时 , 设置有效期为两分钟
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5.模拟发送验证码
log.debug("发送短信验证码成功, 验证码:{" + code + "}");
//6.返回前端数据ok
return Result.ok();
}
(2)短信验证码登录注册:
实现逻辑 :
- 校验手机号 ,
- 不符合
- 就直接返回错误信息 , 避免有人使用正确手机号获取验证码 , 但是注册时切换错误手机号
- 符合
- 从Redis中获取验证码 ,
- 判断验证码是否失效(也就是验证码查询不出来) 或者 校验用户输入验证码和Redis中的验证码是否一致
- 不符合
- 直接输出错误信息
- 符合
- 从数据库中根据手机号查询用户信息
- 查询不出来
- 创建新用户 , 使用随机的字符串加前缀作为用户名
- 查询出来
- 查询不出来
- 将获取的用户信息选择拷贝搭配DTO类中 , (隐藏用户隐私信息)
- 将UserDTO类转换为map类型数据 ,
- 设置一个随机字符串和前缀作为该用户的登录令牌
- 将令牌作为key , UserDTO作为key进行缓存 , 使用map格式作为缓存数据类型
- 将获取的UserDTO返回前端
- 从数据库中根据手机号查询用户信息
- 不符合
- 判断验证码是否失效(也就是验证码查询不出来) 或者 校验用户输入验证码和Redis中的验证码是否一致
- 从Redis中获取验证码 ,
- 不符合
代码实现 :
@Override
public Result login(LoginFormDTO loginForm) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
//2. TODO 校验验证码 , 从redis中获取验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
//3.判断验证码是否失效(也就是验证码查询不出来) 或者 校验用户输入验证码和Redis中的验证码是否一致
return Result.fail("验证码错误");
// TODO 使用反证的方式 , 可以减少if语句的判断次数
}
//4.一致 , 根据手机号查询用户
// TODO 使用的是MyBatisPlus中的方法 , 进行查询的
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if (user == null) {
//6.不存在 , 创建新用户并保存
user = createUserWithPhone(phone);
}
// TODO 7.保存用户信息到redis中
// TODO 7.1 随机生成token作为登录令牌
// TODO 使用huTool提供的UUID , 下边的写法是生成不带下划线的UUID , 默认值为false,带下划线的UUID
String token = UUID.randomUUID().toString(true);
// TODO 使用BeanUtil中的copyProperties方法 , 可以将user中的属性自动拷贝到UserDTO中 , 对于没有的属性,不进行拷贝
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// TODO 7.2 将User对象转为Hash集合 , 将UserDTO转换为一个Map集合 ,
// 这个时候 , 进行转换的时候 ,会出现异常 , 因为我们使用的是String类型的redis对象 , 在转换的时候 , key值只能是String类型的
// 但是 , 这个BeanUtil工具类 , 允许我们进行自定义 ,
// 添加两个参数 , 一个是new HashMap<>() ,
// 一个是CopyOptions , 定义自定义的操作
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true) // TODO 设置是否忽略空值
// TODO 对字段值的修改器 , 需要两个参数 , 修改前的字段名和字段值 , 修改后的字段值
.setFieldValueEditor((fileName,fileValue) -> fileValue.toString()));
// TODO 7.3 存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// TODO 7.4 设置token的有效期 , redis中不能在上一个方法中直接设置有效期 , 可以在下边设置
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
// TODO 8.返回token , 用来将这个token保存在浏览器中 , 下次登录的时候 , 会携带这个token进行访问
return Result.ok(token);
}
//TODO 创建用户
private User createUserWithPhone(String phone) {
//1.创建用户 :
User user = new User();
user.setPhone(phone);
//生成随机的字符串 , 用来当做用户名
user.setNickName("user_" + RandomUtil.randomString(10));
save(user);
return user;
}
(3)拦截是否有登录令牌 , 有就刷新 , 没有就直接放行
-
使用拦截器 , 负责检测用户登录的时间 , 使用缓存 token来定义用户的登录时间 , 只要有操作 , 就刷新token的时间 ,
-
只负责检测请求中有没有携带token , 没有就直接放行 , 有了就刷新token ,
实现逻辑 :
- 设置前置拦截 , 获取请求头中的token
- 为空 , 说明没有登录 , 使用isBlank判断 ,
- 直接放行
- 不为空 , 获取Redis中的用户的信息
- 判断获取的map集合是否为空
- 为空 , 直接放行
- 不为空 , 将获取到的信息转为Map格式 , 存储在ThreadLocal域中
- 刷新token的有效期
- 判断获取的map集合是否为空
- 为空 , 说明没有登录 , 使用isBlank判断 ,
代码实现 :
/**
* 拦截器类 , 负责刷新token保存时间的 , 只有用户登录了才进行操作 , 其他的一概放行
*/
public class RefreshTokenInterceptor implements HandlerInterceptor {
// TODO 注意: 拦截器是我们自己创建的类 , 不受Spring容器管理 , 所以 , 不能直接注入RedisTemplate
// TODO 我们只能使用构造函数的方式进行注入 , 谁调用这个拦截器 ,谁负责注入这个RedisTemplate
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
//前置拦截
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// TODO 1.获取请求头中的token
String token = request.getHeader("authorization");
// TODO StrUtil中的isBlank方法就是判断是否是空值
if (StrUtil.isBlank(token)){
// TODO 为空,说明没有登录 , 直接放行
return true;
}
// TODO 2.基于token获取redis中的用户信息
// TODO 不能简单的使用get来获取值了 , 使用get获取的只是hash中的map中的一个值 , 而我们想获取的是全部的值 , 使用entries这个方法
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
// TODO 不用判断是否为null了,entries会做判断 , 如果为null会返回一个空的map , 所以这里只用判断是否为空就可以了
if (userMap.isEmpty()){
// TODO 为空说明用户没有登录, 直接放行 , 不作操作
return true;
}
// TODO 不为空 , 进行token以及数据的保存工作
// TODO 5.将查询到的Hash数据转为UserDTO对象 , 最后一个参数是否忽略转换中的异常 , false是不忽略
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// TODO 5.存在 , 保存用户信息到ThreadLocal , 这是一个工具类 , 内部创建了一个ThreadLocal对象,来进行操作
UserHolder.saveUser(userDTO);
// TODO 7.刷新token的有效期 , 也就是从新设置对应key的有效时间
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+ token,LOGIN_USER_TTL, TimeUnit.MINUTES);
//6.放行
return true;
}
@Override
//后置拦截
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//删除用户
UserHolder.removeUser();
}
(4)拦截是否登录 :
有了第一个拦截器 , 这个类 就只用拦截是否登录即可 ,
实现逻辑 :
- 从ThreadLocal域中获取用户信息 .
- 只要为null , 就说明没有登录 , 直接拦截
- 不为null , 放行
代码实现 :
/**
* 拦截器类 , 负责拦截是否登录的
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
//前置拦截
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// TODO 1.判断是否需要拦截 , ThreadLocal中是否有用户信息
if (UserHolder.getUser() == null){
// 没有 , 需要拦截 , 设置状态码 ,
response.setStatus(401);
// 拦截
return false;
}
//有用户 , 直接放行
return true;
}
}
(5)拦截器的配置类:
@Configuration
// TODO 配置拦截器
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// TODO token 刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**")
.order(0); // TODO 设置优先级 ,值越小 , 优先级越高
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/blog/hot",
"/shop-type/**",
"/upload/**",
"/voucher/**",
"/shop/**",
"/user/code",
"/user/login"
).order(1);
}
}
知识点 :
(1)编写返回前端的工具类 , 后端统一使用这个工具类返回前端数据
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor //无参构造
@AllArgsConstructor //有参构造
// TODO 这个类用于整个工程中返回前端数据的封装 , 不管是查询出错 , 还是封装数据 , 都是通过这个类进行封装的 ,
// 对其中的方法进行重载 , 使其可以适用于多种情况
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);
}
}
(2)编写实体类的时候 , 使用注解指定对应的表名和标记主键
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data//@Data相当于@Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode这5个注解的合集。
@EqualsAndHashCode(callSuper = false)
// TODO 默认就是false
//此注解会生成equals(Object other) 和 hashCode()方法
//当使用@Data注解时,则有了@EqualsAndHashCode注解,那么就会在此类中存在equals(Object other) 和 hashCode()方法,
//且不会使用父类的属性 , 在继承之后 , 只要子类属性里面的相同的话,那hashcode的值就相同啦 ,
// 这时会出现问题 :
// 有多个类有相同的部分属性,把它们定义到父类中,恰好id(数据库主键)也在父类中,那么就会存在部分对象在比较时,它们并不相等,却因为lombok自动生成的equals(Object other) 和 hashCode()方法判定为相等,从而导致出错
// 当使用true的时候 , 在进行比较的时候 , 就会引用父类中继承过来的属性 , 这样进行判定的时候 , 就不会出现相等的情况了
@Accessors(chain = true)
// TODO 开启链式访问 , 可以将所有的set方法连在一起写
@TableName("tb_user") // TODO 指定是哪个数据库中的表
public class User implements Serializable {
// TODO 序列化
private static final long serialVersionUID = 1L;
//主键
@TableId(value = "id", type = IdType.AUTO) //指定主键 , type : 指定id是自增
private Long id;
//手机号码
private String phone;
//密码,加密存储
private String password;
//昵称,默认是随机字符
private String nickName;
//用户头像
private String icon = "";
//创建时间
private LocalDateTime createTime;
//更新时间
private LocalDateTime updateTime;
}
(3)编写常量类 : 定义一些固定的值
// TODO 指定的常量类 , 用于一些基础值的定义
// 一个公有类 , 常量都是静态公有属性 , final ,不可修改的属性
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;
}
(4)编写校验类 , 和正则常量类
import cn.hutool.core.util.StrUtil;
public class RegexUtils {
/**
* 是否是无效手机格式
* @param phone 要校验的手机号
* @return true:符合,false:不符合
*/
public static boolean isPhoneInvalid(String phone){
return mismatch(phone, RegexPatterns.PHONE_REGEX);
}
/**
* @param str 要检验的值
* @param regex 正则属性
* @return true|false
*/
// 校验是否不符合正则格式 , 这个方法没必要暴露在外边 , 定义为私有方法
private static boolean mismatch(String str, String regex){
if (StrUtil.isBlank(str)) { //判断是否为空
return true; //这里返回的是true , 但是这个是空字符
}
return !str.matches(regex); // str.matches(regex) : 如果输入的值符合正则表达式 , 则返回true , 前边加一个! , 只要符合 , 返回的就是false , 这样做是用反证法 , 用于调用此方法时的反证逻辑
}
}
(5)huTool中的StrUtil类
- isEmpty仅仅是判断null和长度为0字符串
- isBlank判断的是null,长度为0,空白字符(包括空格,制表符\t,换行符\n,换页符\f,回车\r)组成的字符串。
private void tstStr2() {
System.out.println(StrUtil.isEmpty(null)); // true
System.out.println(StrUtil.isEmpty("")); // true
System.out.println(StrUtil.isEmpty(" ")); // false
System.out.println(StrUtil.isEmpty("aaa")); // false
System.out.println(StrUtil.isEmpty("\r \t \n \f")); // false
System.out.println(StrUtil.isBlank(null)); // true
System.out.println(StrUtil.isBlank("")); // true
System.out.println(StrUtil.isBlank(" ")); // true
System.out.println(StrUtil.isBlank("aaa")); // false
System.out.println(StrUtil.isBlank("\r \n \t \f")); // true
}
(6)编写线程域的存储类:将登录信息的DTO信息存储进线程域中
public class UserHolder {
// TODO ThreadLocal : 每一次请求都会是一个新的线程 , ThreadLocal就是一个线程域 ,
// 在线程内部是以map的形式进行存储的 , 每个线程之间相互独立 , 没有干扰
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();
}
}
(7)获取互斥锁的方法
使用StringRedisTemplate 对象来进行setnx操作时 , 使用的是这个操作来进行的
#### 获取锁 :
使用的是Redis中的setnx , 这个操作在执行的时候 , 只有当它不存在的时候 , 才能执行成功 , 一旦创建成功之后 , 后边的所有setnx操作都会失败 ,
// TODO 设置获取锁的方法 , 这个key值就是锁的名称 , 谁调用 , 谁来赋予
private boolean tryLock(String key) {
// TODO setIfAbsent : 就是setnx的操作 , 可以设置过期时间 , 一般设置为业务完成时间的十倍左右 , 可以自定义
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
// TODO 不要直接返回这个Boolean ,
// 因为直接返回的话是要进行拆箱的 , 这时可能会造成空指针异常 , 我们使用的是huTool中的工具类
// 他可以帮你进行判断 , isTrue , isFalse(判断是否为false) , isBoolean(拆箱) ,
return BooleanUtil.isTrue(flag);
}
// TODO 删除锁
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
(8)设置当前时间和增加多少时间
//LocalDateTime.now() : 表示当前时间 ,
// plusSeconds : 表示添加多少时间
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
(9)判断逻辑时间是否过期
//判断是否过期 , 是否在当前时间之后 , isBefore是判断是否在当前时间之前
if (expireTime.isAfter(LocalDateTime.now())){
}
(10)创建一个线程池
// TODO 创建一个线程池 , 里边有十个线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
(11)开启新的线程 :
// TODO 6.3 成功 , 开启独立线程实现缓存重建 , 线程池在上边创建 , 线程池的名称.submit
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally{
// TODO 释放锁
unLock(lockKey);
}
});
(12)自定义锁 :
注意 , 使用user.toString , 虽然每次获取的值是一致的 , 但是 , 底层实现是每次新now一个对象 , 所以 , 两次之间作比较 , 是不一样的 , 使用intern方法 , 是在字符串常量池中 . 寻找相同的值的地址 , 这样两个字符串就是完全一样的了
synchronize(user_id.toString.intern()){
代码块 ;
}
(13)指定单元素集合 :
//这个操作 , 是指定是一个单元素的集合
Collections.singletonList(KEY_PREFIX + name)
(14)lua语言
Redis提供了Lua脚本功能 , 在一个脚本中编写多条Redis命令 , 确保多条命令执行时的原子性 , lua语言是一种编程语言 , 它的基本语法可以参考网站 : https://www.runoob.com/lua/lua-tutorial.html
编写脚本 :
执行脚本 :
在这后边 , 加一个数字 , 表示参数的个数 , 从这个数字后边开始 , 数几个 , 这些都是keys数组中的参数 , 剩下的都是ARGV数组中的参数 ,
注意 lua语言的数组 , 初始脚标是1
(15)将从数据库中查询出来的数据
按照指定的列进行排序
List<ShopType> sort = query().orderByAsc("sort").list();
(16)
什么是缓存 :
缓存就是数据交换的缓冲区 , 称作 (Cache) , 是存储数据的临时地方 , 一般读写性能较高
- 缓存的作用 :
- 降低后端的负载
- 提高读写效率 , 降低响应时间
- 缓存的成本 :
- 数据一致性成本
- 代码维护成本
- 运维成本
商户查询缓存 :
逻辑实现 :
- 从缓存中查询商铺信息
- 判断是否有缓存
- 有缓存
- 直接返回商铺信息 , 转为list格式
- 没有缓存 , 根据id从数据库中查询数据
- 判断是否有这个商铺
- 没有
- 直接返回错误信息
- 有
- 将数据保存到Redis中 , 并返回数据
- 没有
- 判断是否有这个商铺
- 有缓存
- 判断是否有缓存
代码实现 :
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public List<ShopType> queryShopTypeList() {
// TODO 1.从缓存中查询商铺信息
String lockShop = stringRedisTemplate.opsForValue().get(LOCK_SHOP_KEY);
// TODO 2.判断不为空 , 就直接返回 , 转为list格式
if (StrUtil.isNotBlank(lockShop)) {
List<ShopType> list = JSONUtil.toList(lockShop, ShopType.class);
return list;
}
// 3.为空 , 从数据库中查询
List<ShopType> sort = query().orderByAsc("sort").list();
// 判断查询数据是否为空
if (ArrayUtil.isEmpty(sort)){
return null;
}
// TODO 4.将查询出来的数据添加到Redis缓存 , 转为字符串
stringRedisTemplate.opsForValue().set(LOCK_SHOP_KEY,JSONUtil.toJsonStr(sort),LOCK_SHOP_TTL, TimeUnit.DAYS);
// TODO 5.将数据返回
return sort;
}
}
缓存更新策略:
03方案 :
先是将数据缓存 , 在一个异步操作中 , 将这些数据保存到数据库 , 这么做的好处是 , 在两次异步操作之间 , 进行的数据增删改 , 不用频繁的对数据库进行操作 , 只用在下一次异步操作时 , 将最终的数据进行保存即可
但是 , 如果在两次异步操作之间 , 出现宕机 , 可能会造成数据的丢失 , 一致性和可靠性都会存在一定的问题 ,
02方案 : 开发和维护成本较高
01方案 : 在开发中经常使用
操作缓存和数据库是有三个问题需要考虑
- 删除缓存还是更新缓存
- 更新缓存 : 每次更新数据库都要更新缓存 , 无效写操作较多
- 删除缓存 : 更新数据库时让缓存失效 , 查询时在更新缓存 (推荐)
- 如何保证缓存与数据库的操作同时成功或失败?
- 单体系统 : 将缓存与数据库操作放在一个事务
- 分布式系统 : 利用TCC等分布式事务方案
- 先操作缓存还是操作数据库 (两种方案都有可能造成线程安全问题 )
- 先删除缓存 , 再操作数据库(出现的可能性较高)
- 先操作数据库 , 再删除缓存 (出现的可能性极低)
总结 :
综上所述 : 选择缓存更新策略的最佳实践方案是 :
- 低一致性需求 : 使用Redis自带的内存淘汰机制
- 高一致性需求 : 主动更新 , 并以超时剔除作为兜底方案
-
读操作 :
- 缓存命中直接返回
- 缓存未命中则查询数据库 , 并写入缓存 , 设定超时时间
-
写操作 :
-
先写数据库 , 然后在删除缓存
-
要确保数据库与缓存操作的原子性
-
在更新操作上加一个注解
@Transactional //TODO 添加事务处理 , 整个方法是一个事务
-
-
-
// TODO 数据的更新操作
@Override
@Transactional //TODO 添加事务处理 , 整个方法是一个事务
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空!");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
缓存穿透 :
- 缓存穿透 :
- 是指客户端请求的数据在缓存中和数据库中都不存在 , 这样的缓存永远都不会生效 , 这些请求都会打到数据库
常见的解决方案 有两种 :
- 缓存空对象
- 实现 :
- 在查询时 , 缓存中没有 , 去查询数据库 , 数据库中也没有 , 返回一个 " " , 将这个" “缓存到缓存中 , 下次再请求的时候 , 直接从redis中返回这个” "
- 优点 :
- 实现简单 , 维护方便
- 缺点 :
- 额外的内存消耗
- 可能造成短期的不一致
- 实现 :
- 布隆过滤
- 优点 :
- 内存占用较少 , 没有多于key
- 缺点 :
- 实现复杂
- 存在误判可能 , 并不是百分百的准确
- 优点 :
- 增强id的复杂度 , 避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
实现逻辑 :
- 从redis中查询商铺缓存
- 判断是否存在
- 存在 , 直接输出
- 不存在 , 判断是否是空字符串 " " ,
- 是 , 返回一个错误信息
- 不是 , 根据id查询数据库
- 数据库中不存在该店铺
- 以id加前缀 作为key , 以 " " 作为key值保存在缓存中 , 设置过期时间
- 返回错误信息
- 数据库中存在该店铺
- 将查询出来的数据转换 , 存储在Redis中
- 返回商铺信息
- 数据库中不存在该店铺
- 判断是否存在
代码实现 :
// TODO 解决缓存穿透问题
public Shop queryWithPassThrough(Long id) {
// TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// TODO isNotBlank只有是有数据的情况加才为true , "" , null都为false
// TODO 3.判断不为空 , 直接返回 , 将字符串转为bean
return JSONUtil.toBean(shopJson, Shop.class);
}
//TODO 判断命中是否是空值 ,
if (shopJson != null) { //结果是空字符串
// 返回一个错误信息
return null;
}
// 4.不存在 , 根据id查询数据库
Shop shop = getById(id);
// 5.不存在返回错误
if (shop == null) {
// TODO 将空值写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return shop;
}
缓存雪崩 :
- 缓存雪崩 :
- 是指在同一时间段大量的缓存key同时失效或者Redis服务宕机 , 导致大量请求到达数据库 , 带来巨大压力
- 解决方案 :
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 添加机制 , 检测到redis服务出现宕机等严重事故时 , 牺牲一部分的业务来减少数据库的压力
- 给业务添加多级缓存
缓存击穿:
- 缓存击穿问题 :
- 也叫作热点key问题 , 就是一个被高并发访问 , 并且缓存重建业务比较复杂的key突然失效了 , 无数的请求访问会在瞬间给数据库带来巨大的冲击
常用的解决方案 :
基于互斥锁方式解决缓存击穿问题 :
互斥锁
- 问题 : 线程中只有一个线程 能够获取互斥锁 , 其他线程就只能进行等待 , 效率不高
需求 : 修改根据id查询商铺的业务 , 基于互斥锁方式来解决缓存击穿问题
获取锁 :
-
使用的是Redis中的setnx , 这个操作在执行的时候 , 只有当它不存在的时候 , 才能执行成功 , 一旦创建成功之后 , 后边的所有setnx操作都会失败 ,
-
// TODO 设置获取锁的方法 , 这个key值就是锁的名称 , 谁调用 , 谁来赋予 private boolean tryLock(String key) { // TODO setIfAbsent : 就是setnx的操作 , 可以设置过期时间 , 一般设置为业务完成时间的十倍左右 , 可以自定义 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS); // TODO 不要直接返回这个Boolean , // 因为直接返回的话是要进行拆箱的 , 这时可能会造成空指针异常 , 我们使用的是huTool中的工具类 // 他可以帮你进行判断 , isTrue , isFalse(判断是否为false) , isBoolean(拆箱) , return BooleanUtil.isTrue(flag); }
删除锁 :
-
// TODO 删除锁 private void unLock(String key) { stringRedisTemplate.delete(key); }
-
缺陷 :
- 如果设置锁之后 , 程序出现错误 , 导致没有人删除这个锁 , 那么后续所有的操作都会出错 ,
-
解决 : 给锁设置一个有效期 , 来进行兜底
实现逻辑 :
代码实现 :
// TODO 互斥锁解决缓存击穿 + 解决缓存穿透问题
public Shop queryWithMutex(Long id) {
// TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
// TODO 3.判断不为空 , 直接返回 , 将字符串转为bean
return JSONUtil.toBean(shopJson, Shop.class);
}
//TODO 判断命中是否是空值 ,
if (shopJson != null) { //结果是空字符串
// 返回一个错误信息
return null;
}
// TODO 开始缓存重建 , 获取互斥锁
// TODO 给每一个店铺设置互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop;
try {
if (!tryLock(lockKey)) {
// 获取互斥锁 失败 , 则休眠并重试
Thread.sleep(50);
// TODO 重试就是递归调用这个方法
return queryWithMutex(id);
}
// 4. 获取互斥锁成功 , 根据id查询数据库
shop = getById(id);
// TODO 模拟重建的延时
Thread.sleep(200);
// 5.不存在返回错误
if (shop == null) {
// TODO 将空值写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally { // 确保一定会释放锁
// TODO 释放互斥锁
unLock(lockKey);
}
// 7.返回
return shop;
}
基于逻辑过期的方式解决缓存击穿问题:
逻辑过期
原有的实体类中并没有设置逻辑过期的时间 , 但是也不能直接在原有的实体类中加这个属性 , 不然会对其他的代码有影响 , 可以新建一个类 , 来单独存放这个逻辑过期时间
@Data
public class RedisData {
// TODO 设置的逻辑过期时间
private LocalDateTime expireTime;
// TODO 用来封装想存入redis的实体类数据 ,
private Object data;
}
封装逻辑过期时间 :缓存重建
public void saveShop2Redis(Long id , Long expireSeconds){
// TODO 1.查询店铺数据
Shop shop = getById(id);
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// TODO 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
// TODO LocalDateTime.now() : 表示当前时间 ,
// plusSeconds : 表示添加多少时间
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// TODO 3.写入Redis , 不用设置过期时间 , 在redisData中已经封装了逻辑过期时间了
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
逻辑实现 :
- 从Redis中查询商铺数据 , 用商铺id加前缀作为唯一标识
- 判断是否命中 ,
- 未命中 , 直接返回错误信息
- 命中 , 将json格式反序列化为对象
- 判断逻辑时间是否过期
- 未过期 , 直接返回数据
- 过期 , 进行缓存重建
- 获取互斥锁 ,
- 成功 ,
- 开启一个新的线程 , 进行缓存重建
- 返回旧数据
- 未成功 , 返回旧数据
- 成功 ,
- 获取互斥锁 ,
- 判断逻辑时间是否过期
- 判断是否命中 ,
代码实现 :
// TODO 创建一个线程池 , 里边有十个线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// TODO 逻辑过期解决缓存击穿问题
public Shop queryWithLogicalExpire(Long id) {
// TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否命中
if (StrUtil.isBlank(shopJson)) {
// TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
// TODO 3.判断为空 , 直接返回null
return null;
}
// TODO 4.命中 , 需要先把json反序列化为对象 ,
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
// TODO 因为实体类中设置的是Object , 所以这里在转换的时候 , 转换的是一个JSONObject类型的数据
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// TODO 5.判断是否过期 , 是否在当前时间之后 , isBefore是判断是否在当前时间之前
if (expireTime.isAfter(LocalDateTime.now())){
// TODO 5.1未过期 , 直接返回信息
return shop;
}
// TODO 5.2 过期 , 缓存重建
// TODO 6.缓存重建 ,
// TODO 6.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// TODO 6.2 判断是否获取互斥锁
if (isLock){
// TODO 6.3 成功 , 开启独立线程实现缓存重建 , 线程池在上边创建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally{
// TODO 释放锁
unLock(lockKey);
}
});
}
return shop;
}
缓存工具的封装 :
基于StringRedisTemplate封装一个缓存工具类 , 满足下列需求
- 方法1 : 将任意java对象序列化为JSON并存储在string类型的key中 , 并且可以设置TTL过期时间
- 方法2 : 将任意java对象序列化为JSON并存储在string类型的key中 , 并且可以设置逻辑过期时间 , 用于处理缓存击穿问题
- 方法3 : 根据指定的key查询缓存 , 并反序列换为指定类型 , 利用缓存空值的方式解决缓存穿透问题
- 方法3 : 根据指定的key查询缓存 , 并反序列化为指定类型 , 需要利用逻辑过期解决缓存击穿问题
代码实现 :
@Component
@Slf4j
public class CacheClient {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedisData redisData;
/**
* 普通类型的缓存
*
* @param key key
* @param value value
* @param time TTL
* @param unit 时间单位
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
// TODO 这里需要将value转为json类型的字符串
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 解决缓存穿透问题
*
* @param keyPrefix 前缀
* @param id id
* @param type 数据类型
* @param function 查询数据库函数
* @param time 缓存时间
* @param unit 时间单位
* @param <R> 泛型 , 返回值
* @param <ID> 泛型 , 主键
* @return 返回的数据
* TODO 解决缓存穿透问题 , 定义泛型 ,使用泛型 , 对于不确定的类型 , 统统使用泛型定义类型
*/
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis中查询缓存 , 以id加key作为唯一标识
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.判断不为空 , 直接返回 , 将字符串转为bean
return JSONUtil.toBean(json, type); // TODO 这里直接使用泛型的数据类型 ,
}
// 判断命中是否是空值 ,
if (json != null) { //结果是空字符串
// 返回一个错误信息
return null;
}
//TODO 4.不存在 , 根据id查询数据库 ,
// 这里不能直接调用数据库进行查询了 , 因为对应的类型不一致 , 查询的数据库也不一致 ,
// 这里可以使用参数传递逻辑 , java中传递逻辑 , 使用的是Function<T,R> function , 可以传递一个函数到方法中
// 调用函数形参中的方法 , apply , 将参数传递进去即可
R r = function.apply(id);
// 5.不存在返回错误
if (r == null) {
// TODO 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", time, unit);
return null;
}
// TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
this.set(key, r, time, unit);
// 7.返回
return r;
}
// TODO 创建一个线程池 , 里边有十个线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 封装逻辑过期时间
*
* @param key key
* @param value value
* @param time 过期时间
* @param unit 时间单位
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// TODO 这里需要将value转为json类型的字符串
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
// TODO 逻辑过期解决缓存击穿问题
public <R , ID> R queryWithLogicalExpire(String keyPrefix, String lockPrefix ,ID id , Class<R> type , Function<ID ,R> function , Long time, TimeUnit unit) {
redisData = new RedisData();
String key = keyPrefix + id ;
// TODO 1.从redis中查询缓存 , 以id作为唯一标识
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否命中
if (StrUtil.isBlank(json)) {
// TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
// TODO 3.判断为空 , 直接返回null
return null;
}
// TODO 4.命中 , 需要先把json反序列化为对象 ,
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// TODO 因为实体类中设置的是Object , 所以这里在转换的时候 , 转换的是一个JSONObject类型的数据
JSONObject data = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(data, type);
// TODO 5.判断是否过期 , 是否在当前时间之后 , isBefore是判断是否在当前时间之前
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// TODO 5.1未过期 , 直接返回旧信息
return r;
}
// TODO 5.2 过期 , 缓存重建
// TODO 6.缓存重建 ,
// TODO 6.1 获取互斥锁
String lockKey = lockPrefix + id;
boolean isLock = tryLock(lockKey);
// TODO 6.2 判断是否获取互斥锁
if (isLock) {
// TODO 6.3 成功 , 开启独立线程实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R r1 = function.apply(id);
this.setWithLogicalExpire(key , r1 ,time , unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// TODO 释放锁
unLock(lockKey);
}
});
}
return r;
}
// TODO 设置获取锁的方法 , 这个key值就是锁的名称 , 谁调用 , 谁来赋予
private boolean tryLock(String key) {
// TODO setIfAbsent : 就是setnx的操作 , 可以设置过期时间 , 一般设置为业务完成时间的十倍左右 , 可以自定义
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
// TODO 不要直接返回这个Boolean ,
// 因为直接返回的话是要进行拆箱的 , 这时可能会造成空指针异常 , 我们使用的是huTool中的工具类
// 他可以帮你进行判断 , isTrue , isFalse(判断是否为false) , isBoolean(拆箱) ,
return BooleanUtil.isTrue(flag);
}
// TODO 删除锁
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
}
优惠券秒杀 :
全局ID生成器 策略:
全局ID生成器 , 是一种在分布式系统下用来生成全局唯一ID的工具 , 一般要满足下列特性
- 唯一性 :全局只有一个
- 高可用 :
- 递增性 :
- 安全性 : 规律不明显 , 让用户猜不到订单的信息
- 高性能 : 生成速度快 , 不影响其他业务的执行
为了增加ID的安全性 , 我们可以不直接使用Redis自增的数值 , 而是拼接一些其他信息
- ID的组成部分 :
- 符号位 : 1bit , 永远为0
- 时间戳 : 31bit , 可以使用60年
- 序列号 : 32bit , 秒内的计数器 , 支持每秒产生2^32个不同ID
编写实现 :
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* 全局ID生成器
*/
@Component
public class RedisIDWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
// TODO 指定的初始时间戳
private static final Long BEGIN_TIMESTAMP = 946684800L;
// TODO 序列号的位数 , 一般不写死
private static final int COUNT_BITS = 32;
//一般这个key , 是根据谁调用谁传递过来的字符串 , 进行拼接
public Long nextId(String keyPrefix) {
// TODO 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSecond - BEGIN_TIMESTAMP;
// TODO 2.生成序列号 , 使用redis的自增长机制 ,
// 不能使用同一个key作为自增长的key值 ,
// Redis单个key的最大自增数是2^64 , 我们设置的序列号最大是 2^32位
// 一直使用同一个key会序列号溢出的问题的 ,
// 可以在key的后边加一个当天的时间戳 , 一天使用一个key的值
// TODO 2.1 获取当前日期 , 精确到天
// 中间使用 : 分隔,在Redis中会进行分层 , 这个时候 , 就可以根据对应的前缀 , 来进行数据的统计
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//自增长 , 默认是从1开始自增的 , 每次增加 1
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// TODO 3.拼接并返回 ,
// 使用的是移位运算 , 将时间戳左移32位
// 同时使用或运算 , 来进行拼接 ,
// 或运算的逻辑是 , 在位移后的位置上 , 全部都是0 ,
// 进行运算时 , 参加运算的两个对象,一个为1,其值为1 ,也就是count值是1就是1 , 是零就是0
// 或的运算速度要比加快
return timeStamp << COUNT_BITS | count ;
}
// TODO 使用下边的方法可以生成指定时间的时间戳
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2000, 1, 1, 0, 0, 0);
long l = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(l);
}
}
30212046316 62362635 703429019
30212047433 31512332 703429045
多线程检验:
@Resource
private RedisIDWorker redisIDWorker;
// TODO 创建一个线程池
private ExecutorService ex = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
//一个线程循环执行100次 , 操作
for (int i = 0; i < 100; i++) {
Long id = redisIDWorker.nextId("order");
System.out.println("id =" + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
ex.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time =" + (end -begin));
}
优惠券秒杀 :
基础逻辑:
数据库表的创建 : 优惠券订单表
代码实现 :
@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIDWorker redisIDWorker;
@Override
public Result sekillVoucher(Long voucherId) {
// TODO 1.查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// TODO 2.判断秒杀是否开始
//开始时间在当前时间之后
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀未开始");
}
// TODO 3.判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("活动以结束");
}
// TODO 4.判断库存是否充足
Integer stock = seckillVoucher.getStock();
if (stock < 1){
return Result.fail("库存不足");
}
// TODO 5.扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if (!success){
return Result.fail("库存不足");
}
// TODO 6.创建订单
VoucherOrder order = new VoucherOrder();
// TODO 6.1订单id
Long orderId = redisIDWorker.nextId("order");
order.setId(orderId);
// TODO 6.2用户id
Long userId = UserHolder.getUser().getId();
order.setUserId(userId);
// TODO 6.3代金卷id
order.setVoucherId(seckillVoucher.getVoucherId());
// 写入数据库
save(order);
// TODO 7.返回订单id
return Result.ok(orderId);
}
}
秒杀的超卖问题 :
乐观锁优化超卖问题 :
- 乐观锁的关键是判断之前查询得到的数据是否有被修改过 , 常见的方法有两种
- 版本号法
- 设置一个版本号 , 在每次执行操作之前 , 先查询库存和版本号 ,
- 在每次执行操作库存扣减之前 , 先进行判断 , 看现在数据库中的版本号 , 和之前获取的版本号是否一致 , 一致就进行操作
- 在执行完库存操作之后 , 将版本号加一
- CAS法 : 在版本号法基础上做了一些改进 , 不用添加版本号 , 每次操作之前 , 先和自己查询的库存进行比较 , 出现不一致 , 就不操作
- 先比较 , 后操作
- 版本号法
-
但是直接和之前获取的库存数进行比较 , 会造成大量的失败 ,
-
我们再次进行改进 , 在进行操作时 , 添加判断条件 , 只要库存大于 0 , 就可以操作
// TODO 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // TODO set stock = stock - 1
.eq("voucher_id", voucherId) // TODO where id = ?
//.eq("stock", seckillVoucher.getStock())
// TODO and stock = ? , 这么做会造成大量的失败
.gt("stock",0)
//TODO 使用gt , 添加的条件是 , stock > 0 , 这样刚好符合业务逻辑
.update();
一人一单问题 :
逻辑实现 :
代码实现 :
使用乐观锁和悲观锁同时使用的形式 ,来优化超卖和一人一单问题 ,
将有关数据库的操作 , 抽取为一个方法 , 并且自定义悲观锁 , 以userid作为锁 , 确保多个相同id的线程中 , 只有一个线程能进行操作
@Transactional
public Result getResult(Long voucherId) {
// TODO 6. 一人一单
Long userId = UserHolder.getUser().getId();
// TODO 给对应的id加锁 , 确保同一个id只能有一个线程能操作 , 这样就没有线程安全问题了,
synchronized(userId.toString().intern()){
// TODO 6.1 查询订单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// TODO 6.2 判断是否存在
if (count > 0){
return Result.fail("您已经购买过了");
}
// TODO 5.扣减库存 , 要在判断表中是否有相同id的数据之后进行操作 ,
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // TODO set stock = stock - 1
.eq("voucher_id", voucherId) // TODO where id = ?
.gt("stock",0) //TODO 使用gt , 添加的条件是 , stock > 0 , 这样刚好符合业务逻辑
.update();
if (!success){
return Result.fail("库存不足");
}
// TODO 6.创建订单
VoucherOrder order = new VoucherOrder();
// TODO 6.1订单id
Long orderId = redisIDWorker.nextId("order");
order.setId(orderId);
// TODO 6.2用户id
order.setUserId(userId);
// TODO 6.3代金卷id
order.setVoucherId(voucherId);
// 写入数据库
save(order);
// TODO 7.返回订单id
return Result.ok(orderId);
}
}
集群部署中的一人一单问题 :
之前使用悲观锁和乐观锁同时并存的方式 , 可以解决一人一单的问题 ,
这是因为 , 锁的原理是在jvm中创建了一个锁监视器 , 通过第一个线程的访问 , 获取锁的名称, 进jvm , 这是 , 其他用户在进行访问的时候 , 就无法获取到锁 , 无法进行操作
如果是使用集群部署的形式 , 那么就会创建两个或多个jvm , 创建多个锁监视器 , 这个时候再进行访问的时候 , 就会导致一个锁只能锁住一个服务器的请求 , 访问其他服务器也可以同时进行操作
分布式锁:
在redis中 , 使用set操作 , 可以同时指定多个属性 , 来完成互斥 和 设置失效时间的操作
set key thread1 NX (设置互斥) EX 10 (设置过期时间)
在jdk中 , 有两种获取锁失败的操作 : 失败返回 或 阻塞
阻塞会不断重试获取锁 , 会对内存造成很大的浪费 , 这里我们使用失败返回的操作 ,
尝试获取锁 :
@Override
public boolean tryLock(Long timeoutSec) {
// TODO 获取线程的标识
long threadId = Thread.currentThread().getId();
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(aBoolean); //使用boolean中的方法 , 判断是否是true , 也可以解决自动拆箱造成的空指针异常情况
}
在原来的代码中 , 就不需要加 synchronize锁了 , 而是直接 尝试获取锁 , 如果获取锁失败 , 那么就直接输出错误信息 , 成功 , 再进行下一步业务操作
分布式锁的极端情况出现的线程问题 :
(1)阻塞超时 ,删除别的线程的锁 :
因为一个带锁线程发生阻塞, 导致锁超时失效 , 其他线程获取这个锁 , 导致 ,阻塞线程删除锁的时候 , 将其他线程获取的锁删除了 ,
- 解决方案 :
- 在获取锁的时候 , 将UUID和线程iD拼接 , 作为value值 , 存入redis中 , 在释放锁的时候 , 进行判断 , 如果二者一致 , 就释放锁 , 如果不一致 , 说明这个锁已经被别人获取了 , 那么就不用进行释放了
public class SimpRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
private String name;
public SimpRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(Long timeoutSec) {
// TODO 获取线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(aBoolean);
}
@Override
public void unLock() {
// TODO 获取线程标识
String thread = ID_PREFIX + Thread.currentThread().getId();
//TODO 获取redis中的数据
String threadId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (thread.equals(threadId)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
(2)使用lua脚本语言 , 解决释放锁时出现的问题
JVM执行垃圾回收的时候 , 所有的代码是无法工作的 , 造成在释放锁的时候发生阻塞
- 极端情况 :
- 线程1获取锁之后 , 顺利执行完业务 , 并且已经判断好了 , 锁是自己拥有的 , 正要执行释放锁的时候 , 垃圾回收进行触发 , 导致线程阻塞, 刚好 , 又到了锁的过期时间过期了 , 那么 , 这个时候 , 这个锁就会被别的线程2获取 , 但是线程1已经做了判断 , 认为这个锁是自己的了 , 继续执行 , 就将线程2的锁释放了
- 解决方案 :
- j将判断和释放锁 , 放在一个原子操作中
- 使用lua脚本语言进行操作
-- 锁的key
-- local key = KEYS[1]
-- 当前线程标识
-- local threadId = ARGV[1]
-- 获取锁中的线程标识 get key
-- local id = redis.call('get',KEYS[1])
-- 比较线程标识与锁中标识是否一致
if (ARGV[1] == redis.call('get',KEYS[1])) then
-- 释放锁
redis.call('del',KEYS[1])
end
return 0
引入lua脚本 :
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static{
UNLOCK_SCRIPT = new DefaultRedisScript<>(); //初始化脚本 ,
//指定脚本的位置 , 一般是放在Resource包中 , 直接使用 ClassPathResource , 就是从Resource包中开始寻找
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
编写释放锁代码 :
//释放锁
@Override
public void unLock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
//这个操作 , 是指定是一个单元素的集合
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
Redisson:
Redisson是一个在Redis的基础上实现的java驻内存数据网络 , 它不仅提供了一系列的分布式的java常用对象 , 还提供了许多分布式服务 , 其中就包含了各种分布式锁的实现
官网地址 : https://redisson.org
GIthub地址 : https://github.com/redisson/redisson
快速入门 :
引入依赖 :
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.8</version>
</dependency>
配置Redisson客户端
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
//添加redis地址 , 这里添加了单点的地址 , 也可以使用config.userClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 创建客户端
return Redisson.create(config);
}
}
使用Redisson的分布式锁 :
// TODO 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// TODO 尝试获取锁 , 参数分别是 : 获取锁的最大等待时间(期间会重试) , 所自动释放时间 , 时间单位
// 如果最大等待时间结束 , 还是没有获取到锁 , 那么才会返回false
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
// TODO 释放锁
lock.unlock();