1 短信登录

1.1 介绍

1.2 发送短信验证码:先存后发

@Override
public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }

    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4.保存验证码到 session
    stringRedisTemplate.opsForValue().set(login:code: + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{}", code);

    // 返回ok
    return Result.ok();
}

1.3 短信验证码登录注册

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.从redis获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(login:code: + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        // 不一致,报错
        return Result.fail("验证码错误");
    }

    // 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
    User user = query().eq("phone", phone).one();

    // 5.判断用户是否存在
    if (user == null) {
        // 6.不存在,创建新用户并保存
        user = createUserWithPhone(phone);
    }

    // 7.保存用户信息到 redis中
    // 7.1.随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 7.2.将User对象转为HashMap存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
            CopyOptions.create()
                    .setIgnoreNullValue(true)
                    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 7.3.存储
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4.设置token有效期
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

    // 8.返回token
    return Result.ok(token);
}

1.4 登录校验拦截器

public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
        tl.set(user);
    }

    public static UserDTO getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

-------------------------------------------------------------------------------------------------------------

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
    }
}

1.5 登录状态刷新

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  = 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, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

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

-------------------------------------------------------------------------------------------------------------

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);

        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

2 商户缓存

2.1 介绍

2.2 缓存与数据库双写一致:先更新后删除

00.操作缓存和数据库时有三个问题需要考虑:
    删除缓存还是更新缓存?
        更新缓存:每次更新数据库都更新缓存,无效写操作较多     ×
        删除缓存:更新数据库时让缓存失效,查询时再更新缓存     √
    如何保证缓存与数据库的操作的同时成功或失败?
        单体系统,将缓存与数据库操作放在一个事务
        分布式系统,利用TCC等分布式事务方案
    先操作缓存还是先操作数据库?
        先删除缓存,再操作数据库      ×
        先操作数据库,再删除缓存      √

00.缓存更新策略的最佳实践方案:
    低一致性需求:使用Redis自带的内存淘汰机制
    高一致性需求:主动更新,并以超时剔除作为兜底方案
        读操作:
            缓存命中则直接返回
            缓存未命中则查询数据库,并写入缓存,设定超时时间
        写操作:
            先写数据库,然后再删除缓存
            要确保数据库与缓存操作的原子性

00.给查询商铺的缓存添加超时剔除、主动更新的策略
    修改ShopController中的业务逻辑,满足下面的需求:
        根据id查询店铺:如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
        根据id修改店铺:先更新数据库,再删除缓存

-------------------------------------------------------------------------------------------------------------

01.根据id查询店铺:如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
    @Override
    public Result queryById(Long id) {
        String key = "cache:shop:" + 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), 30L, TimeUnit.MINUTES);
        //7.返回
        return Result.ok(shop);
    }

02.根据id修改店铺:先更新数据库,再删除缓存
    @Override
    @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();
    }

2.3 缓存穿透

00.缓存穿透:防止恶意攻击。
    一般而言,我们不会缓存一些无意义的数据,但是如果非要利用一些无意义的数据反复发起请求,
    会形成一种恶意攻击,从而使得大量数据库资源由此造成浪费
    场景:用户发出多条缓存中不存在的"恶意数据”,跳过缓存,大量请求访问DB
    解决:将无意义的数据也进行缓存,并且将过期时间设置的相对短一些

00.缓存穿透产生的原因是什么?
    用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力

00.缓存穿透的解决方案有哪些?
    缓存null值
    布隆过滤
    增强id的复杂度,避免被猜测id规律
    做好数据的基础格式校验
    加强用户权限校验
    做好热点参数的限流

-------------------------------------------------------------------------------------------------------------

@Override
public Result queryById(Long id) {
    // 解决缓存穿透:缓存null值
    // Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 互斥锁解决缓存击穿
    // Shop shop = cacheClient.queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 逻辑过期解决缓存击穿
    // Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

     if (shop == null) {
         return Result.fail("店铺不存在!");
     }
     // 7.返回
     return Result.ok(shop);
}

// 解决缓存穿透:缓存null值
public <R,ID> R queryWithPassThrough(
        String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
    String key = keyPrefix + id;
    // 1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(json)) {
        // 3.存在,直接返回
        return JSONUtil.toBean(json, type);
    }
    // 判断命中的是否是空值
    if (json != null) {
        // 返回一个错误信息
        return null;
    }

    // 4.不存在,根据id查询数据库
    R r = dbFallback.apply(id);
    // 5.不存在,返回错误
    if (r == null) {
        // 将空值写入redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 返回错误信息
        return null;
    }
    // 6.存在,写入redis
    this.set(key, r, time, unit);
    return r;
}

2.4 缓存击穿

