Redis学习笔记——以黑马点评为例(上)

一、短信登录:Session

1.1 实现验证码发送

根据页面发送的请求,找到/user/code,编写userConntroller
在这里插入图片描述

/**
     * 发送手机验证码
     */
@PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // TODO 发送短信验证码并保存验证码
        //return Result.fail("功能未完成");
        return userService.sendCode(phone,session);
    }

ctrl +左击userService,进入IUserService中,编写sendCode方法

Result sendCode(String phone, HttpSession session);

进入对应Impl实现类,实现sendCode方法,并对其具体实现

@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.info("发送短信验证码成功,验证码:{}",code);
        //返回ok
        return Result.ok();
    }

1.2 实现短信验证码登录和注册功能

根据页面发送的请求,找到/user/login,编写userConntroller
在这里插入图片描述

/**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO  loginForm, HttpSession session){
        // TODO 实现登录功能
        //return Result.fail("功能未完成");
        return userService.login(loginForm,session);
    }

ctrl +左击userService,进入IUserService中,编写sendCode方法

Result login(LoginFormDTO loginForm, HttpSession session);

进入对应Impl实现类,实现sendCode方法,并对其具体实现

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

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

        //7.保存用户信息到session中
        session.setAttribute("user",user);

        return Result.ok();
    }

    private User creatUserWithPhone(String phone) {
        //1.创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));    //使用系统常量
        //2.保存用户
        save(user);
        return user;
    }

执行后,可查看后台数据库更新了一条记录
在这里插入图片描述

1.3 登录校验拦截器

用户的请求会带Cookie,从Session中获取用户。
在用户的请求和所有的Controller之间设置拦截器,将拦截到的用户信息存放在ThreadLocal(线程)中,每一个进入到Tomcat的请求都是一个独立的线程。
在utils下编写拦截器LoginInterceptor类,实现preHandle和afterCompletion这两个方法

public class LoginInterceptor implements HandlerInterceptor {
    /**
     * 前置拦截
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @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((UserDTO) user);
        //6.放行
        return true;
    }

    /**
     * 渲染之后
     * @param request
     * @param response
     * @param handler
     * @param ex
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //移出用户
        UserHolder.removeUser();
    }
}

拦截器编写完后,需要在程序运行过程中添加该拦截器
在Config包下创建MvcConfig

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    //添加拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                //排除不需要拦截的路径
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );
    }
}

通过封装UserDTO,减少返回的信息,隐藏用户敏感信息
在这里插入图片描述
更改前述代码,使信息在存放至session中时就以UserDTO的形式存放,则后述从ThreadLocal线程中获取时也以UserDTO形式获取
在这里插入图片描述
在这里插入图片描述

1.4 集群的session共享

多台Tomcat不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失问题。
session拷贝的缺点:内存空间的浪费,存在延迟等
其替代方案需满足:数据共享,内存存储,key/value结构

1.5 基于Redis实现共享session登录

根据手机号生成的验证码以key/value形式存入到Redis,登录注册时,以手机号为key,验证码为value在Redis中校验。并根据手机号查询用户,判断是否存在,最终以Hash结构,随机token为key存储用户数据存入Redis(可以将对象中的每个字段独立存储,可以针对单个字段做CURD,并且内存占用更少),返回token给客户端,登录校验时,请求并携带token,从redis获取用户信息,判断是否存在,保存用户到ThreadLocal,放行,结束。

发送验证码
在这里插入图片描述
在这里插入图片描述

验证码登录注册
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在LoginInterceptor中,由于LoginInterceptor是手动创建的,因此无法通过依赖注入StringRedisTemplate,只能通过构造函数拦截器中注入
在这里插入图片描述
在这里插入图片描述
在LoginInterceptor中继续修改
在这里插入图片描述
至此,启动程序后,再次点击我的,仍需进行登录操作。用户访问拦截器拦截的页面会刷新页面,访问的是不需要拦截的页面则不会导致页面的刷新。

解决状态登录刷新问题

对此,我们添加一个拦截器,让其进行刷新拦截,拷贝LoginInterceptor,命名为RefreshTokenInterceptor,修改代码:
在这里插入图片描述
原来的LoginInterceptor拦截器更改为:
在这里插入图片描述
MvcConfig中添加刚才编写的拦截器,通过order()哎定义拦截器的先后执行顺序
在这里插入图片描述
运行程序后,报错
在这里插入图片描述
此时我们查看UserController中/me的代码,对照前端代码,发现所传数据不一致,后端给的是user,前端给的是token,将前端改为token,同时将跳转页面修改为:跳转到info.html首页
在这里插入图片描述
由于在拦截器配置中加入了/user/me,那将不会执行LoginInterceptor中的代码,也就是不会从session中拿到用户信息放到threadlocal中去,也就是/user/me接口没有从返回的浏览器数据中拿到相应的user数据,因此登录后点击“我的”,需要重新登陆,我们可以直接在me()方法里面拿到用户信息并返回,方法是直接从HttpServletRequest中拿到session,之后从session中直接拿到用户信息
在userController类中注入

@Resource
    private HttpServletRequest httpServletRequest;

修改代码:

@GetMapping("/me")
    public Result me(){
        // TODO 获取当前登录的用户并返回
        //return Result.fail("功能未完成");
        //UserDTO user = UserHolder.getUser();
        HttpSession session =httpServletRequest.getSession();
        UserDTO user = (UserDTO) session.getAttribute("user");
        return Result.ok(user);
        //return Result.ok("/blog/of/me");
    }

二、商户查询缓存

2.1 什么是缓存&&添加Redis缓存

缓存
缓存是数据交换的缓冲区,是存储数据的临时地方,一般读写性能比较高。
缓存的作用:降低后端负载;提高读写效率,降低响应时间
缓存的成本:数据的一致性成本;代码维护成本;运维成本
在缓存数据一致性处理过程中,会出现缓存穿透击穿等问题,代码复杂度会大大提高

添加Redis缓存
在这里插入图片描述

添加商户id缓存

shopController控制层
在这里插入图片描述
service服务层
在这里插入图片描述
Impl实现层

@Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        //提前创建一个key值,将"cache:shop:"封装为一个常量,放在RedisConstants中
        String key =  CACHE_SHOP_KEY + id;

        //1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            //3.存在,直接返回
            //将shopJson转换为shop实体类型
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //4.不存在,根据id查询数据库
        Shop shop = getById(id);
        //5.不存在,返回错误
        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        //6.存在,写入redis   将shop转为一个Jsonstring类型存入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
        //7.返回
        return Result.ok(shop);
    }

添加商户类型缓存

shopController控制层
在这里插入图片描述
service服务层
在这里插入图片描述
Impl实现层

@Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryShopList() {
        //提前创建一个key值,将"cache:shoptype:"封装为一个常量,放在RedisConstants中
        String key =  CACHE_SHOPTYPE_KEY;

        //1.从redis查询商铺类型缓存
        List<String> shopTypeList =new ArrayList<>();
        shopTypeList = stringRedisTemplate.opsForList().range(key,0,-1);
        //2.判断是否存在
        if (!shopTypeList.isEmpty()) {
            //3.存在,直接返回
            //将shopTypeJson转换为shopType实体类型
            //创建一个新的List,存放shopType实体类型
            List<ShopType> typeList = new ArrayList<>();
            for (String  s:shopTypeList) {
                ShopType shopType = JSONUtil.toBean(s, ShopType.class);
                typeList.add(shopType);
            }
            return Result.ok(typeList);
        }

        //4.不存在,根据分类查询数据库
        List<ShopType> typeList = query().orderByAsc("sort").list();
        //5.不存在,返回错误
        if (typeList.isEmpty()) {
            return Result.fail("不存在该分类!");
        }
        //6.添加该分类进数据库
        for (ShopType shopType:typeList) {
            //将该分类类型转换为字符串形式,放入数据库List中
            String s = JSONUtil.toJsonStr(shopType);
            shopTypeList.add(s);
        }
        //6.存在,写入redis   ,以List形式,从右边直接全部推入
        stringRedisTemplate.opsForList().rightPushAll(key,shopTypeList);
        //7.返回
        return Result.ok(typeList);
    }

2.2 缓存更新策略

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
先删除缓存,再操作数据库
正常情况
在这里插入图片描述
特殊情况
在这里插入图片描述
先写数据库,再删除缓存
正常情况
在这里插入图片描述
特殊情况
在这里插入图片描述
缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis自带的内存淘汰机制
2.高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作:
缓存命中则直接返回
缓存未命中则查询数据库,并写入缓存,设定超时时间
写操作:
先写数据库,然后再删除缓存
要确保数据库与缓存操作的原子性

2.3 实现商铺缓存与数据库的双写一致

controller控制层:
在这里插入图片描述
service服务层:
在这里插入图片描述
Impl实现层:

@Override
    @Transactional    //通过事务实现回滚,控制他们的原子性
    public Result update(Shop shop) {
        Long id = shop.getId();
        if (id == null){
            return Result.fail("店铺id不能为空!");
        }
        //1.更新数据库
        updateById(shop);
        //2.删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
        return Result.ok();
    }

2.4 缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会达到数据库。
产生原因:
用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
解决思路:
1、缓存空对象(空串或其他标志字符):
优点:实现简单,维护方便
缺点:额外的内存消耗;可能造成短期的不一致
2、布隆过滤:
优点:内存占用较少,没有多余key
缺点:实现复杂;存在误判可能
3、增加id的复杂度,避免被猜测id规律
4、做好数据的基础格式校验
5、加强用户权限校验
6、做好热点参数的限流
在这里插入图片描述

解决商铺查询的缓存穿透问题(缓存空对象)

在这里插入图片描述
在ShopServiceImpl中的queryById方法中进行代码修改:
在这里插入图片描述

2.5 缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案
1、给不同的key的TTL添加随机值
2、利用Redis集群提高服务的可用性(Redis哨兵)
3、给缓存也无添加降级限流策略
4、给业务添加多级缓存
在这里插入图片描述

2.5 缓存击穿(热点Key)

缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
在这里插入图片描述
解决方案:
1、互斥锁(一致性)
优点:没有额外的内存消耗;保证一致性;实现简单
缺点:线程需要等待,性能受影响;可能有死锁风险
在这里插入图片描述
2、逻辑过期(可用性)
优点:线程无需等待;性能较好
缺点:不能保证一致性(返回旧数据);有额外内存消耗;实现复杂

在这里插入图片描述
在这里插入图片描述

修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题

在这里插入图片描述
在ShopServiceImpl中添加方法

//TODO 获取锁
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //TODO 释放锁
    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }

将缓存穿透和互斥锁解决缓存击穿方法分别进行单独封装,并在queryById方法中进行调用
在这里插入图片描述
定义queryWithPassThrough,进行缓存穿透
在这里插入图片描述
定义queryWithMutes方法,进行缓存击穿

//TODO 互斥锁解决缓存击穿
    public Shop queryWithMutes(Long id){
        //提前创建一个key值,将"cache:shop:"封装为一个常量,放在RedisConstants中
        String key =  CACHE_SHOP_KEY + id;

        //1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            //3.存在,直接返回
            //将shopJson转换为shop实体类型
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //缓存穿透,判断命中的是否是空值
        if (shopJson != null){    //不等于null,也就是空字符串
            //返回错误信息
            return null;
        }

        //TODO 4.实现缓存重建
        //TODO 4.1 获取互斥锁
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            //TODO 4.2 判断是否获取成功
            if (!isLock){
                //TODO 4.3 失败,则休眠
                Thread.sleep(50);
                 return queryWithMutes(id);
            }

            //4.4 成功,根据id查询数据库
            shop = getById(id);
            //TODO 模拟重建的延时
            Thread.sleep(200);
            //5.不存在,返回错误
            if (shop == null) {
                //将空值写入redis
                stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
                //返回错误信息
                return null;
            }
            //6.存在,写入redis   将shop转为一个Jsonstring类型存入redis
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            //TODO 7. 释放互斥锁
            unLock(lockKey);
        }

        //8.返回
        return shop;
    }

修改根据id查询商铺的业务,利用逻辑过期来解决缓存击穿问题

在这里插入图片描述
数据预热,将要存放到redis的数据封装为data,放在redisData中,并在shopServiceImpl中添加方法saveShop2Redis,将shop添加到redis中
在这里插入图片描述

/将shop添加到redis中
    public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException{
        //1.查询店铺数据
        Shop shop = getById(id);
        Thread.sleep(200);
        //2.封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //3.写入Redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

编写测试类进行检验

@SpringBootTest
class HmDianPingApplicationTests {

    @Resource
    private ShopServiceImpl shopService;

    @Test
    void testSave(){
        shopService.saveShop2Redis(1L,10L);
    }

}

打开redis查看,数据以data和过期时间
在这里插入图片描述
单独封装逻辑过期解决缓存穿击穿方法,并在queryById方法中进行调用
在这里插入图片描述

//TODO 创建线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    //TODO 逻辑过期解决缓存击穿问题
    public Shop queryWithLogicalExpire(Long id){
        //提前创建一个key值,将"cache:shop:"封装为一个常量,放在RedisConstants中
        String key =  CACHE_SHOP_KEY + id;
        //1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            //3.不存在,直接返回空
            return null;
        }
        //TODO 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data =(JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();

        //TODO 5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //TODO 5.1 未过期,直接返回店铺信息
            return shop;
        }
        //TODO 5.2 已过期,需要缓存重建
        //TODO 6.缓存重建
        //TODO 6.1 获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        //TODO 6.2 判断是否获取锁成功
        if (isLock){
            //TODO 6.3 成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    //重建缓存
                    this.saveShop2Redis(id,20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
        }
        //TODO 6.4 返回过期的商铺信息
        return shop;
    }

2.6 封装Redis工具类

在utils包下创建CacheClient类

private final StringRedisTemplate stringRedisTemplate;

    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));
    }

拷贝queryWithPassThrough的代码到CacheClient类中进行改写,并注释掉原有的queryWithPassThrough方法:

//TODO 缓存穿透
    public <R,ID> R queryWithPassThrough(           //函数逻辑
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        //提前创建一个key值,将"cache:shop:"封装为一个常量,放在RedisConstants中
        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){    //不等于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   将shop转为一个Jsonstring类型存入redis
        this.set(key, r, time, unit);
        //7.返回
        return r;
    }

到ShopServiceImpl中直接调用新的方法(提前引入CacheClient类):
在这里插入图片描述

拷贝queryWithLogicalExpire的代码到CacheClient类中进行改写,并注释掉原有的queryWithLogicalExpire方法:

//TODO 逻辑过期解决缓存击穿问题
    //TODO 创建线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public <R,ID> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){
        //提前创建一个key值,将"cache:shop:"封装为一个常量,放在RedisConstants中
        String key =  keyPrefix + id;
        //1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if (StrUtil.isBlank(json)) {
            //3.不存在,直接返回空
            return null;
        }
        //TODO 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        JSONObject data =(JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        LocalDateTime expireTime = redisData.getExpireTime();

        //TODO 5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //TODO 5.1 未过期,直接返回店铺信息
            return r;
        }
        //TODO 5.2 已过期,需要缓存重建
        //TODO 6.缓存重建
        //TODO 6.1 获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        //TODO 6.2 判断是否获取锁成功
        if (isLock){
            //TODO 6.3 成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    //重建缓存
                    //查询数据库
                    R r1 = dbFallback.apply(id);
                    //写入Redis
                    this.setWithLogicalExpire(key, r1, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
        }
        //TODO 6.4 返回过期的商铺信息
        return r;
    }

    //TODO 获取锁
    private boolean tryLock(String key){

        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //TODO 释放锁
    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }

改写测试类:
在这里插入图片描述
测试:首先运行HmDianPingApplicationTests类里的测试方法,10秒后逻辑过期,redis中仍显示数据,但数据为过期状态。此时运行后台程序,通过Jmeter进行测试,对原有的测试结果做清空处理,重新运行,在idea中可以看到仅对数据库做一次查询。
在这里插入图片描述
在这里插入图片描述

三、优惠券秒杀

3.1 全局唯一ID

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般需要满足:唯一性,递增性,安全性,高可用,高性能。

订单表如果使用数据库自增ID会存在一些问题。
1.id的规律性太明显。
2.受单表数据量的限制(分表之后每张表都自增长,id会出现重复)。
在这里插入图片描述
符号位永远为0代表整数。
31位的时间戳是以秒为单位,定义了一个起始时间,用当前时间减起始时间。
32位的是序列号是Redis自增的值,支持每秒产生2^32个不同ID

redis实现全局唯一id

在utils包下定义一个RedisWorker类,是一个基于Redis的ID生成器。
自增超过32位彼时便无法再存储新的数据,解决的方案是采用拼接日期。

  • 每天一个key,方便统计订单量
  • ID构造是 时间戳 + 计数器
/**
     * 开始时间戳
     */
    private  static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号位数
     */
    private  static final int COUNT_BITS = 32;
    
    @Resource
    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;
    }

    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2022,1,1,0,0,0);
        long second = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println("second = " + second);
    }

