微服务实现幂等性

230 阅读5分钟

AOP幂等性

根据yudao-cloud项目的配置进行整理

避免一个接口执行重复的请求,避免同一参数被添加多次

RestFul接口类型:
  • GET:支持幂等性
  • POST:不支持幂等性
  • PUT:支持幂等性
  • DELETE:支持幂等性
需要幂等的场景:
  • 超时重试:可能因为网络原因,第一次请求响应延迟但其实到达,触发重试请求,这时就会有两个相同的请求。
  • 异步回调:监听(观察者模式)、异步回调(Callback)和多线程场景下,可能会有多个线程和实例之间并行执行。
  • 消息队列:Kafaka、RocketMQ、RabbitMQ,可能存在丢失消息的情况,多次发送的情况下。
实现幂等:每一次http请求都生成一个幂等标识,接口在判断幂等性时,只要判断redis中是否存在key即可。
分布式锁:
  • 单体式(SpringBoot):单体式架构中的多个线程是共享内存的,不管怎么修改,只要在同一时间只有一个线程操作,其他线程都能知道被修改过并且知道修改后的数据情况,所以使用synchronized就可以保证同步。

  • 分布式(SpringCloud):

    • 使用分布式锁,保证在微服务中,同一时间只有一个服务的一个线程进行操作某个资源。
    • 每个服务都有独立的内存空间,不能共享内存,服务之间的内存相互隔离,无法直接获取和修改彼此的内存,即使一个微服务修改了共享资源,其他服务也不会立即察觉到这个修改。
    • 共享资源:数据库,缓存,文件系统,消息队列,共享服务,对于这些资源可能会被多个服务,在必要时需要使用分布式锁。
分布式锁的注解:当服务获取锁的时间超过了指定时间即为失败。

@Lock4j(keys = {"#user.id", "#user.name"}, expire = 60000, acquireTimeout = 1000)

keys:使用SpEL表达式提取参数,expire:锁的过期时间(60秒),acquireTimeout:获取锁的超时时间

幂等性代码:Redis+Lock4j+Redission

依赖:
<!-- Redis -->
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.7.17</version>
</dependency><!-- 自动装配 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-autoconfigure</artifactId>
    <version>2.7.17</version>
</dependency><!-- 服务保障相关 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>lock4j-redisson-spring-boot-starter</artifactId>
    <version>2.2.3</version>
    <optional>true</optional>
</dependency><!-- DB 相关 -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.18.0</version>
</dependency><!-- 实现对 Caches 的自动化配置 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency><!-- Jackson的时间日期模块 -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency><!-- 容错库 -->
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot2</artifactId>
    <version>1.7.1</version>
    <optional>true</optional>
</dependency><!-- Hutool工具包 -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.22</version>
</dependency>
SpringBoot版本低于2.7.17:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.7.17</version>
</dependency>
自定义注解:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
​
/**
 * 幂等注解
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
​
    /**
     * 幂等的超时时间,默认为 1 秒
     *
     * 注意,如果执行时间超过它,请求还是会进来
     */
    int timeout() default 1;
    /**
     * 时间单位,默认为 SECONDS 秒
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;
​
    /**
     * 提示信息,正在执行中的提示
     */
    String message() default "重复请求,请稍后重试";
​
    /**
     * 使用的 Key 解析器,定义了keyResolver(),使用反射获取解析对象
     */
    Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
    /**
     * 使用的 Key 参数
     */
    String keyArg() default "";
​
}
幂等Key解析器接口:
/**
 * 幂等 Key 解析器接口
 */
public interface IdempotentKeyResolver {
​
    /**
     * 解析一个 Key
     *
     * @param idempotent 幂等注解
     * @param joinPoint  AOP 切面
     * @return Key
     */
    String resolver(JoinPoint joinPoint, Idempotent idempotent);
​
}
默认幂等Key解析器:
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import org.aspectj.lang.JoinPoint;
import org.springframework.stereotype.Service;
​
/**
 * 默认幂等 Key 解析器,使用方法名 + 方法参数,组装成一个 Key
 *
 * 为了避免 Key 过长,使用 MD5 进行“压缩”
 */
@Service
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
​
    @Override
    public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
        String methodName = joinPoint.getSignature().toString();
        String argsStr = StrUtil.join(",", joinPoint.getArgs());
        return SecureUtil.md5(methodName + argsStr);
    }
​
}
SpringEL表达式:用于提取参数
import cn.hutool.core.util.ArrayUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
​
import java.lang.reflect.Method;
​
/**
 * 基于 Spring EL 表达式,
 */
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
​
    private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();
    private final ExpressionParser expressionParser = new SpelExpressionParser();
