5 Redis学习(Redisson分布式锁实现)

在这里插入图片描述
概述
分布式系统有一个著名的理论CAP,指在一个分布式系统中,最多只能同时满足:
一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。
所以在设计系统时,往往需要权衡,在CAP中作选择。当然,这个理论也并不一定完美,不同系统对CAP的要求级别不一样,选择需要考虑方方面面。
在微服务系统中,一个请求存在多级跨服务调用,往往需要牺牲强一致性保证系统高可用,比如通过分布式事务,异步消息等手段完成。但还是有的场景,需要阻塞所有节点的所有线程,对共享资源的访问。比如并发时“超卖”和“余额减为负数”等情况。
本地锁可以通过语言本身支持,要实现分布式锁,就必须依赖中间件,数据库、redis、zookeeper等。
分布式锁特性
不管使用什么中间件,有几点是实现分布式锁必须要考虑到的。

  1. 互斥:互斥好像是必须的,否则怎么叫锁。
  2. 死锁: 如果一个线程获得锁,然后挂了,并没有释放锁,致使其他节点(线程)永远无法获取锁,这就是死锁。分布式锁必须做到避免死锁。
  3. 性能: 高并发分布式系统中,线程互斥等待会成为性能瓶颈,需要好的中间件和实现来保证性能。
  4. 锁特性:考虑到复杂的场景,分布式锁不能只是加锁,然后一直等待。最好实现如Java Lock的一些功能如:锁判断,超时设置,可重入性等。
    Redis实现之Redisson原理
    redission实现了JDK中的Lock接口,所以使用方式一样,只是Redssion的锁是分布式的。如下:
RLock lock = redisson.getLock("className"); 
lock.lock(); 
try {
    // do sth.
} finally {
    lock.unlock(); 
}

好,Lock主要实现是RedissionLock。
RedissionLock
先来看常用的Lock方法实现。

@Override
public void lock() {
    try {
        lockInterruptibly();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}
@Override
public void lockInterruptibly() throws InterruptedException {
    lockInterruptibly(-1, null);
}

再看lockInterruptibly方法:

@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    // 获取锁
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    if (ttl == null) { // 获取成功
        return;
    }
    // 异步订阅redis chennel
    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);// 取消订阅
    }
}

总结lockInterruptibly:获取锁,不成功则订阅释放锁的消息,获得消息前阻塞。得到释放通知后再去循环获取锁。
下面重点看看如何获取锁:Long ttl = tryAcquire(leaseTime, unit, threadId)

 private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    return get(tryAcquireAsync(leaseTime, unit, threadId));// 通过异步获取锁,但get(future)实现同步
}

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    if (leaseTime != -1) { //1 如果设置了超时时间,直接调用 tryLockInnerAsync
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    //2 如果leaseTime==-1,则默认超时时间为30s
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS, TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG);
    //3 监听Future,获取Future返回值ttlRemaining(剩余超时时间),获取锁成功,但是ttlRemaining,则刷新过期时间
    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;
}

已经在注释中解释了,需要注意的是,此处用到了Netty的Future-listen模型。
下面就是最重要的redis获取锁的方法tryLockInnerAsync:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> 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.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

这个方法主要就是调用redis执行eval lua,为什么使用eval,因为redis对lua脚本执行具有原子性。把这个方法翻译一下:

-- 1.  没被锁{key不存在}
eval "return redis.call('exists', KEYS[1])" 1 myLock
-- (1) 设置Lock为key,uuid:threadId为filed, filed值为1
eval "return redis.call('hset', KEYS[1], ARGV[2], 1)" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- (2) 设置key过期时间{防止获取锁后线程挂掉导致死锁}
eval "return redis.call('pexpire', KEYS[1], ARGV[1])" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 2. 已经被同线程获得锁{key存在并且field存在}
eval "return redis.call('hexists', KEYS[1], ARGV[2])" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- (1) 可重入,但filed字段+1
eval "return redis.call('hincrby', KEYS[1], ARGV[2],1)" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- (2) 刷新过去时间
eval "return redis.call('pexpire', KEYS[1], ARGV[1])" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 3. 已经被其他线程锁住{key存在,但是field不存在}:以毫秒为单位返回 key 的剩余超时时间
eval "return redis.call('pttl', KEYS[1])" 1 myLock

这就是核心获取锁的方式,下面直接释放锁方法unlockInnerAsync:

-- 1. key不存在
eval "return redis.call('exists', KEYS[1])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- (1) 发送释放锁的消息,返回1,释放成功
eval "return redis.call('publish', KEYS[2], ARGV[1])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 2. key存在,但field不存在,说明自己不是锁持有者,无权释放,直接return nil
eval "return redis.call('hexists', KEYS[1], ARGV[3])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
eval "return nil"
-- 3. filed存在,说明是本线程在锁,但有可能其他地方重入锁,不能直接释放,应该-1
eval "return redis.call('hincrby', KEYS[1], ARGV[3],-1)" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 4. 如果减1后大于0,说明还有其他重入锁,刷新过期时间,返回0。
eval "return redis.call('pexpire', KEYS[1], ARGV[2])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 5. 如果不大于0,说明最后一把锁,需要释放
-- 删除key
eval "return redis.call('del', KEYS[1])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 发释放消息
eval "return redis.call('publish', KEYS[2], ARGV[1])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 返回1,释放成功

从释放锁代码中看到,删除key后会发送消息,所以上文提到获取锁失败后,阻塞订阅此消息。
另外,上文提到刷新过期时间方法scheduleExpirationRenewal,指线程获取锁后需要不断刷新失效时间,避免未执行完锁就失效。这个方法的实现原理也类似,只是使用了Netty的TimerTask,每到过期时间1/3就去重新刷一次,如果key不存在则停止刷新。Timer实现大概如下:

private static void nettyTimer() {
    final int expireTime = 6;
    EventExecutorGroup group = new DefaultEventExecutorGroup(1);
    final Timer timer = new HashedWheelTimer();
    timer.newTimeout(timerTask -> {
        Future<Boolean> future = group.submit(() -> {
            System.out.println("刷新key的失效时间为"+expireTime +"秒");
            return false;// 但key不存在时,返回true
        });
        future.addListener(future1 -> {
            if (!future.getNow()) {
                nettyTimer();
            }
        });
    }, expireTime/3, TimeUnit.SECONDS);
}

Springboot分别使用乐观锁和分布式锁(基于redisson)完成高并发防超卖

在电商中经常会有防超卖的需求,本质上是对一条数据的多线程并发情况下的数据安全性进行控制。
譬如一个商品goods,库存是100,在多线程都去读取修改的情况下,会产生数据错乱。
不加锁的情况
我们来看一个简单的例子,有个goods表,里面有个int型字段amount。我们用多线程来频繁修改amount的值,看看结果。

 @Transactional(rollbackFor = Exception.class)
    public void mult(Long goodsId) {
        PtGoods ptGoods = ptGoodsManager.find(goodsId);
        System.out.println(ptGoods.getAmount());
        ptGoods.setAmount(ptGoods.getAmount() + 1);
        ptGoodsManager.update(ptGoods);
    }

这是操作修改amount字段的类GoodsService的代码,就是每次调用给amount的值加1.
下面是测试类,用100个线程来模拟同时操作该商品的数量:

for (int i = 0; i < 100; i++) {
	new Thread(() -> {
    	goodsService.mult(1L);
    }
    ).start();
}

运行后,我们去查看amount的最终的值,发现是10(每次调用都不一样)。正常情况下,被100次调用后应该是100才对,说明在并发下,出现了数据错误,原因大家都懂不细说。
解决方案
我们为了不让商品出现超卖,必然需要对上面的情况进行处理,方式也有很多种,基本都是锁。
来简单探讨一下几种情况下的处理。
1 最简单的是单体应用,也就是你的程序只部署了一份,不是分布式负载均衡部署的,也没有其他程序来修改你的数据。那么在天真的同学,可能会想到在修改数据的地方加个synchronized。锁住整个方法,线程自然老实排队通过该方法。
在这里插入图片描述
这样会有效吗?答案当然是否定的,可以看到,方法中大量重复读取了修改前的数据库值,即便线程是一个一个执行的,但是db依然返回了修改前的值。原因也很简单,jpa在执行save方法后,方法已经走完,就会进入下一个线程的逻辑,但此时,数据库并没有更新成功。jpa操作db,也是通过线程池连接的,执行了save,修改了表的值,save走完了,db未必瞬时更新,这是要时间的。与此同时,另一个查询请求早已到来,可能因为jpa的二级缓存、或者就是查询时,db的值还未修改成功。无论哪种都会导致读取到脏数据。
在这里插入图片描述
2 既然线程锁不能起到有效的作用,大家可能意识到问题点在于数据库的读写不一致。这就诞生了另外一套方案,即数据库上的锁——悲观锁和乐观锁
悲观锁可以做到我读取时,就把该条数据锁住,其他人不允许读写,我做完我的事务后,别人才可以读写。
乐观锁可以做到,在我写入时,会再次查询最新的值,之后对比一下我读取时的版本,倘若最新的版本和我读取的不一致,那我就不写入,并抛异常。
jpa做悲观锁很简单,在Repository的方法上,加上Lock注解,PESSIMISTIC_READ,PESSIMISTIC_WRITE比较常用。具体的不细谈,因为我在做微服务时,不赞同用任何数据库级别的方式,尤其是悲观锁,使用不当易造成表锁,会严重影响其他业务的读写。
乐观锁也很简单,只需在model类里加上@Version注解的一个字段即可。譬如我在Goods类上加个
@Version private Long version;即可实现乐观锁。倘若该表的读多写少,发生冲突的概率不高,那么避免并发读写错误可以选择比较简单的乐观锁。
3 分布式锁,
就是借助于redis或者zookeeper来完成的在分布式环境下的锁,能够使用于分布式环境和单实例环境,而无需对数据库做任何要求,无需关心使用的是mysql还是MongoDB等任何数据库。也就是完全基于应用层面的对并发读写进行的控制,也是比较推荐的实现方式。
在这里插入图片描述
乐观锁就是在修改时,带上version版本号。这样如果试图修改已被别人修改过的数据时,会抛出异常。在一定程度上,也可以作为防超卖的一种处理方法。我们来看一下。
我们在Goods的entity类上,加上这个字段。

