redis项目-黑马点评 项目笔记

redis项目-黑马点评



功能一:短信登录

redis项目-黑马点评 项目笔记_第1张图片

发送验证码,通过手机号校验,生成6位随机数,存储到redis当中,然后在发送验证码

判断登录的过程

redis项目-黑马点评 项目笔记_第2张图片

功能实现

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;


    //      发送验证码
    @Override
    public Result sendCode(String phone, HttpSession session) {
//        1.校验手机号
            if (RegexUtils.isPhoneInvalid(phone)) {
//        2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误");
            }
//        3.符合,生成验证码
        String s = RandomUtil.randomNumbers(6);
//        4.保存验证码到 redis LOGIN_CODE_KEY等定义见RedisConstants工具类
//        session.setAttribute("code",s);
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,s,LOGIN_CODE_TTL,TimeUnit.MINUTES);
//        5.发送验证码
        log.debug("发送短信验证码成功,验证码:{}",s);
//        6.返回ok
        return Result.ok();
    }

//    验证登录以及注册
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
//        1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号请保持一致");
        }
//        2.校验验证码
//        Object cacheCode = session.getAttribute("code");
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.equals(code)){
//        3,不一致,报错
            return Result.fail("验证码错误");
        }
//        4.一致,根据手机号查询
        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对象转为Hash存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//        因为工具类要求key 和 value都是String类型,但是id是Long类型,需要进行转换
//        两种方法,一种是自定义个map,另一种就是CopyOptions,造一个可以更改的新map出来
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create().
                        setIgnoreNullValue(true).
                        setFieldValueEditor((fildName,fileValue) -> fileValue.toString()));
//        7.3 存储
        String tokenKey = LOGIN_USER_KEY+token;
        stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
//        7.4 有效期
        stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
//        session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
//        返回token
        return Result.ok(token);
    }

    private User createUserWithPhone(String phone) {
        User user = new User();
        user.setPhone(phone);
//        USER_NICK_NAME_PREFIX 为编写的code,代替user_
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
//      保存用户
        save(user);
        return user;
    }
}

此时完成登录令牌的生成,以及信息储存,但是如果每个需要登录的模块都需要判断登录状态就很繁琐,因此考虑拦截器,但是由于实现类里面存在时效,因此需要刷新,而判断登录的拦截器只能拦截需要登录的功能,如果点击不需要登录的功能,时间一过,自动删除,判断为未登录,显然不合理,因此需要两个拦截器,一个用来判断token,并刷新时效,另一个判断是否登录,进行拦截

拦截器一

package com.hmdp.utils;


import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * @author 火云勰神
 * @date 2022-09-27 16:16
 * @description 拦截器
 */
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;
//因为当前拦截器并没有交给spring控制,所以需要提供构造器,不能通过注解注入
    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//        获取获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
//        HttpSession session = request.getSession();
//        基于token来获取redis中的用户
        String key = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//        Object user = session.getAttribute("user");
//        判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
//        将查询到的hash数据转为hashDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//        存在,保存用户信息到threadLocal
        UserHolder.saveUser(userDTO);
//        刷新token有效期
        stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//        放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//        移除用户
        UserHolder.removeUser();
    }
}

package com.hmdp.utils;


import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author 火云勰神
 * @date 2022-09-27 16:16
 * @description 拦截器
 */
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//        RefreshTokenInterceptor拦截器拦截所有的资源并生成token
//        经过R拦截器到本拦截,只需要判断ThreadLocal是否存在用户
        if (UserHolder.getUser() == null) {
//            没有,需要拦截
            response.setStatus(401);
//            拦截
        }
//      有用户,放行
        return true;
    }

}

功能二:商户查询缓存

redis项目-黑马点评 项目笔记_第3张图片

具体的实现流程

redis项目-黑马点评 项目笔记_第4张图片

店铺缓存:

​ 实现思想:查询店铺信息时,先从redis查询,如果不存在,再从数据库中查询,查到以后,先保存在redis当中再返回

 @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;

//        1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
//        2.判断是否存在
        if (!StrUtil.isBlank(shopJson)) {
//        3.存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
//        4,不存在,根据id查询数据库
        Shop shop = getById(id);
//          4.1 不存在,返回错误
        if (shop == null) {
            return Result.fail("店铺不存在");
        }
//          4.2  存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
//        5.返回
        return Result.ok(shop);
    }

分类列表缓存

@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryList() {
//        List shopTypes = stringRedisTemplate.opsForList();
        String key = "cache:typeList";
        //1.在redis中间查询
        List<String> shopTypeList =  stringRedisTemplate.opsForList().range(key,0,-1);
        //2.判断是否缓存中了
        //3.中了返回
        if(!shopTypeList.isEmpty()){
            List<ShopType> typeList = new ArrayList<>();
            for (String s:shopTypeList) {
                ShopType shopType = JSONUtil.toBean(s,ShopType.class);
                typeList.add(shopType);
            }
            return Result.ok(typeList);
        }
//        缓存中不存在,直接从数据库中查询并返回
        List<ShopType> typeList = query().orderByAsc("sort").list();
        //5.不存在直接返回错误
        if(typeList.isEmpty()){
            return Result.fail("不存在分类");
        }
        for(ShopType shopType : typeList){
            String s = JSONUtil.toJsonStr(shopType);
            shopTypeList.add(s);
        }
        //6.存在直接添加进缓存
        stringRedisTemplate.opsForList().rightPushAll(key, shopTypeList);
        return Result.ok(typeList);
    }
}

当前存在多个问题,如缓存的更新问题,缓存穿透,雪崩等问题,见下面处理方案

此时存在一个缺点,如果数据库中的信息发生修改,但是redis也存在相关信息,那么,查询到的数据是保存在缓存里面的旧数据,导致内容不一致,需要进行修改

redis项目-黑马点评 项目笔记_第5张图片
在这里插入图片描述

对于主动更新策略,目前最常见的存在三种

redis项目-黑马点评 项目笔记_第6张图片

redis项目-黑马点评 项目笔记_第7张图片

采取先更新再删除的策略

package com.hmdp.service.impl;

import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;

import java.util.concurrent.TimeUnit;

import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_TTL;

