一、短信登录及分布式session
验证码缓存
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
boolean invalid = RegexUtils.isPhoneInvalid(phone);
if(invalid){
return Result.fail("手机号格式不正确");
}
//2.生成验证码
String code = RandomUtil.randomNumbers(6);
//3.保存验证码到session或redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY +phone,code,2, TimeUnit.MINUTES);
//发送验证码
SMSUtils.sendCode(phome,code);
return Result.ok(new LoginFormDTO(phone,code));
}
登录/注册
@Override
public Result loginService(LoginFormDTO loginForm,HttpSession session) {
// TODO 实现登录功能
//1.验证手机号
if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
return Result.fail("手机号格式不正确");
}
//2.校验验证码
// Object cacheCode = session.getAttribute("code");
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+loginForm.getPhone());
if(cacheCode == null || !cacheCode.toString().equals(loginForm.getCode())){
return Result.fail("验证码错误");
}
//3.查询该用户是否存在
User user = query().eq("phone", loginForm.getPhone()).one();
//4.若不存在:创建用户并添加到数据库
if(user == null){
user= createUserWithPhone(loginForm.getPhone());
}
//5.保存数据
//5.1 将user属性拷贝到userDto中
UserDTO dto = BeanUtil.copyProperties(user,UserDTO.class);
//5.2生成token作为登陆令牌
String token = UUID.randomUUID().toString();
//5.3将数据保存到redis中
//由于使用stringRedisTemplate,所以map的key和val都要转成string类型才能被正确序列化和反序列化
Map<String, Object> map = BeanUtil.beanToMap(dto, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((key, val) -> val.toString()));
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
//5.3设置token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY+token,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);
}
二、登录拦截器以及刷新
我们定义两个拦截器,一是FlushTokenInterceptor,二是loginInterceptor,作用如下:
- FlushTokenInterceptor:根据token从redis中获取用户信息并将它封装成User对象放入ThreadLocal中,同时刷新过期时间
- loginInterceptor:对需要登录的路径进行拦截,从ThreadLocal中获取User对象,若获取不到说明没有登录
- FlushToken拦截器的优先级高于login拦截器
MvcConfig
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/voucher/**",
"/shop-type/**").order(1);
registry.addInterceptor(new FlushTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
FlushTokenInterceptor
public class FlushTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public FlushTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取token
// HttpSession session = request.getSession();
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
return true;
}
//2.基于token取出user信息
// UserDTO user = (UserDTO) session.getAttribute("user");
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
//3.判单用户是否存在
if(entries.isEmpty()){
return true;
}
//4。保存用户信息到threadLocal
UserDTO userDTO = BeanUtil.fillBeanWithMap(entries,new UserDTO(),false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,30, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
LoginInterceptor
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserDTO user = UserHolder.getUser();
if(user == null){
response.setStatus(401);
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
三、商户查询缓存
缓存
引入缓存的目的是提高读数据的性能,降低响应时间。
但不代表任何数据我们都可以通过添加缓存来提高性能,对于那些经常修改的数据,若是添加缓存反而会降低性能。再者,缓存一般占用的是内存,滥用缓存也会对内存造成浪费。
可以考虑添加缓存的数据:读多写少的数据、热点数据
缓存带来的数据不一致问题
该问题主要是由于更新缓存策略不当造成的
缓存更新策略:先修改数据库,再删除缓存
这样能够最大限度的保证数据一致性,但仍然存在数据不一致的问题,如下:
造成数据不一致是由于读key时key过期了,并且读数据库的操作先于修改数据库的操作、写缓存的操作晚于删除缓存的操作,这是几乎不可能的发生的,理由如下:
在多线程并行下,更新数据库的时间都要远远大于写入缓存的时间,所以写入缓存的执行时机应该先于更新数据库。
缓存穿透
当用户频繁访问数据库中不存在的数据时,请求直接穿透缓存直接到达数据库,增加了数据库的压力
解决方法:1. 对数据库中不存在的数据缓存null 2. 布隆过滤器
注意:缓存空值设置的过期时间尽量短,当数据库新增数据时可能会造成数据不一致,所以尽量缩短数据不一致的时间
缓存雪崩
大量缓存在同一时间内过期,导致大量请求到达数据库
解决方法:1. 给不同key的TTL加上一个随机值 2. 给缓存业务添加降级限流
缓存击穿
热点数据过期导致大量请求到达数据库
解决方法:1. 添加互斥锁 2. 设置逻辑过期时间
由于逻辑过期策略不会因为结果为null而去查询数据库,只会在缓存过期时才去查询数据库,因此需要注意以下事项
逻辑过期注意事项:
- 逻辑过期的数据需要提前预热,建立逻辑过期缓存
- 查询该业务的数据时先使用逻辑过期策略,若返回null再使用其它策略
- 修改数据时不用删除逻辑过期的key
- 删除数据时需要删除逻辑过期的key和不同缓存的key
缓存查询工具类
该类封装了不同的方法去做缓存的查询以及添加,目的是为了解决不同的缓存问题
@Component
public class CacheClient {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private ExecutorService CACHE_REBUILD_POOL = Executors.newFixedThreadPool(10);
/**
* 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
*/
public <T> void setKeyWithExpire(String key, T data, long time, TimeUnit unit){
if(data.getClass() != Integer.class && data.getClass() != Long.class && data.getClass() != Double.class && data.getClass() != String.class ) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data), time, unit);
}
stringRedisTemplate.opsForValue().set(key, data.toString(), time, unit);
}
/**
* 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
*/
public <T> void setKeyWithLogicExpire(String key,T data, long time){
//1.构建redisData并设置逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(data);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(time));
//2.将redisData添加到redis中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
*/
public <T,ID> T queryDataThroughPass(String keyPrefix, ID id, Class<T> clazz, Function<ID,T> dbCallback,long time,TimeUnit unit){
//1.通过key从redis中获取json
String json = stringRedisTemplate.opsForValue().get(keyPrefix+id);
//2.若json不为null和空白字符串则代表redis命中,返回结果
if(StrUtil.isNotBlank(json)){
return JSONUtil.toBean(json,clazz);
}
//3.若redis没命中则判断未命中情况
if(json != null){ //3.1 redis中存在该key的空白字符串,说明是缓存穿透的key,返回null
return null;
}else { //3.2 redis中没有该key,需要去数据库中查找数据
T data = dbCallback.apply(id);
// 1.存在该数据,缓存到redis
if(data != null){
setKeyWithExpire(keyPrefix + id,JSONUtil.toJsonStr(data), time,unit);
}else {
//2.没有该数据,对该key设为""
setKeyWithExpire(keyPrefix+id,"",time,unit);
}
return data;
}
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用互斥锁的方式解决缓存击穿问题
**/
public <T,ID> T queryDataByMutex(String keyPrefix, ID id, Class<T> clazz, Function<ID,T> dbCallback,long time,TimeUnit unit){
//1.通过key获取json
String json = stringRedisTemplate.opsForValue().get(keyPrefix + id);
//2.判断redis是否命中,命中直接返回
if(StrUtil.isNotBlank(json)){
return JSONUtil.toBean(json,clazz);
}
//3.若没命中
if(json != null) {//3.1 查看redis是否对该key缓存空值,是则返回null
return null;
}
T data = null;
//3.2 若不是则重建缓存
try {
boolean lock = tryLock(id);
if (!lock) {
Thread.sleep(50);
return queryDataByMutex(keyPrefix, id, clazz, dbCallback, time, unit);
}
data = dbCallback.apply(id);
if (data != null) { //1. 从数据库中查询数据,若存在则添加到redis中
setKeyWithExpire(keyPrefix + id, data, time, unit);
} else {
//2. 若不存在则对该key缓存空值
setKeyWithExpire(keyPrefix + id, "", time, unit);
}
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
unlock(id);
}
return data;
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
*/
public <T,ID> T queryDataByLogicExpire(String keyPrefix, ID id, Class<T> clazz, Function<ID,T> dbCallback,long time){
//1.从redis中获取json
String json = stringRedisTemplate.opsForValue().get(keyPrefix + id);
//2.若该json不存在则说明没有对热点信息预热,直接返回null
if(StrUtil.isBlank(json)){
return null;
}
//3.将json转化成redisData,查看是否过期
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
T data = JSONUtil.toBean((JSONObject) redisData.getData(), clazz);
// 3.1 若没有过期则直接返回
if(redisData.getExpireTime().isAfter(LocalDateTime.now())){
return data;
}else { // 3.2 若过期则重建缓存
//1.获取互斥锁
boolean lock = tryLock(id);
if(!lock){ //若不成功则直接返回旧数据
return data;
}
//2.若成功则异步重建缓存
CACHE_REBUILD_POOL.submit(()->{
T res = dbCallback.apply(id);
setKeyWithLogicExpire(keyPrefix+id,res,time);
unlock(id);
});
return data;
}
}
private <T> boolean tryLock(T id){
String key = RedisConstants.LOCK_SHOP_KEY + id;
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private <T> void unlock(T id){
String key = RedisConstants.LOCK_SHOP_KEY + id;
stringRedisTemplate.delete(key);
}
}
业务流程
public Shop findShopThroughRedis(long id){
String key = RedisConstants.CACHE_SHOP + id;
//1.查询redis中是否有该商铺
String json = stringRedisTemplate.opsForValue().get(key);
//2.如果有则返回
if(!StrUtil.isBlank(json)){
return JSONUtil.toBean(json,Shop.class);
}
//3.若不存在则去数据库查询该店铺
Shop shop = this.getById(id);
//3.1 若该店铺不存在则对该id保存null,防止缓存穿透
if(shop == null){
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//4.将查询到的shop保存到redis中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL*60 + RandomUtil.randomInt(-30,30), TimeUnit.SECONDS);
return shop;
}