Capítulo prático do Dark Horse Redis6

Diretório de artigos

1. Visão Geral

imagem-20230701192254202

ilustrar:

​ Este projeto demonstra apenas as funções da camada de serviço aqui

2. Login por SMS

Resumo das notas:

  1. Comando Redis:

    • Na função de envio do código de verificação SMS, o método Stringde conjunto de comandos Redis seté usado para concluir o salvamento do código de verificação.

    • Nas funções de login e registro do código de verificação SMS, o método Hashde conjunto de comandos Redis putAllé usado para salvar as informações dos usuários logados.

    • Na função de verificar o status de login, o Hashconjunto de comandos Reids é usado para entriesverificar o valor não nulo das informações do usuário logado e salvar a identidade.

  2. Dificuldades na implementação da função:

    • Na ferramenta Hutool, o método de conversão do tipo Bean para o tipo Map BeanUtil、beanToMapé utilizado CopyOptions.create().ignoreNullValue().setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())para converter o tipo de parâmetro em um dos atributos.

2.1 Login de implementação de sessão regular

2.1.1 Visão geral

imagem-20230620190838544

ilustrar:

​ O login através da Sessão é dividido no envio das etapas acima. Enviar código de verificação por SMS, login e registro do código de verificação por SMS e verificar o status de login. Ao salvar o código de verificação e o usuário no domínio da sessão, o gerenciamento de sessão e outras operações podem ser realizados.

2.1.2 Casos de uso básicos

Etapa 1: enviar código de verificação

  • Adicionar método UserServiceImplde classesendCode
/**
     * @param phone   手机号
     * @param session session域
     * @return Result风格结果
     */
@Override
public Result sendCode(String phone, HttpSession session) {
    
    
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
    
    
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }

    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 4.保存验证码、手机号到session
    session.setAttribute("code", code);
    session.setAttribute("phone", phone);
    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{},", code);
    return Result.ok();
}

Etapa 2: faça login

  • Adicionar método UserServiceImplde classelogin
/**
     * @param loginForm 封装登录用户的DTO
     * @param session   session域
     * @return eoken
     */
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    
    

    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone) || ObjectUtil.notEqual(phone, session.getAttribute("phone").toString())) {
    
    
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号错误!");
    }
    // 3.校验验证码
    String code = loginForm.getCode();
    if (RegexUtils.isCodeInvalid(code) || ObjectUtil.notEqual(code, session.getAttribute("code").toString())) {
    
    
        // 4.如果不符合,返回错误信息
        return Result.fail("验证码错误!");
    }
    // 5.判断用户是否存在
    LambdaQueryWrapper<User> lambdaQuery = new LambdaQueryWrapper<>();
    lambdaQuery.eq(User::getPhone, phone);
    User user = userMapper.selectOne(lambdaQuery);
    // 6.用户不存在,创建用户,保存到数据库
    if (ObjectUtil.isNull(user)) {
    
    
        user = new User(phone, USER_NICK_NAME_PREFIX + RandomUtil.randomString(5));
        userMapper.insert(user);
    }
    // 7.保存用户到session
    // 此处保存用户session信息时,使用Hutool工具的拷贝字节流的方式将属性值存入UserDTO类中,防止过多的用户信息发送给前端,造成安全问题
    session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
    return Result.ok();
}

Reabastecimento:

​ O que significa DTO, site de referência: (43 mensagens) Java tem um conhecimento profundo de DTO e como usar DTO_O que é o blog-CSDN do dto_visant

Reabastecimento:

  • LoginFormDTOtipo
@Data
public class LoginFormDTO {
     
     
    private String phone;
    private String code;
    private String password;
}

Etapa 3: verifique o status de login

1. Crie LoginInterceptorum interceptador

/*
* 定义拦登录拦截器并实现逻辑,在拦截器中,记录用户身份信息
*/
public class LoginInterceptor implements HandlerInterceptor {
    
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    

        // 1.获取session
        HttpSession session = request.getSession();
        // 2.获取session中的用户
        UserDTO user = (UserDTO) session.getAttribute("user");
        // 3.判断用户是否存在
        boolean result = ObjectUtil.isNull(user);
        // 4.不存在,拦截
        if (result) {
    
    
            // 返回401状态码
            response.setStatus(401);
            return false;
        }
        // 5.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(user);
        // 6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    
    
        // 当拦截器完成之后,将内存中ThreadLocal(对线程内)里面保存的用户信息清除,释放内存空间,避免浪费
        UserHolder.removeUser();
    }
}

2. Crie MvcConfigum arquivo de configuração

/*
* 创建配置类,并注册登录拦截器
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    
    
        // 配置相应的放行逻辑
        registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
                "/shop/**",
                "/voucher/**",
                "/shop-type/**",
                "/upload/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        );
    }

    @Bean
    LoginInterceptor getLoginInterceptor() {
    
    
        return new LoginInterceptor();
    }
}

2.1.3 Resumo

imagem-20230621091917804

ilustrar:

​De acordo com a arquitetura do sistema, quando for necessário construir um cluster através do tomcat, ocorrerão problemas de compartilhamento de sessão nesta implementação. O domínio da sessão não pode ser compartilhado entre o mesmo tomcat e outro tomcat.

2.2Redis implementa login de sessão compartilhada

2.2.1 Visão geral

imagem-20230621093235161

ilustrar:

Salve as informações do código de verificação no Redis em vez do domínio da sessão, o que facilita o problema de vários servidores Tomcat acessando o serviço Redis e resolvendo o problema do domínio compartilhado da sessão.

imagem-20230621092755862

ilustrar:

​ Não é recomendado usar diretamente o número do celular como chave para o Token armazenado no Redis aqui, porque o Token será devolvido ao front end no futuro, e o uso do número do celular causará o risco de vazamento de informações .

2.2.2 Casos de uso básicos

ilustrar:

  • Ideias de implementação:

    Quando o código de verificação por SMS é enviado, o código de verificação é armazenado no Redis. Ao verificar o código de verificação, recupere-o do Redis e verifique-o. Após o login bem-sucedido, o token é armazenado no Redis e, ao verificar a identidade do usuário, ele é retirado do Redis e verificado.

Etapa 1: importar dependências

1. Modifique Pom.xmlo arquivo e adicione as seguintes dependências

<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.1</version>
</dependency>
<!--hutool-->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.16</version>
</dependency>

ilustrar:

Hutool é uma pequena e abrangente biblioteca de classes de ferramentas Java. Ela usa encapsulamento de método estático para reduzir o custo de aprendizado de APIs relacionadas e melhorar a eficiência do trabalho. Torna o Java tão elegante quanto uma linguagem funcional e torna a linguagem Java "doce".

Etapa 2: gravar Pom.xmlo arquivo de configuração

server:
  port: 8081
spring:
  application:
    name: hmdp
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/db1?useSSL=false&serverTimezone=UTC
    username: root
    password: qweasdzxc
  redis:
    host: 10.13.164.55
    port: 6379
    password: qweasdzxc
    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: com.hmdp.entity # 别名扫描包
logging:
  level:
    com.hmdp: debug

Etapa 3: encapsular o conjunto de resultados do estilo de resultado

1. Crie uma classe Result no pacote 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);
    }
}

ilustrar:

Encapsule o conjunto de resultados Result e processe e retorne uniformemente os resultados da camada de controle.

Etapa 4: crie uma classe de ferramenta

1. Crie uma classe constante do sistema

  • utilCrie uma SystemConstantsclasse constante do sistema no pacote
public class SystemConstants {
    
    
    public static final String IMAGE_UPLOAD_DIR = "D:\\lesson\\nginx-1.18.0\\html\\hmdp\\imgs\\";
    public static final String USER_NICK_NAME_PREFIX = "user_";
    public static final int DEFAULT_PAGE_SIZE = 5;
    public static final int MAX_PAGE_SIZE = 10;
}

2. Crie uma classe de ferramenta de verificação

2.1 Crie expressões regulares comuns

  • utilCrie uma RegexPatternsclasse de validação de formato no pacote
public abstract class RegexPatterns {
    
    
    /**
     * 手机号正则
     */
    public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
    /**
     * 邮箱正则
     */
    public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    /**
     * 密码正则。4~32位的字母、数字、下划线
     */
    public static final String PASSWORD_REGEX = "^\\w{4,32}$";
    /**
     * 验证码正则, 6位数字或字母
     */
    public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";
}

2.2 Criar regras de verificação

  • utilCrie uma RegexUtilsclasse de verificação de parâmetro no pacote
public class RegexUtils {
    
    
    /**
     * 是否是无效手机格式
     *
     * @param phone 要校验的手机号
     * @return true:符合,false:不符合
     */
    public static boolean isPhoneInvalid(String phone) {
    
    
        return mismatch(phone, RegexPatterns.PHONE_REGEX);
    }

    /**
     * 是否是无效邮箱格式
     *
     * @param email 要校验的邮箱
     * @return true:符合,false:不符合
     */
    public static boolean isEmailInvalid(String email) {
    
    
        return mismatch(email, RegexPatterns.EMAIL_REGEX);
    }

    /**
     * 是否是无效验证码格式
     *
     * @param code 要校验的验证码
     * @return true:符合,false:不符合
     */
    public static boolean isCodeInvalid(String code) {
    
    
        return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
    }

    // 校验是否不符合正则格式
    private static boolean mismatch(String str, String regex) {
    
    
        if (StrUtil.isBlank(str)) {
    
    
            return true;
        }
        return !str.matches(regex);
    }
}

Passo 5: Implementar o serviço de envio de código de verificação por SMS

1. UserServiceImplCrie um sendCodemétodo de envio na classe de implementação

@Autowired
StringRedisTemplate stringRedisTemplate; //利用StringRedisTemplate实现对Redis的操作
@Autowired
UserMapper userMapper;

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

    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 4.保存验证码到redis
    stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{},", code);

    return Result.ok();
}

Etapa 6: implementar o serviço de login

1. UserServiceImplCrie um loginmétodo send na classe

@Autowired
UserMapper userMapper;

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    
    

    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
    
    
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.从Redis获取校验验证码
    String verCode = loginForm.getCode();
    String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    if (RegexUtils.isCodeInvalid(verCode) || ObjectUtil.notEqual(code, verCode)) {
    
    
        // 4.如果不符合,返回错误信息
        return Result.fail("验证码错误!");
    }
    // 5.判断用户是否存在
    LambdaQueryWrapper<User> lambdaQuery = new LambdaQueryWrapper<>();
    lambdaQuery.eq(User::getPhone, phone);
    User user = userMapper.selectOne(lambdaQuery);
    // 6.用户不存在,创建用户,保存到数据库
    if (ObjectUtil.isNull(user)) {
    
    
        user = new User(phone, USER_NICK_NAME_PREFIX + RandomUtil.randomString(5));
        userMapper.insert(user);
    }
    // 7.保存用户到redis
    // 7.1使用Hutool的UUID方法随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 7.2将User对象转换为HashMap,便于存储Redis
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    // 此处通过Hutool工具在将Bean数据转换为Map
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create().ignoreNullValue()
                        .setFieldValueEditor(new BiFunction<String, Object, Object>() {
    
    
                            @Override
                            public Object apply(String fieldName, Object fieldValue) {
    
    
                                return fieldValue.toString(); // 因为UserDTO实体类的ID为Long类型,不能直接存入Redis,需要转换为其余类型
                            }
                        }));
    // 7.3利用Redis的hash方式,存储用户信息
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4设置有效期
    stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 8.返回结果
    return Result.ok(token); //此处需要将token返回给前端,前端添加到请求头的authorization中
}

Perceber:

  • Defina o período de validade do Token para facilitar a limpeza do armazenamento de dados Redis e para melhorar a segurança dos dados.

  • Você precisa definir o tipo no método de conversão de Bean em Map para String.

    imagem-20230621113911283

ilustrar:

O período de validade do token é definido aqui, o que significa que toda vez que o tempo do token para login expirar, o usuário será forçado a sair e fazer login novamente. Portanto, você precisa encontrar um local para definir o horário de atualização do token. Basta estender o período de validade do Token permitindo que o usuário fique ativo em 30 minutos. Continue revisando as etapas abaixo

Reabastecimento:

  • UserDTOtipo
@Data
public class UserDTO {
     
     
    private Long id;
    private String nickName;
    private String icon;
}

Etapa 7: verifique o status de login

imagem-20230621190241757

ilustrar:

Adicione um novo interceptor, intercepte todos os caminhos e processe o tempo de atualização do domínio da sessão no Redis para todos os recursos acessados ​​em todos os caminhos.

1. Adicionar RefreshTokenInterceptorinterceptador

public class RefreshTokenInterceptor implements HandlerInterceptor {
    
    
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    

        // 1.获取请求头中的Token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
    
    
            return true;
        }
        // 2.基于Token获取Redis中的用户
        String tokenKey = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
    
    
            // 4.不存在则放行,交给LoginInterceptor拦截器进行处理
            return true;
        }
        // 5.将查询到的Hash数据转换为UserDTO对象(便于存储到ThreadLocal中)-Hutool(BeanUtil.fillBeanWithMap)
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新Token有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    
    
        // 当执行完Controller内的方法后,对线程内的用户信息进行删除
        UserHolder.removeUser();
    }
}

ilustrar:

​ Aqui você precisa atualizar o período de validade do Token para manter o usuário online.

2. Modifique LoginInterceptoro interceptador

public class LoginInterceptor implements HandlerInterceptor {
    
    

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

3. Modifique MvcConfiga classe de configuração

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    
    
        // 	设置多个拦截器的先后顺序,让拦截器的执行时机变得有序
        registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
                "/shop/**",
                "/voucher/**",
                "/shop-type/**",
                "/upload/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        ).order(1);
        registry.addInterceptor(getRefreshTokenInterceptor()).addPathPatterns("/**").order(0);
    }

    @Bean
    LoginInterceptor getLoginInterceptor() {
    
    
        return new LoginInterceptor();
    }

    @Bean
    RefreshTokenInterceptor getRefreshTokenInterceptor() {
    
    
        return new RefreshTokenInterceptor();
    }
}

2.2.3 Resumo

Ao registrar e atualizar o status de login do usuário por meio do Redis, pode facilitar o compartilhamento de informações de operação do Redis pelo Tomcat no cluster, resolvendo assim o problema de salvamento e verificação de sessão.

3. Cache de consulta do comerciante

Resumo das notas:

  1. Comando Redis:

    • Na função de adicionar cache, o Stringconjunto de comandos sete getmétodos Redis são usados ​​para completar o cache e a consulta dos dados do comerciante e melhorar a velocidade de resposta do sistema

    • Na função de estratégia de atualização de cache, o método Stirngde conjunto de comandos Redis setusa o atributo de expiração de tempo limite ao implementar a eliminação de tempo limite. Ao implementar atualizações ativas, o deletecomando Redis é usado para remover a chave.

    • Na função de penetração de cache, o método Stringde conjunto de comandos Redis seté usado e a técnica é usada para salvar o valor nulo para resolver a penetração de cache

    • Na função de divisão de cache, o método Stringde conjunto de comandos Redis setIfAbsenté usado para implementar o bloqueio mutex.

  2. Dificuldades na implementação da função:

    • Ao implementar a função de estratégia de atualização de cache, use a eliminação de tempo limite para atingir requisitos de consistência mais baixos. Use a atualização ativa para executar o processo de primeiro excluir o banco de dados e depois excluir o cache para atingir requisitos de consistência mais elevados.
    • Ao implementar a função de penetração de cache, ao implementar o esquema de bloqueio mutex, os bloqueios de verificação dupla são usados ​​para realizar verificações secundárias para evitar o carregamento repetido de dados.
    • Ao implementar a função de penetração de cache, ao implementar o esquema de expiração lógica, RedisDataas classes podem ser usadas de maneira inteligente para adicionar atributos de membro adicionais da classe de produto sem modificar a classe original. Use Executors.newFixedThreadPoolmétodos para criar pools multithread e enviar tarefas
    • Na ferramenta Hutool, use StrUtil.isNotBlanko método para concluir o julgamento da existência das informações armazenadas. Método de uso RandomUtil.randomLong, adicionar valores aleatórios, resolver avalanche de cache. Use JSONUtil.toBeano método para desserializar o objeto. Use JSONUtil.toJsonStro método para serializar o objeto

3.1 Adicionar cache

3.1.1 Visão geral

significado:

O cache é um buffer para troca de dados (chamado Cache [ kæʃ ]) É um local temporário para armazenamento de dados e geralmente possui alto desempenho de leitura e gravação.

imagem-20230621194023079

efeito:

imagem-20230621193443492

Cenários de aplicação:

imagem-20230621193412204

Reabastecimento:

O cache pode nos trazer muitas vantagens, mas é mais propenso a problemas como quebra de cache e avalanche de cache.

imagem-20230621194247699

