java学习(day37)

该项目来自于黑马程序员的Redis课程里面的一个实战项目

1、短信登录

1.1、导入黑马点评项目

1.1.1 、导入SQL

在这个地方我们导入数据库时候产生了报错

这是因为之前苍穹外卖时候,他这个sql语句里面

有这个建表语句

而这次点评项目sql里面没有建表语句

所以我们可以先写一个建表语句

复制代码
CREATE DATABASE IF NOT EXISTS hmdp;
USE hmdp;

然后选好数据库就能导入了

.1.2、有关当前模型

手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。

在tomcat支撑起并发流量后,我们如果让tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。

1.1.3、导入后端项目

在资料中提供了一个项目源码:

1.1.4、导入前端工程

1.1.5 运行前端项目

1.2 、基于Session实现登录流程

发送验证码:

用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号

如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

短信验证码登录、注册:

用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息

校验登录状态:

用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行

实现发送短信验证码功能

页面流程

具体代码如下

贴心小提示:

具体逻辑上文已经分析,我们仅仅只需要按照提示的逻辑写出代码即可。

  • 发送验证码

我们是把这个主要功能放到service里面去做

UserServiceImpl

复制代码
@Override
public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4.保存验证码到 session
    session.setAttribute("code",code);
    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{}", code);
    // 返回ok
    return Result.ok();
}

2. 校验手机号

Java

复制代码
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
    // 2.如果不符合,返回错误信息
    return Result.fail("手机号格式错误!");
}
  • 这里调用了一个叫 RegexUtils 的工具类(通常里面封装了正则表达式)来判断手机号格式对不对(比如是不是 11 位、是不是 1 开头)。
  • 如果 isPhoneInvalid 返回 true(无效手机号),就直接打断后续操作,返回给前端一个带有错误提示的 Result.fail这一步是为了防止乱填数据,并节省调用真实短信接口的费用。

3. 生成验证码

Java

复制代码
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
  • 代码走到这里,说明手机号没问题。
  • 调用 RandomUtil(通常是 Hutool 等开源工具包里的类)生成一个 6位数的随机纯数字 字符串,并赋值给变量 code

4. 保存验证码

Java

复制代码
// 4.保存验证码到 session
session.setAttribute("code",code);
  • 这是非常关键的一步! 为什么要存起来?因为等会儿用户收到短信后,要把验证码填在网页上发回来。服务器必须记住刚才发给这个用户的验证码是多少,才能进行对比校验。
  • session 就像是服务器为当前用户开的一个"私人储物柜"。这行代码把生成的 code 放进了储物柜,并贴上了一个叫 "code" 的标签。

5. 模拟发送并返回结果

Java

复制代码
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
  • 这里就是您上一题问到的日志代码。在真实的商业项目中,这里应该是一段调用阿里云、腾讯云等短信服务商 API 的代码。但为了本地测试方便和省钱,这里直接用日志打印来模拟发送成功。

  • 最后,返回 Result.ok() 告诉前端:"一切顺利,验证码已经发了,你提示用户注意查收吧"。

  • 登录

UserServiceImpl

复制代码
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.校验验证码
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if(cacheCode == null || !cacheCode.toString().equals(code)){
        //3.不一致,报错
        return Result.fail("验证码错误");
    }
    //一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();

    //5.判断用户是否存在
    if(user == null){
        //不存在,则创建
        user =  createUserWithPhone(phone);
    }
    //7.保存用户信息到session中
    session.setAttribute("user",user);

    return Result.ok();
}

然后就在下面创建一个createUserWithphone的方法

复制代码
private User createUserWithPhone(String phone) {
    // 1. 创建用户
    User user = new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    // 2. 保存用户
    save(user);
    return user;
}

这个save就是mybatisplus 的提供的一个方法,它的作用就和insert一样

mybatisPlus 初窥门径

这里面用到了mybati splus ,下面我们来好好讲讲这个东西

你可以把 MyBatis-Plus 理解为 MyBatis 的"超级外挂"或"增强包" 。它底层依然是 MyBatis,但它帮你把单表的"增删改查"全自动写好了,你再也不用为了写一个简单的 SELECT * FROM ... WHERE ... 去建 XML 文件或者写 @Select 注解了。

下面我为你把这段代码和类声明"翻译"成你能秒懂的 MyBatis 逻辑。


1. 拆解这段代码干了啥

Java

复制代码
// 一致,根据手机号查询用户
User user = query().eq("phone", phone).one();

如果只用原生 MyBatis,为了实现这个功能,你需要做三件事:

  1. UserMapper.java 里写:User selectByPhone(String phone);
  2. UserMapper.xml 里写:<select id="selectByPhone" resultType="User"> SELECT * FROM tb_user WHERE phone = #{phone} </select>
  3. 在 Service 里调用:userMapper.selectByPhone(phone);

但是用了 MyBatis-Plus,上面的 1 和 2 全都省了!你只需要写这一行代码。

它的链式调用翻译过来就是:

  • query(): 告诉框架,"我要开始构建一个查询条件了"。
  • .eq("phone", phone): eq 是 equals(等于)的缩写。意思是 SQL 里的 WHERE phone = #{phone}。也就是 eq(数据库列名, 要匹配的值)
  • .one(): 告诉框架,"执行这个查询,并且我确信结果最多只有一条,请给我返回一个 User 对象(如果找不到就返回 null)"。既然有.one()那就有.list 意思就是返回一个list集合

2. 为什么要继承 ServiceImpl**?泛型又是什么意思?**

这个是mybaitsplus 提供的 可以帮助我们实现单表的增删改查

之前我记得usermapper 还会写一点方法,现在还需写吗

你记得非常准确!以前用原生 MyBatis 的时候,UserMapper 里面密密麻麻写满了各种增删改查的方法。

现在用了 MyBatis-Plus,绝大多数情况下你不需要写方法了,但遇到特殊情况依然要手写。

我们可以把情况分为两类:

1. 什么时候 不需要 写任何方法?(大约占日常开发的 80%~90%)

只要你的业务需求是单表操作(只操作 tb_user 这一张表),你就不需要在 UserMapper 里写哪怕一行代码。

因为你继承的 BaseMapper<User> 已经默默帮你写好了几十个常用的方法,比如:

  • insert(user) (新增用户)
  • deleteById(id) (根据ID删除)
  • updateById(user) (根据ID更新)
  • selectById(id) (根据ID查询)
  • selectList(queryWrapper) (条件查询/列表查询)

所以,在处理这些常规需求时,你的 UserMapper 就是一个干干净净的"空壳":

Java

复制代码
@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 里面什么都不用写!这就够了!
}

2. 什么时候 依然需要 自己写方法?(大约占 10%~20%)

MyBatis-Plus 的设计理念是 "单表 MP 搞定,多表手写 SQL"。当你遇到以下两种情况时,就需要像以前一样在里面写方法了:

场景一:多表联查 (JOIN)

假设你需要查询"某个用户的基本信息,以及他最近购买的订单记录"。这就涉及到了 tb_usertb_order 两张表。MyBatis-Plus 原生不支持多表 JOIN。

这时候,你就要自己动手了:

Java

复制代码
@Mapper
public interface UserMapper extends BaseMapper<User> {
    
    // 你依然可以像以前一样写 @Select 注解
    @Select("SELECT u.*, o.order_no FROM tb_user u JOIN tb_order o ON u.id = o.user_id WHERE u.id = #{userId}")
    List<UserOrderDTO> selectUserWithOrders(Long userId);
}

场景二:非常复杂的单表查询(或性能优化)

有时候单表查询的条件极其复杂(比如一大堆嵌套的 ANDOR,或者复杂的聚合函数 GROUP BYHAVING),如果强行用 MyBatis-Plus 的链式代码(Wrapper)去拼接,代码会变得极其难读。

这时候,为了代码清晰或者专门优化 SQL 性能,我们也会选择退回到原生 MyBatis 的写法,在 Mapper 里定义方法,然后去 XML 文件里写复杂的 SQL。

你提到的类的头部通常长这样:

Java

复制代码
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService

为什么要 extends ServiceImpl**?**

你上面能直接调用 query(),或者在其他地方调用 save(user)removeById(id) 等等这些现成的方法,全是因为你继承了 ServiceImpl

ServiceImpl 是 MyBatis-Plus 官方提前帮你写好的一个类,里面包含了成百上千行实现增删改查的代码。你一继承它,你的 UserServiceImpl 就瞬间白嫖了所有这些单表操作的方法,不需要自己一行行去写了。

为什么泛型是 <UserMapper, User>****?

既然 ServiceImpl 是官方写好的一个通用类,官方在写这个类的时候,根本不知道未来你会用它来操作"用户表"还是"订单表",也不知道你的 Mapper 叫什么名字。

所以官方用了泛型,让你在使用时"填空":

  • 第一个填空位 UserMapper:告诉 ServiceImpl:"哥们,当你底层真正需要去操作数据库时,请使用我提供的 UserMapper 这个接口去执行。"(相当于自动帮你做了 @Autowired UserMapper 的注入)。

我们来回忆一下之前纯mybatis的时候

1. 以前(只用原生 MyBatis 时)你是怎么写的

在标准的 Spring + MyBatis 架构中,Service 层需要调用 Mapper 层去查数据库。所以你必须在 Service 类里手动注入 Mapper:

Java

复制代码
@Service
public class UserServiceImpl implements IUserService {

    // 👇 就是这里!你以前一定会写这段代码把 Mapper 注入进来
    @Autowired 
    private UserMapper userMapper;

    @Override
    public void login(String phone) {
        // 然后你才能在方法里使用 userMapper 去查数据库
        User user = userMapper.selectByPhone(phone);
        // ... 其他逻辑
    }
}

如果没有 @AutowireduserMapper 拿过来,你在 Service 里根本没法和数据库打交道。


2. 现在(用了 MyBatis-Plus 时)变成了什么样

用了 MyBatis-Plus 之后,你的代码变成了这样:

Java

复制代码
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    // 👆 注意看,这里面干干净净,没有 @Autowired private UserMapper userMapper; 这一行了!

    @Override
    public void login(String phone) {
        // 你直接调用了 query() 就能查数据库
        User user = query().eq("phone", phone).one();
    }
}

💡 核心解密:它是怎么"自动注入"的?

当你写了 extends ServiceImpl<UserMapper, User> 的时候,你继承的那个父类 ServiceImpl 的源码里,其实偷偷写了类似这样的代码:

Java

复制代码
// 这是 MyBatis-Plus 官方源码的简化版
public class ServiceImpl<M extends BaseMapper<T>, T> {

    @Autowired
    protected M baseMapper; // 这里把传进来的 UserMapper 注入了,取名叫 baseMapper

    // 你调用的 query() 方法,底层其实就是在使用这个 baseMapper
}

总结一下: 以前,你是自己当大厨,必须亲自去仓库(Spring 容器)把菜刀(UserMapper)拿过来( @Autowired**)才能切菜。 现在,你继承了一个"全自动厨房(ServiceImpl)"** ,你在门口(泛型 <UserMapper, ...>)告诉厨房:"我今天切菜要用那把特定的菜刀",厨房内部就已经把菜刀给你摆好(自动注入为 baseMapper),你直接喊一声"切菜(query())"就可以了!

  • 第二个填空位 User:告诉 ServiceImpl:"哥们,我们这次要操作的数据表映射的实体类是 User 类。查询结果请帮我封装成 User 对象,新增数据时我也传给你 User 对象。"

💡 总结对照表

|---------------|-----------------------------------|------------------------------------------------|
| 概念 | 原生 MyBatis | MyBatis-Plus |
| Mapper 层 | 自己写接口,自己写 XML/注解 | 继承 BaseMapper<User> ,全自动搞定单表 SQL |
| Service 层 | 注入 Mapper,自己写各种增删改查方法 | 继承 ServiceImpl<UserMapper, User> ,白嫖全套增删改查方法 |
| 根据手机号查询 | userMapper.selectByPhone(phone) | query().eq("phone", phone).one() |

这就是为什么大家爱用 MyBatis-Plus 的原因:少写重复代码,准点下班。 复杂的多表联查(JOIN)你依然可以像以前一样去写 XML,两者完全不冲突!

还有一个

当你写下 ServiceImpl<UserMapper, User> 时,你已经完成了两件大事:

  1. 锁定目标: 你告诉框架,这个 Service 处理的实体类是 User
  2. 触发扫描: MyBatis-Plus 在项目启动时,会通过 Java 反射机制(Reflection) 去"解剖"这个 User 类。