编写新的测试方法进行测试

@Resource
    private RedisIdWorker redisIdWorker;
    private ExecutorService es = Executors.newFixedThreadPool(500);
    @Test
    void testIdWorker() throws InterruptedException {
        // 创建 CountDownLatch 对象,需要等待 300 个线程完成任务
        CountDownLatch latch = new CountDownLatch(300);
        //运行任务
        Runnable task = () -> {
            //每个线程生成100个id
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            //每个线程池执行完进行countdown, 每个任务countdown一次,  300 个线程即300 个任务,需要countdown300次
            latch.countDown();
        };
        //任务开始前计时
        long begin = System.currentTimeMillis();
        //每个任务提交300次
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }
        // 等待 300 个线程完成任务, 进行了300 次countdown
        latch.await();
        //任务结束后计时
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end-begin));
    }

运行之后可以看到以十进制输出的所有编号:
在这里插入图片描述
可以在Redis中看到自增长的结果,1次是30000
在这里插入图片描述
总结:
全局唯一ID生成策略:

  • UUID利用JDK自带的工具类即可生成,生成的是16进制的字符串,无单调递增的特性。
  • Redis自增(每天一个key,方便统计订单量。时间戳+计数器的格式。)
  • snowflake雪花算法(不依赖于Redis,性能更好,对于时钟依赖)
  • 数据库自增(单独一张特定表代替redis自增)