@Version
    private Long version;
@Transactional
    public synchronized void mult(Long goodsId) {
        PtGoods ptGoods = ptGoodsManager.find(goodsId);
        logger.info("----amount:" + ptGoods.getAmount());
        ptGoods.setAmount(ptGoods.getAmount() + 1);
        ptGoodsManager.update(ptGoods);
    }

测试一下:

for (int i = 0; i < 100; i++) {
	new Thread(() -> {
    	goodsService.mult(1L);
    }).start();
}

可以发现,抛出了很多异常,这就是乐观锁的异常。可想而知,当高并发购买同一个商品时,会出现大量的购买失败,而不会出现超卖的情况,因为他限制了并发的访问修改。
在这里插入图片描述
这样其实显而易见,也是大有问题的,只适应于读多写少的情况,否则大量的失败也是有损用户体验,明明有货,却不卖出。
那怎么解决呢,有人提出了重试策略。托若出现了上面的异常,就去重试执行该方法,直到成功。我们来看看能不能有用。
定义个自定义注解

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
 * @author wuweifeng wrote on 2019/5/8.
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RetryOnFailure {
}

定义个切面,注意把order设置的小一点,让该切面优先于Transactional注解,这样才能优先被捕捉乐观锁异常。

@Aspect
@Component
@Order(1)
public class RetryAspect {
    public static final int MAX_RETRY_TIMES = 10;//max retry times
    private Logger log = LoggerFactory.getLogger(getClass());
    @Around("@annotation(retryOnFailure)")
    public Object doConcurrentOperation(ProceedingJoinPoint pjp, RetryOnFailure retryOnFailure) throws Throwable {
        int attempts = 0;
        do {
            attempts++;
            try {
                pjp.proceed();
            } catch (Exception e) {
                if (e instanceof ObjectOptimisticLockingFailureException ||
                        e instanceof StaleObjectStateException) {
                    log.info("retrying....times:{}", attempts);
                    if (attempts > MAX_RETRY_TIMES) {
                        log.info("retry excceed the max times..");
                        throw e;
                    }
                }
            }
        } while (attempts < MAX_RETRY_TIMES);
        return null;
    }
}

在这里插入图片描述
从结果上看,进行了大量的重试,最终原本该加到100的amount字段,值已经到200多了。出现了严重的并发修改值错误。原因我也没细想,因为根本没打算采用这种方式。首先重试策略肯定是比较差的方式,因为非常不可控。其次,靠借助于数据库的锁来抛异常然后做处理,也是不太好的方式,应当尽量将问题控制在DB以外,由业务代码来控制。

分布式锁

通过上面的一系列操作,我们可以看到,通过单体应用自身的代码是控制不住这种情况的。此时就需要借助于一个第三方框架,能够提供无论是多线程或者分布式环境下的具备原子性的组件。比较常用的就是redis和zookeeper了。
以redis为例,这里选用功能比较丰富强大的redis客户端redisson来完成,https://github.com/redisson/redisson。
redisson具体的可以去上GitHub看他文档,我这里直接来讲怎么用了。
pom里加入依赖

<dependency>
	<groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.10.6</version>
</dependency>

