Redis案例

一、登录案例

1、项目架构

在这里插入图片描述

2、业务流程

在这里插入图片描述
在这里插入图片描述

3、项目依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
    <version>5.1.47</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.3</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.8</version>
</dependency>

4、配置参数

server:
  port: 8081
spring:
  application:
    name: hmdp
  datasource:
    druid:
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://localhost:3306/redis?useSSL=false&serverTimezone=UTC
      username: root
      password: 123456
  redis:
    host: localhost
    port: 6379
    lettuce:
      pool:
        max-active: 10
        max-idle: 10
        min-idle: 1
        time-between-eviction-runs: 10s
  jackson:
    default-property-inclusion: non_null # JSON处理时忽略非空字段
mybatis-plus:
  type-aliases-package: erer.redis.project.mapper # 别名扫描包
logging:
  level:
    erer.redis: debug

5、POJO

1. Entity

package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_user")
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 手机号码
     */
    private String phone;

    /**
     * 密码,加密存储
     */
    private String password;

    /**
     * 昵称,默认是随机字符
     */
    private String nickName;

    /**
     * 用户头像
     */
    private String icon = "";

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;
}

2. DTO

  • UserDTO
package erer.redis.project.dto;
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}
  • LoginFormDTO
package erer.redis.project.dto;
@Data
public class LoginFormDTO {
    private String phone;
    private String code;
    private String password;
}
  • Result
package erer.redis.project.dto;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}

6、Utils

1. UserHolder

package erer.redis.project.utils;
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();
    }
}

2. RedisConstants

package erer.redis.project.utils;
public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 30L;
}

7、Mapper

package erer.redis.project.mapper;
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

8、Service

1. 接口

package erer.redis.project.service;
public interface IUserService extends IService<User> {
    // 发送验证码
    Result sendCode(String phone, HttpSession session);
    // 实现登录功能
    Result login(LoginFormDTO loginForm, HttpSession session);
    // 根据电话号码创建用户
    User createUserWithPhone(String phone);
}

2. 实现类

package erer.redis.project.service.impl;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 如果不符合,返回错误信息
            log.debug("手机号格式错误!");
            return Result.fail("手机号格式错误!");
        }
        // 2.符合生成验证码
        String code = RandomUtil.randomNumbers(6);
        // 3.保存验证码到 redis
        stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
        // 4.发送验证码
        log.debug("发送验证码成功,验证码:{" + code + "}");
        return Result.ok();
    }

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 如果不符合,返回错误信息
            log.debug("手机号格式错误!");
            return Result.fail("手机号格式错误!");
        }
        // 3.校验验证码,从 redis 获取
        String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
        if (cacheCode == null || !cacheCode.equals(loginForm.getCode())) {
            // 验证码错误,报错
            return Result.fail("验证码错误!!!");
        }
        // 4.根据手机号查询用户
        User user = query().eq("phone", phone).one();
        // 5.判断用户是否存在
        if (user == null) {
            // 不存在,创建用户
            user = createUserWithPhone(phone);
        }
        // 6. 保存用户信息(基本信息)到 redis 中
        // 6.1 随机生成 token 值,作为登录令牌
        String token = UUID.randomUUID().toString();
        // 6.2 将 user 对象转换为 UserDTO
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        // 6.3 将 UserDTO 对象转换为 HashMap(setIgnoreNullValue(true):忽略空值;id 为 int 型,转换为 String 型)
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue)-> fieldValue.toString()));
        // 6.4 储存
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // 6.5 设置有效期
        stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 7 返回 token
        return Result.ok(token);
    }

    public User createUserWithPhone(String phone) {
        // 1.创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        // 2.保存用户
        save(user);
        return user;
    }
}

9、Controller

package erer.redis.project.controller;
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private IUserService userService;
    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }
    /**
     * 登录功能
     *
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
        // 实现登录功能
        return userService.login(loginForm, session);
    }
    /**
     * 登出功能
     *
     * @return 无
     */
    @PostMapping("/logout")
    public Result logout() {
        // TODO 实现登出功能
        return Result.fail("功能未完成");
    }
    @GetMapping("/me")
    public Result me() {
        // 获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }
    @GetMapping("/info/{id}")
    public Result info(@PathVariable("id") Long userId) {
        // 查询详情
        UserInfo info = userInfoService.getById(userId);
        if (info == null) {
            // 没有详情,应该是第一次查看详情
            return Result.ok();
        }
        info.setCreateTime(null);
        info.setUpdateTime(null);
        // 返回
        return Result.ok(info);
    }
}

10、Interceptor

在这里插入图片描述

1. token 刷新拦截器

package erer.redis.project.interceptor;
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private final StringRedisTemplate stringRedisTemplate;

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

    @Override
    // 前置拦截器:token 刷新拦截器
    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 tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 3. 判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 4.将查询到的 Hash 数据 转换为 UserDTO 对象 (false:不忽略异常)
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 5.用户存在,保存用户信息到 threadLocal 放行
        UserHolder.saveUser(userDTO);
        // 6.刷新 token 有效期
        stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 7.放行
        return true;
    }

    @Override
    // 校验完毕,销毁信息,避免内存泄露。
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

2. 登录校验

package erer.redis.project.interceptor;
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;
        }
        // 2.用户存在,放行
        return true;
    }
}

11、核心配置类

package erer.redis.project.config;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    // 加载拦截器
    public void addInterceptors (InterceptorRegistry registry) {
        // token 刷新拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**")
                .order(0);
        // 登录 拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                )
                .order(1);
    }
}

二、商户查询缓存

1、什么是缓存

  • 缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。

在这里插入图片描述
在这里插入图片描述

2、缓存作用模型

在这里插入图片描述

package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        // 1.从 Redis 查询 商铺缓存
        String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 存在直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 3.不存在根据 id 查询数据库
        Shop shop = getById(id);
        if (shop == null) {
            // 不存在,返回错误
            return Result.fail("商铺信息不存在!");
        }
        // 4.存在,写入 Redis,并设置超时时间
        stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }
}

3、缓存更新策略

项目内存淘汰超时剔除主动更新
说明内存不足时自动淘汰部分数据缓存数据添加TTL时间,到期自动删除修改数据库同时更新缓存。
一致性一般
维护成本
  • 业务场景:
    • 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
    • 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存

4、主动更新策略

  • Cache Aside Pattern(最优解)
  • 由缓存的调用者,在更新数据库的同时更新缓存
  • Read/Write Through Pattern
    • 缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。
  • Write Behind Caching Pattern
    • 调用者只操作缓存,由其它线程异步的将缓存数据持久化到数据库,保证最终一致。

1. Cache Aside Pattern

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

2. 最佳实践方案

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

3. 主动更新策略

package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    // 开启事务
    @Transactional
    public Result update(Shop shop) {
        // 1.判断 id 是否存在
        Long id = shop.getId();
        if (id == null) {
            return Result.fail("店铺 id 不能为空。");
        }
        // 2.更新数据库
        updateById(shop);
        // 3.删除缓存
        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
        return Result.ok();
    }
}

5、缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

1. 缓存空对象

1.1 解决方案

在这里插入图片描述

1.2 优缺点
  • 优点:实现简单,维护方便
  • 缺点:
    • 额外的内存消耗
    • 可能造成短期的不一致
1.3 业务流程

在这里插入图片描述

1.4 代码实现
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {
        // 1.从 Redis 查询 商铺缓存
        String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        // 2.判断店铺信息是否命中
        if (StrUtil.isNotBlank(shopJson)) {
            // 2.1 店铺信息命中直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        } else if (shopJson != null) {
            // 2.2 店铺信息是空字符串
            return Result.fail("店铺信息不存在!");
        }
        // 3.未命中根据 id 查询数据库
        Shop shop = getById(id);
        if (shop == null) {
            // 3.1 将空值写入 Redis(缓存空对象解决缓存穿透)
            stringRedisTemplate.opsForValue().set(shopKey, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 3.2 不存在,返回错误
            return Result.fail("商铺信息不存在!!!");
        } else {
            // 3.3 存在,写入 Redis,并设置超时时间
            stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return Result.ok(shop);
        }
    }
}

2. 布隆过滤

2.1 解决方案

在这里插入图片描述

2.2 优缺点
  • 优点:内存占用较少,没有多余key
  • 缺点:
    • 实现复杂
    • 存在误判可能

3. 其他解决方案

  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

6、缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

在这里插入图片描述

解决方案

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

7、缓存击穿

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

解决方案优点缺点
互斥锁没有额外的内存消耗;保证一致性;实现简单线程需要等待;性能受影响;可能有死锁风险
逻辑过期线程无需等待,性能较好不保证一致性;有额外内存消耗;实现复杂

1. 互斥锁

1.1 解决方案

在这里插入图片描述

1.2 业务流程

在这里插入图片描述

