黑马点评主要功能点实现总结
一、登录认证
1、发送手机验证码
流程图:
实现代码(service):
@Override public Result sendCode(String phone) { //校验手机号 if (RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误"); } //如果符合,生成验证码 String code = RandomUtil.randomNumbers(6); code = "666666"; //验证码保存到redis // session.setAttribute(LOGIN_CODE_KEY,code); redisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES); //模拟发送验证码 log.debug("模拟发送登录code:{}",code); return Result.ok(); }
2、验证码登录
流程图:
代码实现(service):
@Override public Result login(LoginFormDTO loginForm) { //校验手机号 String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式不正确"); } //检验验证码 String cacheCode = redisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone); String code = loginForm.getCode(); if (cacheCode==null || !code.equals(cacheCode.toString())){ return Result.fail("验证码不正确"); } //根据手机号查询用户 User user = query().eq("phone", phone).one(); //如果不存在,则创建用户 if (user==null){ user = createUserWithPhone(phone); } //将用户信息保存到redis //session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class)); String token = UUID.randomUUID().toString(true); String tokenKey = LOGIN_USER_KEY+token; UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //将对象转换成map,并将所有的值转化成string类型 Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()) ); redisTemplate.opsForHash().putAll(tokenKey,map); redisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES); 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; }
3、退出登录
思路:删除客户端浏览器中的token;或者删除redis中缓存的用户数据即可。本项目通过后端返回ok,然后前端删除token实现。
实现代码:略
4、拦截器
1、认证信息拦截器
拦截需要用户登录信息的请求。
拦截器实现代码:
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; } }
2、token刷新拦截器
拦截所有请求,如果用户已登录,则刷新token时间。
流程图:
实现代码:
public class refreshTokenInterceptor implements HandlerInterceptor { private StringRedisTemplate redisTemplate; public refreshTokenInterceptor(StringRedisTemplate redisTemplate){ this.redisTemplate = redisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //从请求头中获取token String token = request.getHeader("authorization"); String tokenKey = LOGIN_USER_KEY + token; //判断是否携带token if (StrUtil.isBlank(token)){ return true; } //从redis获取用户信息 Map<Object, Object> userMap = redisTemplate.opsForHash().entries(tokenKey); //判断是否有缓存 if (userMap.isEmpty()){ return true; } User user = BeanUtil.fillBeanWithMap(userMap, new User(), false); //将用户信息保存到线程域 UserHolder.saveUser(user); //刷新token有效期 redisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES); //放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
二、查询缓存
1、缓存更新
先更新数据库,再删除缓存;并加上事务;
实现代码(service):
@Override @Transactional public Result update(Shop shop) { Long shopId = shop.getId(); if (shopId==null){ return Result.fail("商铺id不能为空"); } //1 更新数据库 updateById(shop); //2 删除缓存 stringRedisTemplate.delete(CACHE_SHOP_KEY + shopId); return Result.ok(); }
2、缓存穿透
问题描述:缓存穿透指大量不存在的key查询数据,由于redis中不存在,会直接请求数据库,给数据库造成巨大压力。
解决方案:
-
缓存空对象
对不存在的数据也建立缓存。
//在下面的缓存击穿中实现
-
布隆过滤器
在客户端和redis之间添加布隆过滤器,布隆过滤器使用的算法会提前判断key值是否存在,只有存在的时候才进行后续的查询操作。
3、缓存雪崩
问题描述:缓存雪崩指大量的key失效或者redis崩溃,压力全部给到数据库。
解决方案:
-
给不同的key添加随机的TTL时间;
-
使用redis集群提高系统的可用性;
-
降级限流策略;
-
给业务添加多级缓存;
4、缓存击穿
问题描述:缓存击穿又叫热点key问题,某些被高并发访问的key失效的时候,重建缓存的大量的并发请求到达数据库,给数据库造成巨大压力。
解决方案:
-
互斥锁
在重建缓存之前先尝试获取锁;
流程:
代码实现:
public Shop cacheJC1(Long id){ //根据id查询redis String key = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); //如果有,直接返回 if (StrUtil.isNotBlank(shopJson)){ Shop shop = JSONUtil.toBean(shopJson, Shop.class); return shop; } //如果是空字符串,也返回(缓存穿透防御措施) // if (shopJson!=null){ // return null; // } String shopLock = LOCK_SHOP_KEY + id; //如果没有,尝试获取锁 boolean b = tryLock(shopLock); if (!b){ //获取锁失败,休眠,并重试从redis获取数据 try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } Shop shop = cacheJC1(id); return shop; }else { //获取锁成功,查询mysqll,重建缓存 Shop shop = getById(id); if (shop == null){ //mysql没有数据 //缓存空值 stringRedisTemplate.opsForValue().set(key,"",2L,TimeUnit.MINUTES); releaseLock(shopLock); return null; } //MySQL有数据,缓存到redis(缓存30分钟) stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES); releaseLock(shopLock); return shop; } } //使用redis模拟获取互斥锁 public boolean tryLock(String shopLock){ Boolean shopLock1 = stringRedisTemplate.opsForValue().setIfAbsent(shopLock, "shopLock",10L,TimeUnit.MINUTES); return BooleanUtil.isTrue(shopLock1); } //使用redis模拟释放互斥锁 public void releaseLock(String shopLock){ stringRedisTemplate.delete(shopLock); }
缺点:如果线程未获取到锁,会等待,影响性能。
-
逻辑过期
在缓存的数据中添加一个过期时间字段,如果已过期,则尝试获取锁,然后返回旧数据并异步重建缓存。
流程:
代码实现:
public Shop cacheJC2(Long id){ //1 根据id查询redis String key = CACHE_SHOP_KEY + id; String shopDataJson = stringRedisTemplate.opsForValue().get(key); //2 判空 String shopLock = LOCK_SHOP_KEY + id; if (StrUtil.isBlank(shopDataJson)){ if (tryLock(shopLock)){ //拿到互斥锁,开启线程,缓存重建 CACHE_EXECUTOR.submit(()->{ try { saveRedisData(id,10L); }catch (Exception e){ System.out.println("shop缓存重建异常"); }finally { //释放锁 releaseLock(shopLock); } }); } //先返回旧的数据 return null; } //3 有缓存,判断是否过期 RedisData redisData = JSONUtil.toBean(shopDataJson, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); if (expireTime.isAfter(LocalDateTime.now())){ //3.1 未过期,直接返回 return shop; } //3.2 过期,开启线程试图重建缓存,并直接返回 if (tryLock(shopLock)){ //拿到互斥锁,开启线程,缓存重建 CACHE_EXECUTOR.submit(()->{ try { saveRedisData(id,10L); }catch (Exception e){ System.out.println("shop缓存重建异常"); }finally { //释放锁 releaseLock(shopLock); } }); } //没有得到互斥锁,直接返回旧数据 return shop; } //使用redis模拟获取互斥锁 public boolean tryLock(String shopLock){ Boolean shopLock1 = stringRedisTemplate.opsForValue().setIfAbsent(shopLock, "shopLock",10L,TimeUnit.MINUTES); return BooleanUtil.isTrue(shopLock1); } //使用redis模拟释放互斥锁 public void releaseLock(String shopLock){ stringRedisTemplate.delete(shopLock); }
三、优惠券秒杀
1、分布式id生成器
订单表的id如果使用数据自增,会有诸多问题。我们使用自定义的id生成器生成id;1位符号位-31位时间戳-32序列号
代码实现:
@Component public class RedisIdWorker { @Resource private StringRedisTemplate stringRedisTemplate; //开始时间 private static final long BEGIN_TIMESTAMP = 1672531200L; //序列号位数 private static final int COUNT_BITS = 32; //key 用于区分不同业务 //id = 1位符号位-31位时间戳-32序列号 public long nextId(String key){ //1 时间戳 LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; //2 序列号 String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); Long increment = stringRedisTemplate.opsForValue().increment("icr" + key + ":" + date); //3 拼接 return timestamp<<COUNT_BITS|increment; } public static void main(String[] args) { LocalDateTime of = LocalDateTime.of(2023, 1, 1, 0, 0, 0); long l = of.toEpochSecond(ZoneOffset.UTC); System.out.println(l); } }
2、自定义的分布式锁
//基于redis实现的分布式锁 public class SimpleRedisLock implements mylock { //key值前缀 private static final String PREFIX = "lock:"; //线程标识前缀 private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-"; //业务前缀 private String name; //redis private StringRedisTemplate stringRedisTemplate; public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) { this.name = name; this.stringRedisTemplate = stringRedisTemplate; } @Override public boolean tryLock(long timeoutSec) { String threadId = ID_PREFIX + Thread.currentThread().getId(); Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(PREFIX + name, threadId + "",timeoutSec, TimeUnit.SECONDS); // System.out.println("success"+success); return Boolean.TRUE.equals(success); } @Override public void unLock() { //获取当前线程id String id = ID_PREFIX + Thread.currentThread().getId(); //获取锁的线程id String tid = stringRedisTemplate.opsForValue().get(PREFIX + name); if (id.equals(tid)){ //一致则释放锁 stringRedisTemplate.delete(PREFIX + name); } } }
3、核心功能点实现(具体代码略)
1、超卖问题
并发场景中共享数据不安全问题。
解决方案:
1、悲观锁
使用悲观锁的缺点是未获取到锁的线程会被阻塞,性能不好;
2、乐观锁
乐观锁不会阻塞线程,(有多种实现,这里使用CAS)他会在更新数据的时候再次确认数据是否被修改,只有再次确认为被修改才会继续执行。
2、一人一单
查询数据库订单信息,判断是否已有订单;
3、用户重复点击秒杀
在判断完秒杀是否开始、库存是否充足这些基本信息后,先尝试获取分布式锁,如果无法获取锁,则说明已点击过下单,这时直接返回错误信息。
4、某些情况下一个线程会错误释放其他线程获取的锁
分布式锁是通过redis的setnx实现的,键包含用户id,值包含线程id;在释放锁的操作中,会判断即将释放的锁是否是当前线程的锁。