ilustrar:

  • Quando o cache não é utilizado para buffer de dados, se o cliente desejar obter dados, ele consultará diretamente o banco de dados através do servidor. Dessa forma, quando as solicitações do cliente atingirem alta simultaneidade, o desempenho do servidor diminuirá gradativamente. Principalmente por causa da velocidade de leitura e gravação do disco, pois o número de leituras e gravações no disco é muito frequente, isso afetará o desempenho do servidor.
  • Quando o cliente deseja obter dados, ele solicita ao servidor, o servidor primeiro obtém os dados através do Redis, o que pode reduzir bastante o número de leituras e gravações no banco de dados, melhorando muito o desempenho do servidor. Principalmente por causa da velocidade de leitura e gravação do disco, pois o número de leituras e gravações no disco é bastante reduzido, o que afetará o desempenho do servidor.

3.1.2 Casos de uso básicos

ilustrar:

imagem-20230621200400553

  • Ideias de implementação:

    O cliente envia uma solicitação e primeiro obtém os dados do Redis. Se acertar, ele retornará. Se não acertar, ele consultará o banco de dados. Se o banco de dados existir, ele será gravado no Redis e os dados serão retornados.Se não existir, um erro será retornado diretamente.

  • Adicione métodos ShopServiceImplna classequeryById
/**
     * @param id 商铺的ID
     * @return 商铺
     */
@Override
public Result queryById(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    
        // 3.存在,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 4.不存在,根据Id查询数据库
    Shop shop = getById(id);
    if (ObjectUtil.isNull(shop)) {
    
    
        // 5.不存在,返回错误
        return Result.fail("店铺不存在!");
    }
    // 6.存在,写入Redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
    // 7.返回结果
    return Result.ok(shop);
}

3.2 Estratégia de atualização de cache

3.2.1 Visão geral

​ Para a estratégia de atualização de cache, ela é dividida nos três métodos a seguir de acordo com os requisitos de consistência.

imagem-20230622102806076

Descrição: Em cenas da vida

  • Requisitos de baixa consistência: Recomenda-se usar um mecanismo de despejo de memória. Por exemplo, cache de consulta de tipo de armazenamento
  • Requisitos de alta consistência: Recomenda-se usar um mecanismo de atualização ativo e eliminar o tempo limite como solução alternativa. Por exemplo, o cache de consulta de detalhes da loja
  • Política de atualização ativa

imagem-20230622104827187

ilustrar:

  • O padrão Cache Aside, por meio de codificação para atualizar o cache do banco de dados ao mesmo tempo, requer o controle do pensamento
  • Padrão de leitura/gravação, esse custo de desenvolvimento de código de serviço é muito alto
  • O Write Behind Caching Pattern armazena um grande número de operações em dados no cache e aguarda um determinado período de tempo antes de operar no banco de dados. Se o cache cair, os dados serão perdidos
  • Padrão de cache à parte

imagem-20230622105417568

ilustrar:

  • Qual é a diferença entre excluir o cache primeiro e excluí-lo depois?

imagem-20230622105617516

  • Exclua o cache primeiro e, em seguida, opere o banco de dados se o registro anormal for grande. Porque a velocidade de leitura e gravação do banco de dados do sistema operacional é mais rápida que a velocidade de leitura e gravação do cache

imagem-20230622105806349

  • Opere o banco de dados primeiro, depois exclua o cache e o registro anormal será pequeno. Porque a velocidade de leitura e gravação do cache é mais lenta do que a velocidade de leitura e gravação do banco de dados do sistema operacional

3.2.2 Implementar eliminação de tempo limite

ilustrar:

  • Ideias de implementação:

    Modifique a lógica de negócios na classe de implementação do produto. Ao consultar a loja com base no ID, se o cache falhar, consulte o banco de dados, grave os resultados do banco de dados no cache e defina o tempo limite.

  • Modifique o método ShopServiceImplna classequeryById
/**
     * @param id 商铺的ID
     * @return 商铺
     */
@Override
public Result queryById(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    
        // 3.存在,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 4.不存在,根据Id查询数据库
    Shop shop = getById(id);
    if (ObjectUtil.isNull(shop)) {
    
    
        // 5.不存在,返回错误
        return Result.fail("店铺不存在!");
    }
    // 6.存在,写入Redis
    // 为从Redis查询商品设置了商品的过期时间,实现缓存更新策略中的超时剔除的功能
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7.返回结果
    return Result.ok(shop);
}

3.2.3 Implementar atualizações ativas

ilustrar:

  • Ideias de implementação:

    Modifique a lógica de negócios na classe de implementação do produto. Ao modificar a loja de acordo com o ID, primeiro modifique o banco de dados e depois exclua o cache

  • Adicione métodos ShopServiceImplna classeupdate
@Override
@Transactional// 因此本项目为单体项目,因此添加事务注解,即可实现事务的同步
public Result update(Shop shop) {
    
    
    Long id = shop.getId();
    if (ObjectUtil.isNull(id)) {
    
    
        return Result.fail("商铺Id不能为空");
    }
    // 1.修改数据库
    updateById(shop);
    // 2.删除缓存
    String key = CACHE_SHOP_KEY + id;
    stringRedisTemplate.delete(key);
    return Result.ok();
}

3.3 Penetração de cache

3.3.1 Visão geral

A penetração do cache significa que os dados solicitados pelo cliente não existem no cache ou no banco de dados, portanto o cache nunca terá efeito. Essas solicitações atingirão o banco de dados, colocando uma enorme pressão sobre o banco de dados.

ilustrar:

Se usuários ilegais sempre quiserem enviar solicitações inúteis em segundo plano, isso causará um serviço anormal ou até mesmo falhará

Existem dois métodos comumente usados ​​para resolver a penetração do cache:

Cache de objetos vazios :

imagem-20230622114646999

ilustrar:

  • Vantagens: implementação simples e fácil manutenção
  • 缺点:额外的内存消耗、可能造成短期的不一致(若插入了真实的数据,但结果被Redis已经缓存,则会出现数据不一致)

布隆过滤

imagem-20230622114953415

说明:

  • 优点:内存占用较少,没有多余key
  • 缺点:实现复杂、存在误判可能(因为算出来的Hash值可能相同,就会误以为不存在的数据显示已存在)

补充:此外还有如下解决方式:

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

3.3.2实现缓存NULL

imagem-20230622142149276

说明:

  • 实现思路:

    ​ 若在Redis中进行命中的未null值,则直接返回错误信息、当将查询不存在的ID在Redis中进行null值的缓存,当缓存完毕后返回错误信息。这样下一次再次查询时就会减少查询数据库的次数

  • 修改ShopServiceImpl类的queryById方法
/**
     * @param id 商铺的ID
     * @return 商铺
     */
@Override
public Result queryById(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    // StrUtil.isNotBlank方法会忽略null、空、换行符
        // 3.存在,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 4.判断命中是否为Null值--此处用缓存Null值方案解决缓存穿透
    if (ObjectUtil.isNotNull(shopJson)) {
    
     // 不是null值,就为空
        return Result.fail("店铺不存在!");
    }

    // 5.未命中,根据Id查询数据库
    Shop shop = getById(id);
    // 5.1判断数据库中数据是否存在
    if (ObjectUtil.isNull(shop)) {
    
     
        // 5.2将未命中的数据进行空值写入Redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return Result.fail("店铺不存在!");
    }
    // 5.3命中,写入Redis--此处用超时剔除,解决缓存中的更新策略、用Key的TTL随机值,解决缓存雪崩
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 6.返回结果
    return Result.ok(shop);
}

3.5缓存雪崩

3.5.1概述

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

imagem-20230622144038605

说明:缓存雪崩常用的解决方案

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

3.5.2实现不同Key的TTL添加随机值方案

说明:

  • 实现思路:

    ​ 为Key的过期时间添加随机值

  • 修改ShopServiceImpl类的queryById方法
/**
     * @param id 商铺的ID
     * @return 商铺
     */
@Override
public Result queryById(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    
        // 3.存在,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 4.判断命中是否未Null值
    if (ObjectUtil.isNull(shopJson)) {
    
    
        return Result.fail("店铺不存在!");
    }
    // 5.不存在,根据Id查询数据库
    Shop shop = getById(id);
    if (ObjectUtil.isNull(shop)) {
    
    
        // 6.将空值写入Redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 不存在,返回错误
        return Result.fail("店铺不存在!");
    }
    // 7.存在,写入Redis-为商品增加随机的TTL值 
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL + RandomUtil.randomLong(10), TimeUnit.MINUTES);
    // 8.返回结果
    return Result.ok(shop);
}

说明:

​ 此种方式解决缓存雪崩问题相对简单,随机增加TTL值即可。更高级的解决方式请继续往下看

3.6缓存击穿(重点)

3.6.1概述

O problema de quebra de cache também é chamado de problema de chave de atalho. Isso significa que uma chave que é acessada de forma altamente simultânea e tem um negócio complicado de reconstrução de cache torna-se subitamente inválida. Inúmeras solicitações de acesso terão um enorme impacto no banco de dados em um instante.

imagem-20230622150941250

ilustrar:

  • No caso de alta simultaneidade, um grande número de threads perde dados neste momento. E como leva muito tempo para consultar o serviço de banco de dados, o tempo de espera para a consulta do serviço de banco de dados será muito longo.
  • Se houver um grande número de solicitações perdidas neste momento, muitos threads acessarão o banco de dados, causando um grande impacto no banco de dados.

Existem duas soluções comuns :

imagem-20230622151457542

ilustrar:

  • A solução para este problema é usar um bloqueio mutex . Ou seja, quando um thread trava ao acessar o banco de dados e depois libera o bloqueio após a conclusão do acesso, se outros threads quiserem acessar o banco de dados, eles terão que esperar até que o bloqueio seja liberado antes de acessar. Reduz a pressão no banco de dados
  • Resolvê-lo usando um bloqueio mutex afetará, na verdade, o desempenho do servidor. Como o serviço está sempre esperando, a obtenção de dados é lenta

imagem-20230622152013120

ilustrar:

  • A solução para este problema é usar expiração lógica . Ou seja, quando um thread acessa dados no Redis, um campo de expiração lógica é adicionado ao Redis. Se um thread descobrir que os dados expiraram logicamente, um novo thread será criado para obter o bloqueio para obter os dados. O thread antigo retorna os dados antigos

imagem-20230622152905992

ilustrar:

  • Bloqueios mutex e soluções de expiração lógica têm vantagens e desvantagens

3.6.2 Implementar esquema de bloqueio mutex

imagem-20230622154314649

ilustrar:

  • Ideias de implementação:

    O bloqueio que vem com sincronizado ou bloqueio não pode atender à lógica de negócios que implementamos.Quando o thread não consegue obter o bloqueio, ele aguardará um determinado período de tempo. Precisamos implementar a lógica dos bloqueios personalizados por meio de bloqueios mutex personalizados.

Etapa 1: adicionar bloqueio

  • Adicionando métodos ShopServiceImpl_tryLock
/**
     * @param key Redis中的键
     * @return 加锁是否成功
     */
private Boolean tryLock(String key) {
    
    
    // 设置锁的过期时间,防止死锁
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "l", LOCK_SHOP_TTL, TimeUnit.MINUTES);
    /*
        注意:此处通过setIfAbsent方法,返回的结果的类型为boolean类型而不是Boolean类型。
              Boolean是boolean的包装类,因此JDK17会进行拆箱。
              拆箱可能会出现空指针异常,因此这里借用Hutool工具进行判别
        * */
    return BooleanUtil.isTrue(result);
}

Passo 2: Solte o bloqueio

  • Adicionando métodos ShopServiceImpl_unLock
/**
     * @param key Redis中的键
     */
private void unLock(String key) {
    
    
    stringRedisTemplate.delete(key);
}

Etapa 3: adicionar mecanismo de bloqueio de verificação dupla

  • Método ShopServiceImplsob modificaçãoqueryById
@Override
public Result queryById(Long id) {
    
    
    // 互斥锁解决缓存击穿
    Shop shop = queryWithMutex(id);
    if (ObjectUtil.isNull(shop)) {
    
    
        return Result.fail("店铺不存在!");
    }
    // 7.返回结果
    return Result.ok(shop);
}


/**
     * 互斥锁解决缓存击穿
     *
     * @param id 店铺的Id信息
     * @return 店铺信息
     */
public Shop queryWithMutex(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    // StrUtil.isNotBlank方法会忽略null、空、换行符
        // 3.存在,则直接返回
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    // 判断命中是否为Null值--此处用缓存Null值方案解决缓存穿透
    if (ObjectUtil.isNotNull(shopJson)) {
    
     // 不是null值,就为空
        return null;
    }

    // 4.实现缓存重建
    // 4.1获取互斥锁
    String lockKey = "lock:shop" + id;
    Shop shop = null;
    try {
    
    
        Boolean isLock = tryLock(lockKey);
        // 4.2判断是否获取成功
        if (!isLock) {
    
    
            // 4.3失败,则休眠并重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }
        Thread.sleep(200);
        // 4.5成功,再次检测Redis缓存是否存在
        String shopJsons = stringRedisTemplate.opsForValue().get(key); //此处实现了双重检验锁
        if (StrUtil.isNotBlank(shopJsons)) {
    
    
            return JSONUtil.toBean(shopJsons, Shop.class);
        }
        // 4.6根据Id查询数据库
        shop = getById(id);
        if (ObjectUtil.isNull(shop)) {
    
    
            // 将空值写入Redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 5.不存在,返回错误
            return null;
        }
        // 6.存在,写入Redis--此处用超时剔除,解决缓存中的更新策略、用Key的TTL随机值,解决缓存雪崩
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL + RandomUtil.randomLong(10), TimeUnit.MINUTES);
    } catch (InterruptedException e) {
    
    
        throw new RuntimeException(e);
    } finally {
    
    
        // 7.释放互斥锁
        unLock(lockKey);
    }
    // 8.返回结果
    return shop;
}

Explicação: No Redis, por que a operação de verificação do cache pela segunda vez é realizada durante a verificação dupla do bloqueio?

  • Ao usar o bloqueio de verificação dupla (bloqueio de verificação dupla) para implementar o acesso ao cache Redis, a segunda verificação do cache é garantir que, após adquirir o bloqueio, outros threads não atualizaram o cache durante esse período.
  • O bloqueio de verificação dupla é uma tecnologia de controle de simultaneidade multithread comumente usada, que pode reduzir o número de usos de bloqueio e melhorar o desempenho, garantindo a segurança do thread. Ao usar bloqueios de verificação dupla, geralmente é feito primeiro um julgamento assíncrono. Se os dados necessários existirem no cache, o resultado será retornado diretamente para evitar a sobrecarga de aquisição do bloqueio. No entanto, devido à execução simultânea de vários threads, podem existir as seguintes situações:
    1. O thread A primeiro verifica o cache e descobre que ele está vazio, então adquire o bloqueio e começa a carregar dados no cache.
    2. Nesse momento, o thread B também realizou a primeira verificação e descobriu que o cache estava vazio, então também tentou adquirir o bloqueio.
    3. Antes que o thread B adquira o bloqueio, o thread A concluiu o carregamento dos dados e liberou o bloqueio.
    4. O thread B adquire o bloqueio, mas não sabe que o thread A carregou os dados no cache, então continua a carregar os dados.
  • Para evitar que o thread B carregue dados repetidamente, é necessária a operação de verificação do cache uma segunda vez. Ao verificar o cache pela segunda vez, o Thread B verifica o cache novamente. Se for descoberto que o cache não está vazio, significa que outros threads carregaram dados no cache durante o processo de aquisição do bloqueio. Neste momento, Thread B pode usar dados diretamente no cache, para evitar carregamento repetido de dados.

3.6.3 Implementação de esquema de expiração lógica

imagem-20230623070800550

ilustrar:

  • Ideias de implementação:
    • O ponto principal é que se o thread antigo descobrir que os dados expiraram, ele ainda retornará os dados antigos, mas, ao mesmo tempo, um novo thread será aberto para atualizar os dados.

Etapa 1: tempo de expiração lógico

  • Criar RedisDataaula
/**
 * 用于封装Shop类,在Shop类现有的成员属性上添加新的成员属性(expireTime)
 */
@Data
public class RedisData<T> {
    
    
    private LocalDateTime expireTime;
    private T data; // 此数据类型定义为泛型,便于封装其余需要实现逻辑过期的类
}

ilustrar:

  • Adicione tempo de expiração lógico à classe Shop

Etapa 2: salve os produtos quentes

  • Adicionar método ShopServiceImpl_shopSave2Redis
/**
     * 保存热点商品到Redis
     *
     * @param id            商铺Id
     * @param expireSeconds 过期时间
     */