1.3 代码实现
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryById(Long id) {
        return queryWithMutex(id);
    }
    // 获取锁
    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);
    }
	// 互斥锁解决缓存击穿
    public Result queryWithMutex(Long id) {
        // 1.从 Redis 查询 商铺缓存
        String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        // 2.判断店铺信息是否命中
        if (StrUtil.isNotBlank(shopJson)) {
            // 2.1 店铺信息命中直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        } else if (shopJson != null) {
            // 2.2 店铺信息是空字符串 ""
            return Result.fail("店铺信息不存在!");
        }
        // 3.实现缓存重建
        // 3.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        try {
            boolean isLock = tryLock(lockKey);
            // 3.2 判断释放获取成功
            if (!isLock) {
                // 3.3 失败,休眠后重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            // 4.未命中根据 id 查询数据库
            Shop shop = getById(id);
            // 模拟重建的延时
//            Thread.sleep(5000);
            if (shop == null) {
                // 4.1 将空值写入 Redis
                stringRedisTemplate.opsForValue().set(shopKey, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 4.2 不存在,返回错误
                return Result.fail("商铺信息不存在!!!");
            } else {
                // 4.3 存在,写入 Redis,并设置超时时间
                stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
                return Result.ok(shop);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
            return Result.fail("商铺信息不存在!");
        } finally {
            // 5.释放互斥锁
            unLock(lockKey);
        }
    }
}

2. 逻辑过期

2.1 解决方案

在这里插入图片描述

2.2 业务流程

在这里插入图片描述

2.3 创建RedisData
package erer.redis.project.utils;
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
2.4 代码实现
package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    // 缓存重建线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    @Override
    public Result queryById(Long id) {
        // 逻辑过期解决缓存击穿问题
        return queryWithLogicalExpire(id);
    }
    // 获取锁
    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);
    }
    // 逻辑过期解决缓存击穿
    public Result queryWithLogicalExpire(Long id) {
        // 1.从 Redis 查询 商铺缓存
        String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        // 2.判断店铺信息是否命中
        if (StrUtil.isBlank(shopJson)) {
            // 店铺信息未命中直接返回
            return null;
        }
        // 3.命中,获取 json 发序列化对象(Shop对象及过期时间)
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 4.判断是否过期
        if (!expireTime.isAfter(LocalDateTime.now())) {
            // 过期,重建缓存
            // 5.获取互斥锁
            String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
            boolean isLock = tryLock(lockKey);
            // 6.判断是否获取成功
            if (isLock) {
                // 6.2 获取成功,开启独立线程,实现缓存重建
                CACHE_REBUILD_EXECUTOR.submit(() -> {
                    try {
                        // 查询店铺数据
                        Shop newShop = getById(id);
                        // 模拟重建的延时
                        Thread.sleep(5000);
                        // 封装逻辑过期时间
                        RedisData newRedisData = new RedisData();
                        newRedisData.setData(newShop);
                        newRedisData.setExpireTime(LocalDateTime.now().plusSeconds(20));
                        // 写入 Redis
                        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + shop.getId(), JSONUtil.toJsonStr(newRedisData));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        unLock(lockKey);
                    }
                });
            }
        }
        return Result.ok(shop);
    }
}

8、缓存工具封装

1. RedisData

package erer.redis.project.utils;
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

2. RedisCacheUtils

package erer.redis.project.utils;
@Slf4j
@Component
public class RedisCacheUtils {
    private final StringRedisTemplate redisTemplate;
    // 缓存重建线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    public RedisCacheUtils(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    /** 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
     * @param key:Redis 缓存 key
     * @param value:Redis 缓存 value
     * @param time:过期时间
     * @param unit:时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }
    /** 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
     * @param key:Redis 缓存 key
     * @param value:Redis 缓存 value
     * @param time:逻辑过期时间
     * @param unit:时间单位
     */
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        // 设置逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入 Redis
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }
    /**
     * 根据指定的 key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
     * @param keyPrefix:RedisKey 前缀
     * @param id:RedisKey id
     * @param type:反序列化 json 的 对象类型
     * @param dbFallBack:数据库查询语句
     * @param time:Redis 缓存过期时间
     * @param unit:时间单位
     * @param <R>:返回值类型
     * @param <ID>:id 类型
     */
    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit) {
        // 1.从 Redis 查询缓存
        String key = keyPrefix + id;
        String json = redisTemplate.opsForValue().get(key);
        // 2.判断缓存信息是否命中
        if (StrUtil.isNotBlank(json)) {
            // 2.1 缓存信息命中直接返回
            return JSONUtil.toBean(json, type);
        } else if (json != null) {
            // 2.2 缓存信息是空字符串(防止缓存穿透)
            return null;
        } else {
            // 2.3 未命中根据 id 查询数据库
            R r = dbFallBack.apply(id);
            if (r == null) {
                // 3 不存在,利用缓存空值的方式解决缓存穿透问题
                redisTemplate.opsForValue().set(key, "", time, unit);
                return null;
            } else {
                // 4 存在,写入 Redis,并设置超时时间
                this.set(key, r, time, unit);
                return r;
            }
        }
    }
    // 获取锁
    private boolean tryLock(String key) {
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    // 释放锁
    private void unLock(String key) {
        redisTemplate.delete(key);
    }
    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
     * @param keyPrefix:RedisKey 前缀
     * @param id:RedisKey id
     * @param type:反序列化 json 的 对象类型
     * @param dbFallBack:数据库查询语句
     * @param time:逻辑过期时间
     * @param unit:时间单位
     * @param <R>:返回值类型
     * @param <ID>:id 类型
     */
    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit) {
        // 1.从 Redis 查询 缓存
        String key = keyPrefix + id;
        String json = redisTemplate.opsForValue().get(key);
        // 2.判断缓存信息是否命中
        if (StrUtil.isBlank(json)) {
            // 店铺信息未命中直接返回
            return null;
        }
        // 3.命中,获取 json 发序列化对象(对象及过期时间)
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 4.判断是否过期
        if (expireTime.isBefore(LocalDateTime.now())) {
            // 过期,重建缓存
            // 5.获取互斥锁
            String lockKey = "redis:cache:lock:" + id;
            boolean isLock = tryLock(lockKey);
            // 6.判断是否获取成功
            if (isLock) {
                // 获取成功,开启独立线程,实现缓存重建
                CACHE_REBUILD_EXECUTOR.submit(() -> {
                    try {
                        // 查询数据库,获取最新数据
                        R newR = dbFallBack.apply(id);
                        // 写入 Redis
                        this.setWithLogicalExpire(key, newR, time, unit);
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        unLock(lockKey);
                    }
                });
            }
        }
        return r;
    }
}

3. 使用工具

package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private RedisCacheUtils redisCacheUtils;
    @Override
    public Result queryById(Long id) {
        // 缓存空对象解决缓存穿透
        // Shop shop = redisCacheUtils.queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 逻辑过期解决缓存击穿问题
        Shop shop = redisCacheUtils.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return shop == null ? Result.fail("店铺不存在!") : Result.ok(shop);
    }
}

三、优惠券秒杀

1、全局唯一ID

1. 面临问题

当用户抢购时,就会生成订单并保存到 数据库,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显
  • 受单表数据量的限制

2. ID生成器特性

  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性

3. ID生成器组成

在这里插入图片描述

ID的组成部分:

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同 ID

4. 代码实现

package erer.redis.project.utils;
@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP;
    /**
     * 序列号位数
     */
    private static final int COUNT_BITS = 32;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    static {
        LocalDateTime time = LocalDateTime.of(2020, 1, 1, 0, 0, 0);
        BEGIN_TIMESTAMP = time.toEpochSecond(ZoneOffset.UTC);
    }
    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("yyyyMMdd"));
        // 2.2 自增长
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        // 3.拼接返回 519767261305644692
        return timestamp << COUNT_BITS | count;
    }
} 

5. 生成策略

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增

2、优惠券秒杀下单

1. 业务流程

在这里插入图片描述

2. 代码实现

package erer.redis.project.service.impl;
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    // 开启事务
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始!");
        }
        // 3.判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
        }
        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        // 5.扣减库存
        boolean flag = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .update();
        if (!flag) {
            return Result.fail("库存不足!");
        }
        // 6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 订单 id
        voucherOrder.setId(redisIdWorker.nextId("order"));
        // 用户 id
        voucherOrder.setUserId(UserHolder.getUser().getId());
        // 代金券 id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 7.返回订单id
        return Result.ok(voucherOrder.getId());
    }
}

3、超卖问题

1. 超卖原因

在这里插入图片描述

2. 问题解决

在这里插入图片描述

3. 乐观锁

3.1 版本号法

在这里插入图片描述

3.2 CAS法

在这里插入图片描述

  • 代码实现
//扣减库存(gt("stock", 0):CAS法解决超卖问题)
boolean flag = seckillVoucherService.update()
        .setSql("stock = stock - 1")		// set stock = stock - 1
        .eq("voucher_id", voucherId)         // where id = voucherId
        .gt("stock", 0)                 	// where stock > 0
        .update();

4、一人一单

1. 业务流程

在这里插入图片描述

2. 代码实现

