黑马点评是一个大量使用Redis的项目,黑马点评要完成的功能如下:
对应的表有:
短信登录
- 基于Session实现登录
发送验证码功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public Result sendCode(String phone, HttpSession httpsession){ if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误"); } String code = RandomUtil.randomNumbers(6); session.setAttribute("code",code); log.debug("发送短信验证码:{}",code); return Result.ok(); }
|
短信登录、注册功能实现
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
|
@Override public Result login(LoginFormDTO loginForm,HttpSession session){ String phone = loginForm.getPhone(); String code = loginForm.getCode(); if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误"); Object cacheCode = session.getAttribute("code"); if(cacheCode == null || !code.equals(cacheCode.toString())){ return Result.fail("验证码错误"); } LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(StringUtils.isNotBlank(phone),User::getPhone,phone); User user = userMapper.selectOne(queryWrapper); if(user == null){ user=createUserWithPhone(phone); } session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class)); return Result.ok(); }
private User createUserWithPhone(Stringphone){ User user = new User(); user.setPhone(phone).setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(6)); userMapper.insert(user); return user; }
|
如何做到数据脱敏?
我们需要保存用户的有关信息,但是不能保存所有的信息,于是创建一个UserDTO对象,将一些不涉及敏感信息的字段放在里面
1 2 3 4 5 6
| @Data public class UserDTO { private Long id; private String nickName; private String icon; }
|
- 基于Redis实现共享session登录
发送验证码
Redis以String数据类型存储(key-手机号,验证码),其中key前缀为业务逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
@Resource privateStringRedisTemplatestringRedisTemplate;
@Override publicResultsendCode(Stringphone,HttpSessionsession){ if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误"); } String code = RandomUtil.randomNumbers(6); stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES); log.debug("发送短信验证码:{}",code); return Result.ok(); }
|
1 2 3 4
| publicclassRedisConstants{ public static final String LOGIN_CODE_KEY = "login:code:"; public static final Long LOGIN_CODE_TTL = 2L; }
|
短信验证码登录、注册
坑:把User对象转换为map后,以hash的形式存储到Redis中会出现类型转换异常
原因:使用的是StringRedis Template,只支持String类型的key-value。而user对象的id是long类型。因此需要自定义map映射规则
细节:存储对象用hash的形式可以减少内存损耗,而且对单个字段的修改也很灵活
存储对象使用 hash
类型可以减少内存损耗的主要原因是 Redis 内部的 内存优化机制。具体来说,它通过 数据压缩 和 共享存储结构 在某些条件下显著减少了内存开销。
特性 |
hash 数据类型 |
string 数据类 |
灵活性 |
适合部分字段操作 |
适合整体操作 |
内存效率 |
字段较少时节省内存 |
字段较多时性能更佳 |
复杂度 |
操作粒度更细 |
需要序列化和反序列化 |
可读性 |
内部数据是字段和值的形式 |
需要解析后才可读 |
适用场景 |
小型对象,频繁字段操作 |
大型对象,频繁整体操作 |
Redis以Hash的数据类型存储对象,其中键为UUID生成的随机token,值为user对象转为的hashmap
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 45 46 47 48 49 50 51
| @Override public Result login(LoginFormDTO loginForm, HttpSession session) { String phone = loginForm.getPhone(); String code = loginForm.getCode(); if(RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机格式不正确!!"); } String Cachecode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone); if (Cachecode==null || !code.equals(Cachecode)) { return Result.fail("无效的验证码"); }
User user = query().eq("phone", phone).one();
if (user==null) { user = createuser(phone); } String token = UUID.randomUUID().toString();
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); HashMap<String, String > userMap = new HashMap<>(); userMap.put("id", String.valueOf(userDTO.getId())); userMap.put("nickName", userDTO.getNickName()); userMap.put("icon", userDTO.getIcon());
String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
return Result.ok(token); }
|
校验登录状态
坑1:由于LoginInterceptor没有交给Spring进行管理,因此StringRedisTemplate不能通过@Resource自动注
入。需要在配置文件中进行构造器注入。
坑2: 只有一个登陆拦截器A,该拦截器上进行获取token、查询Redis用户、刷新token有效期的操作。而该拦截 器只需要登陆路径进行拦截。此时就有问题了。当用户访问不需要登陆的路径时,就不会刷新token有效期,30分 钟后,token会自动过期。
优化:在这个拦截器A前再加一个拦截器B,用于拦截一切路径,把获取token、查询Redis用户、刷新token有效期 的操作放到这个拦截器B上做。而拦截需要登陆的路径的拦截器A只需要判断ThreadLocal中有没有用户即可。
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
|
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 { String token = request.getHeader("authorization"); if (StrUtil.isBlank(token)) { return true; } String key = RedisConstants.LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); if (userMap.isEmpty()) { return true; } UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); 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
| public class LoginInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (UserHolder.getUser()==null) { response.setStatus(401); return false; } 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
| @Configuration public class MvcConfig implements WebMvcConfigurer { @Autowired private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
registry.addInterceptor(new LoginInterceptor()). excludePathPatterns("/user/login", "/user/code", "/blog/hot", "/shop/**", "/shop-type/**", "/upload/**", "/voucher/**").order(1); } }
|
为什么我的拦截器能够生效?
MvcConfig实现了WebMvcConfigurer类,并通过 addInterceptors
方法注册了拦截器,再通过@Configuration注解标明为配置类,交给Spring容器管理,Spring 会识别并调用其中的配置方法,并定制Web MVC的行为。
商户查询缓存
店铺查询缓存
缓存穿透: 非法的数据redis中没有,数据库中也没有,导致一直绕过redis来查询数据库。
Redis 以String类型存储的店铺对象, 根据商铺id查询到店铺之后,使用序列化的方式,将对象转成Json字符串的格式,并且设置ttl时间为2分钟
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
| public Shop querywithchuantou(Long id) { String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); if (StrUtil.isNotBlank(shopJson)) { Shop shop = JSONUtil.toBean(shopJson, Shop.class); return shop; }
if (shopJson!=null) { return null; }
Shop shop = getById(id);
if (shop == null) { stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, null, CACHE_NULL_TTL, TimeUnit.MINUTES); return null; }
String jsonStr = JSONUtil.toJsonStr(shop); stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES); return shop; }
|
店铺查询缓存与数据库一致性的实现
根据id修改店铺信息时,先修改数据库,再删除缓存。注意:在方法前面需要加上事务注解,以保证原子性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
@Override @Transactional public Result update(Shop shop) { if (shop.getId() == null){ return Result.fail("店铺id不能为空!!"); } updateById(shop); stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId()); return Result.ok(); }
|
解决店铺查询的缓存击穿问题
出现缓存击穿的原因是热点key过期了。因此我们可以不设置热点key的过期时间,让热点key永不过期。但为了更 新热点key,还需要给key一个过期时间,这里采用逻辑过期的方式来解决这个问题。也就是把过期时间以value的 方式存到key中。让key的过期判断交给程序员去做,而不是Redis。
如何给数据添加过期时间的字段?
- 新建一个RedisData类,这个类有过期时间字段,同时有另外一个Object类字段,可以存放数据,相当于给数据又加了一层封装
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
| public Shop queryWithLogicalExpire(Long id) { String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); if (StrUtil.isBlank(json)) { return null; } RedisData redisData = JSONUtil.toBean(json, RedisData.class); JSONObject shopJson = (JSONObject) redisData.getData(); Shop shop = JSONUtil.toBean(shopJson, Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); if (LocalDateTime.now().isBefore(expireTime)) { return shop; } boolean flag = tryLock(LOCK_SHOP_KEY + id); if (flag) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { this.saveShop2Redis(id, 20L); } catch (Exception e) { throw new RuntimeException(e); } finally { unlock(LOCK_SHOP_KEY + id); } }); return shop; } return shop; }
|
优惠券秒杀下单、一人一单
Redisson可重入锁
基于setnx实现的分布式锁有以下几个问题:
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但是业务执行耗时较长,也会导致锁释放,存在安全隐患
Reddison:
- 可重入:替代setnx使用的string数据类型,使用hash类型,记录线程id和重入次数
- 可重试:利用信号量和PubSub功能实现等待、唤醒、获取锁失败的重试机制
- 超时续约:获取锁成功后,开启一个定时器,每隔一段时间,重置超时时间