Redis

Redis从入门到实战

1.什么是Redis

Redis是一个基于内存的NoSQL数据库

特征:

  • 键(key)—值(value)型 key是一个string类型,value支持多种数据类型(常见的是string,hash,list,set,zset)
  • 单线程,每个命令具备原子性
  • 低延迟,速度快(基于内存,IO多路复用,基于C语言的良好的编码)
  • 支持数据持久化
  • 指出主从集群,分片集群
  • 支持多语言客户端

2.Redis命令

2.1Redis通用命令

  • keys:查看符合模板的所有key
  • del:删除一个指定的key
  • exists:判断key是否存在
  • expire:给key设定一个有效期,有效期到期key自动删除
  • TTL:查看一个key的剩余有效期

具体以上命令怎么用,可以查看帮助文档,例如查看删除key的用法

help del;

2.2String

image-20231201183738738

2.3Hash

Hash这种结构的value也是一个hash结构(filed-value)

image-20231201183844031

2.4List

List结构跟Java种的LinkedList相似,可以看作是一个双向链表的结构,既支持正向检索,也支持反向

  • 有序
  • 可重复
  • 插入删除操作快
  • 查询速度一般

image-20231201184246002

2.5Set

Redis的Set结构与Java种的HashSet类似,可以看做是一个value为null的HashMap。因为也是一个hash表,具备HashSet类似的特征

  • 无序
  • 不可重复
  • 查找快
  • 支持交际,并集,差集等功能

image-20231201184527360

2.6Zset

Redis的SortedSet(Zset)是一个可排序的set集合,与Java种的TreeSet有些类似,但底层数据结构却差别很大。SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素进行排序,底层由跳表(SkipList)和Hash表实现

  • 可排序
  • 不可重复
  • 查询速度快

由于Zset可以进行排序,常用来做排行榜这种业务需求

3.Redis的Java客户端

我们主要学Spring提供的Redis的Java客户端

image-20231201185647331

image-20231201185832349

Spring提供了一个stringRedisTemplate,它的key和value默认为String类型,当需要存储Java对象的时候,需要手动完成对象的序列化和反序列化

4.实战-短信登录

4.1基于Session实现登录流程

发送验证码:

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

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

image-20231202155711915

短信验证码登录、注册:

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

image-20231202155803581

校验登录状态:

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

image-20231202155817894

image-20231202171056455

4.2实现发送短信验证码

请求方式请求路径请求参数返回值
POST/user/codephone

UserServiceImpl中具体代码如下

package com.hmdp.service.impl;

import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.User;
import com.hmdp.mapper.UserMapper;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RegexUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpSession;