package erer.redis.project.service.impl;
/**
 * 乐观锁
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始!");
        }
        // 3.判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
        }
        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        return creatVoucherOrder(voucherId);
    }
    // 开启事务
    @Transactional
    public Result creatVoucherOrder(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        // 针对每个用户加锁
        synchronized (userId.toString().intern()) {
            // 5.一人一单
            Integer count = query().eq("user_id", userId)
                    .eq("voucher_id", voucherId)
                    .count();
            if (count > 0) {
                return Result.fail("用户已购买一次。");
            }
            // 6.扣减库存(gt("stock", 0):CAS法解决超卖问题)
            boolean flag = seckillVoucherService.update()
                    .setSql("stock = stock - 1")	// set stock = stock - 1
                    .eq("voucher_id", voucherId)	// where id = voucherId
                    .gt("stock", 0)                 // where stock > 0
                    .update();
            if (!flag) {
                return Result.fail("库存不足!");
            }
            // 7.创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            // 订单 id
            voucherOrder.setId(redisIdWorker.nextId("order"));
            // 用户 id
            voucherOrder.setUserId(userId);
            // 代金券 id
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);
            // 7.返回订单id
            return Result.ok(voucherOrder.getId());
        }
    }
}

3. 并发安全问题

集群模式下,synchronized 锁失效:

在这里插入图片描述

5、分布式锁

1. 分布式锁原理

在这里插入图片描述

2. 分布式锁特点

**分布式锁:**满足分布式系统或集群模式下多进程可见并且互斥的锁。

  • 多进程可见
  • 互斥
  • 高可用
  • 高性能(高并发)
  • 安全性(死锁问题)
  • 可重入性
  • 公平锁

3. 分布式锁实现

项目MySQLRedisZookeeper
互斥利用mysql本身的互斥锁机制利用setnx这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁超时时间,到期释放临时节点,断开连接自动释放

6、Redis分布式锁

1. 实现方法

实现分布式锁时需要实现的两个基本方法:

  • 获取锁
    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false
# 添加锁,NX是互斥、EX是设置超时时间
SET lock thread1 NX EX 10
  • 释放锁
    • 手动释放
    • 超时释放:获取锁时添加一个超时时间
# 释放锁,删除即可
DEL key

2. 业务流程

在这里插入图片描述

3. 代码实现

3.1 创建锁对象
package erer.redis.project.lock.impl;
public class SimpleRedisLock implements ILock {
    private static final String KEY_PREFIX = "simple:redis:lock:";
    private StringRedisTemplate stringRedisTemplate;
    private String name;
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        long threadId = Thread.currentThread().getId();
        // 获取锁
        Boolean flag = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(flag);
    }
    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}
3.2 使用锁对象
package erer.redis.project.service.impl;
/**
 *  解决问题:
 *      分布式系统或集群模式下多进程导致 synchronized 失效。
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    // 开启事务
    @Transactional
    public Result creatVoucherOrder(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        // 创建锁对象
        SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        // 尝试获取锁
        boolean isLock = redisLock.tryLock(12000000);
        // 判断是否获取成功
        if (!isLock) {
            // 获取锁失败,直接返回
            return Result.fail("不允许重复下单!");
        }
        try {
            // 5.一人一单
            Integer count = query().eq("user_id", userId)
                    .eq("voucher_id", voucherId)
                    .count();
            if (count > 0) {
                return Result.fail("用户已购买一次。");
            }
            // 6.扣减库存(gt("stock", 0):CAS法解决超卖问题)
            boolean flag = seckillVoucherService.update()
                    .setSql("stock = stock - 1")                // set stock = stock - 1
                    .eq("voucher_id", voucherId)        // where id = voucherId
                    .gt("stock", 0)                 // where stock > 0
                    .update();
            if (!flag) {
                return Result.fail("库存不足!");
            }
            // 7.创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            // 订单 id
            voucherOrder.setId(redisIdWorker.nextId("order"));
            // 用户 id
            voucherOrder.setUserId(userId);
            // 代金券 id
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);
            // 7.返回订单id
            return Result.ok(voucherOrder.getId());
        } finally {
             redisLock.unlock();
        }
    }
}

7、改进Redis分布式锁

1. 阻塞情况一

1.1 产生原因
  • 线程1 获取锁,由于某种原因导致业务阻塞
  • 业务阻塞时间过长,导致线程1超时释放锁
  • 此时线程2 获取锁,执行业务
  • 线程2 执行任务过程中,线程1 继续,完成业务
  • 线程1 完成业务后 释放锁,此时释放的锁为 线程2 的锁

在这里插入图片描述

1.2 解决方案
  • 在获取锁时存入线程标识(可以用UUID表示)
  • 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致

在这里插入图片描述

1.3 业务流程

在这里插入图片描述

1.4 代码实现
package erer.redis.project.lock.impl;
/**
 *  改进Redis分布式锁:
 *  1、业务阻塞时间过长,导致线程超时释放锁 问题
 *  在获取锁时存入线程标识(可以用UUID表示),在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致
 */
public class SimpleRedisLock implements ILock {

    private final StringRedisTemplate stringRedisTemplate;
    private final String name;