/**
 * 

* 服务实现类 *

* * @author 火云勰神 * @since 2021-12-22 */
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result queryById(Long id) { String key = CACHE_SHOP_KEY + id; // 1.从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (!StrUtil.isBlank(shopJson)) { // 3.存在,直接返回 Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 4,不存在,根据id查询数据库 Shop shop = getById(id); // 4.1 不存在,返回错误 if (shop == null) { return Result.fail("店铺不存在"); } // 4.2 存在,写入redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); // 5.返回 return Result.ok(shop); } // 更新方法 @Override @Transactional public Result updateShop(Shop shop) { Long id = shop.getId(); if (id == null) { return Result.fail("店铺id不能为空"); } // 1.更新数据库 updateById(shop); // 2.删除缓存 stringRedisTemplate.delete( CACHE_SHOP_KEY + id); return Result.ok(); } }

2.1 缓存穿透问题处理

缓存穿透实质客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会达到数据库

redis项目-黑马点评 项目笔记_第8张图片

数据不存在,数据库会返回空值,假如可以使用多线程访问,会导致数据库出问题

常见的两种解决方案:

  • 缓存空对象
    • 当请求对象不存在时,往缓存中存入null

    • redis项目-黑马点评 项目笔记_第9张图片

    • 优点:实现简单,维护方便

    • 缺点:

      • 消耗额外的内存,只要是空值就往里面存(设置时效)
      • 可能造成短期的不一致(假如真给数据库存了一条数据,但是redis当中是null,那就造成不一致)
  • 布隆过滤
    • redis项目-黑马点评 项目笔记_第10张图片

    • 添加一个布隆过滤器,如果数据不存在,那么直接拒绝

    • 布隆过滤器其实是一个算法,保存的是二进制数,但是并不一定准确,他拒绝的时候,数据一定不存在,他放行的时候,数据不一定存在

    • 优点

      • 内存占用少,没有多余的key
    • 缺点:

      • 实现复杂
      • 存在误判可能

缓存穿透的时间解决思路

redis项目-黑马点评 项目笔记_第11张图片

店铺查询方法

 public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;

//        1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
//        2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
//        3.存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
//        isNotBlank方法只有在存在字符串才返回true,即存在商铺信息
//        所以需要多加一条判断,是否为空值,如果不加,直接访问数据库
//       shopJson不等于null的情况就只剩下空字符串
//        当前情况为空字符串
        if (shopJson != null) {
//        返回个错误信息
            return Result.fail("店铺不存在");
        }
//        4,不存在,根据id查询数据库
        Shop shop = getById(id);
//          4.1 不存在,返回错误
        if (shop == null) {
//            将空值写入redis,并且限制存在时间更短
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
//            返回错误信息
            return Result.fail("店铺不存在");
        }
//          4.2  存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//        5.返回
        return Result.ok(shop);
    }

2.2 缓存雪崩问题处理

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

redis项目-黑马点评 项目笔记_第12张图片

在一瞬间大量的key值或者redis宕机失效未命中直接访问数据库

解决方案

  • 给不同的key的TTL 添加随机值
  • 微服务解决
    • 利用Redis集群提高服务的可用性
    • 给缓存业务添加降级限流策略
    • 给业务添加多级缓存

2.3 缓存击穿问题处理

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

redis项目-黑马点评 项目笔记_第13张图片

解决方案

  • 互斥锁
    • redis项目-黑马点评 项目笔记_第14张图片

    • 只让一个线程完成缓存创建

  • 逻辑过期
    • 既然是因为TTL过期导致缓存击穿问题,那么就不设置TTL,改为设置缓存过期,本身线程发现过期,上锁,开启新线程,写缓存,本身返回旧数据,当来了一个新线程以后,检测到逻辑过期会获取锁失败,那么直接返回旧数据,知道缓存写入新的缓存后,线程才能得到新的数据
    • redis项目-黑马点评 项目笔记_第15张图片

redis项目-黑马点评 项目笔记_第16张图片

具体解决方案

基于互斥锁的缓存击穿解决

redis项目-黑马点评 项目笔记_第17张图片

自动拆箱导致空指针: https://blog.csdn.net/weixin_38106322/article/details/109712597

package com.hmdp.service.impl;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;

import java.util.concurrent.TimeUnit;

import static com.hmdp.utils.RedisConstants.*;