/**
 * <p>
 * 服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

     @Override
     public Result sendCode(String phone, HttpSession session) {
          //校验手机号
          if(RegexUtils.isPhoneInvalid(phone)){
               //不符合返回
               return Result.fail("手机号错误");
          }
          //符合生成验证码
          String code = RandomUtil.randomNumbers(6);
          //保存验证码到session
          session.setAttribute("code",code);
          //发送验证码
          //由于验证码的发送要基于第三方服务,所以我们这里仅作日志打印
          log.debug("验证码发送成功:" + code);
          //返回结果
          return Result.ok();
     }
}

4.3实现短信验证码登录、注册

请求方式请求路径请求参数返回值
POST/user/loginJson风格的密码和验证码

UserServiceImpl中新增具体代码如下

@Override
     public Result login(LoginFormDTO loginForm, HttpSession session) {
          //校验手机号
          String phone = loginForm.getPhone();
          if(RegexUtils.isPhoneInvalid(phone)){
               //不符合返回
               return Result.fail("手机号错误");
          }
          //校验验证码
          String code = loginForm.getCode();
          Object sessionCode = session.getAttribute("code");
          if(sessionCode==null || !sessionCode.toString().equals(code)){
               return Result.fail("验证码错误");
          }
          //根据手机号查询用户
          LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          lambdaQueryWrapper.eq(User::getPhone,phone);
          User user = getOne(lambdaQueryWrapper);
          //不存在,创建并保存到数据库
          if(ObjectUtils.isEmpty(user)){
               user = createUserWithPhone(phone);
          }
          //存在,保存用户到session
          session.setAttribute("user",user);
          //返回
          return Result.ok();
     }

     private User createUserWithPhone(String phone) {
          //创建用户
          User user = new User();
          user.setPhone(phone);
          user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
          //保存至数据库
          save(user);
          return user;
     }
}

4.4登录校验

我们完成发送短信,以及短信验证码的注册登录两个接口后,依然无法正常访问页面,原因是没有进行登录状态的校验

Cookie 实际上是一小段的文本信息,浏览器请求服务器,如果服务器需要记录该用户状态,就使用response 向浏览器颁发一个 Cookie ,浏览器会把Cookie保存起来

Session 创建于服务器端,「保存于服务器」,维护于服务器,每创建一个新的 Session,服务器端都会分配一个唯一的 ID,并且把这个 ID 保存到浏览器的 Cookie 中,保存形式是以 「sessionID」 来保存的。浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上,这就是session。浏览器再次访问时只需要从该 session 中查找该客户的状态就可以了。每个用户访问服务器都会建立一个session,那服务器是怎么标识用户的唯一身份呢?事实上,用户与服务器建立连接的同时,服务器会自动为其分配一个SessionId

但并不是所有的controller我们都要实现登陆状态的校验,我们可以定义一个拦截器,然后对一些不需要拦截的接口放行,拦截到的用户信息保存到ThreadLocal(因为每一个请求都是一个线程,ThreadLocal会把每一个请求过来的用户开辟一个线程空间来保存对应的用户)中

第一步:编写登录校验拦截器

package com.hmdp.utils;

import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Objects;

public class LoginInterceptor implements HandlerInterceptor {
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
          //前置拦截,controller之前,做登录校验
          //获取session
          HttpSession session = request.getSession();
          //获取session中的用户
          User user = (User)session.getAttribute("user");
          //判断用户是否存在
          if(Objects.isNull(user)){
               //不存在,拦截
               response.setStatus(401);
               return false;
          }
          UserDTO userDTO = new UserDTO();
          userDTO.setId(user.getId());
          userDTO.setNickName(user.getNickName());
          userDTO.setIcon(user.getIcon());
          //存在,保存在ThreadLocal中
          UserHolder.saveUser(userDTO);
          return true;
     }

     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
          //视图渲染之后,返回给用户之前,销毁用户信息
          UserHolder.removeUser();
     }
}

第二步:添加拦截器,使其生效

package com.hmdp.config;

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

@Configuration
public class MvcConfig implements WebMvcConfigurer {
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
          registry.addInterceptor(new LoginInterceptor())
                  .excludePathPatterns(
                          "/blog/hot",
                          "/voucher/**",
                          "/upload/**",
                          "/shop/**",
                          "/shop-type/**",
                          "/user/code",
                          "/user/login"
                  );
     }
}

第三步:拦截器的任务完成后,请求会到达controller,我们下面编写controller层,(获取当前登录的用户并返回)

请求方式请求路径请求参数返回值
Get/user/me
@GetMapping("/me")
    public Result me(){
        //获取当前登录的用户并返回
        return Result.ok(UserHolder.getUser());
    }

4.5接口优化

我们在me接口中,会返回很多关于用户的敏感信息,比如密码和手机号等,我们需要封装一下。起源是在我们在存入session的时候,就是存入的User对象,而这个User对象包含了用户的全部信息,我们创建一个UserDto来存入session

image-20231202184221372

4.6集群session共享问题

后期会tomcat做水平扩展,多台tomcat并不共享session存储空间,当请求切换到不同的tomcat服务时导致数据丢失问题

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

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

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

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

综上共享session的方案应该满足

  • 数据共享
  • 内存存储
  • key-value存储

我们自然联想到了redis

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

image-20231202190718007

image-20231202194535194

image-20231202194454829

4.7基于Redis实现短信登录

第一步:发送验证码代码修改

@Override
     public Result sendCode(String phone, HttpSession session) {
          //校验手机号
          if(RegexUtils.isPhoneInvalid(phone)){
               //不符合返回
               return Result.fail("手机号错误");
          }
          //符合生成验证码
          String code = RandomUtil.randomNumbers(6);
          //保存验证码到redis当中,key为login:code:phone,value为验证码,并设置有效期为2分钟,防止恶意刷验证码
          stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone,code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
          //发送验证码
          //由于验证码的发送要基于第三方服务,所以我们这里仅作日志打印
          log.debug("验证码发送成功:" + code);
          //返回结果
          return Result.ok();
     }

第二步:登录代码修改

@Override
     public Result login(LoginFormDTO loginForm, HttpSession session) {
          //校验手机号
          String phone = loginForm.getPhone();
          if(RegexUtils.isPhoneInvalid(phone)){
               //不符合返回
               return Result.fail("手机号错误");
          }
          //校验验证码
          String code = loginForm.getCode();
          //不能从session中读了,应该改为从redis中读取验证码
          String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
          if(cacheCode==null || !cacheCode.equals(code)){
               return Result.fail("验证码错误");
          }
          //根据手机号查询用户
          LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          lambdaQueryWrapper.eq(User::getPhone,phone);
          User user = getOne(lambdaQueryWrapper);
          //不存在,创建并保存到数据库
          if(Objects.isNull(user)){
               user = createUserWithPhone(phone);
          }
          //存在,保存用户到redis
          //转为UserDto
          UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
          //UUID生成随机字符串作为token,为redis的key,hash结构的userDto为value
          String token = UUID.randomUUID().toString(true);
          //将userDto转为map类型,Long类型的id必须转为string类型
          Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
                  CopyOptions.create()
                          .setIgnoreNullValue(true)
                          .setFieldValueEditor((fileName,fieldValue) -> fieldValue.toString())
                  );
          stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_TOKEN_KEY + token,userMap);
          //设置token有效期为30分钟
          stringRedisTemplate.expire(RedisConstants.LOGIN_TOKEN_KEY + token,RedisConstants.LOGIN_TOKEN_TTL,TimeUnit.MINUTES);
          //返回,要携带token,前端login接口拿到会存到sessionStorage中,后续/me会携带token访问
          return Result.ok(token);
     }

第三步:校验代码修改

package com.hmdp.utils;

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

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

public class LoginInterceptor implements HandlerInterceptor {

     //由于这个类是我们自己创建的拦截器,不是由spring进行管理的,所以不能使用注解去注入redisTemplate,可以通过构造函数的方式去注入
     private StringRedisTemplate stringRedisTemplate;
     public LoginInterceptor(StringRedisTemplate stringRedisTemplate){
          this.stringRedisTemplate = stringRedisTemplate;
     }
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
          //前置拦截,controller之前,做登录校验
          //获取请求头中的token
          String token = request.getHeader("authorization");
          if(StrUtil.isBlank(token)){
               //拦截
               response.setStatus(401);
               return false;
          }
          //基于token获取redis中的对象
          Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_TOKEN_KEY + token);
          if(userMap.isEmpty()){
               //拦截
               response.setStatus(401);
               return false;
          }
          UserDTO userDto = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
          //存在,保存在ThreadLocal中
          UserHolder.saveUser(userDto);
          //刷新token有效期
          stringRedisTemplate.expire(RedisConstants.LOGIN_TOKEN_KEY,RedisConstants.LOGIN_TOKEN_TTL, TimeUnit.MINUTES);
          return true;
     }

     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
          //视图渲染之后,返回给用户之前,销毁用户信息
          UserHolder.removeUser();
     }
}

4.8思考

问题

我们上面已经基本实现了短信登录功能,但还有一个小问题没有解决

image-20231203093549677

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

解决

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

image-20231203093827671

代码实现

增加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;

public class RefreshTokenInterceptor implements HandlerInterceptor {

     //由于这个类是我们自己创建的拦截器,不是由spring进行管理的,所以不能使用注解去注入redisTemplate,可以通过构造函数的方式去注入
     private StringRedisTemplate stringRedisTemplate;

     public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate){
          this.stringRedisTemplate = stringRedisTemplate;
     }
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
          //前置拦截,controller之前,做登录校验
          //获取请求头中的token
          String token = request.getHeader("authorization");
          if(StrUtil.isBlank(token)){
               //为空或不为空,都放行,因为可能拦截到的是不需要登录就可以访问的接口
               return true;
          }
          //基于token获取redis中的对象
          Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_TOKEN_KEY + token);
          if(userMap.isEmpty()){
               //为空或不为空,都放行,因为可能拦截到的是不需要登录就可以访问的接口
               return true;
          }
          UserDTO userDto = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
          //存在,保存在ThreadLocal中
          UserHolder.saveUser(userDto);
          //刷新token有效期
          stringRedisTemplate.expire(RedisConstants.LOGIN_TOKEN_KEY + token,RedisConstants.LOGIN_TOKEN_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 com.hmdp.dto.UserDTO;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Objects;


public class LoginInterceptor implements HandlerInterceptor {

     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
          UserDTO userDto = UserHolder.getUser();
          if(Objects.isNull(userDto)){
               //拦截
               response.setStatus(401);
               return false;
          }
          return true;
     }
}

第三步:配置拦截器

package com.hmdp.config;

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

@Configuration
public class MvcConfig implements WebMvcConfigurer {

     //这个类加了@Configuration,是由spring管理,我们可以从这里注入,然后传入构造函数
     @Autowired
     private StringRedisTemplate stringRedisTemplate;

     @Override
     public void addInterceptors(InterceptorRegistry registry) {
          registry.addInterceptor(new LoginInterceptor())
                  .excludePathPatterns(
                          "/blog/hot",
                          "/voucher/**",
                          "/upload/**",
                          "/shop/**",
                          "/shop-type/**",
                          "/user/code",
                          "/user/login"
                  ).order(1);
          //把新增的拦截器也加入进来,并且设置其拦截优先级高于登录拦截器,order值越低优先级越高
          registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
     }
}

4.9退出登录

请求方式请求路径请求参数返回值
POST/user/logout

5.实战-商户查询缓存

5.1缓存

缓存就是数据交换的缓冲区,是存储数据的临时地方,读写性能高。在web开发的每一层都可以做缓存,如下图

image-20231203105550368

引入缓存的会带来很多好处,但同时也会增加一些成本

image-20231203105855995

5.2添加商户缓存

shop-type/list这个接口是直接查询数据库,我们接下来要做的是添加缓存,方案如下

image-20231203110523835

第一步:ShopController层修改

    /**
     * 根据id查询商铺信息
     * @param id 商铺id
     * @return 商铺详情数据
     */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        return shopService.queryById(id);
    }

第二步:ShopServiceImpl实现service层未实现的方法,主要新增redis缓存相关逻辑

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 com.hmdp.utils.RedisConstants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

     @Autowired
     private StringRedisTemplate stringRedisTemplate;

     @Override
     public Result queryById(Long id) {
          //从redis中查询商户
          String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
          if(StrUtil.isNotBlank(shopJson)){
               //redis中有数据,直接返回
               Shop shop = JSONUtil.toBean(shopJson, Shop.class);
               return Result.ok(shop);
          }
          //没有查询数据库
          Shop shop = getById(id);
          if(Objects.isNull(shop)){
               //数据库中不存在,就失败
               return Result.fail("商铺不存在");
          }
          //数据库中存在,先同步至redis
          stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop));
          return Result.ok(shop);
     }
}