3.2 添加优惠券

请求头中若缺少Authorization,会报401错误
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

3.3 实现优惠券秒杀下单

在这里插入图片描述

  • 判断秒杀是否开始或结束,所以要先查询优惠券的信息,如果尚未开始或者已经结束无法下单。
  • 要判断库存是否充足,如果不足则无法下单。
    在这里插入图片描述

在VoucherOrderController中注入IVoucherOrderService,并调用方法。
在这里插入图片描述
在IVoucherOrderService中创建方法

public interface IVoucherOrderService extends IService<VoucherOrder> {
    Result seckillVoucher(Long voucherId);
}

在VoucherOrderServiceImpl中实现该方法

@Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;        //获取全局唯一id

    @Override
    @Transactional  //设计对两张表进行操作,加上事务回滚,一旦出现问题可以进行事务回滚
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        //2.判断秒杀是否开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //尚未开始
            return Result.fail("秒杀尚未开始!!");
        }
        //3.判断秒杀是否已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            //已经结束
            return Result.fail("秒杀已经结束!!");
        }
        //4.判断库存是否充足
        if (seckillVoucher.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);

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

在这里插入图片描述
秒杀券订单:
在这里插入图片描述
秒杀券库存扣减:
在这里插入图片描述

3.4 库存超卖问题

