正确实现分布式锁

412 阅读6分钟

「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。

前言

大多数人实现分布式锁通常都是这样写的:Boolean flag=stringRedisTemplate.opsForValue().setIfAbsent(myKey,myValue,60, TimeUnit.SECONDS);

这样实现为什么会有问题的呢?

当一个线程位置1获取到了锁,如果这时因为业务执行时间较长(位置2),或其他的一些未知原因导致该分布式锁已经过期了被服务器删除了,但是这时业务还没执行完成,也就是当前线程还未执行释放锁语句。

如果此时有其他的线程获取了锁(因为该锁是过时释放的),这时我们的第一个真正获取锁因业务执行时间较长的线程此时运行到位置3执行释放锁语句,这时就把其他线程设置的锁给删除了,这是一个严重问题。

那到底怎么做才是正确的分布式锁实现尼?

你可能想把锁过期时间设置较长的时间,如1小时。

但是这是个不好的实践,如果设置过期时间较长,在还没有释放锁的时刻如果程序宕机了,此时该key会在redis服务器中存在较长时间,会严重影响业务的整体使用,如果你希望运维帮你手动删除这个key,看他打不打你就完事了。

如何正确解锁

如果我们知道锁是我们加的,释放的时候也只能是我们释放,那就解决问题了。

我们已经实现了加锁的原子性,如果我们把加锁的value设置成我们当前操作的唯一值,该value标识了当前请求的唯一性,解锁时,我们判断该key的value是我们的value就可能保证释放的锁是我们自己加上去的。

我们可以利用redis支持lua脚本的特性,解锁时执行一个lua脚本即可。

lua脚本分析

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
  • KEYS[1]:为key值
  • redis.call('get', KEYS[1]):调用redis的get方法获取key的值
  • redis.call('get', KEYS[1]) == ARGV[1] 获取的值和传入的值判断是否相等,即:缓存中值是否和我们传入的第一个值ARGV[1]是否相等,相等就调用redis.call('del', KEYS[1]) 进行删除该key,这样就保证加锁和解锁是同一个线程。

实战:正确使用分布式锁

依赖

<!--       spring集成的redis,集成注解使用等,也可用redisson客户端-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!--为了使用 lettuce 线程池,必须使用该依赖-->
<!-- 对象池,使用redis时必须引入 Jackson2JsonRedisSerializer-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

配置文件

spring: 
    #redis 配置
    redis:
      host: 127.0.0.1
      port: 6379
      # 密码
      password:
      # 连接超时时间(记得添加单位,Duration)
      timeout: 10000ms
      # Redis默认情况下有16个分片,这里配置具体使用的分片
      database: 0
      lettuce:
        pool:
          # 连接池最大连接数(使用负值表示没有限制) 默认 8
          max-active: 8
          # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
          max-wait: -1ms
          # 连接池中的最大空闲连接 默认 8
          max-idle: 8
          # 连接池中的最小空闲连接 默认 0
          min-idle: 0

配置类

@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
@EnableCaching
public class RedisConfig {

    @Autowired
    private Environment env;

    protected final static Logger log = LoggerFactory.getLogger(RedisConfig.class);

    /**
     * redisTemplate
     * 序列化
     *
     * @param redisConnectionFactory
     * @return RedisTemplate<String, Object>
     */
    @Bean(name = "redisTemplate")
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        //key的序列化
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(valueFastjsonSerializer());
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
    
    
    /**
     *
     * @return  RedisSerializer<Object>
     */
    private RedisSerializer<Object> valueFastjsonSerializer() {
        //value的序列化
        FastJson2JsonRedisSerializer fastJson2JsonRedisSerializer = new FastJson2JsonRedisSerializer(Object.class);
        //设置localdate localdatetime 序列化问题
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        fastJson2JsonRedisSerializer.setObjectMapper(mapper);
        return fastJson2JsonRedisSerializer;
    }
}    
    

分布式锁工具类

/**
 * @Description: redis 分布式锁,只支持redis单机版,redis集群暂时不支持redis分布式锁(包括redlock),搭建高可用分布式锁请使用zookeeper分布式锁
 * @Author: jianweil
 */
@Slf4j
@Component
public class RedisLock {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 释放锁脚本,原子操作,lua脚本
     */
    private static final String UNLOCK_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

    /**
     * 默认过期时间(30ms)
     */
    private static final long DEFAULT_EXPIRE = 30L;


    /**
     * 获取分布式锁,原子操作
     *
     * @param lockKey   锁
     * @param lockValue 唯一ID, 可以使用UUID.randomUUID().toString();
     * @return 是否枷锁成功
     */
    public boolean tryLock(String lockKey, String lockValue) {
        return this.tryLock(lockKey, lockValue, DEFAULT_EXPIRE, TimeUnit.MILLISECONDS);
    }

