Redis 学习笔记 3:黑马点评

Redis 学习笔记 3:黑马点评

准备工作

需要先导入项目相关资源:

启动后端代码和 Nginx。

短信登录

发送验证码

java 复制代码
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    // 发送短信验证码并保存验证码
    return userService.sendCode(phone, session);
}
java 复制代码
@Log4j2
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    public Result sendCode(String phone, HttpSession session) {
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("不是合法的手机号!");
        }
        String code = RandomUtil.randomNumbers(6);
        session.setAttribute("code", code);
        // 发送短信
        log.debug("发送短信验证码:{}", code);
        return Result.ok();
    }
}

登录

java 复制代码
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
    // 实现登录功能
    return userService.login(loginForm, session);
}
java 复制代码
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 验证手机号和验证码
    if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
        return Result.fail("手机号不合法!");
    }
    String code = (String) session.getAttribute("code");
    if (code == null || !code.equals(loginForm.getCode())) {
        return Result.fail("验证码不正确!");
    }
    // 检查用户是否存在
    QueryWrapper<User> qw = new QueryWrapper<>();
    qw.eq("phone", loginForm.getPhone());
    User user = this.baseMapper.selectOne(qw);
    if (user == null) {
        user = this.createUserByPhone(loginForm.getPhone());
    }
    // 将用户信息保存到 session
    session.setAttribute("user", user);
    return Result.ok();
}

private User createUserByPhone(String phone) {
    User user = new User();
    user.setPhone(phone);
    user.setNickName("user_" + RandomUtil.randomString(5));
    this.baseMapper.insert(user);
    return user;
}

统一身份校验

定义拦截器:

java 复制代码
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 从 session 获取用户信息
        HttpSession session = request.getSession();
        User user = (User) session.getAttribute("user");
        if (user == null) {
            response.setStatus(401);
            return false;
        }
        // 将用户信息保存到 ThreadLocal
        UserDTO userDTO = new UserDTO();
        userDTO.setIcon(user.getIcon());
        userDTO.setId(user.getId());
        userDTO.setNickName(user.getNickName());
        UserHolder.saveUser(userDTO);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

添加拦截器:

java 复制代码
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login");
    }
}

使用 Redis 存储验证码和用户信息

用 Session 存储验证码和用户信息的系统,无法进行横向扩展,因为多台 Tomcat 无法共享 Session。如果改用 Redis 存储就可以解决这个问题。

修改后的 UserService:

java 复制代码
@Log4j2
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    @Override
    public Result sendCode(String phone, HttpSession session) {
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("不是合法的手机号!");
        }
        String code = RandomUtil.randomNumbers(6);
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL);
        // 发送短信
        log.debug("发送短信验证码:{}", code);
        return Result.ok();
    }

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 验证手机号和验证码
        if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
            return Result.fail("手机号不合法!");
        }
        String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());
        if (code == null || !code.equals(loginForm.getCode())) {
            return Result.fail("验证码不正确!");
        }
        // 检查用户是否存在
        QueryWrapper<User> qw = new QueryWrapper<>();
        qw.eq("phone", loginForm.getPhone());
        User user = this.baseMapper.selectOne(qw);
        if (user == null) {
            user = this.createUserByPhone(loginForm.getPhone());
        }
        // 将用户信息保存到 session
        String token = UUID.randomUUID().toString(true);
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(user, userDTO);
        try {
            stringRedisTemplate.opsForValue().set(LOGIN_USER_KEY + token,
                    OBJECT_MAPPER.writeValueAsString(userDTO), LOGIN_USER_TTL);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
        return Result.ok(token);
    }

    private User createUserByPhone(String phone) {
        User user = new User();
        user.setPhone(phone);
        user.setNickName("user_" + RandomUtil.randomString(5));
        this.baseMapper.insert(user);
        return user;
    }
}

修改后的登录校验拦截器:

java 复制代码
public class LoginInterceptor implements HandlerInterceptor {
    private final StringRedisTemplate stringRedisTemplate;
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 从头信息获取 token
        String token = request.getHeader("Authorization");
        if (ObjectUtils.isEmpty(token)) {
            // 缺少 token
            response.setStatus(401);
            return false;
        }
        // 从 Redis 获取用户信息
        String jsonUser = this.stringRedisTemplate.opsForValue().get(LOGIN_USER_KEY + token);
        UserDTO userDTO = OBJECT_MAPPER.readValue(jsonUser, UserDTO.class);
        if (userDTO == null) {
            response.setStatus(401);
            return false;
        }
        // 将用户信息保存到 ThreadLocal
        UserHolder.saveUser(userDTO);
        // 刷新 token 有效期
        stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

还需要添加一个更新用户信息有效期的拦截器:

java 复制代码
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 {
        // 如果请求头中有 token,且 redis 中有 token 相关的用户信息,刷新其有效期
        String token = request.getHeader("Authorization");
        if (ObjectUtils.isEmpty(token)) {
            return true;
        }
        if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(LOGIN_USER_KEY + token))) {
            stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);
        }
        return true;
    }
}

