前言

分布式锁

​ 分布式锁是在分布式系统中保证同步和协调的一种机制。它通常用于协调多个进程或节点之间的访问和资源使用,以防止并发冲突和数据损坏。

​ 分布式锁的实现通常采用一种称为互斥体的同步原语。互斥体是一种锁定机制,可以用来确保一个进程或节点在任何时候都具有对某个资源的独占访问权限。在分布式环境中,互斥体必须经过适当的设计和实现,以确保它能够正确地工作和协调不同的节点和进程。

​ 常见的分布式锁实现方式包括:基于Redis的分布式锁、ZooKeeper的分布式锁、数据库乐观锁等。这些实现方式中,Redis和ZooKeeper常常被使用在大规模的分布式系统中,因为它们提供了可靠的数据存储和高效的访问控制机制。而数据库乐观锁则主要用于小规模分布式系统中,因为它比较简单直接,但是更加依赖于网络的性能。

幂等

​ 幂等是指对同一操作,多次执行所产生的影响与执行一次的影响相同。也就是说,即使同一操作被执行多次,也不会对系统产生额外的影响。在计算机科学中,幂等性是一种非常重要的概念,常常用于设计和实现高可靠性、可复用性、可扩展性的分布式系统和网络服务。常见的幂等操作包括查询数据库、修改状态、发送邮件等。

选型

本次使用lock4j+redisson实现分布式锁和幂等组件,其中,redisson的版本不应该过高,要和Spring Boot版本对应,否则无法正常启动。

Spring Boot:2.6

lock4j: 2.2.3

官方仓库:lock4j: 基于Spring AOP 的声明式和编程式分布式锁,支持RedisTemplate、Redisson、Zookeeper (gitee.com)

正文

首先搭建一个Spring Boot 2.6.3 的项目,搭建过程省略。

引入lock4j+redisson依赖

<dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>lock4j-redisson-spring-boot-starter</artifactId>
              <version>2.2.3</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

分布式锁

首先对lock4j进行一些配置,默认的lock4j在缓存中是以lock4j为前缀的key,但是为了保持项目的一致(比如某个微服务就以它的项目名称为开头)就需要修改其默认的key前缀。

@AutoConfigureBefore(LockAutoConfiguration.class)
public class MyLock4jConfig {

    // 引入默认的配置
    private final Lock4jProperties properties;

        // 修改lock4j前缀 自定义默认使用key 项目名称:lock4j:***
    public MyLock4jConfig(Lock4jProperties properties, ApplicationName applicationName) {
        properties.setLockKeyPrefix(applicationName.getServiceName() + ":lock4j");
        this.properties = properties;
    }

    @Bean
    @ConditionalOnMissingBean
    @SuppressWarnings("all")
    // 这里加入 @ConditionalOnMissingBean注解,目的是为了项目代码里可以灵活重写
      // @SuppressWarnings("all")可以抑制idea的警告
    public LockTemplate lockTemplate(List<LockExecutor> executors) {
        LockTemplate lockTemplate = new LockTemplate();
        lockTemplate.setProperties(properties);
        lockTemplate.setExecutors(executors);
        return lockTemplate;
    }

        // 默认的锁失败策略 这里进行自定义
    @Bean
    public DefaultLockFailureStrategy lockFailureStrategy() {
        return new DefaultLockFailureStrategy();
    }
}

ApplicationName是自定义的获取项目spring.application.name的配置,具体如下:

@Configuration
@Data
public class ApplicationName {

    @Value("${spring.application.name}")
    private String serviceName;
}

实现自定义锁失败策略,实则就是打印语句并抛出一个错误,要不要捕获并打印错误取决于你的项目。GlobalException是我自定义的错误。

@Slf4j
public class DefaultLockFailureStrategy implements LockFailureStrategy {

    @Override
    public void onLockFailure(String key, Method method, Object[] arguments) {
        log.debug("[onLockFailure][线程:{} 获取锁失败 key:{} 获取失败:{}]", Thread.currentThread().getName(), key, arguments);
        throw new GlobalException(REQUEST_LOCKED);
    }
}