    private static final String KEY_PREFIX = "simple:redis:lock:";
    private static final String ID_PREFIX = UUID.randomUUID() + ":";

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean flag = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(flag);
    }

    @Override
    public void unlock() {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 释放锁
        if (threadId.equals(id)) {
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

2. 阻塞情况二

2.1 产生原因
  • 线程1 完成 锁标识后,释放锁之前,发生阻塞,导致超时释放锁
  • 此时 线程2 获取锁,执行业务
  • 线程2 执行任务过程中,线程1 继续并释放锁
  • 此时释放的锁为 线程2 的锁

在这里插入图片描述

2.2 解决方案

基于Lua脚本实现分布式锁的释放锁逻辑,确保多条命令执行时的原子性。

  • 执行redis命令
# 执行redis命令
redis.call('命令名称', 'key', '其它参数', ...)
# 先执行 set name jack
redis.call('set', 'name', 'jack')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
  • 调用脚本
# 我们要执行 redis.call('set', 'name', 'jack') 这个脚本
# 调用脚本:""-脚本内容; 0-脚本需要的key类型的参数个数
EVAL "return redis.call('set', 'name', 'jack')"  0
# 如果脚本中的key、value不想写死,可以作为参数传递
# key类型参数会放入KEYS数组,其它参数会放入ARGV数组,角标从1开始
# 调用脚本:""-脚本内容; 1-脚本需要的key类型的参数个数
EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name Rose
2.3 业务流程
  • 获取锁中的线程标识
  • 判断是否与指定的标识(当前线程标识)一致
  • 如果一致则释放锁(删除)
  • 如果不一致则什么都不做
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
2.4 代码实现
  • unlock.lua
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
  • 释放锁
package erer.redis.project.lock.impl;
/**
 *  改进Redis分布式锁:
 *  2、线程 完成 锁标识后,释放锁之前,发生阻塞,导致超时释放锁
 *  基于Lua脚本实现分布式锁的释放锁逻辑,确保多条命令执行时的原子性。
 */
public class SimpleRedisLock implements ILock {

    private final StringRedisTemplate stringRedisTemplate;
    private final String name;

    private static final String KEY_PREFIX = "simple:redis:lock:";
    private static final String ID_PREFIX = UUID.randomUUID() + ":";
    
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    // 静态加载,只加载一次
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    @Override
    public void unlock() {
        // 调用 lua 脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,  // 脚本
                Collections.singletonList(KEY_PREFIX + name),   // key 数组
                ID_PREFIX + Thread.currentThread().getId()  // ARGV 数组
        );
    }
}

3. 实现思路

  • 利用 set nx ex 获取锁,并设置过期时间,保存线程标示
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁
  • 基于Lua脚本实现分布式锁的释放锁逻辑,确保原子性

4. 特性

  • 利用 set nx 满足互斥性
  • 利用 set ex 保证故障时锁依然能释放,避免死锁,提高安全性
  • 利用 Redis 集群保证高可用和高并发特性

8、Redisson分布式锁

1. 分布式锁存在问题

  • 不可重入:同一个线程无法多次获取同一把锁
  • 不可重试:获取锁只尝试一次就返回false,没有重试机制
  • 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
  • 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现

2. Redisson 介绍

Redisson 是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

  • 分布式锁(Lock)和同步器(Synchronizer)
    • 可重入锁(Reentrant Lock)
    • 公平锁(Fair Lock)
    • 联锁(MultiLock)
    • 红锁(RedLock)
    • 读写锁(ReadWriteLock)
    • 信号量(Semaphore)
    • 可过期性信号量(PermitExpirableSemaphore)
    • 闭锁(CountDownLatch)

3. Redisson入门

3.1 引入依赖
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>
3.2 配置客户端
package erer.redis.project.config;
@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://localhost:6379");
        // 创建客户端
        return Redisson.create(config);
    }
}
3.3 使用分布式锁
package erer.redis.project;
@SpringBootTest()
class ProjectApplicationTests {
    @Resource
    private RedissonClient redissonClient;
    @Test
    void testRedisson() throws InterruptedException {
        // 获取锁(可重入),指定锁的名称
        RLock lock = redissonClient.getLock("anyLock");
        // 尝试获取锁,参数分别是:获取锁最大等待时间(期间会重试),锁自动释放时间,时间单位
        boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
        // 判断释放获取成功
        if (isLock) {
            try {
                System.out.println("执行业务");
            } finally {
                // 释放锁
                lock.unlock();
            }
        }
    }
}
package erer.redis.project.service.impl;
/**
 *  解决以下问题:
 *      不可重入、不可重试、超时释放、主从一致性 等
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Resource
    private RedissonClient redissonClient;
    // Redisson分布式锁
    // 开启事务
    @Transactional
    public Result creatVoucherOrder(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        // 创建锁对象
        RLock redissonLock = redissonClient.getLock("order:" + userId);
        // 尝试获取锁:参数分别是:获取锁最大等待时间(期间会重试),锁自动释放时间,时间单位
        // 空参不等待,失败直接结束
        boolean isLock = redissonLock.tryLock();
        // 判断是否获取成功
        if (!isLock) {
            // 获取锁失败,直接返回
            return Result.fail("不允许重复下单!");
        }
        try {
            // 5.一人一单:查询订单
            Integer count = query().eq("user_id", userId)
                    .eq("voucher_id", voucherId)
                    .count();
            if (count > 0) {
                return Result.fail("用户已购买一次。");
            }
            // 6.扣减库存(gt("stock", 0):CAS法解决超卖问题)
            boolean flag = seckillVoucherService.update()
                    .setSql("stock = stock - 1")                // set stock = stock - 1
                    .eq("voucher_id", voucherId)        // where id = voucherId
                    .gt("stock", 0)                 // where stock > 0
                    .update();
            if (!flag) {
                return Result.fail("库存不足!");
            }
            // 7.创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            // 订单 id
            voucherOrder.setId(redisIdWorker.nextId("order"));
            // 用户 id
            voucherOrder.setUserId(userId);
            // 代金券 id
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);
            // 7.返回订单id
            return Result.ok(voucherOrder.getId());
        } finally {
            redissonLock.unlock();
        }
    }
}

4. 可重入锁原理

4.1 业务流程

在这里插入图片描述

4.2 获取锁
-- 锁的key
local key = KEYS[1];
-- 线程唯一标识
local threadId = ARGV[1];
-- 锁的自动释放时间
local releaseTime = ARGV[2];
-- 判断是否存在
if(redis.call('exists', key) == 0) then
  -- 不存在, 获取锁,设置 value值(记录获取次数)
  redis.call('hset', key, threadId, '1');
  -- 设置有效期
  redis.call('expire', key, releaseTime);
  -- 返回结果
  return 1;
end;
-- 锁已经存在,判断 threadId 是否是自己
if(redis.call('hexists', key, threadId) == 1) then
  -- 锁是自己,自增:重入次数+1
  redis.call('hincrby', key, threadId, '1');
  -- 重置有效期
  redis.call('expire', key, releaseTime);
  -- 返回结果
  return 1;
end;
-- 代码走到这里,说明获取锁的不是自己,获取锁失败
return 0;
4.3 释放锁
-- 锁的key
local key = KEYS[1];
-- 线程唯一标识
local threadId = ARGV[1];
-- 锁的自动释放时间
local releaseTime = ARGV[2];
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
    -- 如果已经不是自己,则直接返回
    return nil;
end ;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为 0
if (count > 0) then
    -- 大于0说明不能释放锁,重置有效期然后返回
    redis.call('EXPIRE', key, releaseTime);
    return nil;
else
    -- 等于0说明可以释放锁,直接删除
    redis.call('DEL', key);
    return nil;
end ;
4.4 代码实现
@Slf4j
@SpringBootTest
class RedissonTest {
    @Resource
    private RedissonClient redissonClient;
    private RLock lock;
    @BeforeEach
    void setUp() {
        lock = redissonClient.getLock("order");
    }
    @Test
    void method1() throws InterruptedException {
        // 尝试获取锁
        boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
        if (!isLock) {
            log.error("获取锁失败 .... 1");
            return;
        }
        try {
            log.info("获取锁成功 .... 1");
            method2();
            log.info("开始执行业务 ... 1");
        } finally {
            log.warn("准备释放锁 .... 1");
            lock.unlock();
        }
    }
    void method2() {
        // 尝试获取锁
        boolean isLock = lock.tryLock();
        if (!isLock) {
            log.error("获取锁失败 .... 2");
            return;
        }
        try {
            log.info("获取锁成功 .... 2");
            log.info("开始执行业务 ... 2");
        } finally {
            log.warn("准备释放锁 .... 2");
            lock.unlock();
        }
    }
}

5. 分布式锁原理

在这里插入图片描述

  • 可重入:利用hash结构记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间

6. 主从一致性问题

  • java 应用 获取锁之后,主节点进行主从同步之前发生宕机

在这里插入图片描述

  • java程序获取的锁失效

在这里插入图片描述

  • 解决办法:联合锁—只有所有节点都拿到锁才成功

在这里插入图片描述

9、分布式锁总结

1. 不可重入Redis分布式锁

  • 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
  • 缺陷:不可重入、无法重试、锁超时失效

2. 可重入的Redis分布式锁

  • 原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
  • 缺陷:redis宕机引起锁失效问题

3. Redisson的multiLock

  • 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
  • 缺陷:运维成本高、实现复杂

四、Redis优化秒杀业务

1、业务流程

1. 原始业务流程

在这里插入图片描述

2. 优化业务流程

在这里插入图片描述

3. 秒杀业务流程

在这里插入图片描述

2、改进业务

1. 新增优惠券并保存Redis

package erer.redis.project.service.impl;
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryVoucherOfShop(Long shopId) {
        // 查询优惠券信息
        List<Voucher> vouchers = getBaseMapper().queryVoucherOfShop(shopId);
        stringRedisTemplate.opsForValue().set("order" + shopId, JSONUtil.toJsonStr(vouchers));
        // 返回结果
        return Result.ok(vouchers);
    }
    @Override
    @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
        // 保存优惠券信息(seckill:stock: + 优惠券 id = 库存)到 Redis
        stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
    }
}

2. Lua脚本判断是否抢购成功

-- 1、参数列表
-- 1.优惠券 id
local voucherId = ARGV[1]
-- 2.用户 id
local userId = ARGV[2]

-- 2、数据 key
-- 1.库存 key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.订单 key
local stockKey = 'seckill:order:' .. voucherId

-- 3、脚本业务
-- 1.判断库存是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 库存不足 返回 1
    return 1
end
-- 2.判断用户是否已经下过单
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 存在,表示重复下单 返回 2
    return 2
end
-- 3.扣库存
redis.call('incrby', stockKey, -1)
-- 4.下单(保存用户)
redis.call('sadd', orderKey, userId)

3. 执行Lua脚本判断抢购结果

4. 开启线程任务

package erer.redis.project.service.impl;
/**
 *  优化业务流程,减少处理时间
 */
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RedissonClient redissonClient;
    // 阻塞队列
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
    // 创建线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
    // 类初始化完成后 执行线程任务
    @PostConstruct
    private void init() {
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }
    
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        // 加载 lua 脚本
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

    @Override
    public Result seckillVoucher(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        // 1.执行Lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT, // lua 脚本
                Collections.emptyList(),    // key 数组
                voucherId.toString(), userId.toString() // ARGV 数组
        );
        // 2.判断执行结果
        if (result.intValue() != 0) {
            // 下单失败!
            return Result.fail(result.intValue() == 1 ? "库存不足!" : "不能重复下单!");
        }
        // 3.抢购成功,将优惠券id和用户id封装后存入阻塞队列
        // 3.1 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 封装订单 id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 封装用户 id
        voucherOrder.setUserId(userId);
        // 封装代金券 id
        voucherOrder.setVoucherId(voucherId);
        // 3.2 放入阻塞队列
        orderTasks.add(voucherOrder);
        return Result.ok(orderId);
    }

    
    // 4.开启线程任务
    private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    // 1. 获取队列中的 订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    // 2. 创建订单
                    creatVoucherOrder(voucherOrder);
                } catch (InterruptedException e) {
                    log.error("处理订单异常", e);
                }
            }
        }
    }

    private void creatVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        Long voucherId = voucherOrder.getVoucherId();
        // 1.创建锁对象
        RLock redissonLock = redissonClient.getLock("redissonLock:order:" + userId);
        // 2.尝试获取锁:参数分别是:获取锁最大等待时间(期间会重试),锁自动释放时间,时间单位
        // 空参不等待,失败直接结束
        boolean isLock = redissonLock.tryLock();
        // 3.判断是否获取成功
        if (!isLock) {
            // 获取锁失败,直接返回
            log.error("不允许重复下单!");
            return;
        }
        try {
            // 4.一人一单:查询订单
            Integer count = query().eq("user_id", userId)
                    .eq("voucher_id", voucherId)
                    .count();
            if (count > 0) {
                log.error("用户已购买一次。");
                return;
            }
            // 5.扣减库存(gt("stock", 0):CAS法解决超卖问题)
            boolean flag = seckillVoucherService.update()
                    .setSql("stock = stock - 1")                // set stock = stock - 1
                    .eq("voucher_id", voucherId)        // where id = voucherId
                    .gt("stock", 0)                 // where stock > 0
                    .update();
            if (!flag) {
                log.error("库存不足!");
                return;
            }
            // 6.保存订单
            save(voucherOrder);
        } finally {
            redissonLock.unlock();
        }
    }
}

五、Redis消息队列

消息队列Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

在这里插入图片描述

Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于List结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

1、基于List模拟消息队列

  • 队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。
  • 不过要注意的是,当队列中没有消息时 RPOP 或 LPOP 操作会返回 null,并不像 JVM 的阻塞队列那样会阻塞并等待消息。因此这里应该使用 BRPOP 或者 BLPOP 来实现阻塞效果。

在这里插入图片描述

1. 优点

  • 利用Redis存储,不受限于JVM内存上限
  • 基于Redis的持久化机制,数据安全性有保证
  • 可以满足消息有序性

2. 缺点

  • 无法避免消息丢失
  • 只支持单消费者

2、基于PubSub的消息队列

**PubSub(发布订阅)**是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。

  • SUBSCRIBE channel [channel] :订阅一个或多个频道
  • PUBLISH channel msg :向一个频道发送消息
  • PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道

在这里插入图片描述

1. 优点

  • 采用发布订阅模型,支持多生产、多消费

2. 缺点

  • 不支持数据持久化
  • 无法避免消息丢失
  • 消息堆积有上限,超出时数据丢失

3、基于Stream的消息队列

1. 发送消息

在这里插入图片描述

# 创建名为 users 的队列,并向其中发送一个消息,内容是:{name=jack,age=21},并且使用Redis自动生成ID
127.0.0.1:6379> XADD users * name jack age 21
"1644805700523-0"

2. 读取第一个消息

在这里插入图片描述

# 消息永久存在,可以被多次读取
127.0.0.1:6379> xread count 1 streams user 0
1) 1) "user"
   2) 1) 1) "1649635916845-0"
         2) 1) "name"
            2) "jack"
            3) "age"
            4) "21"

3. 读取最新消息

# block:阻塞方式读取信息;0:阻塞毫秒时间(0表示永久阻塞)
127.0.0.1:6379> xread count 1 block 0 streams user $
1) 1) "user"
   2) 1) 1) "1649636518683-0"
         2) 1) "name"
            2) "tom"
            3) "age"
            4) "20"
(4.48s)

注意
当我们指定起始 ID 为 $ 时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题。

4. xread命令特点

  • 消息可回溯
  • 一个消息可以被多个消费者读取
  • 可以阻塞读取
  • 有消息漏读的风险

4、Stream:消费者组

消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:

  • 消息分流:队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度
  • 消息标示:消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息,确保每一个消息都会被消费
  • 消息确认:消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除

1. 创建消费者组

xgroup create  key groupName ID [MKSTREAM]
  • key:队列名称
  • groupName:消费者组名称
  • ID:起始ID标示,$代表队列中最后一个消息,0则代表队列中第一个消息
  • MKSTREAM:队列不存在时自动创建队列
