04-redis分布式锁实战

一、分布式锁的使用场景

1.1 场景

  • 互联网秒杀
  • 抢优惠券
  • 接口幂等性校验

1.2 分布式锁的特性

1、互斥性:在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁;
2、高可用性:在分布式场景下,一小部分服务器宕机不影响正常使用,这种情况就需要将提供分布式锁的服务以集群的方式部署;
3、防止锁超时:如果客户端没有主动释放锁,服务器会在一段时间之后自动释放锁,防止客户端宕机或者网络不可达时产生死锁;
4、独占性:加锁解锁必须由同一台服务器进行,也就是锁的持有者才可以释放锁,不能出现你加的锁,别人给你解锁了;

二、实战

2.1 初始版本

package com.zengqingfa.springboot.mybatis.demo.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.TimeUnit;

@RestController
public class IndexController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock()   {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        return "end";
    }

}

这种情况下如果有两个线程同时进来,会出现超卖,日志打印结果如下:
image.png

@RequestMapping("/deduct_stock")
public String deductStock()   {
    synchronized (this){
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
            System.out.println("扣减失败,库存不足");
        }
    }
    return "end";
}

如果是分布式的情况下,synchronized关键字是无效的。
搭建如下的架构,来验证synchronized是无效的,只是jvm进程级别的锁

F:\software\Nginx\nginx-1.11.1>start nginx

F:\software\Nginx\nginx-1.11.1>nginx -v
nginx version: nginx/1.11.1

F:\software\Nginx\nginx-1.11.1>tasklist /fi "imagename eq nginx.exe"

映像名称                       PID 会话名              会话#       内存使用
========================= ======== ================ =========== ============
nginx.exe                    57040 Console                    1      8,136 K
nginx.exe                    56612 Console                    1      8,576 K

//重新加载配置文件
F:\software\Nginx\nginx-1.11.1>nginx.exe -s reload  

//停止nginx
F:\software\Nginx\nginx-1.11.1>nginx.exe -s stop

配置负载均衡规则,F:\software\Nginx\nginx-1.11.1\conf\nginx.conf

  server 
	 { 
        listen       80; 
        server_name  localhost; 
 
	  proxy_set_header X-Forwarded-Host $host; 
			proxy_set_header X-Forwarded-Server $host; 
			proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 
	  proxy_set_header Host $host; 
	   
	  location / { 
			   proxy_pass http://redislock; 
			   proxy_connect_timeout 600; 
			   proxy_read_timeout 600; 
			}  
	} 
	
	upstream redislock { 
			  server   127.0.0.1:8085 weight=1; 
			  server   127.0.0.1:8086 weight=1; 
	} 

开启jemter压测工具,压测1000个请求
F:\software\apache-jmeter-5.0\bin\jmeter.bat
image.png

@RequestMapping("/deduct_stock")
public String deductStock() {
    String lockKey = "lockKey";
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "shenlongfeixian");
    if (!result) {
        return "稍后重试";
    }
    int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
    if (stock > 0) {
        int realStock = stock - 1;
        stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
        System.out.println("扣减成功,剩余库存:" + realStock + "");
    } else {
        System.out.println("扣减失败,库存不足");
    }
    stringRedisTemplate.delete(lockKey);
    return "end";
}

代码如果抛出异常,stringRedisTemplate.delete(lockKey)执行不到,key一直存在redis中,会出现死锁
改进版本2:

@RequestMapping("/deduct_stock")
public String deductStock() {
    String lockKey = "lockKey";
    try {
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "shenlongfeixian");
        if (!result) {
            return "稍后重试";
        }
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
            System.out.println("扣减失败,库存不足");
        }
    } finally {
        stringRedisTemplate.delete(lockKey);
    }
    return "end";
}

如果代码并非执行过程的抛出异常,而且服务突然的在执行到某一行后服务宕机,代码依然会执行不到。
改进版本3:增加超时时间

   @RequestMapping("/deduct_stock")
    public String deductStock() {
        String lockKey = "lockKey";
        try {
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "shenlongfeixian");
            //增加超时时间
            stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
            if (!result) {
                return "稍后重试";
            }
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }

如果宕机发生的情况在设置key和设置key的超时时间之间呢?
改进版本4:redis中提供了设置key和设置key的超时时间是一条原子命令
org.springframework.data.redis.core.ValueOperations#setIfAbsent(K, V, long, java.util.concurrent.TimeUnit)


	/**
	 * Set {@code key} to hold the string {@code value} and expiration {@code timeout} if {@code key} is absent.
	 *
	 * @param key must not be {@literal null}.
	 * @param value must not be {@literal null}.
	 * @param timeout the key expiration timeout.
	 * @param unit must not be {@literal null}.
	 * @return {@literal null} when used in pipeline / transaction.
	 * @since 2.1
	 * @see <a href="https://redis.io/commands/set">Redis Documentation: SET</a>
	 */
	@Nullable
	Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit);