public void shopSave2Redis(Long id, Long expireSeconds) {
    
    
    // 1.查询商品
    Shop shop = getById(id);
    // 2.封装逻辑过期时间
    RedisData<Shop> redisData = new RedisData<>();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // 3.添加缓存
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

ilustrar:

​ Adicione um método para salvar itens importantes e salvar itens no Redis

Reabastecimento:

  • Em qualquer classe de teste, adicione produtos importantes simulando o histórico de gerenciamento de produtos
@Autowired
ShopServiceImpl shopService;

/**
     * 通过模拟商品管理后台,添加热点商品
     */
@Test
public void testSave() {
     
     
    shopService.shopSave2Redis(1L, 10L);
}

Etapa 3: adicionar expiração lógica

1. Modifique o método ShopServiceImplna classequeryById

@Override
    public Result queryById(Long id) {
    
    
        // 缓存Null值解决缓存穿透
        // Shop shop = queryWithPassThrough(id);
        // 互斥锁解决缓存击穿
        // Shop shop = queryWithMutex(id);
        // 逻辑过期方式解决缓存击穿
        Shop shop = queryWithLogicExpire(id);
        if (ObjectUtil.isNull(shop)) {
    
    
            return Result.fail("店铺不存在!");
        }
        // 7.返回结果
        return Result.ok(shop);
    }

2. Crie um conjunto de threads

/**
     * 创建线程池
     */
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

ilustrar:

  • Crie um pool de threads para permitir que threads adicionais consultem e atualizem dados

3. Implemente o método de expiração lógica e ShopServiceImpladicione queryWithLogicExpiremétodos à classe

/**
     * 逻辑过期方式解决缓存击穿
     *
     * @param id 店铺的Id信息
     * @return 店铺信息
     */
public Shop queryWithLogicExpire(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
    // 2.判断是否存在
    if (StrUtil.isBlank(shopJson)) {
    
    
        // 3.未命中,直接返回空
        return null;
    }
    // 4.命中,需要先把Json序列化为对象
    RedisData<Shop> redisData = JSONUtil.toBean(shopJson, new TypeReference<RedisData<Shop>>() {
    
    
    }.getType(), false);
    Shop shop = redisData.getData();
    // 5.检查缓存过期时间
    if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
    
    
        // 5.1未过期,直接返回旧数据
        return shop;
    }
    // 5.2已过期,缓存重建
    // 6.缓存重建
    // 6.1获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Boolean isLock = tryLock(lockKey);
    if (isLock) {
    
    
        // 6.2获取互斥锁成功,再次检查缓存过期时间
        String result = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(result)) {
    
    
            RedisData<Shop> redisData2 = JSONUtil.toBean(result, new TypeReference<RedisData<Shop>>() {
    
    
            }.getType(), false);
            Shop shop2 = redisData2.getData();
            if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
    
    
                return shop2;
            }
        }
        // 6.3开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
    
    
            try {
    
    
                // 6.3.1重建锁
                this.shopSave2Redis(1L, 20L);// 这里便于测试设置时长为20秒。实际情况建议30分钟查一次
            } catch (Exception e) {
    
    
                throw new RuntimeException(e);
            } finally {
    
    
                // 6.3.2释放锁
                unLock(lockKey);
            }
        });
    }
    // 6.4获取互斥锁失败,直接返回过期数据
    return shop;
}

3.7 Pacote de ferramentas de cache

ilustrar:

​ Esta classe de ferramenta é escrita com a ajuda de ferramentas Hutool, genéricos, etc. Esta classe de ferramenta é ajustada, verificada e precisa

  • Exemplos de uso de ferramentas
// 缓存Null值解决缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class,this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期方式解决缓存击穿
Shop shop = cacheClient.queryWithLogicExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

ilustrar:

  • Para usar a classe de ferramenta, você precisa passar a chave armazenada no Redis e o tipo de valor de retorno necessário para consultar o banco de dados.
  • Criar classe CacheClient da ferramenta
@Slf4j
@Component
@AllArgsConstructor
public class CacheClient {
    
    
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    /**
     * 创建线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 加锁
     *
     * @param key Redis中的键
     * @return 加锁是否成功
     */
    private Boolean tryLock(String key) {
    
    
        // 设置锁的过期时间,防止死锁
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "l", LOCK_SHOP_TTL, TimeUnit.MINUTES);
        /*
        注意:此处通过setIfAbsent方法,返回的结果的类型为boolean类型而不是Boolean类型。
              Boolean是boolean的包装类,因此JDK17会进行拆箱。
              拆箱可能会出现空指针异常,因此这里借用Hutool工具进行判别
        * */
        return BooleanUtil.isTrue(result);
    }

    /**
     * 解锁
     *
     * @param key Redis中的键
     */
    private void unLock(String key) {
    
    
        stringRedisTemplate.delete(key);
    }

    /**
     * 设置存储在Redis中的键,并指定Redis中的过期时间
     *
     * @param key   键
     * @param value 值
     * @param time  时间
     * @param unit  时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
    
    
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 设置存储在Redis中的键,并指定对象的逻辑过期时间
     *
     * @param key   键
     * @param value 值
     * @param time  过期时间
     * @param unit  时间单位
     */
    public void setWithLogicExpire(String key, Object value, Long time, TimeUnit unit) {
    
    
        RedisData<Object> redisData = new RedisData<>();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); // 此处设置RedisData对象的值
    }

    /**
     * 缓存Null值解决缓存穿透
     *
     * @param keyPrefix  键的前缀
     * @param id         存储键的前缀以及查询数据库的ID
     * @param type       存储数据的类型
     * @param dbFallback 获取数据库数据的逻辑
     * @param time       过期时间
     * @param unit       时间单位
     * @param <R>        返回值类型
     * @param <ID>       id类型
     * @return 存储数据类的对象
     */
    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 strJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
        // 2.判断数据是否存在
        if (StrUtil.isNotBlank(strJson)) {
    
    // StrUtil.isNotBlank方法会忽略null、空、换行符
            // 3.存在,则直接返回
            return JSONUtil.toBean(strJson, type);
        }
        // 4.不存在,则进一步判断
        // 4.1判断命中是否为Null值--此处实现了缓存Null值方案(不是null值,就为空),缓解了缓存穿透问题的影响
        if (ObjectUtil.isNotNull(strJson)) {
    
    
            return null;
        }
        // 4.2查询数据库,获得返回值数据
        R r = dbFallback.apply(id);
        // 5.判断数据是否存在
        if (ObjectUtil.isNull(r)) {
    
    
            // 5.1不存在
            // 将空值写入Redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回空
            return null;
        }
        // 5.2存在
        // 写入Redis--此处实现了超时剔除功能,缓解了缓存中的更新策略的影响、实现了Key的TTL随机值,缓解了缓存雪崩问题的影响
        this.set(key, JSONUtil.toJsonStr(r), time + RandomUtil.randomLong(5), unit);
        // 6.返回结果
        return r;
    }

    /**
     * 逻辑过期方式解决缓存击穿
     *
     * @param keyPrefix  键的前缀
     * @param id         存储键的前缀以及查询数据库的ID
     * @param type       存储数据的类型
     * @param dbFallback 获取数据库数据的逻辑
     * @param time       过期时间
     * @param unit       时间单位
     * @param <R>        返回值类型
     * @param <ID>       id类型
     * @return 存储数据类的对象
     */

    public <R, ID> R queryWithLogicExpire(String keyPrefix, ID id, Class<R> type,
                                          Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    
    
        // 1.从Redis查询商铺缓存
        String key = keyPrefix + id;
        String jsonStr = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
        // 2.判断数据是否命中
        if (StrUtil.isBlank(jsonStr)) {
    
    
            // 2.1未命中,直接返回空
            return null;
        }
        // 2.2.命中,需要先把Json序列化为对象
        RedisData<R> redisData = JSONUtil.toBean(jsonStr, new TypeReference<RedisData<R>>() {
    
    
        }.getType(), false);

        R r = redisData.getData(); // 注意,此时因实用Hutool工具进行类型转换,返回的R类型为JSONObject
        R bean = JSONUtil.toBean((JSONObject) r, type);
        // 3.检查缓存过期时间
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
    
    
            // 3.1未过期,直接返回旧数据
            return bean;
        }
        // 3.2已过期,缓存重建
        // 4.缓存重建
        // 4.1获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        Boolean isLock = tryLock(lockKey);
        if (isLock) {
    
    
            // 4.2获取互斥锁成功,再次检查缓存过期时间
            String jsonStr2 = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(jsonStr2)) {
    
    
                RedisData<R> redisData2 = JSONUtil.toBean(jsonStr2, new TypeReference<RedisData<R>>() {
    
    
                }.getType(), false);
                R r2 = redisData2.getData();
                if (redisData2.getExpireTime().isAfter(LocalDateTime.now())) {
    
    
                    return JSONUtil.toBean((JSONObject) r2, type);
                }
            }
            // 6.3开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
    
    
                try {
    
    
                    // 6.3.1重建锁
                    // 查询商品
                    R r3 = dbFallback.apply(id); // 此处可用debug进行调试 log.debug("我成功执行");
                    // 添加缓存
                    this.setWithLogicExpire(key, r3, time, unit);

                } catch (Exception e) {
    
    
                    throw new RuntimeException(e);
                } finally {
    
    
                    // 6.3.2释放锁
                    unLock(lockKey);
                }
            });
        }
        // 6.4获取互斥锁失败,直接返回过期数据
        return bean;
    }
}

4. Venda relâmpago de cupom

Resumo das notas:

  1. Comando Redis:

    • Na função de realizar o ID único global, são utilizados Stringos comandos do Redis, incremente as estatísticas do número de IDs gerados são realizadas por meio de métodos.
    • Na implementação da função de bloqueio distribuído, são utilizados Stringos comandos do Redis, setIfAbsente a aquisição do bloqueio distribuído é realizada por meio de métodos.
    • StringNa implementação da função de venda flash de otimização do Redis, os comandos do Redis são usados set​​para adicionar produtos de grande venda por meio de métodos.
  2. Dificuldades na implementação da função:

    • Ao implementar a função de estratégia de atualização de cache, use a eliminação de tempo limite para atingir requisitos de consistência mais baixos. Use a atualização ativa para executar o processo de primeiro excluir o banco de dados e depois excluir o cache para atingir requisitos de consistência mais elevados.
    • Ao resolver o problema de estoque sobrevendido, usamos eqo método da ferramenta MyBatis-Plus para implementar de forma inteligente o método CAS para resolver o problema.
    • Ao implementar a função de uma pessoa e uma pessoa, IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();a API é usada de forma inteligente para obter o objeto proxy da estrutura Spring. E synchronizeduso inteligente de fechaduras. O método de uso toStringde métodos intern()garante a exclusividade do objeto bloqueado
    • Ao implementar a função de bloqueio distribuído, Thread.currentThread().getId()a API é habilmente usada para obter o ID do thread atual como a chave para obter o bloqueio. Modifique a lógica de liberação do bloqueio e determine de forma inteligente se o bloqueio liberado é o seu próprio antes de liberar o bloqueio para evitar a exclusão acidental do bloqueio.
    • Para implementar a função de venda flash de otimização do Redis, um script foi escrito Luapara garantir que a operação seja atômica e completar a função de pedido de cupom. Use stringRedisTemplate.executeo método para executar o script Lua. Além da utilização de filas de bloqueio, existe a função de processamento assíncrono de pedidos. Consulte esta seção para obter detalhes
    • Ao usar Redssionferramentas, tenha uma compreensão teórica e aplicação do princípio de bloqueio reentrante, princípio de repetibilidade, princípio de renovação de tempo limite e problemas de consistência mestre-escravo.
    • Na ferramenta MyBatis-Plus, update()métodos são usados ​​e métodos são combinados .setSqlpara realizar execução suplementar de instruções SQL.

4.1 Visão Geral

​ Cupom Flash Sale é uma forma de atividade promocional que atrai usuários para participar de compras urgentes, fornecendo cupons em quantidades limitadas dentro de um tempo limitado, atingindo assim o objetivo de marketing e vendas. A seguir está uma maneira simples de implementar vendas instantâneas de cupons:

  1. Prepare cupons: Crie um lote de cupons e defina sua quantidade e data de validade.
  2. Exibir informações do evento: exiba informações do evento de venda relâmpago na página inicial, incluindo desconto de cupom, preço original, preço de venda relâmpago, horário de início e término do evento, etc.
  3. Participação do usuário na venda relâmpago: os usuários entram na página da venda relâmpago antes do horário de início do evento e aguardam o início da venda relâmpago. Após o início da venda relâmpago, os usuários podem clicar no botão de venda relâmpago para fechar uma compra.
  4. Verifique o estoque: quando o usuário clica no botão de venda relâmpago, primeiro verifique se o estoque do cupom é suficiente. Se o estoque for insuficiente, isso indicará que a venda relâmpago terminou ou está esgotada.
  5. Processamento de pedidos: Se o estoque for suficiente, um pedido é gerado e o estoque de cupons é deduzido. As transações do banco de dados podem ser usadas para garantir consistência na colocação de pedidos e deduções de estoque.
  6. Pagamento do pedido: O usuário paga o valor do pedido e conclui o processo de transação.
  7. Concluindo a venda relâmpago: Após o término da atividade de venda relâmpago, os dados da atividade serão coletados, como número de participantes, número de pedidos bem-sucedidos, utilização de cupons, etc.

​ No desenvolvimento real, também é necessário considerar o acesso simultâneo e a otimização do desempenho em cenários de alta simultaneidade, como limitar a frequência de vendas flash dos usuários e usar meios técnicos como cache distribuído e filas de mensagens para melhorar as capacidades de processamento simultâneo do sistema.

​ Deve-se observar que a venda relâmpago de cupom é uma atividade promocional especial que requer consideração abrangente das necessidades de negócios, design do sistema, ajuste de desempenho e outros fatores para garantir a estabilidade do sistema e a experiência do usuário.

4.2 ID globalmente exclusivo

4.2.1 Visão geral

Cada loja pode emitir cupons:

imagem-20230623165251479

ilustrar:

Quando um usuário corre para comprar, um pedido será gerado e salvo na tabela tb_voucher_order, porém, se a tabela de pedidos usar o banco de dados para aumentar automaticamente o ID, haverá alguns problemas. Por exemplo: 1. A regularidade do ID é muito óbvia, 2. Limitada pela quantidade de dados em uma única tabela

O gerador de ID global é uma ferramenta usada para gerar um ID globalmente exclusivo em um sistema distribuído e geralmente atende às seguintes características

imagem-20230623165350978

ilustrar:

  • Singularidade, singularidade global, sem diferenças
  • Alto desempenho, pode gerar o ID necessário em muito pouco tempo
  • Alta disponibilidade, pode implementar facilmente operações avançadas, como replicação mestre-escravo
  • Cada vez mais, pode obedecer a certas regras. Segurança, os usuários não adivinharão facilmente

4.2.2 Casos de uso básicos

imagem-20230623165721873

Descrição: Componentes do ID

  • Bit de sinal: 1 bit, sempre 0
  • Timestamp: 31 bits, em segundos, podem ser usados ​​por 69 anos (o valor máximo que pode ser representado por um timestamp representado por 31 bits é 2^31 - 1 é aproximadamente igual a 69 anos)
  • Número de série: 32 bits, contador em segundos, suportando 2 ^ 32 IDs diferentes por segundo

Perceber:

​ A geração de um ID globalmente exclusivo requer o cumprimento de cinco características para garantir o desempenho do sistema.

Etapa 1: ferramenta de geração de ID personalizada

  • Criar RedisIdWorkerclasse de ferramenta
@Component
public class RedisIdWorker {
    
    
    @Resource
    StringRedisTemplate stringRedisTemplate;

    private final static long BEGIN_TIMESTAMP = 1076630400L; //秒级时间戳有10位,指定日期

    public long nextId(String keyPrefix) {
    
    
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowTimeStamp = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowTimeStamp - BEGIN_TIMESTAMP;
        // 2.生成序列号
        // 2.1获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yy:MM:dd"));
        // 2.2自增长
        long count = stringRedisTemplate.opsForValue().increment("inc:" + keyPrefix + date); // 若所操作的键不存在,使用Increment可以自动的创建该键
        // 3.拼接并返回
        return timeStamp << 32 | count;
    }
}

Reabastecimento:

No Redis, o tipo de dados usado para a operação de incremento automático é um número inteiro assinado de 64 bits, que é do tipo int64. Portanto, o limite superior da operação de incremento automático é 9223372036854775807 (2^63 - 1). Portanto, é recomendado usar chaves diferentes para chaves diferentes.

Reabastecimento:

  • Java converterá automaticamente binário em decimal quando <<o operador ou for usado , por exemplo|1076630400L
01000000 00101100 00010011 10000000
  • Quando um número binário é deslocado 32 bits para a esquerda, 0s são adicionados à direita
01000000 00101100 00010011 10000000 00000000 00000000 00000000 00000000

Etapa 2: teste

  • nova Testaula
private static final ExecutorService es = Executors.newFixedThreadPool(500);

@Test
public void testSize() throws InterruptedException {
    
    
    CountDownLatch latch = new CountDownLatch(300); // 创建一个计数器,初始值为300

    Runnable task = () -> {
    
     // 定义一个任务,生成唯一ID并打印
        for (int i = 0; i < 100; i++) {
    
    
            long code = redisIdWorker.nextId("code"); // 生成唯一ID
            System.out.println(code); // 打印ID
        }
        latch.countDown(); // 任务执行完毕,计数器减一
    };

    long begin = System.currentTimeMillis(); // 记录开始时间
    for (int i = 0; i < 300; i++) {
    
    
        es.submit(task); // 提交任务到线程池执行
    }
    latch.await(); // 等待计数器归零,即等待所有任务执行完毕
    long end = System.currentTimeMillis(); // 记录结束时间
    System.out.println(end - begin); // 打印任务执行时间
}

4.3 Ordem de eliminação instantânea de cupom

4.3.1 Visão geral

imagem-20230624123849468

