分布式锁-从单体锁到分布式锁

本文详细介绍了在物流项目中使用分布式锁来避免订单重复创建的问题,重点讲解了基于RedisSETNX实现的分布式锁以及使用Redisson库简化实现。同时讨论了自行实现的锁存在的问题,如超时释放、主从一致性等,并强调了Redisson提供的完善解决方案。
摘要由CSDN通过智能技术生成

回顾总结一下之前做的物流项目的分布式锁。

场景:

想象一下这样的场景,快递员提交了支付请求,由于网络等原因一直没有返回二维码,此时快递员针对该订单又发起了一次请求,这样的话就可能针对于一个订单生成了2个交易单,这样就重复了,所以我们需要在处理请求生成交易单时对该订单锁定,如果获取到锁就执行,否则就抛出异常。
实际上,在这里我们是需要使用分布式锁来实现,首先要解释下为什么是用分布式锁,不是用本地锁,是因为微服务在生产部署时一般都是集群的,而我们需要的在多个节点之间锁定,并不是在一个节点内锁定,所以就要用到分布式锁,如何实现分布式锁呢,下面我们一起来学习下。

核心思想:

实现分布式锁,可以借助redis的SETNX命令完成,该命令设置值时,如果key不存在,为key设置指定的值,返回1,如果存在返回0,也就意味着相同的key只能设置成功一次,假设有多个线程同时设置值,只能有一个设置成功,这样就得到互斥的效果,也就可以达到锁的效果。

192.168.150.101:0>SETNX abc 123
"1"  ---设置成功
192.168.150.101:0>SETNX abc 123
"0"  ---设置失败
192.168.150.101:0>SETNX abc 123
"0"  ---设置失败
192.168.150.101:0>get abc
"123"  ---可以正常查询值

商品并发操作图:
image.png

业务功能:

下面我们基于并发创建交易这样的业务场景进行测试。(未加任何锁)

@Service
public class NativePayService {

    @Resource(name = "aliNativePayHandler")
    private NativePayHandler nativePayHandler;

    /**
     * 创建交易单示例代码
     *
     * @param productOrderNo 订单号
     * @return 交易单对象
     */
    public TradingEntity createDownLineTrading(Long productOrderNo) {
        TradingEntity tradingEntity = new TradingEntity();
        tradingEntity.setProductOrderNo(productOrderNo);

        //基于订单创建交易单
        tradingEntity.setTradingOrderNo(IdUtil.getSnowflakeNextId());
        tradingEntity.setCreated(LocalDateTime.now());
        tradingEntity.setTradingAmount(BigDecimal.valueOf(1));
        tradingEntity.setMemo("运费");

        //调用三方支付创建交易
        this.nativePayHandler.createDownLineTrading(tradingEntity);

        return tradingEntity;
    }

}

使用多线程模拟并发测试:

 @Test
    void createDownLineTrading() throws Exception {
        Long productOrderNo = 1122334455L;

        //多线程模拟并发
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                TradingEntity tradingEntity = nativePayService.createDownLineTrading(productOrderNo);
                System.out.println("交易单:" + tradingEntity + ", 线程id = " + Thread.currentThread().getId());
            }).start();
        }

		//睡眠20秒,等待所有子线程的完成
        Thread.sleep(20000);
    }

运行结果:
image.png
可以看到,对同一个订单创建了多个交易但对象,这就是并发常见下的数据重复问题。

基于Redis实现分布式锁

定义锁接口:

public interface ILock {
    /**
     * 尝试获取锁
     *
     * @param name       锁的名称
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true表示获取锁成功,false表示获取锁失败
     */
    boolean tryLock(String name, Long timeoutSec);

    /**
     * 释放锁
     */
    void unlock(String name);
}

基本的实现:

@Component
public class SimpleRedisLock implements ILock {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(String name, Long timeoutSec) {
        // 获取线程标示
        String threadId = Thread.currentThread().getId() + "";
        // 获取锁 setIfAbsent()是SETNX命令在java中的体现
        // 使用setIfAbsent()方法尝试设置键值对,如果键不存在则设置成功并返回true,否则返回false
        // 当前线程的标识作为锁的值。
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock(String name) {
        //通过del删除锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

业务中使用(createDownLineTradingLock()方法):

 /**
     * 创建交易单示例代码
     *
     * @param productOrderNo 订单号
     * @return 交易单对象
     */
    public TradingEntity createDownLineTradingLock(Long productOrderNo) {

        //获取锁
        String lockName = Convert.toStr(productOrderNo);
        boolean lock = this.simpleRedisLock.tryLock(lockName, 5L);
        if (!lock) {
            System.out.println("没有获取到锁,线程id = " + Thread.currentThread().getId());
            return null;
        }

        System.out.println("获取到锁,线程id = " + Thread.currentThread().getId());

        //createDownLineTrading创建交易单示例方法
        TradingEntity tradingEntity = createDownLineTrading(productOrderNo);
        
        //释放锁
        this.simpleRedisLock.unlock(lockName);
        return tradingEntity;
    }

测试:

image.png
可以看到,线程23、24没有获取到锁,只要线程25获取到了锁,最终一个订单只会对应一个交易单,这样才符合需求。

问题分析

自己基于Redis实现基本上是ok的,但是仔细分析会发现一些问题,比如:设置持有锁的时间为5秒,而程序所运行的时间大于5秒,这样就会出现,程序还没结束锁已经释放了其他线程就可以获取到这个锁而当前线程在释放锁时,就会把其他线程的锁删除了,最终可能会导致脏数据。
为了解决这个问题,我们可以在删除时判断一下看是存储的值是否是当前线程的id,是就删除,不是就不删除。
代码实现:

 @Override
    public void unlock(String name) {
        // 获取线程标示
        String threadId = Thread.currentThread().getId() + "";
        // 获取锁中的标示
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标示是否一致
        if(threadId.equals(id)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

这样是不是就没问题了呢?并不是,其实还是存在问题的。
问题就是,释放锁时查询与删除并不是一个原子性操作,这样带来的问题就是,查询时有数据,删除时数据可能被其他线程删除了。image.png
这样是不是就没问题了呢?并不是,其实还是存在问题的。
问题就是,释放锁时查询与删除并不是一个原子性操作,这样带来的问题就是,查询时有数据,删除时数据可能被其他线程删除了。
除了这个问题外还有其他问题:
1.不可重入,同一个线程无法多次获取同一把锁。
2.不可重试,获取锁只尝试一次就返回false,没有重试机制
3.超时释放,锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
4.主从一致性,如果Redis提供了主从集群主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现。
总结一句话,就是自己基于Redis实现分布式锁需要解决的问题非常多,实现非常的复杂,而Redisson已经完美的实现并且解决了这些问题,我们可以直接使用。

Redisson快速入门

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

image.png
Redisson: Easy Redis Java client with features of In-Memory Data Grid
导入依赖:

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

配置:

import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import lombok.Data;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.annotation.Resource;

@Data
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class RedissonConfiguration {

    @Resource
    private RedisProperties redisProperties;

    @Bean
    public RedissonClient redissonSingle() {
        Config config = new Config();
        SingleServerConfig serverConfig = config.useSingleServer()
                .setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort());
        if (null != (redisProperties.getTimeout())) {
            //设置持有时间
            serverConfig.setTimeout(1000 * Convert.toInt(redisProperties.getTimeout().getSeconds()));
        }
        if (StrUtil.isNotEmpty(redisProperties.getPassword())) {
            //设置密码
            serverConfig.setPassword(redisProperties.getPassword());
        }
        //创建RedissonClient
        return Redisson.create(config);
    }

}

项目中使用:

    @Resource
    private RedissonClient redissonClient;

     /**
     * 创建交易单示例代码
     *
     * @param productOrderNo 订单号
     * @return 交易单对象
     */
    public TradingEntity createDownLineTradingRedissonLock(Long productOrderNo) {
        //获取锁
        String lockName = Convert.toStr(productOrderNo);
        //获取公平锁,优先分配给先发出请求的线程
        RLock lock = redissonClient.getFairLock(lockName);
        try {
            //尝试获取锁,最长等待获取锁的时间为5秒
            if (lock.tryLock(5L, TimeUnit.SECONDS)) {
                System.out.println("获取到锁,线程id = " + Thread.currentThread().getId());
                //休眠5s目的是让线程执行慢一些,容易测试出并发效果
                Thread.sleep(5000);
                return createDownLineTrading(productOrderNo);
            }
            System.out.println("没有获取到锁,线程id = " + Thread.currentThread().getId());
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //释放锁,需要判断当前线程是否获取到锁
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
        return null;
    }

测试结果:
image.png
可以看到,与我们自己实现的效果是一样的,可见使用Redisson是非常方便实现分布式锁的。

看门狗机制

在使用Redisson分布式锁时,我们有没有指定存储到Redis中锁的有效期时间?如果有的话是多久?如果程序执行时间超出这个时间会怎么样?
其实,在程序中我们并没有指定存储到Redis中锁的有效期时间,而是Redisson的默认存储时间,默认时间是30秒。如果程序的执行时间超出30秒,锁是自动删除吗,是不会的,Redisson一旦加锁成功就会启动一个watch dog【看门狗】,当时间每过期1/3时,就检查一下,如果当前线程还继续持有锁就会重新刷新到30秒,直到最后的锁释放。
image.png
可以看到,通过watch dog机制确保不会在业务程序结束之前存储到Redis的锁过期。
可以在Redisson的Config对象中设置锁的默认存时间config.setLockWatchdogTimeout(10 * 1000);
需要注意的是,如果在获取锁时指定了leaseTime参数,看门狗程序是不会生效的,如下:
image.png
上述的配置,锁的有效期时间为10秒,10秒后锁会自动释放,不会续期。

  • 47
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值