/**
 * 

* 服务实现类 *

* * @author 火云勰神 * @since 2021-12-22 */
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result queryById(Long id) { // 缓存穿透 // Shop shop = queryWithPassThrough(id); // 互斥锁解决缓存击穿 Shop shop = queryWithMutex(id); if (shop == null) { return Result.fail("店铺不存在"); } // 返回 return Result.ok(shop); } // 查询互斥锁 public Shop queryWithMutex(Long id) { { String key = CACHE_SHOP_KEY + id; // 1.从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { // 3.存在,直接返回 return JSONUtil.toBean(shopJson, Shop.class); } // isNotBlank方法只有在存在字符串才返回true,即存在商铺信息 // 所以需要多加一条判断,是否为空值,如果不加,直接访问数据库 // shopJson不等于null的情况就只剩下空字符串 // 当前情况为空字符串 if (shopJson != null) { // 返回个错误信息 return null; } // 4.实现缓存重建 String lockKey = LOCK_SHOP_KEY + id; // 4.1 获取互斥锁 Shop shop = null; try { boolean isLock = tryLock(lockKey); // 4.2 判断是否获取成功 if (!isLock){ // 4.3 失败,则休眠 Thread.sleep(LOCK_SHOP_TTL); //递归调用 return queryWithMutex(id); } // 4.4 成功,根据id查询数据库 shop = getById(id); if (shop == null) { // 将空值写入redis,并且限制存在时间更短 stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回错误信息 return null; } // 写入redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { // 释放互斥锁 unLock(lockKey); } // 返回 return shop; } } 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); } // 更新方法 @Override @Transactional public Result updateShop(Shop shop) { Long id = shop.getId(); if (id == null) { return Result.fail("店铺id不能为空"); } // 1.更新数据库 updateById(shop); // 2.删除缓存 stringRedisTemplate.delete( CACHE_SHOP_KEY + id); return Result.ok(); } }

逻辑过期

redis项目-黑马点评 项目笔记_第18张图片

package com.hmdp.service.impl;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import static com.hmdp.utils.RedisConstants.*;

/**
 * 

* 服务实现类 *

* * @author 火云勰神 * @since 2021-12-22 */
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result queryById(Long id) { // 缓存穿透 // Shop shop = queryWithPassThrough(id); // 互斥锁解决缓存击穿 // Shop shop = queryWithMutex(id); // 逻辑过期解决缓存击穿 Shop shop = queryWithLogicalExpire(id); if (shop == null) { return Result.fail("店铺不存在"); } // 返回 return Result.ok(shop); } //线程池 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); // 逻辑过期解决缓存击穿 public Shop queryWithLogicalExpire(Long id){ String key = CACHE_SHOP_KEY + id; // 1.从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 当前情况是为空 if (StrUtil.isBlank(shopJson)) { // 返回null值 return null; } // 4. 命中需要判断过期时间,需要吧json反序列化为对象 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); // 因为RedisData类里面Data数据是object类型,反序列化以后并不知道具体是个什么类型的 // 为了方便以后还能缓存其他数据,使用JSONObject JSONObject data = (JSONObject) redisData.getData(); Shop shop = JSONUtil.toBean(data, Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); // 5. 判断是否过期 if (expireTime.isAfter(LocalDateTime.now())){ // 5.1 未过期 直接返回店铺信息 return shop; } // 5.2 已过期 需要缓存重建 // 6 缓存重建 String lockKey = LOCK_SHOP_KEY +id; boolean isLock = tryLock(lockKey); // 6.1 判断是否获取锁成功 if (isLock) { CACHE_REBUILD_EXECUTOR.submit(()->{ try { this.saveShopRedis(id,30L); } catch (Exception e) { throw new RuntimeException(e); } finally { // 释放锁 unLock(lockKey); } }); } // 6.2 成功 开启独立线程实现缓存重建 // 6.3返回过期的商品信息 return shop; } 查询互斥锁 // public Shop queryWithMutex(Long id) { // { // String key = CACHE_SHOP_KEY + id; // 1.从redis查询商铺缓存 // String shopJson = stringRedisTemplate.opsForValue().get(key); 2.判断是否存在 // if (StrUtil.isNotBlank(shopJson)) { 3.存在,直接返回 // return JSONUtil.toBean(shopJson, Shop.class); // } isNotBlank方法只有在存在字符串才返回true,即存在商铺信息 所以需要多加一条判断,是否为空值,如果不加,直接访问数据库 shopJson不等于null的情况就只剩下空字符串 当前情况为空字符串 // if (shopJson != null) { 返回个错误信息 // return null; // } 4.实现缓存重建 // String lockKey = LOCK_SHOP_KEY + id; 4.1 获取互斥锁 // Shop shop = null; // try { // boolean isLock = tryLock(lockKey); 4.2 判断是否获取成功 // if (!isLock){ // // 4.3 失败,则休眠 // Thread.sleep(LOCK_SHOP_TTL); // return queryWithMutex(id); // } 4.4 成功,根据id查询数据库 // shop = getById(id); // if (shop == null) { // // 将空值写入redis,并且限制存在时间更短 // stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES); // // 返回错误信息 // return null; // } 写入redis // stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); // } catch (InterruptedException e) { // throw new RuntimeException(e); // } finally { 释放互斥锁 // unLock(lockKey); // } 返回 // return shop; // } // } // public Shop queryWithPassThrough(Long id){ // String key = CACHE_SHOP_KEY + id; // 1.从redis查询商铺缓存 // String shopJson = stringRedisTemplate.opsForValue().get(key); 2.判断是否存在 // if (StrUtil.isNotBlank(shopJson)) { 3.存在,直接返回 // return JSONUtil.toBean(shopJson, Shop.class); // } isNotBlank方法只有在存在字符串才返回true,即存在商铺信息 所以需要多加一条判断,是否为空值,如果不加,直接访问数据库 shopJson不等于null的情况就只剩下空字符串 当前情况为空字符串 // if (shopJson != null) { 返回个错误信息 // return null; // } 4,不存在,根据id查询数据库 // Shop shop = getById(id); 4.1 不存在,返回错误 // if (shop == null) { 将空值写入redis,并且限制存在时间更短 // stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES); 返回错误信息 // return null; // } 4.2 存在,写入redis // stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); 5.返回 // return shop; // } 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); } // 向redis写入店铺数据,并写入逻辑过期时间 public void saveShopRedis(Long id,Long expireSeconds) { // 1.查询店铺数据 Shop shop = getById(id); // 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)); } // 更新方法 @Override @Transactional public Result updateShop(Shop shop) { Long id = shop.getId(); if (id == null) { return Result.fail("店铺id不能为空"); } // 1.更新数据库 updateById(shop); // 2.删除缓存 stringRedisTemplate.delete( CACHE_SHOP_KEY + id); return Result.ok(); } }

功能三:优惠券秒杀

3.1 全局id生成器

redis项目-黑马点评 项目笔记_第19张图片

package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

/**
 * @author 火云勰神
 * @date 2022-10-02 16:41
 * @description    全局id生成器
 */

@Component
public class RedisIdWorker {
//    2022.10.2 16:51对应的秒数
//    作为开始的时间戳
    private static final long BEGIN_TIMESTAMP = 1664729460L;
/*
* 序列号的位数
* */
    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 timesTemp = nowSecond - BEGIN_TIMESTAMP;
//        2.生成序列号
//        string实现自增长,并且是因为redis独立于数据库的,该数据唯一
//        获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//        3.拼接并返回
//        如果简单的拼接就会变成一个字符串,要保证让第一位为符号位 后面为时间戳,最后一个为序列号
//        那么就需要吧时间戳往左移动知道留出合适的位置
        return timesTemp << COUNT_BITS | count;
    }

}

3.2 秒杀券

redis项目-黑马点评 项目笔记_第20张图片

实现普通抢购

package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;

/**
 * 

* 服务实现类 *

* * @author 火云勰神 * @since 2021-12-22 */
@Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private ISeckillVoucherService iSeckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Override @Transactional public Result seckillVoucher(Long voucherId) { // 1.查询优惠券 SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId); // 2.判断秒杀是否开始 if ( voucher.getBeginTime().isAfter(LocalDateTime.now())){ // isAfter表示当前时间在开始之前 return Result.fail("秒杀尚未开始"); } // 3.判断秒杀是否已经结束 if (voucher.getEndTime().isBefore(LocalDateTime.now())){ // 结束 return Result.fail("秒杀已经结束"); } // 4.判断库存是否充足 if (voucher.getStock() < 1) { // 库存不足 return Result.fail("库存不足"); } // 5.扣减库存 boolean success = iSeckillVoucherService. 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.3 超卖问题

​ 超卖问题其实就是线程不安全,在高并发的条件下,不能保证所有线程一一执行,例如:当库存只剩下1的时候,期望的情况是:线程一判断库存,执行扣除操作,线程二判断发现库存为0,执行返回操作,但是在高并发的情况下,很有可能发生线程一查询完库存,线程二查询库存,发现都为1,两个线程都执行扣除操作,导致最终库存为-1

解决线程安全问题一般有两种,一种是悲观锁,一种是乐观锁,悲观锁认为一定会发生线程安全问题,一定要锁上,此时线程一定安全,但是效率很低,乐观锁则认为线程问题不一定发生,在对数据修改的时候才进行判断

redis项目-黑马点评 项目笔记_第21张图片

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种

  • 版本号法:即给数据加上一个版本字段,每次对于数据的操作都会让版本号加一,那么判断数据有没有被修改,其实也就是对版本号进行判断
    • redis项目-黑马点评 项目笔记_第22张图片

    • 在查询数据的时候,把版本号也查询出来,再执行扣减操作的时候,版本号也需要进行修改,并且sql语句存在条件是版本号是当前查询出来的版本号,如线程一,查询到的版本号是1,进行修改,版本号加一,而线程二,查询到的版本号为一,但是此时线程一已经让版本号发生修改,此时版本号为二,因此扣件失败

  • CAS法:直接使用库存来进行判断,对库存进行操作的时候,判断库存和查询出来的库存是否一致,如果一致,那么就可以进行删减,如果不一致,那么就不能够进行删减
    • redis项目-黑马点评 项目笔记_第23张图片

    • 但是此时存在问题,当线程一进行修改,库存减一后,其他现存很可能就无法进行操作,如:线程一查询到100个库存,还没进行操作,其他线程查询到100,线程一成功扣除一个,其他线程因为库存和查询出来的库存不相符而停止操作,导致很可能就只有几个线程能够正确执行,

    • 解决办法:并不需要锁太小心,只需要条件大于0即可

package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;

/**
 * 

* 服务实现类 *

* * @author 火云勰神 * @since 2021-12-22 */
@Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private ISeckillVoucherService iSeckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Override @Transactional public Result seckillVoucher(Long voucherId) { // 1.查询优惠券 SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId); // 2.判断秒杀是否开始 if ( voucher.getBeginTime().isAfter(LocalDateTime.now())){ // isAfter表示当前时间在开始之前 return Result.fail("秒杀尚未开始"); } // 3.判断秒杀是否已经结束 if (voucher.getEndTime().isBefore(LocalDateTime.now())){ // 结束 return Result.fail("秒杀已经结束"); } // 4.判断库存是否充足 if (voucher.getStock() < 1) { // 库存不足 return Result.fail("库存不足"); } // 5.扣减库存 boolean success = iSeckillVoucherService. 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("库存不足"); } // 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 一人一单

package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;

/**
 * 

* 服务实现类 *

* * @author 火云勰神 * @since 2021-12-22 */
@Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private ISeckillVoucherService iSeckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Override public Result seckillVoucher(Long voucherId) { // 1.查询优惠券 SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId); // 2.判断秒杀是否开始 if ( voucher.getBeginTime().isAfter(LocalDateTime.now())){ // isAfter表示当前时间在开始之前 return Result.fail("秒杀尚未开始"); } // 3.判断秒杀是否已经结束 if (voucher.getEndTime().isBefore(LocalDateTime.now())){ // 结束 return Result.fail("秒杀已经结束"); } // 4.判断库存是否充足 if (voucher.getStock() < 1) { // 库存不足 return Result.fail("库存不足"); } // 根据id上锁,如果把锁加在方法上,那么效率很低下 // 希望同一个用户过来用同一把锁 // 所以userId需要需要转成字符串 // 但是普通调用toString方法,也是每次new一个对象,也打不到预期效果 // 因此调用intern从字符串常量池拿到字符串,那么保证,相同id对应唯一字符串 // 但是如果加载方法体上,方法结束,锁释放,此时,spring还未提交事务, // 其他线程就能够进来更改事务,就会引发线程安全问题 // 因此把锁加在函数的外面 Long userId = UserHolder.getUser().getId(); synchronized(userId.toString().intern()) { // 因为事务的原理是底层通过动态代理完成的,但是此时直接调用,是目标对象调用 // 因此直接调用不生效,需要用代理类来进行调用 // 当时此时并不能拿到代理对象 // 首先得添加aspectjweaver依赖 // 其次需要在启动类上暴露代理对象 // @EnableAspectJAutoProxy(exposeProxy = true) IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); } } @Transactional public Result createVoucherOrder(Long voucherId) { // 5.一人一单 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 = iSeckillVoucherService. 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); // 8.返回订单id return Result.ok(orderId); } }

3.5 分布式锁

synchronized锁只能保证当前环境互斥,但是如果是分布式系统就没有办法保证互斥生效,所以此时就需要分布式锁

分布式锁

满足分布式系统或集群模式下多进程可见并且互斥的锁

redis项目-黑马点评 项目笔记_第24张图片

redis项目-黑马点评 项目笔记_第25张图片

分布式锁的实现流程

redis项目-黑马点评 项目笔记_第26张图片

package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

/**
 * @author 火云勰神
 * @date 2022-10-06 20:11
 * @description
 */
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) {
//        获取线程的标识
        long threadId = Thread.currentThread().getId();
//        获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.MINUTES);
//        防止拆箱的过程中,success为空,拆箱结果为空指针
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
//        释放锁 即删除锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

当前分布式锁存在一个问题,假如线程一获得锁之后,因为某种情况进入了阻塞状态,那么当锁过期以后,线程二就拿到锁,执行到一半,线程一开始唤醒执行,而此时线程二持有的锁就会被线程一释放掉,举个例子,自己拿钥匙开门,怎么都打不开,换了个大锤砸开了,但是发现砸开的不是自己家门

问题示例图:

redis项目-黑马点评 项目笔记_第27张图片

解决:

redis项目-黑马点评 项目笔记_第28张图片

并使用lua脚本保证原子性一致性

Redis使用同一个Lua解释器来执行所有命令,同时,Redis保证以一种原子性的方式来执行脚本:当lua脚本在执行的时候,不会有其他脚本和命令同时执行。从别的客户端的视角来看,一个lua脚本要么不可见,要么已经执行完。

package com.hmdp.utils;

import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;

import java.util.Collections;
import java.util.concurrent.TimeUnit;

/**
 * @author 火云勰神
 * @date 2022-10-06 20:11
 * @description
 */
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:";
//    线程表示的前缀
//    如果只用线程id,那两个jvm的id会冲突,如果拼上线程的名称
//    可以保证,相同线程获得的锁相同,不同线程获得的锁不相同
   private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
//        读取文件
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
//        指定返回类型
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

   @Override
    public boolean tryLock(long timeoutSec) {
//        获取线程的标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
//        获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.MINUTES);
//        防止拆箱的过程中,success为空,拆箱结果为空指针
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
//        调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
//                因为需要传入集合类型,使用工具类转化 KEYS[I] 即锁
                Collections.singletonList(KEY_PREFIX + name),
//                ARGS[I] 即线程标识
                ID_PREFIX + Thread.currentThread().getId()
                );
    }


//    @Override
//    public void unlock() {
        获取线程标识
//        String threadId = ID_PREFIX + Thread.currentThread().getId();
        获取redis锁中的标识
//        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        判断标识是否一致
//        if (threadId.equals(id)){
        释放锁 即删除锁
//            stringRedisTemplate.delete(KEY_PREFIX + name);
//        }
//    }
}

3.6 分布式锁的优化

redis项目-黑马点评 项目笔记_第29张图片

可重入锁

redis项目-黑马点评 项目笔记_第30张图片

应该采取hash类型来进行存储,因为string类型的值并不可以拆分,也不能重复

redis项目-黑马点评 项目笔记_第31张图片

线程标识 还有进入线程的次数

3.7 基于redis完成优惠券秒杀优化

redis项目-黑马点评 项目笔记_第32张图片

seckill.lua

---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by 火云勰神.
--- DateTime: 2022/10/14 15:13
---
--参数列表 优惠券id
local voucherId = ARGV[1]
--用户id
local userId = ARGV[2]

--2.数据KEY
--2.1 库存key
--lua脚本是使用..来进行拼接的
local stockKey = 'secKill:stock:' .. voucherId
--2.2 订单key
local orderKey = 'secKill:order:' .. voucherId

--3.脚本业务
--判断库存是否充足  取出 stockKey
if (tonumber(redis.call('get', stockKey)) <= 0) then
--    库存不足
    return 1
end

--判断用户是否下单
if (redis.call('sismember',orderKey,userId) == 1) then
--    存在  说明重复下单  返回2
    return 2
end

--扣减库存,下单,保存用户
--incrby就是加操作
redis.call('incrby',stockKey,-1)
--下单
redis.call('sadd',orderKey,userId)
return 0

变同步下单变为异步下单

首先利用redis完成库存余量、一人一单的判断,完成抢单业务

再讲下单业务放入阻塞队列,利用独立线程异步下单

package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 

* 服务实现类 *

* * @author 火云勰神 * @since 2021-12-22 */
@Service @Slf4j public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private ISeckillVoucherService iSeckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedissonClient redissonClient; private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript<>(); // 读取文件 SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); // 指定返回类型 SECKILL_SCRIPT.setResultType(Long.class); } // 阻塞队列 当一个线程尝试从队列获取元素的时候 // 如果没有元素,线程就会被阻塞,知道线程里面有元素,才会被唤醒 private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024); private static final ExecutorService secKill_order_executor = Executors.newSingleThreadExecutor(); // 利用spring的注解保证初始化完成就开始执行线程任务 @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) { // 因为并不是主线程来完成,所以通过threadlocal来取userid是取不到的 Long userId = voucherOrder.getUserId(); // 创建锁对象 RLock lock = redissonClient.getLock("lock:order:" + userId); // 获取锁 boolean isLocke = lock.tryLock(); if (!isLocke) { // 虽然redis已经做了并发判断 // 但是为了以防万一 log.error("不允许重复下单"); return; } try { // 获取代理对象 proxy.createVoucherOrder(voucherOrder); } finally { lock.unlock(); } } private IVoucherOrderService proxy; @Override public Result secKillVoucher(Long voucherId) { // 获取用户 Long id = UserHolder.getUser().getId(); // 1.执行lua脚本 Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), id.toString() ); // 2.判断结果是否为0 int r = result.intValue(); if (r != 0) { // 2.1 不为0 return Result.fail(r == 1 ? "库存不足":"不能重复下单"); } VoucherOrder voucherOrder = new VoucherOrder(); // 2.2 为0 有购买资格,把下单信息保存到阻塞队列 // 订单id long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); // 用户id voucherOrder.setUserId(id); // 代金券id voucherOrder.setVoucherId(voucherId); // 创建阻塞队列 orderTasks.add(voucherOrder); // 获取代理对象 proxy = (IVoucherOrderService) AopContext.currentProxy(); // 3 返回订单id return Result.ok(0); } // @Override // public Result secKillVoucher(Long voucherId) { 1.查询优惠券 // SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId); 2.判断秒杀是否开始 // if ( voucher.getBeginTime().isAfter(LocalDateTime.now())){ isAfter表示当前时间在开始之前 // return Result.fail("秒杀尚未开始"); // } 3.判断秒杀是否已经结束 // if (voucher.getEndTime().isBefore(LocalDateTime.now())){ 结束 // return Result.fail("秒杀已经结束"); // } 4.判断库存是否充足 // if (voucher.getStock() < 1) { 库存不足 // return Result.fail("库存不足"); // } 根据id上锁,如果把锁加在方法上,那么效率很低下 希望同一个用户过来用同一把锁 所以userId需要需要转成字符串 但是普通调用toString方法,也是每次new一个对象,也打不到预期效果 因此调用intern从字符串常量池拿到字符串,那么保证,相同id对应唯一字符串 但是如果加载方法体上,方法结束,锁释放,此时,spring还未提交事务, 其他线程就能够进来更改事务,就会引发线程安全问题 因此把锁加在函数的外面 // Long userId = UserHolder.getUser().getId(); 创建锁对象 SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate); // RLock lock = redissonClient.getLock("lock:order:" + userId); // // 获取锁 // boolean isLocke = lock.tryLock(); // if (!isLocke) { 获取锁失败 要么返回错误信息 要么重试 当前场景不允许重复下单 // return Result.fail("一个人只允许下一单"); // } // try { // IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // return proxy.createVoucherOrder(voucherId); // } finally { 手动释放锁 // lock.unlock(); // } // // } @Transactional public void createVoucherOrder(VoucherOrder voucherOrder) { // 5.一人一单 Long userId = voucherOrder.getUserId(); // 5.1 查询订单 int count = query().eq("user_id", userId). eq("voucher_id", voucherOrder.getVoucherId()).count(); // 5.2 判断是否存在 if (count > 0) { log.error("用户已经购买一次"); return; } // 6.扣减库存 boolean success = iSeckillVoucherService. 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; } save(voucherOrder); } }

功能四:达人探店

实现思路,当进行点赞的时候,给数据库的liked字段加1,当再次进行点赞时,liked字段减一,并且,如果需要对点赞的用户进行排序,set类型不能做到,需要使用scoreSet类型

package com.hmdp.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.BooleanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.mapper.BlogMapper;
import com.hmdp.service.IBlogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import static com.hmdp.utils.RedisConstants.BLOG_LIKED_KEY;

/**
 *
 * @author 火云勰神
 * @since 2021-12-22
 */
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private IUserService userService;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 查询用户
        records.forEach(blog -> {
            this.queryUser(blog);
            this.isBlogLiked(blog);
        });
        return Result.ok(records);
    }

    @Override
    public Result queryBlogById(Long id) {
//        查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("博客不存在");
        }
//        查询blog相关的用户
        queryUser(blog);
//        查询blog是否备点赞
        isBlogLiked(blog);
        return Result.ok(blog);
    }

    private void isBlogLiked(Blog blog) {
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            return;
        }
        Long userId = UserHolder.getUser().getId();
