RedisPro
项目环境
从资料里解压压缩包,用idea打开,修改一下自己的mysql数据库和redis数据库配置。
注意:项目端口要是8081,不要改变,因为目前和前端ngnix是一致的。上下文路径也不用配置,默认/
启动服务后,访问http://localhost:8081/shop-type/list,测试服务是否正常。
ngnix前端服务:
解压压缩包后,放在任意一个目录下,注意不带中文和特殊符号,在文件路径上。
进入ngnix的所在目录,打开cmd窗口,执行命令:
start ngnix.exe
打开浏览器,进入手机模式,访问http://127.0.0.1:8080,可以访问到app主页即可。
短信登录
session流程分析
ThreadLocal
在服务中,每一个请求到达服务都是一个线程,而ThreadLocal把数据保存到一个每一个线程内部,每一个线程内部都有一个map来保存这些数据,这样一来每一个线程都有自己独立的存储空间,每一个请求都有自己的独立的线程,相互之间没有干扰。数据验证通过后,放行请求。
后续的请求业务,从自己的ThradLocal取出自己的数据做验证,然后决定是否放行。
1)发送短信验证码
流程分析
1.用户输入手机号。2.发送请求(/api/user/code?phone=xxxxx POST)。3.controller处理。4.调用service,完成业务。5.校验手机号。6.根据校验结果决定是否生成验证码。7.保存验证码到session。8.发送验证码。9.返回业务结果。
编码实现
/**
* 生成短信验证码
* @param phone 手机号
* @param session session
* @return 处理结果
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号是否合格
if (RegexUtils.isPhoneInvalid(phone)) {
//如果无效
return Result.fail("您TM手机号格式写错了");
}
//生成验证码,调用工具类,6为数字验证码
String numbers = RandomUtil.randomNumbers(6);
//保存验证码
session.setAttribute("code", numbers);
//返回结果
log.debug("发送短信验证码成功:" + numbers);
return Result.ok();
}
2)登录功能
流程分析:
1.用户点击登录(参数:手机号,验证码,密码)。2.controller处理请求。3.校验手机号。4.校验验证码。5.查询用户是否是新用户。6.如果是新用户,则自动注册一个User并保存数据库。7.如果不是则不注册。8.把当前登录用户保存到session。9.返回登录成功(这里使用验证码登录,验证码对的情况下,一定登录成功。如果使用密码,可能会密码错误)。
编码实现:
手机号和验证码校验在controller中;
public Result loginConfirm(String phone, HttpSession session) {
//查询用户是否是第一次登录,query是继承的ServiceImpl<UserMapper, User>中的方法,MP提供的。
User user = query().eq("phone", phone).one();
if (user == null) {
//第一次登录,注册用户,保存到数据库
user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
//新用户入库
save(user);//api
}
//保存到session
session.setAttribute(USER_SESSION_KEY, user);
return Result.ok();
}
3)校验登录状态
api/user/me
使用ThreadLocal,保存该次请求的需要的数据。
拦截器登录验证
User user = (User) request.getSession().getAttribute(SystemConstants.USER_SESSION_KEY);
if (user == null) {
//如果session中用户不存在
System.out.println("用户不存在");
response.setStatus(401);
return false;
}
//保存用户对象到ThreadLocal
UserHolder.saveUser(user);
//放行
return true;
拦截器配置
@Override
public void addInterceptors(InterceptorRegistry registry) {
//排除一些不登陆也可以用的路径
registry.addInterceptor(new loginInterceptor()).
excludePathPatterns("/user/code", "/user/login", "/blog/hot",
"/shop-type/**", "/voucher/**", "/upload/**");
}
4)缺陷所在
session共享问题:
多台服务器之间无法共享一个session域。nginx在收到请求后会把请求进行负载均衡,选择一个压力小的服务器将请求发到该服务器。(Tomcat会有多台,组成一个集群)。而如果两次请求发在了不同的Tomcat服务器上,那么登录验证就失效了。
解决办法:
作为存储容器,应该可以满足多台Tomcat之间共享,在内存存储(保证高效读写,低延迟),key-value结构。
Redis流程分析
1:基本流程和session大概一致,验证码保存在redis中,用phone做key。
2:登录功能时,成功登录的话,将userDto对象保存到redis,数据结构使用string(json)即可。也可以使用hash(内存占用更小)。而key的选择需要保证在拦截器做登录验证的时候也方便取到。所以决定采用token(随机的字符串序列)作为key保存在redis,同时作为数据响应给客户端。
3:登录成功后从后端收到token数据,保存在浏览器本地存储,方便发请求的时候获取。
4:前端怎么保证每次发送都带着token呢?这里前端配置了拦截器,在每次请求发送的时候都从浏览器本地存储获取token然后作为请求头发送请求。
关键代码
验证码到redis
//保存到redis,并设置存活时间
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, numbers, LOGIN_CODE_TTL, TimeUnit. TimeUnit.MINUTES);
登录业务
{ //验证手机号
//TODO 从redis取出验证码并验证
String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
if (code == null || !code.equals(loginForm.getCode())) {
return Result.fail("验证码错误");
}
//查询用户是否是第一次登录
User user = query().eq("phone", phone).one();
//TODO 转存UserDTo
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//保存到session,为了减轻session,服务器的压力,不保存整个user对象,保存部分user属性就行了。
//session.setAttribute(USER_SESSION_KEY, BeanUtil.copyProperties(user, UserDTO.class));
//保存到redis TODO 1.生成随机token
String token = UUID.randomUUID().toString().replaceAll("-","");
//TODO 2.将user对象以map格式存到redis/或者String.json
Map<String,String> userDtoMap=new HashMap<>();
userDtoMap.put("id", String.valueOf(userDTO.getId()));
userDtoMap.put("nickName", userDTO.getNickName());
userDtoMap.put("icon", userDTO.getIcon());
//TODO 3.保存到redis,并设置存活时间
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,userDtoMap);
stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);
//TODO 4.响应token到前端
return Result.ok(token);
}
拦截器token刷新
- 如果用户一直在操作,那么就会刷新redis的user对象存活时间,保证用户一直处于登录状态。
- 此拦截器会拦截所有请求,并做出刷新时间的操作。
//拦截器做刷新token,刷新用户user在redis的存活时间
public class RefreshTokenInterceptor implements HandlerInterceptor {
//TODO 注意这里自动注入,需要自己手动创建bean到sprig容器,spring才会帮我们自动注入
//在MvcConfig,配置拦截器的类中使用@Bean放入容器
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//TODO 1.获取前端发送token,用来获取redis的userDto
String token = request.getHeader("authorization");
if (StringUtils.isBlank(token))
//放行,给下一个拦截器,就是登录验证拦截器
return true;
//TODO 2.获取redis的对象
Map<Object, Object> userDtoMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
//判断map是否为空
if (userDtoMap.isEmpty())
//如果session中用户不存在
return true;
//TODO 3.转换map为userDTO
UserDTO userDto = BeanUtil.fillBeanWithMap(userDtoMap, new UserDTO(), false);
//TODO 4.保存对象到ThreadLocal,生命周期一个request完成周期
UserHolder.saveUser(userDto);
//TODO 5.刷新userDto在redis的存活时间
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
登录验证拦截器
此拦截器用于拦截未登录的情况下发出的请求。
由于前置拦截器做了判断,只有成功拿到redis的userDto对象,并保存在ThreadLocal后,后者的拦截器才会成功取到userDTO对象,做出放行。否则拦截此次请求。
@Override
//请求前
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (UserHolder.getUser() == null) { //从ThreadLocal获取
response.setStatus(401);
return false;
}
//放行
return true;
}
拦截器配置
@Configuration
public class MvcConfig implements WebMvcConfigurer {
private final RefreshTokenInterceptor refreshTokenInterceptor = new RefreshTokenInterceptor();
@Override
public void addInterceptors(InterceptorRegistry registry) {
//排除一些不登陆也可以用的路径
registry.addInterceptor(new loginInterceptor()).
excludePathPatterns("/user/code", "/user/login", "/blog/hot",
"/shop-type/**", "/voucher/**", "/upload/**").order(1);
//设置拦截器的顺序,数值越小越靠前,越先执行
registry.addInterceptor(refreshTokenInterceptor).order(0);
}
@Bean
//把拦截器对象放入spring容器,方便spring容器做自动注入拦截器中的成员属性stringRedisTemplate
public RefreshTokenInterceptor loginInterceptor() {
return this.refreshTokenInterceptor;
}
}
商店缓存
缓存:高速的读取能力
缺点:数据一致性问题,人力资源问题。
将店铺信息存到缓存中(使用redis作为缓存),减轻数据库压力。
流程分析:
1.用户提交店铺id。2.service处理业务。3.从redis查询缓存是否命中。4.如果命中,(判断是否是空对象)直接返回。5.如果没有命中,去查询数据库。6.如果数据库中不存在,响应404(缓存空对象,并设置过期时间)。7.如果数据库中存在,同步redis和响应店铺信息。
编码实现:
@Override
public Result queryShopById(Long id) {
//这里序列化了JSON字符串,也可以用hash,不过我嫌实体类shop转map麻烦,就没有用
//shop转map,有工具类,但是可能会产生类型转换异常,比如long不能转为string
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Shop.class));
//TODO 查询redis
Shop shop = (Shop) redisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
//TODO 判断是否存在,存在直接返回shop
//TODO 否则查询数据库
shop = query().eq("id", id).one();
//TODO 判断数据库是否存在,不存在返回404
//TODO 同步redis,这里使用了JSON格式
String key = RedisConstants.CACHE_SHOP_KEY + id;
redisTemplate.opsForValue().set(key, shop);
//TODO 设置存活30分钟,和user一致
redisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
//TODO 响应
return Result.ok(shop);
}
缓存更新策略
主动更新策略
1.由缓存调用者,更新数据库的同时更新缓存。(胜出)
-
1)删除缓存还是更新缓存?
- 更新:每次更新数据库都更新缓存,但无效写操作较多(修改了多次,但只有最后一次有效)
- 删除:更新数据库的时候让缓存失效,在查询的时候才更新缓存(较好)。
-
2)如何保证缓存和数据库的操作的原子性?
- 单体系统:将缓存的数据库的操作作为一个事务处理。
- 分布式系统:利用TTC等分布式事务方案。
-
3)线程安全问题,先操作缓存还是先操作数据库?
-
数据库的操作比较慢,而缓存的操作快,较小概率出现第二次情况。
- 需要保证操作的原子性
2.缓存和数据库合为一个服务,由服务来维护一致性。
3.调用者只操作缓存,由异步的线程将缓存的数据持久化到数据库,保证最终一致。
缓存穿透
指用户请求的数据在缓存和数据库中都不存在,这样每次这样的请求都会到达数据库,好像缓存就不存在似的,缓存被传过去了,被穿透力。
如何解决?
1)缓存空对象
当请求查询数据库不存在的时候,把一个空对象添加到缓存(并设置一个不长的有效期),这样下次请求就会从缓存得到这个空对象。
优点:实现简单,维护方便
缺点:1.可能出现短期的数据不一致问题。用户可能查询到空对象但实际数据库刚好插入了这个对象。(设置合理的存活时间,或者在插入数据库的时候也更新缓存(删除))2.内存占用问题,可能缓存中会有很多空对象。(设置空对象的存活时间,并合理设置时长)。
2)布隆过滤
在redis前加一个过滤器,当请求发过来的时候,先走过滤器,布隆过滤器会判断数据是否存在,如果不存在会直接拒绝请求。否则,放行请求,执行后续的操作。
优点:内存占用少,独有的hash算法不会占用大量内存
缺点:1.实现复杂。2.可能出现误判的情况。
修改商铺查询业务
主要更新点:
//TODO 查询redis
Shop shop = (Shop) redisTemplate.opsForValue().get(key);
//TODO 判断是否存在
if (shop != null) {
//TODO 缓存是空对象,或者不存在,先前放入的时候是一个没有赋值的裸对象
if(shop.getId()==null)
return Result.ok("店铺不存在");
return Result.ok(shop);
}
当数据库也不存在时:
//TODO 否则查询数据库
shop = query().eq("id", id).one();
//TODO 判断数据库是否存在
if (shop == null) {
//TODO 避免缓存穿透
redisTemplate.opsForValue().set(key,new Shop());//序列化的json:{id:null,icon:null,...}
//TODO 设置存活时间,不要太长 2min
redisTemplate.expire(key,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
LocalTime的JSON序列化问题
对象序列化JSON的时候,把LocalTime当作对象序列化了,序列化后的结果是
createTime:{
"year":2021,
"monthValue":12,
"dayOfMonth":22,
"hour":18,
...
}//这样的格式在反序列化的过程中抛出异常,redisTemplate设置的序列化方式
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Shop.class));
解决办法:
/**
* 创建时间
* 添加注解,规范化LocalTime的序列化格式
*/
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;
添加注解后:“createTime”:[2021,12,22,18,10,39]
缓存雪崩
指同一时间段大量的key同时失效或者Redis服务宕机,导致大量的请求到达数据库,带来巨大压力。
如果解决?
1)给不同的key的TTL添加不同的随机值
2)利用redis集群,提高服务的可用性.避免主机宕机导致失误.
3)给缓存业务添加降级限流策略
4)给业务添加多级缓存
缓存击穿
又叫热点key问题,就是一个被该并发访问并且缓存重建业务比较复杂的key突然失效了,无数/大量的请求达到数据库,造成数据量压力骤增。
如何解决?
1)互斥锁
多线程并发的情况下,每一个线程都需要先获取锁才能重建key,获取失败的话,就等待,直到锁释放。
特点:线程间串行执行,发现有人在重建了,就等待,直到重建完成,返回数据,保证了数据的一致性。
优点:没有额外的内存消耗,保证了数据一致性,实现简单
缺点:线程需要等待,性能较差。可能产生死锁。
2)逻辑删除
通过在保存缓存的时候,手动维护一个表示过期时间的属性,根据属性值判断该key是否过期。
在多线程并发环境下,如果key过期了,首个到达的线程,如果发现不存在,就直接返回,否则如果发现key过期,会先获取锁然后另开一个线程去重建key。而其他线程发现过期后会获取不到锁,而直接就直接把过期的key返回。直到线程重建key完成,所有线程都会获取到最新的key。
特点:线程间无需等待,发现已经在重建了,就返回。只是返回的数据可能过期了,丢失了短暂的一致性。
优点:线程无需等待,性能较好,最多达到过期的数据。
缺点:不保证数据一致性,有额外的内存消耗,实现复杂。
编码实现
模拟互斥锁
//TODO 否则缓存重建
String shopLockKey = LOCK_SHOP_KEY + id;
try {
//TODO 1.获取互斥锁
while (!getShopLock(shopLockKey)) {
//TODO 2.判断是否获取成功
//TODO 3.失败,休眠重试 50ms
Thread.sleep(50);
}
//TODO 4.成功再次查询缓存,是否存在
Shop newShop = redisTemplate.opsForValue().get(key);
//TODO 判断是否存在
if (newShop != null) {
//TODO 缓存是空对象,或者不存在,先前放入的时候是一个没有赋值的裸对象,内存消耗大一点
if (newShop.getId() == null) {
return null;
}
return newShop;
}
shop = query().eq("id", id).one();
//TODO 判断数据库是否存在
if (shop == null) {
//TODO 避免缓存穿透,放入空对象 设置存活时间,不要太长 2min
redisTemplate.opsForValue().set(key, new Shop(), RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//TODO 同步redis,这里使用了JSON格式 设置存活30分钟,和user一致,数据一致性问题
redisTemplate.opsForValue().set(key, shop, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (Exception e) {
unShopLock(shopLockKey);
throw new RuntimeException();
}
unShopLock(shopLockKey);
模拟逻辑删除
见工具类
缓存穿透和缓存击穿的工具类(泛型)
缓存穿透
/**
* 根据id查询数据并解决缓存穿透问题
*
* @param preKey 缓存的前缀key
* @param id 查询的id
* @param type 返回的数据类型
* @param dbCallBack 查询操作
* @param time key的存活时间
* @param unit 时间单位
* @param <T> 泛型,哪个实体类
* @param <ID> 泛型,id的数据类型
* @return 从查询结果
*/
public <T, ID> T queryWithCacheThrough(String preKey, ID id, Class<T> type,
Function<ID, T> dbCallBack, Long time, TimeUnit unit) {
//缓存穿透 使用JSONUtil 保存空对象的时候内存消耗更少
String key = preKey + id;
//TODO 查询redis 返回普通的json格式
String json = stringRedisTemplate.opsForValue().get(key);
//TODO 判断是否存在
if (StringUtils.isNotBlank(json)) {
//TODO 缓存命中
return JSONUtil.toBean(json, type);
}
//TODO 如果json是"",说明是空对象
if (json != null)
return null;
//TODO 否则查询数据库
T t = dbCallBack.apply(id);
//TODO 判断数据库是否存在
if (t == null) {
//TODO 避免缓存穿透,放入空对象 并设置存活时间,不要太长 2min
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//TODO 同步redis,这里使用了JSON格式 设置存活30分钟,和user一致,数据一致性问题
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(t), time, unit);
//TODO 响应
return t;
}
缓存击穿(逻辑删除)
/**
* 需要事前把数据放到redis中,并设置好逻辑过期时间
* 逻辑删除,解决缓存击穿的情况
*
* @param preKey 数据在redis的前缀key
* @param lockKey 模拟setnx的锁的key
* @param id 数据的id
* @param classType 数据实体类的类型
* @param callback 数据库操作函数
* @param time 逻过期时间
* @param unit 时间单位
* @param <T> 泛型,数据所属类的数据类型
* @param <ID> 泛型,id的数据类型
* @return 返回查询结果
*/
public <T, ID> T queryWithLogicallyDel(String preKey, String lockKey, ID id, Class<T> classType,
Function<ID, T> callback, Long time, TimeUnit unit) {
String key = preKey + id;
//TODO 查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//TODO 判断是否存在
if (StringUtils.isBlank(json))
//TODO 如果不存在,直接返回
return null;
//"date:{id:"",nickName:"",...},expireTime:xxxx.xx.xx"
//这里redisData的data其实是一个字符串类型json格式的数值
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//把实体类对象从json转换回来
T t = JSONUtil.toBean((JSONObject) redisData.getData(), classType);
//TODO 如果存在,判断是否过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now()))
//TODO 如果没有过期,返回数据
return t;
//TODO 如果过期了,尝试获取锁
if (getShopLock(lockKey)) {
//TODO 获取锁成功,开个线程池去重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//TODO 重建缓存,并重新设置逻辑过期时间,新时间是当前加time
T obj = callback.apply(id);
this.saveDataToRedis(key, obj, time, unit);
} catch (Exception e) {
throw new RuntimeException();
} finally {
unShopLock(lockKey);
}
});
}
//TODO 获取失败,返回过期数据
return t;
}
其他方法:
//保存一个具有逻辑过期时间的数据到redis
public void saveDataToRedis(String key, Object data, Long time, TimeUnit unit) {
RedisData newRedisData = new RedisData();
newRedisData.setData(data);
//设置逻辑过期时间
newRedisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
//保存到redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(newRedisData));
}
//获取锁
private boolean getShopLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unShopLock(String key) {
stringRedisTemplate.delete(key);
}
Function<K, T> callback
作为参数,传递一个函数。可以使用匿名函数。
前一个K,表示参数类型
T表示返回类型
edisData();
newRedisData.setData(data);
//设置逻辑过期时间
newRedisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
//保存到redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(newRedisData));
}
//获取锁
private boolean getShopLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, “1”, LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unShopLock(String key) {
stringRedisTemplate.delete(key);
}
### Function<K, T> callback
作为参数,传递一个函数。可以使用匿名函数。
前一个K,表示参数类型
T表示返回类型
方法:T apply(K key),表示调用传递的函数内容。