分布式锁使用场景分析&Spring AOP+注解实现分布式锁

1,779 阅读8分钟

一、什么是分布式锁

  1. 服务中常规加锁ReentrantLock或者synchronized锁只能锁住当前服务资源,而在微服务中,一个服务可以部署多个节点,多个服务可能有同样的功能入口,故需要使用分布式锁。
  2. 分布式锁顾名思义是在分布式微服务中使用的锁,可以在多个服务之间或同一服务不同节点共用同一把锁,保证资源同一时间只能被一个线程占有,保证业务有序进行,一般使用服务之外的中间件实现,如redis,zookeeper,mysql,使多个服务可以在统一平台获取同一把锁。

二、分布式锁的特点

  1. 同一时间多个服务只能一个线程获取到锁
  2. 可重入锁(防止死锁)
  3. 高性能、高可用,获取锁和释放锁的过程效率高,减少对业务的影响
  4. 可以根据业务需求定制锁的范围和锁的时间
  5. 加锁和解锁的过程保证原子性,避免加锁过程被抢占资源
  6. 具备锁失效机制,若服务挂掉能自动释放锁防止死锁
  7. 具备非阻塞锁特性,即没有获取到锁可直接返回获取锁失败

三、使用场景分析

  1. 对同一资源的争夺,如秒杀商品,商品减少过程;

    特点:与用户无关,与数据本身有关;

  2. 数据有唯一性要求,数据库中同一类型数据(绑定关系、认证等)只能存在一条,因查询和存储是两步操作,不支持原子性,在没有唯一索引情况下两个线程争抢可能都请求成功;

    特点:可能和用户有关,如同一用户只能收藏一个任务,也可能与用户无关,如渠道商认证统一信用代码必须唯一,加锁的范围判断是否关联用户

  3. 同一功能有多个入口,如认证操作(新增认证信息,绑定用户两步操作),在PC和小程序有两端操作,可能在同时操作时导致重复数据入库;

  4. 用户手残重复请求,接口内部可能有唯一性校验,也可能需求本身可以不唯一,但是用户只是想请求一次,不小心点击了两次,则同样的数据进来两次;

  5. 接口需顺序访问,接口本身没有唯一要求,可以重复请求,但因对服务资源占用过大,过多请求会导致服务崩溃,同一时间只能处理一个请求,接口需阻塞进行。

四、解决方案

  1. 数据库,mysql事务本身支持表锁行锁、读写锁等,并支持出错回滚,对数据有安全控制,缺点在于无法自由控制锁的范围,并且在业务层面的需求放在交给数据库不符合设计要求,对数据库的压力也很大,可做兜底控制。

  2. redis,redis具有访问速度快,可以定制锁的粒度和生效时间的好处,一般使用redisson,本身加锁过程使用lua脚本保证原子性,也有看门狗机制防止死锁。

五、具体实现方案

redisson使用一般需指定锁持有时间,未获取到锁等待时间,锁的key。

pom.xml引入依赖

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

1. 常规的使用分布式锁:

// 这里获取锁的key,指定锁的范围
RLock rLock = redissonClient.getLock("user_auth");
// 第一个时间为未获取到锁的等待时间,第二个时间为锁占有时间
boolean tryLock = rLock.tryLock(0L, 30L, TimeUnit.SECONDS);
if (!tryLock) {
    log.info("获取锁失败");
    throw new Exception();
}
try {
    log.info("获取锁成功");
    // 执行业务逻辑
    // ...
} finally {
    // 保证锁的释放
    rLock.unlock();
}

以上实现保证同一时间只能有一个线程可以请求成功,使用jmeter压力测试,实测100个线程同时请求只能有一个线程获取锁成功。

2. Spring AOP+自定义注解方式实现分布式锁

  1. 注解中可以指定持有锁时间和等待时间,默认都为-1,即未获取到锁直接返回失败,永久持有锁直至锁被释放(当持有锁时间为-1时看门狗机制才能生效,若指定持有锁时间则超时会自动释放);
  2. 锁的Key默认为接口路径URI + 接口入参hashCode + 用户id(无用户id则用ip替代),即当不指定任何参数时,加此注解功能为对同一接口同一用户同样的入参不能在同一时间请求,即防止用户重复请求接口;
  3. 可在接口入参中实现指定的接口返回redisKey,指定锁的粒度,并可选择性的辅助拼接用户id;
  4. 可指定报错时抛出异常的错误码,以提示用户错误信息。

自定义一个注解:


/**
 * 使用此注解会被RepeatLockAspect拦截,默认对当前方法加不等待永久持有的锁,直到此方法执行结束释放锁
 * 锁的范围为同一用户同一接口同一入参不能重复请求
 * @see RedisKeyNameLock
 * @see RepeatLockAspect
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RepeatLock {

    /**
     * 持有锁时间,指定时间后自动释放锁, 默认 -1秒,永久持有,直到当前锁被释放
     */
    int failTimes() default -1;

    /**
     * 等待时间,默认-1秒,不等待,立即失败
     */
    int waitTime() default -1;

    /**
     * redisKey是否需要拼接userId,若找不到userId则拼接用户ip,默认true拼接,(pc和小程序获取userId,运营后台获取staffId)
     */
    boolean concatUserId() default true;

    /**
     * 若获取锁失败抛出异常的错误码,需在api-error.properties中维护,抛出ApiException
     */
    String errorCode() default "";
}

这里可以实现此接口以返回指定的key:



/**
 * @see RepeatLockAspect
 */
public interface RedisKeyNameLock {