框架内部的逻辑大概是这样的:

  • 第一步(找到类): "哦,泛型里的 TUser 类。"
  • 第二步(看头顶): "我来看看 User 类头顶上有没有贴 @TableName 标签?"
  • 第三步(读取内容): * 如果有,比如 @TableName("tb_user"),框架就记下:"以后这个 Service 发出的 SQL 语句,表名都写 tb_user。"
    • 如果没有,框架就启动备份方案:"既然没贴名牌,那我就默认表名和类名一样,也叫 user 吧。"

不仅仅是表名

通过泛型找到这个类后,框架还会继续往下扫描类里面的成员变量(Fields)

  • 看到 @TableId:它就知道了数据库里的主键是哪一列,主键是怎么生成的(比如自增)。
  • 看到普通变量 phone:它就知道了数据库里有一个叫 phone
  • 看到 @TableField(exist = false):它就知道了这个变量只是代码里临时用的,不要去数据库里查这一列。

当没有定义注解的时候默认规则

当我们通过注解定义了查哪张表的时候

这里有一个小技巧,找实现这里有两种方式

对着这个ctrl+Alt+B

或者直接点左边绿色I按钮(这个之前常用)

然后在数据库中点击这个ddl也可以直接打开这个建表语句,查看相关的内容

在这里我们加入了mybatis plus的内容

实现登录拦截功能

注意登陆这里是我们自己写到登陆的业务层里面自己拦,后面其它功能,不可能都像登陆这样一个个controller

都这样亲历亲为,所以写一个拦截器全局拦截,然后把登陆功能放行就行了

提到拦截器这里我们又不由的想到过滤器,接下来我们把它们放在一张图片里面再来对比一下

温馨小贴士:tomcat的运行原理

当用户发起请求时,会访问我们像tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket相互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应

通过以上讲解,我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据

温馨小贴士:关于threadlocal

黑马说每一个进入tomcat的请求都是一个独立的线程

如果小伙伴们看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离

正是因为 map 不一样,所以最后得到的数据是绝对不一样的

我们通过拦截器校验完成之后,需要把校验后得到结果放在threadlocal里面最后再由threadlocal传给这几个controller

这里面登陆的凭证也就是session_id 实际上就在cookie里面

拦截器代码

我们是把这个拦截器代码写在utils包里面

我们需要重写它的一下方法,但是在这里我们发现一个问题,我们使用alt+enter好像已经不管用了,这是因为

alt+enter只在修复错误时候才管用

这里我就要用到ctlr+i ctrl+O也可以

重写pre方法和after方法

复制代码
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中的用户
        Object user = session.getAttribute("user");
        //3.判断用户是否存在
        if(user == null){
            //4.不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        //5.存在,保存用户信息到Threadlocal
        UserHolder.saveUser((User)user);
        //6.放行
        return true;
    }
}

这里黑马漏了一个afterCompletetion

复制代码
@Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户,防止内存泄漏和数据串位
        UserHolder.removeUser();
    }

为什么要加这个,前面那个是要在拦截之前生效,后面这个是

1. 防止"数据串位"(极其严重的安全 Bug)

还记得我们刚才说的吗?Tomcat 里的线程是**"用完即还,重复利用"**的。

  • 假设用户【张三】发起了请求,Tomcat 派出了【线程 A】来接待。拦截器把张三的信息存进了【线程 A】的私有口袋(ThreadLocalMap)里。
  • 请求结束了,如果你不清理口袋,【线程 A】就带着张三的信息回到了线程池待命。
  • 过了一会儿,用户【李四】发起了请求。Tomcat 恰好又把【线程 A】派出去接待李四。
  • 如果后面的代码直接去读 ThreadLocal,系统就会误以为当前操作的人是张三! 这就导致李四看到了张三的隐私,或者花掉了张三的钱。

所以,必须在每次请求结束时,把口袋翻过来抖干净。

也就是说threadlocha里面的数据我们只在这一次线程里面使用就行了,线程结束就要清空换回线池,不清空,下次别的线程就会看到

从这里其实也回答了为什么通过treadlocal可以做到线程隔离

让拦截器生效

复制代码
@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);
    }
}

这里后面的.order(1)是指拦截器执行的顺序

隐藏用户敏感信息

我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了

在登录方法处修改

复制代码
//7.保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));

注意这里和我们之前苍穹外卖学的

BeanUtils.copyProperties(源, 目标)有点不一样,目标这里之前我们不是传字节码对象,而是一个实例化对象

复制代码
// 1. 必须自己手动 new 一个空的目标对象
UserDTO userDTO = new UserDTO(); 
// 2. 将 user 的属性拷贝到 userDTO 中 (注意:没有返回值)
BeanUtils.copyProperties(user, userDTO); 
// 3. 把填满数据的 userDTO 存入 session
session.setAttribute("user", userDTO);

在拦截器处:

复制代码
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);

在UserHolder处:将user对象换成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();
    }
}

这里有一个小技巧查找用法,看看getUser在哪写地方被用了

这是最标准的查找方式,会在底部打开一个专用的 Find 窗口,方便你慢慢看。

  • Windows / Linux: Alt + F7

session共享问题

核心思路分析:

每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了

但是这种方案具有两个大问题

1、每台服务器中都有完整的一份session数据,服务器压力过大。

2、session拷贝数据时,可能会出现延迟

所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了

其实jwt也可以解决,并且是最适合企业的

Redis代替session的业务流程

设计key的结构

首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。

设计key的具体细节

所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code了

在设计这个key的时候,我们之前讲过需要满足两点

1、key要具有唯一性

2、key要方便携带

如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了

整体访问流程

当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

基于Redis实现短信登录

这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。

UserServiceImpl代码

复制代码
@Override
public Result sendCode(String phone, HttpSession session) {
    // 1. 校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2. 如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    
    // 3. 符合,生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4. 保存验证码到 redis (原注释写的是session,但代码实际是存redis)
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

    // 5. 发送验证码 (这里并没有真正发短信,而是用日志模拟发送)
    log.debug("发送短信验证码成功,验证码:{}", code);
    
    // 返回ok
    return Result.ok();
}

这里黑马漏一段sendcode方法

UserServiceImpl代码

复制代码
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.从redis获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        // 不一致,报错
        return Result.fail("验证码错误");
    }

    // 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
    User user = query().eq("phone", phone).one();

    // 5.判断用户是否存在
    if (user == null) {
        // 6.不存在,创建新用户并保存
        user = createUserWithPhone(phone);
    }

    // 7.保存用户信息到 redis中
    // 7.1.随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 7.2.将User对象转为HashMap存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                                                     CopyOptions.create()
                                                     .setIgnoreNullValue(true)
                                                     .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 7.3.存储
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4.设置token有效期
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

    // 8.返回token
    return Result.ok(token);
}

然后在这里我们看到了

Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), ...);

里面BeanUtil的另一种用法

在这里 String token = UUID.randomUUID().toString(true);

这里输入true的目的是把横杠去掉

比如默认没有true是这样

550e8400-e29b-41d4-a716-446655440000

然后这里的返回值也和之前不一样

因为在新的方案里,你必须亲手把"开门钥匙(Token)"交给前端,否则前端下次来请求时,服务器就不认识它了。

为了让你彻底明白,我们把这两种方式比作"去酒店开房":

1. 之前的代码(Session 方案):为什么什么都不返回?

Java

复制代码
session.setAttribute("user", ...);
return Result.ok(); // 空手而归
  • 底层原理: 当你调用 session.setAttribute****时,Tomcat 服务器会在底层悄悄帮你做一件事------它会在返回给浏览器的响应头里,自动塞进一个叫 JSESSIONID****的 Cookie(就像一张隐形的 VIP 会员卡)。
  • 前端动作: 浏览器非常听话,它会自动把这个 Cookie 存起来。下次再发请求时,浏览器会自动带上这个 Cookie。
  • 总结: 因为 Tomcat 和浏览器 在底层达成了自动发卡、自动亮卡的默契 ,所以你写的业务代码根本不需要操心"把凭证传给前端"这件事,直接 **return Result.ok()**即可。

2. 现在的代码(Redis + Token 方案):为什么必须返回 Token?

Java

复制代码
String token = UUID.randomUUID().toString(true);
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
return Result.ok(token); // 把钥匙递给前端
  • 背景变化: 现在你不用 Session 了,你把用户信息存到了 Redis 这个"第三方大仓库"里。并且,你自己用代码生成了一把专属钥匙( token**)。**
  • 底层变化: Redis 可不像 Tomcat 那么"贴心",它 不会 自动帮你往浏览器的 Cookie 里写东西。
  • 必须返回的原因: 既然这把 token****钥匙是你自己生成的,并且没有任何机制会自动把它传给前端,那你就 必须在代码里显式地把它作为数据返回给前端 (也就是 Result.ok(token)****)。

3. 前端拿到这个 Token 之后会做什么?

当你 **return Result.ok(token)**之后,前端(比如 Vue、React 或小程序)接下来的动作是至关重要的闭环:

  1. 保存钥匙: 前端会在代码里,把这个 token****存到浏览器的本地存储中(通常是 localStorage****或 sessionStorage**)。**
  2. 每次请求都带着钥匙: 之后前端每次向后端发送请求(比如查询购物车、修改资料),都会在 HTTP 请求的 请求头(Header) 里面,手动把这个 token****塞进去(通常放在叫 Authorization****的请求头里)。
  3. 后端验明正身: 后端收到请求,从头里面拿出 token**,去 Redis 里一查:** "哦!这把钥匙对应的是张三!" 这样就完成了身份验证。

解决状态登录刷新问题

初始方案思路总结:

在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

优化方案

既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

代码

RefreshTokenInterceptor

复制代码
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.判断用户是否存在
        //这里不用null是因为stringRedisTemplate查询到为空会返回一个空map而不是null
        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();
    }
}

LoginInterceptor

复制代码
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;
    }
}

然后黑马这里还漏一个

MvcConfig

复制代码
package com.hmdp.config;

import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    // 1. 让 Spring 把刷新 Token 的拦截器塞进来
    @Autowired
    private RefreshTokenInterceptor refreshTokenInterceptor;

    // 2. 让 Spring 把校验登录的拦截器塞进来
    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        
        // ==========================================
        // 第一道关卡:全局刷新 Token 拦截器
        // ==========================================
        registry.addInterceptor(refreshTokenInterceptor)
                .addPathPatterns("/**") // 拦截所有请求
                .order(0);              // order(0) 优先级最高,最先执行!

        // ==========================================
        // 第二道关卡:校验登录拦截器
        // ==========================================
        registry.addInterceptor(loginInterceptor)
                .excludePathPatterns(   // 放行不需要登录的接口
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                )
                .order(1);              // order(1) 优先级排在后面,等 Token 刷新完再执行
    }
}
一些值得注意的细节

首先拦截器这里,为什么不用是有原因的

复制代码
 private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;

作者说这里不能用 @Autowired 注入,根本原因只有一条:

因为这个 LoginInterceptor****对象通常是你手动 new****出来的,它并没有交由 Spring 容器(IoC)管理。

为了让你彻底明白,我们来拆解一下背后的原理:

1. Spring @Autowired****的工作前提

Spring 的依赖注入(@Autowired)有一个铁律:Spring 只能给它自己创建和管理的 Bean 注入属性。 如果一个对象是你用 new 关键字手动创建出来的,那么 Spring 就"管不着"这个对象,它里面的 @Autowired 注解就会完全失效,对应的属性就会是 null,一用就会报空指针异常(NullPointerException)。

2. 拦截器(Interceptor)通常是怎么配置的?

想一想你是在哪里把这个 LoginInterceptor 注册到系统里的?你一定有一个类似 MvcConfig 的配置类(实现了 WebMvcConfigurer 接口),里面的代码大概是这样的:

Java

复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 【注意看这里!】这里使用了 new 关键字!
        registry.addInterceptor(new LoginInterceptor())
        .addPathPatterns("/**")
        .excludePathPatterns("/user/login", "/user/code");
    }
}

发现了没?由于拦截器是通过 new LoginInterceptor() 实例化的,它脱离了 Spring 容器的控制 。这就是为什么你在 LoginInterceptor 里面写 @Autowired StringRedisTemplate 会没用的原因。


3. 作者的巧妙解法:通过构造函数"传递"

既然在 LoginInterceptor 里用不了 @Autowired,那我们怎么拿到 StringRedisTemplate 去操作 Redis 呢?

思路转换: 虽然 LoginInterceptor 不是 Spring 管理的,但你的配置类 MvcConfig 是有 @Configuration 注解的,它是被 Spring 管理的!

