Redis从入门到实战

1.什么是Redis

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

特征:

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

2.Redis命令

2.1Redis通用命令

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

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

help del;

2.2String

2.3Hash

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

2.4List

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

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

2.5Set

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

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

2.6Zset

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

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

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

3.Redis的Java客户端

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

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

4.实战-短信登录

4.1基于Session实现登录流程

发送验证码:

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

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

短信验证码登录、注册:

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

校验登录状态:

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

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;

/**

*

* 服务实现类

*

*

* @author 虎哥

* @since 2021-12-22

*/

@Slf4j

@Service

public class UserServiceImpl extends ServiceImpl 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 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

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共享的问题了

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 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 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 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思考

问题

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

在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录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 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开发的每一层都可以做缓存,如下图

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

5.2添加商户缓存

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

/**

*

* 前端控制器

*

*

* @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 implements IShopTypeService {

@Autowired

private StringRedisTemplate stringRedisTemplate;

@Override

public Result queryTypeList() {

//先从redis中查,range(xx,0,-1)可以查询索引第一个到索引倒数第一个(即所有数据)

List shopTypeJsonList = stringRedisTemplate.opsForList().range(RedisConstants.CACHE_SHOP_TYPE_KEY, 0, -1);

//判断是否有该缓存

if(CollectionUtils.isNotEmpty(shopTypeJsonList)){

//如果有缓存信息,那么就封装返回

List shopTypeList = shopTypeJsonList.stream()

.map(new Function() {

@Override

public ShopType apply(String s) {

ShopType shopType = JSONUtil.toBean(s, ShopType.class);

return shopType;

}

})

.collect(Collectors.toList());

return Result.ok(shopTypeList);

}

//如果没有缓存就从数据库中查

List shopTypeList = query().orderByAsc("sort").list();

//如果数据库中不存在就返回失败

if(shopTypeList==null || shopTypeList.isEmpty()){

return Result.fail("商品类型不存在");

}

//写入redis

List JsonList = shopTypeList.stream()

.map(new Function() {

@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缺点:

实现复杂存在误判可能

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

修改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同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

常见的解决方案有两种:

互斥锁逻辑过期

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

因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用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;

}

6.优惠券秒杀

6.1全局唯一id

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

id规律太明显,比如用户很容易就猜出某个时间间隔内系统有多少订单受单表数据量的限制,mysql单张表最多支持500W个数据,数据量超过这个值后,就要分表存储,这样每个表的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<

}

}

总结:

全局唯一id生成策略:

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

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

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

6.2实现秒杀的下单功能

请求方式请求路径请求参数返回值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 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,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题

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

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

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

由于我们的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 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 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;

/**

*

* 服务实现类

*

*

* @author 虎哥

* @since 2021-12-22

*/

@Service

public class VoucherOrderServiceImpl extends ServiceImpl 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 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 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 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 {

Result secKillVoucher(Long voucherId);

Result createVoucherOrder(Long voucherId);

}

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

org.aspectj

aspectjweaver

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

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集群环境下的并发问题

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

由于现在我们部署了多个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锁失效了,分布式锁解决了这个问题

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

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

可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思 互斥:互斥是分布式锁的最基本的条件,使得程序串行执行 高可用:程序不易崩溃,时时刻刻都保证较高的可用性 高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能 安全性:安全也是程序中必不可少的一环

7.1Redis实现分布式锁的思路

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

获取锁:

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

set lock thread1 NX EX 10

释放锁:

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

del key

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

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 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快速入门

引入依赖

org.redisson

redisson

3.13.6

配置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 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;

/**

*

* 前端控制器

*

*

* @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 page = blogService.query()

.eq("user_id", user.getId()).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));

// 获取当前页数据

List 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 implements IBlogService {

@Resource

private IUserService userService;

@Override

public Result queryHotBlog(Integer current) {

// 根据用户查询

Page page = query()

.orderByDesc("liked")

.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));

// 获取当前页数据

List 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;

/**

*

* 服务实现类

*

*

* @author 虎哥

* @since 2021-12-22

*/

@Service

public class BlogServiceImpl extends ServiceImpl implements IBlogService {

@Resource

private IUserService userService;

@Autowired

private StringRedisTemplate stringRedisTemplate;

@Override

public Result queryHotBlog(Integer current) {

// 根据用户查询

Page page = query()

.orderByDesc("liked")

.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));

// 获取当前页数据

List 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,即元素存在就获取到分数,元素不存在就返回nullzset中有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;

/**

*

* 服务实现类

*

*

* @author 虎哥

* @since 2021-12-22

*/

@Service

public class BlogServiceImpl extends ServiceImpl implements IBlogService {

@Resource

private IUserService userService;

@Autowired

private StringRedisTemplate stringRedisTemplate;

@Override

public Result queryHotBlog(Integer current) {

// 根据用户查询

Page page = query()

.orderByDesc("liked")

.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));

// 获取当前页数据

List 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 userIds = stringRedisTemplate.opsForZSet().range(key, 0, 4);

if(userIds==null || userIds.isEmpty()){

return Result.ok(Collections.emptyList());

}

//查询数据库封装UserDTO返回(严格来说这里是UserVo)

Set userDTOS = userIds.stream()

.map(new Function() {

@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.*;

/**

*

* 前端控制器

*

*

* @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 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 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 page = blogService.query()

.eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));

//获取当前页的数据

List 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 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 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 commonFollowUserIds = stringRedisTemplate.opsForSet().intersect(key1, key2);

if(commonFollowUserIds==null || commonFollowUserIds.isEmpty()){

return Result.ok(Collections.EMPTY_SET);

}

Set longIds = commonFollowUserIds.stream().map(Long::valueOf).collect(Collectors.toSet());

List users = userService.listByIds(longIds);

List 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 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 commonFollowUserIds = stringRedisTemplate.opsForSet().intersect(key1, key2);

if(commonFollowUserIds==null || commonFollowUserIds.isEmpty()){

return Result.ok(Collections.EMPTY_SET);

}

Set longIds = commonFollowUserIds.stream().map(Long::valueOf).collect(Collectors.toSet());

List users = userService.listByIds(longIds);

List 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 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());

}

相关链接

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: