月夜星空-liulq

--愿美好接踵而至

    微服务架构已经成为了主流的技术架构,微服务架构对高并发的支持也已经成为了大家讨论的热点,这篇文章是我自己学习的体会和心得,不能代表任何主流方向。在这篇文章中将会由浅入深逐步学习高并发场景的解决方案。没有任何一种方案是可以应对所有的场景,本文也只是针对常见场景的分析说明,技术水平有限,写的不好还望海涵,欢迎大家多多指正。

1 单体服务基本逻辑

@Autowired
ProductService productService;

@RequestMapping(value = "test1", method = RequestMethod.GET)
public void salesProduct1(String productId, int count) {
    // 从数据库 查询商品信息,扣减库存
    ProductDTO productDTO = productService.getById(productId);
    if (productDTO.getStock() <= 0) {
        throw new RuntimeException("库存数量不足");
    }
    //开始扣减库存  更新数据库
    productDTO.setStock(productDTO.getStock() - count);
    productService.update(productDTO);
}

    看上面这段代码,很简单,就是请求进来之后,查询商品信息,如果商品库存为0则停止售卖,否则则库存减1,更新数据库。

  这段代码问题太多了,秒杀系统,肯定会有多个请求同时进来的情况,那就会出现多个请求都走了get方法之后发现库存还有剩余,然后进行如下操作,但是update时就会出现多卖的情况。

  第一时间想到的就是synchronized加锁,要知道synchronized锁的性能并不算很高,这种加锁是很难满足10万并发量的,并且如果是分布式多节点集群部署,那就更不行了。

2 sql控制,防止库存负数问题

 

@Override
public void update(ProductDTO productDTO) {
    // 更新数据库  增加库存数量大于0的条件
    // update product_stock set stock = productDTO.getStock() where id=1 and stock>0
}

    这个方式确实可以防止一部分数据库库存出现负数的情况,为什么说只能防止一部分呢,假设库存数量还有1,但是订单是2,那是不是还是会出现库存为负的情况。

 那我们换一种写法:

    update product_stock set stock = productDTO.getStock() where id=1 and productDTO.getStock()>0
    这确实保证不会出现负数,这相当于通过数据库乐观锁的方式实现的更新,性能并不会很高。

3 数据预热,引入redis

@RequestMapping(value = "salesProduct2", method = RequestMethod.GET)
public void salesProduct2(String productId, int count) {
    //相当于jedis.get("stock")
    int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
    int newStock = stock - count;
    if (newStock <= 0) {
        throw new RuntimeException("库存数量不足");
    }
    // 从redis 查询商品信息,扣减库存
    //相当于jedis.set("stock",String.valueOf(newStock))
    stringRedisTemplate.opsForValue().set("stock", String.valueOf(newStock));
    //创建订单
    SalesProductDTO salesProductDTO = new SalesProductDTO();
    salesProductService.SalesProduct(salesProductDTO);
}

    这段代码引入了redis,把库存数量缓存到了redis中,判断库存、扣减库存都是从redis完成的,大大提高了性能,但是这段代码还是有问题的,同样存在并发问题,有多个请求进来时,一起走了get方法,判断库存都还是有的,但是set(扣减)的时候,就会出现多减的情况。

  既然存在多个线程进来,出现了并发,那我们可以通过加锁方式(synchronized)来防止这个问题:

@RequestMapping(value = "salesProduct2", method = RequestMethod.GET)
    public void salesProduct2(String productId, int count) {
        synchronized (this) {
            //相当于jedis.get("stock")
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            int newStock = stock - count;
            if (newStock <= 0) {
                throw new RuntimeException("库存数量不足");
            }
            // 从redis 查询商品信息,扣减库存
            //相当于jedis.set("stock",String.valueOf(newStock))
            stringRedisTemplate.opsForValue().set("stock", String.valueOf(newStock));
            //创建订单
            SalesProductDTO salesProductDTO = new SalesProductDTO();
            salesProductService.SalesProduct(salesProductDTO);
        }
    }
}

    那么问题来了,我们都知道,现在都是分布式系统,都会负载均衡部署多个节点,那么synchronized就解决不了这个问题。

基础版分布式锁

    使用redis的分布式锁来解决这个问题,先说说redis的两个命令:set和setnx,这两个命令都是向redis中设置key-value,但是是有区别的:

Set mylock liuliqiang;

Set mylock liuliqiangNew;

    这两条命令执行之后,redis当中的mylock的值是liuliqiangNew,新值覆盖了原值;

Setnx mylock liuliqiang;

Setnx mylock liuliqiangNew;

    这两条命令执行之后mylock的值是liuliqiang,setnx只会针对不存在的key进行设置值,存在则不处理。

 

@RequestMapping(value = "salesProduct3", method = RequestMethod.GET)
public void salesProduct3(String productId, int count) {
    //分布式锁 key
    String lockKey = productId;
    // 相当于jedis.setnx(key,value)
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "liuliqiang");
    if(!result){
        throw new RuntimeException("error_code");
    }
    try{
        //相当于jedis.get("stock")
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        int newStock = stock - count;
        if (newStock <= 0) {
            throw new RuntimeException("库存数量不足");
        }
        // 从redis 查询商品信息,扣减库存
        //相当于jedis.set("stock",String.valueOf(newStock))
        stringRedisTemplate.opsForValue().set("stock", String.valueOf(newStock));
        
        //创建订单
        SalesProductDTO salesProductDTO = new SalesProductDTO();
        salesProductService.SalesProduct(salesProductDTO);
    }finally {
        //执行结束  删除锁
        stringRedisTemplate.delete(lockKey);
    }
}

    这个代码是否存在问题呢,肯定是存在的,虽然我们把删除锁的代码(delete(lockKey))写到了finally当中,但是我们还是不能确保这个锁真的会在使用之后被删除掉(比如程序宕机了),如果不删除,则下次请求进来之后还是无法获取到锁的。

5 设置分布式锁的超时时间

    上面的问题就是当程序异常结束时,redis中锁的key是不会删除的,那么我们可以通过设置超时时间来解决这个问题,当程序异常结束时,redis中的key超出超时时间后,自动删除这个key,这里需要这里redis的设置超时时间的命令:

exprie(lockkey,10,TimeUnit.SECONDS);//超过10秒则超时

  但是在上面的setIfAbsent下面加上这个exprie还是有问题的,这是两条命令,当在这两条命令中间程序异常了,或者exprie失败了,还是会无法让超时时间起到作用。

  Jedis提供了一个原子性的操作,让这两个操作具有原子性。

@RequestMapping(value = "salesProduct3", method = RequestMethod.GET)
    public void salesProduct3(String productId, int count) {
        //分布式锁 key
        String lockKey = productId;
//        // 相当于jedis.setnx(key,value)
//        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "liuliqiang");
//        // 设置超时时间
//        stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
        // jedis针对设置key-value和超时时间,提供了原子性操作
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "liuliqiang",10,TimeUnit.SECONDS);
        if (!result) {
            throw new RuntimeException("error_code");
        }
        try {
            //相当于jedis.get("stock")
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            int newStock = stock - count;
            if (newStock <= 0) {
                throw new RuntimeException("库存数量不足");
            }
            // 从redis 查询商品信息,扣减库存
            //相当于jedis.set("stock",String.valueOf(newStock))
            stringRedisTemplate.opsForValue().set("stock", String.valueOf(newStock));

            //创建订单
            SalesProductDTO salesProductDTO = new SalesProductDTO();
            salesProductService.SalesProduct(salesProductDTO);
        } finally {
            //执行结束  删除锁
            stringRedisTemplate.delete(lockKey);
        }
    }

    如果高并发不多的情况下,这段代码基本可以满足了需求,但是当做并发量高的情况,这段代码的问题就出现了。

 举个例子,我们线程1进来时设置了锁,超时时间10秒,但是由于某些原因,10秒之后线程1没有执行完成,但是锁已经达到了超时时间10秒了,自动删除,这时候线程2进来就可以抢夺到锁了,此时就出现了线程1和线程2同时在这段代码中执行,自然存在了并发问题。并且当线程2执行了5秒之后,线程1可能就执行完成了,线程1删除了线程2加的锁,线程3又可以进入了,这样这把锁就相当于永久失效了。