添加这个新的拦截器,并且确保其位于登录验证拦截器之前:

java 复制代码
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**");
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login");
    }
}

商户查询

缓存

对商户类型查询使用 Redis 缓存以提高查询效率:

java 复制代码
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    @Override
    public Result queryTypeList() {
        String jsonTypeList = stringRedisTemplate.opsForValue().get(CACHE_TYPE_LIST_KEY);
        if (!StringUtils.isEmpty(jsonTypeList)) {
            List<ShopType> typeList = JSONUtil.toList(jsonTypeList, ShopType.class);
            return Result.ok(typeList);
        }
        List<ShopType> typeList = this
                .query().orderByAsc("sort").list();
        if (!typeList.isEmpty()){
            stringRedisTemplate.opsForValue().set(CACHE_TYPE_LIST_KEY, JSONUtil.toJsonStr(typeList), CACHE_TYPE_LIST_TTL);
        }
        return Result.ok(typeList);
    }
}

对商户详情使用缓存:

java 复制代码
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        // 先从 Redis 中查询
        String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        if (!StringUtils.isEmpty(jsonShop)) {
            Shop shop = JSONUtil.toBean(jsonShop, Shop.class);
            return Result.ok(shop);
        }
        // Redis 中没有,从数据库查
        Shop shop = this.getById(id);
        if (shop != null) {
            jsonShop = JSONUtil.toJsonStr(shop);
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);
        }
        return Result.ok(shop);
    }
}

缓存更新策略

在编辑商户信息时,将对应的缓存删除:

java 复制代码
@Override
public Result update(Shop shop) {
    if (shop.getId() == null) {
        return Result.fail("商户id不能为空");
    }
    // 更新商户信息
    this.updateById(shop);
    // 删除缓存
    stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
    return Result.ok();
}

缓存穿透

缓存穿透指如果请求的数据在缓存和数据库中都不存在,就不会生成缓存数据,每次请求都不会使用缓存,会对数据库造成压力。

可以通过缓存空对象的方式解决缓存穿透问题。

在查询商铺信息时缓存空对象:

java 复制代码
@Override
public Result queryById(Long id) {
    // 先从 Redis 中查询
    String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    if (!StringUtils.isEmpty(jsonShop)) {
        Shop shop = JSONUtil.toBean(jsonShop, Shop.class);
        return Result.ok(shop);
    }
    // Redis 中没有,从数据库查
    Shop shop = this.getById(id);
    if (shop != null) {
        jsonShop = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);
        return Result.ok(shop);
    } else {
        // 缓存空对象到缓存中
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL);
        return Result.fail("店铺不存在");
    }
}

在这里,缓存中的空对象用空字符串代替,并且将缓存存活时间设置为一个较短的值(比如说2分钟)。

在从缓存中查询到空对象时,返回商铺不存在:

java 复制代码
@Override
public Result queryById(Long id) {
    // 先从 Redis 中查询
    String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    if (!StringUtils.isEmpty(jsonShop)) {
        Shop shop = JSONUtil.toBean(jsonShop, Shop.class);
        return Result.ok(shop);
    }
    // 如果从缓存中查询到空对象,表示商铺不存在
    if ("".equals(jsonShop)) {
        return Result.fail("商铺不存在");
    }
    // ...
}

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

可以利用 Redis 做互斥锁来解决缓存击穿问题:

java 复制代码
@Override
public Result queryById(Long id) {
    //        return queryWithCachePenetration(id);
    return queryWithCacheBreakdown(id);
}

/**
     * 用 Redis 创建互斥锁
     *
     * @param name 锁名称
     * @return 成功/失败
     */
private boolean lock(String name) {
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(name, "1", Duration.ofSeconds(10));
    return BooleanUtil.isTrue(result);
}

/**
     * 删除 Redis 互斥锁
     *
     * @param name 锁名称
     */
private void unlock(String name) {
    stringRedisTemplate.delete(name);
}

/**
     * 查询店铺信息-缓存击穿
     *
     * @param id
     * @return
     */