//        判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + blog.getId();
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        blog.setIsLike(score != null);
    }

    @Override
    public Result likeBlog(Long id) {
//        获取登录用户
        Long userId = UserHolder.getUser().getId();
//        判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + id;
//        score查到分数(如果存在能查到分数,如果不存在返回null)
//        在当前情况下的score是时间戳
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
//        因为变量是包装类,如果直接使用可能会有空指针异常
        if (score == null) {
            //        如果未点赞,可以点赞,数据库点赞+1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
//        保存用户到redis到set集合
//        set可以存储很多点赞的用户并且保证用户id唯一
//            但是set类型不能进行排序,所以采用socketSet
//            zadd key value score
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
            }
        }else {
//        如果已经点赞,取消点赞
//        4.1 数据库点赞数-1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
//        4.2 把用户从redis的set集合移除
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().remove(key,userId.toString());
            }
        }
        return Result.ok();
    }

    @Override
    public Result queryBlogByLikes(Long id) {
        String key = BLOG_LIKED_KEY + id;
//        查询top5的点赞用户  zrange key 0 - 4
        Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 5);
        if (top5 == null || top5.isEmpty()) {
            return Result.ok();
        }
//        解析出用户id
        List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
//        根据用户id查询用户
        List<UserDTO> userDTOS = userService.listByIds(ids)
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());