    /**
     * 指定redisKeyName,不指定默认为接口uri+hashCode+userId
     */
    String getRedisKeyName();
}

在方法上添加@RepeatLock注解会被此拦截器拦截:


import java.util.*;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Aspect
@Order(0)
@Component
public class RepeatLockAspect {

    @Resource
    private RedissonClient redissonClient;

    // 定义切点
    @Pointcut("@annotation(RepeatLock)")
    public void pointCut() {
    }

    /**
     * 此拦截器需结合@RepeatLock和接口RedisKeyNameLock使用
     * @see RepeatLock
     * @see RedisKeyNameLock
     * 当加了注解没有指定任何参数时,默认对当前方法加不等待永久持有的锁,锁的key为接口URI+入参hashCode+userId(为空时拼接ip)
     * 可在入参的dto实现接口RedisKeyNameLock指定redisKeyName, 此时不再拼接入参hashCode
     * 可在注解RepeatLock中指定等待时间,锁占有时长,是否拼接userId,抛出异常的errorCode
     **/
    @Around(value = "pointCut() && @annotation(repeatLock)")
    public Object around(ProceedingJoinPoint joinPoint, RepeatLock repeatLock) throws Throwable {
        // 因要获取HttpServletRequest和ip,故此注解只能加在http请求过程中方法上
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes == null) {
            throw new ApiException("1120104", "此注解只能加在http请求过程中方法上");
        }
        HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
        String requestURI = request.getRequestURI();
        String ip = IpUtils.getIpAddr(request);

        String userId = "";
        Enumeration<String> enumeration = request.getParameterNames();
        while (enumeration.hasMoreElements()) {
            String parameter = enumeration.nextElement();
            if (parameter.equals("userId")) {
                userId = request.getParameter(parameter);
                break;
            }
        }
        StringJoiner redisKey = new StringJoiner("_");

        // 遍历方法入参判断是否有实现RedisKeyLock接口的类
        Object[] obj = joinPoint.getArgs();
        Optional<Object> optional = Arrays.stream(obj).filter(Objects::nonNull).filter(o -> o instanceof RedisKeyNameLock).findFirst();
        if (optional.isPresent()) {
            // 若接口入参类实现了RedisKeyNameLock类则获取实现的redisKeyName
            RedisKeyNameLock redisKeyLock = (RedisKeyNameLock) optional.get();
            if (StringUtils.isEmpty(redisKeyLock.getRedisKeyName())) {
                throw new ApiException("1120104", "需指定redisKey");
            }
            redisKey.add(redisKeyLock.getRedisKeyName());
        } else {
            // 不指定默认为接口路径URI拼接入参hashCode
            redisKey.add(requestURI).add(String.valueOf(Arrays.hashCode(obj)));
        }
        // 若需要拼接用户id则拼接
        if (repeatLock.concatUserId()) {
            redisKey.add(StringUtils.isEmpty(userId) ? ip : userId);
        }

        // 尝试获取锁
        RLock lock = redissonClient.getLock(redisKey.toString());
        // 默认锁时长为不等待,永久持有,redisson有看门狗机制,会默认加锁30秒,每10秒检查锁是否仍在使用进行续期30秒,若服务挂掉则30秒后会自动解锁
        boolean tryLock = lock.tryLock(repeatLock.waitTime(), repeatLock.failTimes(), TimeUnit.SECONDS);
        if (!tryLock) {
            log.warn("ip地址: {},用户id为: {},访问接口:{},获取锁失败, 锁的key为:{}", ip, userId, requestURI, redisKey);
            // 可指定抛出异常错误码,不指定默认错误
            throw new ApiException(StringUtils.isEmpty(repeatLock.errorCode()) ? "1120114" : repeatLock.errorCode());
        }

        log.info("ip地址: {},用户id为: {},访问接口:{},获取锁成功,锁的key为:{}", ip, userId, requestURI, redisKey);
        Object proceed;
        try {
            // 执行方法业务逻辑
            proceed = joinPoint.proceed();
        } finally {
            // 若方法内部抛出异常保证锁能正常释放
            lock.unlock();
        }

        return proceed;
    }

}

将注解加在方法上具体使用:


    @RepeatLock
    @PostMapping("/testRepeatLock")
    public void testRepeatLock(@Valid @RequestBody SysLogEntity sysLogEntity, @RequestParam("userId") Long userId) throws Exception {
        Thread.sleep(10000);
    }


@Data
public class SysLogEntity implements RedisKeyNameLock, Serializable {
    private static final long serialVersionUID = 1L;

    private String id;

    private String userName;

    private String operation;

    private String method;

    private Date createTime;

    @Override
    public String getRedisKeyName() {
        return this.id + this.userName;
    }

}

以上功能可通过注解简单实现常规业务加锁过程。

优点:

  1. 使用方便,无需用户关注加锁和释放锁过程。
  2. 在不指定任何参数时默认为防重复请求功能,能基本预防用户的误操作。
  3. 可指定锁的时间和范围。

缺点:

  1. 锁的范围过大,对整个方法加锁,锁占有时间长
  2. 虽可以实现接口返回锁的key,在部分场景仍无法满足用户对锁范围的控制,此场景下仍需用户在业务中手动写加锁释放锁代码。

综上,对分布式锁提供了一个简单解决方案,可以轻量级的满足大部分常规业务加锁控制,减少代码的冗余,提升开发效率,在复杂业务场景下仍需开发者手动书写代码。

版权声明:本文为博主原创文章,转载请附上原文出处链接

原文链接:juejin.cn/post/705790…