127.0.0.1:6379> xadd s1 * k1 v1
"1649638753923-0"
127.0.0.1:6379> xadd s1 * k2 v2
"1649638762059-0"
127.0.0.1:6379> xadd s1 * k3 v3
"1649638768680-0"
127.0.0.1:6379> xadd s1 * k4 v4
"1649638775981-0"
127.0.0.1:6379> xadd s1 * k5 v5
"1649638782658-0"
# 队列中已经有消息但不想消费用 $;队列中没有消息或想消费已有消息采用 0
127.0.0.1:6379> xgroup create s1 g1 0
OK

2. 其他常见命令

# 删除指定的消费者组
xgroup destory key groupName

# 给指定的消费者组添加消费者
xgroup createConsumer key groupname consumername

# 删除消费者组中的指定消费者
xgroup delConsumer key groupname consumername

3. 从消费者组读取消息

xreadgroup GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
  • group:消费组名称
  • consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
  • count:本次查询的最大数量
  • BLOCK milliseconds:当没有消息时最长等待时间
  • NOACK:无需手动ACK,获取到消息后自动确认
  • STREAMS key:指定队列名称
  • ID:获取消息的起始ID:
    • “>”:从下一个未消费的消息开始
    • 其它:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
1) 1) "s1"
   2) 1) 1) "1649638753923-0"
         2) 1) "k1"
            2) "v1"
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
1) 1) "s1"
   2) 1) 1) "1649638762059-0"
         2) 1) "k2"
            2) "v2"
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
1) 1) "s1"
   2) 1) 1) "1649638768680-0"
         2) 1) "k3"
            2) "v3"
# 确认处理消息(pending-list)
127.0.0.1:6379> xack s1 g1 1649638753923-0 1649638762059-0 1649638768680-0
(integer) 3
# 获取未确认消息
127.0.0.1:6379> xpending s1 g1 - + 10
(empty list or set)
# 读取消息
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
1) 1) "s1"
   2) 1) 1) "1649638775981-0"
         2) 1) "k4"
            2) "v4"
# 获取未确认消息(pending-list)
127.0.0.1:6379> xpending s1 g1 - + 10
1) 1) "1649638775981-0"
   2) "c1"
   3) (integer) 3634
   4) (integer) 1
# 读取未处理消息(pending-list)中的第一条消息
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 0
1) 1) "s1"
   2) 1) 1) "1649638775981-0"
         2) 1) "k4"
            2) "v4"
# 确认处理消息
127.0.0.1:6379> xack s1 g1 1649638775981-0
(integer) 1
# 读取未处理消息(pending-list)中的第一条消息
127.0.0.1:6379> xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 0
1) 1) "s1"
   2) (empty list or set)

4. 消费者监听消息

while (true) {
    // 尝试监听队列,使用组赛模式,最长等待2000 毫秒
    Object msg = redis.call("XREADGROUP GROUP gI CI COUNT 1 BLOCK 2000 STREAMS SI >");
    // null说明没有消息,继续下一次
    if (msg == null){
        continue;
    }
    try {
        // 处理消息,完成后一定要 ACK
        handleMessage(msg);
    } catch (Exception e) {
        while (true) {
            Object msg = redis.call("XREADGROUP GROUP gI CI COUNT 1 STREAMS S1 @");
            // null说明没有异常消息,所有消息都已确认,结束据环
            if (msg == null) {
                break;
            }
            try {
                //说明有异常消息,再次处理
                handleMessage(msg);
            } catch (Exception e) {
                //再次出现异常,记灵日志,继续婚环
                continue;
            }
        }
    }
}

5. xreadgroup命令特点

  • 消息可回溯
  • 可以多消费者争抢消息,加快消费速度
  • 可以阻塞读取
  • 没有消息漏读的风险
  • 有消息确认机制,保证消息至少被消费一次

5、Redis消息队列

项目ListPubSubStream
消息持久化支持不支持支持
阻塞读取支持支持支持
消息堆积处理受限于内存空间
可以利用多消费者加快处理
受限于消费者缓冲区受限于队列长度
可以利用消费者组提高消费速度,减少堆积
消息确认机制不支持不支持支持
消息回溯不支持不支持支持

6、实现异步秒杀下单

1. 创建消息队列

127.0.0.1:6379> xgroup create stream.orders g1 0 mkstream
OK

2. 编写Lua脚本

  • 在认定有抢购资格后,直接向 stream.orders 中添加消息,内容包含 voucherId、userId、orderId
-- 1、参数列表
-- 1.优惠券 id
local voucherId = ARGV[1]
-- 2.用户 id
local userId = ARGV[2]
-- 3.订单 id
local orderId = ARGV[3]

-- 2、数据 key
-- 1.库存 key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.订单 key
local orderKey = 'seckill:order:' .. voucherId

-- 3、脚本业务
-- 1.判断库存是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 库存不足 返回 1
    return 1
end
-- 2.判断用户是否已经下过单
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 存在,表示重复下单 返回 2
    return 2
end
-- 3.扣库存
redis.call('incrby', stockKey, -1)
-- 4.下单(保存用户)
redis.call('sadd', orderKey, userId)
-- 5.发送消息到队列中 xadd stream.orders * k1 v1 k2 v2...  (*代表由Redis自动生成消息的唯一id)
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', order)
return 0

3. 执行Lua脚本

4. 开启一个线程任务

5. 尝试获取消息

6. 完成下单

package erer.redis.project.service.impl;
/**
 * 基于Redis的Stream结构作为消息队列,实现异步秒杀下单
 */
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RedissonClient redissonClient;
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        // 加载 lua 脚本
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

    // 创建线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
    // 线程任务
    private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {
            String queueName = "stream.orders";
            while (true) {
                try {
                    // 1. 获取 消息队列中的 订单信息 xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 >
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),  // g1:消费者组的名称、c1:消费者名称
                            StreamReadOptions.empty()
                                    .count(1).block(Duration.ofSeconds(2)),  // 读取一条信息,等待时间为2s
                            // 读取 stream.order 中下一个未消费的消息
                            StreamOffset.create(queueName, ReadOffset.lastConsumed())
                    );
                    // 2. 判断订单信息是否为空
                    if (list == null || list.isEmpty()) {
                        // 为空,说明没有消息,继续下一次循环
                        continue;
                    }
                    // 3. 解析消息,创建订单
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    creatVoucherOrder(voucherOrder);
                    log.info("处理订单,完成!");
                    // 4. 确认消息
                    stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                    handlePendingList();
                }
            }
        }
    }
    // 类初始化完成后 执行线程任务
    @PostConstruct
    private void init() {
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    // 处理 异常消息
    private void handlePendingList() {
        String queueName = "stream.order";
        while (true) {
            try {
                // 1. 获取 PendingList 队列中异常消息 xreadgroup GROUP g1 c1 count 1 block 2000 streams s1 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from("g1", "c1"),  // g1:消费者组的名称、c1:消费者名称
                        StreamReadOptions.empty()
                                .count(1).block(Duration.ofSeconds(2)),  // 读取一条信息,等待时间为2s
                        // 读取 stream.order 中下一个未消费的消息
                        StreamOffset.create(queueName, ReadOffset.from("0"))
                );
                // 2. 判断订单信息是否为空
                if (list == null || list.isEmpty()) {
                    // 为空,说明 PendingList 没有 异常消息,结束循环
                    break;
                }
                // 3. 解析消息,创建订单
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                creatVoucherOrder(voucherOrder);
                log.info("异常订单处理完毕");
                // 4. 确认消息
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
            } catch (Exception e) {
                log.error("处理 Pending 订单异常", e);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException interruptedException) {
                    interruptedException.printStackTrace();
                }
            }
        }
    }

    @Override
    public Result seckillVoucher(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        Long orderId = redisIdWorker.nextId("order");
        // 1.执行Lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT, // lua 脚本
                Collections.emptyList(),    // key 数组
                String.valueOf(voucherId), String.valueOf(userId), String.valueOf(orderId) // ARGV 数组
        );
        // 2.判断执行结果
        int flag = Integer.parseInt(String.valueOf(result));
        if (flag != 0) {
            // 下单失败!
            return Result.fail(flag == 1 ? "库存不足!" : "不能重复下单!");
        }
        return Result.ok(orderId);
    }

    private void creatVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        Long voucherId = voucherOrder.getVoucherId();
        // 1.创建锁对象
        RLock redissonLock = redissonClient.getLock("redissonLock:order:" + userId);
        // 2.尝试获取锁:参数分别是:获取锁最大等待时间(期间会重试),锁自动释放时间,时间单位
        // 空参不等待,失败直接结束
        boolean isLock = redissonLock.tryLock();
        // 3.判断是否获取成功
        if (!isLock) {
            // 获取锁失败,直接返回
            log.error("不允许重复下单!");
            return;
        }
        try {
            // 4.一人一单:查询订单
            Integer count = query().eq("user_id", userId)
                    .eq("voucher_id", voucherId)
                    .count();
            if (count > 0) {
                log.error("用户已购买一次。");
                return;
            }
            // 5.扣减库存(gt("stock", 0):CAS法解决超卖问题)
            boolean flag = seckillVoucherService.update()
                    .setSql("stock = stock - 1")                // set stock = stock - 1
                    .eq("voucher_id", voucherId)        // where id = voucherId
                    .gt("stock", 0)                 // where stock > 0
                    .update();
            if (!flag) {
                log.error("库存不足!");
                return;
            }
            // 6.保存订单
            save(voucherOrder);
        } finally {
            redissonLock.unlock();
        }
    }
}

六、探店笔记

1、发布探店笔记

1. 前端接口

在这里插入图片描述

2. POJO

  • tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_blog")