5.3添加商品类型缓存

第一步:ShopTypeController层

package com.hmdp.controller;


import com.hmdp.dto.Result;
import com.hmdp.entity.ShopType;
import com.hmdp.service.IShopTypeService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
    @Resource
    private IShopTypeService typeService;

    @GetMapping("list")
    public Result queryTypeList() {
        return typeService.queryTypeList();
    }
}

第二步:ShopTypeServiceImpl中实现方法

package com.hmdp.service.impl;


import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.hmdp.dto.Result;
import com.hmdp.entity.ShopType;
import com.hmdp.mapper.ShopTypeMapper;
import com.hmdp.service.IShopTypeService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisConstants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

     @Autowired
     private StringRedisTemplate stringRedisTemplate;
     @Override
     public Result queryTypeList() {
          //先从redis中查,range(xx,0,-1)可以查询索引第一个到索引倒数第一个(即所有数据)
          List<String> shopTypeJsonList = stringRedisTemplate.opsForList().range(RedisConstants.CACHE_SHOP_TYPE_KEY, 0, -1);
          //判断是否有该缓存
          if(CollectionUtils.isNotEmpty(shopTypeJsonList)){
               //如果有缓存信息,那么就封装返回
               List<ShopType> shopTypeList = shopTypeJsonList.stream()
                       .map(new Function<String, ShopType>() {
                            @Override
                            public ShopType apply(String s) {
                                 ShopType shopType = JSONUtil.toBean(s, ShopType.class);
                                 return shopType;
                            }
                       })
                       .collect(Collectors.toList());
               return Result.ok(shopTypeList);
          }
          //如果没有缓存就从数据库中查
          List<ShopType> shopTypeList = query().orderByAsc("sort").list();
          //如果数据库中不存在就返回失败
          if(shopTypeList==null || shopTypeList.isEmpty()){
               return Result.fail("商品类型不存在");
          }
          //写入redis
          List<String> JsonList = shopTypeList.stream()
                  .map(new Function<ShopType, String>() {
                       @Override
                       public String apply(ShopType shopType) {
                            return JSONUtil.toJsonStr(shopType);
                       }
                  }).collect(Collectors.toList());
          stringRedisTemplate.opsForList().rightPushAll(RedisConstants.CACHE_SHOP_TYPE_KEY,JsonList);
          //返回
          return Result.ok(shopTypeList);
     }
}

5.4缓存更新策略

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

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存 该方案胜出
  • 如何保证缓存与数据库的操作的同时成功或失败?

    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案

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

  • 先操作缓存还是先操作数据库?
    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存 该方案胜出

5.5实现商铺缓存一致

由上节我们讨论得出的结论,我们实现思路如下

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

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

第一步:修改ShopServiceImpl中queryById的方法,主要修改其中的一行代码,设置缓存超时时间

//数据库中存在,同步至redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

第二步:更新业务先修改数据库,再删除缓存

ShopController层

  @PutMapping
    public Result updateShop(@RequestBody Shop shop) {
        return shopService.update(shop);
    }

ShopServiceImpl实现update方法

 @Override
     //两步操作,要保证原子行,若出现异常,事务回滚
     @Transactional
     public Result update(Shop shop) {
          //店铺id不能为空
          Long id = shop.getId();
          if(id==null){
               return Result.fail("店铺id不能为空");
          }
          //先更新数据库,再删除redis缓存
          updateById(shop);
          stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
          return Result.ok();
     }

5.6缓存穿透

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

常见的解决方案有两种:

  • 缓存空对象

    • 优点:实现简单,维护方便
    • 缺点:
      • 额外的内存消耗
      • 可能造成短期的不一致 该方案最为常用
  • 布隆过滤

    • 优点:内存占用较少,没有多余key
    • 缺点:
      • 实现复杂
      • 存在误判可能

下面我们解决商铺查询中的缓存穿透问题

image-20231203180003544

修改ShopServiceImpl的queryById方法逻辑更改如下

 @Override
     public Result queryById(Long id) {
          //从redis中查询商户
          String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
          if(StrUtil.isNotBlank(shopJson)){
               //redis中有数据,直接返回
               Shop shop = JSONUtil.toBean(shopJson, Shop.class);
               return Result.ok(shop);
          }
          //上面已经判断过有值的情况,这里只需要判断!=null,就一定是空字符串了
          if(shopJson!=null){
               return Result.fail("商铺不存在");
          }
          //没有查询数据库
          Shop shop = getById(id);
          if(Objects.isNull(shop)){
               //数据库中不存在,将空值写入redis
               stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
               return Result.fail("商铺不存在");
          }
          //数据库中存在,先同步至redis
          stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop));
          return Result.ok(shop);
     }

5.7缓存雪崩

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

解决方案:

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

5.8缓存击穿

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

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

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

image-20231203195205764

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

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

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

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

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

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

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

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

代码实现根据id查询商铺接口的缓存击穿问题

由于redis命令中,setnx可以在key已经存在时,禁止以相同的key去存放键值对,很好的起到了"锁"的效果

第一步:编写获取锁和释放锁逻辑

     //获取锁
     private boolean tryLock(String key){
          Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
          return BooleanUtil.isFalse(aBoolean);
     }

     //释放锁
     private boolean unlock(String key){
          Boolean aBoolean = stringRedisTemplate.delete(key);
          return BooleanUtil.isFalse(aBoolean);
     }

第二步:将之前的缓存穿透的代码封装起来

//缓存穿透
     private Shop queryWithPassThrough(Long id){
          //从redis中查询商户
          String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
          if(StrUtil.isNotBlank(shopJson)){
               //redis中有数据,直接返回
               Shop shop = JSONUtil.toBean(shopJson, Shop.class);
               return shop;
          }
          //上面已经判断过有值的情况,这里只需要判断!=null,就一定是空字符串了
          if(shopJson!=null){
               return null;
          }
          //没有查询数据库
          Shop shop = getById(id);
          if(Objects.isNull(shop)){
               //数据库中不存在,将空值写入redis
               stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
               return null;
          }
          //数据库中存在,先同步至redis
          stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop));
          return shop;
     }

第三步:互斥锁解决缓存击穿问题

image-20231203202407930

private Shop queryWithMutex(Long id) {
          //从redis中查询商户
          String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
          if(StrUtil.isNotBlank(shopJson)){
               //redis中有数据,直接返回
               Shop shop = JSONUtil.toBean(shopJson, Shop.class);
               return shop;
          }
          //上面已经判断过有值的情况,这里只需要判断!=null,就一定是空字符串了
          if(shopJson!=null){
               return null;
          }
          //到这里,缓存没命中,我们需要进行缓存重建
          //1.获取互斥锁
          String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
          Shop shop = null;
          try {
               boolean isLock = tryLock(lockKey);
               //2.判断是否获取成功
               if (!isLock) {
                    //3.互斥锁获取失败就休眠并重试
                    Thread.sleep(50);
                    queryWithMutex(id);
               }
               //4.成功,根据id查询查询数据库
                shop = getById(id);
               if (Objects.isNull(shop)) {
                    //数据库中不存在,将空值写入redis
                    stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                    return null;
               }
               //数据库中存在,先同步至redis
               stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
          }catch (Exception e){
               throw new RuntimeException(e);
          }finally {
               //释放互斥锁
               unlock(lockKey);
          }
          //返回
          return shop;
     }

6.优惠券秒杀