    /**
     * 获取分布式锁,原子操作
     *
     * @param lockKey   锁
     * @param lockValue 唯一ID, 可以使用UUID.randomUUID().toString();
     * @param expire    过期时间
     * @param timeUnit  时间单位
     * @return 是否枷锁成功
     */
    public boolean tryLock(String lockKey, String lockValue, long expire, TimeUnit timeUnit) {
        try {
            RedisCallback<Boolean> callback = (connection) -> connection.set(lockKey.getBytes(StandardCharsets.UTF_8),
                    lockValue.getBytes(StandardCharsets.UTF_8), Expiration.seconds(timeUnit.toSeconds(expire)),
                    RedisStringCommands.SetOption.SET_IF_ABSENT);
            return redisTemplate.execute(callback);
        } catch (Exception e) {
            log.error("redis lock error ,lock key: {}, value : {}, error info : {}", lockKey, lockValue, e);
        }
        return false;
    }

    /**
     * 释放锁
     * script:需要执行的lua脚本,在Lua中可以通过变量KEYS、ARGV数组访问后面定义的key、arg。比如keys[1]代表第一个key,argv[1]代表第一个arg,注意的是下标是从1开始
     * numkeys: 指定key的个数
     * key:指定每个key的名字
     * arg:自定义参数,比如value。
     *
     * @param lockKey   锁
     * @param lockValue 唯一ID
     * @return 执行结果
     */
    public boolean unlock(String lockKey, String lockValue) {
        RedisCallback<Boolean> callback = (connection) -> connection.eval(UNLOCK_LUA.getBytes(), ReturnType.BOOLEAN, 1, lockKey.getBytes(StandardCharsets.UTF_8), lockValue.getBytes(StandardCharsets.UTF_8));
        return redisTemplate.execute(callback);
    }

    /**
     * 获取Redis锁的value值
     *
     * @param lockKey 锁
     */
    public String get(String lockKey) {
        try {
            RedisCallback<String> callback = (connection) -> new String(Objects.requireNonNull(connection.get(lockKey.getBytes())), StandardCharsets.UTF_8);
            return redisTemplate.execute(callback);
        } catch (Exception e) {
            log.error("get redis value occurred an exception,the key is {}, error is {}", lockKey, e);
        }
        return null;
    }
}

测试

@RunWith(SpringRunner.class)
@SpringBootTest(classes = CoreApplicationTest.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Slf4j
public class RedisLockTest extends BaseJunitTest {

    private volatile Integer count = 5000;
    private ExecutorService executorService = Executors.newFixedThreadPool(500);

    @Autowired
    private RedisLock redisLock;


    /**
     * 测试分布式锁
     */
    @Test
    public void testAopLock() throws InterruptedException {
        IntStream.range(0, 3000).forEach(i -> executorService.execute(() -> this.aopBuy(i)));
    }


    public void aopBuy(int userId) {
        // 设置 Redis 键,该键名跟业务挂钩,应为一个不变的值,这里设置为 test
        String key = "test";
        // 生成 UUID,将该 UUID 最为 Redis 的值。
        // 注:设置个 UUID 随机值充当 Redis 存入的 Value 是为了保证,在分布式环境且存在多实例情况下,
        // 进行加锁和解锁操作的都是相同的进程(同一个实例),这样能够避免该锁被别的进程(实例)执行解锁操作。
        String value = UUID.randomUUID().toString();
        // 获取分布式锁,设置超时时间为 10 秒
        boolean execute = redisLock.tryLock(key, value, 10, TimeUnit.SECONDS);
        if (execute) {
            try {
                // 加锁成功
                log.info("{} 正在出库。。。", userId);
                doBuy();
                log.info("{} 扣库存成功。。。", userId);
            } catch (Exception e) {
                log.info("出库错误!");
            } finally {
                // 执行完成,确保执行释放分布式锁
                redisLock.unlock(key, value);
            }
        } else {
            log.error("获取分布式锁失败");
            //其他操作
        }
    }

    public void doBuy() {
        count--;
        log.info("count值为{}", count);
    }
}
  1. 生成当前操作的唯一value,用于后续的解锁判断
  2. 使用try catch finally 确保释放锁语句执行
  3. 过期时间最好为业务操作时间的3倍左右(redission工具包完美实现了自动续约功能,推荐使用)

思考

该redis分布式锁,只支持redis单机版,若在redis集群中使用该方案是有问题的。包括redlock也是有争议的

搭建高可用分布式锁请使用zookeeper分布式锁。

写在最后

  • 👍🏻:有收获的,点赞鼓励!
  • ❤️:收藏文章,方便回看!
  • 💬:评论交流,互相进步!