public class Blog implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    /**
     * 商户id
     */
    private Long shopId;
    /**
     * 用户id
     */
    private Long userId;
    /**
     * 用户图标
     * @TableField :该字段不属于 tb_blog 表
     */
    @TableField(exist = false)
    private String icon;
    /**
     * 用户姓名
     */
    @TableField(exist = false)
    private String name;
    /**
     * 是否点赞过了
     * @TableField :该字段不属于 tb_blog 表
     */
    @TableField(exist = false)
    private Boolean isLike;
    /**
     * 标题
     */
    private String title;
    /**
     * 探店的照片,最多9张,多张以","隔开
     */
    private String images;
    /**
     * 探店的文字描述
     */
    private String content;
    /**
     * 点赞数量
     */
    private Integer liked;
    /**
     * 评论数量
     */
    private Integer comments;
    /**
     * 创建时间
     */
    private LocalDateTime createTime;
    /**
     * 更新时间
     */
    private LocalDateTime updateTime;
}
  • tb_blog_comments:其他用户对探店笔记的评价
package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_blog_comments")
public class BlogComments implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    /**
     * 用户id
     */
    private Long userId;
    /**
     * 探店id
     */
    private Long blogId;
    /**
     * 关联的1级评论id,如果是一级评论,则值为0
     */
    private Long parentId;
    /**
     * 回复的评论id
     */
    private Long answerId;
    /**
     * 回复的内容
     */
    private String content;
    /**
     * 点赞数
     */
    private Integer liked;
    /**
     * 状态,0:正常,1:被举报,2:禁止查看
     */
    private Boolean status;
    /**
     * 创建时间
     */
    private LocalDateTime createTime;
    /**
     * 更新时间
     */
    private LocalDateTime updateTime;
}

3. 保存图片

package erer.redis.project.controller;
@Slf4j
@RestController
@RequestMapping("upload")
public class UploadController {
    @PostMapping("blog")
    public Result uploadImage(@RequestParam("file") MultipartFile image) {
        try {
            // 获取原始文件名称
            String originalFilename = image.getOriginalFilename();
            // 生成新文件名
            String fileName = createNewFileName(originalFilename);
            // 保存文件 SystemConstants.IMAGE_UPLOAD_DIR = 文件保存地址
            image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
            // 返回结果
            log.debug("文件上传成功,{}", fileName);
            return Result.ok(fileName);
        } catch (IOException e) {
            throw new RuntimeException("文件上传失败", e);
        }
    }

    @GetMapping("/blog/delete")
    public Result deleteBlogImg(@RequestParam("name") String filename) {
        File file = new File(SystemConstants.IMAGE_UPLOAD_DIR, filename);
        if (file.isDirectory()) {
            return Result.fail("错误的文件名称");
        }
        FileUtil.del(file);
        return Result.ok();
    }

    private String createNewFileName(String originalFilename) {
        // 获取后缀
        String suffix = StrUtil.subAfter(originalFilename, ".", true);
        // 生成目录
        String name = UUID.randomUUID().toString();
        int hash = name.hashCode();
        int d1 = hash & 0xF;
        int d2 = (hash >> 4) & 0xF;
        // 判断目录是否存在
        File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR, StrUtil.format("/blogs/{}/{}", d1, d2));
        if (!dir.exists()) {
            dir.mkdirs();
        }
        // 生成文件名
        return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix);
    }
}

4. 保存笔记

package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {

    @Resource
    private IBlogService blogService;

    // 保存探店博文
    @PostMapping
    public Result saveBlog(@RequestBody Blog blog) {
        // 获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // 保存探店笔记
        blogService.save(blog);
        // 返回id
        return Result.ok(blog.getId());
    }

    // 通过 id 获取探店笔记
    @GetMapping("/{id}")
    public Result queryById(@PathVariable("id") Long id) {
        return blogService.queryById(id);
    }
    // 获取高赞笔记
    @GetMapping("/hot")
    public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
        return blogService.queryHotBlog(current);
    }
}

5. 获取探店笔记

package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
    @Resource
    private IUserService userService;
    
    @Override
    // 通过 id 获取探店笔记
    public Result queryById(Long id) {
        // 查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        } else {
            // 补全 blog 用户信息
            queryBlogUser(blog);
        }
        return Result.ok(blog);
    }
    @Override
    // 获取高赞笔记
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 补全 blog 作者信息
        records.forEach(this::queryBlogUser);
        return Result.ok(records);
    }
    // 补全 blog 作者信息
    private void queryBlogUser(Blog blog) {
        // 获取编写 blog 的 用户id
        Long userId = blog.getUserId();
        // 根据 用户id 获取用户信息
        User user = userService.getById(userId);
        // blog 插入 用户昵称
        blog.setName(user.getNickName());
        // blog 插入 用户头像
        blog.setIcon(user.getIcon());
    }
}

2、实现点赞功能

  • 同一个用户只能点赞一次,再次点击则取消点赞
  • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)

1. 添加标识

  • 给 Blog 类中添加一个isLike字段,标识是否被当前用户点赞
package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_blog")
public class Blog implements Serializable {
    private static final long serialVersionUID = 1L;
	...
    /**
     * 是否点赞过了
     * @TableField :该字段不属于 tb_blog 表
     */
    @TableField(exist = false)
    private Boolean isLike;
    ...
}

2. 完成点赞功能

  • 利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
  • 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
  • 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {
    @Resource
    private IBlogService blogService;
    @PutMapping("/like/{id}")
    public Result likeBlog(@PathVariable("id") Long id) {
//        // 修改点赞数量
//        blogService.update()
//                .setSql("liked = liked + 1").eq("id", id).update();
        return blogService.likeBlog(id);
    }
}
package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private IUserService userService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    // 通过 id 获取探店笔记
    public Result queryById(Long id) {
        // 查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        } else {
            // 补全 blog 用户信息
            queryBlogUser(blog);
            // 查询 blog 是否被点赞
            isBlogLiked(blog);
        }
        return Result.ok(blog);
    }

    @Override
    // 获取高赞笔记
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 补全 blog 用户信息
        records.forEach(blog -> {
            this.queryBlogUser(blog);
            this.isBlogLiked(blog);
        });
        return Result.ok(records);
    }

    @Override
    // 笔记点赞功能
    public Result likeBlog(Long id) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
        String key = RedisConstants.BLOG_LIKED_KEY + id;
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
        if (BooleanUtil.isFalse(isMember) ) {
            // 2.1当前用户没有点赞,完成点赞
            boolean isSuccess = update()
                    .setSql("liked = liked + 1")
                    .eq("id", id)
                    .update();
            if (isSuccess) {
                stringRedisTemplate.opsForSet().add(key, userId.toString());
            }
        } else {
            // 2.2当前用户已经点赞,取消点赞
            boolean isSuccess = update()
                    .setSql("liked = liked - 1")
                    .eq("id", id)
                    .update();
            if (isSuccess) {
                stringRedisTemplate.opsForSet().remove(key, userId.toString());
            }
        }
        return Result.ok();
    }

    // 补全 blog 作者信息
    private void queryBlogUser(Blog blog) {
        // 获取编写 blog 的 用户id
        Long userId = blog.getUserId();
        // 根据 用户id 获取用户信息
        User user = userService.getById(userId);
        // blog 插入 用户昵称
        blog.setName(user.getNickName());
        // blog 插入 用户头像
        blog.setIcon(user.getIcon());
    }

    // 判断用户是否点赞笔记
    private void isBlogLiked(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            // 用户未登录,无需查询是否点赞
            return;
        }
        // 2.获取用户id
        Long userId = user.getId();
        // 3.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
        String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        // 4.如果点过赞 结果为 true
        blog.setIsLike(score != null);
    }
}

3、点赞排行榜

  • 按照点赞时间先后排序,返回Top5的用户
ListSetSortedSet
排序方式按添加顺序排序无法排序根据score值排序
唯一性不唯一唯一唯一
查找方式按索引查找或首尾查找根据元素查找根据元素查找

1. 改造点赞功能

2. 获取点赞用户

package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {
    @Resource
    private IBlogService blogService;
	...
    @GetMapping("/likes/{id}")
    public Result queryLikesBlog(@PathVariable("id") Long id) {
        return blogService.queryLikesBlog(id);
    }
	...
}

3. 排序打印结果

package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private IUserService userService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    // 通过 id 获取探店笔记 
    public Result queryById(Long id) {
        // 查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        } else {
            // 补全 blog 用户信息
            queryBlogUser(blog);
            // 查询 blog 是否被点赞
            isBlogLiked(blog);
        }
        return Result.ok(blog);
    }

    @Override
    // 获取高赞笔记
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 补全 blog 用户信息
        records.forEach(blog -> {
            this.queryBlogUser(blog);
            this.isBlogLiked(blog);
        });
        return Result.ok(records);
    }

    @Override
    // 笔记点赞功能
    public Result likeBlog(Long id) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
        String key = RedisConstants.BLOG_LIKED_KEY + id;
        // 获取时间戳
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        if (score == null) {
            // 2.1当前用户没有点赞,完成点赞
            boolean isSuccess = update()
                    .setSql("liked = liked + 1")
                    .eq("id", id)
                    .update();
            if (isSuccess) {
                // 将用户id 保存到 Redis 的 Zset 集合,同时保存时间戳
                stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
            }
        } else {
            // 2.2当前用户已经点赞,取消点赞
            boolean isSuccess = update()
                    .setSql("liked = liked - 1")
                    .eq("id", id)
                    .update();
            if (isSuccess) {
                // 将用户id 在 Redis 的 Zset 集合中删除
                stringRedisTemplate.opsForZSet().remove(key, userId.toString());
            }
        }
        return Result.ok();
    }

    @Override
    // 获取笔记点赞用户
    public Result queryLikesBlog(Long id) {
        // 1.查询top5的点赞用户 zrange key 0 4
        // BLOG_LIKED_KEY = "blog:liked:"
        String key = RedisConstants.BLOG_LIKED_KEY + id;
        Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
        if (top5 == null || top5.isEmpty()) {
            return Result.ok(Collections.emptyList());
        } else {
            // 2.解析出其中的用户id
            List<Long> ids = top5
                    .stream()
                    .map(Long::valueOf)
                    .collect(Collectors.toList());
            String idsStr = StrUtil.join(",", ids);
            // 3.根据用户id获取用户 select * from tb_user where id in (...) 会打乱排序
            // select * from tb_user where id in (...) order by field (id, ...)
            List<UserDTO> users = userService
                    .query()    // 查询
                    .in("id", ids)  // 条件:in
                    .last("order by field(id,"+ idsStr + ")")   // 拼接 order语句
                    .list() // 查询结果封装为 list
                    .stream()
                    .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                    .collect(Collectors.toList());
            // 4.返回
            return Result.ok(users);
        }
    }

    // 补全 blog 作者信息
    private void queryBlogUser(Blog blog) {
        // 获取编写 blog 的 用户id
        Long userId = blog.getUserId();
        // 根据 用户id 获取用户信息
        User user = userService.getById(userId);
        // blog 插入 用户昵称
        blog.setName(user.getNickName());
        // blog 插入 用户头像
        blog.setIcon(user.getIcon());
    }

    // 判断用户是否点赞笔记
    private void isBlogLiked(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            // 用户未登录,无需查询是否点赞
            return;
        }
        // 2.获取用户id
        Long userId = user.getId();
        // 3.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
        String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        // 4.如果点过赞 结果为 true
        blog.setIsLike(score != null);
    }
}