ilustrar:

  • Este back-end é Knife4j. Verifique o log para ver o processo de implementação detalhado.
  • Você precisa primeiro adicionar cupons de venda por tempo limitado em segundo plano e, em seguida, implementar a operação depois que os cupons forem adicionados com sucesso.

4.3.2 Casos de uso básicos

imagem-20230624150127895

ilustrar:

​ Ideias de implementação: determinar anomalias de tempo, determinar estoque, operar estoque, criar pedidos, devolver IDs de pedidos e implementar funções básicas de pedido de cupons

Etapa 1: implementar a lógica de pedido de venda relâmpago de cupom

  • Modifique o método VoucherOrderServiceImplna classeseckillVoucher
@Resource
private ISeckillVoucherService seckillVoucherService;

@Autowired
RedisIdWorker redisIdWorker;

@Override
@Transactional // 设置到多张表的操作,需要添加事务
/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 订单信息
     */
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 success = seckillVoucherService.update()
        .setSql("stock = stock -1").eq("voucher_id", voucherId).update();
    if (!success) {
    
    
        // 扣减失败
        return Result.fail("库存不足");
    }
    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1设置订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2设置用户ID
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 6.4保存订单
    save(voucherOrder);
    // 7.返回订单id
    return Result.ok(orderId);
}

4.3.3 Resumo

imagem-20230624161411042

ilustrar:

Neste ponto, verifique o estoque do pedido. Verifica-se que o estoque ficou negativo, indicando que o estoque está sobrevendido . Consulte a próxima seção para resolver esse problema.

4.4 Problema de sobrevenda de estoque

4.4.1 Visão geral

imagem-20230624150838257

ilustrar:

​ Em circunstâncias normais, quando o thread 1 descobre que o estoque é maior que zero, o estoque é deduzido. Imediatamente depois, o Thread 2 julgou o inventário novamente e descobriu que o inventário ainda era maior que zero, então relatou um erro normalmente.

imagem-20230624153347904

ilustrar:

Em circunstâncias anormais, quando o thread 1 consulta se o estoque está normal, outros threads consultam e descobrem que o estoque está normal e, por sua vez, realizam deduções de estoque, e o problema de sobrevenda ocorrerá.

  • Para resolver o problema de sobrevenda, você Pode bloquear.O bloqueio é dividido EM bloqueio pessimista e bloqueio otimista.

imagem-20230624153728046

ilustrar:

  • Como o método de modificação do bloqueio otimista é permitir que os threads sejam executados em série, ele já foi demonstrado ao consultar o cache em busca de commodities, portanto, não será demonstrado aqui
  • O método de modificação do bloqueio otimista verificará se alguém está verificando os dados antes que o programa os execute para ver se alguém os atualizou.
  • O bloqueio otimista é executado de duas maneiras:

imagem-20230624154652753

ilustrar:

​ Antes de cada modificação da instrução SQL, a versão da consulta é consistente com o número da versão consultada e, se for consistente, falhará

imagem-20230624154755365

ilustrar:

Como consultar o número da versão pode descobrir se os dados foram modificados, consultar o inventário também pode descobrir se os dados foram modificados. Desta forma, a consulta de inventário

Suplemento: Problema de sobrevenda resolvido

  • Bloqueio pessimista: adicione bloqueio de sincronização para permitir que threads sejam executados em série
    • Vantagens: simples e grosseiro
    • Desvantagens: desempenho geral
  • Bloqueio otimista: sem bloqueio e julgamento se outros threads estão modificando durante a atualização
    • Vantagens: bom desempenho
    • Desvantagens: Existe um problema de baixa taxa de sucesso

4.4.2 Implementar método CAS (Compare And Switch)

ilustrar:

  • Ideias de implementação:

    ​ Adicionando bloqueios para obter um bloqueio otimista, de modo que, ao atualizar os dados, verifique se ainda há estoque ao mesmo tempo e, se houver, a segunda eliminação foi bem-sucedida

Etapa 1: adicionar verificação de consulta CAS

  • método modificado VoucherOrderServiceImpl_seckillVoucher
@Override
@Transactional // 设置到多张表的操作,需要添加事务
/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 控制层对此业务的处理结果
     */
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.扣减库存-使用CAS方式解决超卖问题
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
        .update();
    if (!success) {
    
    
        // 扣减失败
        return Result.fail("库存不足");
    }
    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1设置订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2设置用户ID
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 6.4保存订单
    save(voucherOrder);
    // 7.返回订单id
    return Result.ok(orderId);
}

ilustrar:

  • Ao deduzir o estoque, considera-se que o estoque é maior que 0 em vez de comparar com a quantidade na consulta do estoque.
  • Através do método de comparação ponto a ponto, o sistema sentirá que há um problema de segurança de estoque, o que fará com que a compra de estoque seja interrompida

4.4.3 Resumo

​ 此时,会出现一个人购买多单的情况。若想要实现一人一单功能,请查看下节

4.5一人一单功能(难点)

4.5.1概述

​ 同一用户,对同一个优惠券只能添加一单

4.5.2 基本用例

imagem-20230624170153264

说明:

  • 实现思路:

    ​ 当库存充足时,根据用户ID和优惠券的ID判断订单是否存在,防止出现一人多单的情况

步骤一:添加依赖

  • 修改pom.xml文件
<!--aspectjweaver-使用API获取动态代理对象时会用到此依赖的底层源码-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

步骤二:实现一人一单功能

  • 修改VoucherOrderServiceImpl类的seckillVoucher方法并添加createVoucherOrder方法
@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("库存不足");
    }
    /*
        补充:在此处加锁,而不是在createVoucherOrder方法内加锁。此时,代码的逻辑变为事务执行完成之后再进行锁的释放,保证事务的成功提交。若在方法内加锁,代码的逻辑变为锁已经释放了,但是事务还没有执行完成,依旧会造成线程安全问题。
         * */
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
    
     
        /*
    1.使用用户的ID作为锁对象是为了缩小加锁的范围,只为访问此方法的用户加锁。实现了不同用户加不同的锁,保证不同用户之间的并发性能。
   2.toString()方法,会在底层new一个新的对象进行加锁,因此添加intern方法,使得率先寻找常量池中的字符串地址值,确保了加锁对象唯一。这样不同的对象就会加不同的锁 */
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // 通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
        return proxy.createVoucherOrder(voucherId); // 用代理对象来调用此createVoucherOrder函数,因为此代理对象由Spring进行创建,因此该函数可被Spring进行管理。而不是用原生对象来调用此createVoucherOrder函数,例如this.createVoucherOrder。用原生对象来调用此函数,不能够触发@Transactional注解的功能
    }
}
/*
@Transactional此注解的生效,是因为Spring对当前VoucherOrderServiceImpl类做了动态代理,从而拿到了VoucherOrderServiceImpl类的代理对象createVoucherOrder方法。因此用代理对象来做的动态代理,所以才能够实现事务管理的功能
*/
@Transactional
public Result createVoucherOrder(Long voucherId) {
    
    
    // 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
    // 5.1获取用户ID
    Long userId = UserHolder.getUser().getId();

    // 5.2判断此订单是否存在
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0) {
    
    
        // 该用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }
    // 6.扣减库存-使用CAS方式解决超卖问题
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
        .update();
    if (!success) {
    
    
        // 扣减失败
        return Result.fail("库存不足");
    }
    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1设置订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 7.2设置用户ID
    voucherOrder.setUserId(userId);
    // 7.3设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 7.4保存订单
    save(voucherOrder);
    // 8.返回订单id
    return Result.ok(orderId);
}

4.5.3总结

imagem-20230624204559297

说明:

  • 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了

  • 通过Nginx制作了负载均衡后,在不同的JVM中,使用的不同的锁监视器,因此单纯的通过悲观锁来对同一用户加锁会出现并发安全的问题。

  • 若需要解决不同的锁监视器,请查看下一小节

4.6分布式锁

4.6.1概述

imagem-20230625113036348

说明:

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

·分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

imagem-20230625113640601

说明:

  • MySQL数据库都支持事务机制,当写入数据时,会自动的上锁,实现了互斥。MySQL支持主从模式。性能比Redis稍微差一点
  • Redis使用Setnx实现互斥,唯一不足的方式就是安全性不够高超时时,容易出现死锁
  • Zookeeper运用了内部节点的机制,利用唯一性和有序性来实现。性能有很强的一致性,因此主从机制会让性能变得更差一些

基于Redis的分布式锁

imagem-20230625112829451

说明:

  • 实现分布式锁的时候,只需要将锁监视器让每个JVM能够进行看到即可

  • 实现分布式锁时需要实现的两个基本方法,获取锁以及释放锁

  • 获取锁分为互斥的方式,确保只能有一个线程获取锁。以及非阻塞的方式,尝试一次,成功返回true,失败返回false

  • # 添加锁,NX是互斥、EX是设置超时时间
    SET lock thread1 NX EX 10 # 同时设置超时与判断,确保操作的原子性
    
  • A liberação do bloqueio é dividida em liberação manual e liberação por tempo limite.Um período de tempo limite é adicionado ao adquirir o bloqueio.

    # 释放锁,删除即可
    DEL key
    

4.6.2 Implementar a versão preliminar do bloqueio distribuído Redis

imagem-20230625124912779

ilustrar:

  • Ideias de implementação:

    ​ Primeiro estabeleça um bloqueio distribuído. Antes de o negócio começar, tente adquirir o bloqueio do Redis, execute o negócio se a aquisição for bem-sucedida, caso contrário, fracasse

Etapa 1: crie um bloqueio

  • Criar ILockinterface de bloqueio
/**
 * 锁的操作方式,获取锁,释放锁
 */
public interface ILock {
    
    
    /**
     * 获取锁
     *
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功;false代表获取锁失败
     */
    boolean tryLock(Long timeoutSec);

    /**
     * 释放锁
     */
    void unLock();
}

ilustrar:

Defina a interface do bloqueio, implemente a especificação básica da operação, adquira o bloqueio e libere o bloqueio

Etapa 2: classe de implementação de bloqueio distribuído

  • Crie SimpleRedisLockuma classe e implemente a ILockinterface de bloqueio
public class SimpleRedisLock implements ILock {
    
    

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    private static final String KEY_PREFIX = "lock:";

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


    @Override
    public boolean tryLock(Long timeoutSec) {
    
    
        long threadID = Thread.currentThread().getId(); //获取线程的ID作为锁值
        Boolean success = stringRedisTemplate.opsForValue() // 设置锁的超时时间,防止锁的释放
                .setIfAbsent(KEY_PREFIX + name, String.valueOf(threadID), timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); //因为返回结果为包装类型,拆箱涉及到可能空指针的影响,这里判断一下
    }

    @Override
    public void unLock() {
    
    
        stringRedisTemplate.delete(KEY_PREFIX + name);

    }
}

ilustrar:

De acordo com a interface que define o bloqueio, implemente operações básicas, adquira bloqueios e libere bloqueios

Etapa 3: modificar a lógica de negócios

  • Modifique métodos e métodos VoucherOrderServiceImplem classesseckillVouchercreateVoucherOrder
@Resource
private ISeckillVoucherService seckillVoucherService;

@Resource
StringRedisTemplate stringRedisTemplate;

@Autowired
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("库存不足");
    }
    Long userId = UserHolder.getUser().getId();
    // 创建锁对象
    SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate); //拼接用户的ID,为每个用户添加自己的锁
    // 获取锁
    boolean success = simpleRedisLock.tryLock(10L);
    if (BooleanUtil.isFalse(success)) {
    
    
        // 获取锁失败,返回错误或重试
        return Result.fail("不允许重复下单");
    }

    try {
    
    
        // 获取代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); //通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
        return proxy.createVoucherOrder(voucherId); //用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
    } finally {
    
    
        // 释放锁
        simpleRedisLock.unLock();
    }
}

@Transactional
public Result createVoucherOrder(Long voucherId) {
    
    
    // 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
    // 5.1获取用户ID
    Long userId = UserHolder.getUser().getId();

    // 5.2判断此订单是否存在
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0) {
    
    
        // 该用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }
    // 6.扣减库存-使用CAS方式解决超卖问题
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
        .update();
    if (!success) {
    
    
        // 扣减失败
        return Result.fail("库存不足");
    }
    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1设置订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 7.2设置用户ID
    voucherOrder.setUserId(userId);
    // 7.3设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 7.4保存订单
    save(voucherOrder);
    // 8.返回订单id
    return Result.ok(orderId);
}

ilustrar:

  • Modifique a lógica de negócios da classe VoucherOrderServiceImpl para implementar o bloqueio manual e a liberação manual de bloqueios
  • É importante notar que ao criar um objeto de bloqueio, você precisa especificar o valor de Key como o identificador exclusivo do usuário e criar bloqueios para diferentes usuários, para que uma pessoa, uma pessoa e uma única função possam ser realizadas. Se o identificador exclusivo do usuário não for adicionado, o identificador bloqueará todos os usuários.

Reabastecimento:

imagem-20230625155426245

  • Dependendo da implementação da versão preliminar dos bloqueios distribuídos, os threads podem excluir acidentalmente os bloqueios de outras pessoas. Como o bloqueio de negócios do thread 1 fez com que o bloqueio fosse liberado com o passar do tempo, o thread 2 descobriu que havia adquirido o bloqueio por coincidência. Nesse momento, após a conclusão do negócio do thread 1, o bloqueio foi liberado manualmente, o que resultou na exclusão acidental do bloqueio do thread 2, e ocorreu o problema de exclusão acidental de threads simultâneos.

imagem-20230625155548142

  • Neste momento, se você quiser alterar e resolver este problema, você pode julgar se o bloqueio é seu antes de liberá-lo. Se for, libere-o; se não, não o libere.

4.6.3 Implementar uma versão melhorada do bloqueio distribuído Redis

imagem-20230625160146377

ilustrar:

  • Ideias de implementação:

    1. Armazene o ID do thread ao adquirir o bloqueio (pode ser representado por UUID)

    2. Ao liberar o bloqueio, primeiro obtenha o identificador do thread no bloqueio e determine se ele é consistente com o identificador do thread atual. Se consistente, libere o bloqueio; se inconsistente, não libere o bloqueio.

Reabastecimento:

Por que o UUID é usado como identificador do thread armazenado? Cada vez que um thread é criado dentro da JVM, o ID será incrementado. Em diferentes JVMs, podem ocorrer conflitos se o ID for usado diretamente como identificador do encadeamento. Neste momento, use UUID para distinguir diferentes JVMs

Etapa 1: use setnx para criar bloqueios distribuídos

  • modificar SimpleRedisLockclasse tryLocke unLockmétodo
public class SimpleRedisLock implements ILock {
    
    

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; // 标识忽略UUID默认自带的下划线

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


    @Override
    public boolean tryLock(Long timeoutSec) {
    
    
        String threadID = ID_PREFIX + Thread.currentThread().getId(); //获取线程的ID作为锁值
        Boolean success = stringRedisTemplate.opsForValue() // 设置锁的超时时间,防止锁的释放
                .setIfAbsent(KEY_PREFIX + name, threadID, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); //因为返回结果为包装类型,拆箱涉及到可能空指针的影响,这里判断一下
    }

    @Override
    public void unLock() {
    
    
        // 获取线程的标识
        String threadID = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断锁标识和线程标识是否一致
        if (ObjectUtil.equal(threadID, id)) {
    
    
            // 相等,则删除
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

ilustrar:

Faça um julgamento antes de excluir o bloqueio todas as vezes. Determine se o ID do thread e o ID do bloqueio são consistentes. Se forem iguais, exclua-os. Se não forem iguais, não são meus e não serão excluídos.

Reabastecimento:

imagem-20230625164907229

  • Dependendo da implementação da versão melhorada do bloqueio distribuído, ainda pode fazer com que os threads excluam acidentalmente os bloqueios de outras pessoas. Porque depois que o negócio do thread 1 é concluído e o sucesso de seu próprio thread é julgado, quando o mecanismo de liberação de bloqueio está prestes a ser executado, o bloqueio do thread faz com que o bloqueio seja liberado com o tempo (o mecanismo de coleta de lixo da JVM pode causar bloqueio de thread). Neste momento, o thread 2 descobriu que havia adquirido o bloqueio por coincidência. Nesse momento, após a conclusão do negócio do thread 1, o bloqueio foi liberado manualmente, o que resultou na exclusão acidental do bloqueio do thread 2, e ocorreu o problema de exclusão acidental de threads simultâneos.

  • Se pudermos concluir o julgamento do thread e liberar o bloqueio após a conclusão do negócio como uma transação (usando script Lua), garantir sua atomicidade pode completar a implementação do bloqueio distribuído Redis.

  • Neste momento, como o Redis não suporta mais a operação de scripts Lua na versão 7.0.x, não concluiremos mais a produção de scripts Lua.

  • Além do mais, quando Setnx é usado para implementar bloqueios distribuídos Redis, os seguintes problemas também ocorrerão

    imagem-20230625172721617

  • A seguir, observe o método Redisson para otimizar bloqueios distribuídos usando o método Redission.

4.6.4 Ferramentas Redisson

4.6.4.1 Visão geral

Redisson é uma grade de dados na memória Java (In-Memory Data Grid) implementada com base no Redis. Ele não apenas fornece uma série de objetos Java comuns distribuídos, mas também fornece muitos serviços distribuídos, incluindo a implementação de vários bloqueios distribuídos.

imagem-20230625174331908

Endereço do site oficial: https://redisson.org , endereço GitHub: https://github.com/redisson/redisson

4.6.4.2 Implementar a versão avançada do bloqueio distribuído Redis

Etapa 1: introduzir dependências

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

Reabastecimento:

​ Recomenda-se que ao estabelecer bloqueios distribuídos, não seja recomendado integrar o SpringBoot através do starter, pois substituirá a implementação da API Redis fornecida pelo site oficial do Spring.

Etapa 2: adicionar Redissonclasse de configuração

@Configuration
public class RedisConfig {
    
    
    @Bean
    public RedissonClient getRedisClient() {
    
    
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://10.13.164.55:6379").setPassword("qweasdzxc");
        // 创建客户端
        return Redisson.create(config);
    }
}

ilustrar:

​ Configurando o cliente Redisson usando o método de nó único do Redis

Etapa 3: Redissonbloqueio distribuído usado

  • Modifique o método VoucherOrderServiceImplda classe de implementaçãoseckillVoucher
/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 控制层对此业务的处理结果
     */
@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("库存不足");
    }
    Long userId = UserHolder.getUser().getId();
    // 创建锁对象
    RLock lock = redissonClient.getLock("lock:order:" + userId);
    // 获取锁(参数含义分别是,获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位)
    boolean isLock = lock.tryLock(); // 默认不重试,锁超过30秒自动释放
    if (BooleanUtil.isFalse(isLock)) {
    
    
        // 获取锁失败,返回错误或重试
        return Result.fail("不允许重复下单");
    }

    try {
    
    
        // 获取代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); //通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
        return proxy.createVoucherOrder(voucherId); //用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
    } finally {
    
    
        // 释放锁(其Redisson内部会自动进行锁释放标识的比对)
        lock.unlock();
    }
}

ilustrar:

​ Você pode ver que o método usado por Redisson é semelhante ao de nossa classe de bloqueio autoconstruída SimpleRedisLock.

4.6.4.3 Princípio do bloqueio reentrante

imagem-20230625200845778

ilustrar:

Um bloqueio reentrante não apenas registra o bloqueio adquirido no bloqueio, mas também registra o número de vezes que o bloqueio foi adquirido. O núcleo subjacente usa o tipo Hash no Redis para registrar IDs de thread e registrar o número de reentradas.

imagem-20230625201031654

ilustrar:

​ Este é o princípio dos bloqueios reentrantes em Redisson. O fluxograma pode ser dividido em duas partes: aquisição do bloqueio e liberação do bloqueio.

  • Obter bloqueio

imagem-20230625201627571

ilustrar:

  • Ao adquirir um bloqueio, determine se o bloqueio existe
  • Se o bloqueio não existir, adicione o identificador do thread após adquirir o bloqueio e defina o período de validade do bloqueio.
  • Se o bloqueio existir, determine se ele pertence a você com base no ID do thread. Nesse caso, adicione 1 à contagem e defina o período de validade do bloqueio. Se não for seu, pode ser porque outros threads estão usando o bloqueio. lock, então a aquisição falha.
  • liberar bloqueio

imagem-20230625202449241

ilustrar:

  • Depois que o negócio for executado, será julgado novamente se o bloqueio pertence a você.
  • Se não for o seu próprio bloqueio, o bloqueio não será mais liberado para evitar a exclusão acidental de bloqueios usados ​​por outros threads.
  • Se for seu próprio bloqueio, a habilidade é reduzida em 1
  • Neste momento, determine se a contagem é 0. Como o negócio executado por este thread pode ser um subnegócio em um negócio aninhado, o bloqueio não pode ser liberado diretamente. Em vez disso, o bloqueio deve ser determinado após a contagem ser determinada se deve libere-o.
  • Se for 0, o bloqueio é liberado
  • Se não for 0, redefina o período de validade do bloqueio e repita as etapas acima novamente.
  • exemplo de código
@SpringBootTest
@Slf4j
public class RedissonTests {
    
    
    @Autowired
    RedissonClient redissonClient;

    RLock lock;

    @BeforeEach
    void before() {
    
    
        lock = redissonClient.getLock("order");
    }

    @Test
    void method1() {
    
    
        boolean isLock = lock.tryLock();
        if (!isLock) {
    
    
            log.error("获取锁失败,1");
            return;
        }
        try {
    
    
            log.info("获取锁成功,1");
            method2();
        } finally {
    
    
            log.info("释放锁,1");
            lock.unlock();
        }
    }

    void method2() {
    
    
        boolean isLock = lock.tryLock();
        if (!isLock) {
    
    
            log.error("获取锁失败, 2");
            return;
        }
        try {
    
    
            log.info("获取锁成功,2");
        } finally {
    
    
            log.info("释放锁,2");
            lock.unlock();
        }
    }
}

Descrição: resultado

imagem-20230625200521130

Reabastecimento:

  • Redisson obtém o código-fonte do bloqueio

imagem-20230625191737341

  • Olhando o código-fonte para aquisição do bloqueio, você pode ver que ele ainda é executado usando um script Lua, e a lógica é semelhante ao fluxograma desenhado. Mas o que é retornado com sucesso aqui é nil, e o que é retornado se falhar é o tempo restante.
  • Redisson libera código-fonte do bloqueio

imagem-20230625191919102

  • Olhando o código-fonte lançado, você pode ver que ele ainda é executado com script Lua, e a lógica é semelhante ao fluxograma desenhado. Mas também há uma mensagem postada aqui

4.6.4.4 Princípio da nova tentativa

imagem-20230625214826176

ilustrar:

  • Neste momento, se a aquisição for bem-sucedida, será retornado nulo. Se a aquisição falhar, o tempo de expiração do bloqueio será retornado.
  • Ver código-fonte repetível
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
     
     
    long time = unit.toMillis(waitTime);  // 将等待时间转换为毫秒
    long current = System.currentTimeMillis();  // 当前时间
    long threadId = Thread.currentThread().getId();  // 当前线程ID
    Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);  // 尝试获取锁的剩余过期时间
    if (ttl == null) {
     
     
        return true;  // 成功获取到锁,返回true
    } else {
     
     
        time -= System.currentTimeMillis() - current;  // 计算剩余等待时间
        if (time <= 0L) {
     
     
            this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
            return false;
        } else {
     
     
            current = System.currentTimeMillis();  // 更新当前时间
            RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);  // 订阅锁释放事件
            if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
     
       // 等待一段时间看是否能够获取到锁释放事件的通知
                if (!subscribeFuture.cancel(false)) {
     
       // 取消订阅
                    subscribeFuture.onComplete((res, e) -> {
     
     
                        if (e == null) {
     
     
                            this.unsubscribe(subscribeFuture, threadId);  // 取消订阅成功后,执行取消订阅操作
                        }
                    });
                }
                this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
                return false;
            } else {
     
     
                try {
     
     
                    time -= System.currentTimeMillis() - current;  // 更新剩余等待时间
                    if (time <= 0L) {
     
     
                        this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
                        return false;
                    } else {
     
     
                        boolean var16;
                        do {
     
     
                            long currentTime = System.currentTimeMillis();  // 当前时间
                            ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);  // 再次尝试获取锁的剩余过期时间
                            if (ttl == null) {
     
     
                                var16 = true;  // 成功获取到锁,返回true
                                return var16;
                            }
                            time -= System.currentTimeMillis() - currentTime;  // 更新剩余等待时间
                            if (time <= 0L) {
     
     
                                this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
                                var16 = false;
                                return var16;
                            }
                            currentTime = System.currentTimeMillis();  // 当前时间
                            if (ttl >= 0L && ttl < time) {
     
     
                                ((RedissonLockEntry) subscribeFuture.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);  // 使用剩余过期时间进行等待
                            } else {
     
     
                                ((RedissonLockEntry) subscribeFuture.getNow()).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);  // 使用剩余等待时间进行等待
                            }
                            time -= System.currentTimeMillis() - currentTime;  // 更新剩余等待时间
                        } while (time > 0L);
                        this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
                        var16 = false;
                        return var16;
                    }
                } finally {
     
     
                    this.unsubscribe(subscribeFuture, threadId);  // 释放订阅锁释放事件
                }
            }
        }
    }
}

  • Este método é usado para tentar adquirir um bloqueio distribuído. Primeiro, ele calcula o tempo de espera e obtém o ID do thread atual. Em seguida, ele tenta adquirir o tempo de expiração restante do bloqueio. Retorna verdadeiro se o bloqueio for adquirido com sucesso (o tempo de expiração restante é nulo). Se o tempo de espera restante tiver sido esgotado, a aquisição do bloqueio falhará e retornará falso.
  • Se o tempo de espera restante ainda estiver disponível, inscreva-se no evento de liberação de bloqueio (esse evento de liberação é publicado quando o bloqueio é liberado) e aguarde um período de tempo para ver se você pode receber a notificação do evento de liberação de bloqueio. Se a espera expirar ou a assinatura for cancelada, a aquisição do bloqueio falhará e retornará falso.
  • Se a notificação do evento de liberação do bloqueio for obtida com sucesso, tente novamente obter o tempo de expiração restante do bloqueio. Retorna verdadeiro se o bloqueio for adquirido com sucesso (o tempo de expiração restante é nulo). Se o tempo de espera restante tiver sido esgotado, a aquisição do bloqueio falhará e retornará falso.
  • No processo de aquisição do bloqueio, um temporizador é utilizado para atualizar o tempo de espera restante e uma operação de espera é realizada durante o processo de espera. Por fim, libere o evento de liberação do bloqueio assinado e retorne o resultado da aquisição do bloqueio.

4.6.4.5 Princípio da renovação do timeout

imagem-20230626075937778

ilustrar:

  • Quando a tarefa for concluída, um retorno de chamada será executado. Se o período de validade restante (ttlRemainingFuture) for deixado no retorno de chamada, ele retornará para atualizar o tempo de expiração do bloqueio.

Reabastecimento:

  • QuandoleaseTime não for igual a 1 negativo, o mecanismo watchdog não será habilitado, ou seja, nenhuma renovação de timeout será realizada. O bloqueio será liberado automaticamente quando expirar após 30 segundos.

imagem-20230626090000985

ilustrar:

  • Ver código-fonte
private void scheduleExpirationRenewal(long threadId) {
     
     
    ExpirationEntry entry = new ExpirationEntry(); // 创建一个过期时间续约条目对象
    ExpirationEntry oldEntry = (ExpirationEntry) EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry); // 尝试将续约条目放入续约映射中

    if (oldEntry != null) {
     
      // 如果续约映射中已存在旧的续约条目
        oldEntry.addThreadId(threadId); // 将当前线程的ID添加到旧的续约条目中
    } else {
     
      // 如果续约映射中不存在旧的续约条目
        entry.addThreadId(threadId); // 将当前线程的ID添加到新的续约条目中
        this.renewExpiration(); // 开始进行过期时间的续约操作
    }
}
  • EXPIRATION_RENEWAL_MAPÉ uma coleção da classe RedissonLock ConcurrentMap<String, ExpirationEntry>, utilizada para colocar itens que precisam ser renovados para facilitar o uso na hora de liberar o bloqueio.

imagem-20230626090301890

  • Atualizar o tempo de expiração iniciará uma tarefa e a atualizará continuamente. Isso significa que o bloqueio neste momento é um bloqueio que nunca expirará.

Reabastecimento:

  • Então, como esse bloqueio é um bloqueio que nunca expira, quando será liberado? Na verdade, ele irá expirar quando o bloqueio for liberado.

imagem-20230626091511067

4.6.4.6 Problema de consistência mestre-escravo

imagem-20230626164550074

ilustrar:

  • Ao usar o cluster Redis, é provável que ocorram problemas de consistência mestre-escravo.
  • Quando o nó mestre que adquire o bloqueio trava inesperadamente, um dos nós escravos será selecionado como o novo nó mestre, mas neste momento o nó mestre antigo não pode sincronizar os dados com o novo nó mestre, resultando em perda de dados.
  • solução

imagem-20230626164817871

ilustrar:

  • Agora, crie um novo cluster Redis e sincronize os dados de bloqueio para cada nó mestre sempre que um bloqueio for adquirido.
  • Mesmo que o nó mestre esteja inativo, ainda é possível garantir que os dados não serão perdidos.
  • Código de amostra

1. Modifique RedisConfigo arquivo de configuração

@Configuration
public class RedisConfig {
    
    
    @Bean
    public RedissonClient redissonClient() {
    
    
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://10.13.164.55:6379").setPassword("qweasdzxc");
        // 创建客户端
        return Redisson.create(config);
    }

    @Bean
    public RedissonClient redissonClient2() {
    
    
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://10.13.164.55:6380").setPassword("qweasdzxc");
        // 创建客户端
        return Redisson.create(config);
    }
}

2. Modifique RedissonTestsa classe de teste

@SpringBootTest
@Slf4j
public class RedissonTests {
    
    
    @Autowired
    RedissonClient redissonClient;

    @Autowired
    RedissonClient redissonClient2;

    RLock lock;

    @BeforeEach
    void before() {
    
    
        RLock lock1 = redissonClient.getLock("order");
        RLock lock2 = redissonClient2.getLock("order");
        // 创建连锁MultiLock
        lock = redissonClient.getMultiLock(lock1, lock2); //这里无论使用哪一个客户端来创建MultiLock都可以
    }

    @Test
    void method1() throws InterruptedException {
    
    
        boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
        if (!isLock) {
    
    
            log.error("获取锁失败,1");
            return;
        }
        try {
    
    
            log.info("获取锁成功,1");
            method2();
        } finally {
    
    
            log.info("释放锁,1");
            lock.unlock();
        }
    }

    void method2() {
    
    
        boolean isLock = lock.tryLock();
        if (!isLock) {
    
    
            log.error("获取锁失败, 2");
            return;
        }
        try {
    
    
            log.info("获取锁成功,2");
        } finally {
    
    
            log.info("释放锁,2");
            lock.unlock();
        }
    }
}

Reabastecimento:

  • Veja o código-fonte para obter o bloqueio
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
     
     
    long newLeaseTime = -1L;

    // 计算新的租期时间
    if (leaseTime != -1L) {
     
     
        if (waitTime == -1L) {
     
     
            newLeaseTime = unit.toMillis(leaseTime);
        } else {
     
     
            newLeaseTime = unit.toMillis(waitTime) * 2L;
        }
    }

    long time = System.currentTimeMillis();
    long remainTime = -1L;

    // 计算剩余等待时间
    if (waitTime != -1L) {
     
     
        remainTime = unit.toMillis(waitTime);
    }

    long lockWaitTime = this.calcLockWaitTime(remainTime);
    int failedLocksLimit = this.failedLocksLimit();
    List<RLock> acquiredLocks = new ArrayList<>(this.locks.size());
    ListIterator<RLock> iterator = this.locks.listIterator();

    while (iterator.hasNext()) {
     
     
        RLock lock = (RLock) iterator.next();
        boolean lockAcquired;

        try {
     
     
            if (waitTime == -1L && leaseTime == -1L) {
     
     
                // 尝试获取锁,不设置等待时间和租期时间
                lockAcquired = lock.tryLock();
            } else {
     
     
                // 计算实际等待时间
                long awaitTime = Math.min(lockWaitTime, remainTime);
                // 尝试获取锁,设置等待时间和租期时间
                lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
            }
        } catch (RedisResponseTimeoutException var21) {
     
     
            // 获取锁超时,释放已获取的锁
            this.unlockInner(Arrays.asList(lock));
            lockAcquired = false;
        } catch (Exception var22) {
     
     
            lockAcquired = false;
        }

        if (lockAcquired) {
     
     
            acquiredLocks.add(lock);
        } else {
     
     
            if (this.locks.size() - acquiredLocks.size() == this.failedLocksLimit()) {
     
     
                // 已获取的锁数量达到失败限制,退出循环
                break;
            }

            if (failedLocksLimit == 0) {
     
     
                // 已达到失败限制次数,释放已获取的锁
                this.unlockInner(acquiredLocks);

                if (waitTime == -1L) {
     
     
                    return false;
                }

                // 重新设置失败锁限制和已获取锁列表
                failedLocksLimit = this.failedLocksLimit();
                acquiredLocks.clear();

                while (iterator.hasPrevious()) {
     
     
                    iterator.previous();
                }
            } else {
     
     
                // 减少失败锁限制次数
                --failedLocksLimit;
            }
        }

        if (remainTime != -1L) {
     
     
            // 更新剩余等待时间
            remainTime -= System.currentTimeMillis() - time;
            time = System.currentTimeMillis();

            if (remainTime <= 0L) {
     
     
                // 等待时间已用完,释放已获取的锁并返回失败
                this.unlockInner(acquiredLocks);
                return false;
            }
        }
    }

    if (leaseTime != -1L) {
     
     
        // 设置锁的租期时间
        List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size());
        Iterator<RLock> var24 = acquiredLocks.iterator();

        while (var24.hasNext()) {
     
     
            RLock rLock = (RLock) var24.next();
            // 异步设置锁的租期时间
            RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
            futures.add(future);
        }

        var24 = futures.iterator();

        while (var24.hasNext()) {
     
     
            // 等待设置锁的租期时间操作完成
            RFuture<Boolean> rFuture = (RFuture) var24.next();
            rFuture.syncUninterruptibly();
        }
    }

    return true;
}
  • Vários nós Redis independentes devem obter bloqueios de reentrada em todos os nós para obter o bloqueio com êxito. Se a aquisição de qualquer bloqueio em todos os nós falhar, a aquisição do bloqueio falhará.