resource/META-INF/spring.factories中加入配置,实际路径按照你的MyLock4jConfig的路径命名。

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxx.common.protection.lock4j.config.MyLock4jConfig,\
com.xxx.web.name.ApplicationName

幂等组件

幂等我们使用注解来实现,新建一个名为Idempotent的注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {


    /**
     * 幂等的超时时间,默认为 1 秒
     */
    int timeout() default 1;

    /**
     * 时间单位,默认为 SECONDS 秒
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 提示信息,正在执行中的提示
     */
    String message() default "请求过于频繁,请稍后再试";

    /**
     * 使用的 Key 解析器
     */
    Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;

    /**
     * 使用的 Key 参数
     */
    String keyArg() default "";
}

接下来新建配合注解的切面逻辑

@Aspect
@Slf4j
public class IdempotentAspect {

    private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;

    private final IdempotentRedisDAO idempotentRedisDAO;

    public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
        this.keyResolvers = CollUtil.toMap(keyResolvers, new HashMap<>(), IdempotentKeyResolver::getClass);
        this.idempotentRedisDAO = idempotentRedisDAO;
    }

    @Before(value = "@annotation(idempotent)")
    public void before(JoinPoint joinPoint, Idempotent idempotent) {
        // 获取 key 解析器
        IdempotentKeyResolver idempotentKeyResolver = keyResolvers.get(idempotent.keyResolver());
        Assert.notNull(idempotentKeyResolver, "对应解析器不存在");
        // 解析key
        String key = idempotentKeyResolver.resolver(joinPoint, idempotent);

        // 锁定key
        boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
        // 锁定失败
        if (!success) {
            log.info("[before][方法({}) 参数({}) 重复请求,幂等组件已过滤]", joinPoint.getSignature().getName(), JSONUtil.toJsonStr(joinPoint.getArgs()));
            throw new GlobalException(REQUEST_LOCKED);
        }
    }
}

IdempotentRedisDAO提供一个redis键的获取方法,规则是当键已存在时,就不能获取,不存在时新建键,这样当再来相同请求时,会因为已存在键而直接返回错误信息。

定义这个键,第一个补位时项目名称,第二个补位是对应幂等的接口

public interface Lock4jRedisKeyConstants {
    String IDEMPOTENT_KEY = "%s:idempotent:%s";
}

IdempotentRedisDAO的实现方式有很多,这里使用setIfAbsent()方法

@AllArgsConstructor
public class IdempotentRedisDAO {

    public static final String IDEMPOTENT_KEY = Lock4jRedisKeyConstants.IDEMPOTENT_KEY;

    private final ApplicationName applicationName;

    private final StringRedisTemplate redisTemplate;

    public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
        String redisKey = formatKey(key);
        return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
    }


    private String formatKey(String key) {
        return String.format(IDEMPOTENT_KEY, applicationName.getServiceName(), key);
    }
}

实现接口信息幂等,实际使用接口方法名称+入参共同作为幂等校验信息,这样短时间内相同方法+相同参数就会被拦截。此外,由于方法名称+入参的字符串可能会太长,会影响redis的效率,故此将其进行MD5加密。

之前自定义的Idempotent注解使用的 Key 解析器可以扩展,这里实现了2种,一种是利用SPEL进行校验,匹配入参中的某些字段,另一种是方法名称+入参实现,匹配所有入参。

SPEL方式

public class SPelIdempotentKeyResolver 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);
        }
    }
}

全部匹配方式

public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {

    @Override
    public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
        String methodName = joinPoint.getSignature().toString();
        // 参数
        Object[] args = joinPoint.getArgs();
        // 参数窜
        String argsStr = StrUtil.join(",", args);
        // 使用md5 加密 保证key不会过长
        return SecureUtil.md5(methodName + argsStr);
    }
}
如果觉得我的文章对你有用,请随意赞赏