七、好友关注

1、关注和取关

1. 前端接口

在这里插入图片描述

2. 数据表

CREATE TABLE `tb_follow` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint unsigned NOT NULL COMMENT '用户id',
  `follow_user_id` bigint unsigned NOT NULL COMMENT '关联的用户id',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;

3. POJO

package erer.redis.project.entity;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_follow")
public class Follow implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    /**
     * 用户id
     */
    private Long userId;
    /**
     * 关联的用户id
     */
    private Long followUserId;
    /**
     * 创建时间
     */
    private LocalDateTime createTime;
}

4. Controller

package erer.redis.project.controller;
@RestController
@RequestMapping("/follow")
public class FollowController {
    @Resource
    private IFollowService followService;

    @PutMapping("/{id}/{isFollow}")
    public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
        return followService.follow(followUserId, isFollow);
    }
    @GetMapping("/or/not/{id}")
    public Result isFollow(@PathVariable("id") Long followUserId) {
        return followService.isFollow(followUserId);
    }
}

5. Service

package erer.redis.project.service.impl;
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    @Override
    // 关注和取关
    public Result follow(Long followUserId, Boolean isFollow) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        // 2.判断是关注还是取关
        if (isFollow) {
            // 关注
            Follow follow = new Follow();
            follow.setUserId(user.getId());
            follow.setFollowUserId(followUserId);
            save(follow);
        } else {
            // 取关:删除 delete from tb_follow where user_id = ? and follow_user_id = ?
            remove(new QueryWrapper<Follow>()
                    .eq("user_id", user.getId())
                    .eq("follow_user_id", followUserId)
            );
        }
        return Result.ok();
    }
    @Override
    // 查询是否关注
    public Result isFollow(Long followUserId) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        // 2.查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?
        Integer count = query()
                .eq("user_id", user.getId())
                .eq("follow_user_id", followUserId)
                .count();
        // 3.返回查询结果
        return Result.ok(count > 0);
    }
}

2、共同关注

1. 改造关注接口

  • 利用 Redis 中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同好友。

2. 完成共同关注

  • Controller
package erer.redis.project.controller;
@RestController
@RequestMapping("/follow")
public class FollowController {
    @Resource
    private IFollowService followService;

    @PutMapping("/{id}/{isFollow}")
    public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
        return followService.follow(followUserId, isFollow);
    }

    @GetMapping("/or/not/{id}")
    public Result isFollow(@PathVariable("id") Long followUserId) {
        return followService.isFollow(followUserId);
    }

    @GetMapping("/common/{id}")
    public Result followCommons(@PathVariable("id") Long id) {
        return followService.followCommons(id);
    }
}
  • Service
package erer.redis.project.service.impl;
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private IUserService userService;

    @Override
    // 关注和取关
    public Result follow(Long followUserId, Boolean isFollow) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        // 2.判断是关注还是取关
        String key = RedisConstants.FOLLOW_KEY + user.getId();
        if (isFollow) {
            // 关注
            Follow follow = new Follow();
            follow.setUserId(user.getId());
            follow.setFollowUserId(followUserId);
            boolean isSuccess = save(follow);
            if (isSuccess) {
                // 把 关注用户的 id ,放入 redis 的 set 集合
                stringRedisTemplate.opsForSet().add(key, followUserId.toString());
            }
        } else {
            // 取关:删除 delete from tb_follow where user_id = ? and follow_user_id = ?
            boolean isSuccess = remove(new QueryWrapper<Follow>()
                    .eq("user_id", user.getId())
                    .eq("follow_user_id", followUserId)
            );
            // 把 关注用户的 id ,从 redis 的 set 集合移除
            if (isSuccess) {
                stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
            }
        }
        return Result.ok();
    }

    @Override
    // 查询是否关注
    public Result isFollow(Long followUserId) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        // 2.查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?
        Integer count = query()
                .eq("user_id", user.getId())
                .eq("follow_user_id", followUserId)
                .count();
        // 3.返回查询结果
        return Result.ok(count > 0);
    }

    @Override
    // 获取共同关注
    public Result followCommons(Long id) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        String key = RedisConstants.FOLLOW_KEY + userId;
        // 2.求交集
        String followKey = RedisConstants.FOLLOW_KEY + id;
        Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, followKey);
        // 3.解析集合
        if (intersect == null || intersect.isEmpty()) {
            // 无共同关注
            return Result.ok(Collections.emptyList());
        } else {
            List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
            // 4.查询用户
            List<UserDTO> users = userService.listByIds(ids)
                    .stream()
                    .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                    .collect(Collectors.toList());
            return Result.ok(users);
        }
    }
}

3、关注推送

1. Feed流

Feed流产品有两种常见模式:

  • Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
    • 优点:信息全面,不会有缺失。并且实现也相对简单
    • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
  • 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
    • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
    • 缺点:如果算法不精准,可能起到反作用

2. Timeline模式

该模式的实现方案有三种:

  • 拉模式:也叫做读扩散

在这里插入图片描述

  • 推模式:也叫做写扩散

在这里插入图片描述

  • 推拉结合:也叫做读写混合,兼具推和拉两种模式的优点

在这里插入图片描述

3. 方案对比

项目拉模式推模式推拉结合
写比例
读比例
用户读取延迟
实现难度复杂简单很复杂
使用场景很少使用用户量少、没有大V过千万的用户量,有大V

4. 关注推送功能

  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询
4.1 Controller
package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {
    @Resource
    private IBlogService blogService;
    @PostMapping
    public Result saveBlog(@RequestBody Blog blog) {
//        // 获取登录用户
//        UserDTO user = UserHolder.getUser();
//        blog.setUserId(user.getId());
//        // 保存探店博文
//        blogService.save(blog);
//        // 返回id
//        return Result.ok(blog.getId());
        return blogService.saveBlog(blog);
    }
 ...
}
4.2 Service
package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private IUserService userService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private IFollowService followService;

    @Override
    public Result saveBlog(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // 2.保存探店笔记
        boolean isSuccess = save(blog);
        if (!isSuccess) {
            return Result.fail("新增笔记失败!");
        } else {
            // 3.查询笔记作者所有粉丝 select * from tb_follow where follow_user_id = ?
            List<Follow> follows = followService
                    .query()
                    .eq("follow_user_id", user.getId())
                    .list();
            // 4.推送笔记id给所有粉丝
            long time = System.currentTimeMillis();
            for (Follow follow : follows) {
                // 获取粉丝 id
                Long userId = follow.getUserId();
                // 推送
                String key = RedisConstants.FEED_KEY + userId;
                stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), time);
            }
            // 5.返回 id
            return Result.ok(blog.getId());
        }
    }
}

5. Feed流的分页问题

5.1 问题描述
  • Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。

在这里插入图片描述

5.2 滚动分页

在这里插入图片描述

6. 滚动分页查询

  • 参数:
    • max:当前时间戳(上一次查询的最小时间戳)
    • min:0
    • offset:0(上一次查询结果中,与最小值一样的元素个数)
    • count:3(单次查询条数)
6.1 POJO
package erer.redis.project.dto;
@Data
public class ScrollResult {
    private List<?> list;
    private Long minTime;
    private Integer offset;
}
6.2 Controller
package erer.redis.project.controller;
@RestController
@RequestMapping("/blog")
public class BlogController {

    @Resource
    private IBlogService blogService;