6.1全局唯一id

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

  • id规律太明显,比如用户很容易就猜出某个时间间隔内系统有多少订单
  • 受单表数据量的限制,mysql单张表最多支持500W个数据,数据量超过这个值后,就要分表存储,这样每个表的id就不能保证唯一

全局ID生成器应该满足如下的特性:

唯一性:Redis的String类型的数据结构有一个INCR命令可以确保唯一,因为Redis是独立于数据库之外的只有一个

高可用:Redis的集群方案,主从方案,哨兵方案可以实现高可用

高性能:Redis就是以高性能著称的

递增性:Redis也是采用自增方案,可以保证自增

安全性:可以使用符号位(1位)+时间戳位(31位)+自增位(32位)拼接成以一个安全性较高的id

image-20231204093341829

代码实现Redis全局唯一id

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;

@Component
public class RedisIdWork {
     /**
      * 开始时间戳2022年1月1日
      */
     private static final long BEGIN_TIMESTAMP = 1640995200L;

     /**
      * 序列号的位数
      */
     private static final int COUNT_BITS = 32;

     private StringRedisTemplate stringRedisTemplate;

     //构造方法注入
     public RedisIdWork(StringRedisTemplate stringRedisTemplate){
          this.stringRedisTemplate = stringRedisTemplate;
     }

     public long nextId(String keyPrefix){
          //1.生成时间戳,根据的是UTC(协调世界时间)
          LocalDateTime now = LocalDateTime.now();
          long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
          long timeStamp = nowSecond - BEGIN_TIMESTAMP;
          //2.生成序列号
          //获取当前时间,精确到天
          String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
          //以icr + keyPrefix + date 作为redis 的 key
          Long count = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + date);
          //以count作为自增位,时间戳前移32位,拼接成一个64位的全局唯一id
          return timeStamp<<COUNT_BITS | count;
     }

}

总结:

全局唯一id生成策略:

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增(把id单独放一张表,效果类似于Redis自增,但性能不如Redis)

我们自己实现的Redis自增id策略:

  • 每天一个key,方便统计订单量
  • ID构造是 时间戳 + 计数器

6.2实现秒杀的下单功能

请求方式请求路径请求参数返回值
POST/voucher优惠券id订单id

image-20231204104448574

第一步:VoucherOrderController层

package com.hmdp.controller;


import com.hmdp.dto.Result;
import com.hmdp.service.IVoucherOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {

    @Autowired
    private IVoucherOrderService voucherOrderService;

    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.secKillVoucher(voucherId);
    }
}

第二步:Impl层

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.RedisIdWork;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          //更新数据库信息,把库存减去1
          seckillVoucherService
                  .update()
                  .setSql("stock=stock-1")
                  .eq("voucher_id",voucherId)
                  .update();
          //创建订单
          VoucherOrder voucherOrder = new VoucherOrder();
          long orderId = redisIdWork.nextId("order");
          voucherOrder.setId(orderId);
          voucherOrder.setVoucherId(voucherId);
          voucherOrder.setUserId(UserHolder.getUser().getId());
          //写入数据库
          save(voucherOrder);
          //返回订单id
          return Result.ok(orderId);
     }
}

6.3超卖问题

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

超卖问题是典型的多线程问题,常见的解决方案是加锁,一种是加悲观锁,一种是加乐观锁

image-20231204151940205

根据以上的比较我们可以看出乐观锁的性能高,但是我们怎么判断数据到底是否已经被修改了呢——给数据加一个版本号

每一次更新数据的时候,判断是否是已知的版本号,如果版本号不一致,证明已经被修改过,发生安全问题

image-20231204152317666

由于我们的stock字段(库存)所起的效果和版本号一样,所以我们完全可以用stock作为判断数据是否被修改的依据,这种思想就是CAS(Compare And Set)

乐观锁解决超卖问题

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.RedisIdWork;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          //更新数据库信息,把库存减去1
          seckillVoucherService
                  .update()
                  .setSql("stock=stock-1")
                  .eq("voucher_id",voucherId)
                   //超卖问题解决
                   //主要是添加了下面这段逻辑,判断库存是否变更,没有被修改才可以去修改数据库(卖出)
                  .eq("stock",seKillVoucher.getStock())//这里添加where id = ?,stock = ?
                  .update();
          //创建订单
          VoucherOrder voucherOrder = new VoucherOrder();
          long orderId = redisIdWork.nextId("order");
          voucherOrder.setId(orderId);
          voucherOrder.setVoucherId(voucherId);
          voucherOrder.setUserId(UserHolder.getUser().getId());
          //写入数据库
          save(voucherOrder);
          //返回订单id
          return Result.ok(orderId);
     }
}

但是以上的编码会也会出现成功率低的问题:同一时间很多线程并发执行,当第一个线程执行时候,修改了stock值,但是很多的线程判断stock已经不是最初查出来的 stock,所以这些线程都不会执行,失败率大大提高。

怎么解决?我们从业务的角度来考虑,就如上述场景,第一个线程做了stock-1的操作(假设stock=100),那么此时stock=99,其他的线程可以继续执行的,完全没必要失败。我们不再判断stock是否相等,而是判断stock>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.RedisIdWork;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          //更新数据库信息,把库存减去1
          seckillVoucherService
                  .update()
                  .setSql("stock=stock-1")
                  .eq("voucher_id",voucherId)
                   //超卖问题解决
                   //主要是添加了下面这段逻辑,判断库存是否变更,没有被修改才可以去修改数据库(卖出)
                  .gt("stock",0)//这里添加where id = ?,stock = ?
                  .update();
          //创建订单
          VoucherOrder voucherOrder = new VoucherOrder();
          long orderId = redisIdWork.nextId("order");
          voucherOrder.setId(orderId);
          voucherOrder.setVoucherId(voucherId);
          voucherOrder.setUserId(UserHolder.getUser().getId());
          //写入数据库
          save(voucherOrder);
          //返回订单id
          return Result.ok(orderId);
     }
}

6.4一人一单

优惠券秒杀这种业务商家本着牺牲一点利润来博取用户购买量,但是为了防止一个用户抢到多个优惠券(黄牛),我们需要判断,如果订单表中有对应的user_id 和 vouche_id,证明已经买过了,代码实现如下

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.RedisIdWork;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          //一人一单的代码实现
          Long userId = UserHolder.getUser().getId();
          int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
          if(count>0){
               return Result.fail("用户已经购买过一次了");
          }
          //更新数据库信息,把库存减去1
          seckillVoucherService
                  .update()
                  .setSql("stock=stock-1")
                  .eq("voucher_id",voucherId)
                  //超卖问题解决
                  //主要是添加了下面这段逻辑,判断库存是否>0,是,才可以去修改数据库(卖出)
                  .gt("stock",0)//这里添加where id = ?,stock = ?
                  .update();
          //创建订单
          VoucherOrder voucherOrder = new VoucherOrder();
          long orderId = redisIdWork.nextId("order");
          voucherOrder.setId(orderId);
          voucherOrder.setVoucherId(voucherId);
          voucherOrder.setUserId(userId);
          //写入数据库
          save(voucherOrder);
          //返回订单id
          return Result.ok(orderId);
     }
}

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

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

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.RedisIdWork;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          return createVoucherOrder(voucherId);
     }

     @Transactional
     public synchronized Result createVoucherOrder(Long voucherId) {
          //一人一单的代码实现
          Long userId = UserHolder.getUser().getId();
               int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
               if (count > 0) {
                    return Result.fail("用户已经购买过一次了");
               }
               //更新数据库信息,把库存减去1
               seckillVoucherService
                       .update()
                       .setSql("stock=stock-1")
                       .eq("voucher_id", voucherId)
                       //超卖问题解决
                       //主要是添加了下面这段逻辑,判断库存是否>0,是,才可以去修改数据库(卖出)
                       .gt("stock", 0)//这里添加where id = ?,stock = ?
                       .update();
               //创建订单
               VoucherOrder voucherOrder = new VoucherOrder();
               long orderId = redisIdWork.nextId("order");
               voucherOrder.setId(orderId);
               voucherOrder.setVoucherId(voucherId);
               voucherOrder.setUserId(userId);
               //写入数据库
               save(voucherOrder);
               //返回订单id
               return Result.ok(orderId);
          }
}

