java如何保证redis设置过期时间的原子性_Redis实现分布式锁

介绍

为了保证共享资源在高并发情况下同一时间只能被一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的锁,synchronized或ReentrantLock进行互斥控制。但是在分布式系统中,应用分布在不同的机器上,这使得单机部署的并发控制锁失效。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁钥解决的问题。


一、分布式锁的原理及特性

之前写了一篇文章,介绍了分布式锁的原理及特性。接下来我们以扣减库存这个场景为例,再详解介绍下分布式锁的原理和特性。

一个电商下订单系统,目前是单机部署的,下单逻辑是库存足够才允许下单成功,不能出现扣减库存为负数的情况。如果是在秒杀场景下,系统的并发量非常的高,所以会预先将商品库存保存在redis中,用户下单的时候会先检查库存是否足够,在更新redis中的库存。系统架构如下:

f436fe913f00faf2f8cad7d7ef296cbe.png

但是这样一来就会产生一个问题:假如当商品库存只剩1个的时候,同时来了两个请求,其中一个执行到第3步,更新数据库的库存为0,还没执行第4步的时候,另一个请求执行到了第2步,发现库存为1,就继续执行第3步,更新数据库的库存为-1,这样就出现了库存超卖的问题。

单机部署的情况我们很容易就想到使用线程锁把2、3、4步锁住,让他们顺序执行完后,另一个线程才能进来执行第2步。结构如下:

76a14a1aaa22641663dd664364a5dd80.png

如上图在执行第2步的时候,我们可以使用synchronized或者ReentrantLock来锁住,然后在第4步执行完后释放锁。

但是随着系统并发的上升,一台机器扛不住了,我们的架构采用增加一台机器,进行多机部署,如下图:

69f8c9c3ee41a83a665d1f24533296b4.png

假如此时两个用户的请求同时到来,但是落在了不同的机器上,因为上图中的两个A系统,运行在两个不同的JVM里面,他们加的锁只对属于自己JVM里面的线程有效,对于其他JVM的线程是无效的。那么这两个请求还是可以同时执行了,还是会出现库存超卖的问题。

那么此时的问题就是,我们需要一个全局唯一的锁,可以保证两台机器加的锁是同一个锁,才能解决如上库存超卖的问题,此时这个场景就是分布式锁的使用场景了。

分布式锁在整个系统提供一个全局、唯一的获取锁的机制,然后每个系统在需要加锁时,都通过这个机制去获取锁,这样不同的系统拿到的就可以认为是同一把锁。具体实现分布式锁的方式可以是以下几种:

1. 数据库实现分布式锁

  • 乐观锁实现方式

  • 悲观锁实现方式,性能非常不好

2. Memcache

  • 利用Memcache的add命令。此命令是原子性操作,只有在key不存在的情况下才能add成功,也就意味着线程得到了锁

3. 基于Redis实现分布锁

  • 利用Redis的setnx命令。只有在key不存在的情况下才能set成功

4. 基于zookeeper实现分布式锁

  • 利用zookeeper的临时顺序节点,来实现分布式锁和等待队列

5. Chubby

  • Google公司实现的粗粒度分布式锁服务,底层利用了Paxos一致性算法

采用分布式锁实现库存扣减的逻辑如下:

01ea475df9f840a10545521b92dff17c.png

所以现在我们知道了库存超卖场景在分布式部署系统的情况下使用Java原生的锁机制无法保证线程安全,所以我们需要用到分布式锁的解决方案。

那么如何实现分布式锁呢?接下来我们介绍下使用redis实现分布式锁的方案。

二、Redis实现分布式锁

Reids的很多命令都可以实现分布式锁,最常用的是setnx命令,setnx的含义是只有当key不存在时设置key的值为value,当key存在时,不做任何反应。当返回1时获取锁成功,当获取锁失败时,每隔1秒自动尝试再次获取锁,看能否获取到锁,等别人的锁过期了或者释放了锁,才能获取到锁。

Redis实现分布式锁流程如下:

d85e0704d6d4e2516996a070920b5be9.png

使用stringRedisTmplate模拟分布式锁的实现,先来个错误版的:

String lockKey = "lock:name"try {  Boolean result = stringRedisTmplate.opsForValue().setIfAbsent(lockKey, "value");  stringRedisTmplate.expire(lockKey,10,TimeUnit.SECONDS);  if (!result) {    return "获取锁失败";  }  // 处理业务逻辑} finally {  stringRedisTmplate.delete(lockKey);}

上面是一个错误的示例,存在的问题是setnx和expire设置过期时间是两步操作,非原子性操作,当某线程执行setnx得到了锁,还没来得及设置过期时间时,redis节点挂掉了,这把锁就永久有效了,其他线程就无法再获得锁了。怎么解决呢?

使用stringRedisTmplate将setnx和expire合并到一起,它的底层是使用的lua脚本,是原子性操作:

Boolean result = stringRedisTmplate.opsForValue().setIfAbsent(lockKey,"value",10,TimeUnit.SECONDS);

这样就解决了无法释放锁的问题,那么这个又存在什么问题呢?我们设置的过期时间是10秒,假设第一个线程从加锁到解锁需要15秒,执行业务逻辑需要10秒,我们用下图模拟下在高并发场景下的流程过程:

db6581c6a15cd7128f777415169005a0.png

上面实现的分布式锁不具备拥有者标识,线程1在执行业务逻辑期间锁过期了,线程2获得了锁,线程1执行完业务逻辑解锁时解锁了线程2的锁,同理后面线程3解锁了线程4的。即发生了在高并发场景下锁永远失效,导致了锁误删的问题。

可以给锁加个唯一标识,比如请求ID:

String lockKey = "lock:name"String requestId = UUID.randomUUID().toString();try {  Boolean result = stringRedisTmplate.opsForValue().setIfAbsent(lockKey,requestId,10,TimeUnit.SECONDS);  if (!result) {    return "获取锁失败";  }  // 处理业务逻辑} finally {  if (requestId.equals(stringRedisTmplate.opsForValue().get(lockKey))) {      stringRedisTmplate.delete(lockKey);  }}

这样就解决误删的问题,但有个问题是在我们还没有处理完业务逻辑,我们设置的锁已经过期了,在高并发场景下可能存在锁永远失效的问题,那么这种问题一般是怎么解决呢?

解决思路一般是子线程监测锁并且重置锁过期时间,当线程获取到了锁,开启一个分线程开启一个定时器,每隔一段时间与检测锁有没有过期,如果锁还存在,则把锁的过期时间进行重置。当主线程执行完释放掉锁后,主线程结束,对应的分线程也就结束了。针对锁过期时间重置的问题可以使用redisson框架来解决,下面我们看下redisson框架来实现分布式锁。

三、Redisson框架实现分布式锁

如果你的项目中Redis是多机部署的,那么使用Redisson实现分布式锁是非常合适的,这是Redis官方提供的Java组件,代码示例:

springboot下配置一个redisson客户端:

@SpringBootApplicationpublic class Application {    public static void main(String[] args) {        SpringApplication.run(Application.class, args);    }    @Bean    public Redisson redisson() {        // 单机模式        Config config = new Config();        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);        return (Redisson)Redisson.create(config);    }}

加锁解锁代码:

RLock lock = redisson.getLock(lockKey);lock.tryLock(30, TimeUnit.SECONDS);lock.unlock();

使用就是这么简单,redisson所有的指令都是通过Lua脚本执行的,保证了原子性。

redisson设置了key默认的过期时间是30分钟,前面提到了一种业务场景,当业务执行时间超过了超时时间可以使用redisson来解决这个问题,redisson重置锁的过期时间过程如下:

569b0ef6544dcac3d08a658bffb08377.png

一般timer定时检测的时间是设置的锁的过期时间的三分之一。它会在你获取锁之后,每隔三分之一超时时间就会把锁的超时时间重置。这样当业务逻辑还没处理完之前,锁是不会过期的,并且如果机器宕机的话,则重置锁时长的timer定时检测也就消失了,锁最多再过一个超时时长也就释放了。我们可以看下它的实现代码:

// 加锁逻辑private  RFuture tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {    if (leaseTime != -1) {        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);    }    // 调用lua脚本,设置key的过期时间    RFuture ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);    ttlRemainingFuture.addListener(new FutureListener() {        @Override        public void operationComplete(Future future) throws Exception {            if (!future.isSuccess()) {                return;            }            Long ttlRemaining = future.getNow();            // lock acquired            if (ttlRemaining == null) {                // 定时重置锁超时时间逻辑                scheduleExpirationRenewal(threadId);            }        }    });    return ttlRemainingFuture;} RFuture tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command) {    internalLockLeaseTime = unit.toMillis(leaseTime);    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,              "if (redis.call('exists', KEYS[1]) == 0) then " +                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +                  "return nil; " +              "end; " +              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +                  "return nil; " +              "end; " +              "return redis.call('pttl', KEYS[1]);",                Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));}// 定时重置锁超时时间最终会调用了这里private void scheduleExpirationRenewal(final long threadId) {    if (expirationRenewalMap.containsKey(getEntryName())) {        return;    }    // 这个任务会延迟10s执行    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {        @Override        public void run(Timeout timeout) throws Exception {            // 这个操作会将key的过期时间重新设置为30s            RFuture future = renewExpirationAsync(threadId);            future.addListener(new FutureListener() {                @Override                public void operationComplete(Future future) throws Exception {                    expirationRenewalMap.remove(getEntryName());                    if (!future.isSuccess()) {                        log.error("Can't update lock " + getName() + " expiration", future.cause());                        return;                    }                    if (future.getNow()) {                        // reschedule itself                        // 通过递归调用本方法,无限循环延长过期时间                        scheduleExpirationRenewal(threadId);                    }                }            });        }    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);    if (expirationRenewalMap.putIfAbsent(getEntryName(), new ExpirationEntry(threadId, task)) != null) {        task.cancel();    }}

四、RedLock算法

除了要考虑分布式锁的实现方式,还要考虑实际redis是如何部署工作的。redis有3种部署方式:

  • 单机部署

  • redis sentinel哨兵模式

  • redis cluster集群模式

如果采用单机模式的redis实现分布式锁,会存在单点问题,只要redis挂掉了,分布式锁就没用了。如果采用redis sentinel哨兵模式,加锁的时候只对一个节点加锁,即使通过sentinel做了高可用,如果master节点挂掉,发生了主从切换,也可能发生锁丢失的情况。redis cluster集群模式也无法避免锁丢失,当slave节点还没同步到锁数据时,master节点挂点了,同样发生锁丢失情况。

基于以上原因,redis的作者提出了RedLock算法,主要思想是在多个集群节点的mster节点都去加相同的锁,锁的过期时间设置的较短,一般几十毫秒,当过半的master节点加锁成功,则认为整个加锁是成功的,要是加锁失败了,则mster节点一次删除这个锁。意思就是只要有一个master节点加了锁,所有mster节点都要不断轮询去尝试获取锁。

35c610e3a4476af9e1a5366cf68fbf13.png

这里采取的是最终一致性。redis的设计决定了数据并不是强一致性的,即使是redlock,在极端情况下,也不能保证所有master加锁的流程都正确。此外redis分布式锁的实现需要不断尝试获取锁,也是比较消耗性能的。

redisson也提供了对redlock算法的支持,用法也很简单:

RedissonClient redisson = Redisson.create(config);RLock lock1 = redisson.getFairLock("lock1");RLock lock2 = redisson.getFairLock("lock2");RLock lock3 = redisson.getFairLock("lock3");RedissonRedLock multiLock = new RedissonRedLock(lock1, lock2, lock3);multiLock.lock();multiLock.unlock();

五、分布式锁的设计思路

分布式锁的设计思路和线程同步锁ReentrantLock的思路是一样的。但是也需要考虑如以下几个问题:

  • 互斥性

  • 死锁情况

  • 可重入性

  • 容错性:锁永久有效问题

  • 加锁解锁同一客户端:锁永久失效问题

  • 锁的性能:数据库悲观锁

  • CAP:RedLock算法

接下来介绍下Jedis客户端工具如何实现正确的加锁与解锁,加深我们对Redis分布式锁的原理以及设计思路的理解。

正确的加锁姿势

public class RedisTool {       private static final String LOCK_SUCCESS = "OK";       private static final String SET_IF_NOT_EXIST = "NX";      private static final String SET_WITH_EXPIRE_TIME = "PX";       /**     * 尝试获取分布式锁     * @param jedis Redis客户端     * @param lockKey 锁     * @param requestId 请求标识     * @param expireTime 超期时间     * @return 是否获取成功          */    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);        if (LOCK_SUCCESS.equals(result)) {             return true;        }                return false;    }}

set方法加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁,不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。

正确的解锁姿势

public class RedisTool {   private static final Long RELEASE_SUCCESS = 1L;       /**     * @param jedis Redis客户端     * @param lockKey 锁     * @param requestId 请求标识     * @return 是否释放成功          */    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));        if (RELEASE_SUCCESS.equals(result)) {            return true;        }               return false;    }}

首先获取锁对应的value值,检查是否与requestId相等,如果相等则解锁。Lua脚本可以确保操作是原子性的。

六、思考

Redis主从架构锁失效的问题

Redis主从架构,主节点设置了锁,当锁还没同步到从节点时,主节点挂掉了,从节点成为了新的主节点,出现可能出现的问题是:

  • 一个线程在主节点加锁

  • 一个线程在新的主节点上加相同的锁,加相同的锁执行成功

这个问题可以使用zookeeper分布式锁来解决。

如何提升分布式锁的性能

比如秒杀场景,都在抢这100件商品,我们可以使用分段锁的思想,将这100件商品10个一组分成10组。则加锁逻辑如下:

9bd262cdf0a952075fe12769f953b8ba.png

推荐阅读

Redis开篇介绍

Redis数据结构与内部编码,你知道多少?

Redis Sentinel哨兵模式

Redis Cluster高可用集群模式

Redis缓存设计与优化

9e9c7393e5b469b5a0de1c17c7bcc4cf.png

看完本文有收获?请转发分享给更多人

关注「并发编程之美」,一起交流Java学习心得

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值