redisson支持单点、集群等模式,这里选择单点的。application.yml配置好redis的连接:

spring:  
    redis:
        host: ${REDIS_HOST:127.0.0.1}
        port: ${REDIS_PORT:6379}
        password: ${REDIS_PASSWORD:}

配置redisson的客户端bean

@Configuration
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String host;
 
    @Bean(name = {"redisTemplate", "stringRedisTemplate"})
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        StringRedisTemplate redisTemplate = new StringRedisTemplate();
        redisTemplate.setConnectionFactory(factory);
        return redisTemplate;
    }
 
    @Bean
    public Redisson redisson() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + host + ":6379");
        return (Redisson) Redisson.create(config);
    }
}

至于使用redisson的功能也很少,其实就是对并发访问的方法加个锁即可,方法执行完后释放锁。这样下一个请求才能进入到该方法。
我们创建一个redis锁的注解

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author wuweifeng wrote on 2019/5/8.
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLock {
    /**
     * 要锁哪个参数
     */
    int lockIndex() default -1
    /**
     * 锁多久后自动释放(单位:秒)
     */
    int leaseTime() default 10;
}

切面类:

import com.tianyalei.giftmall.global.annotation.RedissonLock;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
 
/**
 * 分布式锁
 * @author wuweifeng wrote on 2019/5/8.
 */
@Aspect
@Component
@Order(1) //该order必须设置,很关键
public class RedissonLockAspect {
    private Logger log = LoggerFactory.getLogger(getClass());
    @Resource
    private Redisson redisson;
 
    @Around("@annotation(redissonLock)")
    public Object around(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) throws Throwable {
        Object obj = null;
        //方法内的所有参数
        Object[] params = joinPoint.getArgs();
        int lockIndex = redissonLock.lockIndex();
        //取得方法名
        String key = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint
                .getSignature().getName();
        //-1代表锁整个方法,而非具体锁哪条数据
        if (lockIndex != -1) {
            key += params[lockIndex];
        }
        //多久会自动释放,默认10秒
        int leaseTime = redissonLock.leaseTime();
        int waitTime = 5;
        RLock rLock = redisson.getLock(key);
        boolean res = rLock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
        if (res) {
            log.info("取到锁");
            obj = joinPoint.proceed();
            rLock.unlock();
            log.info("释放锁");
        } else {
            log.info("----------nono----------");
            throw new RuntimeException("没有获得锁");
        }
        return obj;
    }
}

这里解释一下,防超卖,其实是对某一个商品在被修改时进行加锁,而这个时候其他的商品是不受影响的。所以不能去锁整个方法,而应该是锁某个商品。所以我设置了一个lockIndex的参数,来指明你要锁的是方法的哪个属性,这里就是锁goodsId,如果不写,则是锁整个方法。
在这里插入图片描述
在切面里里面RLock.tryLock,则是最多等待5秒,托若还没取到锁就走失败,取到了则进入方法走逻辑。第二个参数是自动释放锁的时间,以避免自己刚取到锁,就挂掉了,导致锁无法释放。

测试类:

package com.tianyalei.giftmall;
 
import com.tianyalei.giftmall.core.goods.GoodsService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import javax.annotation.Resource;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
 
@RunWith(SpringRunner.class)
@SpringBootTest
public class GiftmallApplicationTests {
    @Resource
    private GoodsService goodsService;
    private CyclicBarrier cyclicBarrier = new CyclicBarrier(100);
    private CyclicBarrier cyclicBarrier1 = new CyclicBarrier(100);
 
    @Test
    public void contextLoads() {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                try {
                    cyclicBarrier.await();
                    goodsService.multi(1L);
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }
            ).start();
            new Thread(() -> {
                try {
                    cyclicBarrier1.await();
                    goodsService.multi(2L);
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }
            ).start();
        }
        try {
            Thread.sleep(6000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

这里用100并发,同时操作2个商品。
在这里插入图片描述
可以看到,这两个商品在各自更新各自的,互不影响。最终在5秒后,有的超时了。调大等待时间,则能保证每个都是100.
通过这种方式,即完成了分布式锁,简单也便捷。当然这里只是举例,在实际项目中,倘若要做防止超卖,以追求最大性能的话,也可以考虑使用redis来存储amount,借助于redis的increase来做数量的增减,能迅速的给出客户端是否抢到了商品的判断,之后再通过消息队列去生成订单之类的耗时操作。

发布了9 篇原创文章 · 获赞 1 · 访问量 72

猜你喜欢

转载自blog.csdn.net/weixin_42937710/article/details/105635359