private Result queryWithCacheBreakdown(Long id) {
    // 先查询是否存在缓存
    String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    if (!StringUtils.isEmpty(jsonShop)) {
        Shop shop = JSONUtil.toBean(jsonShop, Shop.class);
        return Result.ok(shop);
    }
    // 如果从缓存中查询到空对象,表示商铺不存在
    if ("".equals(jsonShop)) {
        return Result.fail("商铺不存在");
    }
    // 缓存不存在,尝试获取锁,并创建缓存
    final String lockName = "lock:shop:" + id;
    try {
        if (!lock(lockName)){
            // 获取互斥锁失败,休眠一段时间后重试
            Thread.sleep(50);
            return queryWithCacheBreakdown(id);
        }
        // 获取互斥锁成功,创建缓存
        // 模拟长时间才能创建缓存
        Thread.sleep(100);
        Shop shop = this.getById(id);
        if (shop != null) {
            jsonShop = JSONUtil.toJsonStr(shop);
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);
            return Result.ok(shop);
        } else {
            // 缓存空对象到缓存中
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL);
            return Result.fail("店铺不存在");
        }
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 释放锁
        unlock(lockName);
    }
}

下面是用逻辑过期解决缓存击穿问题的方式。

首先需要将热点数据的缓存提前写入 Redis(缓存预热):

java 复制代码
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    /**
     * 创建店铺缓存
     *
     * @param id       店铺id
     * @param duration 缓存有效时长
     */
    public void saveShopCache(Long id, Duration duration) {
        Shop shop = getById(id);
        RedisCache<Shop> redisCache = new RedisCache<>();
        redisCache.setExpire(LocalDateTime.now().plus(duration));
        redisCache.setData(shop);
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisCache));
    }
    // ...
}
java 复制代码
@SpringBootTest
class HmDianPingApplicationTests {
    @Autowired
    private ShopServiceImpl shopService;

    @Test
    public void testSaveShopCache(){
        shopService.saveShopCache(1L, Duration.ofSeconds(1));
    }

}
java 复制代码
@Data
public class RedisCache<T> {
    private LocalDateTime expire; //逻辑过期时间
    private T data; // 数据
}

Redis 中的缓存信息包含两部分:过期时间和具体信息。大致如下:

json 复制代码
{
    "data": {
        "area": "大关",
        "openHours": "10:00-22:00",
        "sold": 4215,
        // ...
    },
    "expire": 1708258021725
}

且其 TTL 是-1,也就是永不过期。

具体的缓存读取和重建逻辑:

java 复制代码
/**
     * 用逻辑过期解决缓存击穿问题
     *
     * @return
     */
private Result queryWithLogicalExpiration(Long id) {
    //检查缓存是否存在
    String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    if (StringUtils.isEmpty(jsonShop)) {
        // 缓存不存在
        return Result.fail("店铺不存在");
    }
    // 缓存存在,检查是否过期
    RedisCache<Shop> redisCache = JSONUtil.toBean(jsonShop, new TypeReference<RedisCache<Shop>>() {
    }, true);
    if (redisCache.getExpire().isBefore(LocalDateTime.now())) {
        // 如果过期,尝试获取互斥锁
        final String LOCK_NAME = LOCK_SHOP_KEY + id;
        if (lock(LOCK_NAME)) {
            // 获取互斥锁后,单独启动线程更新缓存
            CACHE_UPDATE_ES.execute(() -> {
                try {
                    // 模拟缓存重建的延迟
                    Thread.sleep(200);
                    saveShopCache(id, Duration.ofSeconds(1));
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unlock(LOCK_NAME);
                }
            });
        }
    }
    // 无论是否过期,返回缓存对象中的信息
    return Result.ok(redisCache.getData());
}

封装 Redis 缓存工具类

可以对对 Redis 缓存相关逻辑进行封装,可以避免在业务代码中重复编写相关逻辑。封装后分别对应以下方法:

  • 设置缓存数据(TTL)
  • 设置缓存数据(逻辑过期时间)
  • 从缓存获取数据(用空对象解决缓存穿透问题)
  • 从缓存获取数据(用互斥锁解决缓存击穿问题)
  • 从缓存获取数据(用逻辑过期解决缓存击穿问题)

工具类的完整代码可以参考这里

本文的完整示例代码可以从这里获取。

参考资料

相关推荐
行然梦实12 分钟前
学习日记_20241110_聚类方法(K-Means)
学习·kmeans·聚类
马船长17 分钟前
制作图片木马
学习
sam-12321 分钟前
k8s上部署redis高可用集群
redis·docker·k8s
秀儿还能再秀29 分钟前
机器学习——简单线性回归、逻辑回归
笔记·python·学习·机器学习
WCF向光而行34 分钟前
Getting accurate time estimates from your tea(从您的团队获得准确的时间估计)
笔记·学习
看山还是山,看水还是。42 分钟前
Redis 配置
运维·数据库·redis·安全·缓存·测试覆盖率
谷新龙0011 小时前
Redis运行时的10大重要指标
数据库·redis·缓存
精进攻城狮@1 小时前
Redis缓存雪崩、缓存击穿、缓存穿透
数据库·redis·缓存
wang09071 小时前
工作和学习遇到的技术问题
学习
Li_0304062 小时前
Java第十四天(实训学习整理资料(十三)Java网络编程)
java·网络·笔记·学习·计算机网络