//        返回
        return Result.ok(userDTOS);
    }

    private void queryUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }


}

功能五:好友关注

5.1 点赞和获取点赞排行榜

点赞人数肯定不会只有一个,但是普通的set类型只能存储数据,并不能能够实现按照某个值来进行排序,所以采取zset,通过点赞时间来进行排序

  @Override
    public Result likeBlog(Long id) {
//        获取登录用户
        Long userId = UserHolder.getUser().getId();
//        判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + id;
//        score查到分数(如果存在能查到分数,如果不存在返回null)
//        在当前情况下的score是时间戳
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
//        因为变量是包装类,如果直接使用可能会有空指针异常
        if (score == null) {
            //        如果未点赞,可以点赞,数据库点赞+1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
//        保存用户到redis到set集合
//        set可以存储很多点赞的用户并且保证用户id唯一
//            但是set类型不能进行排序,所以采用socketSet
//            zadd key value score
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
            }
        }else {
//        如果已经点赞,取消点赞
//        4.1 数据库点赞数-1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
//        4.2 把用户从redis的set集合移除
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().remove(key,userId.toString());
            }
        }
        return Result.ok();
    }

    @Override
    public Result queryBlogByLikes(Long id) {
        String key = BLOG_LIKED_KEY + id;
//        查询top5的点赞用户  zrange key 0 - 4
        Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 5);
        if (top5 == null || top5.isEmpty()) {
            return Result.ok();
        }
