实战篇
项目地址:https://github.com/hedsay/DianPing
# 基于Redis实现登录功能
本章重点:
- 判断密码格式工具类;
- MVC拦截器使用方式;
- 基于Session,Redis登录使用方式;
# Session登录
每台服务器只能保存一份 session 数据,不能服务器之间共享,如果采用其它方式共享,数据拷贝也会出现延迟,徒增服务器压力;
校验登录:客户端输入验证码与服务端session中的验证码校验;校验成功根据手机号查询用户,若不存在则创建并保存到数据库;无论是否存在将用户信息保存到session;
先将验证码保存在session中;
session有关API:setAttribute("key"),getAttribute("key");
public Result login(LoginFormDTO loginForm, HttpSession session) { String phone = loginForm.getPhone(); if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号码格式错误!"); } Object cacheCode = session.getAttribute("code"); String code = loginForm.getCode(); if(cacheCode==null || !cacheCode.toString().equals(code)){ return Result.fail("验证码错误!"); } //select * from tb_user where phone = ? User user = query().eq("phone", phone).one(); if(user == null){ user = createUserWithPhone(phone); } //用户脱敏 session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class)); return Result.ok(); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19拦截器与保存用户信息:客户端请求时会从cookie中携带sessionId,服务端根据sessionId从session中拿到用户信息;在Controllers前加一层拦截器,若没有session信息,进行拦截,有则将用户信息保存到threadLocal中放行;
本地线程工具类:
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(); } }
1
2
3
4
5
6
7
8
9
10
11
12拦截器:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("user"); if(user == null){ response.setStatus(401); return false; } //保存到ThreadLocal UserHolder.saveUser((UserDTO) user); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19配置拦截器:
@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" ).order(1); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Redis登录
key用随机唯一 UUID 代替
校验登录:采用Hash数据结构保存用户信息,用户信息的id为long类型,存入redis需要转换为string类型,详细见代码;
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone); String code = loginForm.getCode(); if(cacheCode==null || !cacheCode.toString().equals(code)){ return Result.fail("验证码错误!"); } //select * from tb_user where phone = ? User user = query().eq("phone", phone).one(); if(user == null){ user = createUserWithPhone(phone); } //用户信息保存到redis 1.随机token作令牌 2.User对象转为HashMap存储 String token = UUID.randomUUID().toString(); UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //id是long类型,存redis需要转换为string,要自定义序列化 Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())); String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey,userMap); stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.SECONDS);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21刷新Token:防止用户在登录期token过期,再重新登录影响用户体验;设置两个拦截器,第一个只负责判断user对象是否存在,第二个拦截器只负责刷新token;
//第一个拦截器 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (UserHolder.getUser() == null) { response.setStatus(401); return false; } return true; } //第二个拦截器 public class RefreshTokenInterceptor implements HandlerInterceptor { //这能被spring容器注入,是因为MvcConfig已经将其注入了 private StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //获取请求头中的token String token = request.getHeader("authorization"); if(StrUtil.isBlank(token)){ return true;//只负责刷新,没有也放行 } String key = LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); if(userMap.isEmpty()) return true; //将hash数据转为UserDTO UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); UserHolder.saveUser(userDTO);//保存到ThreadLocal stringRedisTemplate.expire(key,LOGIN_USER_TTL, TimeUnit.MINUTES);//刷新token return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44拦截器配置:
@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); // token刷新的拦截器 registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)) .addPathPatterns("/**").order(0); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 基于Redis实现缓存
# 缓存商户数据
利用 hutool工具包将 JSON转换为 Bean对象
商户查询:查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis;
public Result queryShopById(Long id) {
String key = "cache:shop:"+id;
//从redis查询
String shopJson = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//不存在,数据库查询
Shop shop = getById(id);
if(shop == null){
return Result.fail("商户不存在!");
}
//存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 缓存穿透
请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库;
常见解决方案:
缓存空对象
public Result queryShopById(Long id) { String key = "cache:shop:"+id; //从redis查询 String shopJson = stringRedisTemplate.opsForValue().get(key); //判断val不为空字符串 if(StrUtil.isNotBlank(shopJson)){ Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } //既非不为空字符串,又不是空对象,只能是空字符串 if(shopJson != null){ return Result.fail("商户不存在!"); } //不存在,数据库查询 Shop shop = getById(id); if(shop == null){ //缓存穿透:判断命中的是否是空值,""并不是null返回true stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES); return Result.fail("商户不存在!"); } //存在,写入redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24布隆过滤
做好热点参数的限流
# 缓存雪崩
同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力;
常见方案:
- 给不同的Key的过期时间添加随机值;
- 利用Redis集群提高服务的可用性;
- 给缓存业务添加降级限流策略;
- 给业务添加多级缓存;
# 缓存击穿
热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击;
常见方案:
互斥锁
互斥锁可以使用 redis中的 setnx,key不存在才能写,存在不能写;(也可以ReentrantLock)
线程1查询缓存未命中,且获取锁成功,就去查询数据库重建缓存,释放锁;
其它线程缓存未命中,获取锁失败重试,直至命中缓存;
public Shop queryWithMutex(Long id) throws InterruptedException { String key = "cache:shop:"+id; String shopJson = stringRedisTemplate.opsForValue().get(key); if(StrUtil.isNotBlank(shopJson)){ return JSONUtil.toBean(shopJson,Shop.class); } if(shopJson != null){ return null; } //缓存重构 //获取互斥锁 String lockKey = "lock:shop:" + id; Shop shop = null; try { boolean isLock = tryLock(lockKey); //获取锁失败 while(!isLock){ Thread.sleep(50); } //锁成功 shop = getById(id); //模拟重建的延时 Thread.sleep(200); if(shop == null){ stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES); return null; } stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES); } catch (Exception e) { throw new RuntimeException(e); } finally{ unlock(lockKey); } return shop; } 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); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42逻辑过期
对象中添加过期字段,通过判断该字段来续期;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); public Shop queryWithLogicalExpire(Long id){ String key = CACHE_SHOP_KEY + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null; } //将json反序列化为对象 RedisData redisData = JSONUtil.toBean(json, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);//Object转Shop LocalDateTime expireTime = redisData.getExpireTime(); //判断是否过期 if(expireTime.isAfter(LocalDateTime.now())) { //未过期,直接返回店铺信息 return shop; } //过期重建缓存,获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); if(isLock) { //获取锁成功后,再检查次缓存是否过期,有可能别的线程重建缓存了,没过期无需重建 //过程省略... CACHE_REBUILD_EXECUTOR.submit(()->{ try { saveShop2Redis(id,20L);//暂停定为20s } catch (InterruptedException e) { throw new RuntimeException(e); }finally { unlock(lockKey); } }); } return shop; } public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException { Shop shop = getById(id); //模拟重建缓存的时间 Thread.sleep(200); //封装逻辑过期时间 RedisData redisData = new RedisData(); redisData.setData(shop); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData)); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
TODO:封装Redis工具类