Redis是一个基于内存的NoSQL数据库
特征:
具体以上命令怎么用,可以查看帮助文档,例如查看删除key的用法
help del;
Hash这种结构的value也是一个hash结构(filed-value)
List结构跟Java种的LinkedList相似,可以看作是一个双向链表的结构,既支持正向检索,也支持反向
Redis的Set结构与Java种的HashSet类似,可以看做是一个value为null的HashMap。因为也是一个hash表,具备HashSet类似的特征
Redis的SortedSet(Zset)是一个可排序的set集合,与Java种的TreeSet有些类似,但底层数据结构却差别很大。SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素进行排序,底层由跳表(SkipList)和Hash表实现
由于Zset可以进行排序,常用来做排行榜这种业务需求
我们主要学Spring提供的Redis的Java客户端
Spring提供了一个stringRedisTemplate,它的key和value默认为String类型,当需要存储Java对象的时候,需要手动完成对象的序列化和反序列化
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
校验登录状态:
用户在请求时候,会从cookie中携带JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行
请求方式 | 请求路径 | 请求参数 | 返回值 |
---|---|---|---|
POST | /user/code | phone | 无 |
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;
/**
*
* 服务实现类
*
*
* @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();
}
}
请求方式 | 请求路径 | 请求参数 | 返回值 |
---|---|---|---|
POST | /user/login | Json风格的密码和验证码 | 无 |
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;
}
}
我们完成发送短信,以及短信验证码的注册登录两个接口后,依然无法正常访问页面,原因是没有进行登录状态的校验
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());
}
我们在me接口中,会返回很多关于用户的敏感信息,比如密码和手机号等,我们需要封装一下。起源是在我们在存入session的时候,就是存入的User对象,而这个User对象包含了用户的全部信息,我们创建一个UserDto来存入session
后期会tomcat做水平扩展,多台tomcat并不共享session存储空间,当请求切换到不同的tomcat服务时导致数据丢失问题
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
综上共享session的方案应该满足
我们自然联想到了redis
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
第一步:发送验证码代码修改
@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();
}
}
问题
我们上面已经基本实现了短信登录功能,但还有一个小问题没有解决
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的
解决
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能
代码实现
增加token刷新拦截器
package com.hmdp.utils;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;
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);
}
}
请求方式 | 请求路径 | 请求参数 | 返回值 |
---|---|---|---|
POST | /user/logout | 无 | 无 |
缓存就是数据交换的缓冲区,是存储数据的临时地方,读写性能高。在web开发的每一层都可以做缓存,如下图
引入缓存的会带来很多好处,但同时也会增加一些成本
shop-type/list这个接口是直接查询数据库,我们接下来要做的是添加缓存,方案如下
第一步: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;
/**
*
* 服务实现类
*
*
* @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);
}
}
第一步: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;
/**
*
* 前端控制器
*
*
* @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;
/**
*
* 服务实现类
*
*
* @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);
}
}
我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
删除缓存还是更新缓存?
如何保证缓存与数据库的操作的同时成功或失败?
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
由上节我们讨论得出的结论,我们实现思路如下
①根据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();
}
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,可能会搞垮数据库
常见的解决方案有两种:
缓存空对象
布隆过滤
下面我们解决商铺查询中的缓存穿透问题
修改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);
}
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
解决方案:
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
常见的解决方案有两种:
解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了
解决方案二、逻辑过期方案:
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据
**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
代码实现根据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;
}
第三步:互斥锁解决缓存击穿问题
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;
}
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就会存在一些问题:
全局ID生成器应该满足如下的特性:
唯一性:Redis的String类型的数据结构有一个INCR命令可以确保唯一,因为Redis是独立于数据库之外的只有一个
高可用:Redis的集群方案,主从方案,哨兵方案可以实现高可用
高性能:Redis就是以高性能著称的
递增性:Redis也是采用自增方案,可以保证自增
安全性:可以使用符号位(1位)+时间戳位(31位)+自增位(32位)拼接成以一个安全性较高的id
代码实现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生成策略:
我们自己实现的Redis自增id策略:
请求方式 | 请求路径 | 请求参数 | 返回值 |
---|---|---|---|
POST | /voucher | 优惠券id | 订单id |
第一步: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;
/**
*
* 前端控制器
*
*
* @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;
/**
*
* 服务实现类
*
*
* @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);
}
}
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题
超卖问题是典型的多线程问题,常见的解决方案是加锁,一种是加悲观锁,一种是加乐观锁
根据以上的比较我们可以看出乐观锁的性能高,但是我们怎么判断数据到底是否已经被修改了呢——给数据加一个版本号
每一次更新数据的时候,判断是否是已知的版本号,如果版本号不一致,证明已经被修改过,发生安全问题
由于我们的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;
/**
*
* 服务实现类
*
*
* @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;
/**
*
* 服务实现类
*
*
* @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);
}
}
优惠券秒杀这种业务商家本着牺牲一点利润来博取用户购买量,但是为了防止一个用户抢到多个优惠券(黄牛),我们需要判断,如果订单表中有对应的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;
/**
*
* 服务实现类
*
*
* @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;
/**
*
* 服务实现类
*
*
* @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;
/**
*
* 服务实现类
*
*
* @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;
/**
*
* 服务实现类
*
*
* @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;
/**
*
* 服务类
*
*
* @author 虎哥
* @since 2021-12-22
*/
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result secKillVoucher(Long voucherId);
Result createVoucherOrder(Long voucherId);
}
第三步:pom.xml引入依赖(动态代理模式)
<dependency>
<groupId>org.aspectjgroupId>
<artifactId>aspectjweaverartifactId>
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);
}
}
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,他们的锁写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,synchronized锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题
# 查看当前本地项目的版本号
git tag
# 删除本地版本号
git tag -d v1.0.0
# 给当前项目打上一个标签,版本号,并添加描述信息
git tag -a v1.0.0 -m "我是描述信息"
# 把本地版本号对应的项目代码推送到远程仓库
git push origin v1.0.0
在集群模式下,synchronized锁失效了,分布式锁解决了这个问题
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
分布式锁他应该满足一些什么样的条件呢?
可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
高可用:程序不易崩溃,时时刻刻都保证较高的可用性
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性:安全也是程序中必不可少的一环
实现分布式锁时需要实现的两个基本方法:
获取锁:
# 添加锁 NX 是互斥,EX是设置超时时间
set lock thread1 NX EX 10
释放锁:
# 释放锁,删除即可
del key
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;
/**
*
* 服务实现类
*
*
* @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实现了锁自动释放。所以实现了锁只有一把的效果
注意不要忘了解决的问题,我们解决是对于某个用户,避免了该用户恶意同一时间发送多个请求来秒杀优惠券的行为
逻辑说明:
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程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的方式来唯一标示线程
更为极端的误删逻辑说明:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生
所以我们要保证判断锁的和释放锁这两个动作的原子性
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
基于setnx实现的分布式锁存在下面的问题:
重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
引入依赖
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.13.6version>
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;
/**
*
* 服务实现类
*
*
* @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);
}
}
在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。
在redission中,我们的也支持支持可重入锁
在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有
发布探店笔记
探店笔记类似点评网站的评价,往往是图文结合。对应的表有两个:
tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
tb_blog_comments:其他用户对探店笔记的评价
请求方式 | 请求路径 | 请求参数 | 返回值 |
---|---|---|---|
Get | /blog/{id} | blog的id | Blog信息,包含用户信息 |
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;
/**
*
* 前端控制器
*
*
* @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;
/**
*
* 服务实现类
*
*
* @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());
}
}
完善点赞功能
需求:
实现步骤:
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;
/**
*
* 服务实现类
*
*
* @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());
}
}
在探店笔记的详情页面,应该把给该笔记点赞的人显示出来,比如最早点赞的TOP5,形成点赞排行榜:
之前的点赞是放到set集合,但是set集合是不能排序的,所以这个时候,咱们可以采用一个可以排序的set集合,就是咱们的sortedSet
sortedSet中score就是时间戳,默认显示最早点赞的TOP5
zset存在的问题:
zset虽然和set类似,但是命令还是有差异的,比如查询元素是否存在set中有方法isMember,但zset中没有
怎么实现TOP5的获取
解决思路:
代码实现:
①点赞逻辑修改,主要把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;
/**
*
* 服务实现类
*
*
* @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的id | top5点赞用户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);
}
关注接口
请求方式 | 请求路径 | 请求参数 | 返回值 |
---|---|---|---|
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.*;
/**
*
* 前端控制器
*
*
* @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;
/**
*
* 服务实现类
*
*
* @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);
}
}
首先实现两个接口
在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;
/**
*
* 服务实现类
*
*
* @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} | 被关注人的id | userDTO的集合 |
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);
}
需求:
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);
}
需求:
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());
}