//        解析出用户id
        List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
        String idStr = StrUtil.join(",", ids);
//        根据用户id查询用户
//        如果使用listByIds,因为数据库里面用in会导致查询数据的顺序与所给参数顺序不同
//        所以需要手动拼接
//        此处相当于 where id IN() ORDER BY FIELD
//        由于id后面的数据不能写死,而ids是一个集合,所以使用工具类进行拆分拼接然后拼如sql语句
        List<UserDTO> userDTOS = userService.query().in("id",ids)
                .last("ORDER BY FIELD(id,"+idStr+")").list()
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());

//        返回
        return Result.ok(userDTOS);
    }

    private void queryUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }

5.2 共同关注

共同关注的实现其实就是取得两个用户所关注的用户的交集,set集合可以实现

package com.hmdp.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Follow;
import com.hmdp.entity.User;
import com.hmdp.mapper.FollowMapper;
import com.hmdp.service.IFollowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;


/**
 *
 * @author 火云勰神
 * @since 2021-12-22
 */
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private IUserService userService;


//    关注/取关
    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
//          获取当前登录用户
//        当前id是被关注的id
        Long userId = UserHolder.getUser().getId();
        String key = "follows:" + userId;
        //        判断是关注还是取关
        if (isFollow){
//        关注  新增数据
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            boolean isSuccess = save(follow);
            if (isFollow) {
//                把关注用户的id放入redis的set集合
//                set类型可以求交集实现共同关注 sadd userId followUserId

                stringRedisTemplate.opsForSet().add(key,followUserId.toString());
            }
        }else {
//        取关  删除   delete from tb_follow where userId = ? and follow_user_id=?
            boolean isSuccess = remove(new QueryWrapper<Follow>()
                    .eq("user_id", userId).eq("follow_user_id", followUserId));
            if (isSuccess) {
//            移除,把关注的用户id从redis集合中移除
                stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
            }
        }
        return Result.ok();
    }

    @Override
    public Result isFollow(Long followUserId) {
        Long userId = UserHolder.getUser().getId();
//        1.查询是否关注
        Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
        return Result.ok(count > 0);
    }

    @Override
    public Result followCommons(Long id) {
//        获取当前登录用户
        Long userId = UserHolder.getUser().getId();
//        当前用户的key
        String key = "follows:" + userId;
//        目标用户的key
        String key2 = "follows:" + id;
//        求交集
        Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
        if (intersect == null || intersect.isEmpty()) {
            return Result.ok(Collections.emptyList());
        }
//        解析出id然后查询用户
        List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
//       查询用户
        List<UserDTO> users = userService.listByIds(ids)
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());
        return Result.ok(users);
    }
}

5.3 关注推送(Feed流)

redis项目-黑马点评 项目笔记_第33张图片

  • 拉模式,也叫做读扩散

    • 给每个用户准备一个发件箱,把自己的信息(摊点笔记、动态等)发送到发件箱中,当用户关注了其他人,就会给这个用户准备个收件箱,那其他人发件箱中的内容就会拉取到收件箱中,并且按照时间戳做一个排序
    • 优点:节省内存空间,收件箱是一次性的,读取完就不用了,下次再进行创建
    • 缺点:耗时很高
    • redis项目-黑马点评 项目笔记_第34张图片
  • 推模式。也叫做写扩散

    • 用户准备有收件箱,当关注的人发送消息之后,会直接推送到所有粉丝的收件箱中

    • redis项目-黑马点评 项目笔记_第35张图片

    • 缺点:内存占用比较高,一个消息需要写n份

  • 推拉结合模式,也叫做读写混合,间距推和啦两种模式

    • 根据粉丝的数量,以及粉丝的活跃程度来进行选择,当粉丝数量少,以及不太活跃的粉丝采用推模式,节省内存,当粉丝比较多并且推送给活跃粉丝的时候,采取拉模式
    • redis项目-黑马点评 项目笔记_第36张图片

完成推送功能,推送到收件箱