Jmeter并发测试:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
执行后:生成109条订单
在这里插入图片描述
库存为-9
在这里插入图片描述
在这里插入图片描述

超卖问题:典型的多线程安全问题,解决方案是加锁

在这里插入图片描述
在这里插入图片描述
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见方式有两种:

- 版本号法
在这里插入图片描述

- CAS法 在这里插入图片描述 库存>0即可,线程安全
在这里插入图片描述
在这里插入图片描述
此时,数据库秒杀券库存为0,秒杀券订单数100

总结:

- 悲观锁:添加同步锁,让线程串行执行

  • 优点:简单粗暴
  • 缺点:性能一般

- 乐观锁:不加锁,在更新时判断是否有其他线程在修改

  • 优点:性能好
  • 缺点:存在成功率低的问题(通过分段锁解决)

3.5 一人一单

在这里插入图片描述
不把锁加在 createVoucherOrder() 方法上,因为任何一个用户来了都要加这把锁,方法之间变成串行执行,性能很差。
因此可以把锁加在用户id上,对不同用户加不同锁,只有当id相同时才会对锁形成竞争关系。

由于toString的内部是new了一个String字符串,每调一次toString都是生成一个全新的字符串对象,锁对象会变,调用intern()方法,intern()方法会优先去字符串常量池里查找与目标字符串值相同的引用返回(只要字符串一样能保证返回的结果一样)。

