详细代码在我的Github上,地址:
https://github.com/CodeTeng/RedisCase
Redis实战篇
1. 短信登录
1.1 基于Session实现登录流程
1.2 实现发送短信验证码
核心代码:
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("非法的手机号码");
}
// 2. 生成验证码
String code = RandomUtil.randomNumbers(6);
// 3. 保存验证码到session中
session.setAttribute(SystemConstants.USER_SESSION_CODE, code);
// 4. 模拟发送验证码
log.debug("短信验证码为:{}", code);
return Result.ok();
}
1.3 实现登录、注册功能
核心代码:
public Result login(LoginFormDTO loginForm, HttpSession session) {
String code = loginForm.getCode();
String phone = loginForm.getPhone();
// 1. 校验表单
if (StrUtil.isBlank(phone)) {
return Result.fail("手机号不能为空");
}
if (StrUtil.isBlank(code)) {
return Result.fail("验证码不能为空");
}
// 2. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
// 3. 校验验证码
String sessionCode = (String) session.getAttribute(SystemConstants.USER_SESSION_CODE);
if (!code.equals(sessionCode)) {
return Result.fail("验证码错误");
}
// 4. 根据手机号查询用户
User user = this.query().eq("phone", phone).one();
// 5. 若不存在 进行注册
if (Objects.isNull(user)) {
user = createUserWithPhone(phone);
}
// 6. 若存在,将用户保存到session中
session.setAttribute(SystemConstants.USER_SESSION_USER, BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
1.4 实现登录拦截功能
拦截器代码:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取session
HttpSession session = request.getSession();
// 2.获取session中的用户
Object user = session.getAttribute(SystemConstants.USER_SESSION_USER);
// 3. 判断用户是否存在
if (user == null) {
// 4. 不存在,拦截
response.setStatus(401);
return false;
}
// 5. 存在 保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);
// 6. 放行
return true;
}
相关配置
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
注意:可以使用
threadlocal
来做到线程隔离,每个线程操作自己的一份数据在
threadLocal
中,无论是他的put
方法和他的get
方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map
,只要线程不一样,map
就不一样,所以可以通过这种方式来做到线程隔离
1.5 session共享问题
1.6 基于Redis实现共享session登录
1.6.1 设计key的结构
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用
哈希,如下图,如果使用String,注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特
别在意内存,其实使用String就可以。
1.6.2 设计key的小细节
所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的
session,但是redis的key是共享的,就不能使用code了
在设计这个key的时候,我们之前讲过需要满足两点
- key要具有唯一性
- key要方便携带
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太
合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了。
1.7 基于Redis实现短信登录
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
核心代码:
public Result login(LoginFormDTO loginForm, HttpSession session) {
String code = loginForm.getCode();
String phone = loginForm.getPhone();
// 1. 校验表单
if (StrUtil.isBlank(phone)) {
return Result.fail("手机号不能为空");
}
if (StrUtil.isBlank(code)) {
return Result.fail("验证码不能为空");
}
// 2. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
// 3. 校验验证码--->从redis中进行获取
// String sessionCode = (String) session.getAttribute(SystemConstants.USER_SESSION_CODE);
String redisCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
if (!code.equals(redisCode)) {
return Result.fail("验证码错误");
}
// 4. 根据手机号查询用户
User user = this.query().eq("phone", phone).one();
// 5. 若不存在 进行注册
if (Objects.isNull(user)) {
user = createUserWithPhone(phone);
}
// 6. 若存在,将用户保存到session中--->保存到redis中---记得脱敏数据
// session.setAttribute(SystemConstants.USER_SESSION_USER, BeanUtil.copyProperties(user, UserDTO.class));
// 6.1 生成token
String token = UUID.randomUUID().toString(true);
// 6.2 将user对象转为Hash进行存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((filedName, filedValue) -> filedValue.toString()));
// 6.3 存到redis中
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 6.4 设置token的有效期
stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 7. 返回token
return Result.ok(token);
}
1.8 解决状态登录刷新问题
1.8.1 初始方案问题
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路
径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方
案他是存在问题的。
1.8.2 优化方案
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做
的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中
的user对象是否存在即可,完成整体刷新功能。
核心代码:
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)) {
// 放行
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
2. 商户查询缓存
2.1 添加商户缓存
核心代码:
public Result queryShopById(Long id) {
// 1. 从redis中查询
String cacheShopJson = redisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2. 判断是否存在
if (StrUtil.isNotBlank(cacheShopJson)) {
Shop shop = JSONUtil.toBean(cacheShopJson, Shop.class);
return Result.ok(shop);
}
// 3. 不存在,根据id从数据库查询
Shop shop = this.getById(id);
// 4. 没有返回未查询到
if (Objects.isNull(shop)) {
return Result.fail("店铺不存在!");
}
// 5. 数据库中查询到,返回并存入缓存
redisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
2.2 缓存更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存
中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
主动更新策略
两种操作方案都有数据不一致性问题
缓存更新策略的最佳实践方案:
- 低一致性需求:使用Redis自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
- 读操作:
2.3 实现商铺和缓存与数据库双写一致
核心思路如下:
修改ShopController中的业务逻辑,满足下面的需求:
-
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
-
根据id修改店铺时,先修改数据库,再删除缓存
核心代码:
查询修改—设置redis缓存时添加过期时间
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
更新修改—先修改数据库,再删除缓存
@Transactional
public Result updateShop(Shop shop) {
// 1. 修改数据库
this.updateById(shop);
// 2. 删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + shop.getId());
return Result.ok();
}
2.4 缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
缓存空对象逻辑
核心代码:
public Result queryShopById(Long id) {
// 1. 从redis中查询
String cacheShopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2. 判断是否存在
if (StrUtil.isNotBlank(cacheShopJson)) {
Shop shop = JSONUtil.toBean(cacheShopJson, Shop.class);
return Result.ok(shop);
}
// 命中的是否是空值
if (cacheShopJson != null) {
return Result.fail("店铺信息不存在!");
}
// 3. 不存在,根据id从数据库查询
Shop shop = this.getById(id);
// 4. 没有返回未查询到
if (Objects.isNull(shop)) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 5. 数据库中查询到,返回并存入缓存,并且设置超时时间
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
小总结
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
- 缓存空对象值
- 布隆过滤
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
2.5 缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
2.6 缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库
带来巨大的冲击。
解决方案逻辑
二者对比
2.6.1 互斥锁解决方案
操作锁代码:
/**
* 获取互斥锁
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
// 不要直接返回,因为有自动拆箱-防止空指针
return BooleanUtil.isTrue(flag);
}
/**
* 释放互斥锁
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
核心代码:
private Shop queryWithMutex(Long id) {
// 1. 从redis中查询缓存
String key = RedisConstants.CACHE_SHOP_KEY + id;
String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(cacheShopJson)) {
// 命中 直接返回
return JSONUtil.toBean(cacheShopJson, Shop.class);
}
// 判断是否为空值
if (cacheShopJson != null) {
return null;
}
// 2. 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
if (!isLock) {
// 获取失败 休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 成功 根据id查询数据库
shop = this.getById(id);
// 3. 判断数据库中是否存在
if (Objects.isNull(shop)) {
// 不存在 存入空对象 防止缓存穿透
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 4. 查询到 写入redis中 并设置过期时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 5. 释放互斥锁
unlock(lockKey);
}
return shop;
}
2.6.2 逻辑过期解决方案
核心代码:
private Shop queryWithLogicalExpire(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从redis中查询
String redisDataJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(redisDataJson)) {
// 2. 不存在 直接返回空
return null;
}
// 3. 命中缓存 判断是否过期
RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期
return shop;
}
// 4. 过期 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 获取成功 开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShopToRedis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
return shop;
}
2.7 缓存工具封装
详情见 CacheClient.java
中查找