如何防止锁的误删除

    通过设置一个唯一的id,删除是判断当前锁的value是不是当前线程创建的id,来防止出现线程2加的锁被线程1误删除了。

@RequestMapping(value = "salesProduct4", method = RequestMethod.GET)
public void salesProduct4(String productId, int count) {
    //分布式锁 key
    String lockKey = productId;
    //针对每个线程创建唯一的uuid,作为redis存储的value,下面删除时,先判断是否为当前线程操作
    String value = UUID.randomUUID().toString();
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, 10, TimeUnit.SECONDS);
    if (!result) {
        throw new RuntimeException("error_code");
    }
    try {
        //相当于jedis.get("stock")
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        int newStock = stock - count;
        if (newStock <= 0) {
            throw new RuntimeException("库存数量不足");
        }
        // 从redis 查询商品信息,扣减库存
        //相当于jedis.set("stock",String.valueOf(newStock))
        stringRedisTemplate.opsForValue().set("stock", String.valueOf(newStock));

        //创建订单
        SalesProductDTO salesProductDTO = new SalesProductDTO();
        salesProductService.SalesProduct(salesProductDTO);
    } finally {
        //保证了线程2不会删除线程1加的锁
        if (value.equals(stringRedisTemplate.opsForValue().get(lockKey)))
            stringRedisTemplate.delete(lockKey);
    }
}

    上面这段代码看上去已经保证了锁不会被其他线程误删除了,但是还是有一定的问题的,仔细看下面这段代码,然后试想一下:

if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {

stringRedisTemplate.delete(lockKey);

}

    假如我们的线程1执行完了if之后,这时候刚巧是10s,锁自动删除了,这时候线程2就会进来进行加锁,那线程1执行delete时,是不是又把线程2的锁误删了呢。

7 看门狗

    简单来说,就是单独开一个线程,判断主线程是否还持有锁,如果持有的话,就对锁的超时时间进行重新设置为10秒即可。现在市场上已经有组件完成了我们的需求--redisson。

常用的redisson,redisson本身已经包含了这样的实现:

引入pom:

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

把redisson注入到bean容器当中:

@Configuration
public class RedissonConfig {
    @Bean
    public Redisson redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        return (Redisson)redisson().create(config);
    }
}

具体代码实现:

@RequestMapping(value = "salesProduct5", method = RequestMethod.GET)
public void salesProduct5(String productId, int count) {
    //分布式锁 key
    String lockKey = productId;
    RLock redissonLock = redisson.getLock(lockKey);
    try{
        //加锁
        redissonLock.lock();
        //相当于jedis.get("stock")
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        int newStock = stock - count;
        if (newStock <= 0) {
            throw new RuntimeException("库存数量不足");
        }
        // 从redis 查询商品信息,扣减库存
        //相当于jedis.set("stock",String.valueOf(newStock))
        stringRedisTemplate.opsForValue().set("stock", String.valueOf(newStock));

        //创建订单
        SalesProductDTO salesProductDTO = new SalesProductDTO();
        salesProductService.SalesProduct(salesProductDTO);
    }finally {
        redissonLock.unlock();
    }
}

    说一说redisson的底层实现逻辑,redisson.lock就相当于setnx,设置了加锁,如果加锁成功之后,后台开启一个线程,每隔10秒检查是否还持有锁(看门狗机制),如果持有则延长加锁的时间。当调用redisson.unlock时释放锁。

 线程1加锁之后,线程2会尝试加锁,如果加锁失败,就会一直执行while循环,尝试加锁(传说中的自旋)。

 

redisson加锁核心源码

    上图是redisson执行的逻辑,需要看懂,接下来简单说明一下redisson的核心代码,这篇文章主要要说的是秒杀系统的设计,至于redisson源码这里不会做太多的讲解,只需要知道原理及核心源码实现就可以了。

 只看核心的东西:

RLock rLock = redisson.getLock("myLock");
rLock.lock();

进入lock,逐层进入的逻辑是:

(1) Lock

(2) this.lockInterruptibly();

(3) Long ttl = this.tryAcquire(leaseTime, unit, threadId);

(4) (Long)this.get(this.tryAcquireAsync(leaseTime, unit, threadId));

(5) private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId)

  以下这段就是加锁的核心逻辑,我们可以看到,这个方法里面只有两行代码,第一行代码设置了超时时间,第二行代码执行了一段lua脚本,这段脚本的的大概意思就是判断key是否存在,不存在则插入到redis当中,并且pexpire(设置超时时间)。

  超时时间是多少呢,就是this.internalLockLeaseTime,进去看看,会发现在config类当中有一个默认的30秒,这里不进去看了。

  大家可能会有个疑问,这里面的lua脚本也是设置了锁,然后设置了锁的超时时间,为什么我们自己写的代码同样的设置就不行呢,这就是redis的原子性问题,redis会把这段lua脚本当做一个原子来执行,而我们自己写的会被认为是两个原子,所以不能保证一起的成功和失败。

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
// 设置超时时间
    this.internalLockLeaseTime = unit.toMillis(leaseTime);
// 执行了一段lua脚本
    return this.commandExecutor.evalWriteAsync(this.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(this.getName()), new Object[]{this.internalLockLeaseTime, this.getLockName(threadId)});
}

    到这里为止我们知道了redisson是如何设置锁的,并且知道了锁超时时间是如何设置的,但是还没有看到设置“看门狗”的地方,接着看。

  我们回过头到tryAcquireAsync方法当中看看:

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

                }
            }
        });
        return ttlRemainingFuture;
    }
}

    这段代码中主要看RFuture<Long> ttlRemainingFuture, 这里添加了一个监听器,执行了加锁之后会回调,然后执行 scheduleExpirationRenewal  方法,这个方法从名称就能看出来,就是定时恢复到期时间,接着看这个方法:

private void scheduleExpirationRenewal(final long threadId) {
    if (!expirationRenewalMap.containsKey(this.getEntryName())) {
        Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            public void run(Timeout timeout) throws Exception {
                RFuture<Boolean> future = RedissonLock.this.commandExecutor.evalWriteAsync(RedissonLock.this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(RedissonLock.this.getName()), new Object[]{RedissonLock.this.internalLockLeaseTime, RedissonLock.this.getLockName(threadId)});
                future.addListener(new FutureListener<Boolean>() {
                    public void operationComplete(Future<Boolean> future) throws Exception {
                        RedissonLock.expirationRenewalMap.remove(RedissonLock.this.getEntryName());
                        if (!future.isSuccess()) {
                            RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", future.cause());
                        } else {
                            if ((Boolean)future.getNow()) {
                                RedissonLock.this.scheduleExpirationRenewal(threadId);
                            }

                        }
                    }
                });
            }
        }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
        if (expirationRenewalMap.putIfAbsent(this.getEntryName(), task) != null) {
            task.cancel();
        }

    }
}

9 高并发的性能问题

  以上的方式已经可以解决大多数的并发场景了,但是还是会有一些问题的,我们创建分布式系统的核心思想就是可以处理高并发的数据,但是我们一旦使用了分布式锁,就相当于把原本并行的方式变成了串行,性能上肯定是会有一点影响的,当然,我们的redis本身新能是非常高的,redis采用单线程的方式,减少了不必要的上线文切换,不过我们还是希望追求更加完美一点的解决方案,所以想想有哪些可以优化的点呢。

1 锁粒度的控制

    尽量减少锁住的代码量,把不必要的逻辑都放到锁的外面实现;