改进代码如下:

@RequestMapping("/deduct_stock")
public String deductStock() {
    String lockKey = "lockKey";
    try {
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "shenlongfeixian", 10, TimeUnit.SECONDS);
        if (!result) {
            return "稍后重试";
        }
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
            System.out.println("扣减失败,库存不足");
        }
    } finally {
        stringRedisTemplate.delete(lockKey);
    }
    return "end";
}

锁失效问题:假设业务执行15s,锁失效的时间是10s,在线程1还没执行完,超过10s,锁失效,线程2可以加锁成功的。线程1执行结束的时候,把线程2加的锁删除了。后续第二个线程把第三个线程的锁释放了,如果高并发的情况下一直持续,锁会一直失效。
改进版本5:自己的加的锁自己释放

@RequestMapping("/deduct_stock")
public String deductStock() {
    String lockKey = "lockKey";
    String value = UUID.randomUUID().toString();
    try {
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, 30, TimeUnit.SECONDS);
        if (!result) {
            return "稍后重试";
        }
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
            System.out.println("扣减失败,库存不足");
        }
    } finally {
        if (value.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
            stringRedisTemplate.delete(lockKey);
        }
    }
    return "end";
}

如果在获取value.equals(stringRedisTemplate.opsForValue().get(lockKey))的时候宕机呢?最多10s的超时时间,锁才会释放。
如果要完全实现100%的没问题,需要考虑的成本很高,代价也很高。
锁超时的问题:锁10s,但是业务代码是执行15s,业务代码没执行完,锁已经超时了
锁的超时时间不是很好设置,设置任何一个值都不是很合适。业务的执行时间不可预估。
锁续命:key的超时时间设置30s,启动一个定时器,每过10s,判断一下当前线程加的这个锁是否存在,如果存在,则给这个锁续命30s。如果锁不存在,下一个定时不执行。

2.2 其他开源实现

redission

github地址:https://github.com/redisson/redisson
引入依赖

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

注入

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient getClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}

使用分布式锁

@RequestMapping("/deduct_stock2")
public String deductStock2() throws InterruptedException {
    String lockKey = "product_001";
    RLock redissonLock = redisson.getLock(lockKey);
    try {
        // 加锁,实现锁续命功能
        redissonLock.lock();
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
            System.out.println("扣减失败,库存不足");
        }
    } finally {
        redissonLock.unlock();
    }
    return "end";
}

redission分布式锁实现原理

    private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
        if (leaseTime != -1) {
            return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        }
        RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        ttlRemainingFuture.addListener(new FutureListener<Long>() {
            @Override
            public void operationComplete(Future<Long> future) throws Exception {
                if (!future.isSuccess()) {
                    return;
                }

                Long ttlRemaining = future.getNow();
                // lock acquired
                if (ttlRemaining == null) {
                    scheduleExpirationRenewal(threadId);
                }
            }
        });
        return ttlRemainingFuture;
    }

    <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);
		//KEYS[1]	Collections.<Object>singletonList(getName())
        //ARGV[1]	internalLockLeaseTime
        //ARGV[2]	getLockName(threadId)
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  //判断key是否存在                           
                  "if (redis.call('exists', KEYS[1]) == 0) then " + 
                  // 如果不存在,使用hash结构存储,大key,小key为线程id,value为                        
                      "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                   // 设置key的过期时间                            
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  // 判断key是否是当前线程加的锁                              
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    // 如果是,增加1,支持锁重入                          
                      "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.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }

	//定时任务续约
    private void scheduleExpirationRenewal(final long threadId) {
        if (expirationRenewalMap.containsKey(getEntryName())) {
            return;
        }

        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                
                RFuture<Boolean> future = renewExpirationAsync(threadId);
                
                future.addListener(new FutureListener<Boolean>() {
                    @Override
                    public void operationComplete(Future<Boolean> 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();
        }
    }

    protected RFuture<Boolean> renewExpirationAsync(long threadId) {
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
              //判断key是是否是当前主线程的id,如果是的话,进行续约internalLockLeaseTime
                "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                "end; " +
                "return 0;",
            Collections.<Object>singletonList(getName()), 
            internalLockLeaseTime, getLockName(threadId));
    }

getName()中获取的name的值是传入的lockKey: RLock redissonLock = redisson.getLock(lockKey);
lockKey最后会赋值给name变量

public abstract class RedissonObject implements RObject {

    protected final CommandAsyncExecutor commandExecutor;
    private final String name;
    protected final Codec codec;

    public RedissonObject(Codec codec, CommandAsyncExecutor commandExecutor, String name) {
        this.codec = codec;
        this.name = name;
        this.commandExecutor = commandExecutor;
    }
    ...
      @Override
    public String getName() {
        return name;
    }
    ...
}

internalLockLeaseTime:默认是30s
入参是通过:commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()方法传递的
org.redisson.config.Config#Config(org.redisson.config.Config),构造函数中有设置值的操作

 private long lockWatchdogTimeout = 30 * 1000;

 public long getLockWatchdogTimeout() {
      return lockWatchdogTimeout;
 }

getLockName(threadId):org.redisson.RedissonLock#getLockName,线程的名称

    protected String getLockName(long threadId) {
        return id + ":" + threadId;
    }

其他线程如果加锁失败,会自旋操作:
org.redisson.RedissonLock#lockInterruptibly(long, java.util.concurrent.TimeUnit)

@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // lock acquired 如果加锁成功,直接返回,如果是其他线程加锁失败,ttl会返回剩余的时间
    if (ttl == null) {
        return;
    }

    RFuture<RedissonLockEntry> future = subscribe(threadId);
    commandExecutor.syncSubscription(future);

    try {
        while (true) {
            //尝试自旋加锁
            ttl = tryAcquire(leaseTime, unit, threadId);
            // lock acquired
            if (ttl == null) {
                break;
            }

            // waiting for message
            if (ttl >= 0) {
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                getEntry(threadId).getLatch().acquire();
            }
        }
    } finally {
        unsubscribe(future, threadId);
    }
    //        get(lockAsync(leaseTime, unit));
}