​
    @Override
    public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
        // 获得被拦截方法参数名列表
        Method method = getMethod(joinPoint);
        Object[] args = joinPoint.getArgs();
        String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
        // 准备 Spring EL 表达式解析的上下文
        StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
        if (ArrayUtil.isNotEmpty(parameterNames)) {
            for (int i = 0; i < parameterNames.length; i++) {
                evaluationContext.setVariable(parameterNames[i], args[i]);
            }
        }
​
        // 解析参数
        Expression expression = expressionParser.parseExpression(idempotent.keyArg());
        return expression.getValue(evaluationContext, String.class);
    }
​
    private static Method getMethod(JoinPoint point) {
        // 处理,声明在类上的情况
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        if (!method.getDeclaringClass().isInterface()) {
            return method;
        }
​
        // 处理,声明在接口上的情况
        try {
            return point.getTarget().getClass().getDeclaredMethod(
                    point.getSignature().getName(), method.getParameterTypes());
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }
​
}
RedisDao:
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
​
import java.util.concurrent.TimeUnit;
​
@Component
public class IdempotentRedisDAO {
​
    /**
     * 幂等操作
     *
     * KEY 格式:idempotent:%s // 参数为 uuid
     * VALUE 格式:String
     * 过期时间:不固定
     */
    private static final String IDEMPOTENT = "idempotent:%s";
​
    private final StringRedisTemplate redisTemplate;
​
    public IdempotentRedisDAO(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
​
    public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
        String redisKey = formatKey(key);
        return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
    }
​
    private static String formatKey(String key) {
        // 用于替换 idempotent:%s 中的 %s
        return String.format(IDEMPOTENT, key);
    }
​
}
自动装配:
@AutoConfiguration(after = RedisAutoConfiguration.class)
public class IdempotentConfiguration {
​
    @Bean
    public IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
        return new IdempotentAspect(keyResolvers, idempotentRedisDAO);
    }
​
    @Bean
    public IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) {
        return new IdempotentRedisDAO(stringRedisTemplate);
    }
​
    // ========== 各种 IdempotentKeyResolver Bean ==========
​
    @Bean
    public DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() {
        return new DefaultIdempotentKeyResolver();
    }
​
    @Bean
    public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() {
        return new ExpressionIdempotentKeyResolver();
    }
​
}
AOP切面:
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.Assert;

import java.util.List;
import java.util.Map;

@Aspect
public class IdempotentAspect {
    private static final Logger log = LoggerFactory.getLogger(IdempotentAspect.class);

    /**
     * IdempotentKeyResolver 集合
     */
    private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;

    private final IdempotentRedisDAO idempotentRedisDAO;

    public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
        this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass);
        this.idempotentRedisDAO = idempotentRedisDAO;
    }

    @Before("@annotation(idempotent)")
    public void beforePointCut(JoinPoint joinPoint, Idempotent idempotent) {
        // 获得 IdempotentKeyResolver
        IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
        Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
        // 解析 Key
        String key = keyResolver.resolver(joinPoint, idempotent);

        // 锁定 Key。
        boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
        // 锁定失败,抛出异常
        if (!success) {
            log.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
            throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message());
        }
    }
}

Lock4j代码:

自动装配:
@AutoConfiguration(before = LockAutoConfiguration.class)
@ConditionalOnClass(name = "com.baomidou.lock.annotation.Lock4j")
public class Lock4jConfiguration {

    @Bean
    public DefaultLockFailureStrategy lockFailureStrategy() {
        return new DefaultLockFailureStrategy();
    }

}
失败策略:
import com.baomidou.lock.LockFailureStrategy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.reflect.Method;

/**
 * 自定义获取锁失败策略,抛出 {@link ServiceException} 异常
 */
public class DefaultLockFailureStrategy implements LockFailureStrategy {

    private static final Logger log = LoggerFactory.getLogger(DefaultLockFailureStrategy.class);

    @Override
    public void onLockFailure(String key, Method method, Object[] arguments) {
        log.debug("[onLockFailure][线程:{} 获取锁失败,key:{} 获取失败:{} ]", Thread.currentThread().getName(), key, arguments);
        throw new ServiceException(GlobalErrorCodeConstants.LOCKED);
    }
}
枚举类:
/**
 * Lock4j Redis Key 枚举类
 */
public interface Lock4jRedisKeyConstants {
​
    /**
     * 分布式锁
     *
     * KEY 格式:lock4j:%s // 参数来自 DefaultLockKeyBuilder 类
     * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构
     * 过期时间:不固定
     */
    String LOCK4J = "lock4j:%s";
​
}