所以,作者的做法是:

  1. 先在 MvcConfig 里面,利用 Spring 的力量把 StringRedisTemplate 注入进来。
  2. 然后,在 new LoginInterceptor() 的时候,通过构造函数 ,把这个模板对象当做参数传递进去。

整个流程的代码配合起来是这样的:

在 MvcConfig 中(被 Spring 管理,可以注入):

Java

复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    // 1. 在这里注入,因为 MvcConfig 是 Spring 管理的 Bean
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 2. 通过构造函数,把拿到的 stringRedisTemplate 塞给拦截器
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
        .addPathPatterns("/**");
    }
}

在 LoginInterceptor 中(你截图里的代码):

Java

复制代码
public class LoginInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    // 接收从 MvcConfig 传过来的 stringRedisTemplate
    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    // ... 下面就可以愉快地使用 stringRedisTemplate 了
}

总结

不是 @Autowired 坏了,而是对象创建的控制权不在 Spring 手里。通过构造函数传参,完美解决了"非 Spring 容器管理的对象"想要使用"Spring 容器管理的组件"的矛盾。

但是在这里我个人更加倾向于把所有的bean都教给spring容器去管理

但是mvc那里就不能用new RefreshToeknInterceptor() 方式去创建了,要用下面代码这种方式去创建了,因为我们的stirngRedisTemplate 是autowired出来 ,你new方式创建的并不是spring容器里面的那个早就创建好的RefreshToeknInterceptor 对象 ,而是一个全新的对象 这时候里面 stirngRedisTemplate 用new方式创建里面值其实是null的 ,除非new 的时候写一个有参构造,但是这样的话就回到了之前那个了

复制代码
 @Autowired
    private RefreshTokenInterceptor refreshTokenInterceptor;

    // 2. 让 Spring 把校验登录的拦截器塞进来
    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        
        // ==========================================
        // 第一道关卡:全局刷新 Token 拦截器
        // ==========================================
        registry.addInterceptor(refreshTokenInterceptor)
                .addPathPatterns("/**") // 拦截所有请求
                .order(0);              // order(0

这里有一个比苍穹外卖做的好的地方

if (StrUtil.isBlank(token)) {

return true;

}

1. 为什么 == null判断****不够用?

如果你只写 if (token == null),它仅仅 能拦截这一种情况:前端完全没有传 authorization 这个请求头。

但是在实际的 Web 开发中,前端传过来的数据经常会"作妖",比如:

  • 情况 A: 前端传了请求头,但是内容是空的(例如 Authorization: )。这时候 Java 后端拿到的 token 会是一个空字符串 ""。此时 token == null 得到的结果是 false(因为它确实是个字符串对象,只是里面没内容),这就漏判了!
  • 情况 B: 前端或者用户不小心敲了几个空格传过来(例如 Authorization: )。此时 token" "token == null 依然是 false,再次漏判

如果漏判了,拿着空字符串或者一堆空格去 Redis 里查数据,肯定会查不到或者报错。

2. **StrUtil.isBlank()**到底做了什么?

图中的 StrUtil 通常是国内非常火的 Java 工具包 Hutool 里的字符串工具类(或者类似 Apache Commons Lang 的 StringUtils)。

isBlank(token) 这个方法内部帮我们做了三重拦截。它等价于下面这一长串代码:

Java

复制代码
// isBlank 的底层逻辑大致是这样的:
if (token == null || token.length() == 0 || token.trim().length() == 0) {
    return true;
}

它能同时防住三种"无效"的字符串:

  1. null (完全不存在)
  2. "" (空字符串,长度为 0)
  3. " " (全是空白字符,包括空格、制表符 \t、换行符 \n 等)

这里有一个小技巧

图中弹出的窗口是 IDEA 中的 "Recent Files"(最近打开的文件) 列表。

呼出这个窗口的默认快捷键是 Ctrl + E (Mac 系统上是 Cmd + E)。

它和你提到的"Shift 搜索"(即双击 Shift 呼出的 "Search Everywhere" / 随处搜索)虽然都是用来找东西的,但设计目的和使用场景完全不同

以下是它们的核心区别:

1. Ctrl + E**(最近打开的文件) ------ 专注"上下文切换"**

  • 作用范围: 非常小。它只显示你最近刚刚打开或编辑过的文件,以及最近使用过的工具窗口(比如左侧的 Project 目录、Database 等)。
  • 核心优势: 极速的来回切换 。在实际开发中,你通常是在 2~3 个文件之间反复横跳(比如 Controller、Service、Mapper)。使用 Ctrl + E,你可以闭着眼睛按回车,瞬间回到上一个编辑的文件,效率极高。
  • 图中小细节: 你看图片右上角有个 Show edited only Ctrl+E。这意味着如果你在打开这个窗口后再按一次 Ctrl + E,它会过滤掉那些你只是"看过"的文件,只保留你"修改过"的文件,这在找刚才改了哪里时非常管用。

2. 双击 Shift (Search Everywhere) ------ 专注"全局检索"

  • 作用范围: 极大。顾名思义"随处搜索",它会在整个项目和 IDE 系统中进行地毯式搜索。它不仅搜文件名,还搜类名、方法名、变量名(Symbols),甚至搜 IDEA 的设置项和操作命令(Actions)。
  • 核心优势:找未知或遗忘的东西 。当你知道项目里大概有个 User 相关的类,但不知道它在哪,或者你想修改 IDEA 的某个字体设置却找不到菜单,双击 Shift 是万能的入口。

实现商家查询缓存的功能

2.1 什么是缓存?

前言 :什么是缓存?

就像自行车,越野车的避震器

举个例子:越野车,山地自行车,都拥有"避震器",防止 车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样;

同样,实际开发中,系统也需要"避震器",防止过高的数据访问猛冲系统,导致其操作线程无法及时处理信息而瘫痪;

这在实际开发中对企业讲,对产品口碑,用户评价都是致命的;所以企业非常重视缓存技术;

缓存( Cache),就是数据交换的缓冲区 ,俗称的缓存就是缓冲区内的数据 ,一般从数据库中获取,存储于本地代码(例如:

复制代码
例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发

例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存

例3:Static final Map<K,V> map =  new HashMap(); 本地缓存

由于其被Static 修饰,所以随着类的加载而被加载到内存之中 ,作为本地缓存,由于其又被final修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;

2.1.1 为什么要使用缓存

一句话:因为速度快,好用

缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力

实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;

但是缓存也会增加代码复杂度和运营的成本:

2.1.2 如何使用缓存

实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用

浏览器缓存:主要是存在于浏览器端的缓存

**应用层缓存:**可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存

**数据库缓存:**在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中

**CPU缓存:**当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存

2.2 添加商户缓存

在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢咯,所以我们需要增加缓存

复制代码
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
    //这里是直接查询数据库
    return shopService.queryById(id);
}

2.2.1 、缓存模型和思路

标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。

代码

代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。

这个地方黑马写的不行

当我们点击跳转到整个页面时候f12 开发者工具

我们可以看到整个接口是一个路径参数,然后我们去后端项目找到这个请求的接口

应该就是这个

复制代码
   @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        return Result.ok(shopService.getById(id));
    }

我们的目的应该是把直接查询数据库给添加一个缓存,用来提高查询速度

目前应该只有从数据库中查询数据

这里有一个问题,为什么mybatisplus它能够知道返回值就是shop,这是因为我们在

复制代码
public interface IShopService extends IService<Shop> {

}

这个地方已经告诉它了

下面是完整代码

复制代码
@Service

public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {
        String shopReids = stringRedisTemplate.opsForValue().get("cache:shop:" + id);

        if (StrUtil.isNotBlank(shopReids)) {
            Shop shop = JSONUtil.toBean(shopReids, Shop.class);
            return Result.ok(shop);
        }
        Shop shop = getById(id);
        if (shop == null) {
            return Result.fail("未查询到店铺");

        }
        String jsonStr = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set("cache:shop:"+id, jsonStr);
        return Result.ok(shop);
    }
}

我们发现这个getById是mybatisplus的方法,为什么之前我们在contoroler还要调用service层才能用但是在这里我们可以直接用 ,但是在这个service层实现类里面我却并没有定义这个getById方法

核心就在于 extends ServiceImpl****这几个字。

1. 为什么在 Service 里可以直接用?(继承关系)

MyBatis-Plus 的开发者提前写好了一个非常强大的父类,叫作 ServiceImpl。这个父类里面已经写好了几十个常用的数据库操作方法,其中就包括 getById()save()removeById() 等等。

当你的 ShopServiceImpl 使用了 extends**(继承)** 关键字认了这个父类之后,就相当于直接继承了父类的全部"家产"(公开方法)

这里还漏了一个查询店铺的类型代码,黑马要我们自己完成

我们来重写这个

也就是上面的美食,ktv 丽人美发,这一栏的查询

ShopTypeController

我们查看原有的代码

复制代码
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
    @Resource
    private IShopTypeService typeService;

    @GetMapping("list")
    public Result queryTypeList() {
        List<ShopType> typeList = typeService
                .query().orderByAsc("sort").list();
        return Result.ok(typeList);
    }
}

发现它是直接去数据库查询的所以在这里我们需要给它添加缓存机制

缓存更新策略

缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。

**内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)

**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存

**主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

上面说了一大堆,基本上如果我们只需要关注主动更新就行了,内存淘汰我们也维护不了

2.3.1 、数据库缓存不一致解决方案:

由于我们的缓存的数据源来自于数据库 ,而数据库的数据是会发生变化的 ,因此,如果当数据库中数据发生变化,而缓存却没有同步 ,此时就会有一致性问题存在,其后果是:

用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案

Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

这个三个我们主要关注01点就行了

2.3.2 、数据库和缓存不一致采用什么方案

综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题

操作缓存和数据库时有三个问题需要考虑:

如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

  • 删除缓存还是更新缓存?
    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?
    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案

应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。

  • 先操作缓存还是先操作数据库?
    • 先删除缓存,再操作数据库

    • 先操作数据库,再删除缓存

    • 其实这两种方案都会出现线程安全问题,但是先操作数据库,再删除缓存出现线程安全的频率明显更低

所以选择这种 然后我们选择的是删除缓存,而不是更新缓存,因为更新缓存,如果没人读缓存,这个更新就是无效操作,所以我们选择删除缓存

如下图所示,就是我们最终的实现方案

代码实现

核心思路如下:

修改ShopController中的业务逻辑,满足下面的需求:

根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

根据id修改店铺时,先修改数据库,再删除缓存

修改重点代码1 :修改ShopServiceImpl的queryById方法

设置redis缓存时添加过期时间

修改重点代码2

代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,然后把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中加载最新的数据,从而避免数据库和缓存不一致的问题

因为这个项目没有管理端,所以我们修改店铺信息,需要借助postman这种工具来实现更新店铺信息的操作

缓存穿透

缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

常见的解决方案有两种:

  • 缓存空对象(这是一种暴力解决方法)
    • 优点:实现简单,维护方便
    • 缺点:
      • 额外的内存消耗
      • 可能造成短期的不一致
  • 布隆过滤(黑马说布隆过滤实际上是一种算法) 会比前一种内存占用更少,存空对象也得占用内存,但是它这个布隆过滤只是存一些hash值 ,缺点就是不准,会误判
    • 优点:内存占用较少,没有多余key
    • 缺点:
      • 实现复杂
      • 存在误判可能

**缓存空对象思路分析:**当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了

**布隆过滤:**布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,

假设布隆过滤器判断这个数据不存在,则直接返回

这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

编码解决商品查询的缓存穿透问题:

核心思路如下:

在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透问题的

现在的逻辑中:如果这个数据不存在,我们不会返回404 ,还是会把这个数据写入到Redis中,并且将value设置为空,欧当再次发起查询时,我们如果发现命中之后,判断这个value是否是null,如果是null,则是之前写入的数据,证明是缓存穿透数据,如果不是,则直接返回数据。

代码
复制代码
@Service

public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {
        String shopRedis = stringRedisTemplate.opsForValue().get("cache:shop:" + id);

        if (StrUtil.isNotBlank(shopRedis)) {
            Shop shop = JSONUtil.toBean(shopRedis, Shop.class);
            return Result.ok(shop);
        }
        if(shopRedis!=null)
        {
            return Result.fail("未查询到店铺");
        }
        Shop shop = getById(id);
        if (shop == null) {
            stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("未查询到店铺");


        }
        String jsonStr = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set("cache:shop:"+id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }
    @Transactional
    @Override
    public Result update(Shop shop) {
        if(shop.getId()==null){
            return Result.fail("店铺id不存在");
        };
        updateById(shop);
        stringRedisTemplate.delete("cache:shop:"+shop.getId());
        return Result.ok();

    }
}