事务是在函数执行结束之后由Spring进行提交,如果把锁加在createVoucherOrder() 内部有点小——因为如果解锁之后,其它线程可以进入,而此时事务尚未提交,仍然会导致安全性问题。

因此最终方案是把synchronized加在createVoucherOrder() 的方法外部,锁住的是用户id,待函数执行结束后,事务提交,再释放锁。

将该部分代码封装在createVoucherOrder方法中,并加上@Transactional事务注解,存放在VoucherOrderServiceImpl实现类中:
在这里插入图片描述
关于代理对象事务的问题:通常情况下,当一个使用了@Transactional注解的方法被调用时,Spring会从上下文中获取一个代理对象来管理事务。

但是如果加@Transactional方法是被同一个类中的另一个方法调用时,Spring不会使用代理对象,而是直接调用该方法,导致事务注解失效。

为避免这种情况,可以使用AopContext.currentProxy方法获取当前的代理对象,然后通过代理对象调用被@Transactional注解修饰的方法,确保事务生效。
在这里插入图片描述
同时在IVoucherOrderService中创建方法:

Result createVoucherOrder(Long voucherId);

运行Jmeter测试可得结果:
在这里插入图片描述
数据库订单数仅有一条
在这里插入图片描述
数据库库存为99
在这里插入图片描述

3.6 集群模式下的线程并发安全问题

验证在集群下synchronized并不能保证线程的并发安全:

拷贝一份tomcat,并进行重命名以及端口号的修改
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
运行程序后,访问http://localhost:8080/api/voucher/list/1页面2次,可以看到轮循效果
在这里插入图片描述
在这里插入图片描述
Jemeter来进行测试,模拟高并发场景:
测试结果显示库存少了2,订单有两条,且为相同的用户id和秒杀券id

在集群模式下,每一个节点都是一个全新的JVM,每个JVM都有自己的锁。锁监视器只能在当前JVM的范围内,监视线程实现互斥。
在这里插入图片描述
需要实现多个JVM使用相同的锁监视器,需要一把跨进程、夸JVM的锁。

四、分布式锁

4.1 Redis的分布式锁实现思路

分布式锁: 满足分布式系统或集群模式下多进程可见并且互斥的锁
在这里插入图片描述在这里插入图片描述在这里插入图片描述

基于redis的分布式锁

实现分布式锁时需要实现的两个基本方法:
获取锁:

  • 互斥:确保只能有一个线程获取锁
  • 非阻塞:尝试一次,成功返回true,失败返回false
# 添加所,利用setnx的互斥特性
 	SETNX lock thread1
# 添加锁过期时间,避免服务宕机引起的死锁
 	EXPIRE lock 10
# 添加锁,NX是互斥,EX是设置超时时间
 	SET lock thread1 NX 10 EX

释放锁:

  • 手动释放
  • 超时释放:获取锁时添加一个超时时间
# 释放锁,删除即可
	DEL key

在这里插入图片描述

4.2 基于redis实现分布式锁初级版本

编写工具类ILock接口:

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec    锁持有的超时时间,过期后自动释放
     * @return  true代表获取锁成功,false代表获取锁失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}

编写工具类SimpleRedisLock

public class SimpleRedisLock implements ILock{

    private String name;
    private StringRedisTemplate stringRedisTemplate;
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    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);    //防止拆箱导致返回的空指针
    }
    
    @Override
    public void unlock() {
        //释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }

修改原来的锁:
在这里插入图片描述

dubugg调试:
设置两个相同的接口,不同的命名,进行发送
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
数据库中仅生成一条订单,库存-1

4.3 Redis分布式锁误删问题

线程A 获取到锁之后,出现了业务阻塞,导致阻塞时间超过了锁自动释放的时间,锁因超时自动释放。此时 线程B 过来拿到了锁,开始执行业务。但线程A此时业务执行完毕,释放了锁,但释放的是线程B的锁。此时线程C过来看锁已被释放,趁虚而入拿到锁,此时线程B和线程C是并行执行。

解决这个问题:线程在删除锁之前要先看锁是否是自己加的(获取锁的标示并判断是否一致)。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

解决Redis分布式锁误删问题

修改初级版本的分布式锁,满足:
1、在获取所示存入线程标示(用UUID表示)
2、在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

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

修改初级版本的分布式锁:

	private static final String KEY_PREFIX = "lock:";
	//调用hutool工具包生成UUID(每次线程调用都会生成一个唯一的UUID),让Redis的前缀变成UUID+线程ID
	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);
        }
    }