4.6.4.7 Resumo dos Princípios

Resumo das notas:

1) Bloqueio distribuído Redis não reentrante:

  • Princípio: Use a exclusividade mútua de setnx ; use ex para evitar deadlock; determine o rótulo do thread ao liberar o bloqueio
  • Defeitos: Sem reentrada, sem nova tentativa, falha no tempo limite de bloqueio

2) Bloqueio distribuído reentrante do Redis:

  • Princípio: Use estrutura hash para registrar a identificação do thread e os tempos de reentrada; use watchDog para estender o tempo de bloqueio; use semáforo para controlar a espera de novas tentativas de bloqueio
  • Defeito: o tempo de inatividade do redis causa problema de falha de bloqueio

3)Redisson e multiLock:

  • Princípio: Vários nós Redis independentes devem obter bloqueios reentrantes em todos os nós para obter o bloqueio com êxito.
  • Desvantagens: altos custos de operação e manutenção, implementação complexa
  • Diagrama esquemático do processo de aquisição e liberação de bloqueios (no modo de nó único)

imagem-20230626090958895

ilustrar:

A imagem à esquerda mostra o fluxo de execução lógica da tentativa de aquisição do bloqueio, e a imagem à direita mostra o fluxo de execução lógica da tentativa de liberação do bloqueio.

4.7 Venda flash de otimização Redis (dificuldade)

4.7.1 Visão geral

imagem-20230626173658068

ilustrar:

​ Com base no atual plano de implementação de negócios de venda relâmpago, o processo de negócios ainda será demorado. Porque ao realizar operações como consulta de cupons e consulta de pedidos, estão envolvidas operações frequentes de banco de dados.

imagem-20230626173837981

ilustrar:

​ Agora usamos Redis para otimizar a função de venda flash e reduzir o negócio de operação de banco de dados. E altere o método de operação síncrona do banco de dados para um método assíncrono. Reduza o processo de negócios de venda relâmpago e torne a conclusão e execução da venda relâmpago mais rápida

4.7.2 Casos de uso básicos

imagem-20230626174252715

ilustrar:

  • Ideias de implementação:

    Determine se o estoque é suficiente. Se não for suficiente, ele terminará. Se for suficiente, será julgado. Ele julgará se o usuário concluiu o pedido. Se o pedido foi feito, ele terminará. Caso o pedido não tenha sido feito, o estoque será deduzido. Após a dedução do estoque, o ID do usuário será salvo no Conjunto. (Primeiro use o Redis para completar o saldo do estoque, julgar uma pessoa e um pedido e concluir o negócio de captura de pedidos) Se o pedido for feito com sucesso, o ID do número do pedido será retornado. Se o pedido falhar, a venda relâmpago falhará ( então o negócio do pedido é colocado na fila de bloqueio, usando um thread separado para fazer um pedido de forma assíncrona)

  • O script Lua é usado para garantir a atomicidade da operação: o julgamento do estoque e a dedução do estoque são bem-sucedidos ao mesmo tempo ou falham ao mesmo tempo.

imagem-20230626174827790

  • Salvar o ID do usuário no Conjunto usa a função integrada de remoção de duplicação para evitar que o usuário faça pedidos repetidos para atingir a função de uma pessoa, um pedido.

Etapa 1: ao adicionar cupons de venda relâmpago, salve as informações do cupom no Redis.

  • método modificado VoucherServiceImpl_addSeckillVoucher
@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);
    // 保存库存到redis中
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), String.valueOf(voucher.getStock()));
}

ilustrar:

Salve as informações de inventário de cupons de venda flash no Redis para reduzir a frequência de leitura do banco de dados MySQl

Etapa 2: com base no script Lua, determine o estoque de venda relâmpago, um pedido por pessoa, e determine se o usuário abocanhou a compra com sucesso.

  • Resourcesadicionar seckill.luascript em
-- 1.参数列表
-- 1.1优惠券id
local voucherId = ARGV[1]
-- 1.2用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1库存Key,用于获取秒杀券的信息
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2订单Key,用于保存购买用户的用户Id,值为Set集合
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
    -- 3.2 库存不足,返回1
    return 1
end 
-- 3.3判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
	return 2
end
-- 3.4.扣库存incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5,下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0

Etapa 3: se o snap-up for bem-sucedido, encapsule o ID do cupom e o ID do usuário e armazene-os na fila de bloqueio.

  • Modifique o método VoucherOrderServiceImplna classeseckillVoucher
public IVoucherOrderService PROXY;   //此时,无法子线程中拿到代理对象,因此定义为成员变量,让子线程可用获取此类的代理对象
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 定义一个阻塞队列
private BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024 * 1024); //类似于MQ

// 静态代码块,用于初始化秒杀脚本
static {
    
    
    SECKILL_SCRIPT = new DefaultRedisScript<>();

    // 设置秒杀脚本的位置为类路径下的 "seckill.lua" 文件
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));

    // 设置秒杀脚本执行结果的类型为 Long
    SECKILL_SCRIPT.setResultType(Long.class);
}
/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 控制层对此业务的处理结果
     */
@Override
public Result seckillVoucher(Long voucherId) {
    
    
    // 获取用户ID
    Long userId = UserHolder.getUser().getId();
    // 1.执行Lua脚本(参数含义:脚本,key,Value)
    Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(), // 因为我们的Lua脚本没有Key,因此利用Collections工具传递空集合
        voucherId.toString(), //将Long类型转换为String类型
        userId.toString()
    );
    // 2.判断结果是否为0
    int r = result.intValue();
    if (r != 0) {
    
    
        // 2.1不为0,代表没有购买资格
        return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
    }
    // 2.2为0,有购买资格,把下单信息保存到阻塞队列
    long orderId = redisIdWorker.nextId("order");
    // 2.3.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 2.4设置订单ID;
    voucherOrder.setId(orderId);
    // 2.5设置用户ID
    voucherOrder.setUserId(userId);
    // 2.6设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 2.7保存订单到阻塞队列
    orderTask.add(voucherOrder);
    // 3.获取代理对象
    PROXY = (IVoucherOrderService) AopContext.currentProxy();// 在主线程中获取代理对象,便于子线程中使用
    // 4.返回订单Id
    return Result.ok(orderId);
}

Etapa 4: iniciar a tarefa de thread, obter continuamente informações da fila de bloqueio e implementar a função de pedido assíncrono

// 定义一个线程池
private final static ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
/**
     * 利用Spring框架提供的注解,让在本类初始化完成之后来执行方法内的内容
     */
@PostConstruct
private void init() {
    
    
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
/**
     * 定义一个内部类,用于实现异步订单的处理
     */
private class VoucherOrderHandler implements Runnable {
    
    
    @Override
    public void run() {
    
    
        while (true) {
    
     // 此处循环执行代码逻辑
            try {
    
    
                // 1.获取队列中的订单信息
                VoucherOrder voucherOrder = orderTask.take(); // 此时,若队列里面没有订单信息,则会阻塞在这里
                // 2.创建订单
                handleVoucherOrder(voucherOrder);
            } catch (Exception e) {
    
    
                log.error("处理订单异常" + e); // 因为这里是子线程来执行任务的处理,因此不用抛出异常,仅仅打印日志即可
            }
        }
    }
}

/**
     * 处理订单信息的业务逻辑
     *
     * @param voucherOrder 阻塞队列里面的订单信息
     */
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    
    
    // 1.获取用户
    Long userId = voucherOrder.getUserId(); //因为此方法交给子线程来执行,因此无法通过主线程的 UserHolder.getUser().getId()方法来获取用户的ID
    // 2.获取锁对象
    RLock lock = redissonClient.getLock(LOCK_ORDER_KEY + userId); // 其实此处无需加锁,因为每个用户不会有多次下单的操作,除非Redis出现异常。因此,这里加锁判断一下比较好
    // 3.获取锁
    boolean isLock = lock.tryLock();
    // 4.判断锁是否获取成功
    if (!isLock) {
    
    
        // 获取锁失败,返回错误重试
        log.error("不允许重复下单");
        return;
    }
    // 5.获取代理对象
    try {
    
     // 此时无法从子线程中获取
        PROXY.createVoucherOrder(voucherOrder);// 用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
    } finally {
    
    
        // 释放锁
        lock.unlock();
    }
}

/**
     * 创建订单信息
     * 这里主要实现了一个对数据库的事务操作,保证
     *
     * @param voucherOrder 阻塞队列里面的订单信息
     */
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
    
    
    // 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
    // 5.1获取用户ID
    Long userId = voucherOrder.getUserId();

    // 5.2判断此订单是否存在
    int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
    if (count > 0) {
    
     // 此处同样也不可能出现重复,除非Redis集群宕机
        // 该用户已经购买过了
        log.error("用户已经购买过一次!");
        return;
    }
    // 6.扣减库存-使用CAS方式解决超卖问题
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherOrder.getVoucherId())
        .gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
        .update();
    if (!success) {
    
    
        // 扣减失败
        log.error("库存不足");
        return;
    }

    // 7.保存订单
    save(voucherOrder);
}

Reabastecimento:

Ainda existem problemas com bloqueio assíncrono e limitações de memória. Neste momento, a fila de bloqueio é armazenada na Jvm. Portanto, se a Jvm estiver inoperante, as informações do pedido serão perdidas, levando a problemas de segurança dos dados.

4.8Fila de mensagens Redis implementa vendas flash assíncronas

4.8.1 Visão geral

Message Queue (Fila de Mensagens) significa literalmente uma fila que armazena mensagens.

O modelo de fila de mensagens mais simples consiste em 3 funções:

  • Fila de mensagens: armazena e gerencia mensagens, também conhecido como corretor de mensagens (Message Broker)
  • Produtor: envia mensagem para fila de mensagens
  • Consumidor: obtém mensagens da fila de mensagens e as processa

imagem-20230627154130025

ilustrar:

  • MQ não é restrito por Jvm. As mensagens no MQ serão persistidas

Entre eles, o Redis oferece três maneiras diferentes de implementar filas de mensagens:

  • Estrutura de lista: simula fila de mensagens com base na estrutura de lista
  • PubSub: modelo básico de mensagens ponto a ponto
  • Stream: um modelo de fila de mensagens relativamente completo

4.8.2 Simular fila de mensagens com base na estrutura de lista

Message Queue (Fila de Mensagens) significa literalmente uma fila que armazena mensagens. A estrutura de dados da lista do Redis é uma lista vinculada bidirecional, que pode simular facilmente o efeito da fila.

A entrada e a saída da fila não ficam do mesmo lado, então podemos usar: LPUSH combinação RPOP, ou RPUSH combinado com LPOP para conseguir isso.

imagem-20230627155018228

ilustrar:

  • Porém, deve-se observar que quando não há mensagem na fila RPOPou LPOPa operação retornará nula, ela não bloqueia e aguarda mensagens como a fila de bloqueio da JVM. BRPOPPortanto, ou deve ser usado aqui BLPOPpara obter o efeito de bloqueio.

Suplemento: Vantagens de simular filas de mensagens com base na estrutura de lista

  • vantagem:
    • Usando armazenamento Redis, não limitado pelo limite superior de memória JVM
    • Baseado no mecanismo de persistência Redis, a segurança dos dados é garantida
    • pode satisfazer a ordem da mensagem
  • deficiência:
    • Não foi possível evitar a perda de mensagens
    • Suporta apenas um único consumidor
  • Se a mensagem na fila de mensagens for obtida do Redis neste momento e o serviço na fila de consumo travar, os dados serão perdidos.

4.8.3 Fila de mensagens baseada em PubSub

​ **PubSub (assinatura de publicação)** é um modelo de mensagens introduzido pelo Redis 2.0. Como o nome indica, os consumidores podem subscrever um ou mais canais.Após o produtor enviar uma mensagem para o canal correspondente, todos os assinantes podem receber a mensagem relevante.

  • ASSINAR canal [canal]: inscreva-se em um ou mais canais
  • PUBLICAR mensagem do canal: Envie uma mensagem para um canal
  • Padrão PSUBSCRIBE[padrão]: inscreva-se em todos os canais que correspondam ao formato padrão

imagem-20230627155559906

ilustrar:

  • O modelo publicar-assinar, simplesmente, é um modelo de produção única e consumo múltiplo

imagem-20230627155826890

ilustrar:

  • Depois de assinar todos os canais que correspondem ao formato Padrão, você pode obter várias mensagens de consumo de canais que correspondem ao formato.

Suplemento: Quais são as vantagens e desvantagens das filas de mensagens baseadas em PubSub

  • vantagem:
    • Adote um modelo de publicação-assinatura para apoiar multiprodução e multiconsumo
  • deficiência:
    • A persistência de dados não é suportada
    • Não foi possível evitar a perda de mensagens
    • Existe um limite máximo para acumulação de mensagens e os dados serão perdidos quando esse limite for excedido.
  • O modelo de publicação-assinatura baseado no PubSub armazena mensagens nos consumidores. Porém, existe um limite máximo para o espaço de cache do consumidor e, se ultrapassar, será perdido
  • As mensagens salvas do cliente serão perdidas se ninguém assinar os dados enviados pelo produtor no Redis.

4.8.4 Fila de mensagens baseada em fluxo

Stream é um novo tipo de dados introduzido no Redis 5.0, que pode implementar uma fila de mensagens muito completa

Redis Stream é usado principalmente para filas de mensagens (MQ, Message Queue).O Redis Stream fornece persistência de mensagens e funções de replicação de backup primário, permitindo que qualquer cliente acesse dados a qualquer momento e pode lembrar o local de acesso de cada cliente. , mas também para garantir que a mensagem não seja perdida

imagem

ilustrar:

  • Grupo de Consumidores : Grupo de consumidores, criado usando o comando XGROUP CREATE. Um grupo de consumidores possui vários consumidores (Consumidor)
  • last_delivered_id : Cursor. Cada grupo de consumidores terá um cursor last_delivered_id. Qualquer consumidor que ler a mensagem moverá o cursor last_delivered_id para frente.
  • pendente_ids : A variável de estado do consumidor (Consumidor), que é usada para manter o id não confirmado do consumidor. pendente_ids registra as mensagens que foram lidas pelo cliente, mas não há confirmação (caractere de confirmação: caractere de confirmação)
  • Processo de negócios - implementado usando a abordagem do consumidor

imagem-20230627170053442

ilustrar:

  • Quando o bloqueio é usado para ler dados na fila de mensagens, o tempo limite é retornadonil
  • No desenvolvimento de negócios, podemos chamar o método de bloqueio XREAD ciclicamente para consultar as últimas notícias, obtendo assim o efeito de monitoramento contínuo da fila. O pseudocódigo é o seguinte:

imagem-20230627170202223

Perceber:

  • Quando o ID especificado $for , neste momento, múltiplas mensagens chegam na fila, então a informação obtida a cada vez é a mais recente, e haverá uma situação de perda de leitura.
  • Processo de negócios - implementado usando grupos de consumidores

imagem-20230627175003679

ilustrar:

​ Grupo de Consumidores (Consumer Group) divide vários consumidores em um grupo e monitora a mesma fila. Com as características acima,

imagem-20230627194312915

ilustrar:

  • O texto acima é o pseudocódigo para implementar vendas flash assíncronas com base na fila de mensagens do Stream.
  • Se houver uma exceção de erro, ela continuará a ser executada até que esta mensagem seja processada com sucesso.

Suplemento: Recursos do comando XREADGROUP para fila de mensagens do tipo STREAM

  • As mensagens podem ser rastreadas
  • Vários consumidores podem competir por novidades e acelerar o consumo.
  • Pode bloquear a leitura
  • Não há risco de mensagens serem perdidas
  • Existe um mecanismo de confirmação de mensagem para garantir que a mensagem seja consumida pelo menos uma vez

4.8.5 Diferenças entre os três métodos de filas de mensagens

imagem-20230627205550184

ilustrar:

  • A coleção List não oferece suporte ao retrocesso de mensagens: quando ocorre um erro ou o consumidor sai de forma anormal durante o processamento de uma mensagem, as mensagens que foram enfileiradas, mas ainda não processadas, podem ser perdidas.
  • PubSub não suporta retrocesso de mensagens: como a publicação e assinatura de mensagens são assíncronas, a sequência estrita de mensagens não pode ser garantida. Além disso, o próprio Redis não é responsável por rastrear o status dos assinantes.Quando um assinante se desconecta, suas alterações de status não podem ser conhecidas.

4.8.6 Implementando a eliminação assíncrona de flash assíncrono da fila de mensagens Redis (pontos principais)

imagem-20230626174252715

ilustrar:

​ Idéia de implementação: em seguida, modifique a função de venda flash de otimização do Redis na seção anterior

Etapa 1: crie uma fila de mensagens do tipo Stream chamada stream.orders