小总结:

缓存穿透产生的原因是什么?

  • 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力

缓存穿透的解决方案有哪些?

  • 缓存null值
  • 布隆过滤
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

这里有一个小技巧

我们鼠标悬浮在方法上面或者按住ctrl+q就可以查看这个方法一些重要详细信息

但是一开始会没有,是因为我们没有下载源码,解决方法如下

  • 按住键盘的 Ctrl 键,然后鼠标左键点击 isNotBlank 这个方法名。
  • 这时你会进入这个方法的底层代码页面(你会发现里面的代码全是只读的,而且没有注释)。
  • 注意看代码编辑器的顶部横幅 或者右上角 ,通常会有一个黄色的提示条,上面写着 "Download Sources" (下载源码)或 "Choose Sources"
  • 点击 Download Sources ,等待底下进度条跑完。再回到你自己的代码里按 Ctrl + Q,详细文档就出来了!

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 给不同的Key的TTL添加随机值,这样就不会一下子同时过期
  • 利用Redis集群提高服务的可用性(用很有b格话来说就是提高redis的高可用性
  • 给缓存业务添加降级限流策略( 这个它说黑马微服务的课里面会讲
  • 给业务添加多级缓存比如nigx也可以做缓存用来弥补 )(这个它说黑马微服务的课里面也会讲

这一部分没有代码 因为后面基本要有微服务框架,咋们这个课没有这个框架,加随机数没啥学习的价值

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

解决方案一、使用锁来解决:

因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。

假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

解决方案二、逻辑过期方案

方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。

我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

进行对比

**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响

逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

利用互斥锁解决缓存击穿问题

这里不能用javase里面学的锁,要用我们自定义的互斥锁

为什么不能用 Java 自带的 synchronized**?**

在真实的生产环境中,为了抗住高并发,"订单服务"或者"商品服务"绝对不可能只部署在一台服务器上,而是会同时部署在多台机器上(比如部署了 3 台机器,即 3 个 JVM 进程)。

如果你在这个查询方法上加了 synchronized

  1. 锁的范围太小(只锁自己): synchronized 的作用域仅仅是当前的 JVM 进程
  2. 场景模拟: 当缓存突然失效,3000 个请求同时打过来,负载均衡把请求平均分发给了这 3 台机器,每台机器 1000 个请求。
  3. 失效结果:
    • 机器 A 的 JVM 里,synchronized 拦住了 999 个线程,只放行了第 1 个线程去查数据库。
    • 机器 B 的 JVM 里,也放行了第 1 个线程去查数据库。
    • 机器 C 的 JVM 里,同样放行了第 1 个线程去查数据库。
  1. 结论: 最终还是有 3 个并发请求同时打到了数据库上去重建相同的缓存。虽然情况比 3000 个好多了,但如果你们部署了 50 台甚至 100 台机器,那瞬间打到数据库的请求依然可能是灾难性的。

为什么要"自定义锁(分布式锁)"?

因为我们要找一个能管得住所有 JVM 进程的"大管家"。

大家都不在同一个 JVM 里,互相看不见对方的锁。所以必须把这把锁放到一个大家都能访问到的公共区域

这个公共区域最常见的选择就是 Redis

分布式互斥锁的工作原理(对应你截图的流程图)

在 Redis 里,我们通常利用 SETNX (Set if Not eXists) 命令来实现自定义互斥锁。

  • 尝试获取互斥锁(SETNX): 所有机器上的所有线程,在发现缓存失效后,都立刻去 Redis 里尝试设置一个特殊的 Key(比如叫 lock:shop:101),值为随意(比如 "1")。 因为 Redis 的单线程特性和 SETNX 命令的原子性,这 3000 个线程里,绝对只有 1 个线程能设置成功!
  • 判断是否获取锁:
    • 是(设置成功的那个天选之子): 意味着它抢到了这把"全网唯一的分布式锁"。它就可以放心地去查询数据库,重建缓存,最后把 Redis 里那个锁 Key 删掉(释放锁)
    • 否(剩下的 2999 个兄弟): 意味着锁已经被别人抢走了。现在别人正在辛勤地查数据库建缓存呢。既然别人在努力,那自己就没必要再去给数据库添乱了。
  • 休眠一段时间后重试(关键步骤): 这 2999 个没抢到锁的线程怎么办?直接报错吗?当然不行,用户还等着看商品详情呢。 看你的截图,没抢到锁的流程是:休眠一段时间(比如 50 毫秒) 。 醒来之后干嘛?重新去走第一步:从 Redis 查询商铺缓存! 因为那个抢到锁的兄弟大概率已经查完数据库并把缓存写好了,所以这 2999 个休眠醒来的线程,第二次去查缓存时就会"命中",直接满载而归,完美避开了对数据库的冲击。

总结一下:Java 原生锁只能防住同一个屋檐下(同一个 JVM)的人,而分布式锁(借助 Redis)能防住全天下所有服务器上的人同时去访问同一个数据库。

也就是如果部署到了100台服务器,synchronized只能锁住自己这台服务器,别的服务器锁不住,所以到时候数据库仍然会收到100次请求,而分布式锁就不会这样,直接一台服务器就可以直接锁死,别的都动不了

核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询

如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿

操作锁的代码:

关键就在于这个setnx 这个指令,如果有数百上千个人执行setnx这个操作,只会有一个人成功,也就是第一个人成功 释放锁其实就是用del 指令把这个锁删掉就会成功

核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。

复制代码
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

这里面这个value值随便几都可以

这里我又调用了hutool工具类里面拆箱工具类,为什么要这么多此一举呢?

1. 什么是"装箱"和"拆箱"?

在 Java 中,数据类型分为两大家族:

  • 基本数据类型(Primitive): 比如 boolean, int, double。它们非常单纯,只能存具体的值(比如 true/false,1,2.5),绝对不能存 null
  • 包装类(Wrapper Class): 比如 Boolean, Integer, Double。它们是高级的"对象",除了能存具体的值,还可以存 null**(表示空、没有)**。

装箱(Boxing): 把基本类型包装成对象。比如把 boolean 的 true 变成 Boolean 对象。 拆箱(Unboxing): 把对象里的值拆出来,变成基本类型。比如把 Boolean 对象拆成 boolean

2. 为什么不能直接 return flag;****?

仔细看你的代码:

  1. setIfAbsent 方法的返回值类型是包装类 Boolean(注意大写),这意味着它返回的结果可能是 true,可能是 false也有可能是 null(比如 Redis 连接突然断了,或者发生了一些底层异常)。
  2. 而你整个 tryLock 方法定义的返回值类型是基本类型 boolean(注意小写)。

如果你直接写 return flag;,Java 编译器会非常"贴心"地帮你做一个隐式的动作:自动拆箱 。它在底层其实执行了 flag.booleanValue()

致命危险来了: 如果 Redis 正常,flag 是 true 或 false,自动拆箱没问题。 但如果某天系统抽风,setIfAbsent 返回了一个 nullflag。此时 Java 依然会傻乎乎地去执行"自动拆箱",试图调用 null.booleanValue()。 结果就是:系统当场抛出 NullPointerException**(空指针异常)**,你的程序直接崩溃!

3. **BooleanUtil.isTrue(flag)**做了什么?

为了防止这种悲剧,我们不能信任 Java 的自动拆箱,而要使用 Hutool 工具包里的 BooleanUtil.isTrue()

如果你去看这个工具方法的底层源码,它大概长这样:

Java

复制代码
public static boolean isTrue(Boolean bool) {
if (bool == null) {
    return false; // 如果是 null,安全地返回 false
}
return bool; // 只有在绝对不为空的情况下,才进行拆箱
}

总结: 不能直接返回,是因为直接返回会触发"自动拆箱"。在拆箱过程中,如果对象是 null,程序就会报空指针异常崩溃。使用 BooleanUtil 工具类,是为了优雅且安全地处理 null 值,保证系统的健壮性。

操作代码:

复制代码
public Shop queryWithMutex(Long id)  {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("key");
// 2、判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
    // 存在,直接返回
    return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的值是否是空值
if (shopJson != null) {
    //返回一个错误信息
    return null;
}
// 4.实现缓存重构
//4.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
    boolean isLock = tryLock(lockKey);
    // 4.2 判断否获取成功
    if(!isLock){
        //4.3 失败,则休眠重试
        Thread.sleep(50);
        //这里用了递归
        return queryWithMutex(id);
    }
    //4.4 成功,根据id查询数据库
    shop = getById(id);
    // 5.不存在,返回错误
    if(shop == null){
        //将空值写入redis
        stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
        //返回错误信息
        return null;
    }
    //6.写入redis
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);

}catch (Exception e){
    throw new RuntimeException(e);
}
finally {
    //7.释放互斥锁
    unlock(lockKey);
}
return shop;
}

在这里有几个问题值得提问考究一下

我们看到这个地方有个休眠,于是我想问?

如果这里不休眠会发生什么

如果你把这一行删掉(或者注释掉),让它获取锁失败后立刻执行 return queryWithMutex(id);,你的程序在一瞬间就会面临三个灾难性的后果:

致命报错:栈溢出 ( java.lang.StackOverflowError**)**

这是最直接、最快发生的崩溃。

  • 原因: 你这里用的是 递归 。如果获取不到锁,当前方法不结束,而是立刻在内部再调用一次自己。
  • 没有休眠的情况: 计算机的执行速度极快。如果另一个线程需要 100 毫秒才能查完数据库并释放锁,在没有 sleep****的情况下,你失败的这台机器在这 100 毫秒内,可能会瞬间递归调用 queryWithMutex****几千甚至上万次!
  • 结果: Java 虚拟机的线程栈深度是有限的。短时间内几千次的方法嵌套调用,会瞬间撑爆栈内存,直接抛出 StackOverflowError**,导致你的程序报错终止。**

这里有一个try catch 快捷键 是ctrl +alt+t,为什么要用try catch 是想在java最后执行时候也要即使发生了异常,也要执行释放锁的代码,不然会导致别的进程永远进不来

然后这个代码实际上是有瑕疵的

漏掉了一个双重检查

1. 什么是双重检查(Double-Check)?

一句话解释:在多线程排队抢锁的场景下,拿到锁的人,在干活(查数据库)之前,必须再看一眼之前的结果(查缓存)是不是已经被上一个拿到锁的人做好了。

场景还原(为什么需要它): 假设你的某件爆款商品缓存刚好过期了,就在这零点零几秒内,有 1000 个用户的请求 同时到达了这行代码。

  1. 第一道门(第一次查缓存): 这 1000 个请求同时去查 Redis,发现都没数据。
  2. 抢锁: 这 1000 个请求同时去执行 trylock()****。
  3. 一人得道,999人排队: 线程 A 运气好,抢到了锁。剩下的 999 个线程没抢到,去执行了 **Thread.sleep(50)**然后重试(排队等待)。
  4. A 去干活: 线程 A 慢悠悠地查了数据库,把数据写回了 Redis,然后释放了锁。
  5. 灾难发生: 线程 A 释放锁之后,前面排队的 999 个线程里,线程 B 睡醒了,它拿到了锁。
    • 如果没有双重检查: 线程 B 拿到锁后,会 直接执行 **getById(id)**去查数据库 !紧接着线程 C、D、E 拿到锁后都会去查一次数据库。这 999 个请求依然会全部打到数据库上,把数据库打死。你的这把锁就白加了。
    • 如果有双重检查: 线程 B 拿到锁后, 先回头看一眼 Redis ,发现"哎?线程 A 已经把数据放进去了!"。于是线程 B 就不去查数据库了,直接拿 Redis 的数据返回。剩下的 998 个线程拿到锁后也是同理。

我们手动补上双重检查后