接口调试,通过api发送两次测试

在这里插入图片描述在这里插入图片描述

此时将redis中的锁删除,8082端口断点向下调试,此时,8082也能获取锁

在这里插入图片描述

在这里插入图片描述

8081端口执行释放锁

在这里插入图片描述

通过获取代理对象,判断锁标示不一致,无法删除锁,此时redis中的锁依然存在没有被删除。

4.4 分布式锁的原子性问题

当线程一已经判断完是自己的锁后,发生阻塞,在删除redis里的数据时卡住了,无法进行删除,阻塞时间过长,导致锁超时释放,此时线程二可以成功获取锁,并执行业务,此时线程一阻塞恢复运行,执行释放锁,但此时锁是线程二的,由于已经判断过,所以直接执行释放锁,因此把线程二的锁释放,再次导致锁误删。 此时线程三获取锁成功并执行业务,出现并发!!!

问题出现的原因:判断所标示和释放锁是两个动作,要避免此问题的发生,必须确保判断锁标示的动作和释放锁的动作是一个原子性操作,一起执行没有间隔!!!

在这里插入图片描述

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

Redis提供了Lua脚本,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言。基本语法参考:https://www.runoob.com/lua/lua-tutorial.html

Redis提供的调用函数,语法:

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

例,执行set name jack ,脚本如下:

#执行set name jack
redis.call('set','name','jack')

例,先执行set name jack ,再执行get name,脚本如下:

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

调用redis执行Lua脚本
数组从1开始

在这里插入图片描述

Lua脚本改造redis的分布式锁

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

1、获取锁中的线程标示
2、判断是否与指定的标示(当前线程标示)一致
3、如果一致则释放锁(删除)
4、如果不一致则什么都不做!

-- 锁的key\nlocal key = key
-- 当前线程标示\nlocal threadId = AGRV[1]
-- 获取锁中的线程标示  get keys\nlocal id = redis.call('get',keys)
-- 比较线程标示与锁中的标示是否一致
	if(id == threadId) then
-- 释放锁  del keys
	return redis.call('del',keys)
	end
	return 0

简化:

--这里的KEYS[1] 就是锁的key  ,这里的ARGV[1] 就是当前线程标示
--获取锁中的标示比较线程标示与锁中的标示是否一致
	if(redis.call('get',KEYS[1]) == ARGV[1]) then
--一致,释放锁 del key
	return redis.call('del',KEYS[1])
	end
--不一致,则直接返回
	return 0

基于Lua脚本实现分布式锁的释放锁逻辑

在这里插入图片描述

在resource目录下创建unLock.lua文件,编写文件:

--这里的KEYS[1] 就是锁的key  ,这里的ARGV[1] 就是当前线程标示
--获取锁中的标示比较线程标示与锁中的标示是否一致
	if(redis.call('get',KEYS[1]) == ARGV[1]) then
--一致,释放锁 del key
    return redis.call('del',KEYS[1])
    end
--不一致,则直接返回
	return 0

在这里插入图片描述

在一开始就将Lua的脚本加载好,而不是等到要调用释放锁的时候再去加载Lua脚本,所以采用静态变量和静态代码块,这些部分在类初始化的时候就会被加载

在这里插入图片描述总结:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标示
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁

特性:

  • 利用set nx 满足互斥性
  • 利用set ex 保证故障时锁依然能释放,避免死锁,提高安全性
  • 利用Redis集群保证高可用和高并发特性

4.5 分布式锁—Redisson功能介绍

在这里插入图片描述

Redisson是一个在Redis的基础上实现的Java驻内存数据网格。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中包含了各种分布式锁的实现。
在这里插入图片描述

Redisson入门

1、引入依赖

		<!--redisson-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.13.6</version>
        </dependency>

2、配置Redisson客户端

	@Configuration
	public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        //配置
        Config config = new Config();               //redis的ip地址及端口号   redis密码
        config.useSingleServer().setAddress("redis://192.168.140.130:6379").setPassword("123321");
        //创建RedissonClient对象
        return Redisson.create(config);
    }
}

3、使用Redisson的分布式锁\n\n在VoucherOrderServiceImpl实现类下,注入Redisson

	@Resource
	private RedissonClient redissonClient;

修改创建的锁对象,将原来的创建方式注释掉
在这里插入图片描述
运行程序,进行接口测试,正常发送请求
在这里插入图片描述
在这里插入图片描述
数据库中生成一条订单,同时库存实现-1
在这里插入图片描述
在这里插入图片描述

进行Jmeter并发测试

在这里插入图片描述
数据库仅插入一条订单数据,确保线程安全

Redisson的可重入锁原理

在这里插入图片描述

在这里插入图片描述

因此必须采用Lua脚本来确保获取锁和释放锁的原子性

在这里插入图片描述

Lua脚本:

获取锁

在这里插入图片描述

释放锁

在这里插入图片描述

Redisson的锁重试和WatchDog机制

Redisson的MultiLock原理

Redisson总结

1)不可重入Redis分布式锁:
原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
缺陷:不可重入、无法重试、锁超时失效