2.3 redis分布式锁存在的问题

1)单点实例安全问题

因为redis使用的是主从架构,存在同步的问题,master宕机,但是没有同步到slave,哨兵架构会把slave升级为master,此时其他线程会可以加锁成功,相当于线程1和线程2同时拥有了锁。怎么解决?
可以使用zk的强一致性分布式架构,如果使用redis,可以容忍一部分这样的小bug。
为什么不使用zk?zk的性能没有redis性能高,没有必要为了分布式锁引入zk。
其他方案:RedLock解决主从失效的问题,原理类似Zookeeper

    @RequestMapping("/redlock")
    public String redlock() throws InterruptedException {
        String lockKey = "product_001";
        //这里需要自己实例化不同redis实例的redisson客户端连接,这里只是伪代码用一个redisson客户端简化了
        RLock lock1 = redisson.getLock(lockKey);
        RLock lock2 = redisson.getLock(lockKey);
        RLock lock3 = redisson.getLock(lockKey);

        /**
         * 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
         */
        RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
        try {
            /**
             * 4.尝试获取锁
             * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
             * leaseTime   锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
             */
            boolean res = redLock.tryLock(10, 30, TimeUnit.SECONDS);
            if (res) {
                //成功获得锁,在这里处理业务
            }
        } catch (Exception e) {
            throw new RuntimeException("lock fail");
        } finally {
            //无论如何, 最后都要解锁
            redLock.unlock();
        }

        return "end";
    }

RedLock存在性能问题,如果存在网络问题,还涉及到事务回滚问题,存在很多的问题,不推荐使用。
性能和安全不能完全兼顾,如果你一定要保证锁的安全性的话,可以用其他的中间件如db、zookeeper来做控制

2)客户端长时间阻塞导致锁失效问题

客户端1得到了锁,因为网络问题或者GC等原因导致长时间阻塞,然后业务程序还没执行完锁就过期了,这时候客户端2也能正常拿到锁,可能会导致线程安全的问题。
image.png

3)redis服务器时钟漂移问题

如果redis服务器的机器时钟发生了向前跳跃,就会导致这个key过早超时失效,比如说客户端1拿到锁后,key的过期时间是12:02分,但redis服务器本身的时钟比客户端快了2分钟,导致key在12:00的时候就失效了,这时候,如果客户端1还没有释放锁的话,就可能导致多个客户端同时持有同一把锁的问题。
基础设施和运维保证时间的正确

2.4 高并发分布式锁如何实现?

可以分段,采取类似ConcurrentHashMap的分段锁实现
举例:分段库存锁,通过hash的手段,hash到不同的节点

eper来做控制

2)客户端长时间阻塞导致锁失效问题

客户端1得到了锁,因为网络问题或者GC等原因导致长时间阻塞,然后业务程序还没执行完锁就过期了,这时候客户端2也能正常拿到锁,可能会导致线程安全的问题。
[外链图片转存中…(img-oB8LeL6t-1666506764455)]

3)redis服务器时钟漂移问题

如果redis服务器的机器时钟发生了向前跳跃,就会导致这个key过早超时失效,比如说客户端1拿到锁后,key的过期时间是12:02分,但redis服务器本身的时钟比客户端快了2分钟,导致key在12:00的时候就失效了,这时候,如果客户端1还没有释放锁的话,就可能导致多个客户端同时持有同一把锁的问题。
基础设施和运维保证时间的正确

2.4 高并发分布式锁如何实现?

可以分段,采取类似ConcurrentHashMap的分段锁实现
举例:分段库存锁,通过hash的手段,hash到不同的节点

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值