    // URL: http://localhost:8080/api/blog/of/follow?&lastId=1650055922613
    @GetMapping("/of/follow")
    public Result queryBlogOfFollow(
            @RequestParam("lastId") Long max,
            @RequestParam(value = "offset", defaultValue = "0") Integer offset) {
        return blogService.queryBlogOfFollow(max, offset);
    }
}
6.3 Service
package erer.redis.project.service.impl;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private IUserService userService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private IFollowService followService;

    @Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        String key = RedisConstants.FEED_KEY + user.getId();
        // 2.查询收件箱 zRevRangeByScore key max min withScores limit offset count
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
        if (typedTuples == null || typedTuples.isEmpty()) {
            return Result.ok();
        } else {
            // 3.解析结果:blogId、minTime(时间戳)、offset
            List<Long> ids = new ArrayList<>(typedTuples.size());
            long minTime = 0;
            int minTimNum = 1;
            for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
                // 获取id
                ids.add(Long.valueOf(tuple.getValue()));
                // 获取时间戳
                long time = tuple.getScore().longValue();
                if (time == minTime) {
                    minTimNum++;
                } else {
                    minTime = time;
                    minTimNum = 1;
                }
            }
            // 4.根据id查询blog
            String idsStr = StrUtil.join(",", ids);
            List<Blog> blogs = query()    // 查询
                    .in("id", ids)  // 条件:in
                    .last("order by field(id," + idsStr + ")")   // 拼接 order语句
                    .list();// 查询结果封装为 list

            for (Blog blog : blogs) {
               // 补全 blog 用户信息
               queryBlogUser(blog);
               // 查询 blog 是否被点赞
               isBlogLiked(blog);
           }

            // 5.封装返回结果
            ScrollResult result = new ScrollResult();
            result.setList(blogs);
            result.setOffset(minTimNum);
            result.setMinTime(minTime);
            return Result.ok(result);
        }
    }

    private void queryBlogUser(Blog blog) {
        // 获取编写 blog 的 用户id
        Long userId = blog.getUserId();
        // 根据 用户id 获取用户信息
        User user = userService.getById(userId);
        // blog 插入 用户昵称
        blog.setName(user.getNickName());
        // blog 插入 用户头像
        blog.setIcon(user.getIcon());
    }

    private void isBlogLiked(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            // 用户未登录,无需查询是否点赞
            return;
        }
        // 2.获取用户id
        Long userId = user.getId();
        // 3.判断当前用户是否点赞 BLOG_LIKED_KEY = "blog:liked:"
        String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        // 4.如果点过赞 结果为 true
        blog.setIsLike(score != null);
    }
}

八、附近商户搜索

1、获取信息

  • 在首页中点击某个频道,即可看到频道下的商户:

在这里插入图片描述

2、信息分类

在这里插入图片描述

@SpringBootTest()
class ProjectApplicationTests {

    @Resource
    private ShopServiceImpl shopService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void loadShopData() {
        // 1.查询店铺信息
        List<Shop> list = shopService.list();
        // 2.根据 typeId 分组,id一致的放到一个集合
        Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
        // 3.分批完成写入 Redis
        for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
            // 获取类型 id
            Long typeId = entry.getKey();
            String key = "shop:geo:" + typeId;
            // 获取同类型的店铺的集合
            List<Shop> value = entry.getValue();
            List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
            // 写入 redis
            for (Shop shop : value) {
                //stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
                locations.add(new RedisGeoCommands.GeoLocation<>(
                        shop.getId().toString(),
                        new Point(shop.getX(), shop.getY())
                ));
            }
            stringRedisTemplate.opsForGeo().add(key, locations);
        }
    }
}

3、距离排序

package erer.redis.project.service.impl;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
        // 1.判断是否需要根据坐标查询
        if (x == null || y == null) {
            // 根据类型分页查询
            Page<Shop> page = query()
                    .eq("type_id", typeId)
                    .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
            // 返回数据
            return Result.ok(page.getRecords());
        }
        // 2.计算分页参数
        int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
        int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
        // 3.查询 redis 、按照距离排序、分页,计算结果:shopId、distance
        String key = RedisConstants.SHOP_GEO_KEY + typeId;
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                .includeDistance()
                .limit(end);
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(
                key,
                GeoReference.fromCoordinate(x, y),
                new Distance(5000),
                args
        );
        // 4.解析出 id
        if (results == null) {
            return Result.ok(Collections.emptyList());
        }
        List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
        if (list.size() <= from) {
            return Result.ok(Collections.emptyList());
        }
        // 截取 from ~ end 的部分
        List<Long> ids = new ArrayList<>(list.size());
        Map<String, Distance> distanceMap = new HashMap<>(list.size());
        list.stream().skip(from).forEach(result -> {
            // 获取店铺 id
            String name = result.getContent().getName();
            ids.add(Long.valueOf(name));
            Distance distance = result.getDistance();
            distanceMap.put(name, distance);
        });
        // 5.根据 id 查询 shop
        String idStr = StrUtil.join(",", ids);
        List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
        for (Shop shop : shops) {
            shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
        }
        // 6.返回
        return Result.ok(shops);
    }
}

九、用户签到

1、BitMap

我们按月来统计用户签到信息,签到记录为1,未签到则记录为0。

在这里插入图片描述

  • 把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为 位图(BitMap)。
  • **Redis **中是利用 string 类型数据结构实现 BitMap,因此最大上限是512M,转换为bit则是232个bit位。

2、操作命令

  • [SETBIT]:向指定位置(offset)存入一 个 0 或 1
  • [GETBIT]:获取指定位置(offset)的bit值
  • [BITCOUNT]:统计BitMap中值为1的bit位的数量
  • [BITFIELD]:操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
  • [BITFIELD_RO]:获取BitMap中bit数组,并以十进制形式返回
  • [BITOP]:将多个BitMap的结果做位运算(与 、或、异或)
  • [BITPOS]:查找 bit 数组中指定范围内第一个0或1出现的位置
127.0.0.1:6379> setbit bm1 0 1
(integer) 0
127.0.0.1:6379> setbit bm1 1 1
(integer) 0
127.0.0.1:6379> setbit bm1 2 1
(integer) 0
127.0.0.1:6379> setbit bm1 5 1
(integer) 0
127.0.0.1:6379> setbit bm1 6 1
(integer) 0
127.0.0.1:6379> setbit bm1 7 1
(integer) 0
127.0.0.1:6379> getbit bm1 2
(integer) 1
127.0.0.1:6379> getbit bm1 3
(integer) 0
127.0.0.1:6379> bitcount bm1
(integer) 6
-- 范围查询 u:有符号,i:无符号, 2:获取2个bit位,0:从0号位开始获取
127.0.0.1:6379> bitfield bm1 get u2 0
1) (integer) 3
127.0.0.1:6379> bitfield bm1 get u3 0
1) (integer) 7
127.0.0.1:6379> bitfield bm1 get u3 1
1) (integer) 6
-- 查找0、1第一次出现的位置
127.0.0.1:6379> bitpos bm1 0
(integer) 3
127.0.0.1:6379> bitpos bm1 1
(integer) 0

3、签到功能

package erer.redis.project.service.impl;
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sign() {
        // 1.获取当前登录的用户
        Long userId = UserHolder.getUser().getId();
        // 2.获取日期
        LocalDateTime now = LocalDateTime.now();
        // 3.拼接 key
        String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
        String key = RedisConstants.USER_SIGN_KEY + userId + ":" + keySuffix;
        // 4.获取今天是本月第几天
        int dayOfMonth = now.getDayOfMonth();
        // 5.写入 Redis
        stringRedisTemplate.opsForValue().setBit(key, dayOfMonth-1, true);
        return Result.ok();
    }
}

4、签到测试

  • 发送请求

在这里插入图片描述

  • 测试结果

在这里插入图片描述

5、签到统计

package erer.redis.project.service.impl;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result signCount() {
        // 1.获取当前登录的用户
        Long userId = UserHolder.getUser().getId();
        // 2.获取日期
        LocalDateTime now = LocalDateTime.now();
        // 3.拼接 key
        String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
        String key = RedisConstants.USER_SIGN_KEY + userId + ":" + keySuffix;
        // 4.获取今天是本月第几天
        int dayOfMonth = now.getDayOfMonth();
        // 5.获取本月截至今天所有的签到记录,返回的是一个十进制的数字
        List<Long> result = stringRedisTemplate.opsForValue().bitField(
                key,
                BitFieldSubCommands
                        .create()
                        .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
        );
        // 6.健壮性判断
        if (result == null || result.isEmpty()) {
            return Result.ok(0);
        }
        Long num = result.get(0);
        if (num == null || num == 0) {
            return Result.ok(0);
        }
        // 7.循环遍历
        int count = 0;
        while (true) {
            // 与 1 做与运算,获取数字最后亿个 bit 位
            if ((num & 1) == 0) {
                // 如果为 0,说明未签到,结束循环
                break;
            } else {
                // 如果不为0,说明签到,计数器 +1
                count++;
            }
            // 把数字右移一位,抛弃掉最后一位 bit
            num >>>= 1;
        }
        return Result.ok(count);
    }
}

6、统计测试

在这里插入图片描述
在这里插入图片描述

十、UV统计

1、HyperLogLog

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
  • PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0

Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。

2、使用方法

在这里插入图片描述

127.0.0.1:6379> pfadd hl e1 e2 e3 e4 e5
(integer) 1
127.0.0.1:6379> pfcount hl
(integer) 5
127.0.0.1:6379> pfadd hl e1 e2 e3 e4 e5
(integer) 0
127.0.0.1:6379> pfcount hl
(integer) 5

3、统计测试

@SpringBootTest()
class ProjectApplicationTests {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void testHyperLogLog() {
        // 准备数组,装用户数据
        String[] users = new String[1000];
        // 数组角标
        int index = 0;
        for (int i = 1; i <= 1000000; i++) {
            // 赋值
            users[index++] = "user_" + i;
            // 每1000条发送一次
            if (i % 1000 == 0) {
                index = 0;
                stringRedisTemplate.opsForHyperLogLog().add("hll1", users);
            }
        }
        // 统计数量
        Long size = stringRedisTemplate.opsForHyperLogLog().size("hll1");
        System.out.println("size = " + size);
    }
}
sTemplate stringRedisTemplate;

    @Override
    public Result sign() {
        // 1.获取当前登录的用户
        Long userId = UserHolder.getUser().getId();
        // 2.获取日期
        LocalDateTime now = LocalDateTime.now();
        // 3.拼接 key
        String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
        String key = RedisConstants.USER_SIGN_KEY + userId + ":" + keySuffix;
        // 4.获取今天是本月第几天
        int dayOfMonth = now.getDayOfMonth();
        // 5.写入 Redis
        stringRedisTemplate.opsForValue().setBit(key, dayOfMonth-1, true);
        return Result.ok();
    }
}
  • 7
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值