)
Redis实战篇一、改进基于session的短信登录1.传统session登录的流程回答传统基于session的短信登录流程如下1用户输入手机号点击发送验证码后端生成验证码并调用短信服务发送到用户手机同时将验证码保存到服务端的session中。2用户输入收到的验证码提交登录请求后端从session中取出验证码进行比对。3验证通过后后端将用户信息存入session并返回登录成功。2.session共享问题回答在分布式集群环境下session共享会面临以下问题1多服务器session不一致由于session存在于各自服务器的内存中同一个用户的不同请求可能被负载均衡分发到不同的服务器上导致在一台服务器上登录后另一台服务器无法获取到登录状态。2解决方案对比session拷贝每台服务器都保存全量session数据但会带来数据冗余和网络开销大集群规模越大越不适用。Nginx ip_hash绑定同一个IP的请求固定分发到同一台服务器但存在单点故障风险。Redis替代session将session数据统一存储在Redis中所有服务器共享同一份数据无状态化架构扩展性最好。3.基于Redis实现短信登录回答使用Redis替代session核心思路是将验证码和登录token存储在Redis中实现分布式共享。1业务流程设计发送验证码1用户输入手机号点击发送验证码。 2后端生成6位数字验证码将验证码以手机号为key存入Redis并设置有效期如2分钟。 3调用短信服务将验证码发送给用户。登录/注册验证1用户输入手机号和验证码提交登录。 2后端从Redis中根据手机号获取验证码进行比对。 3验证通过后根据手机号查询数据库判断用户是否存在新用户自动注册。 4生成随机token作为登录凭证将用户信息以Hash结构存入Rediskey为token并设置有效期。 5将token返回给前端前端后续请求携带token进行身份认证。登录拦截校验1前端请求头中携带token可由前端存入localStorage或cookie。 2后端拦截器从请求头中获取token从Redis中查询用户信息。 3若Redis中存在则刷新有效期放行请求若不存在则返回未登录。2核心代码实现发送验证码PostMapping(/code) public Result sendCode(RequestParam(phone) String phone, HttpSession session) { //1.校验手机号格式 if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail(手机号格式错误); } //2.生成验证码 String code RandomUtil.randomNumbers(6); //3.保存验证码到Redis设置有效期2分钟 stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES); //4.发送验证码 log.debug(发送短信验证码成功验证码{}, code); //实际项目中调用短信服务smsService.send(phone, code); return Result.ok(); }登录/注册PostMapping(/login) public Result login(RequestBody LoginFormDTO loginForm, HttpSession session) { //1.校验手机号 String phone loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail(手机号格式错误); } //2.从Redis获取验证码并校验 String cacheCode stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY phone); String code loginForm.getCode(); if (cacheCode null || !cacheCode.equals(code)) { return Result.fail(验证码错误); } //3.根据手机号查询用户 User user query().eq(phone, phone).one(); //4.判断用户是否存在不存在则创建新用户 if (user null) { user createUserWithPhone(phone); } //5.生成随机token作为登录凭证 String token UUID.randomUUID().toString(true); //6.将用户信息转为Hash存入Redis UserDTO userDTO BeanUtil.copyProperties(user, UserDTO.class); MapString, 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); //7.设置token有效期 stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); //8.删除验证码一次性使用 stringRedisTemplate.delete(LOGIN_CODE_KEY phone); //9.返回token return Result.ok(token); } private User createUserWithPhone(String phone) { User user new User(); user.setPhone(phone); user.setNickName(USER_NICK_NAME_PREFIX RandomUtil.randomString(10)); save(user); return user; }登录拦截器public class LoginInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public LoginInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { //1.获取请求头中的token String token request.getHeader(authorization); if (StrUtil.isBlank(token)) { response.setStatus(401); return false; } //2.基于token从Redis中获取用户 String tokenKey LOGIN_USER_KEY token; MapObject, Object userMap stringRedisTemplate.opsForHash().entries(tokenKey); //3.判断用户是否存在 if (userMap.isEmpty()) { response.setStatus(401); return false; } //4.将查询到的Hash数据转为UserDTO UserDTO userDTO BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); //5.保存用户信息到ThreadLocal UserHolder.saveUser(userDTO); //6.刷新token有效期 stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); //7.放行 return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { //移除ThreadLocal中的用户避免内存泄漏 UserHolder.removeUser(); } }3Token刷新的优化回答上述拦截器每次请求都会刷新token的TTL如果用户一直活跃则token永不过期若用户在TTL时间内无操作则自动过期需要重新登录。这种方式兼顾了安全性和用户体验。另外可以设计双拦截器方案第一个拦截器不做登录校验仅刷新token有效期针对所有请求。第二个拦截器做登录校验拦截需要登录才能访问的接口。这种设计可以避免不需要登录的接口如首页也走登录逻辑的问题。4Redis替代Session的优势总结对比维度SessionRedis方案服务器扩展性需要session共享方案扩展困难天然支持分布式无状态架构内存占用占用服务器内存无法集中管控独立存储不影响业务服务器数据持久化服务器重启session丢失Redis支持持久化数据更安全有效期管理依赖容器配置不够灵活精确控制每个key的TTL可视化管理难以查看和管理Redis客户端可直接查看和管理性能本地内存读写快网络IO但性能依然很高二、商品查询缓存相关问题1.什么是缓存回答缓存就是数据交换的缓冲区称作Cache是存储数据的临时地方一般读写性能较高。Web开发中各级缓存示意图如下所示缓存是一把“双刃剑”在带来优势的同时也包含一定的成本缓存的作用和成本如下图所示2.添加Redis缓存回答首先相对于没有添加缓存时客户端的请求直接与数据库交互数据库的查询压力较大系统性能也会受到一定的影响。那么添加Redis缓存后客户端与数据库的交互就如下图所示大大减轻了数据库的查询压力。在本项目中用户根据id查询商铺缓存的流程图如下所示其中核心代码//核心代码 public Result queryById(Long id) { String key CACHE_SHOP_KEY id; //1.从Redis中查询商铺缓存 String shopJson stringRedisTemplate.opsForValue().get(key); //2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { //3.存在直接返回 Shop shop JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } //4.不存在根据id查询数据库 Shop shop getById(id); //5.不存在返回错误 if(shop null){ return Result.fail(店铺不存在); } //6.存在写入Redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop)); //7.返回 return Result.ok(shop); }3.缓存更新策略回答由于数据不一致问题即内存发送更新而缓存数据保持没变。所以需要对缓存进行更新常见缓存更新策略主要有以下几种1如何选择缓存更新策略根据业务场景来选择1对于低一致性需求使用内存淘汰机制。例如店铺类型的查询缓存。2对于高一致性需求主动更新并以超时剔除作为兜底方案。例如店铺详情查询的缓存。2主动更新策略的实现方案有以下三种3操作缓存和数据库时需要考虑的问题1删除缓存还是更新缓存回答先进行对比更新缓存每次更新数据库都更新缓存无效写操作较多删除缓存更新数据库时让缓存失效查询时再更新缓存总结一般选择删除缓存。2如何保证缓存与数据库的操作同时成功或失败回答两种情况单体系统将缓存与数据库操作放在一个事务分布式系统利用TCC等分布式事务方案3先操作缓存还是先操作数据库回答对比如下所示4缓存更新策略最佳方案核心代码如下//核心代码 Transactional public Result update(Shop shop) { Long id shop.getId(); if(id null){ return Result.fail(店铺id不能为空); } //1.先写数据库 updateById(shop); // 2.再删除缓存 stringRedisTemplate.delete(CACHE_SHOP_KEY id); return Result.ok(); }4.缓存穿透1什么是缓存穿透回答缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在这样缓存永远不会生效这些请求都会打到数据库。2常见的解决方案回答常见的解决方案有两种缓存空对象和布隆过滤。1缓存空对象如下图所示优点实现简单维护方便缺点额外的内存消耗可能造成短期的不一致2布隆过滤如下图所示优点内存占用较少没有多余key缺点实现复杂存在误判可能3通过缓存空对象解决商铺查询的缓存穿透问题引入缓存穿透后业务流程的变化核心代码如下//核心代码 public Result queryById(Long id) { String key CACHE_SHOP_KEY id; //1.从Redis中查询商铺缓存 String shopJson stringRedisTemplate.opsForValue().get(key); //2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { //3.存在直接返回 Shop shop JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } //判断命中的是否是空值 if(shopJson ! null){ //返回一个错误信息 return Result.fail(店铺不存在); } //4.不存在根据id查询数据库 Shop shop getById(id); //5.不存在返回错误 if(shop null){ //将空值写入redis stringRedisTemplate.opsForValue().set(key, ,CACHE_NULL_TTL, TimeUnit.MINUTES); //返回错误信息 return Result.fail(店铺不存在); } //6.存在写入Redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); //7.返回 return Result.ok(shop); }5.缓存雪崩1什么是缓存雪崩回答缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机导致大量的请求到达数据库带来巨大压力具体如下图所示2解决方案回答可通过以下方案1给不同的key的TTL添加随机值2利用Redis集群提高服务的可用性3给缓存业务添加降级限流策略4给业务添加多级缓存6.缓存击穿1什么是缓存击穿回答缓存击穿问题也叫热点key问题就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了无数的请求访问会在瞬间给数据库带来巨大的冲击具体如下图所示2常见的解决方案回答常见的解决方案有两种互斥锁和逻辑过期对比如下3基于互斥锁方式解决缓存击穿问题回答引入互斥锁后业务流程发送变化如下所示核心代码如下//核心代码 //定义互斥锁解决缓存击穿函数 public Shop queryWithMutex(Long id){ String key CACHE_SHOP_KEY id; //1.从Redis中查询商铺缓存 String shopJson stringRedisTemplate.opsForValue().get(key); //2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { //3.存在直接返回 return JSONUtil.toBean(shopJson, Shop.class); } //判断命中的是否是空值 if(shopJson ! null){ //返回一个错误信息 return null; } //4.实现缓存重建 //4.1 获取互斥锁 String lockkey lock:shop: id; Shop shop null; try { boolean isLock tryLock(lockkey); //4.2 判断是否获取成功 if(!isLock){ //4.3 失败休眠并重试 Thread.sleep(50); return queryWithMutex(id); } //4.4 成功根据id查询数据库 shop getById(id); //模拟重建的延迟 Thread.sleep(200); //5.不存在返回错误 if(shop null){ //将空值写入redis stringRedisTemplate.opsForValue().set(key, ,CACHE_NULL_TTL, TimeUnit.MINUTES); //返回错误信息 return null; } //6.存在写入Redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); }finally { //7. 释放互斥锁 unlock(lockkey); } //8.返回 return shop; }4基于逻辑过期方式解决缓存击穿问题回答引入逻辑过期后业务流程发送变化如下所示核心代码如下//核心代码 private static final ExecutorService CACHE_REBUILD_EXECUTOR Executors.newFixedThreadPool(10); public Shop queryWithLogicalExpire(Long id){ String key CACHE_SHOP_KEY id; //1.从Redis中查询商铺缓存 String shopJson stringRedisTemplate.opsForValue().get(key); //2.判断是否存在 if (StrUtil.isBlank(shopJson)) { //3.未命中直接返回 return null; } //4.命中需要先把json反序列化为对象 RedisData redisData JSONUtil.toBean(shopJson, RedisData.class); Shop shop JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime redisData.getExpireTime(); //5.判断是否过期 if (expireTime.isAfter(LocalDateTime.now())) { //5.1 未过期直接返回商铺信息 return shop; } //5.2 过期需要缓存重建 //6.缓存重建 //6.1 获取互斥锁 String lockKey LOCK_SHOP_KEY id; boolean isLock tryLock(lockKey); //6.2 判断是否获取锁成功 if (isLock) { //6.3 成功开启独立线程实现缓存重建 CACHE_REBUILD_EXECUTOR.submit(() - { //重建缓存 try { this.saveShopToRedis(id,30L); } catch (Exception e) { throw new RuntimeException(e); }finally { //释放锁 unlock(lockKey); } }); } //6.4 返回过期的商铺信息 return shop; } //写入数据到redis private void saveShopToRedis(Long id,Long expireSeconds){ //1.查询店铺数据 Shop shop getById(id); //2.封装逻辑过期时间 RedisData redisData new RedisData(); redisData.setData(shop); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); //3.写入redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id,JSONUtil.toJsonStr(redisData)); }