【一】做一个秒杀系统【高并发减库存】

前言

疫情期间,闲来无事,空闲时间利用起来,秒杀系统走起。

秒杀业务流程

要做秒杀系统,先弄明白具体业务流程。

用户点击秒杀按钮、跳转到订单页面、填写好订单信息后(地址、数量等信息)、点击提交订单按钮、生成订单。

以上就是一个成功下单的基本流程。这里我们不关心前端的控制,只实现java后台。所以直接从用户点击提交按钮开始。

概括为:用户点击提交订单按钮向后台发送请求。请求内容包括秒杀id,商品id,用户id等信息。后台根据信息查看库存是否充足,如果充足扣减库存,如果扣减库存成功,生成订单信息,提示用户下单成功。

当然前端向服务器发送消息后,nginx需要做限流处理,尤其是秒杀业务,比如后台tomcat集群最多能撑住10000/s的请求,那么需要做一些限流措施,确保到达集群的请求小于这个请求数。关于限流这块不做介绍,我们只考虑到达接口后的情形。

以上就是一个最简单的秒杀系统流程。像淘宝、京东等比这个秒杀流程要更繁琐。这里为了实现方便还是以最简单的流程为主。只要掌握这个流程,其他繁琐的流程也是信手拈来。

难点

一说秒杀系统,离不开的就是多线程、高并发,这也是该系统的难点。如何处理高并发,如何处理多线程同时修改一条库存记录呢?

思路一

上边已经说过,难点是解决高并发,高并发的难点是多个线程同时想要修改一条库存记录,如何保证数据的正确性。

我们可以利用redis,在秒杀前将需要秒杀的商品id和库存数量存入redis中,由于redis的原子性,多个线程竞争也是会先后执行,保证了数据的准确性。

假如现有某商品参加秒杀系统,库存数量为10,现有1000个线程来同时去减库存。

如果想测试上边的假设也很简单,准备一个redis服务,写个测试用例,用1000个线程就测试即可。

话不多说,开干!

首选,准备redis服务,以及java操作redis环境redisTemplate。如果这块不清楚的参看我的另外一篇文章

redis知识整理

接着,在测试类中,通过redisTemplate向redis中存入一个商品及库存,代码如下:

    @Test
    public void test(){
        redisTemplate.opsForValue().set("20200217",10);
    }

接着查看是否插入成功:

    @Test
    public void test1(){
        Object value = redisTemplate.opsForValue().get("20200217");
        System.out.println(value);
    }

接着写一个线程任务,用来测试上边的假设,

这块要明确减库存的步骤,首先判断库存数量-需求数量是否大于0,如果不满足条件,那么不能减库存。如果满足条件,执行减库存。想要让这个逻辑是原子性的,在redis中,只能通过lua脚本来保证。

lua脚本如下:

//lua脚本,先获取库存数量,假如库存数量大于等于当前需求数量,则执行库存数量减去需求数量,否则返回-1
    private static final String LUA_SCRIPT = "if tonumber(redis.call('get',KEYS[1]))>=tonumber(ARGV[1]) then return redis.call('decrby',KEYS[1],ARGV[1]) else return -1 end";

线程任务如下:

    private class MyRunnable implements Runnable{
        @Override
        public void run() {
            Object result = redisTemplate.execute(
                    (RedisConnection connection) -> connection.eval(
                            LUA_SCRIPT.getBytes(),
                            ReturnType.INTEGER,
                            1,
                            "20200217".getBytes(),//商品id
                            "1".getBytes()));//这块的需求数量可以修改为其他值
            System.out.println(result);
            if(result.toString().equals("-1")){
               System.out.println("库存为空,秒杀结束!");
            }else {
                System.out.println("秒杀成功!生成订单");
                //下边执行生成订单的逻辑,
                //假如生成订单失败,库存可以回滚
            }
        }
    }