2)可重入的Redis分布式锁:
原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
缺陷:redis宕机引起锁失效问题

3)Redisson的multiLock:
原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功

五、秒杀优化

查询优惠券、查询订单、减库存、创建订单都需要与数据库交互,导致效率低下。特别是减库存和创建订单都是对数据库的写操作,耗时较久。
在这里插入图片描述

5.1 异步秒杀思路

优化方案:
我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点

第一个难点
我们怎么在redis中去快速校验一人一单,还有库存判断

第二个难点
由于我们校验和tomcat下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了

在这里插入图片描述

库存:KEY用string类型,VALUE用数值类型。

一人一单:KEY用string类型,VALUE用set集合类型。
在这里插入图片描述

5.2 改进秒杀业务,提高并发性能

需求:

基于Redis完成秒杀性能的判断

  • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
  • 基于Lua脚本判断秒杀库存、一人一单,决定用户是否抢购成功

基于阻塞队列实现异步秒杀下单

  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

新增秒杀优惠券的同时,将优惠券信息保存到Redis中

在这里插入图片描述

接口测试:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

基于Lua脚本判断秒杀库存、一人一单,决定用户是否抢购成功

在这里插入图片描述

在recourse中编写seckill.lua脚本文件

-- 1. 参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1]
-- 1.1 用户id
local userId = ARGV[2]

-- 2. 数据key
-- 2.1 库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3. 脚本业务
-- 3.1 判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2 库存不足,返回 1
    return 1
end
-- 3.3 判断用户是否下单 SISMEMBER orderKey userId
    --redis.call调用的方法,返回值是数字
if (redis.call('sismember',orderKey,userId) == 1) then
    -- 3.4 存在,说明是重复下单,返回2
    return 2
end
-- 3.5 扣减库存  incrby stockKey-1
redis.call('incrby', stcok, -1)
-- 3.6 下单(保存用户)  sadd orderKey userId
redis.call('sadd', orderKey, userId)

修改VoucherOrderServiceImpl代码:
删除原有的对秒杀资格的判断,编写基于Redis的判断秒杀资格代码

//静态代码块执行脚本
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("unLock.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

    /**
     * 基于redis判断秒杀资格
     * @param voucherId
     * @return
     */
    @Override
    //@Transactional  //设计对两张表进行操作,加上事务回滚,一旦出现问题可以进行事务回滚
    public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        //2.判断结果是否为0
        int r = result.intValue();
        if (r != 0){
            //2.1 不为0 ,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        //2.2 为0 , 有购买资格,把下单信息保存到阻塞队列
        long orderId = redisIdWorker.nextId("order");
        //TODO 保存到阻塞队列




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

对接口进行测试
在这里插入图片描述
redis中库存 - 1,生成一条订单
在这里插入图片描述
在这里插入图片描述
再次发送
在这里插入图片描述
redis中没有新增订单,库存也没有减少

如果抢购成功,将优惠券id和用户id封装后存入阻塞队列

		//阻塞队列
   		 private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
		//2.2 为0 , 有购买资格,把下单信息保存到阻塞队列
        //TODO 保存到阻塞队列
        //创建订单
        VoucherOrder voucherOrder = new VoucherOrder();        
        //2.3 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //2.4 用户id
        voucherOrder.setUserId(userId);
        //2.5 代金券id
        voucherOrder.setVoucherId(voucherId);
        //2.6 放入阻塞队列
        orderTasks.add(voucherOrder);

开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

在这里插入图片描述

修改VoucherOrderServiceImpl代码:

//阻塞队列
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
    //线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
    
    @PostConstruct      //在当前类初始化完毕以后执行
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }
    
    //线程任务
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            while (true){
                try {
                    //1. 获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    //2. 创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常",e);
                }
            }
        }
    }


    //该业务基于线程进行
    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //1、获取用户
        Long userId = voucherOrder.getUserId();
        //2、创建锁对象
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //3、获取锁
        boolean isLock = lock.tryLock();
        //4、判断是否获取锁成功
        if (!isLock){
            //获取锁失败,返回错误信息或重试
            log.error("不允许重复下单");
            return;
        }
        try {
            //获取代理对象(事务)
            //IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();  //无法获取
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            //释放锁
            lock.unlock();
        }
    }


    //以成员变量方式,放在当前类中,以便于子线程读取时,拿到的是一个现成的代理对象
    private IVoucherOrderService proxy;
    /**
     * 基于redis判断秒杀资格
     * @param voucherId
     * @return
     */
    @Override
    //@Transactional  //设计对两张表进行操作,加上事务回滚,一旦出现问题可以进行事务回滚
    public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        //2.判断结果是否为0
        int r = result.intValue();
        if (r != 0){
            //2.1 不为0 ,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        //2.2 为0 , 有购买资格,把下单信息保存到阻塞队列
        //TODO 保存到阻塞队列
        //创建订单
        VoucherOrder voucherOrder = new VoucherOrder();        
        //2.3 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //2.4 用户id
        voucherOrder.setUserId(userId);
        //2.5 代金券id
        voucherOrder.setVoucherId(voucherId);
        //2.6 放入阻塞队列
        orderTasks.add(voucherOrder);

        //3、提前获取的代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //3. 返回订单id
        return Result.ok(orderId);
    }


    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder){
        //TODO 6.一人一单
        Long userId = voucherOrder.getUserId();
        //6.1 根据优惠券id和用户id查询订单数
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        //6.2 判断是否存在
        if (count > 0 ){
            //用户已经购买过了
            log.error("用户已经购买过一次!!");
            return;
        }
        //5.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")        //set stock = stock - 1
                .eq("voucher_id",voucherOrder.getVoucherId()).gt("stock", 0)      //where id = ? and stock > 0
                .update();
        if (!success){
            //扣减失败
            log.error("库存不足!!");
            return;
        }
        //6.创建订单
        save(voucherOrder);
    }