但是这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,以下这段代码需要修改为:
intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法

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.RedisIdWork;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          return createVoucherOrder(voucherId);
     }

     @Transactional
     public  Result createVoucherOrder(Long voucherId) {
          //一人一单的代码实现
          Long userId = UserHolder.getUser().getId();
          synchronized (userId.toString().intern()) {
               int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
               if (count > 0) {
                    return Result.fail("用户已经购买过一次了");
               }
               //更新数据库信息,把库存减去1
               seckillVoucherService
                       .update()
                       .setSql("stock=stock-1")
                       .eq("voucher_id", voucherId)
                       //超卖问题解决
                       //主要是添加了下面这段逻辑,判断库存是否>0,是,才可以去修改数据库(卖出)
                       .gt("stock", 0)//这里添加where id = ?,stock = ?
                       .update();
               //创建订单
               VoucherOrder voucherOrder = new VoucherOrder();
               long orderId = redisIdWork.nextId("order");
               voucherOrder.setId(orderId);
               voucherOrder.setVoucherId(voucherId);
               voucherOrder.setUserId(userId);
               //写入数据库
               save(voucherOrder);
               //返回订单id
               return Result.ok(orderId);
          }
     }
}

但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:

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

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.RedisIdWork;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          Long userId = UserHolder.getUser().getId();
          synchronized (userId.toString().intern()) {
               return createVoucherOrder(voucherId);
          }
     }

     @Transactional
     public  Result createVoucherOrder(Long voucherId) {
          //一人一单的代码实现
          Long userId = UserHolder.getUser().getId();
               int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
               if (count > 0) {
                    return Result.fail("用户已经购买过一次了");
               }
               //更新数据库信息,把库存减去1
               seckillVoucherService
                       .update()
                       .setSql("stock=stock-1")
                       .eq("voucher_id", voucherId)
                       //超卖问题解决
                       //主要是添加了下面这段逻辑,判断库存是否>0,是,才可以去修改数据库(卖出)
                       .gt("stock", 0)//这里添加where id = ?,stock = ?
                       .update();
               //创建订单
               VoucherOrder voucherOrder = new VoucherOrder();
               long orderId = redisIdWork.nextId("order");
               voucherOrder.setId(orderId);
               voucherOrder.setVoucherId(voucherId);
               voucherOrder.setUserId(userId);
               //写入数据库
               save(voucherOrder);
               //返回订单id
               return Result.ok(orderId);
          }
}

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

第一步:seckillVoucher更改如下,用代离对象来调用createVoucherOrder方法

@Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          Long userId = UserHolder.getUser().getId();
          synchronized (userId.toString().intern()) {
               IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
               return proxy.createVoucherOrder(voucherId);
          }
     }

第二步:在IVoucherOrderService中添加createVoucherOrder方法

package com.hmdp.service;

import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
public interface IVoucherOrderService extends IService<VoucherOrder> {

     Result secKillVoucher(Long voucherId);

     Result createVoucherOrder(Long voucherId);

}

第三步:pom.xml引入依赖(动态代理模式)

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

第四步:启动类添加注解,暴露代理对象

package com.hmdp;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}

6.5集群环境下的并发问题

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

image-20231204170307435

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

tips

# 查看当前本地项目的版本号
git tag
# 删除本地版本号
git tag -d v1.0.0
# 给当前项目打上一个标签,版本号,并添加描述信息
git tag -a v1.0.0 -m "我是描述信息"
# 把本地版本号对应的项目代码推送到远程仓库
git push origin v1.0.0

7.分布式锁

在集群模式下,synchronized锁失效了,分布式锁解决了这个问题

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

image-20231205091326626

分布式锁他应该满足一些什么样的条件呢?

  • 可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思

  • 互斥:互斥是分布式锁的最基本的条件,使得程序串行执行

  • 高可用:程序不易崩溃,时时刻刻都保证较高的可用性

  • 高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能

  • 安全性:安全也是程序中必不可少的一环

7.1Redis实现分布式锁的思路

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

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false
    # 添加锁 NX 是互斥,EX是设置超时时间
    set lock thread1 NX EX 10
    
  • 释放锁:

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

image-20231205092828561

7.2Redis分布式锁的实现版本1

image-20231205093211797

package com.hmdp.utils;

import com.hmdp.service.ILock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {


     private StringRedisTemplate stringRedisTemplate;

     //业务名称
     private String name;

     //key前缀
     private static final String key_prefix = "lock:";

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


     @Override
     public boolean tryLock(long timeoutSec) {
          //value为当前的线程标示,我们可以用线程的id来表示
          long currentThreadId = Thread.currentThread().getId();
          Boolean success = stringRedisTemplate.opsForValue()
                  .setIfAbsent(key_prefix + name,currentThreadId + "", timeoutSec, TimeUnit.SECONDS);
          //由于success是包装类,直接返回会做自动拆箱的一个动作,如果success的值为null
          //就会报空指针异常,我们要避免这种情况
          return Boolean.TRUE.equals(success);
     }

     @Override
     public void unlock() {
          //释放锁
          Boolean success = stringRedisTemplate.delete(key_prefix + name);
     }
}

业务代码也要修改,改为用分布式锁实现

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.RedisIdWork;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Autowired
     private StringRedisTemplate stringRedisTemplate;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          Long userId = UserHolder.getUser().getId();
          //分布式锁实现
          SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate,"order:" + userId);
          boolean isLock = simpleRedisLock.tryLock(5);
          if(!isLock){
               return Result.fail("不允许重复下单!");
          }
          try {
               //有可能会出现异常,不管怎么样最后都要释放锁
               IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
               return proxy.createVoucherOrder(voucherId);
          } finally {
               simpleRedisLock.unlock();
          }
     }
     @Transactional
     public  Result createVoucherOrder(Long voucherId) {
          //一人一单的代码实现
          Long userId = UserHolder.getUser().getId();
               int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
               if (count > 0) {
                    return Result.fail("用户已经购买过一次了");
               }
               //更新数据库信息,把库存减去1
               seckillVoucherService
                       .update()
                       .setSql("stock=stock-1")
                       .eq("voucher_id", voucherId)
                       //超卖问题解决
                       //主要是添加了下面这段逻辑,判断库存是否>0,是,才可以去修改数据库(卖出)
                       .gt("stock", 0)//这里添加where id = ?,stock = ?
                       .update();
               //创建订单
               VoucherOrder voucherOrder = new VoucherOrder();
               long orderId = redisIdWork.nextId("order");
               voucherOrder.setId(orderId);
               voucherOrder.setVoucherId(voucherId);
               voucherOrder.setUserId(userId);
               //写入数据库
               save(voucherOrder);
               //返回订单id
               return Result.ok(orderId);
          }
}

总结:

这样我们就解决了集群模式下由于锁不唯一导致的线程安全问题,之前虽然是加锁了,但是局限于一台服务器,而每台服务器都针对于自己的jvm去加锁,即锁的监视器在每台服务器上都存在一个,锁就不唯一了。而基于Redis的分布式锁方案由于Redis只有一台,使用setnx实现了互斥的效果,ex实现了锁自动释放。所以实现了锁只有一把的效果