2 分段锁机制

    先说个题外话,知道CurrentHashMap吗,知道的同学可能对分段锁这个词不会陌生,在向map中添加元素的时候,会计算HashCode,根据HashCode判断出当前元素应该添加的位置,然后只针对这一个位置所在的“一段”进行加锁,这就是分段锁的思想。

  我们在使用分布式锁的时候,为了提高性能,也可以使用这样的方式,我们上面把一个商品的100个库存都写入到了redis的一个key里面了,那所有的针对这个商品的请求进来都需要争抢一把锁,来操作这个商品的库存,如果我们把一个商品的库存写入不同的key里面呢,是不是就分撒了请求呢。

  我们可以把100个库存写入10个reids可以里面,请求进来按照某种规则,分散到不同的redis里面,其他的逻辑和上面的完全一致,这样不就可以解决更加高并发的问题了吗。这里面的规则完全可以自己定,比如说按照请求进来的毫秒数的末位分配等等。

10 MQ解决复杂业务逻辑

    上面已经就redis的分布式锁解决高并发的问题,进行了比较全面的讲解说明,但是实际的开发当中还会遇到各式各样的问题,这些都是需要根据实际的场景来做具体的分析解决,比如我上面的代码,一直留了一个坑:

 

    实际的开发当中,创建订单的业务应该会比较麻烦的,耗时也会比较多,如果把创建订单的逻辑写到这里,肯定会对性能产生很大的影响,那怎么做呢?我们可以把创建订单的逻辑交给其他的服务来做,向MQ中写入一条创建订单的消息,其他的服务监听这个MQ,然后进行业务处理,这样就可以简化了我们这个扣减库存服务的压力,大大提高了服务的处理性能,当然这样的处理方式可能对于客户体验会有一些影响,客户并不能马上知道自己的订单已经创建成功了,那怎么办呢,说一个方案吧,可以前端收到订单扣减服务返回的成功消息之后,隔一段时间去掉一下创建订单的服务,获取当前订单的创建状态,然后这期间前端可以采用进度条等方式来给用户一个友好的提示(当然,防止订单服务出现问题,可以自旋一段时间之后通知用户“系统繁忙,建议稍后重新查询等等”,然后等到订单创建成功之后可以给用户发送通知、短信等等)。

  Mq的方式把复杂的业务交给其他服务处理,可以提高我们服务的处理速度,但是mq也会有一些不安全性,比如说如何确保mq的100%投递、如何保证不被重复消费等等,这些都是mq已经考虑到的问题了,这里不做太多的说明,感兴趣的可以自己百度一下,方案很多,这里再说说一种极端的情况,mq服务器彻底宕机了,这样mq自己实现的任何方式都不再适用了,这种情况怎么办呢?

11 MQ停止工作的处理方案

    一旦mq宕机了,就会出现问题了,我们已经扣减了库存,但是mq没收到创建订单的消息,订单服务自然也不会创建订单了,可以写入mq之前记录一条mq日志,写入数据库或者redis当中,日志主要包括的内容:发送MQ的消息体、写入的时间、订单的状态(写入时是待处理、等到创建完订单之后更新为已创建),再有一个额外的服务,定时执行,比如每隔一段时间执行一次,看数据库或者redis中的日志,是否有“待处理”的订单,如果存在则看这条日志的写入时间,超过一定时间之后(具体的时间间隔应该看实际业务处理能力),并且读取MQ中的队列,如果读取异常或者读取到的队列中不包含我们这条订单信息,则说明某种原因导致了这条订单不会被创建了,但是前面的库存已经被扣减了,所以这里需要把多扣减的库存加回来。

 

    当然了,有些同学可能还会担心,我们的定时任务服务查询待处理的订单,并把没处理的订单多扣减的库存加回去,这个过程是存在问题的,加入定时任务服务查询订单时刚巧有一个订单服务正在处理这个订单呢,我们的定时任务服务把库存加上了,但是后续实际的订单又创建成功了。这个问题确实存在的,这个就要看实际的业务需要了,如果业务上对库存的多减问题不太关注,那么完全不需要这个定时任务服务,只需要记录上发送MQ的日志即可,稍后由程序员手动维护数据即可。说到这里再多说一句吧,其实没有任何一种方案可以再到百分百的满足需求的,具体的还是要看实际的业务场景,这篇文章我所说的也就是一些基本常见方案。

 

posted on 2023-04-16 20:09  月夜星空-liulq  阅读(823)  评论(0)    收藏  举报