XGROUP CREATE stream.orders g1 0 MKSTREAM

ilustrar:

​ Use a ferramenta de linha de comando para criá-lo no Redis com antecedência, porque você só precisa criar um em todo o Redis e não há necessidade de criá-lo repetidamente.

Etapa 2: modifique o script Lua anterior para pedidos de venda relâmpago. Depois de determinar que você está qualificado para compra urgente, adicione diretamente uma mensagem a stream.orders, contendo voucherId, userId, orderId.

1. ResourcesModifique seckill.luao script em

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

-- 1.3订单id
local orderId = ARGV[3]

-- 2.数据key
-- 2.1库存Key,用于获取秒杀券的信息
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2订单Key,用于保存购买用户的用户Id,值为Set集合
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.3判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
	return 2
end
-- 3.4.扣库存incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5,下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6发送消息到队列中,XADD stream.orders * k1 v1 k2 v2 ...return
redis.call("xadd", "stream.orders","*", "userId", userId,"voucherId", voucherId,"id",orderId)
return 0

ilustrar:

Usando o banco de dados operacional xadd, adicione um script Lua para enviar mensagens para a fila de bloqueio

2. Modifique os métodos VoucherOrderServiceImplda classeseckillVoucher

/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 控制层对此业务的处理结果
     */
@Override
public Result seckillVoucher(Long voucherId) {
    
    
    // 获取用户ID
    Long userId = UserHolder.getUser().getId();
    // 获取订单ID
    long orderId = redisIdWorker.nextId("order");
    // 1.执行Lua脚本(参数含义:脚本,key,Value)
    Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(), // 因为我们的Lua脚本没有Key,因此利用Collections工具传递空集合
        voucherId.toString(), //将Long类型转换为String类型
        userId.toString(),
        String.valueOf(orderId)
    );
    // 2.判断结果是否为0
    int r = result.intValue();
    if (r != 0) {
    
    
        // 2.1不为0,代表没有购买资格
        return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
    }
    // 3.获取代理对象
    PROXY = (IVoucherOrderService) AopContext.currentProxy();// 在主线程中获取代理对象,便于子线程中使用
    // 4.返回订单Id
    return Result.ok(orderId);
}

Etapa 3: quando o projeto for iniciado, inicie uma tarefa de thread, tente receber a mensagem em stream.orders e conclua o pedido.

  • Modifique o método VoucherOrderServiceImplna classeVoucherOrderHandler
/**
     * 定义一个内部类,用于实现异步订单的处理
     */
private class VoucherOrderHandler implements Runnable {
    
    
    String queueName = "stream.orders"; // 注意,此处的队列名称,需要与Lua脚本里的相对于

    @Override
    public void run() {
    
    
        while (true) {
    
    
            try {
    
    
                // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.order >
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"), // 读取消费者信息
                    StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                    StreamOffset.create(queueName, ReadOffset.lastConsumed())
                );
                // 2.判断消息获取是否成功
                if (list == null || list.isEmpty()) {
    
    
                    // 2.1 如果获取失败,说明没有消息,继续下一次循环
                    continue;
                }
                // 3.解析消息中的订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                // 4.如果获取成功,可以下单
                handleVoucherOrder(voucherOrder);
                // 5.ACK确认 SACK stream.order g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId()); //设置消息ID

            } catch (Exception e) {
    
    
                log.error("处理订单异常" + e);
                handlePendingList();
            }
        }
    }

    private void handlePendingList() {
    
    
        while (true) {
    
    
            try {
    
    
                // 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.order 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( // 注意,此处读取消息变为0
                    Consumer.from("g1", "c1"), // 读取消费者信息
                    StreamReadOptions.empty().count(1),
                    StreamOffset.create(queueName, ReadOffset.from("0"))// 未提供API,需要自己上传数据
                );
                // 2.判断消息获取是否成功
                if (list == null || list.isEmpty()) {
    
    
                    // 2.1 如果获取失败,说明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);
                // 4.如果获取成功,可以下单
                handleVoucherOrder(voucherOrder);
                // 5.ACK确认 SACK stream.order g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId()); //设置消息ID

            } catch (Exception e) {
    
    
                log.error("处理pendingList订单异常" + e);
                try {
    
    
                    Thread.sleep(20); // 若抛出异常,可休眠一段时间后再进行PendingList的消息处理
                } catch (InterruptedException ex) {
    
    
                    ex.printStackTrace();
                }
            }
        }
    }
}

ilustrar:

Modifique o código aqui para implementar a lógica de pseudocódigo da fila de mensagens baseada em fluxo

5. Especialistas visitam a loja

Resumo das notas:

  1. Pontos de função do Redis:

    • SortedSetNa função like, use o comando no Redis para scoreconsultar se o usuário logado gosta do blog ou de cada blog. Use addo método para registrar as informações semelhantes do usuário logado neste blog. Método removepara remover informações semelhantes deste blog por usuários logados

    • SortedSetNa função de lista de classificação semelhante, use o comando no Redis rangepara consultar a classificação dos usuários que gostam do blog por meio de métodos

  2. Dificuldades na implementação da função:

    1. Uso básico de uso eq, e adição de instruções SQL no MyBatis- updateplussetSqllast
    2. No stream Stream, digite map()métodos de conversão e finalizaçãotoList()
    3. Na ferramenta Hutool, emendando strings StrUtil.joine copiando classes de entidadeBeanUtil.copyProperties

5.1 Visão Geral

​A exploração de lojas de talentos é um tipo de atividade de experiência de viagem e exploração. Viajantes ou entusiastas de viagens geralmente atuam como especialistas (especialistas ou guias turísticos) para orientar outras pessoas na exploração da cultura, história, gastronomia e atrações de um destino específico.

5.2 Publicar notas de exploração da loja

imagem-20230628210054151

5.3 Curtidas

Reabastecimento:

  • O mesmo usuário só pode curtir uma vez. Clicar novamente cancelará o like.
  • Caso o usuário atual tenha gostado, o botão curtir ficará em destaque (o front end foi implementado, a julgar pelo atributo isLike do campo classe Blog)

Etapa 1: adicione um campo isLike à classe Blog para indicar se o usuário atual gostou dele

  • Modifique os atributos dos membros da classe Blog
/**
     * 是否点赞过了
     */
@TableField(exist = false)
private Boolean isLike;

ilustrar:

Adicionado o atributo de membro isLike, que é usado para determinar se o usuário gosta ou não. Este atributo não existe no banco de dados, portanto adicione uma anotação para facilitar o destaque dele durante a consulta.

Etapa 2: modifique a função like e use a coleção de conjuntos Redis para determinar se foi curtido. Se não tiver sido curtido, o número de curtidas será +1, e se tiver sido curtido, o número de curtidas será -1.

  • Modifique o método likeBlog da classe BlogServiceImpl
@Override
public Result likeBlog(Long id) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();

    String key = "blog:liked:" + id; //以商铺的Key作为键
    // 2.判断当前登录用户是否已经点赞
    Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
    // 3.如果未点赞,可以点赞
    if (BooleanUtil.isFalse(isMember)) {
    
     //包装类自动拆箱时,可能为空
        // 3.1数据库点赞数+1
        boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
        // 3.2保存用户到Redis的Set集合
        if (isSuccess) {
    
    
            stringRedisTemplate.opsForSet().add(key, userId.toString());
        }
    } else {
    
    
        // 4.如果已点赞,取消点赞
        // 4.1数据库点赞数-1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        // 4.2把用户从Redis的set集合中移除
        if (isSuccess) {
    
    
            stringRedisTemplate.opsForSet().remove(key, userId.toString());
        }
    }
    return Result.ok();
}

ilustrar:

Modifique o número total de curtidas no banco de dados MySQL e consulte os usuários de curtidas no banco de dados Redis

Etapa 3: Modifique o negócio de consultar o Blog com base no ID, determine se o usuário conectado no momento gostou dele e atribua o valor ao campo isLike.

  • Modifique o método queryBlogById da classe BlogServiceImpl e encapsule o método queryBlogUser
@Override
public Result queryBlogById(Long id) {
    
    
    // 1.查询blog
    Blog blog = getById(id);
    if (blog == null) {
    
    
        return Result.fail("笔记不存在!");
    }
    // 2.查询blog有关的用户
    queryBlogUser(blog);
    // 3.查询blog是否被点赞
    queryBlogLiked(blog);
    return Result.ok(blog);
}

// 查询Redis中,该商铺,该用户是否点赞
private void queryBlogLiked(Blog blog) {
    
    
    UserDTO user = UserHolder.getUser();
    if (ObjectUtil.isNull(user)) {
    
    
        // 用户未登录的情况,不做查询
        return;
    }
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断当前登录用户是否已经点赞
    String key = BLOG_LIKED_KEY + blog.getId(); //以商铺的Key作为键
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    blog.setIsLike(ObjectUtil.isNotNull(score));
}


/**
     * 根据博客查询用户
     *
     * @param blog 商铺
     */
private void queryBlogUser(Blog blog) {
    
    
    Long userId = blog.getUserId();
    User user = userService.getById(userId);
    blog.setName(user.getNickName());
    blog.setIcon(user.getIcon());
}

ilustrar:

​ Consultar as informações detalhadas do blog na loja, consultar o conteúdo do blog, bem como qual usuário postou o blog e as informações sobre curtidas do blog pelo usuário logado

Etapa 4: Modifique a consulta de paginação Blog Business, determine se o usuário conectado no momento gostou dela e atribua o valor ao campo isLike.

@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();
    // 查询用户
    records.forEach(blog -> {
    
    
        queryBlogUser(blog);
        queryBlogLiked(blog); //每个商铺都需要查询该用户是否已点赞
    });
    return Result.ok(records);
}

ilustrar:

​ Percorra todas as lojas, verifique quais lojas o usuário gostou e defina todas as lojas que o usuário gostou como True

5.4 Curtir lista de classificação

ilustrar:

imagem-20230629183444535

  通过SortedSet集合记录用户点赞情况,并且为点赞排行做铺垫。而List、Set在点赞排行上各有缺点

Etapa 1: substitua o comando Liked Redis definido comoSortedSet

1. Modifique o método likeBlog da classe BlogServiceImpl

@Override
public Result likeBlog(Long id) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();

    String key = BLOG_LIKED_KEY + id; //以商铺的Key作为键
    // 2.判断当前登录用户是否已经点赞
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    // 3.如果未点赞,可以点赞
    if (ObjectUtil.isNull(score)) {
    
     //包装类自动拆箱时,可能为空
        // 3.1数据库点赞数+1
        boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
        // 3.2保存用户到Redis的Set集合
        if (isSuccess) {
    
    
            stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
        }
    } else {
    
    
        // 4.如果已点赞,取消点赞
        // 4.1数据库点赞数-1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        // 4.2把用户从Redis的set集合中移除
        if (isSuccess) {
    
    
            stringRedisTemplate.opsForZSet().remove(key, userId.toString());
        }
    }
    return Result.ok();
}

2. Modifique o método queryBlogLiked da classe BlogServiceImpl

// 查询Redis中,该商铺,该用户是否点赞
private void queryBlogLiked(Blog blog) {
    
    
    UserDTO user = UserHolder.getUser();
    if (ObjectUtil.isNull(user)) {
    
    
        // 用户未登录的情况,不做查询
        return;
    }
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断当前登录用户是否已经点赞
    String key = BLOG_LIKED_KEY + blog.getId(); //以商铺的Key作为键
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    blog.setIsLike(ObjectUtil.isNotNull(score));
}

Etapa 2: implementar a função de consulta de classificação de curtidas

  • Adicione o método queryBlogLikes na classe BlogServiceImpl
@Override
public Result queryBlogLikes(Long id) {
    
    
    // 1.查询Top5的点赞用户
    String key = BLOG_LIKED_KEY + id;
    Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
    // 2.解析出用户的Id
    if (ObjectUtil.isEmpty(top5) || ObjectUtil.isNull(top5)) {
    
    
        return Result.ok(Collections.emptyList()); // 返回一个空集合
    }
    List<Long> ids = top5.stream().map(Long::valueOf).toList();

    String idStr = StrUtil.join(",", ids);
    // 3.根据Id查询用户信息 WHERE id in (5,1) ORDER BY FIELD(id,5,1)
    // 因为关系型数据库SQL语句查询in的特性,默认根据ID升序来排序,会改变用户排行榜的顺序,因此需要通过ORDER BY FIELD来进行自定义排序
    List<UserDTO> userList = userService.query()
        .in("id", ids)
        .last("ORDER BY FIELD(id," + idStr + ")") //拼接ID
        .list()
        .stream()
        .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
        .toList();
    return Result.ok(userList);
}

6. Amigos seguem

Resumo das notas:

  1. Pontos de função Redis
    • Nas funções de seguir e deixar de seguir, Seto conjunto de comandos do Redis é usado. Por meio addde um removemétodo, as funções de seguir e deixar de seguir são implementadas para que usuários logados sigam outros blogueiros.
    • Na função de atenção conjunta, Seto conjunto de comandos do Redis é usado, e intersecta função de atenção conjunta é realizada através do processamento de interseção dos dois conjuntos.
    • SortedSetNa função follow push, use o comando no Redis addpara adicionar o blog publicado pelo blogueiro à caixa de entrada do fã por meio do método (implementado como o modo push do fluxo de feed). Consulta de paginação de rolagem através reverseRangeByScoreWithScoresdo método realiza a função de consulta de rolagem de paginação
  2. Dificuldades na implementação da função:
    • Use-o no MyBatis-plus removee lastadicione instruções SQL para uso.
    • No stream Stream, ele é usado em conjunto com o map()método de conversão do tipo MyBatis-plus.
    • Na ferramenta Hutool, a emenda de strings realiza a consulta ordenada de vários dados , StrUtil.joincompletando o SQL do banco de dados e copiando a classe de entidade para completar a cópia do DTO.ORDER BY FIELDBeanUtil.copyProperties
    • Na função push, a consulta dinâmica do modo de streaming de feed implementa um algoritmo simples registrando o tempo mínimo e o número de elementos ignorados . Consultar as informações básicas após o blog e a implementação detalhada de informações semelhantes

6.1 Seguir e deixar de seguir

6.1.1 Visão geral

imagem-20230701092307957

ilustrar:

Implementar funções de seguir e deixar de seguir

6.1.2 Casos de uso básicos

Etapa 1: implementar a função follow

  • Adicionado método follow da classe FollowServiceImpl
@Override
public Result follow(Long followUserId, Boolean isFollow) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断是关注还是取消关注
    if (BooleanUtil.isTrue(isFollow)) {
    
    
        // 关注
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        save(follow);
    } else {
    
    
        // 取消关注,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
        remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
    }
    return Result.ok();
}

Etapa 2: implementar a função de consulta e acompanhamento

  • Adicionado método isFollow da classe FollowServiceImpl
@Override
public Result isFollow(Long followUserId) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.查询是否关注
    Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
    // 3.判断
    return Result.ok(count > 0);
}

6.2 Preocupações comuns

6.2.1 Visão geral

imagem-20230701092407919

ilustrar:

Implementar funções de seguir e deixar de seguir

6.2.2 Casos de uso básicos

Etapa 1: modificar a lógica de atenção

  • Modifique o método follow da classe FollowServiceImpl para adicionar registros follow no Redis
@Override
public Result follow(Long followUserId, Boolean isFollow) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断是关注还是取消关注
    if (BooleanUtil.isTrue(isFollow)) {
    
    
        // 关注
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        if (isSuccess) {
    
    
            // 当前登录用户记录被关注者的ID
            String key = "follows:" + userId;
            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", userId).eq("follow_user_id", followUserId));
        if (isSuccess) {
    
    
            // 把关注用户的ID从Redis中移除
            String key = "follows:" + userId;
            stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
        }
    }
    return Result.ok();
}

Etapa 2: implementar uma lógica de atenção comum

  • Adicione o método followCommon da classe FollowServiceImpl
@Override
public Result followCommon(Long followUserId) {
    
    
    // 1.获取当前用户
    Long userId = UserHolder.getUser().getId();
    String userKey = "follows:" + userId;
    String followKey = "follows:" + followUserId;
    // 2.查询登录用户与该博客账号共同关注名单
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(userKey, followKey);
    // 3.解析Id集合
    if (ObjectUtil.isNull(intersect) || ObjectUtil.isEmpty(intersect)) {
    
    
        return Result.ok(Collections.emptyList());
    }
    List<Long> ids = intersect.stream().map(Long::valueOf).toList();
    // 4.查询用户
    List<UserDTO> userIds = userService.query().in("id", ids).list()
        .stream()
        .map(user -> BeanUtil.copyProperties(user, UserDTO.class)).toList();
    return Result.ok(userIds);
}

ilustrar:

Use principalmente o método de coleção Set intersectno Redis para encontrar valores comuns

6.3 Siga o empurrão

6.3.1 Visão geral

imagem-20230701092500665

ilustrar:

​ Follow push também é chamado de Feed stream , traduzido literalmente como alimentação. Forneça continuamente aos usuários uma experiência "imersiva" e obtenha novas informações por meio de atualização suspensa infinita.

6.3.2 Fluxo de alimentação

