初始化项目:
短信登录
![](https://img-blog.csdnimg.cn/img_convert/f01340d4825d8e40cb49dfdbd390481a.png)基于Session实现登录
发送短信验证码
![](https://img-blog.csdnimg.cn/img_convert/7d43f4d27e3b03ff579659b9f67d0669.png) /**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
/**
* 发送验证码
*
* @param phone
* @param session
* @return
*/
Result sendCode(String phone, HttpSession session);
/**
* 发送验证码
*
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到session
session.setAttribute("code", code);
// todo:5.发送验证码到手机
log.debug("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
短信验证码登录、注册
![](https://img-blog.csdnimg.cn/img_convert/3adf515050df24e46b6f6ae5c14387fd.png) /**
* 登录功能
*
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
return userService.login(loginForm, session);
}
/**
* 登录
* @param loginForm
* @param session
* @return
*/
Result login(LoginFormDTO loginForm, HttpSession session);
/**
* 登录
*
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
// 如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
// 3.不一致,报错
return Result.fail("验证码错误!");
}
// 4.一致,根据手机号查询用户
User user = query().eq("phone", loginForm.getPhone()).one();
// 5.用户是否存在
if (user == null) {
// 5.1 不存在,注册新用户
user = createWithPhone(loginForm.getPhone());
}
// 5.2 存在,直接登录
session.setAttribute("user", user);// 将用户信息存入session
return Result.ok();
}
校验登录状态
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中的用户
Object user = session.getAttribute("user");
// 3.判断用户是否存在
if (user == null) {
// 4.不存在,拦截
response.setStatus(401);
return false;
}
// 5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser((User) user);
// 6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
import com.hmdp.entity.User;
public class UserHolder {
private static final ThreadLocal<User> tl = new ThreadLocal<>();
public static void saveUser(User user){
tl.set(user);
}
public static User getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
隐藏用户敏感信息
```java @Data public class UserDTO { private Long id; private String nickName; private String icon; } ```@GetMapping("/me")
public Result me() {
// TODO 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
/**
* 登录
*
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
// 如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
// 3.不一致,报错
return Result.fail("验证码错误!");
}
// 4.一致,根据手机号查询用户
User user = query().eq("phone", loginForm.getPhone()).one();
// 5.用户是否存在
if (user == null) {
// 5.1 不存在,注册新用户
user = createWithPhone(loginForm.getPhone());
}
// 5.2 存在,直接登录
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
session.setAttribute("user", userDTO);
return Result.ok();
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取session
HttpSession session = request.getSession();
// 2.获取session中的用户
Object user = session.getAttribute("user");
// 3.判断用户是否存在
if (user == null) {
// 4.不存在,拦截
response.setStatus(401);
return false;
}
// 5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);
// 6.放行
return true;
}
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
集群的session共享问题
** session共享问题** :多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。session的替代方案应该满足:
- 数据共享
- 内存存储
- key、value结构
基于Redis实现共享session登录
![](https://img-blog.csdnimg.cn/img_convert/33be3cd9f8f9b651682d9242cbc70f9e.png)/**
* 发送验证码
*
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到redis // set key value ex 120
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// todo:5.发送验证码到手机
log.debug("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
保存登录的用户信息,可以使用String结构,以JSON字符串来保存,比较直观:
KEY | VALUE |
---|---|
heima:user:1 | { name:“Jack”, age:21} |
heima:user:2 | { name:“Rose”, age:18} |
Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少:
KEY | VALUE | |
---|---|---|
field | value | |
heima:user:1 | name | Jack |
age | 21 | |
heima:user:2 | name | Rose |
age | 18 |
/**
* 登录
*
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
// 如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 2.从redis获取校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 3.不一致,报错
return Result.fail("验证码错误!");
}
// 4.一致,根据手机号查询用户
User user = query().eq("phone", loginForm.getPhone()).one();
// 5.用户是否存在
if (user == null) {
// 5.1 不存在,注册新用户
user = createWithPhone(loginForm.getPhone());
}
// 5.2 存在,直接登录,保存用户到redis
// 5.2.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 5.2.2 将User对象转为Hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 5.2.3 将token和user存储到redis
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);
// 5.2.4 设置token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 6.返回token
return Result.ok(token);
}
Redis代替session需要考虑的问题:
- ****选择合适的数据结构
- ****选择合适的key
- 选择合适的存储粒度
解决状态登录刷新问题
登录拦截器的优化
![](https://img-blog.csdnimg.cn/img_convert/644b7cb725abd99372c18f19e98ea7a9.png)将拦截器分为登录拦截器和token刷新的拦截器并配置到MvcConfig中
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;
}
}
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
// 不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
// 2.基于token获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
// 4.不存在,拦截
response.setStatus(401);
return false;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1); // order值越大,执行的优先级反而越低
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
商户查询缓存
什么是缓存
** 缓存** 就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。添加Redis缓存
给查询的商铺信息添加缓存/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
/**
* 根据id查询店铺
* @param id
* @return
*/
Result queryById(Long id);
@Override
public Result queryById(Long id) {
// 1.从redis查询商品缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StringUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误信息
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("店铺不存在");
}
// 6.存在,将商品数据存入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回商品信息
return Result.ok(shop);
}
案例:给商铺类型查询业务添加缓存
店铺类型在首页和其它多个页面都会用到,如图:
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
@Resource
private IShopTypeService typeService;
@GetMapping("list")
public Result queryTypeList() {
// List<ShopType> typeList = typeService
// .query().orderByAsc("sort").list();
// return Result.ok(typeList);
return typeService.getTypeList();
}
}
public interface IShopTypeService extends IService<ShopType> {
/**
* 获取店铺类型列表
* @return
*/
Result getTypeList();
}
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 获取店铺类型列表
*
* @return
*/
@Override
public Result getTypeList() {
// 1.从redis中查询店铺类型
List<String> shopTypes = stringRedisTemplate.opsForList().range("shop_type", 0, -1);
// 2.判断是否命中缓存
if (!shopTypes.isEmpty()) {
// 3.命中则转为ShopType类型直接返回
List<ShopType> tmp = shopTypes.stream()
.map(type -> JSONUtil.toBean(type, ShopType.class))
.collect(Collectors.toList());
return Result.ok(tmp);
}
// 4.未命中则查询数据库,判断商铺类型是否存在
List<ShopType> tmp = query().orderByAsc("sort").list();
if (tmp.isEmpty()) {
return Result.fail("店铺类型不存在");
}
// 5.不存在,返回错误信息
shopTypes = tmp.stream()
.map(type -> JSONUtil.toJsonStr(type))
.collect(Collectors.toList());
// 6.存在则将商铺数据写入redis
stringRedisTemplate.opsForList().leftPushAll("cache_shop_type", shopTypes);
// 6.根据查询结果返回
return Result.ok(tmp);
}
}
缓存更新策略
| | ** 内存淘汰** | ** 超时剔除** | ** 主动更新** | | --- | --- | --- | --- | | ** 说明** | 不用自己维护,利用 Redis 的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存。 | 给缓存数据添加 TTL 时间,到期后自动删除缓存。下次查询时更新缓存。 | 编写业务逻辑,在修改数据库的同时,更新缓存。 | | ** 一致性** | 差 | 一般 | 好 | | ** 维护成本** | 无 | 低 | 高 |业务场景:
- 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
- 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
先删除缓存,再操作数据库
![](https://img-blog.csdnimg.cn/img_convert/7d400d78892e188ab5016e5c62233eaf.png)![](https://img-blog.csdnimg.cn/img_convert/29efec585a078c5a3a8cd4f241ca65de.png)先操作数据库,再删除缓存
![](https://img-blog.csdnimg.cn/img_convert/69cc29a116d8057b7d97f70af8956ad9.png)![](https://img-blog.csdnimg.cn/img_convert/42fcb2c963c2d260fa56ac1f22f3d5a4.png)@Override
public Result queryById(Long id) {
// 1.从redis查询商品缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StringUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return Result.fail("店铺不存在");
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误信息
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("店铺不存在");
}
// 6.存在,将商品数据存入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回商品信息
return Result.ok(shop);
}
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.update(shop);
}
/**
* 更新店铺
*
* @param shop
* @return
*/
Result update(Shop shop);
@Override
@Transactional // 开启事务
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
{
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
"address": "金华路锦昌文华苑29号",
"comments": 3035,
"avgPrice": 80,
"score": 37,
"name": "鸡你太美茶餐厅",
"typeId": 1,
"id": 1
}
缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis****自带的内存淘汰机制
2.****高一致性需求:主动更新,并以超时剔除作为兜底方案
- ****读操作:
- ****缓存命中则直接返回
- ****缓存未命中则查询数据库,并写入缓存,设定超时时间
- ****写操作:
- ****先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
缓存穿透
** 缓存穿透** 是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。常见的解决方案有两种:
-
缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
-
布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
@Override
public Result queryById(Long id) {
// 1.从redis查询商品缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StringUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return Result.fail("店铺不存在");
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误信息
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("店铺不存在");
}
// 6.存在,将商品数据存入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回商品信息
return Result.ok(shop);
}
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
- ****缓存null值
- ****布隆过滤
- ****增强id的复杂度,避免被猜测id规律
- ****做好数据的基础格式校验
- ****加强用户权限校验
- 做好热点参数的限流
详情请见SpringCloud
缓存雪崩
** 缓存雪崩** 是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
具体实现复习SpringCloud的课程
缓存击穿(热点key)
** 缓存击穿问题** 也叫热点Key问题,就是一个被** 高并发访问** 并且** 缓存重建业务较复杂** 的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。常见的解决方案有两种:
- 互斥锁
- 逻辑过期
KEY | VALUE |
---|---|
heima:user:1 | { name:“Jack”, age:21, expire:152141223} |
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | •没有额外的内存消耗 •保证一致性 •实现简单 |
•线程需要等待,性能受影响 •可能有死锁风险 |
逻辑过期 | •线程无需等待,性能较好 | •不保证一致性 •有额外内存消耗 •实现复杂 |
基于互斥锁方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题核心思路
:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是,进行查询之后。
- 如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,直到获取到锁为止,才能进行查询
- 如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿
操作锁的代码
核心思路就是利用redis的setnx方法来表示获取锁,如果redis没有这个key,则插入成功,返回1,如果已经存在这个key,则插入失败,返回0。在StringRedisTemplate中返回true/false,我们可以根据返回值来判断是否有线程成功获取到了锁
以下代码均在ShopServiceImpl中修改
// 获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
// 缓存穿透
public Shop queryWithPassThrough(Long id) {
// 1.从redis查询商品缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误信息
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,将商品数据存入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回商品信息
return shop;
}
// 互斥锁解决缓存击穿
private Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商品缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重构
// 4.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
// 4.2 判断否获取成功
try {
if (!tryLock(lockKey)) {
// 4.3 失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 获取锁成功后应该再次检测redis缓存是否存在,做DoubleCheck,如果存在则无需重建缓存
shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
JSONUtil.toBean(shopJson, Shop.class);
}
// 4.4 成功,根据id查询数据库
shop = getById(id);
// 5.不存在,返回错误信息
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,将商品数据存入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7.释放互斥锁
unlock(lockKey);
}
// 8.返回商品信息
return shop;
}
@Override
public Result queryById(Long id) {
// 1.缓存穿透
// Shop shop = queryWithPassThrough(id);
// 2.互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
// 返回商品信息
return Result.ok(shop);
}
基于逻辑过期方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题思路分析:当用户开始查询redis时,判断是否命中
- 如果没有命中则直接返回空数据,不查询数据库
- 如果命中,则将value取出,判断value中的过期时间是否满足
- 如果没有过期,则直接返回redis中的数据
- 如果过期,则在开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后再释放互斥锁
- 封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么新建一个类包含原有的数据和过期时间
步骤一
这里我们选择新建一个实体类,包含原有数据(用万能的Object)和过期时间,这样对原有的代码没有侵入性
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
步骤二
在ShopServiceImpl中新增方法,进行单元测试,看看能否写入数据
// 把商品数据缓存到redis
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1.查询店铺数据
Shop shop = 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));
}
编写测试方法
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
public void test() throws InterruptedException {
shopService.saveShop2Redis(1L, 1000L);
}
运行测试方法,去Redis图形化页面看到存入的value,确实包含了data和expireTime
{
"data": {
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
"images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-_QiKuOvyio1OOxsRtFoXqu0G3iT2T27qat3WhLVEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vfCF2ubeXzk49OsGrXt_KYDCngOyCwZK-s3fqawWswzk.jpg,https://qcloud.dpfile.com/pc/IOf6VX3qaBgFXFVgp75w-KKJmWZjFc8GXDU8g9bQC6YGCpAmG00QbfT4vCCBj7njuzFvxlbkWx5uwqY2qcjixFEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vmIU_8ZGOT1OjpJmLxG6urQ.jpg",
"address": "金华路锦昌文华苑29号",
"comments": 3035,
"avgPrice": 80,
"updateTime": 1666502007000,
"score": 37,
"createTime": 1640167839000,
"name": "476茶餐厅",
"x": 120.149192,
"y": 30.316078,
"typeId": 1,
"id": 1
},
"expireTime": 1666519036559
}
步骤三:正式代码
正式代码我们就直接照着流程图写就好了
// 逻辑过期解决缓存击穿
private Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商品缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.不存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 未过期,直接返回店铺信息
return shop;
}
// 5.2 已过期,需要缓存重建
// 6.缓存重建
// 6.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 判断是否获取成功
if (isLock) {
// 6.3 成功,开启独立线程,实现缓存重建
// 6.3.1 检测redis缓存是否过期,如果未过期则无需重建缓存
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return shop;
}
// 6.3.2 开启独立线程
CACHE_REBUILD_POOL.submit(() -> {
// 6.3.2.1 重建缓存
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {