一、背景

还在为不了解分布式锁而烦恼吗?还在为众多微服务接口不幂等而发愁吗?如果是,并且有兴趣同我一起学习,那请接着看本文,通过本文能够学习到分布式锁的基本原理、如何实现分布式锁以及使用分布式锁解决接口幂等问题。

二、基础知识

本文是通过使用 Redis 实现分布式锁,当然也可用使用各大数据库,比如 Mysql、Oracle 自持的行级锁、大厂的 Zookeeper 等方案实现。

  • 分布式锁的基本思想
    我们既然称其为“锁”,那就是说只有唯一的一把钥匙才能将锁打开,将这种思想放到我们软件设计上来,那就是在同一时间内只有同一个进程或者线程拥有这个”钥匙“来锁住资源,防止其他进程或线程用”钥匙“锁住同一个资源。当然,这是一种通俗的理解,在我们软件工程中,“锁”是要更复杂,更难以掌握的。

  • Redis 实现分布式锁原理
    Redis 主要是利用命令 redis.call()SETNXPEXPIRE 实现分布式锁的,但是因为是两个分开的命令,单独执行这两个命令肯定是非原子性,根据答墨菲定理未来一定会发生非原子的操作。好在一点是的 Redis 可以使用 Lua 脚本将单独的多个命令统一顺序执行,命令 EVAL。通过 EVAL 命名可以执行多个命令,这些命名要么都成功,要么都失败(这就是我们想要的事务的原子性啊)。关于 Lua 脚本如何使用,Redis 官网有示例,可以点击 Lua 脚本使用 学习。如果觉得 Lua 太难,那就感谢 Redis 帮我们实现了分布式锁框架 Redisson 吧,Redisson 实现分布式锁。另外 Redisson 帮我们实现了更多细节问题,例如,通过加入 watchdog 监控锁的状态,当实例还在运行时自动帮你续约(实际就是通过命令 PEXPIRE 重新设定过期时间)。

三、解决方案

为了能够在多场景下复用,避免重复造轮子的现象,我们可以借助 Spring AOP 技术,通过自定义注解 @ApiIdempotent 来实现,写好后在打成 jar 放到我们的中央仓库,在项目上引入 jar ,再在需要控制接口幂等的 Controller 方法上加上我们的注解即可,方便快捷。我这下面自定义一个接口幂等的注解:

/**
 * 自定义接口幂等注解
 * @author ouyang
 * @version 1.0
 * @date 2020/4/20 11:21
 **/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {

    /**
     * 过期时间,单位:ms。 默认2000
     */
    long expire() default 2000;

    /**
     * 重试次数,默认0
     */
    int retryTimes() default 0;

    /**
     * 重试间隔时间,单位:ms,默认100
     */
    long retryInterval() default 100;
}

本注解 @ApiIdempotent 支持自定义锁时间、重试加锁次数及重试间隔设置。

/**
 * @author ouyang
 * @version 1.0
 * @date 2020/4/20 11:21
 **/
@Aspect
@Component
public class ApiIdempotentAspect {

    private final Logger logger = LoggerFactory.getLogger(ApiIdempotentAspect.class);
    private final RedisLockUtil redisLockUtil;

    @Autowired
    public ApiIdempotentAspect(RedisLockUtil redisLockUtil) {
        this.redisLockUtil = redisLockUtil;
    }
    @Pointcut("@annotation(com.gridsum.techpub.apiidempotent.annotation.ApiIdempotent)")
    public void apiIdempotentPointCut() { }

    @Around("apiIdempotentPointCut()")
    public Object apiIdempotentAround(ProceedingJoinPoint point) throws Throwable {
    	// TODO lock
    	
        Object result = point.proceed();
        
        // TODO unlock
        
        return result;
    }
}
  • 基于 lua 脚本实现分布式锁解决接口幂等方案
/**
 * 锁
 * @author ouyang
 * @version 1.0
 * @date 2020/4/20 11:21
 **/
@Component
public class RedisLockUtil {
    private final Logger logger = LoggerFactory.getLogger(RedisLockUtil.class);

    private static final String KEY_PREFIX = "apiIdempotent:";

    //定义获取锁的lua脚本
    private static final DefaultRedisScript<Long> LOCK_LUA_SCRIPT = new DefaultRedisScript<>(
            "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end"
            , Long.class
    );

    //定义释放锁的lua脚本
    private static final DefaultRedisScript<Long> UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>(
            "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return -1 end"
            , Long.class
    );

    private static final Long LOCK_SUCCESS = 1L;
    private static final Long LOCK_EXPIRED = -1L;

    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    public RedisLockUtil(@Qualifier("customRedisTemplate") RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 加锁
     * @param key 锁的 key
     * @param value  value ( key + value 必须保证唯一)
     * @param expire key 的过期时间,单位 ms
     * @param retryTimes 重试次数,即加锁失败之后的重试次数
     * @param retryInterval 重试时间间隔,单位 ms
     * @return 加锁 true 成功
     */
    public boolean lock(String key, String value, long expire, int retryTimes, long retryInterval) {
        key = KEY_PREFIX + key;

        logger.info("locking... redisK = {}", key);
        try {
            //执行脚本
            Object result = redisTemplate
                    .execute(LOCK_LUA_SCRIPT, Collections.singletonList(key), value, expire);
            //存储本地变量
            if(LOCK_SUCCESS.equals(result)) {
                logger.info("locked... redisK = {}", key);
                return true;
            } else {
                //重试获取锁
                int count = 0;
                while(count < retryTimes) {
                    try {
                        Thread.sleep(retryInterval);
                        result = redisTemplate
                                .execute(LOCK_LUA_SCRIPT, Collections.singletonList(key), value, expire);
                        if(LOCK_SUCCESS.equals(result)) {
                            logger.info("locked... redisK = {}", key);
                            return true;
                        }
                        logger.warn("{} times try to acquire lock", count + 1);
                        count++;
                    } catch (Exception e) {
                        logger.error("acquire redis occurred an exception", e);
                    }
                }

                logger.info("fail to acquire lock {}", key);
                return false;
            }
        } catch (Throwable e1) {
            logger.error("acquire redis occurred an exception", e1);
        }

        return false;
    }

    /**
     * 释放KEY
     * @param key   释放本请求对应的锁的key
     * @param value 释放本请求对应的锁的value
     * @return 释放锁 true 成功
     */
    public boolean unlock(String key, String value) {
        key = KEY_PREFIX + key;
        logger.info("unlock... redisK = {}", key);
        try {
            // 使用lua脚本删除redis中匹配value的key
            Object result = redisTemplate
                    .execute(UNLOCK_LUA_SCRIPT, Collections.singletonList(key), value);
            //如果这里抛异常,后续锁无法释放
            if (LOCK_SUCCESS.equals(result)) {
                logger.info("release lock success. redisK = {}", key);
                return true;
            } else if (LOCK_EXPIRED.equals(result)) {
                logger.warn("release lock exception, key has expired or released");
            } else {
                //其他情况,一般是删除KEY失败,返回0
                logger.error("release lock failed");
            }
        } catch (Throwable e) {
            logger.error("release lock occurred an exception", e);
        }

        return false;
    }
}

缺点:
基于 Lua 脚本实现的分布式锁,锁的失效时间是自己设定的,需要根据接口的响应时间评个人经验设定合理的值,如果设定的失效时间过短,将可能导致该锁失效。

  • 基于 Redisson 实现分布式锁解决接口幂等方案

/**
 * 锁
 * @author ouyang
 * @version 1.0
 * @date 2020/4/20 11:21
 **/
@Component
public class RedisLockUtil {
    private final Logger logger = LoggerFactory.getLogger(RedisLockUtil.class);

    private final RedissonClient redissonClient;

    @Autowired
    public RedisLockUtil(@Qualifier("customRedisson") RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

   /**
     * 加锁
     * @param key 锁的 key
     * @param value  value ( key + value 必须保证唯一)
     * @param expire key 的过期时间,单位 ms
     * @param retryTimes 重试次数,即加锁失败之后的重试次数
     * @param retryInterval 重试时间间隔,单位 ms
     * @return 加锁 true 成功
     */
    public RLock lock(String key, String value, long expire, int retryTimes, long retryInterval) {
        logger.info("locking... redisK = {}", key);
        RLock fairLock = redissonClient.getFairLock(key + ":" +  value);
        try {
            boolean tryLock = fairLock.tryLock(0, expire, TimeUnit.MILLISECONDS);
            if (tryLock) {
                logger.info("locked... redisK = {}", key);
                return fairLock;
            } else {
                //重试获取锁
                logger.info("retry to acquire lock: [redisK = {}]", key);
                int count = 0;
                while(count < retryTimes) {
                    try {
                        Thread.sleep(retryInterval);
                        tryLock = fairLock.tryLock(0, expire, TimeUnit.MILLISECONDS);
                        if(tryLock) {
                            logger.info("locked... redisK = {}", key);
                            return fairLock;
                        }
                        logger.warn("{} times try to acquire lock", count + 1);
                        count++;
                    } catch (Exception e) {
                        logger.error("acquire redis occurred an exception", e);
                        break;
                    }
                }

                logger.info("fail to acquire lock {}", key);
            }
        } catch (Throwable e1) {
            logger.error("acquire redis occurred an exception", e1);
        }

        return fairLock;
    }

    /**
     * 释放KEY
     * @param fairLock 分布式公平锁
     * @return 释放锁 true 成功
     */
    public boolean unlock(RLock fairLock) {
        try {
            //如果这里抛异常,后续锁无法释放
            if (fairLock.isLocked()) {
                fairLock.unlock();
                logger.info("release lock success");

                return true;
            }
        } catch (Throwable e) {
            logger.error("release lock occurred an exception", e);
        }

        return false;
    }
}

四、实验

  1. 通过 jmeter 进行压测(15 线程 * 循环 10 次),结果达到预期。
    在这里插入图片描述
    在这里插入图片描述

五、说在最后的话

  1. 本文必须保证 key + value 的唯一性。

  2. 如有说错的地方,还请在下方评论留言(每个评论我都会去认真查看),不吝赐教,我也是一直在学习中。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