双重检查完整代码
复制代码
    public Shop QueryWithMutex(Long id) {
        // ================== 第 1 次检查 ==================
        String shopRedis = stringRedisTemplate.opsForValue().get("cache:shop:" + id);

        if (StrUtil.isNotBlank(shopRedis)) {
            Shop shop = JSONUtil.toBean(shopRedis, Shop.class);
            return shop;
        }
        if (shopRedis != null) {
            return null;
        }
        String key = LOCK_SHOP_KEY + id;

        Shop shop = null;
        try {
            if (!trylock(key))
            {
                Thread.sleep(150);
                return QueryWithMutex(id);

            }

// ================== 第 2 次检查 ==================
            shopRedis = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
            if (StrUtil.isNotBlank(shopRedis)) {
                shop = JSONUtil.toBean(shopRedis, Shop.class);
                return shop;
            }
            if (shopRedis != null) {
                return null;
            }
            shop = getById(id);

            if (shop == null) {
                stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;


            }
            String jsonStr = JSONUtil.toJsonStr(shop);
            stringRedisTemplate.opsForValue().set("cache:shop:" + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            delock(key);
        }
        return shop;

    }

但是你会发现加这个双重检查之后,其实我们正常情况下也是会多查一次redis ,但是redis是存储在内存里面的,所以一点都不亏

接下来做一个高并发测试 图片里面用到的是jmeter

1. 线程数:1000

在 JMeter 里,1 个线程就代表 1 个真实的用户 。 设置 1000 个线程,就意味着你要模拟 1000 个用户同时去访问你的 QueryWithMutex 接口(比如同时点进某家热门商铺的详情页)。

2. Ramp-Up 时间(秒):5

很多新手会误以为这是"测试总共运行 5 秒钟",其实完全不是。 Ramp-Up 的意思是"启动这 1000 个用户需要花费多长时间"。

  • 如果设为 0:代表 1000 个用户在第 1 毫秒内瞬间"砸"向服务器(极端的绝对并发)。
  • 你设置了 5 秒:代表 JMeter 会在这 5 秒钟内,平滑、均匀地把这 1000 个用户启动完毕。
  • 意思就是Ramp-Up 时间(5秒)绝对不等于总测试时间,总时间一定是大于 5 秒的。

我们来拉一条"时间线",你就彻底明白这多出来的时间去哪了:

根据上面截图中设置的参数(1000个线程,Ramp-Up 5秒,循环次数为 1):

  • 第 0.00 秒: 第 1 个用户启动,向服务器发送请求。
  • 第 1.00 秒: 已经有 200 个用户启动完毕并发送了请求。
  • ...
  • 第 5.00 秒: 第 1000 个用户(也就是最后一个用户)刚刚被启动,准备向服务器发送请求。

3. "Q什么到达 200":指的是 QPS(每秒并发请求数)

由于你设置了 1000 个用户在 5 秒内启动,这里存在一个非常简单的除法: 1000 (用户) ÷ 5 (秒) = 200 (个/秒)

这意味着:

  • 第 1 秒:有 200 个用户发起了请求。
  • 第 2 秒:又有 200 个用户发起请求。
  • 以此类推,直到第 5 秒,1000 个请求全部发送完毕。

我们不想再下载一个jmeter来做一个高并发测试了,这里我们采用apifox来做这个高并发测试

你当前的测试配置,正在以大约 200 QPS 的压力(每秒 200 个并发请求),对你的代码进行轰炸。

后端测试工具

我们发现apifox的介绍里面 Apifox = Postman + Swagger + Mock + JMeter

至此为止后端用到测试工具我们全部都接触到了

这里100个用户和jmeter 不一样 jmeter是只发一次 而这个100个用户是持续发送qps其实也挺恐怖的

从测试结果我们可以看到平均qps是39.75

然后我们发现查询语句只执行了一次

利用逻辑过期解决缓存击穿问题

实际热点key数据库中有,的redis中都有

实际并没有ttl过期时间,只是我们人为的设计的一格expire字段用来记录它的过期时间

需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题

思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

如果封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你

步骤一、

新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。

复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

步骤二、

ShopServiceImpl 新增此方法,利用单元测试进行缓存预热

在测试类中

但是这个逻辑过期我们心里面必须得有一个谱,没有缓存预热的数据,正常查都会查不到,正是因为如此

我们才不需要管缓存击穿空数据的问题

步骤三:正式代码

ShopServiceImpl

复制代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
    // 3.存在,直接返回
    return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
    // 5.1.未过期,直接返回店铺信息
    return shop;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
    CACHE_REBUILD_EXECUTOR.submit( ()->{

        try{
            //重建缓存 这里不用this也可以
            this.saveShop2Redis(id,20L);
        }catch (Exception e){
            throw new RuntimeException(e);
        }finally {
            unlock(lockKey);
        }
    });
}
// 6.4.返回过期的商铺信息
return shop;
}

我们发现逻辑过期是不是抢锁那里不用递归了,因为并不是每次请求都得枪锁执行

Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);

仔细分析一下这里

为什么我们这里要把它强转成一个(JSONObject)类型呢,这是因为

在源码里面

hutool 它的方法 第一个参数只支持下面这两种写法

在 Hutool(或者 Fastjson、Jackson)的源码里,JSONUtil.toBean 这个方法是有明确的参数类型限制的。它的源码里大概只定义了这么几个"入口":

复制代码
// 入口 1:专门接收普通字符串
public static <T> T toBean(String jsonString, Class<T> beanClass) { ... }

// 入口 2:专门接收 JSON 对象(JSONObject 实现了 JSON 接口)
public static <T> T toBean(JSON json, Class<T> beanClass) { ... }

而JSONObject恰好可以实现了JSON接口,参数对应才可以传进去,直接传object会报错

因为redis的更新我需要另外一个独立线程来执行

这里我们是采用线程池来创建一个独立的新线程,比我们自己创建一个线程性能更好,要不断创建销毁

易错点

我自己在写这个代码的时候,遇到了一个非常易错的点

致命错误 锁释放的位置(最容易踩的坑)

Java

复制代码
// 你的代码
try {
    CACHE_REBUILD_EXECUTOR.submit(() -> { ... });
} finally {
    delock(key); // 主线程瞬间释放了锁!
}

错误原因是我是在主线程里面释放锁的,实际上应该在我们开启的那个线程释放锁 为什么因为 我们的任务是教给另一个线程做的必须得等它把任务做完才可以释放锁

  • 产生后果: 主线程把任务"扔"给线程池后,是不会等待 线程池执行完的。主线程瞬间走到 finally,把锁释放了!

总结

其实这三种总结来说,我们害怕的就是对数据库造成大量请求

第一种穿透就是因为请求那种数据库根本不存在的数据,如果有别有用心的人,一直那这种请求,我们数据库就会吃不消 这种就是穿透

第二种就是 首先请求是对很多不同数据的请求 但是这些请求的缓存在同一时间全部失效了,所以数据库会受到大量请求,所以这个就叫雪崩

第三种就是 请求只对一个数据请求,但是这个请求很多 ,与上面不同的是上面那个是对一种数据请求并不多,然后恰好这个时候,这个缓存失效了,然后数据库就被大量访问了 这就是击穿

然后记忆口诀是:只要记忆一个穿透就行了

缓存穿透 (Penetration) ------ 重点在"透"

  • 字面解析: "透"有"穿透过去、一透到底、透明"的意思。说明这个请求不仅穿过了缓存,还穿过了数据库,最后什么也没捞着。

封装Redis工具类

前面我已经解决了缓存使用过程中的各种各样的问题,但是每次开发的过程中都去写这些逻辑实际上都挺复杂的,如果每次开发的时候都去写这些逻辑,其实开发的成本还是挺高的。于是我需要将这些开发的解决方案封装成工具

实际上方法一和方法三是配对的解决缓存穿透问题,方法二方法四是配对的,用解决热点击穿的问题

复制代码
@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    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)));
        // 写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    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 json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if (json != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        return r;
    }

    public <R, ID> R queryWithLogicalExpire(
        String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return r;
        }
        // 5.2.已过期,需要缓存重建
        // 6.缓存重建
        // 6.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2.判断是否获取锁成功
        if (isLock){
            // 6.3.成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R newR = dbFallback.apply(id);
                    // 重建缓存
                    this.setWithLogicalExpire(key, newR, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4.返回过期的商铺信息
        return r;
    }

    public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, type);
        }
        // 判断命中的是否是空值
        if (shopJson != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4.获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6.存在,写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放锁
            unlock(lockKey);
        }
        // 8.返回
        return r;
    }

    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}

我们在传参数的时候经常会看到这个TimeUnit ,实际上它就是时间的单位

然后这里用到了函数式编程,这个高级用法是我之前从来没见过的

Function<ID, R>

其实这个之前看源码也见到过,

  • 第一个字母( ID**)** :代表入参(Input)的类型。也就是参数的类型。
  • 第二个字母( R**)** :代表返回值(Result/Return)的类型。

Lambda 表达式是怎么和 **<ID, R>**对应的?

复制代码
// 在 UserServiceImpl.java 中调用同一个工具类
public User queryUserById(Long id) {
    return cacheClient.queryWithPassThrough(
            "cache:user:", 
            id, 
            User.class, 
            (id2) -> shopMapper.selectById(id2)
    );
}

我们把你刚才看到的 Lambda 拿过来,一句句跟泛型对齐:

它这里用lambda表达式代替了之前匿名内部类的写法,之前我一直以为lambda表达式就是匿名内部类,实际上不是,只是代替了匿名内部类

但是本质上都是创了一个实现function接口的对象

这就是函数式编程

复制代码
// 你的 Lambda 写法:
(id2) -> shopMapper.selectById(id2)

这里用了lambda表达式 ()括号里面是方法的参数 ,右边是返回值,因为lambda可以省略return,所以这里就没有return

在这里我们好好感受了函数式编程,和泛型收获颇丰

在ShopServiceImpl 中

复制代码
@Resource
private CacheClient cacheClient;

@Override
public Result queryById(Long id) {
// 解决缓存穿透
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

// 互斥锁解决缓存击穿
// Shop shop = cacheClient
//         .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

// 逻辑过期解决缓存击穿
// Shop shop = cacheClient
//         .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

if (shop == null) {
    return Result.fail("店铺不存在!");
}
// 7.返回
return Result.ok(shop);
}

我们可以看到用了一个这个方法

this::getById ,这个就是方法引用

使用方法引用( ::****)有一个极其苛刻的前提: Lambda 表达式的输入参数,必须和你要调用的方法的参数,在个数、类型和顺序上完全一一对应。

它是怎么从 Lambda 演变过来的?

咱们把这三种形态放在一起对比,你就能秒懂它的进化过程了:

第一代(匿名内部类) :啰嗦的废话。

  1. Java

    new Function<Long, Shop>() {
    @Override
    public Shop apply(Long id2) {
    return this.getById(id2);
    }
    }

第二代(Lambda 表达式) :去掉外壳,保留灵魂。

  1. Java

    (id2) -> this.getById(id2)

第三代(方法引用) 连参数都懒得写了,直接指路!

  1. Java

    this::getById

二、 为什么可以把参数 **(id2)**省略掉?

这是 Java 编译器极其聪明的体现。

在第二代 Lambda 表达式 (id2) -> this.getById(id2)中,你有没有发现一个现象: 这个 Lambda 表达式本身什么逻辑都没做,它纯粹就是一个"二传手"(传话筒)。 它左手接过来一个 id2,右手就 原封不动 地把这个 id2****塞给了 getById****方法。

Redis秒杀(优惠卷秒杀)

它们说聊redis一定离不开秒杀,因为秒杀又正好是这种高并发 热点key的场景,所以很适合用redis来解决

3.1 -全局唯一ID

每个店铺都可以发布优惠券

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显
  • 受单表数据量的限制

场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。

场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

成部分:符号位:1bit,永远为0

时间戳:31bit,以秒为单位,可以使用69年

序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

作为id 我们其实接触到了两种

我们在这里选择的是redis自增的方案

下面是一个工具类,然后在这个工具类里面用把这个自增id给存到redis里面

而且redis自增它是有限制好像底层是c语言,用到的类型是long long 类型

表面上看:你的 Java 程序用 String****类型存进去了 "1"****,Redis 里存的也是字符串 "1"****。 底层真相:当你喊出 increment****的那一刻,Redis 内部以极快的速度完成了 字符串 -> 整数 -> 加1 -> 字符串****的转换。

而这个整数就有限制了是64 位 大小

3.2 -Redis实现全局唯一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;
    }
}

.toEpochSecond(ZoneOffset.UTC)****:把上面那个时间,转换成"秒级时间戳"(也就是距离 1970 年元旦过去了多少秒)。 这个ZoneOffset.UTC就是传给它一个时区 其实就是英国时区,咋们是东八区,英国是0时区

但是gemini指出这么写不规范

它建议不用用localdate time 用instant 获得绝对时间戳

复制代码
`LocalDateTime.now()` 获取的是**你服务器当前所在时区的本地时间**(比如咱们在东八区,获取的就是下午 16 点)。它本身是没有"时区概念"的,它就是一个单纯的表盘读数。
但是,下一句你强行用了 `ZoneOffset.UTC`(伦敦零时区)去把它转换成秒数。