进行测试:
在这里插入图片描述
redis和数据库中均产生一条订单,库存扣减1
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

5.3 总结

秒杀业务的优化思路:

  1. 先利用Redis完成库存余量、一人一单判断,完成抢单业务
  2. 再将下单业务放入阻塞队列,利用独立线程异步下单

基于阻塞队列的异步秒杀存在哪些问题:

  1. 内存限制
  2. 数据安全问题(没有持久化支持)

六、Redis消息队列

6.1 认识消息队列

消息队列,存放消息的队列。最简单的消息队列模型包含三个角色:

  • 消息队列:存储和管理消息,也被称为消息代理
  • 生产者:发送消息和消息队列
  • 消费者:从消息队列获取消息并处理消息

Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于list结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

6.2 基于list实现消息队列

List数据结构是一个双向链表,容易模拟出队列效果
队列是入口和出口不在一边,因此可以利用:LPUSH 结合 RPOP 或者 RPUSH 结合 LPOP 实现)(先进先出

注意:如果队列中没有消息时,RPOP或LPOP的操作会返回null,不会像JVM的阻塞队列那样阻塞并等待消息,因此这里应该用BRPOPBLPOP来实现阻塞效果
在这里插入图片描述
优点:

利用Redis存储,不受限于JVM内存上限
基于Redis的持久化机制,数据安全性有保证
可以满足消息有序性

缺点:

无法避免消息丢失
只支持单消费者

6.3 基于PubSub 实现消息队列

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

6.4 基于Stream实现消息队列

Stream的单消费模式

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

Stream的消费者组模式

在这里插入图片描述

创建消费者组:

在这里插入图片描述
在这里插入图片描述

从消费者组读取消息:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
XACK确认消费:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

6.5 Redis消息队列总结

在这里插入图片描述

6.6 基于Stream消息队列实现异步秒杀

需求:

  • 创建一个Stream类型的消息队列,名为stream.orders
  • 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
  • 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单

1、进入控制台操作,创建一个Stream类型的消息队列,名为stream.orders
在这里插入图片描述

2、修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId

修改Lua脚本:
在这里插入图片描述
在这里插入图片描述

修改原有的基于Redis的判断秒杀资格代码:

		@Override
   		//@Transactional  //设计对两张表进行操作,加上事务回滚,一旦出现问题可以进行事务回滚
   		public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //获取订单id
        long orderId = redisIdWorker.nextId("order");
        //1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(),String.valueOf(orderId)
        );
        //2.判断结果是否为0
        int r = result.intValue();
        if (r != 0){
            //2.1 不为0 ,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        //3、提前获取的代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //3. 返回订单id
        return Result.ok(orderId);
    }

3、项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单

修改VoucherOrderServiceImpl实现类中代码
注释掉原有的阻塞队列的线程任务
在这里插入图片描述

		private class VoucherOrderHandler implements Runnable{
        String queueName = "stream.orders";
        @Override
        public void run() {
            while (true){
                try {
                    //1. 获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create(queueName, ReadOffset.lastConsumed())
                    );
                    //2. 判断消息获取是否成功
                    if (list == null || list.isEmpty()){
                        //2.1 如果获取失败,说明没有消息,继续下一次循环
                        continue;
                    }
                    //3. 解析消息中的订单信息
                    MapRecord<String,Object,Object> record = list.get(0);
                    Map<Object, Object> values = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                    //3. 如果获取成功,可以下单
                    handleVoucherOrder(voucherOrder);
                    //2. ACK确认  SACK stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常",e);
                    handlePendingList();
                }
            }
        }

        private void handlePendingList() {
            while (true){
                try {
                    //1. 获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1  STREAMS streams.order 0
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create(queueName, ReadOffset.from("0"))
                    );
                    //2. 判断消息获取是否成功
                    if (list == null || list.isEmpty()){
                        //2.1 如果获取失败,说明pending-list没有消息,结束循环
                        break;
                    }
                    //3. 解析消息中的订单信息
                    MapRecord<String,Object,Object> record = list.get(0);
                    Map<Object, Object> values = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                    //3. 如果获取成功,可以下单
                    handleVoucherOrder(voucherOrder);
                    //2. ACK确认  SACK stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {
                    log.error("处理pending-list订单异常",e);
                    try {
                        Thread.sleep(20);
                    } catch (InterruptedException interruptedException) {
                        interruptedException.printStackTrace();
                    }
                }
            }
        }
    }

运行测试:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值