1 基础篇
1.1 初识Redis
1.2 Redis常见命令
1.3 Redis的Java客户端
- Jedis和Lettuce:这两个主要是提供了Redis命令对应的API,方便我们操作Redis
- SpringDataRedis又对这两种做了抽象和封装
1.3.1 Jedis客户端(一般不使用这个)
- Jedis的官网地址: https://github.com/redis/jedis
1.3.2 SpringDataRedis客户端
- SpringDataRedis中提供了RedisTemplate工具类,其中封装了各种对Redis的操作
- 将不同数据类型的操作API封装到了不同的类型中:
1.3.2.1 快速入门
- SpringBoot已经提供了对SpringDataRedis的支持,使用非常简单
- 首先,新建一个maven项目,然后按照下面步骤执行:
- 1)引入依赖
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--连接池依赖common-pool 这个是必须的 默认使用到-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
- 2)配置Redis:application.yml文件
spring:
redis:
host: 192.168.150.101
port: 6379
password: 123321
# springboot默认使用lecctuce 如果要使用jedis 需要自己引入jedis的依赖
lettuce:
pool:
# 最大连接
max-active: 8
# 最大空闲连接
max-idle: 8
# 最小空闲连接
min-idle: 0
# 最大等待时长,没有等到会报错
max-wait: 100ms
- 3)注入RedisTemplate
- SpringBoot的自动装配,我们可以拿来就用:
@SpringBootTest
class RedisStringTests {
@Autowired
private RedisTemplate edisTemplate;
@Test
void testString() {
// 写入一条String数据
redisTemplate.opsForValue().set("name", "虎哥");
// 获取string数据
Object name = stringRedisTemplate.opsForValue().get("name");
System.out.println("name = " + name);
}
}
1.3.2.2 自定义序列化
- RedisTemplate可以接收任意Object作为值写入Redis:
- 只不过写入前会把Object序列化为字节形式,默认是采用JDK序列化,得到的结果是这样的:
- 缺点:可读性差、内存占用较大
- 可以自定义RedisTemplate的序列化方式,代码如下:
- com/heima/redis/config/RedisConfig.java
// redis配置类
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory){
// 1 创建RedisTemplate对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 2 设置连接工厂
template.setConnectionFactory(connectionFactory);
// 3 创建JSON序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer =
new GenericJackson2JsonRedisSerializer();
// 4.1 设置Key的序列化 key和hashkey采用String序列化
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 4.2 设置Value的序列化 value和hashvalue采用JSON序列化
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);
// 5 返回
return template;
}
}
- com/heima/RedisDemoApplicationTests.java
@SpringBootTest
class RedisDemoApplicationTests {
@Autowired
private RedisTemplate<String,Object> redisTemplate;
@Test
void testString() {
// 写入一条String数据
redisTemplate.opsForValue().set("name", "虎哥");
// 获取string数据
Object name = redisTemplate.opsForValue().get("name");
System.out.println("name = " + name);
}
@Test
void testSaveUser() {
// 写入数据
redisTemplate.opsForValue().set("user:100", new User("虎哥", 21));
// 获取数据
User o = (User) redisTemplate.opsForValue().get("user:100");
System.out.println("o = " + o);
}
}
- 采用了JSON序列化来代替默认的JDK序列化方式,存储自定义对象的时候会这样:
- 总结
1、整体可读性有了很大提升
2、能将Java对象自动的序列化为JSON字符串,并且查询时能自动把JSON反序列化为Java对象
3、但是记录了序列化时对应的class名称,是为了查询时实现自动反序列化,这会带来额外的内存开销
1.3.2.3 StringRedisTemplate
- 为了节省内存空间,不使用JSON序列化器来处理value,而是统一使用String序列化器
- 要求只能存储String类型的key和value
- 当需要存储Java对象时,手动完成对象的序列化和反序列化
- 这种用法比较普遍,SpringDataRedis就提供了RedisTemplate的子类:StringRedisTemplate
- 它的key和value的序列化方式默认就是String方式
- 省去了我们自定义RedisTemplate的序列化方式的步骤,而是直接使用:
package com.heima;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.heima.redis.pojo.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.Map;
@SpringBootTest
class RedisStringTests {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 存字符串
*/
@Test
void testString() {
// 写入一条String数据
stringRedisTemplate.opsForValue().set("verify:phone:13600527634", "124143");
// 获取string数据
Object name = stringRedisTemplate.opsForValue().get("name");
System.out.println("name = " + name);
}
// springmvc中默认使用的JSON序列化工具
private static final ObjectMapper mapper = new ObjectMapper();
/**
* 存对象
*/
@Test
void testSaveUser() throws JsonProcessingException {
// 创建对象
User user = new User("虎哥", 21);
// 手动序列化
String json = mapper.writeValueAsString(user);
// 写入数据
stringRedisTemplate.opsForValue().set("user:200", json);
// 获取数据
String jsonUser = stringRedisTemplate.opsForValue().get("user:200");
// 手动反序列化
User user1 = mapper.readValue(jsonUser, User.class);
System.out.println("user1 = " + user1);
}
@Test
void testHash() {
// hash存用的是put 取一个用的是get
stringRedisTemplate.opsForHash().put("user:400", "name", "虎哥");
stringRedisTemplate.opsForHash().put("user:400", "age", "21");
// 一次性把所有的key value都取出来 用entries
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries("user:400");
System.out.println("entries = " + entries);
}
@Test
void name() {
}
}
2 实战篇
2.1 短信登录
2.1.1 导入黑马点评项目(看黑马文件)
- 记得修改mysql、redis的配置信息
2.1.2 基于Session实现登录流程
-
发送验证码:
-
短信验证码登录、注册:
-
校验登录状态:
2.1.3 实现发送短信验证码功能
-
页面流程
-
具体代码如下
/**
* 发送手机验证码
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1 校验手机号
if (RegexUtils.isPhoneInvalid(phone)){
// 2 如果返回true 则说明不符合
// 如果不符合 返回错误信息
return Result.fail("手机号格式错误");
}
// 2 生成验证码
String code = RandomUtil.randomNumbers(6);
// 3 保存验证码到session
session.setAttribute("code",code);
// 4 发送验证码
log.debug("成功发送短信验证码,验证码为:"+code);
return Result.ok();
}
2.1.4 实现登录功能
/**
* 登录功能
*
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1 校验手机号
// 获取验证码时手机号是正确的 登录时也许会改变
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2 如果不符合 返回错误信息
return Result.fail("手机号格式错误");
}
// 2 校验验证码
// 2.1 用户输入的code
String code = loginForm.getCode();
// 2.1 保存在session中的code
String sessionCode = (String) session.getAttribute("code");
// 这样写太繁琐 换一种方式写
// if (sessionCode == null) {
// return Result.fail("验证码错误");
// } else {
// if (!sessionCode.equals(code)) {
// return Result.fail("验证码错误");
// }
// }
if (sessionCode == null || !sessionCode.equals(code)) {
// 3 验证码不一致 报错
return Result.fail("验证码错误");
}
// 4 验证码不一致 根据手机号查询用户
LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>();
lqw.eq(User::getPhone, phone);
User user = baseMapper.selectOne(lqw);
// 5 判断用户是否存在
if (null == user) {
// 6 不存在 创建用户并且保存
user = createUserWithPhone(phone);
}
// 7 保存用户到session
session.setAttribute("user",user);
return Result.ok();
}
/**
* 根据手机号创建用户
* @param phone 手机号
* @return 用户对象
*/
private User createUserWithPhone(String phone) {
// 创建用户
User user = new User();
user.setPhone(phone);
// 创建一个有规律的随机用户名
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
baseMapper.insert(user);
return user;
}
2.1.5 登录校验功能
- 关于threadlocal
1、每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收
2、每个请求都是独立的,在每个用户去访问工程时,可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据
3、在threadLocal中,无论是put方法和get方法, 都是获得当前用户的线程,然后从线程中取出线程的成员变量map
4、只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离
- 请求头的Cookie中含有SessionId
- 拦截器代码
package com.hmdp.interceptor;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1 获取session
HttpSession session = request.getSession();
// 2 获取session中的而用户
UserDTO user = (UserDTO)session.getAttribute("user");
// 3 判断用户是否存在
if (user == null){
// 4 不存在 拦截 返回401状态码
response.setStatus(401);
return false;
}
// 5 存在 保存用户信息到Threadlocal中
UserHolder.saveUser(user);
// 6 放行
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.interceptor.LoginInterceptor;
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 {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
// 不需要拦截的路径
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/upload/**",
"/shop-type/**",
"/voucher/**"
);
}
}
- com/hmdp/controller/UserController.java
/**
* 登录校验功能
* @return
*/
@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
2.1.6 隐藏用户敏感信息
- 通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱
- 应当在返回用户信息之前,将用户的敏感信息进行隐藏
- 采用的核心思路就是书写一个UserDto对象,这个UserDto对象没有敏感信息
- 改变登录功能的代码
// 7 保存用户到session
// user中含有全部信息 包括许多敏感信息 需要把user转化成userDTo
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
- 在拦截器处:
// 2 获取session中的而用户
UserDTO user = (UserDTO)session.getAttribute("user");
- 在UserHolder处:将user对象换成UserDTO
2.1.7 session共享问题
2.1.8 Redis代替session的业务流程
2.1.8.1 设计key的结构
2.1.8.2 设计key的具体细节
- 关于key的处理,session他是每个用户都有自己的session
- 但是redis的key是共享的,就不能使用code了
- 在后台生成一个随机串token,然后让前端带来这个token就能完成整体逻辑
2.1.8.3 整体访问流程
2.1.9 基于Redis实现短信登录
- com/hmdp/service/impl/UserServiceImpl.java
/**
* 发送手机验证码
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2 如果返回true 则说明不符合
// 如果不符合 返回错误信息
return Result.fail("手机号格式错误");
}
// 3 生成验证码
String code = RandomUtil.randomNumbers(6);
// 4 保存验证码到redis 设置有效期
// 把key设置为常量 有前缀的 login:code
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,2, TimeUnit.MINUTES);
// 5 发送验证码
log.debug("成功发送短信验证码,验证码为:" + code);
return Result.ok();
}
/**
* 登录功能
*
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1 校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)){
// 2 如果不符合 返回错误信息
return Result.fail("手机号格式错误");
}
// 2 校验验证码
// 用户输入的code
String code = loginForm.getCode();
// 从redis获取验证码校验
String redisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
if (redisCode == null || !redisCode.equals(code)){
// 3 不一致 报错
return Result.fail("验证码错误");
}
// 4 根据手机号查询用户
LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>();
lqw.eq(User::getPhone,phone);
User user = baseMapper.selectOne(lqw);
// 5 判断用户是否存在
if (user == null){
// 6 不存在 创建用户并且保存
user=createUserWithPhone(phone);
}
// 7 保存用户信息到redis中
// 7.1 随机生成token 作为登录令牌
// 使用UUID生成token
String token = UUID.randomUUID().toString();
// 7.2 将User对象转为Hash存储
// 将user转变为userDto
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// userDTO转变为Map结构
// Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
// id是long类型的数据 stringRedisTemplate要求所有数据都必须是string 需要转换成string
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
// 忽略空的值
.setIgnoreNullValue(true)
// 修改字段值 字段名 字段值 -> 修改后的字段名 修改后的字段值
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3 存储 使用Hash结构存储
// 使用putAll:里面有多个key value 需要把userDto转变为Map结构
// 记得设置有效期 session有效期30分钟
// java.lang.Long cannot be cast to java.lang.String
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,userMap);
// 7.4 设置token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);
// 8 返回token
return Result.ok(token);
}
/**
* 根据手机号创建用户
* @param phone 手机号
* @return 用户对象
*/
private User createUserWithPhone(String phone) {
// 创建用户
User user = new User();
user.setPhone(phone);
// 创建一个有规律的随机用户名
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
baseMapper.insert(user);
return user;
}
- 对于token有效期的设置:每当有一个请求进来,有效期就应该刷新,需要改变拦截器的代码
- com/hmdp/interceptor/LoginInterceptor.java
// 这里是拦截器 不在spring容器中 不能同@Autowired注入stringRedisTemplate
// 换个方法 在配置类中注入
// 或者加个注解:@Configuration 就可以自动注入啦
private StringRedisTemplate stringRedisTemplate;
// 构造函数
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1 获取请求头中的token
// 这一步需要前端把token保存到请求头中
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
// 不存在 拦截 返回401状态码
response.setStatus(401);
return false;
}
// 2 基于token获取redis中的用户
// 只用 get 只能取出一个
// 用entrise 取出的是一个map
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY+token);
// 3 判断用户是否存在
// 如果userMap是null的话 自动返回一个空值
if (userMap.isEmpty()){
return true;
}
// 5 将查询到的Map对象转为UserDto
// 忽略转换过程的错误:肯定不忽略
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(),false);
// 6 存在 保存用户信息到Threadlocal中
UserHolder.saveUser(userDTO);
// 7 刷新token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8 放行
return true;
}
- com/hmdp/config/MvcConfig.java
// @Configuration:这里可以注入
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
// 不需要拦截的路径
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/upload/**",
"/shop-type/**",
"/voucher/**"
);
}
2.1.10 登录拦截器的优化
2.1.10.1 初始方案思路总结:
- 现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效
- 此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的
2.1.10.2 优化方案:
- com/hmdp/interceptor/RefreshTokenInterceptor.java
package com.hmdp.interceptor;
import cn.hutool.core.bean.BeanUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import io.netty.util.internal.StringUtil;
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;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;
public class RefreshTokenInterceptor implements HandlerInterceptor {
// 这里是拦截器 不在spring容器中 不能同@Autowired注入stringRedisTemplate
// 换个方法 在配置类中注入
// 或者加个注解:@Configuration 就可以自动注入啦
private StringRedisTemplate stringRedisTemplate;
// 添加一个构造器
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1 获取token(请求头中的)
// 这一步需要前端把token保存到请求头中
String token = request.getHeader("authorization");
if(StringUtil.isNullOrEmpty(token)){
// 不需要拦截 返回true 直接放行 后面还有一个拦截器
return true;
}
// 2 基于token获取redis中的用户
// 只用 get 只能取出一个
// 用entrise 取出的是一个map
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY+token);
// 3 判断用户是否存在
if (userMap.isEmpty()){
// 不需要拦截 返回true 直接放行 后面还有一个拦截器
return true;
}
// 5 将查询到的Map对象转为UserDto
// 忽略转换过程的错误:肯定不忽略
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(),false);
// 6 存在 保存用户信息到Threadlocal中
UserHolder.saveUser(userDTO);
// 7 刷新token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
- com/hmdp/interceptor/LoginInterceptor.java
package com.hmdp.interceptor;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import io.netty.util.internal.StringUtil;
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.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1 判断是否需要拦截(threadlocal中是否有我们的用户)
if(UserHolder.getUser() == null){
// 没有 需要拦截 设置状态码
response.setStatus(401);
// 拦截
return false;
}
// 有用户 放行
return true;
}
}
- com/hmdp/config/MvcConfig.java
package com.hmdp.config;
import com.hmdp.interceptor.LoginInterceptor;
import com.hmdp.interceptor.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:这里可以注入
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 后执行 默认情况下按照添加顺序执行 拦截需要登录的请求
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/upload/**",
"/shop-type/**",
"/voucher/**"
)
// 确定执行顺序 越小越先执行
.order(1);
// 先执行 拦截所有请求
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**")
// 确定执行顺序 越小越先执行
.order(0);
}
}
2.2 商户查询缓存
2.2.1 什么是缓存
- 缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取
2.2.2 添加商户缓存
- 缓存模型和思路
- com/hmdp/service/impl/ShopServiceImpl.java
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@Override
public Result queryById(Long id) {
// key要唯一 就用id
String key = CACHE_SHOP_KEY + id;
// 1 从redis查询商铺缓存 以店铺ID为key
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2 判断是否存在
// null "" "\t\n" 都会被认为是false
if (StrUtil.isNotBlank(shopJson)) {
// 3 存在直接返回 JSON格式变回类对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4 shop不存在 根据id查询数据库
Shop shopById = iShopService.getById(id);
// 5 不存在 返回错误
if (shopById == null){
return Result.fail("该商铺不存在");
}
// 6 存在 写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shopById));
// 7 返回
return Result.ok(shopById);
}
2.2.3 缓存更新策略
- 缓存更新是redis为了节约内存而设计出来,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,把他叫为淘汰更合适
- 三种更新策略对比
- 数据库缓存不一致解决方案
- 数据库和缓存不一致具体解决方法:
- 删除缓存还是更新缓存?
更新缓存:每次更新数据库都更新缓存,无效写操作较多
删除缓存:更新数据库时让缓存失效,查询时再更新(选择这个)
- 先操作缓存还是先操作数据库?
先删除缓存,再操作数据库
先操作数据库,再删除缓存(选择这个)
2.2.4 实现商铺和缓存与数据库双写一致
- 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,设置超时时间
- 根据id修改店铺时,先修改数据库,再删除缓存
- 修改重点代码1:修改ShopServiceImpl的queryById方法:设置redis缓存时添加过期时间
// 6 存在 写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shopById)
,CACHE_SHOP_TTL, TimeUnit.MINUTES);
- com/hmdp/service/impl/ShopServiceImpl.java
- 重写shopController的更新方法
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@Override
// 保证原子性
@Transactional
public Result updateShop(Shop shop) {
// 获取店铺id
Long id = shop.getId();
if (id == null){
return Result.fail("店铺ID不能为空");
}
// 1 更新数据库
iShopService.updateById(shop);
// 2 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok();
}
2.2.5 缓存穿透问题的解决思路
- 缓存空对象(本项目选择的)
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
- 布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
2.2.6 编码解决商品查询的缓存穿透问题
- com/hmdp/service/impl/ShopServiceImpl.java
package com.hmdp.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisData;
import org.springframework.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;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private IShopService iShopService;
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@Override
public Result queryById(Long id) {
// key要唯一 就用id
String key = CACHE_SHOP_KEY + id;
// 1 从redis查询商铺缓存 以店铺ID为key
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2 判断是否存在
// null "" "\t\n" 都会被认为是false
if (StrUtil.isNotBlank(shopJson)) {
// 3 存在直接返回 JSON格式变回类对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断是否是空值 是空值的话 就说明店铺不存在
if (shopJson == ""){
// 返回一个错误信息
return Result.fail("该店铺不存在");
}
// 4 shop不存在 根据id查询数据库 shopJson == null
Shop shopById = iShopService.getById(id);
// 5 不存在 返回错误
if (shopById == null){
// 5.1 将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
// 5.2 返回错误信息
return Result.fail("该商铺不存在");
}
// 6 存在 写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shopById)
,CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7 返回
return Result.ok(shopById);
}
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@Override
// 保证原子性
@Transactional
public Result updateShop(Shop shop) {
// 获取店铺id
Long id = shop.getId();
if (id == null){
return Result.fail("店铺ID不能为空");
}
// 1 更新数据库
iShopService.updateById(shop);
// 2 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok();
}
}
2.2.7 缓存雪崩问题及解决思路
- 缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
- 解决方案:
给不同的Key的TTL添加随机值
利用Redis集群提高服务的可用性(微服务)
给缓存业务添加降级限流策略(微服务)
给业务添加多级缓存(微服务)
2.2.8 缓存击穿问题及解决思路
- 缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
- 产生危害:同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
- 使用锁来解决
- 逻辑过期方案:不设置过期时间,其实就不会有缓存击穿的问题,采用逻辑过期方案
- 进行对比
2.2.9 利用互斥锁解决缓存击穿问题
- 操作锁的代码: 核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false
2.2.10 利用逻辑过期解决缓存击穿问题
- 代码汇总
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisData;
import org.springframework.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;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private IShopService iShopService;
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queyWithPassThrough(id);
// 互斥锁解决缓存击穿
Shop shop = queyWithMutex(id);
// 使用逻辑过期解决缓存击穿
// 7 返回
if (shop == null){
return Result.fail("该店铺不存在");
}
return Result.ok(shop);
}
// 线程池
private static final ExecutorService CACHE_RREBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queyWithLogicalExpie(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1 从redis查询商铺缓存 以店铺ID为key
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2 判断是否存在
// null "" "\t\n" 都会被认为是false
if (StrUtil.isBlank(shopJson)) {
// 3 不存在直接返回 JSON格式变回类对象
return null;
}
// 4 shopJson不为空 则需要把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
// 先强行转化为JSONObject
JSONObject data = (JSONObject)redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5 判断逻辑时间是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 6 未过期 则直接返回商铺信息
return shop;
}
// 7 过期 需要重建缓存
// 7.1 尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY+id;
boolean flag = tryLock(lockKey);
if (flag){
// 7.3 获取互斥锁成功 开启独立线程 实现缓存重建
CACHE_RREBUILD_EXECUTOR.submit(()->{
try {
// 重建缓存
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(lockKey);
}
});
}
// 7.2 获取互斥锁失败 返回店铺信息
// 不管成功还是失败 最后都是要返回shop
return shop;
}
/**
* 存储逻辑过期时间
*
* @param id
*/
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1 查询商铺信息
Shop shop = iShopService.getById(id);
Thread.sleep(200);
// 2 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3 写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
/**
* 互斥锁解决缓存击穿
*
* @param id
* @return
*/
public Shop queyWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1 从redis查询商铺缓存 以店铺ID为key
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2 判断是否存在
// null "" "\t\n" 都会被认为是false
if (StrUtil.isNotBlank(shopJson)) {
// 3 存在直接返回 JSON格式变回类对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 是一个空值
if (shopJson.equals("")) {
return null;
}
// 既没有数据 也没有空值 是null
// 4 实现缓存重建
// 4.1 获取互斥锁
// 锁的key和缓存的key不一样
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean flag = tryLock(lockKey);
// 4.2 判断是否获取成功
if (!flag) {
// 4.3 失败 则休眠并且重试
Thread.sleep(50);
// 进行递归 要返回
return queyWithMutex(id);
}
// 4.4 成功 根据id查询数据库
shop = iShopService.getById(id);
// 模拟重建缓存 在本地查询太快了 休眠一下
Thread.sleep(200);
// 查询数据库结果 不存在 返回错误
if (shop == null) {
// 将空值写入redis 这里写入的是空值 而不是Null
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 4.5 将商铺数据写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop)
, CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 5 最终必须释放互斥锁
unLock(lockKey);
}
// 6 返回数据
return shop;
}
/**
* 获取锁
*/
public boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 不能直接返回 有可能会出现空指针
// 上面是引用类型 转换为基本类型
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
public void unLock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 缓存穿透代码
* 返回空或者数据本身
* @param id
* @return
*/
public Shop queyWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1 从redis查询商铺缓存 以店铺ID为key
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2 判断是否存在
// null "" "\t\n" 都会被认为是false
if (StrUtil.isNotBlank(shopJson)) {
// 3 存在直接返回 JSON格式变回类对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 是一个空值
if (shopJson.equals("")) {
return null;
}
// 4 不存在 根据id查询数据库
Shop shopById = iShopService.getById(id);
// 5 查询数据库结果 不存在 返回错误
if (shopById == null) {
// 将空值写入redis 这里写入的是空值 而不是Null
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6 存在 写入redis 设置过期时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopById)
, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7 返回
return shopById;
}
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@Override
// 保证原子性
@Transactional
public Result updateShop(Shop shop) {
// 获取店铺id
Long id = shop.getId();
if (id == null){
return Result.fail("店铺ID不能为空");
}
// 1 更新数据库
iShopService.updateById(shop);
// 2 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok();
}
}
2.3 优惠卷秒杀
2.3.1 全局唯一ID
- 全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性
- 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
- ID的组成部分:符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
2.3.2 Redis实现全局唯一Id
- com/hmdp/utils/RedisIdWorker.java:新建一个类
package com.hmdp.utils;
import org.springframework.beans.factory.annotation.Autowired;
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 RedisIdWorker {
// 定义一个初始时间戳
public static final long BEGIN_TIME = 1640995200L;
// 序列号位数
public static final long COUNT_BITS = 32;
// 用到redis的自增长
@Autowired
private StringRedisTemplate stringRedisTemplate;
public long nextId(String keyPrefix){
// 1 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSecond - BEGIN_TIME;
// 2 生成序列号
// 确定当天序列号的key 获取当天日期 精确到天
String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:DD"));
// 实现自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + data);
// 3 拼接并返回 借助位运算
// 移位之后后面32位全都是0 或运算可以保证原来的样子
return timeStamp << COUNT_BITS | count;
}
public static void main(String[] args) {
// LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
// long timeToSecond = time.toEpochSecond(ZoneOffset.UTC);
// System.out.println(timeToSecond);
}
}
- stringRedisTemplate.opsForValue().increment函数:
2.3.3 添加优惠卷
- 每个店铺都发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
- tb_voucher:优惠券的基本信息,优惠金额、使用规则等
- tb_seckill_voucher:秒杀券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
- 新增普通卷代码: VoucherController
/**
* 新增普通券
* @param voucher 优惠券信息
* @return 优惠券id
*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
- 新增秒杀卷代码:
- VoucherController
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
- VoucherServiceImpl
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
baseMapper.insert(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
}
- 最后还有查询优惠券
2.3.4 实现秒杀下单
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
- com/hmdp/service/impl/VoucherOrderServiceImpl.java
package com.hmdp.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.Voucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedisIdWorker redisIdWorker;
/**
* 优惠券秒杀功能
* @param voucherId
* @return
*/
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1 根据id查询优惠券信息
LambdaQueryWrapper<SeckillVoucher> lqw = new LambdaQueryWrapper<>();
lqw.eq(SeckillVoucher::getVoucherId,voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(lqw);
// 2 判断秒杀是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3 判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4 判断库存是否充足
if (seckillVoucher.getStock()<1){
return Result.fail("秒杀券已经不足");
}
// 5 扣减库存
seckillVoucher.setStock(seckillVoucher.getStock()-1);
boolean success = seckillVoucherService.update(seckillVoucher, null);
if (!success){
return Result.fail("秒杀券已经不足");
}
// 6 创建订单 写入数据库
VoucherOrder voucherOrder = new VoucherOrder();
// 生成全局唯一Id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
baseMapper.insert(voucherOrder);
// 7 返回订单id
return Result.ok(orderId);
}
}
2.3.5 库存超卖问题分析
- 有关超卖问题分析:在我们原有代码中是这么写的
// 4 判断库存是否充足
if (seckillVoucher.getStock()<1){
return Result.fail("秒杀券已经不足");
}
// 5 扣减库存
seckillVoucher.setStock(seckillVoucher.getStock()-1);
boolean success = seckillVoucherService.update(seckillVoucher, null);
if (!success){
return Result.fail("秒杀券已经不足");
}
- 假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存
- 超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁
- 对于加锁,我们通常有两种解决方案
2.3.6 乐观锁解决超卖问题(学习了多线程再来补充)
2.3.7 优惠券秒杀(一人一单)
- 需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
- @Transactional和synchronized什么时候提交事务、什么时候释放锁
- P53和P54后面再看看
2.3.8 集群环境下的并发问题
- 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
- 将服务启动两份,端口分别为8081和8082:(记得更改端口)
- 然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:
- 有关锁失效原因分析:部署了多个tomcat,每个tomcat都有一个属于自己的jvm,每个jvm都有一个自己的锁对象