这就相当于:你在东八区看了眼手表,是下午 16 点。然后你强行告诉计算机:"把这个下午 16 点当做伦敦时间,给我算一下从 1970 年过去了多少秒。"
结果就是,**你算出来的时间戳,比全世界标准的真实时间戳硬生生快了 8 个小时!** 如果你们公司以后加了海外服务器(比如在美国),两边机器算出来的时间戳就不一样了,ID 排序会彻底错乱。

**✅ 解决方案:**
要么乖乖用系统默认时区转换,要么直接用绝对时间戳 API。
```java
// 推荐写法:直接获取绝对时间戳,管你在哪个国家,秒数绝对一致
long endTimeStamp = Instant.now().getEpochSecond();

它看的不是本地时间,而是:

从 UTC 1970-01-01 00:00:00 到现在这个瞬间,过去了多少秒

这个"现在这个瞬间",全世界是同一个。


举个例子。

假设此刻 UTC 时间是:

2026-05-21 12:00:00 UTC

那么同一瞬间,各地本地时间可能显示为:

中国北京:2026-05-21 20:00:00 UTC+8

日本东京:2026-05-21 21:00:00 UTC+9

英国伦敦:2026-05-21 12:00:00 UTC+0

美国纽约:2026-05-21 08:00:00 UTC-4

看起来时间不同,但它们都表示同一个瞬间:

UTC 2026-05-21 12:00:00

所以时间戳一样。

测试类

知识小贴士:关于countdownlatch

countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题

我们如果没有CountDownLatch ,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch

CountDownLatch 中有两个最重要的方法

1、countDown

2、await

await 方法 是阻塞方法,我们担心分线程没有执行完时,main线程就先执行,所以使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch 内部维护的 变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。

复制代码
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);

Runnable task = () -> {
    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++) {
    es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}

我们来讨论一下这个到底是怎么用的CountDownLatch latch = new CountDownLatch(300);

相当于有有一个计数器,一共值是300,而这个latch.countDown();是每次执行一次这个计数器就减一

latch.await();语句就相当于把主线程锁住了,不能继续往下执行了,只有当计数器减为0时候,才能继续

执行long end = System.currentTimeMillis();这里的代码

3.3 添加优惠卷

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

tb_voucher:优惠券的基本信息,优惠金额、使用规则等tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种劵,就得限制数量,从表结构上也能看出,特价卷除了具有优惠劵的基本信息以外,还具有库存,抢购时间,结束时间等等字段

**新增普通卷代码:**VoucherController

复制代码
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
}

新增秒杀卷代码:

VoucherController

复制代码
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
    voucherService.addSeckillVoucher(voucher);
    return Result.ok(voucher.getId());
}

VoucherServiceImpl

复制代码
@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());
}

3.4 实现秒杀下单

下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的controller即可

秒杀下单应该思考的内容:

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

下单核心逻辑分析:

当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件

比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

VoucherOrderServiceImpl

复制代码
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    // 尚未开始
    return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    // 尚未开始
    return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
    // 库存不足
    return Result.fail("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
    //扣减库存
    return Result.fail("库存不足!");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);

return Result.ok(orderId);

}

3.5 库存超卖问题分析

有关超卖问题分析:在我们原有代码中是这么写的

复制代码
if (voucher.getStock() < 1) {
    // 库存不足
    return Result.fail("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
    //扣减库存
    return Result.fail("库存不足!");
}

假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:见下图:

悲观锁:

悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

乐观锁:

乐观锁虽然有个锁字实际上是不加锁的

乐观锁是怎么判断"别人有没有动过数据"的?

图片下半部分的文字,重点讲了乐观锁的两种实现方式,这是核心难点:

方式一:版本号机制 (Version)

给数据库表加一个字段叫 version**(版本号)。**

  1. 用户 A 和 B 同时查询: 发现库存 stock = 1**,版本号** version = 1**。**
  2. 用户 A 手速快先买了: 他提交时的条件是"我要把库存变成 0, 前提是版本号必须还是 1 "。因为没人动过,条件成立。更新成功,并且同时把版本号加一,变成了 version = 2**。**
  3. 用户 B 手速慢提交了: 他的条件也是"把库存变 0, 前提是版本号还是 1 "。但是此时数据库里的版本号已经是 2 了!条件不成立,更新失败(拦截了超卖!)。

方式二:CAS 机制 (Compare And Swap)

CAS 翻译过来就是"比较并交换" 。它的逻辑和版本号一模一样,只不过它不依赖版本号字段,而是直接 比较数据本身。 图片里的这段文字: var5 是操作前读取的内存值... 如果预估值 == 内存值,代表中间没有被人修改过**。(**

  • 内存值 (Actual Value): 指此时此刻, 数据库(或真实内存)里实际存在的那个数字 。它是绝对客观、绝对真实的。
  • 预估值 (Expected Value / 旧值): 你脑子里记住的那个数字 (也就是你刚开始查询时,读到的数字)。

  • 大白话翻译: 我买之前看到库存是 1(预估值)。我付款后去仓库扣减时,我探头看一眼仓库现在的真实库存(内存值)。如果它 还是 1 ,说明没人动过,我赶紧把它换成 0(Swap);如果它变成别的数字了(比如变成了 0),说明有人捷足先登了,我就不换了,宣告失败。

什么是自旋 (Spin / do-while)?

假设你用 CAS 失败了,系统可以直接报错"抢购失败"。但有时候为了让用户体验好点,代码里会写一个 while****循环:失败了别放弃,马上 重新读取 最新库存,再试一次 CAS,直到成功或者库存真没了为止。这个一直循环重试的动作,行话就叫"自旋"。

实际上cas 就是version法的简化,cas法它查的是库存,但是库存的话实际上本身数据库里有的,这就引出了cas 和version 最本质区别,version是自己添加的一个字段,cas 拿数据库现有的数据

而且拿stock作为标识有个version法没有的好处是

秒杀减库存的终极目标是什么?是 防超卖(不变成负数) 。 哪怕中间经历了 5 -> 4 -> 5,只要在我准备减库存的那一瞬间,库存是 大于 0 的,我就能卖

CAS会有ABA问题,但是绝对不会有超售问题。(因为如果有人退货,这个本来就应该卖出去)

ABA问题

数据从状态 A,变成了状态 B,然后又变回了状态 A。

  1. 初始状态: 卡里 100 块。
  2. 发生并发: 你连按两次,产生 线程1 和 线程2。
  3. 线程2 执行完: 扣了 50,吐给你 50 块现金。卡里剩 50。
  4. 朋友打钱(ABA的 B->A): 朋友给你转了 50,卡里变回 100。
  5. 线程1 苏醒(被 ABA 蒙蔽): 查到卡里是 100,CAS(100, 50) 成功。卡里变成 50。此时,代码继续往下走,ATM 咔咔咔又吐给你 50 块现金!

最终结果: 你手里拿了 100 块现金,卡里剩 50。你的总资产还是 150 块。 从数学上看,你一分钱都没少!

那 ABA 的灾难到底在哪? 灾难在于:发生了"幽灵交易"(违背了用户意愿)。 你原本的意愿是:只取 50 块钱去买烟,剩下的钱留在卡里准备明天系统自动扣水电费。 结果因为系统被 ABA 欺骗,导致系统自作主张把朋友刚打给你的钱也给你取出来了!明天扣水电费时,发现卡里钱不够了,导致你违约停水停电。

这就是数字场景下 ABA 的本质:数据虽然对得上,但发生的业务动作是错误的。

3.6 乐观锁解决超卖问题

修改代码方案一、

VoucherOrderServiceImpl 在扣减库存时,改为:

复制代码
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?

以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败

修改代码方案二、

之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁需要变一下,改成stock大于0 即可

复制代码
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0

知识小扩展:

针对cas中的自旋压力过大,我们可以使用Longaddr这个类去解决

Java8 提供的一个对AtomicLong改进后的一个类,LongAdder

大量线程并发更新一个原子性的时候,天然的问题就是自旋,会导致并发性问题,当然这也比我们直接使用syn来的好

所以利用这么一个类,LongAdder来进行优化

如果获取某个值,则会对cell和base的值进行递增,最后返回一个完整的值

优惠券秒杀-一人一单

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单

现在的问题在于:

优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单

具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单

VoucherOrderServiceImpl

初步代码:增加一人一单逻辑

复制代码
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    // 尚未开始
    return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    // 尚未开始
    return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
    // 库存不足
    return Result.fail("库存不足!");
}
// 5.一人一单逻辑
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
    // 用户已经购买过了
    return Result.fail("用户已经购买过一次!");
}

//6,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
    //扣减库存
    return Result.fail("库存不足!");
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);

voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);

return Result.ok(orderId);

}

**存在问题:**现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作

这里没讲清除, 不同用户之间确实不会因为"一人一单"发生冲突。 这里的并发问题,防的根本不是"别人抢了我的订单",而是防的"同一个用户(你自己),在同一瞬间发起了多次请求".)

**注意:**在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁

这里别看底下代码一堆实际上

它这个锁只要在方法上加个关键词synchronized就行了

复制代码
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {

    Long userId = UserHolder.getUser().getId();
    // 5.1.查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

    // 6.扣减库存
    boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1") // set stock = stock - 1
    .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
    .update();
    if (!success) {
        // 扣减失败
        return Result.fail("库存不足!");
    }

    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 7.2.用户id
    voucherOrder.setUserId(userId);
    // 7.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    // 7.返回订单id
    return Result.ok(orderId);
}

,但是这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,因为我们锁的是同一个用户的多次请求,而不想锁不同用户之间的请求

所以我们需要去控制锁的粒度,以下这段代码需要修改为:intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法

这里再解释一下

**toString()**会创建"新对象"

你代码里的 userId 是一个 Long 类型的包装类对象。当你调用 userId.toString() 时,Java 底层是怎么做的呢? 它会执行类似这样的逻辑:直接在堆内存中 new 一个新的 String 对象返回。

你可以自己写段测试代码试一下:

Java

复制代码
Long userId1 = 1024L;
Long userId2 = 1024L; // 假设这是两个不同请求传进来的相同用户 ID

String s1 = userId1.toString();
String s2 = userId2.toString();

System.out.println(s1.equals(s2)); // true  (值是一样的,都是 "1024")
System.out.println(s1 == s2);      // false (内存地址不一样,是两个独立的对象!)

3. 场景还原(为什么锁不住?)

假设用户 A(ID为 1024)手速很快,同时发起了两次抢单请求(线程 1 和线程 2):

  1. 线程 1 进来,执行 userId.toString(),在堆内存中创建了一个字符串对象 @0x001 (值为 "1024"),然后对 @0x001 加锁,进入了同步块。
  2. 线程 2 同时进来,执行 userId.toString(),在堆内存中又创建了一个全新的字符串对象 @0x002 (值也是 "1024"),然后对 @0x002 加锁。
  3. 因为 @0x001@0x002 是两个完全不同的锁对象,线程 2 不会被阻塞,它也直接进入了同步块。
  4. 结果:两个线程同时去查询订单、判断是否存在、扣减库存,导致"一人一单"防线被击穿,出现了超卖或重复下单。

💡 正确的写法:使用 .intern()

为了让相同值的字符串始终返回同一个内存地址的对象,你需要使用 String 类的 intern() 方法。

修改为:

Java

复制代码
synchronized (userId.toString().intern()) {
    // 业务逻辑...
}

**intern()**的作用: 当调用 intern() 时,JVM 会去"字符串常量池"里找,有没有内容为 "1024" 的字符串。

  • 如果没有,就把它放进常量池,并返回常量池里的引用。
  • 如果已经有了,就直接返回常量池里那个现成的引用。

加上 intern() 后,不管多少个并发请求,只要 userId 是 1024,它们最终拿到的都是常量池里同一个对象引用,synchronized 就能完美锁住它们了。

复制代码
// 模拟两个请求中生成的独立字符串对象(内容相同,地址不同)
String s1 = new String("1024");
String s2 = new String("1024");

// 1. 普通 toString() 的结果就类似于这样,它们是两个独立的对象
System.out.println(s1 == s2); // 结果:false ❌ (锁不住)

// 2. 调用 intern() 去常量池里"捞"对象
String interned1 = s1.intern(); // 池子里没有,放进去并返回池中地址
String interned2 = s2.intern(); // 池子里有了,直接返回第一次放进去的地址

// 3. 见证奇迹的时刻
System.out.println(interned1 == interned2); // 结果:true ✅ (拿到同一把锁了!)

@Transactional
public  Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
    // 5.1.查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

    // 6.扣减库存
    boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1") // set stock = stock - 1
    .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
    .update();
    if (!success) {
        // 扣减失败
        return Result.fail("库存不足!");
    }

    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 7.2.用户id
    voucherOrder.setUserId(userId);
    // 7.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    // 7.返回订单id
    return Result.ok(orderId);
}
}

但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务(trasactional )控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,(为什么因为只有锁执行完之后,还有事务提交,只有事务提交完了才会写入数据库)但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:

在seckillVoucher 方法中,添加以下逻辑,这样就能保证事务的特性,同时也控制了锁的粒度

含金量巨高

但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象( 也就是代理对象 ), 来操作事务

什么是 impl****对象?什么是代理对象?

在你的项目里,你写了一个类,比如叫 VoucherOrderServiceImpl(这就是所谓的 impl,即 Implementation 实现类)。

当 Spring 启动时,它会做两件事:

  • 第一件事(创建 impl****对象): Spring 会把你写的这个 VoucherOrderServiceImpl 类实例化,放在内存里。这个就是 impl****对象 。它非常"单纯",里面只有你写的纯纯的业务逻辑 (比如计算库存、生成订单号等),没有任何事务控制的代码
  • 第二件事(创建代理对象): 因为你在这个类或者方法上加了 @Transactional 注解,Spring 发现:"哟,这个类需要事务支持!" 于是,Spring 在底层利用动态代理技术(CGLIB 或 JDK 代理),在内存里动态捏造 出了一个全新的对象包裹在 impl 对象外面。这个新造出来的对象,就是代理对象(Proxy)

2. 为什么需要代理对象?(代理对象里到底有啥?)

你可以把代理对象 想象成一个"包装盒",而 impl****对象是里面的"核心零件"。

为什么必须要有这个包装盒?因为 Spring 的事务逻辑(开启事务、提交事务、回滚事务)全都写在这个包装盒(代理对象)上!

真实的代理对象内部长这样(伪代码演示):

Java

复制代码
// 这是 Spring 在内存里偷偷生成的代理对象 (Proxy)
public class VoucherOrderServiceProxy {

    // 内部持有了你写的真实的 impl 对象
    private VoucherOrderServiceImpl targetImpl; 

    // 代理覆盖了你的下单方法
    public void createVoucherOrder(Long voucherId) {
        try {
            // 1. 【代理对象干的活】开启数据库事务 (Begin Transaction)
            startTransaction(); 

            // 2. 【核心逻辑】调用你写的真实 impl 对象的业务逻辑
            targetImpl.createVoucherOrder(voucherId); 

            // 3. 【代理对象干的活】提交数据库事务 (Commit)
            commitTransaction(); 
        } catch (Exception e) {
            // 4. 【代理对象干的活】发生异常,回滚事务 (Rollback)
            rollbackTransaction(); 
        }
    }
}

3. 为什么用 this.createVoucherOrder****事务就废了?

在 Java 语言的基础语法中,this 永远指向当前正在执行这段代码的对象本身

当请求打进来时,它的执行轨迹是这样的:

  1. 外部请求首先访问的是代理对象
  2. 代理对象开启了事务(如果是外部调用的事务方法)。
  3. 代理对象将任务丢给 impl****对象 去执行具体逻辑。
  4. 现在,代码执行到了你写的 impl 对象内部(比如正在执行 seckillVoucher 方法)。
  5. 此时,你在代码里写了:return this.createVoucherOrder(voucherId);

致命的逻辑来了: 在这里,this 指向的是谁?是你写的、那个干干净净、没有包裹任何事务逻辑的 impl****对象本身

当你用 this.createVoucherOrder() 时,相当于肉身直接调用了另一个肉身方法 。整个过程都在那个"核心零件"内部流转,完全没有经过外面的"包装盒(代理对象)"

既然连代理对象的边都没碰到,那代理对象里写的 startTransaction()commitTransaction() 自然也就不会被执行了,这就是为什么事务完全没有生效的根本原因。

这里代理对象必须得用接口作为返回值,你可以把这个代理对象,看作接口的另一个实现类 ,和当前的实现类是双胞胎兄弟关系

当然使用这个引入代理对象的方法

我们需要添加一些注解,和依赖文件

引入 aspectjweaver****依赖(提供底层工具)

XML

复制代码
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

(打开系统开关)

这个注解加在 Spring Boot 的启动类上,它的作用非常关键。

复制代码
@EnableAspectJAutoProxy(exposeProxy = true) 

Idea在本地配置集群环境

这两张图片展示了黑马配置集群的过程

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

1、我们将服务启动两份,端口分别为8081和8082:

2、然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:

我的操作

我配置集群的方式并不像教程里面的这么复杂

之前点击箭头 ,然后再选择config,接着再ctrl+D

这里配置这个是用来覆盖yaml文件里面配置的端口,防止用同一个8081端口产生端口冲突

然后按照如图所示配置

只配置上面这些部分还不够,还需在nigx里面进行配置,配置负载均衡,和反向代理

这个就是轮询访问,就是当访问8080nigx接口时候, 只要你没有显式地去指定其他的分配算法,Nginx 就会自动、默默地采用"轮询(Round Robin)"作为默认策略。 一个请求转发给8081,一个转发给8082

这里我们是权重是1的轮询

如果权重一个是1 一个是2呢 还能轮询吗

是的,它依然是轮询! 只不过它从"普通轮询"升级成了它的进阶版:"加权轮询(Weighted Round Robin)"。

这恰恰是 Nginx 最聪明的设计之一。它并不是变成"随机"或者按概率瞎猜,它依然严格遵循着一个固定且公平的循环圈,只不过这个圈里的"席位"变了。

我用你举的例子(8081 权重是 2,8082 权重是 1)来给你还原一下 Nginx 的发牌顺序:

1. 绝对可控的固定序列

Nginx 看到 weight=2weight=1 后,会在内部生成一个由 3 个请求组成的最小轮询周期。

如果此时来了 6 个用户请求,Nginx 的分发顺序会像流水线一样极其精准:

  • 第 1 个请求 ➡️ 发给 8081 (使用第 1 个权重)
  • 第 2 个请求 ➡️ 发给 8081 (使用第 2 个权重)
  • 第 3 个请求 ➡️ 发给 8082 (使用唯一的权重)

有关锁失效原因分析

由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。

为什么,本质原因就是

synchronized 这种我们平时用的锁,在学术上叫 "本地锁(JVM 锁)" 。 它的生存范围非常小,只能存活在当前 Tomcat 所在的那个 JVM 内存里也就是我们此时对用户A的锁在TomcatA和TomcatB并不共享

分布式锁(集群环境中有效的锁)

用Redis实现分布式锁

其实就是用redis 锁,之前锁是jvm里面的锁,多部署几份,各个jvm就不认各自的锁了

但是分布式锁是redis作为锁,(数据库也可以但通常用redis)redis肯定就是一个全局锁了

下面用两张图片,可以直接指出分布式锁和jvm内部锁的区别

这里我们可以一步操作,这样就更加具备原子性,不是分两部而是两步合成一步

这里有一个阻塞式的

和非阻塞式的

  • 非阻塞式: 就像上面讲的 SET NX 命令,如果没拿到锁,它会直接返回失败,不会在那里干等。
  • 阻塞式: 如果没拿到锁,线程会一直等待(或者重试),直到拿到锁为止。用原生 Redis 实现阻塞锁比较麻烦,需要自己写循环重试的代码(自旋锁)。

我们这里用的就是非阻塞式锁,因为阻塞式的锁对cpu的负荷会比较高

在这里黑马展示了,如何通过自定义一个分布式锁

这里ILock 和下面的SimpleRedisLock实现类都放在util包里面

SimpleRedisLock

这个SimpleRedisLock是一个实现类,得实现上面定义这个Ilock接口

复制代码
public class SimpleRedisLock implements ILock

然里面下面是重写接口里面的这两个方法

利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性

复制代码
private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = Thread.currentThread().getId()
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
  • 释放锁逻辑

SimpleRedisLock

释放锁,防止删除别人的锁

复制代码
public void unlock() {
    //通过del删除锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}
  • 修改业务代码 就是修改VoucherOrderServiceImpl 类里面代码

    @Override
    public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    // 尚未开始
    return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    // 尚未开始
    return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
    // 库存不足
    return Result.fail("库存不足!");
    }
    Long userId = UserHolder.getUser().getId();
    //创建锁对象(新增代码)
    SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
    //获取锁对象
    boolean isLock = lock.tryLock(1200);
    //加锁失败
    if (!isLock) {
    return Result.fail("不允许重复下单");
    }
    try {
    //获取代理对象(事务)
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
    } finally {
    //释放锁
    lock.unlock();
    }
    }

这里学到一个快捷键,因为我们自己要写一些常量名要大写,但是大写会很不习惯,所以我们可以先小写然后再ctrl+shift+u 一键切换大小写

4.4 Redis分布式锁误删情况说明

逻辑说明:

持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明

解决方案:解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。

分布式锁误删

4.5 解决Redis分布式锁误删问题

需求:修改之前的分布式锁实现,满足:在获取锁时存入线程标示(可以用UUID表示)在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

为什么这里一定得用UUID,而不能用Thread ID 呢 只存 Thread ID 会导致在集群环境下发生误删,因为不同机器(JVM)上的 Thread ID 会重复。 而UUID是真正的世界上没有两个一样的UUID

  • 如果一致则释放锁
  • 如果不一致则不释放锁

核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。

具体代码如下:加锁

复制代码
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}

释放锁

复制代码
public void unlock() {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁中的标示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 判断标示是否一致
    if(threadId.equals(id)) {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

有关代码实操说明:

在我们修改完此处代码后,我们重启工程,然后启动两个线程,第一个线程持有锁后,手动释放锁,第二个线程 此时进入到锁内部,再放行第一个线程,此时第一个线程由于锁的value值并非是自己,所以不能释放锁,也就无法删除别人的锁,此时第二个线程能够正确释放锁,通过这个案例初步说明我们解决了锁误删的问题。

4.6 分布式锁的原子性问题

更为极端的误删逻辑说明:

线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,

原子性操作(Lua脚本)

看到原子性,大家肯定首先想到的就是事务,那redis里面到底有没有事务呢,其实有,但是黑马不推荐用事务做,说了一大堆也没听懂,反正最后推荐用这个lua脚本去做,最后来保证这个原子性

4.7 Lua脚本解决多条命令原子性问题

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性

为什么叫lua脚本,因为编写这个脚本时候,用到的是一个叫lua的编程语言

Lua是一种编程语言,它的基本语法大家可以参考网站:Lua 教程 | 菜鸟教程,这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了,作为Java程序员这一块并不作一个简单要求,并不需要大家过于精通,只需要知道他有什么作用即可。

首先得要明白,我们是要用lua去调用redis

用java调用redis很简单,因为spring本身就提供了redistemplate供我们调用 那lua该怎么调用redis,这就是我们在这里学的重点

这里重点介绍Redis提供的调用函数,语法如下:

首先要明白下面这些 redis.call****等等调用函数是由 Redis官方 提供的,而不是 Lua 本身提供的。

下面的这些语法规则就是lua语法规则,而且reids里面是内置lua的

redis.call('命令名称', 'key', '其它参数', ...)

例如,我们要执行set name jack,则脚本是这样:

复制代码
# 执行 set name jack
redis.call('set', 'name', 'jack')

例如,我们要先执行set name Rose,再执行get name,则脚本如下:

复制代码
# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name

写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:

例如,我们要执行 redis.call('set', 'name', 'jack') 这个脚本,语法如下:

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

下面给出一段就能很好理解这个了 实际上lua脚本内容只是 "return redis.call('set', KEYS1, ARGV1])" 这个一个字符串,其它的都是reids客户端的命令已经比如eval 就是执行脚本意思斌不是lua脚本了,只有双引号引起的那个字符串才是

复制代码
EVAL "return redis.call('set', KEYS[1], ARGV[1]])" 2 name Jack

第一步:理解什么是 KEYS 和 ARGV?
在 Lua 脚本的世界里,KEYS 和 ARGV 就是两个空的数组(盒子)。

注意:作为一个 Java 程序员,你习惯了数组下标从 0 开始(比如 arr[0])。但在 Lua 语言里,数组下标是从 1 开始的!

所以,KEYS[1] 的意思就是:KEYS 这个数组里的第 1 个元素。

ARGV[1] 的意思就是:ARGV 这个数组里的第 1 个元素。

第二步:那个数字 2 是怎么分拣快递的?
当你敲下 2 name Jack 的时候,Redis 内部有一个"分拣员",它的工作规则是这样的:

它看到了数字 2。

它心里默念:"好的,老板说了,接下来的 2 个单词,全部扔进 KEYS 数组里!剩下的所有单词,扔进 ARGV 数组里。"

于是,分拣员开始干活:

抓起第一个单词 name ➡️ 它是前 2 个名额里的,扔进 KEYS 数组。现在 KEYS[1] = "name"。

抓起第二个单词 Jack ➡️ 它还是前 2 个名额里的,扔进 KEYS 数组。现在 KEYS[2] = "Jack"。

还有单词吗?没了。

所以 ARGV 数组里空空如也。

灾难发生了:
当你的 Lua 脚本执行到 redis.call('set', KEYS[1], ARGV[1]) 时:

KEYS[1] 被替换成了 "name"。

ARGV[1] 去数组里找,发现什么都没有(在 Lua 里叫 nil,也就是 Java 里的 null)。

于是最终执行的是:redis.call('set', 'name', null)。你把一个 null 设给 Redis,肯定会报错!

第三步:怎么正确地写一个包含数字 2 的例子?
既然数字写了 2,就意味着你真的要操作 2 个不同的 Redis Key。

假设场景: 你要同时给两个用户改名字。你要把 user1 的名字改成 Jack,把 user2 的名字改成 Rose。

正确的命令应该这么写:
EVAL "redis.call('set', KEYS[1], ARGV[1]); redis.call('set', KEYS[2], ARGV[2])" 2 user1 user2 Jack Rose

分拣员再次开始干活(看到数字 2):

抓起前 2 个单词(user1,user2),扔进 KEYS 数组:

KEYS[1] = "user1"

KEYS[2] = "user2"

抓起剩下的所有单词(Jack,Rose),扔进 ARGV 数组:

ARGV[1] = "Jack"

ARGV[2] = "Rose"

脚本开始执行替换:
原来脚本里的占位符被替换后,Redis 实际执行的代码就变成了:

Lua
redis.call('set', 'user1', 'Jack'); 
redis.call('set', 'user2', 'Rose')
完美运行!

接下来我们来回一下我们释放锁的逻辑:

释放锁的业务流程是这样的

1、获取锁中的线程标示

2、判断是否与指定的标示(当前线程标示)一致

3、如果一致则释放锁(删除)

4、如果不一致则什么都不做

如果用Lua脚本来表示则是这样的:

最终我们操作redis的拿锁比锁删锁的lua脚本就会变成这样

4.8 利用Java代码调用Lua脚本改造分布式锁

我们首先应该在resource包底下创建unlock.lua 这个文件,这个里面就是纯粹的lua脚本,idea里面好像对lua脚本没有任何注释我们应该在vscode 或者cursor里面打开进行编写

在vscode中我们选中这个语言,然后选择lua

然后在这个里面进行编写

我们之前自定义的trylock方法里面没有问题,只要改造unlock方法就行了

lua脚本本身并不需要大家花费太多时间去研究,只需要知道如何调用,大致是什么意思即可,所以在笔记中并不会详细的去解释这些lua表达式的含义。

我们的RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图股

Java代码

复制代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

public void unlock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
        UNLOCK_SCRIPT,
        Collections.singletonList(KEY_PREFIX + name),
        ID_PREFIX + Thread.currentThread().getId());
}
经过以上代码改造后,我们就能够实现 拿锁比锁删锁的原子性动作了~

注意这里用一个我们之前从来没有见过的,static静态代码块来赋初值

这种方式我们之前从来都没有见过

对于 static final 的变量,在 Java 的世界里,有且只有这两种合法的赋值方式,你用大白话总结得非常到位:

"跟在屁股后面直接赋值" (行内声明并初始化):

复制代码
private static final String LOCK_PREFIX = "lock:";
    • 适用场景: 适合那种能通过一个简单的 **=**号就能搞定的基本类型、字符串或者简单对象的赋值。

"在静态代码块里面赋值" **static {}**块初始化):

复制代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}
    • 适用场景: 适合你现在面临的这种"复杂对象赋值"。因为创建它不仅需要 new****一下,还需要再调用好几个属性设置方法( setLocation**、** setResultType**),不能在一行代码内搞定,所以必须依靠静态代码块这个"小房间"来一口气完成这一堆初始化动作。**

小总结:

基于Redis的分布式锁实现思路:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标示
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁
    • 特性:
      • 利用set nx满足互斥性
      • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
      • 利用Redis集群保证高可用和高并发特性

笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题

但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来10块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission啦

测试逻辑:

第一个线程进来,得到了锁,手动删除锁,模拟锁超时了,其他线程会执行lua来抢锁,当第一天线程利用lua删除锁时,lua能保证他不能删除他的锁,第二个线程删除锁时,利用lua同样可以保证不会删除别人的锁,同时还能保证原子性。

redission

5、分布式锁-redission

5.1 分布式锁-redission功能介绍

那么什么是Redission呢

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

这里面的这个可重入要得好好说一下了,就是一个锁如果不可重入的话,是不好的,容易产生死锁,比如一个线程里面的方法A拿到了一个锁,去调用方法B,方法B也需要拿到这个锁,而A执行完释放这个锁,需要执行方法B

B要执行需要拿到这个锁,这样就产生了死锁,这就是不可重入带来的劣势

之前我们用到的自己实现的redis 虽然感觉已经很完美了,实际上还是会存在上面这些问题

上面这句话不说人话,实际上说人话就是redission它是一个别人写好的成熟框架,可以解决上面这些问题

Redission提供了分布式锁的多种多样的功能,其实前面学的都是白雪企业里面没必要自己写锁,只是为了让我们更加明白原理

5.2 分布式锁-Redission快速入门

引入依赖:

复制代码
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>

配置Redisson客户端:

复制代码
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        // 配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6379")
        .setPassword("123321");
        // 创建RedissonClient对象
        return Redisson.create(config);
    }
}

如何使用Redission的分布式锁

复制代码
@Resource
private RedissionClient redissonClient;

@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
    try{
        System.out.println("执行业务");          
    }finally{
        //释放锁
        lock.unlock();
    }

}



}

在 VoucherOrderServiceImpl

注入RedissonClient

复制代码
@Resource
private RedissonClient redissonClient;

@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    // 尚未开始
    return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    // 尚未开始
    return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
    // 库存不足
    return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象
boolean isLock = lock.tryLock();

//加锁失败
if (!isLock) {
    return Result.fail("不允许重复下单");
}
try {
    //获取代理对象(事务)
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
} finally {
    //释放锁
    lock.unlock();
}
}

redission底层原理(先跳过)

接下来会讲redission的底层原理,和项目无关建议先跳过,非常枯燥,以后学完黑马的那个juc再回来看

5.3 分布式锁-redission可重入锁原理

在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。

在redission中,我们的也支持支持可重入锁

在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有,所以接下来我们一起分析一下当前的这个lua表达式

这个地方一共有3个参数

KEYS1 : 锁名称

ARGV1: 锁失效时间

ARGV2: id + ":" + threadId; 锁的小key

exists: 判断数据是否存在 name:是lock是否存在,如果==0,就表示当前这把锁不存在

redis.call('hset', KEYS1, ARGV2, 1);此时他就开始往redis里边去写数据 ,写成一个hash结构

Lock{

id + ":" + threadId : 1

}

如果当前这把锁存在,则第一个条件不满足,再判断

redis.call('hexists', KEYS1, ARGV2) == 1

此时需要通过大key+小key判断当前这把锁是否是属于自己的,如果是自己的,则进行

redis.call('hincrby', KEYS1, ARGV2, 1)

将当前这个锁的value进行+1 ,redis.call('pexpire', KEYS1, ARGV1); 然后再对其设置过期时间,如果以上两个条件都不满足,则表示当前这把锁抢锁失败,最后返回pttl,即为当前这把锁的失效时间

如果小伙帮们看了前边的源码, 你会发现他会去判断当前这个方法的返回值是否为null,如果是null,则对应则前两个if对应的条件,退出抢锁逻辑,如果返回的不是null,即走了第三个分支,在源码处会进行while(true)的自旋抢锁。

复制代码
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"

5.4 分布式锁-redission锁重试和WatchDog机制

说明:由于课程中已经说明了有关tryLock的源码解析以及其看门狗原理,所以笔者在这里给大家分析lock()方法的源码解析,希望大家在学习过程中,能够掌握更多的知识

抢锁过程中,获得当前线程,通过tryAcquire进行抢锁,该抢锁逻辑和之前逻辑相同

1、先判断当前这把锁是否存在,如果不存在,插入一把锁,返回null

2、判断当前这把锁是否是属于当前线程,如果是,则返回null

所以如果返回是null,则代表着当前这哥们已经抢锁完毕,或者可重入完毕,但是如果以上两个条件都不满足,则进入到第三个条件,返回的是锁的失效时间,同学们可以自行往下翻一点点,你能发现有个while( true) 再次进行tryAcquire进行抢锁

复制代码
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
    return;
}

接下来会有一个条件分支,因为lock方法有重载方法,一个是带参数,一个是不带参数,如果带带参数传入的值是-1,如果传入参数,则leaseTime是他本身,所以如果传入了参数,此时leaseTime != -1 则会进去抢锁,抢锁的逻辑就是之前说的那三个逻辑

复制代码
if (leaseTime != -1) {
    return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}

如果是没有传入时间,则此时也会进行抢锁, 而且抢锁时间是默认看门狗时间 commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()

ttlRemainingFuture.onComplete((ttlRemaining, e) 这句话相当于对以上抢锁进行了监听,也就是说当上边抢锁完毕后,此方法会被调用,具体调用的逻辑就是去后台开启一个线程,进行续约逻辑,也就是看门狗线程

复制代码
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
                                                     commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
                                                     TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    if (e != null) {
        return;
    }

    // lock acquired
    if (ttlRemaining == null) {
        scheduleExpirationRenewal(threadId);
    }
});
return ttlRemainingFuture;

此逻辑就是续约逻辑,注意看commandExecutor.getConnectionManager().newTimeout() 此方法

Method( new TimerTask() {},参数2 ,参数3 )

指的是:通过参数2,参数3 去描述什么时候去做参数1的事情,现在的情况是:10s之后去做参数一的事情

因为锁的失效时间是30s,当10s之后,此时这个timeTask 就触发了,他就去进行续约,把当前这把锁续约成30s,如果操作成功,那么此时就会递归调用自己,再重新设置一个timeTask(),于是再过10s后又再设置一个timerTask,完成不停的续约

那么大家可以想一想,假设我们的线程出现了宕机他还会续约吗?当然不会,因为没有人再去调用renewExpiration这个方法,所以等到时间之后自然就释放了。

复制代码
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }

    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }

            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                    return;
                }

                if (res) {
                    // reschedule itself
                    renewExpiration();
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

    ee.setTimeout(task);
}

5.5 分布式锁-redission锁的MutiLock原理

为了提高redis的可用性,我们会搭建集群或者主从,现在以主从为例

此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。

为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

那么MutiLock 加锁原理是什么呢?笔者画了一幅图来说明

当我们去设置了多个锁时,redission会将多个锁添加到一个集合中,然后用while循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在4500ms有线程加锁失败,则会再次去进行重试.

相关推荐
于指尖飞舞2 小时前
java后端面试题(多线程极简)
java·开发语言
IT 行者3 小时前
GitHub Spec Kit 实战(四):读懂和干预 /speckit.plan——AI 最自由发挥的一步
java·人工智能·github·ai编程·claude
独隅3 小时前
IntelliJ IDEA 在 Windows 上的完整安装与使用指南
java·windows·intellij-idea
Misnearch3 小时前
为什么List<int[]> ans = new ArrayList<>()能成功创建
java·object
梦想的颜色3 小时前
从零入门:Docker在Ubuntu上的安装、使用与主流镜像仓库实战(Java/Go/MySQL/PostgreSQL/MongoDB/Nginx
java·ubuntu·docker
是多巴胺不是尼古丁3 小时前
期末java复习--string
java·开发语言·python
Survivor0013 小时前
高并发系统流量治理的底层算法
java·开发语言
凡人叶枫3 小时前
Effective C++ 条款35:考虑 virtual 函数以外的其他选择
java·c++·spring
garmin Chen3 小时前
从 Transformer 到 Agent:大模型技术全景解析
java·人工智能·python·深度学习·transformer
愚公移码4 小时前
蓝凌EKP18产品:流程引擎技术篇之流程核心概念模型
java·人工智能·流程引擎·蓝凌