因为随时都有可能在更新博客,那么分页根据下标来分页就不能完成需求,需要采取滚动分页来完成

  @Override
    public Result saveBlog(Blog blog) {
        // 获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // 保存探店博文 把笔记发送给粉丝  推模式
        boolean isSave = save(blog);
//        只有成功才会去推送,否则就报错
        if (!isSave) {
            return Result.fail("新增失败");
        }
//        当前方法拿到的是登录用户,也就是写这篇笔记的用户
//        follow_userId是被关注的用户
//        查询粉丝 select * from tb_follow where follow_userId = userId
        List<Follow> follows = iFollowService.query()
                .eq("follow_user_id", user.getId())
                .list();
        for (Follow follow : follows) {
//            获取粉丝的id
//            follow表中,userId是关注的人,followId是被关注的人
            Long userId = follow.getUserId();
//            推送
            String key = "feeds:" + userId;
//            推送博客的id,不要整个博客都进行推送
            stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
        }

        // 返回id
        return Result.ok(blog.getId());
    }

在分页查找中,如果按照脚标来查询,就会发生混轮,如:0-3查到的是 1 2 3 ,4-5 查到的是 4 5,当时此时进来一个数据7 那么此时 0-3查到的数据为 7 1 2,而4-5查到的数据就是3 4,就会发生混乱,所以采取scoreSet的分数(score)来进行查询,如 6 5 4 3 2 1分数,上次查询了6 5 4,那么这次查询的只需要查询分数比4小的即可,滚动查询只需要关心开始以及数量

滚动分页查询:

  • max :
    • 第一次 当前时间戳
    • 不是第一次,上次查询的最小值
  • min 0
  • offset 偏移量
    • 第一次来0
    • 不是第一次来,为1,因为offset是包含关系,即小于等于当前值的数,但是不需要当前值
    • 如果上次查询结果中,与最小值一样的元素的个数,如:上次查询结果为 7 6 6,那么这次就需要跳过两个数字
  • count 固定值,每页的数量
    @Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
//        1.找到收件箱  即获取当前用户
        Long userId = UserHolder.getUser().getId();
//        2.查询收件箱 滚动分页查询  ZREVRANGRBYSCORE z1 MAX MIN WITHSCORES LIMIT OFFSET COUNT
        String key = FEED_KEY + userId;
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
//      非空判断
        if (typedTuples == null || typedTuples.isEmpty()) {
            return Result.ok();
        }
        //      3.解析数据:包含blogId,时间戳score,需要找到最小时间戳返回给前端
//        还有偏移量offset
        List<Long> ids = new ArrayList<>(typedTuples.size());
//        在外面定义一个最小变量,每次找到都对赋值,保证最小
        long minTime = 0;
        int os = 1;
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
//          key value类型   key就是id  value就是分数
            //            获取id
            ids.add(Long.valueOf(typedTuple.getValue()));
//            获取时间戳
            long time = typedTuple.getScore().longValue();
//            找到相同的时间戳,计算偏移量
            if (time == minTime){
                os++;
            }else {
                minTime = time;
                os = 1;
            }
        }
//        4.根据id查询blog
        String idStr = StrUtil.join(",", ids);
        List<Blog> blogs =query().in("id",ids)
                .last("ORDER BY FIELD(id,"+idStr+")").list();
//        5.封装并返回

        for (Blog blog : blogs) {
            //        查询blog相关的用户
            queryUser(blog);
//        查询blog是否被点赞
            isBlogLiked(blog);
        }

        ScrollResult r = new ScrollResult();
        r.setList(blogs);
        r.setOffset(os);
        r.setMinTime(minTime);
        return Result.ok(r);
    }

功能六:附近商户

geo数据结构的使用

GEOSEARCH BYLONLAT(地理坐标) x y BYRADIUS(半径) 10 WITHDISTANCE(距离)

默认单位是米,如果有WITHDISTANCE,那么返回结果会带上单位

    @Override
    public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
//       因为前段不一定按照距离来做排序,所以坐标有可能为空
//        1.判断是否需要根据坐标查询
        if (x == null || y == null) {
//            不需要坐标查询,按照数据库查询
       // 根据类型分页查询
        Page<Shop> page =query()
                .eq("type_id", typeId)
                .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        // 返回数据
        return Result.ok(page.getRecords());
        }
//        2.计算分页参数
//        开始
        int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
//        结束
        int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
//        3.查询redis,按照距离排序,分页
//        结果:shopId,distance
//        GEOSEARCH BYLONLAT(地理坐标) x y BYRADIUS(半径) 10 WITHDISTANCE(距离)
//        按类型存,按类型取出
        String key = SHOP_GEO_KEY+typeId;
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
                .search(
                        key,
                        GeoReference.fromCoordinate(x, y),
                        new Distance(5000),               //limit是限制范围,但是只能指定结束,都是从第一条开始到结束,只能对结果手动截取
                        RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));//默认单位为m,当前为5km,结果也是m
//        4.解析出id
       if (results == null) {
           return Result.ok();
       }
//       当前集合从0到end,需要手动截取        list.subList() 或者stream流
        List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
       List<Long> ids = new ArrayList<>();
        Map<String,Distance> distanceMap = new HashMap<>(list.size());
      if (list.size() <= from) {
//          没有下一页
          return Result.ok();
      }
       list.stream().skip(from).forEach(result -> {
//            参见test测试单元的存储过程
//            获取店铺id
            String shopId = result.getContent().getName();
//           需要把id转换为long类型的进行查询店铺信息
            ids.add(Long.valueOf(shopId));
            //            获取距离
            Distance distance = result.getDistance();
//            保证id与distance一一对应
           distanceMap.put(shopId,distance);
        });
//        5.根据id查询店铺
        String idStr = StrUtil.join(",", ids);
        List<Shop> shops = query().in("id", ids)
                .last("ORDER BY FIELD(id," + idStr + ")").list();//        6.返回
//       在实体类,distance是只属于实体类,用于返回给前端的字段
        for (Shop shop : shops) {
//            因为集合有存储,所以根据id来取出值,但是取出的是对象,需要调用getValue来转成相应的值
            shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
        }
        return Result.ok(shops);
    }

功能七:用户签到

7.1 BitMap的基本用法

把每一个bit为对应当月的每一天,形成映射关系,用0和1表示业务状态,这种思路就成为位图
redis项目-黑马点评 项目笔记_第37张图片

即用java代码实现一个类似上图的签到表,如果使用数据库签到,那么一天的签到量是位图占据内存的好多倍,所以不采取数据库表的形式来存储签到,布隆过滤器的底层也是使用位图来存储

redis项目-黑马点评 项目笔记_第38张图片

用法:

  • setbit:插入
    • setbit key 下标 0/1
  • getbit:获取
    • getbit key 下标
  • bitcount:统计数量,可以统计所以为1的字段的数量
    • bitcount key
  • bitfield:操作,查询,增加 删除
    • bitfield key 操作类型(get…) 返回类型(u无符号/i有符号) 结束位置 开始位置
    • bitfield bm1 get u2 0 表示从0位开始查询2位,并返回无符号的数字(返回结果为十进制)
    • 此时存入的二进制数为1110010001000000 通过bitfield 得到 1 1,即前两位,换为10进制结果为3
  • bitfield_ro 表示只读操作下的bitfield
  • bittop:将多个bitmap的结果做位运算(与、或、异或)
  • bitpos:查找bit数组中指定范围内第一个0或1出现的位置
    • bitpos key start end 不指定开始/结束的下标,那么说明从头到尾开始查