最后是测试程序代码:

    @Test
    public void test5(){
        ExecutorService executorService = Executors.newCachedThreadPool();
        //生成1000个线程来执行任务。
        for(int i=0;i<1000;i++){
            executorService.execute(new MyRunnable());
        }
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

控制台成功输出结果,10次秒杀成功,其他都是秒杀失败。

对于这种方式,笔者觉得实现简单,效率也很高。但网上对这种实现方式的介绍很少,不明白为什么。

思路二

其实思路是跟思路一一样的,只是实现方式不一样,思路一是通过lua来实现原子性操作,思路二是通过锁的方式来实现原子性,就是说同一时间,只允许一个线程进入锁区域来操作库存记录。

由于高并发场景多数是集群环境,那么传统的sychronized关键字是不起作用的,这里就直接上分布式锁。

分布式锁的实现方式一般是通过redis或者zookeeper来实现,这里就使用redis来实现。

那么redis如何实现加锁呢?

加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。

SET lock_key random_value NX PX 5000

这个random_value要求是唯一的,因为等到解除锁时,需要通过该值来判断是否删除,如果不是唯一,有可能存在误删除锁的情况。

解锁的过程就是将Key键删除。但也不能乱删。通过random_value来删除。

为了保证解锁操作的原子性,我们用LUA脚本完成这一操作。先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。

lua代码如下:

if redis.call('get',KEYS[1]) == ARGV[1] then 
   return redis.call('del',KEYS[1]) 
else
   return 0 
end

 知道以上原理后,就可以通过java代码来实现了。

环境同思路一

先创建一个RedisLock工具类。这里就直接贴代码了,主要就是2个方法,加锁、解锁:

package com.ming.seckillredistest.util;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisLock {
    private final long TIME_OUT=5000;//5秒钟后还未获取到锁设定为超时。
    //释放锁的脚本,
    private final String LUA_SCRIPT = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 加锁方法
     * @param key
     * @param value
     * @return
     */
    public boolean lock(String key,String value){
        //开始获取锁的时间
        long startTime=System.currentTimeMillis();
        //如果获取不到锁一直阻塞,并尝试继续获取锁。
        while (true){
            boolean result=redisTemplate.opsForValue().setIfAbsent(key,value,3000, TimeUnit.MILLISECONDS);
            if(result){
                return true;
            }
            long tempTime=System.currentTimeMillis()-startTime;
            if(tempTime>TIME_OUT){
                return false;
            }
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public boolean unlock(String key,String value){
        String tempValue="\""+value+"\"";//由于config中对序列号的配置,这块需要做处理。
        Object result = redisTemplate.execute((RedisConnection connection) -> connection.eval(
                LUA_SCRIPT.getBytes(),
                ReturnType.INTEGER,
                1,
                key.getBytes(),
                tempValue.getBytes()));

        if(result.toString().equals("0")){
            return false;
        }
        return true;
    }
}

锁完成了,就准备线程任务代码:

    private class MyRunnable1 implements Runnable{
        @Override
        public void run() {
            String lockKey="202002172031";
            String lockValue= "202002172031001";
            boolean lockResult = redisLock.lock(lockKey, lockValue);
            if(lockResult){
                Object value=redisTemplate.opsForValue().get("20200217");
                if(Integer.parseInt(value.toString())>0){
                    redisTemplate.opsForValue().decrement("20200217");
                    System.out.println("秒杀成功,生成订单");
                }else {
                    System.out.println("秒杀失败");
                }
                boolean unlock = redisLock.unlock(lockKey, lockValue);
                if(!unlock){
                    System.out.println("释放锁失败!");
                }
            }else {
                System.out.println("锁超时,秒杀失败");
            }
        }
    }

最后,测试类调用:

    @Test
    public void test6(){
        ExecutorService executorService = Executors.newCachedThreadPool();
        //生成1000个线程来执行任务。
        for(int i=0;i<1000;i++){
            executorService.execute(new MyRunnable1());
        }
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

测试结果成功输出到控制台,没有问题。 

思路二的方式在网上比较常见,但相比思路一,感觉有些多余了。这块也是比较好奇,为什么没有人讨论思路一的方式。

最后,引用熬丙博客上的一张图片,来概括秒杀流程。

总结

本来想通过一篇文章来结束,写到这发现篇幅太长了,这篇文章定性为【高并发减库存】,接下来写tomcat的并发。 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值