Existem dois modos comuns para produtos de streaming de feed:

  • Linha do tempo: Sem filtragem de conteúdo, simplesmente classificando por horário de lançamento do conteúdo , frequentemente usado para amigos ou seguidores. Por exemplo, círculo de amigos
    • Vantagens: Informações abrangentes, sem lacunas. E a implementação é relativamente simples
    • Desvantagens: Há muito ruído de informação, os usuários não estão necessariamente interessados ​​e a eficiência de aquisição de conteúdo é baixa.
  • Classificação inteligente: use algoritmos inteligentes para bloquear conteúdo que viole regulamentações e não seja do interesse dos usuários. Envie informações nas quais os usuários estão interessados ​​​​para atrair usuários
    • Vantagens: Alimentando informações de interesse dos usuários, os usuários têm alta viscosidade e são facilmente viciados
    • Desvantagens: Se o algoritmo não for preciso, pode ter o efeito oposto
  • A página pessoal neste exemplo é baseada nos amigos que você segue, por isso usa o modo Linha do tempo. Existem três opções de implementação para este modo:
    • modo puxar
    • modo push
    • combinação push-pull

Modo pull : também chamado de difusão de leitura

imagem-20230630072650029

ilustrar:

​ Quando um blogueiro publica um blog, o blog será armazenado em sua caixa de saída. Os fãs puxam o conteúdo da caixa de saída para sua própria caixa de entrada e o conteúdo é classificado automaticamente

Modo push : também chamado de difusão de gravação

imagem-20230630072901471

ilustrar:

Quando um blogueiro publica um blog, ele não tem uma caixa de entrada e envia mensagens diretamente para cada usuário fã. Cada usuário fã recebe mensagens do blog em sua própria caixa de entrada

Modo combinado push-pull : também chamado de híbrido de leitura-gravação, tem as vantagens dos modos push e pull.

imagem-20230630073034751

ilustrar:

Os blogueiros enviam blogs. Se forem grandes blogueiros V, eles têm sua própria caixa de saída. Os fãs comuns leem o conteúdo na caixa de saída, enquanto os fãs ativos recebem diretamente os blogs da caixa de saída em sua própria caixa de entrada. Se você é um blogueiro comum, envie o blog diretamente para a caixa de entrada de seus fãs.

Esquema de implementação do fluxo de alimentação

imagem-20230630073340946

6.3.3 Casos de uso básicos

Etapa 1: modificar o negócio de adicionar notas de visita à loja, salvar o blog no banco de dados e enviá-lo para as caixas de entrada dos fãs ao mesmo tempo

  • Adicionado método saveBlog da classe BlogServiceImpl
@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("新增笔记失败");
    }
    // 3. 查询笔记作者的所有粉丝
    List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
    // 4. 推送笔记Id到所有粉丝
    for (Follow follow : follows) {
    
    
        // 4.1获取粉丝Id
        Long userId = follow.getUserId();
        // 4.2推送到粉丝收件箱
        String key = "feed:" + userId;
        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    // 5.返回id
    return Result.ok(blog.getId());
}

Etapa 2: implementar consulta de paginação

imagem-20230630091507562

ilustrar:

​ Use a forma de paginação de rolagem para implementar a consulta de paginação de rolagem do fluxo de feed

  • Modifique o método queryBlogOfFollow da classe BlogServiceImpl
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
    String key = FEED_KEY + userId;
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
        .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
    // 3.非空判断
    if (typedTuples == null || typedTuples.isEmpty()) {
    
    
        return Result.ok(); // 如果关注列表没有数据,则返回
    }
    // 4.解析数据:blogId、minTime(时间戳)、offset(集合中分数值等于最新时间的元素个数),拿到最新时间戳粉丝和
    List<Long> ids = new ArrayList<>(typedTuples.size());
    long minTime = 0;
    int os = 1; //定义需要跳过的元素个数
    for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
    
    
        // 4.1.获取id
        System.out.println("value:::" + tuple.getValue());
        ids.add(Long.valueOf(tuple.getValue())); // 收集一下Blog集合
        // 4.2.获取分数(时间戳)
        long time = tuple.getScore().longValue(); //记录一下最新时间戳分数
        System.out.println("value:::" + tuple.getScore());
        if (time == minTime) {
    
     // 如果 最新时间戳跟上一个元素时间戳相同则增加跳过元素次数,避免重复查询
            os++;
        } else {
    
    
            minTime = time;
            os = 1; // 重置跳过元素的个数
        }
    }
    // 5.根据id查询blog
    String idStr = StrUtil.join(",", ids);
    List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); // 因为In语句不能保证查询出的ID
    for (Blog blog : blogs) {
    
    
        // 5.1.查询blog有关的用户
        queryBlogUser(blog); // 设置一下博客的基本信息
        // 5.2.查询blog是否被点赞
        queryBlogLiked(blog); // 该博客是否点赞
    }
    // 6. 封装并返回
    ScrollResult r = new ScrollResult();
    r.setList(blogs);
    r.setOffset(os); // 将本次查询跳过次数进行封装返回,避免下一次结果的重复查询
    r.setMinTime(minTime);
    return Result.ok(r);
}

Reabastecimento:

ZREVRANGEBYSCOREO comando do método retorna todos os membros no intervalo de pontuação especificado no conjunto ordenado

7. Comerciantes próximos

Resumo das notas:

  1. Pontos de função Redis
    • Nas funções de seguir e deixar de seguir, Seto conjunto de comandos do Redis é usado. Por meio addde um removemétodo, as funções de seguir e deixar de seguir são implementadas para que usuários logados sigam outros blogueiros.
    • Na função de atenção conjunta, Seto conjunto de comandos do Redis é usado, e intersecta função de atenção conjunta é realizada através do processamento de interseção dos dois conjuntos.
    • SortedSetNa função follow push, use o comando no Redis addpara adicionar o blog publicado pelo blogueiro à caixa de entrada do fã por meio do método (implementado como o modo push do fluxo de feed). Consulta de paginação de rolagem através reverseRangeByScoreWithScoresdo método realiza a função de consulta de rolagem de paginação
  2. Dificuldades na implementação da função:
    • Use-o no MyBatis-plus removee lastadicione instruções SQL para uso.
    • No stream Stream, ele é usado em conjunto com o map()método de conversão do tipo MyBatis-plus.
    • Na ferramenta Hutool, a emenda de strings realiza a consulta ordenada de vários dados , StrUtil.joincompletando o SQL do banco de dados e copiando a classe de entidade para completar a cópia do DTO.ORDER BY FIELDBeanUtil.copyProperties
    • Na função push, a consulta dinâmica do modo de streaming de feed implementa um algoritmo simples registrando o tempo mínimo e o número de elementos ignorados . Consultar as informações básicas após o blog e a implementação detalhada de informações semelhantes

7.1 Visão Geral

imagem-20230630171653042

ilustrar:

Cada loja é classificada por tipo

imagem-20230630173449822

ilustrar:

No Redis, podemos usar o tipo de loja como um conjunto de IDs como chave do Redis, o ID de cada loja como valor e a localização geográfica de cada loja como pontuação.

7.2 Casos de uso básicos

Reabastecimento:

imagem-20230701170333393

Etapa 1: importar informações da loja para GEO

  • O método loadShopData no novo método de teste
/**
     * 导入店铺信息到GEO
     */
@Test
public void loadShopData() {
    
    
    // 1.查询商铺信息
    List<Shop> shopList = shopService.list();
    // 2.根据商铺typeId分组
    Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
    // 3.分批完成Redis的写入
    for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
    
    
        // 3.1获取商铺类型id
        Long typeId = entry.getKey();
        String key = SHOP_GEO_KEY + typeId;
        // 3.2获取同类型的商铺集合
        List<Shop> value = entry.getValue();
        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
        // 3.3写入redis GEOADD key 经度 纬度 member
        for (Shop shop : value) {
    
    
            RedisGeoCommands.GeoLocation<String> e = new RedisGeoCommands.GeoLocation<>(
                shop.getId().toString(),
                new Point(shop.getX(), shop.getY())
            );
            locations.add(e);
        }
        stringRedisTemplate.opsForGeo().add(key, locations);
    }
}

Descrição: Adicionado com sucesso

imagem-20230701105323078

Passo 2: Consultar lojas por tipo de loja

Método ShopServiceImpl no novo método ShopServiceImpl

@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    
    
    // 1.判断是否通过距离进行查询
    if (ObjectUtil.isNull(x) || ObjectUtil.isNull(y)) {
    
    
        // 根据类型分页查询
        Page<Shop> page = query()
            .eq("type_id", typeId)
            .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        // 返回数据
        return Result.ok(page.getRecords());
    }
    // 2.计算分页查询
    String key = SHOP_GEO_KEY + typeId;
    Integer from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
    Integer end = current * SystemConstants.DEFAULT_PAGE_SIZE;
    // 3.查询redis,按照距离排序、分页。结果:shopId、distance
    GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() //GEOSEARSH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
        .search(key,
                GeoReference.fromCoordinate(x, y),
                new Distance(5000), // 指定距离范围
                RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance() //返回结果带上距离
                .limit(end) //limit方法,指定从 0 到 end
               );
    // 4.解析出ID
    if (ObjectUtil.isNull(results) || ObjectUtil.isEmpty(results)) {
    
    
        return Result.ok(Collections.emptyList()); //无最新数据
    }

    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
    if (list.size() <= from) {
    
    
        return Result.ok(Collections.emptyList()); //无最新数据
    }
    // 4.1截取from ~ end的部分
    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> skipList = list.stream().skip(from).toList();
    List<Long> shopIds = new ArrayList<>(skipList.size());
    Map<Long, Distance> shopDistances = new HashMap<>(skipList.size());
    for (GeoResult<RedisGeoCommands.GeoLocation<String>> geoLocationGeoResult : skipList) {
    
    
        // 4.2 获取店铺Id
        Long shopId = Long.valueOf(geoLocationGeoResult.getContent().getName());
        shopIds.add(shopId);
        // 4.3 获取距离
        Distance distance = geoLocationGeoResult.getDistance();
        shopDistances.put(shopId, distance);
    }

    // 5.根据ID查询shop
    String idStr = StrUtil.join(",", shopIds);
    List<Shop> shopList = query().in("id", shopIds).last("ORDER BY FILED(id," + idStr + ")").list();
    for (Shop shop : shopList) {
    
    
        Distance distance = shopDistances.get(shop.getId());
        shop.setDistance(distance.getValue());
    }
    // 6.返回
    return Result.ok(shopList);
}

8. Check-in do usuário

Resumo das notas:

  1. Pontos de função do Redis:

    • Na função de login, use o comando no Redis para definir se o usuário faz login no dia Bitmapatravés setBitdo método (lembre-se que o parâmetro do método offset começa em 0)

    • Na função de estatísticas de login, use o comando no Redis Bitmape use bitFieldo método para obter o número decimal de login no intervalo especificado na matriz.

  2. Dificuldades na implementação da função:

    • Na implementação da função de estatística de login, &os operadores são usados ​​para determinar se o login é baseado em um único dígito e se o resultado da operação é 0, e o deslocamento é realizado para registrar o número de logins consecutivos, alcançar uma pequena implementação do algoritmo.

8.1 Função de login

imagem-20230701152613608

imagem-20230701152959915

ilustrar:

​ BitMap é uma boa maneira de implementar a ideia de login.

imagem-20230701153108419

ilustrar:

Ao operar o bitMap, você pode contar o número de check-ins.

8.1.1 Visão geral

8.1.2 Casos de uso básicos

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

ilustrar:

​ Em StringRedisTemplate, os comandos para bitMap são colocados opsForValueno método

8.2 Função de estatísticas de check-in

8.2.1 Visão geral

imagem-20230701160020416

imagem-20230701160027488

8.2.2 Casos de uso básicos

  • Adicione métodos UserServiceImplna classesignCount
/**
     * 统计最近一次连续签到天数
     *
     * @return 签到天数
     */
@Override
public Result signCount() {
    
    
    // 1.获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.获取日期
    LocalDateTime now = LocalDateTime.now();
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    // 3.拼接Key
    String key = USER_SIGN_KEY + userId + keySuffix;
    // 4.获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    // 5.获取本月截至今天为止的所有签到记录,并返回一个十进制数 BITFIELD sign:5:202303 GET u14 0
    List<Long> result = stringRedisTemplate.opsForValue().bitField(key,
                                                                   BitFieldSubCommands.create().
                                                                   get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)) //指定从第几天结束
                                                                   .valueAt(0) // 指定从第几天开始
                                                                  );

    if (ObjectUtil.isNull(result) || ObjectUtil.isEmpty(result)) {
    
    
        // 没有任何签到结果
        return Result.ok(0);
    }
    Long num = result.get(0);
    if (ObjectUtil.isNull(num) || num == 0) {
    
    
        // 没有任何签到结果
        return Result.ok(0);
    }
    // 6.遍历循环
    int count = 0;
    while (true) {
    
    
        // 6.1让这个数字与1做与运算,得到数字的最后一个bit位。判断这个bit位是否位零
        if ((num & 1) == 0) {
    
    
            // 为0,则表示未签到
            break;
        } else {
    
    
            //为1,则表示已签到
            count++;
        }
        // 继续位移,判断下一位
        num >>>= 1; // num = num >> 1
    }
    return Result.ok(count);
}

9. Estatísticas UV

Resumo das notas:

  • Pontos de função do Redis:

    Na função de estatísticas do UA, os usuários são adicionados ao Redis usando HyperLogLogcomandos no Redis por meio de métodos para obter estatísticas sobre as visitas dos usuários.add

9.1 Visão Geral

​UV: O nome completo é Visitante Único, também chamado de visitantes únicos, que se refere às pessoas físicas que acessam e navegam nesta página pela Internet. Se o mesmo usuário visitar o site várias vezes em um dia, isso será registrado apenas uma vez.
PV: O nome completo é Page View, também chamado de page views ou cliques. Cada vez que um usuário visita uma página do site, um PV é registrado. Se o usuário abrir a página várias vezes, vários PVs são registrados. Freqüentemente usado para medir o tráfego do site.

As estatísticas UV são mais difíceis de fazer no lado do servidor, porque para determinar se o usuário foi contado, as informações do usuário contadas precisam ser salvas. Mas se cada usuário visitante for salvo no Redis, a quantidade de dados será muito assustadora.

9.2 Casos de uso básicos

  • Criar novo método de teste
@Test
public void testHyperLogLog() {
    
    
    String key = "user:ua:2023:7";
    // 准备一个空数组
    String[] users = new String[1000];
    int index = 0;
    for (int i = 1; i <= 1000000; i++) {
    
    
        users[index++] = "user_" + i;
        if (i % 1000 == 0) {
    
    
            index = 0;
            // 发送到redis
            stringRedisTemplate.opsForHyperLogLog().add(key, users);
        }
    }
    // 统计数量
    Long count = stringRedisTemplate.opsForHyperLogLog().size(key);
    System.out.println(count);
}

posto de gasolina do conhecimento

1. Teclas de atalho comuns para IDEA

A tecla de atalho Idea Ctrl+alt+u pode exibir o diagrama de dependência de relacionamento da classe

A tecla de atalho da ideia Ctrl+shift+uselecionará maiúsculas e minúsculas

2. Classe de ferramenta de coletores

(55 mensagens) [Série Java 8] Colecionador Coletor e classe de ferramentas Collectors_collector processamento_Gentil, trabalhador, corajoso e inteligente Blog de Lao Yang-blog CSDN

3. Estrutura do executor

(53 mensagens) Simultaneidade Java - Explicação detalhada do Executor Framework (Estrutura do Executor Framework e membros do Framework)_tongdanping's Blog-CSDN Blog

Registro

Adicionar documentação da API on-line do knife4j

Etapa 1: adicionar dependências

<!--knife4j-->
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>

Etapa 2: adicionar arquivos de configuração

/**
 * knife4j配置信息
 */
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfig {
    
    

    @Bean
    public Docket defaultApi() {
    
    
        return new Docket(DocumentationType.SWAGGER_2)
                .groupName("黑马点评管理系统后台接口文档")
                .apiInfo(defaultApiInfo())
                .select()
                .apis(RequestHandlerSelectors.withClassAnnotation(RestController.class))
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo defaultApiInfo() {
    
    
        return new ApiInfoBuilder()
                .title("管理系统后台接口文档")
                .description("管理系统后台接口文档")
                .contact(new Contact("开发组", "", ""))
                .version("1.0")
                .build();
    }
}

Etapa 3: modificar a configuração Yml

spring:
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

Etapa 4: modificar as regras do interceptador

@Override
public void addInterceptors(InterceptorRegistry registry) {
    
    
    registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
        "/shop/**",
        "/voucher/**",
        "/shop-type/**",
        "/upload/**",
        "/blog/hot",
        "/user/code",
        "/user/login",
        "/doc.html/**", // 需要放行此类文件需要的路径请求
        "/swagger-resources/**",
        "/v2/**"
    ).order(1);
    registry.addInterceptor(getRefreshTokenInterceptor()).addPathPatterns("/**").order(0);

}

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    
      // 处理一下,静态访问的路径
    registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
    registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}

Acho que você gosta

Origin blog.csdn.net/D_boj/article/details/131333229
Recomendado
Clasificación