@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
/**
* session用户key
*/
public static final String USER_CONSTANT = "user";
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号码
boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
if (phoneInvalid) {
return Result.fail("手机号码格式错误!");
}
//生成6位数的验证码
String code = RandomUtil.randomNumbers(6);
session.setAttribute("code", code);
//发送验证码
log.info("send code success,code={}", code);
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号码
if (Objects.isNull(loginForm)) {
return Result.fail("参数为空!");
}
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号码格式错误!");
}
//验证码校验
String code = (String) session.getAttribute("code");
if (StringUtils.isBlank(code) || !StringUtils.equals(code, loginForm.getCode())) {
return Result.fail("验证码错误!");
}
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getPhone, phone);
User user = getOne(wrapper);
if (!Objects.nonNull(user)) {
//注册新用户
user = getNewUserByPhone(phone);
save(user);
}
session.setAttribute(USER_CONSTANT, BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
/**
* 根据手机号码创建新用户
*
* @param phone 手机号码
* @return
*/
private User getNewUserByPhone(String phone) {
User user = new User();
user.setCreateTime(LocalDateTime.now());
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
user.setUpdateTime(LocalDateTime.now());
return user;
}
}
session数据拷贝可以解决这个问题,但是多台tomcat之间存储相同的数据会浪费内存空间,拷贝会有数据延迟。
session每个浏览器有不同的code,tomcat里保存里很多code。
public Result sendCode(String phone, HttpSession session) {
//校验手机号码
boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
if (phoneInvalid) {
return Result.fail("手机号码格式错误!");
}
//生成6位数的验证码
String code = RandomUtil.randomNumbers(6);
//保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.SECONDS);
//发送验证码
log.info("send code success,code={}", code);
return Result.ok();
}
登录
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号码
if (Objects.isNull(loginForm)) {
return Result.fail("参数为空!");
}
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号码格式错误!");
}
//验证码校验
String code = (String) session.getAttribute("code");
if (StringUtils.isBlank(code) || !StringUtils.equals(code, loginForm.getCode())) {
return Result.fail("验证码错误!");
}
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getPhone, phone);
User user = getOne(wrapper);
if (!Objects.nonNull(user)) {
//注册新用户
user = getNewUserByPhone(phone);
save(user);
}
session.setAttribute(USER_CONSTANT, BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
登录拦截器
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.http.HttpStatus;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
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.*;
/**
* 登录拦截器
*
* @author zhangzengxiu
* @date 2023/10/6
*/
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头的token
String token = request.getHeader("authorization");
if (StringUtils.isBlank(token)) {
response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
return false;
}
//获取redis中的token
String tokenKey = LOGIN_USER_KEY + token;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(tokenKey);
if (CollectionUtils.isEmpty(map)) {
//未授权
response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
return false;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//用户信息保存到ThreadLocal中
UserHolder.saveUser(userDTO);
//刷新token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
}
拦截器
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
/**
* 这个LoginInterceptor是new出来的,所以不能使用Spring注入Bean
*/
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
}
使用拦截器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 添加拦截器
*
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration registration = registry.addInterceptor(new LoginInterceptor(stringRedisTemplate));
registration.excludePathPatterns("/user/code");
registration.excludePathPatterns("/user/login");
registration.excludePathPatterns("/blog/hot");
registration.excludePathPatterns("/shop/**");
registration.excludePathPatterns("/shop-type/**");
registration.excludePathPatterns("/voucher/**");
}
}
拦截器:配置为Spring的组件
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
}
注册拦截器:依赖注入使用即可
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
/**
* 添加拦截器
*
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration registration = registry.addInterceptor(loginInterceptor);
registration.excludePathPatterns("/user/code");
registration.excludePathPatterns("/user/login");
registration.excludePathPatterns("/blog/hot");
registration.excludePathPatterns("/shop/**");
registration.excludePathPatterns("/shop-type/**");
registration.excludePathPatterns("/voucher/**");
}
}
当前存在的问题:
如果用户访问不需要登录鉴权的接口,token就不会刷新,token可能会过期。
token刷新拦截器
import cn.hutool.core.bean.BeanUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
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;
/**
* @author zhangzengxiu
* @date 2023/10/6
*/
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头的token
String token = request.getHeader("authorization");
if (StringUtils.isBlank(token)) {
return true;
}
//获取redis中的token
String tokenKey = LOGIN_USER_KEY + token;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(tokenKey);
if (CollectionUtils.isEmpty(map)) {
//未授权
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//用户信息保存到ThreadLocal中
UserHolder.saveUser(userDTO);
//刷新token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
}
登录拦截器
import cn.hutool.http.HttpStatus;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Objects;
/**
* 登录拦截器
*
* @author zhangzengxiu
* @date 2023/10/6
*/
@Component
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(HttpStatus.HTTP_UNAUTHORIZED);
return false;
}
return true;
}
/**
* 后置拦截器
* 销毁用户信息,防止内存泄露
*
* @param request
* @param response
* @param handler
* @param ex
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
缓存就是数据交换的缓冲区称作Cache,是存储数据的临时地方,一般读写性能比较高。
计算机构造:CPU+内存+磁盘
CPU要做数据计算必须先从内存或者硬盘读取到数据,然后放到寄存器才可以运算。计算机性能受限
CPU会把经常需要读写的数据放到CPU缓存中,这样做高速运算的时候,就不需要每次从内存或者磁盘中进行数据读取,再进行运算,而是直接从缓存中获取数据进行运算。
这样可以充分释放CPU的运算能力。CPU缓存越大,可存储的数据越多,处理的性能越高。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
if (Objects.isNull(id) || id < 0) {
return Result.fail("非法商户!");
}
//从redis中查询缓存信息
String shopCacheKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopCacheKey);
if (StringUtils.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//未命中缓存查询数据库
Shop shop = getById(id);
if (Objects.isNull(shop)) {
return Result.fail("商户不存在!");
}
//缓存商户信息
stringRedisTemplate.opsForValue().set(shopCacheKey, JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
}
数据不一致情况
出现的可能性相对较低,加超时时间作为兜底!!!
出现的条件:
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求会全部打到数据库中。
缓存雪崩是指同意时段大量的缓存key同时失效或者redis宕机,导致大量请求到达数数据库,带来巨大压力。
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务复杂的key突然失效,无数的请求在瞬间给业务数据库带来巨大的冲击。
互斥锁牺牲了可用性,保证了一致性:CP
逻辑过期牺牲了一致性,保证了可用性:AP
setnx只有第一个可以操作成功,其他的都会失败。
可以设置有效期作为兜底
有效期设置为业务执行时间的10-20倍
/**
* 尝试获取锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
//setnx
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//自动拆箱 防止NPE
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 互斥锁解决缓存缓存击穿问题
*
* @param id
* @return
*/
private Shop queryShopByMutex(Long id) {
//从redis中查询缓存信息
String shopCacheKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopCacheKey);
if (StringUtils.isNotBlank(shopJson)) {
return getShopFromCache(shopJson);
}
Shop shop = null;
String lockKey = "lock:shop:" + id;
try {
//获取互斥锁
boolean isLock = tryLock(lockKey);
if (!isLock) {
//获取锁失败,休眠 重试
TimeUnit.MILLISECONDS.sleep(50);
//一直重试 会有性能问题
return queryShopByMutex(id);
}
//获取锁成功,再次查询缓存是否存在,Double Check
shopJson = stringRedisTemplate.opsForValue().get(shopCacheKey);
if (StringUtils.isNotBlank(shopJson)) {
return getShopFromCache(shopJson);
}
//未命中缓存查询数据库
shop = getById(id);
//模拟重建延时200ms
TimeUnit.MILLISECONDS.sleep(200);
if (Objects.isNull(shop)) {
//缓存空值 缓存2min
stringRedisTemplate.opsForValue().set(shopCacheKey, NULL_VAL, CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//缓存商户信息,添加过期时间 30分钟
stringRedisTemplate.opsForValue().set(shopCacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException();
} finally {
//释放互斥锁
unLock(lockKey);
}
return shop;
}
/**
* 从缓存中获取shop信息
*
* @param shopJson
* @return
*/
private Shop getShopFromCache(String shopJson) {
if (StringUtils.equals(NULL_VAL, shopJson)) {
//空值
return null;
}
return JSONUtil.toBean(shopJson, Shop.class);
}
/**
* 模拟缓存预热
*
* @param id
* @param expireSeconds 过期时间
*/
public void saveShopToRedis(Long id, long expireSeconds) {
if (Objects.isNull(id)) {
return;
}
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//未设置过期时间
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
@Autowired
private ShopServiceImpl shopService;
/**
* 单测:缓存预热
*/
@Test
public void saveShopToRedis() {
shopService.saveShopToRedis(1L, 10L);
}
/**
* 缓存重建线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 查询商户信息
* 逻辑过期解决缓存击穿问题
*
* @param id
* @return
*/
public Shop queryShopByLogicExpire(Long id) {
if (Objects.isNull(id) || id < 0) {
return null;
}
RedisData redisData = getRedisDataFromCache(id);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
//是否过期
if (!cacheIsExpire(redisData)) {
//未过期
return shop;
}
//过期
String lockKey = LOCK_SHOP_KEY + id;
//获取互斥锁
if (!tryLock(lockKey)) {
//获取互斥锁失败,返回已经过期的商户信息
return shop;
}
//获取锁成功
redisData = getRedisDataFromCache(id);
//Double Check 再次查看缓存是否过期
if (!cacheIsExpire(redisData)) {
//没过期,无需重建缓存
return JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
}
//开启独立线程进行缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShopToRedis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
return shop;
}
/**
* 缓存是否过期
*
* @param redisData
* @return
*/
private boolean cacheIsExpire(RedisData redisData) {
//是否过期 Double check
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//未过期
return false;
}
return true;
}
private RedisData getRedisDataFromCache(Long id) {
String shopCacheKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopCacheKey);
if (StringUtils.isBlank(shopJson)) {
//不存在 直接返回
return null;
}
return JSONUtil.toBean(shopJson, RedisData.class);
}
jmeter压测100QPS
查看运行结果
前面会返回旧数据
后面会返回新数据
数据会有短暂不一致的问题,但是保证了可用性。
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
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.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;
/**
* @author zhangzengxiu
* @date 2023/10/7
*/
@Slf4j
@Component
public class CacheClient {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 缓存不存在的数据
*/
public static final String NULL_VAL = "-1";
/**
* 锁key前缀
*/
private static final String LOCK_KEY = "lock:";
/**
* 缓存重建线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 设置缓存
*
* @param key
* @param value
* @param expireTime
* @param timeUnit
*/
public void set(String key, Object value, long expireTime, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), expireTime, timeUnit);
}
/**
* 逻辑过期时间
*
* @param key
* @param value
* @param expireTime
* @param timeUnit
*/
public void setLogicExpire(String key, Object value, long expireTime, TimeUnit timeUnit) {
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 解决缓存穿透问题
*
* @param id
* @param keyPrefix
* @param type
* @param function
* @param expireTime
* @param timeUnit
* @param
* @param
* @return
*/
public <R, ID> R queryWithPassThrough(ID id, String keyPrefix, Class<R> type, Function<ID, R> function, long expireTime, TimeUnit timeUnit) {
if (Objects.isNull(id)) {
return null;
}
//从redis中查询缓存信息
String cacheKey = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(json)) {
if (StringUtils.equals(NULL_VAL, json)) {
//空值
return null;
}
return JSONUtil.toBean(json, type);
}
//未命中缓存查询数据库
R res = function.apply(id);
if (Objects.isNull(res)) {
//缓存空值 缓存2min
this.set(cacheKey, NULL_VAL, 2L, TimeUnit.MINUTES);
return null;
}
//缓存添加过期时间
this.set(cacheKey, JSONUtil.toJsonStr(res), expireTime, timeUnit);
return res;
}
/**
* 逻辑过期解决缓存击穿问题
*
* @param id
* @return
*/
public <R, ID> R queryByLogicExpire(String keyPrefix, ID id, Class<R> type, long expireTime, TimeUnit unit, Function<ID, R> function) {
if (Objects.isNull(id)) {
return null;
}
String cacheKey = keyPrefix + id;
RedisData redisData = getRedisDataFromCache(cacheKey);
JSONObject data = (JSONObject) redisData.getData();
R res = JSONUtil.toBean(data, type);
//是否过期
if (!cacheIsExpire(redisData)) {
//未过期
return res;
}
//过期
String lockKey = LOCK_KEY + id;
//获取互斥锁
if (!tryLock(lockKey)) {
//获取互斥锁失败,返回已经过期的信息
return res;
}
//获取锁成功
redisData = getRedisDataFromCache(cacheKey);
//Double Check 再次查看缓存是否过期
if (!cacheIsExpire(redisData)) {
//没过期,无需重建缓存
return JSONUtil.toBean((JSONObject) redisData.getData(), type);
}
//开启独立线程进行缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//查询DB
R r = function.apply(id);
//写入redis
this.setLogicExpire(lockKey, r, expireTime, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
return res;
}
/**
* 缓存是否过期
*
* @param redisData
* @return
*/
private boolean cacheIsExpire(RedisData redisData) {
//是否过期 Double check
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//未过期
return false;
}
return true;
}
/**
* 从缓存中获取RedisData
*
* @param cacheKey
* @return
*/
private RedisData getRedisDataFromCache(String cacheKey) {
String shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isBlank(shopJson)) {
//不存在 直接返回
return null;
}
return JSONUtil.toBean(shopJson, RedisData.class);
}
/**
* 尝试获取锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
//setnx
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//自动拆箱 防止NPE
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
}
使用方式
@Override
public Result queryShopById(Long id) {
//获取店铺信息 缓存穿透
//Shop shop = queryShopByPassThrough(id);
//使用工具类实现
Shop shop = cacheClient.queryWithPassThrough(id, CACHE_SHOP_KEY, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//互斥锁 缓存击穿
//Shop shop = queryShopByMutex(id);
//逻辑过期时间 解决缓存击穿问题
//Shop shop = queryShopByLogicExpire(id);
Shop shop = cacheClient.queryByLogicExpire(CACHE_SHOP_KEY, id, Shop.class, CACHE_SHOP_TTL, TimeUnit.MINUTES, this::getById);
if (Objects.isNull(shop)) {
return Result.fail("商户不存在!");
}
return Result.ok(shop);
}