127.0.0.1:6379> setbit bm1 0 1
(integer) 0
127.0.0.1:6379> setbit bm1 1 1
(integer) 0
127.0.0.1:6379> setbit bm1 2 1
(integer) 0
127.0.0.1:6379> setbit bm1 5 1
(integer) 0
127.0.0.1:6379> setbit bm1 9 1
(integer) 0
127.0.0.1:6379> getbit bm1 3
(integer) 0
127.0.0.1:6379> getbit bm1 2
(integer) 1
127.0.0.1:6379> bitcount bm1
(integer) 5
127.0.0.1:6379> bitfield bm1 get u2 0
1) (integer) 3
127.0.0.1:6379> bitpos bm1 0 
(integer) 3
127.0.0.1:6379> bitpos bm1 1
(integer) 0

7.2 签到功能

redis项目-黑马点评 项目笔记_第39张图片

post请求表示新增,key值考虑是由用户+时间构成,想把每个用户每个月的签到情况做一个统计,而当前用户和当天的时间都是可以获取到的,所以不需要前端传入数据

接口:
@PostMapping("/sign")
    public Result sign() {
        return userService.sign();
    }    

实现类:
@Override
    public Result sign() {
//        1.获取当前登录用户
        Long userId = UserHolder.getUser().getId();
//        2.获取日期
        LocalDateTime now = LocalDateTime.now();
//        3.拼接key
//        格式化日期
        String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = USER_SIGN_KEY+userId+keySuffix;
//        4.获取今天是本月的第几天 但是redis中第一天是0,即比当前数据少1
        int dayOfMonth = now.getDayOfMonth();
//        5.写入redis
//        stringRedisTemplate是没有单独的bitmap方法,全部整合到字符串当中
        stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
        return Result.ok();
    }

当前时间为 2022/10/24

redis项目-黑马点评 项目笔记_第40张图片

7.3 签到统计

问题一:连续签到天数?

​ 从最后一次签到开始向前统计,知道遇到第一次未签到的位置,计算总的签到次数,就是连续签到天数

在这里插入图片描述

问题二:如何得到本月到今天位置的所有签到数据?

​ bitfield:bitfield key 操作类型(get…) 返回类型(u无符号/i有符号) 结束位置 开始位置

需要确定的参数,开始位置和结束位置,所以最终的命令应该为

bitfield key get u(dayofMonth)0

dayofMonth表示今天是本月的第几天,并且bitfield中,今天是第几天就需要多啊少个比特位

问题三:如何从后向前遍历每个bit位?

与1做与运算,就能得到最后一个bit位,随后右移一位,最后一个数字会越界被舍弃,所以下一个bit为就成为了最后一个bit为

1 0 1 1 1         右移一位						1 0 1 1  越界舍弃  1

​	    1     -----------------------> 		          1       

———————————        			                  ——————————————————————  					                     

​	    1                                             1           1

redis项目-黑马点评 项目笔记_第41张图片

 @Override
    public Result signCount() {
        //        1.获取当前登录用户
        Long userId = UserHolder.getUser().getId();
//        2.获取日期
        LocalDateTime now = LocalDateTime.now();
//        3.拼接key
//        格式化日期
        String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = USER_SIGN_KEY+userId+keySuffix;
//        4.获取今天是本月的第几天 但是redis中第一天是0,即比当前数据少1
        int dayOfMonth = now.getDayOfMonth();
//        5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制数字
//        bitfield sign:1010:202210 get u24 0
        List<Long> result = stringRedisTemplate.opsForValue()
                .bitField(
                        key, BitFieldSubCommands.create() //bitField需要两个参数,一个是key,另一个事子命令,通过当前方法创建
                                //指定为get,并且返回参数无符号
                                .get(
                                        BitFieldSubCommands
                                                .BitFieldType
                                                .unsigned(dayOfMonth))
                                .valueAt(0));
//        非空判断
        if (result == null || result.isEmpty()) {
            return Result.ok();
        }
//        因为当前只传了一个get,所以,集合里面只有一个元素
        Long num = result.get(0);
        if (num == null || num == 0) {
            return Result.ok();
        }
//        6.循环遍历
        int count = 0;  //计数器
        while (true) {
//        6.1 让这个数字月1做与运算的刀片数字的最后一个bit位
//        6.2 判断这个bit位是否为0
           if ((num & 1) == 0) {
               //        6.3 如果为0,说明未签到
               break;
           }else {
//        6.4 如果不为0,说明已签到,计算器+1
                count++;
//        6.5 把数字右移一位,抛弃最后一位数字
               num >>>=1;
           }
        }
        return Result.ok(count);
   }

功能八:uv统计

8.1 HyperLogLog用法

两个重要概念:

  • uv:全称Unique Visitor,独立访客量,实质通过互联网访问、浏览这个网页的自然人,一天内同一个用户多次访问该网站,只记录一次
  • pv:全称Page View,也叫做页面访问量或点击量,用户每访问网站的一个页面,记录一次pv,用户多次打开页面,则记录多次pv,往往用来衡量网站的流量

假如每次来访客,都直接写入redis,如果用户量达到千万甚至更多,那么将会占据很大很大的一部分内存,所以诞生了HyperLogLog

HyperLogLog,是从Loglog算法派生来的概率算法,用来确定非常大的集合的技术,而不需要存储其所有值链接:https://juejin.cn/post/6844903785744056333#heading-0

redis的HLL是基于string实现的,并且可以保证单个HLL的内存永远小于16kb,但是存在误差,因为是根据算法计算出来的,大概0.81的准确率

redis项目-黑马点评 项目笔记_第42张图片

重复的数据并不会影响结果,适用于唯一性统计

8.2 实现uv统计

        String []values = new String[1000];
        int j = 0;
        for (int i = 0; i < 1000000; i++) {
//            保证数据脚标不会超过999
            j = i % 1000;
            values[j] = "user_" + i;
            if (j == 999) {
//                发送到redis
                stringRedisTemplate.opsForHyperLogLog().add("hl2",values);
            }
        }
//        统计数量
        Long hl2 = stringRedisTemplate.opsForHyperLogLog().size("hl2");
        System.out.println("hl2 = " + hl2);

    }


当前笔记仅为黑马点评项目的学习笔记,图片来源于黑马ppt,如有侵权,联系我删除
如果内容描述或者知识点有误,欢迎指出
学习传送门
gitee传送门

你可能感兴趣的:(redis,java,数据库,缓存,1024程序员节)