00.缓存击穿:某一个热点数据过期,造成大量用户请求直奔DB的现象
    解决:1.监控线程,假设redis缓存中数据将要过期,及时给它更新过期时间
         2.提前设置好时间,保证热点数据在高峰期不过期,调大redis中的过期时间
    场景:双十一秒杀iphone12,通常在秒杀前,提前将iPhone12数据放入缓存中
         ①缓存正常:用户直接访问缓存,可以减轻数据库压力
         ②缓存过期:redis缓存中iPhone12突然过期,会造成大量用户请求直奔DB

00.解决方案:
    互斥锁
    逻辑过期

-------------------------------------------------------------------------------------------------------------

@Override
public Result queryById(Long id) {
    // 解决缓存穿透
    // Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 互斥锁解决缓存击穿
    // Shop shop = cacheClient.queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 逻辑过期解决缓存击穿
    // Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

     if (shop == null) {
         return Result.fail("店铺不存在!");
     }
     // 7.返回
     return Result.ok(shop);
}

// 互斥锁解决缓存击穿
public <R, ID> R queryWithMutex(
        String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    String key = keyPrefix + id;
    // 1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // 3.存在,直接返回
        return JSONUtil.toBean(shopJson, type);
    }
    // 判断命中的是否是空值
    if (shopJson != null) {
        // 返回一个错误信息
        return null;
    }

    // 4.实现缓存重建
    // 4.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    R r = null;
    try {
        boolean isLock = tryLock(lockKey);
        // 4.2.判断是否获取成功
        if (!isLock) {
            // 4.3.获取锁失败,休眠并重试
            Thread.sleep(50);
            return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
        }
        // 4.4.获取锁成功,根据id查询数据库
        r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }finally {
        // 7.释放锁
        unlock(lockKey);
    }
    // 8.返回
    return r;
}

// 逻辑过期解决缓存击穿
public <R, ID> R queryWithLogicalExpire(
        String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    String key = keyPrefix + id;
    // 1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isBlank(json)) {
        // 3.存在,直接返回
        return null;
    }
    // 4.命中,需要先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5.判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())) {
        // 5.1.未过期,直接返回店铺信息
        return r;
    }
    // 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 {
                // 查询数据库
                R newR = dbFallback.apply(id);
                // 重建缓存
                this.setWithLogicalExpire(key, newR, time, unit);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally {
                // 释放锁
                unlock(lockKey);
            }
        });
    }
    // 6.4.返回过期的商铺信息
    return r;
}

private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

2.5 缓存雪崩

00.缓存雪崩:大量缓存全部失效
    场景一:大量缓存设置了相同的过期时间,原因:代码没写好
    场景二:缓存服务器故障,宕机状态

00.解决方案:
    给不同的Key的TTL添加随机值
    利用Redis集群提高服务的可用性
    给缓存业务添加降级限流策略
    给业务添加多级缓存

3 优惠券秒杀:一人一单

3.1 全局唯一ID

全局唯一ID生成策略:
    UUID
    Redis自增
    snowflake算法
    数据库自增

Redis自增ID策略:
    每天一个key,方便统计订单量
    ID构造是 时间戳 + 计数器

3.2 Redis实现全局唯一Id

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

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

    public long nextId(String keyPrefix) {
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2.生成序列号
        // 2.1.获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
 
        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

3.3 添加优惠卷

3.4 实现秒杀下单

3.5 库存超卖:问题分析

3.6 库存超卖:乐观锁

3.7 优惠券秒杀:一人一单

3.8 集群环境:锁失效

4 分布式锁:redis的setNx结构

4.1 基本原理

4.2 核心思路

4.3 Redis分布式锁:代码实现

4.4 Redis分布式锁:误删情况

4.5 Redis分布式锁:解决误删问题

4.6 分布式锁:原子性问题

4.7 分布式锁:Lua脚本解决原子性问题

4.8 分布式锁:Lua脚本解决原子性问题(Java代码)

5 分布式锁:redission

5.1 功能介绍

5.2 快速入门

5.3 可重入锁原理

5.4 锁重试和WatchDog机制

5.5 锁的MutiLock原理

6 秒杀优化:阻塞队列

6.1 异步秒杀思路

6.2 Redis完成秒杀资格判断

6.3 基于阻塞队列实现秒杀优化

7 Redis消息队列:Stream

7.1 介绍

7.2 基于List实现消息队列

7.3 基于PubSub的消息队列

7.4 基于Stream的消息队列

7.5 基于Stream的消息队列-消费者组

7.6 基于Stream的消息队列-异步秒杀下单

8 达人探店:点赞+排行榜

8.1 发布笔记

8.2 查看笔记

8.3 点赞功能

8.4 点赞排行榜