注意不要忘了解决的问题,我们解决是对于某个用户,避免了该用户恶意同一时间发送多个请求来秒杀优惠券的行为

7.3Redis分布式锁误删

逻辑说明:

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

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

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

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

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

package com.hmdp.utils;

import cn.hutool.core.lang.UUID;
import com.hmdp.service.ILock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {


     private StringRedisTemplate stringRedisTemplate;

     //业务名称
     private String name;

     //key前缀
     private static final String key_prefix = "lock:";

     private static final String lock_prefix = UUID.randomUUID().toString(true) + "-";

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


     @Override
     public boolean tryLock(long timeoutSec) {
          //value为当前的线程标示,我们可以用线程的id来表示
          String currentThreadId = lock_prefix + Thread.currentThread().getId();
          Boolean success = stringRedisTemplate.opsForValue()
                  .setIfAbsent(key_prefix + name, currentThreadId, timeoutSec, TimeUnit.SECONDS);
          //由于success是包装类,直接返回会做自动拆箱的一个动作,如果success的值为null
          //就会报空指针异常,我们要避免这种情况
          return Boolean.TRUE.equals(success);
     }

     @Override
     public void unlock() {
          //获取当前线程标示
          String currentThreadId = lock_prefix + Thread.currentThread().getId();
          //获取锁中的标示
          String id = stringRedisTemplate.opsForValue().get(key_prefix + name);
          if(id.equals(currentThreadId)){
               //释放锁
               stringRedisTemplate.delete(key_prefix + name);
          }
     }
}

每一个jvm内,获取到的当前线程id一样,UUID在每个jvm中都不一样,可以用UUID拼接线程id的方式来唯一标示线程

7.4分布式锁原子性问题

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

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

所以我们要保证判断锁的和释放锁这两个动作的原子性

7.5 lua脚本解决多条命令原子性

Redis 提供了 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。Lua是一种编程语言,可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,我们将释放锁的操作写到Lua脚本中去,直接调用脚本

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

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

8.Redission讲解

8.1Redission功能介绍

基于setnx实现的分布式锁存在下面的问题:

重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。

不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。

**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患

主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。

8.2Redission快速入门

引入依赖

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

配置Redisson客户端

package com.hmdp.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

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

我们基于Redission实现秒杀业务

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.RedisIdWork;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

     @Autowired
     private ISeckillVoucherService seckillVoucherService;

     @Autowired
     private RedisIdWork redisIdWork;

     @Autowired
     private RedissonClient redissonClient;

     @Autowired
     private StringRedisTemplate stringRedisTemplate;

     @Override
     @Transactional
     public Result secKillVoucher(Long voucherId) {
          //查询秒杀券的信息
          SeckillVoucher seKillVoucher = seckillVoucherService.getById(voucherId);
          //查询是否符合开始秒杀的时间
          if(seKillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
               return Result.fail("秒杀时间未到");
          }
          //查询是否已过秒杀的时间
          if(seKillVoucher.getEndTime().isBefore(LocalDateTime.now())){
               return Result.fail("秒杀时间已过");
          }
          //判断库存是否还有
          if(seKillVoucher.getStock() < 1){
               return Result.fail("库存不足");
          }
          Long userId = UserHolder.getUser().getId();
          //Redission分布式锁实现
//          SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate,"order:" + userId);
          RLock lock = redissonClient.getLock("lock:order:" + userId);
          boolean isLock = lock.tryLock();
          if(!isLock){
               return Result.fail("不允许重复下单!");
          }
          try {
               //有可能会出现异常,不管怎么样最后都要释放锁
               IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
               return proxy.createVoucherOrder(voucherId);
          } finally {
               lock.unlock();
          }
     }
     @Transactional
     public  Result createVoucherOrder(Long voucherId) {
          //一人一单的代码实现
          Long userId = UserHolder.getUser().getId();
               int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
               if (count > 0) {
                    return Result.fail("用户已经购买过一次了");
               }
               //更新数据库信息,把库存减去1
               seckillVoucherService
                       .update()
                       .setSql("stock=stock-1")
                       .eq("voucher_id", voucherId)
                       //超卖问题解决
                       //主要是添加了下面这段逻辑,判断库存是否>0,是,才可以去修改数据库(卖出)
                       .gt("stock", 0)//这里添加where id = ?,stock = ?
                       .update();
               //创建订单
               VoucherOrder voucherOrder = new VoucherOrder();
               long orderId = redisIdWork.nextId("order");
               voucherOrder.setId(orderId);
               voucherOrder.setVoucherId(voucherId);
               voucherOrder.setUserId(userId);
               //写入数据库
               save(voucherOrder);
               //返回订单id
               return Result.ok(orderId);
          }
}

8.3Redission可重入锁

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

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

在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有

9. 实战-达人探店

发布探店笔记

探店笔记类似点评网站的评价,往往是图文结合。对应的表有两个:
tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
tb_blog_comments:其他用户对探店笔记的评价

9.1查看探店笔记

请求方式请求路径请求参数返回值
Get/blog/{id}blog的idBlog信息,包含用户信息

BlogController层

package com.hmdp.controller;


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.service.IBlogService;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@RestController
@RequestMapping("/blog")
public class BlogController {

    @Resource
    private IBlogService blogService;
    
    @PostMapping
    public Result saveBlog(@RequestBody Blog blog) {
        // 获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // 保存探店博文
        blogService.save(blog);
        // 返回id
        return Result.ok(blog.getId());
    }

    @PutMapping("/like/{id}")
    public Result likeBlog(@PathVariable("id") Long id) {
        // 修改点赞数量
        blogService.update()
                .setSql("liked = liked + 1").eq("id", id).update();
        return Result.ok();
    }

    @GetMapping("/of/me")
    public Result queryMyBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
        // 获取登录用户
        UserDTO user = UserHolder.getUser();
        // 根据用户查询
        Page<Blog> page = blogService.query()
                .eq("user_id", user.getId()).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        return Result.ok(records);
    }

    @GetMapping("/hot")
    public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
        return blogService.queryHotBlog(current);
    }


    @GetMapping("/{id}")
    public Result queryBlogById(@PathVariable("id") Long id){
        return blogService.queryBlogById(id);
    }
}

BlogServiceImpl层

package com.hmdp.service.impl;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
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 org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

     @Resource
     private IUserService userService;


     @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(this::getBlogUser);
          return Result.ok(records);
     }


     @Override
     public Result queryBlogById(Long id) {
          Blog blog = getById(id);
          if(blog==null){
               return Result.fail("笔记不存在");
          }
          getBlogUser(blog);
          return Result.ok(blog);
     }

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

9.2点赞功能

完善点赞功能

需求:

  • 同一个用户只能点赞一次,再次点击则取消点赞
  • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)

实现步骤:

  • 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
  • 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
  • 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
  • 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段

BlogController层

 @PutMapping("/like/{id}")
    public Result likeBlog(@PathVariable("id") Long id) {
        return blogService.likeBlog(id);
    }

BlogServiceImpl层

package com.hmdp.service.impl;

import cn.hutool.core.util.BooleanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
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.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

     @Resource
     private IUserService userService;

     @Autowired
     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.getBlogUser(blog);
               this.blogIsLiked(blog);
          });
          return Result.ok(records);
     }

     @Override
     public Result likeBlog(Long id) {
          Long userId = UserHolder.getUser().getId();
          String key = "blog:liked" + id;
          Boolean isLike = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
          if(BooleanUtil.isTrue(isLike)){
               //数据库点赞数减一
               boolean success = update().setSql("liked=liked-1").eq("id",id).update();
               if(success){
                    //就进行redis中去除
                    stringRedisTemplate.opsForSet().remove(key,userId.toString());
               }
          }else{
               //数据库点赞数加一
               boolean success = update().setSql("liked=liked+1").eq("id",id).update();
               if(success){
                    //如果没有点赞,redis中添加数据
                    stringRedisTemplate.opsForSet().add(key,userId.toString());
               }
          }
          return Result.ok();
     }


     @Override
     public Result queryBlogById(Long id) {
          Blog blog = getById(id);
          if(blog==null){
               return Result.fail("笔记不存在");
          }
          getBlogUser(blog);
          //查询blog是否被点过赞,如果点过,赋值isLike
          blogIsLiked(blog);
          return Result.ok(blog);
     }

     private void blogIsLiked(Blog blog) {
          Long userId = UserHolder.getUser().getId();
          String key = "blog:liked" + blog.getId();
          Boolean isLike = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
          blog.setIsLike(BooleanUtil.isTrue(isLike));
     }

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

9.3点赞排行榜

在探店笔记的详情页面,应该把给该笔记点赞的人显示出来,比如最早点赞的TOP5,形成点赞排行榜:

之前的点赞是放到set集合,但是set集合是不能排序的,所以这个时候,咱们可以采用一个可以排序的set集合,就是咱们的sortedSet

sortedSet中score就是时间戳,默认显示最早点赞的TOP5

zset存在的问题:

  • zset虽然和set类似,但是命令还是有差异的,比如查询元素是否存在set中有方法isMember,但zset中没有

  • 怎么实现TOP5的获取

解决思路:

  • zset中有get score,即元素存在就获取到分数,元素不存在就返回null
  • zset中有zrange命令,可以实现某个范围的查询

代码实现:

①点赞逻辑修改,主要把set修改为zset

package com.hmdp.service.impl;

import cn.hutool.core.util.BooleanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
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.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

     @Resource
     private IUserService userService;

     @Autowired
     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.getBlogUser(blog);
               this.blogIsLiked(blog);
          });
          return Result.ok(records);
     }

     @Override
     public Result likeBlog(Long id) {
          Long userId = UserHolder.getUser().getId();
          String key = "blog:liked" + id;
          Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
          if(score!=null){
               //数据库点赞数减一
               boolean success = update().setSql("liked=liked-1").eq("id",id).update();
               if(success){
                    //就进行redis中去除
                    stringRedisTemplate.opsForZSet().remove(key,userId.toString());
               }
          }else{
               //数据库点赞数加一
               boolean success = update().setSql("liked=liked+1").eq("id",id).update();
               if(success){
                    //如果没有点赞,redis中添加数据
                    stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
               }
          }
          return Result.ok();
     }


     @Override
     public Result queryBlogById(Long id) {
          Blog blog = getById(id);
          if(blog==null){
               return Result.fail("笔记不存在");
          }
          getBlogUser(blog);
          //查询blog是否被点过赞,如果点过,赋值isLike
          blogIsLiked(blog);
          return Result.ok(blog);
     }

     private void blogIsLiked(Blog blog) {
          Long userId = UserHolder.getUser().getId();
          String key = "blog:liked" + blog.getId();
          Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
          blog.setIsLike(score!=null);
     }

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

②点赞排行榜接口实现

请求方式请求路径请求参数返回值
Get/blog/likes/{id}blog的idtop5点赞用户dto

BlogController层

    @GetMapping("/likes/{id}")
    public Result queryBlogLikes(@PathVariable("id") Long id){
        return blogService.queryBlogLikes(id);
    }

BlogServiceImpl层

@Override
     public Result queryBlogLikes(Long id) {
          String key = "blog:liked" + id;
          //查询top5用户id
          Set<String> userIds = stringRedisTemplate.opsForZSet().range(key, 0, 4);
          if(userIds==null || userIds.isEmpty()){
               return Result.ok(Collections.emptyList());
          }
          //查询数据库封装UserDTO返回(严格来说这里是UserVo)
          Set<UserDTO> userDTOS = userIds.stream()
                  .map(new Function<String, UserDTO>() {
                       @Override
                       public UserDTO apply(String userId) {
                            UserDTO userDTO = new UserDTO();
                            Long id = Long.valueOf(userId);
                            User user = userService.getById(id);
                            userDTO.setId(id);
                            userDTO.setNickName(user.getNickName());
                            userDTO.setIcon(user.getIcon());
                            return userDTO;
                       }
                  }).collect(Collectors.toSet());
          return Result.ok(userDTOS);
     }

10.实战-好友关注

10.1关注和取关

关注接口

请求方式请求路径请求参数返回值
PUT/follow/2/true被关注人的id,是否关注true

取关接口

请求方式请求路径请求参数返回值
Get/follow/or/not/2被关注人的id

FollowController层实现

package com.hmdp.controller;


import com.hmdp.dto.Result;
import com.hmdp.service.IFollowService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@RestController
@RequestMapping("/follow")
public class FollowController {

     @Autowired
     private IFollowService followService;

     @PutMapping("/{id}/{isFollow}")
     public Result follow(@PathVariable("id") Long id, @PathVariable("isFollow") Boolean isFollow){
          return followService.follow(id, isFollow);
     }

     @GetMapping("/or/not/{id}")
     public Result follow(@PathVariable("id") Long id){
          return followService.isFollow(id);
     }
}

FollowServiceImpl层实现

package com.hmdp.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hmdp.dto.Result;
import com.hmdp.entity.Follow;
import com.hmdp.mapper.FollowMapper;
import com.hmdp.service.IFollowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

     @Override
     public Result follow(Long id, Boolean isFollow) {
          //首先判断是否已经关注,如果未关注就实现数据库新增数据,关注了就删减
          Long userId = UserHolder.getUser().getId();
          if(userId == null){
               return Result.fail("用户未登录");
          }
          if(isFollow){
               Follow follow = new Follow();
               follow.setUserId(userId);
               follow.setFollowUserId(id);
               save(follow);
          }else{
               LambdaQueryWrapper<Follow> lambdaQueryWrapper = new LambdaQueryWrapper<>();
               lambdaQueryWrapper.eq(Follow::getFollowUserId,id);
               lambdaQueryWrapper.eq(Follow::getUserId,userId);
               remove(lambdaQueryWrapper);
          }
          return Result.ok();
     }

     @Override
     public Result isFollow(Long id) {
          //查询数据库中是否有此条数据,有就说明已经关注了
          Long userId = UserHolder.getUser().getId();
          Integer count = query().eq("user_id", userId).eq("follow_user_id", id).count();
          return Result.ok(count > 0);
     }
}

10.2共同关注

首先实现两个接口

在UserController中

@GetMapping("/{id}")
    public Result quertUserById(@PathVariable("id") Long userId){
        User user = userService.getById(userId);
        if(user==null){
            return Result.ok();
        }
        UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
        return  Result.ok(userDTO);
    }

在BlogController中

    @GetMapping("/of/user")
    public Result queryBlogByUserId(@RequestParam(value = "id") Long id,
                                    @RequestParam(value = "current") Integer current){
        Page<Blog> page = blogService.query()
                .eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        //获取当前页的数据
        List<Blog> records = page.getRecords();
        return Result.ok(records);
    }

实现思路:

共同关注是当前登录用户和当前登录用户关注的某个用户,这两个用户各自关注的用户的交集,那么我们想到了Redis中的set集合有求交际的命令,所以我们应该改造接口,把关注接口的数据新增存在redis的set集合中,key为当前用户,value为当前用户所关注的所有用户,这样会更加方便我们后续查询共同关注的用户。当然取关接口也要改造,数据库数据修改成功后,我们也要添加把redis中的ke移除

package com.hmdp.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hmdp.dto.Result;
import com.hmdp.entity.Follow;
import com.hmdp.mapper.FollowMapper;
import com.hmdp.service.IFollowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

     @Autowired
     private StringRedisTemplate stringRedisTemplate;
     @Override
     public Result follow(Long id, Boolean isFollow) {
          //首先判断是否已经关注,如果未关注就实现数据库新增数据,关注了就删减
          Long userId = UserHolder.getUser().getId();
          if(userId == null){
               return Result.fail("用户未登录");
          }
          //指定key
          String key = "follow:" + userId;
          if(isFollow){
               Follow follow = new Follow();
               follow.setUserId(userId);
               follow.setFollowUserId(id);
               boolean isSuccess = save(follow);
               if(isSuccess){
                    stringRedisTemplate.opsForSet().add(key, id.toString());
               }
          }else{
               LambdaQueryWrapper<Follow> lambdaQueryWrapper = new LambdaQueryWrapper<>();
               lambdaQueryWrapper.eq(Follow::getFollowUserId,id);
               lambdaQueryWrapper.eq(Follow::getUserId,userId);
               boolean isSuccess = remove(lambdaQueryWrapper);
               if(isSuccess){
                    stringRedisTemplate.opsForSet().remove(key, id.toString());
               }
          }
          return Result.ok();
     }

     @Override
     public Result isFollow(Long id) {
          //查询数据库中是否有此条数据,有就说明已经关注了
          Long userId = UserHolder.getUser().getId();
          Integer count = query().eq("user_id", userId).eq("follow_user_id", id).count();
          return Result.ok(count > 0);
     }
}

接下来我们实现共同关注接口

请求方式请求路径请求参数返回值
GET/follow/common/{id}被关注人的iduserDTO的集合

FollowController层

 @GetMapping("/common/{id}")
     public Result commonFollows(@PathVariable("id") Long id){
          return  followService.commonFollows(id);
     }

FollowServiceImpl层实现

     @Override
     public Result commonFollows(Long id) {
          Long userId = UserHolder.getUser().getId();
          String key1 = "follow:" + userId;
          String key2 = "follow:" + id;
          Set<String> commonFollowUserIds = stringRedisTemplate.opsForSet().intersect(key1, key2);
          if(commonFollowUserIds==null || commonFollowUserIds.isEmpty()){
               return Result.ok(Collections.EMPTY_SET);
          }
          Set<Long> longIds = commonFollowUserIds.stream().map(Long::valueOf).collect(Collectors.toSet());
          List<User> users = userService.listByIds(longIds);
          List<UserDTO> userDTOS = BeanUtil.copyToList(users, UserDTO.class);
          return Result.ok(userDTOS);
     }

10.3推模式实现Feed流

需求:

  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询

Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。

传统了分页在feed流是不适用的,因为我们的数据会随时发生变化

假设在t1 时刻,我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是10~6 这几条记录,假设现在t2时候又发布了一条记录,此时t3 时刻,我们来读取第二页,读取第二页传入的参数是page=2 ,size=5 ,那么此时读取到的第二页实际上是从6 开始,然后是6~2 ,那么我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做

Feed流的滚动分页

我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据

举个例子:我们从t1时刻开始,拿第一页数据,拿到了10~6,然后记录下当前最后一次拿取的记录,就是6,t2时刻发布了新的记录,此时这个11放到最顶上,但是不会影响我们之前记录的6,此时t3时刻来拿第二页,第二页这个时候拿数据,还是从6后一点的5去拿,就拿到了5-1的记录。我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了

核心的意思:就是我们在保存完探店笔记后,获得到当前笔记的粉丝,然后把数据推送到粉丝的redis中去

代码实现:

BlogController层

@PostMapping
    public Result saveBlog(@RequestBody Blog blog) {
        return blogService.saveBlog(blog);
    }

BlogServiceImpl层

  @Override
     public Result saveBlog(Blog blog) {
          // 获取登录用户
          UserDTO user = UserHolder.getUser();
          blog.setUserId(user.getId());
          // 保存探店博文
          boolean isSuccess = save(blog);
          if(!isSuccess){
              return Result.fail("笔记发布失败");
          }
          List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
          for (Follow follow : follows) {
               //得到用户id
               Long userId = follow.getUserId();
               // 4.2.推送
               String key = "feed:" + userId;
               //把一个用户的粉丝id作为key,新增博客的id作为value,保存至redis中,实现了推送功能
               stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(),             System.currentTimeMillis());
          }
          // 返回id
          return Result.ok(blog.getId());
     }

说明已经关注了
Long userId = UserHolder.getUser().getId();
Integer count = query().eq(“user_id”, userId).eq(“follow_user_id”, id).count();
return Result.ok(count > 0);
}
}


接下来我们实现共同关注接口

| 请求方式 | 请求路径            | 请求参数     | 返回值        |
| -------- | ------------------- | ------------ | ------------- |
| GET      | /follow/common/{id} | 被关注人的id | userDTO的集合 |

FollowController层

~~~~ java
 @GetMapping("/common/{id}")
     public Result commonFollows(@PathVariable("id") Long id){
          return  followService.commonFollows(id);
     }

FollowServiceImpl层实现

     @Override
     public Result commonFollows(Long id) {
          Long userId = UserHolder.getUser().getId();
          String key1 = "follow:" + userId;
          String key2 = "follow:" + id;
          Set<String> commonFollowUserIds = stringRedisTemplate.opsForSet().intersect(key1, key2);
          if(commonFollowUserIds==null || commonFollowUserIds.isEmpty()){
               return Result.ok(Collections.EMPTY_SET);
          }
          Set<Long> longIds = commonFollowUserIds.stream().map(Long::valueOf).collect(Collectors.toSet());
          List<User> users = userService.listByIds(longIds);
          List<UserDTO> userDTOS = BeanUtil.copyToList(users, UserDTO.class);
          return Result.ok(userDTOS);
     }

10.3推模式实现Feed流

需求:

  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询

Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。

传统了分页在feed流是不适用的,因为我们的数据会随时发生变化

假设在t1 时刻,我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是10~6 这几条记录,假设现在t2时候又发布了一条记录,此时t3 时刻,我们来读取第二页,读取第二页传入的参数是page=2 ,size=5 ,那么此时读取到的第二页实际上是从6 开始,然后是6~2 ,那么我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做

Feed流的滚动分页

我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据

举个例子:我们从t1时刻开始,拿第一页数据,拿到了10~6,然后记录下当前最后一次拿取的记录,就是6,t2时刻发布了新的记录,此时这个11放到最顶上,但是不会影响我们之前记录的6,此时t3时刻来拿第二页,第二页这个时候拿数据,还是从6后一点的5去拿,就拿到了5-1的记录。我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了

核心的意思:就是我们在保存完探店笔记后,获得到当前笔记的粉丝,然后把数据推送到粉丝的redis中去

代码实现:

BlogController层

@PostMapping
    public Result saveBlog(@RequestBody Blog blog) {
        return blogService.saveBlog(blog);
    }

BlogServiceImpl层

  @Override
     public Result saveBlog(Blog blog) {
          // 获取登录用户
          UserDTO user = UserHolder.getUser();
          blog.setUserId(user.getId());
          // 保存探店博文
          boolean isSuccess = save(blog);
          if(!isSuccess){
              return Result.fail("笔记发布失败");
          }
          List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
          for (Follow follow : follows) {
               //得到用户id
               Long userId = follow.getUserId();
               // 4.2.推送
               String key = "feed:" + userId;
               //把一个用户的粉丝id作为key,新增博客的id作为value,保存至redis中,实现了推送功能
               stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(),             System.currentTimeMillis());
          }
          // 返回id
          return Result.ok(blog.getId());
     }
  • 16
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值