SpringCloud Alibaba微服务实战之商品秒杀

SpringCloud Alibaba微服务实战之商品秒杀

大家好,我是一安~

导读:本篇主要是基于SpringBoot实现的高并发商品限时抢购秒杀。

简介

在电商领域,存在着典型的秒杀业务场景,那何谓秒杀场景呢。简单来说就是一件商品的购买人数远远大于这件商品的库存,而且这件商品在很短的时间内就会被抢购一空。

比如每年的618、双11大促,小米新品促销等业务场景,就是典型的秒杀业务场景。

我们可以将电商系统的架构简化成下图所示:SpringCloud Alibaba微服务实战之商品秒杀由图所示,我们可以简单的将电商系统的核心层分为:负载均衡层、应用层和持久层。接下来,我们就预估下每一层的并发量。

  • 假如负载均衡层使用的是高性能的Nginx,则我们可以预估Nginx最大的并发度为:10W+
  • 假设应用层我们使用的是Tomcat,而Tomcat的最大并发度可以预估为800左右。
  • 假设持久层的缓存使用的是Redis,数据库使用的是MySQLMySQL的最大并发度可以预估为1000左右、Redis的最大并发度可以预估为5W左右。

所以,负载均衡层、应用层和持久层各自的并发度是不同的,那么,为了提升系统的总体并发度和缓存,我们通常可以采取哪些方案呢?

  • 系统扩容:系统扩容包括垂直扩容和水平扩容,增加设备和机器配置,绝大多数的场景有效。
  • 缓存:本地缓存或者集中式缓存,减少网络IO,基于内存读取数据。大部分场景有效。
  • 读写分离:采用读写分离,分而治之,增加机器的并行处理能力。

秒杀业务特点

SpringCloud Alibaba微服务实战之商品秒杀秒杀系统的并发量存在瞬时凸峰的特点,也叫做流量突刺。

秒杀技术特点

SpringCloud Alibaba微服务实战之商品秒杀
  • 瞬时并发量非常高:大量用户会在同一时间抢购商品;瞬间并发峰值非常高。
  • 读多写少:系统中商品页的访问量巨大;商品的可购买数量非常少;库存的查询访问数量远远大于商品的购买数量。

    在商品页中往往会加入一些限流措施,前面篇有讲过利用Sentinel实现服务限流熔断降级。

  • 流程简单:秒杀系统的业务流程一般比较简单;总体上来说,秒杀系统的业务流程可以概括为:下单减库存。

针对这种短时间内大流量的系统来说,就不太适合使用系统扩容了,因为即使系统扩容了,也就是在很短的时间内会使用到扩容后的系统,大部分时间内,系统无需扩容即可正常访问。

秒杀系统方案

SpringCloud Alibaba微服务实战之商品秒杀
  • 异步解耦:将整体流程进行拆解,核心流程通过队列方式进行控制。
  • 限流防刷:控制网站整体流量,提高请求的门槛,避免系统资源耗尽。
  • 资源控制:将整体流程中的资源调度进行控制,扬长避短。

由于应用层能够承载的并发量比缓存的并发量少很多。所以在高并发系统中,我们可以直接使用OpenResty负载均衡层访问缓存,避免了调用应用层的性能损耗。

秒杀业务流程

SpringCloud Alibaba微服务实战之商品秒杀
  • 认证:检测用户是否正常登录。
  • 限流:通过判断消息队列的长度来进行判断,消息队列中堆积的是用户的请求,我们可以根据当前消息队列中存在的待处理的请求数量来判断是否需要对用户的请求进行限流处理。
  • 异步:用户的秒杀请求通过前面的验证后,发送到MQ中进行异步通知处理。

正文

商品模块

引入依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <exclusions>
            <exclusion>
                <groupId>io.lettuce</groupId>
                <artifactId>lettuce-core</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
    </dependency>

Redis配置类:

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory);
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        //序列化设置 ,这样为了存储操作对象时正常显示的数据,也能正常存储和获取
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        return redisTemplate;
    }
    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
        stringRedisTemplate.setConnectionFactory(factory);
        return stringRedisTemplate;
    }

    @Bean
    public RedisTemplate<String, Serializable> limitRedisTemplate(LettuceConnectionFactory factory) {
        RedisTemplate<String, Serializable> template = new RedisTemplate<String, Serializable>();
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setConnectionFactory(factory);
        return template;
    }


    /**
     * 对hash类型的数据操作
     *
     * @param redisTemplate
     * @return
     */
    @Bean
    public HashOperations<String, String, Object> hashOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForHash();
    }

    /**
     * 对redis字符串类型数据操作
     *
     * @param redisTemplate
     * @return
     */
    @Bean
    public ValueOperations<String, String> valueOperations(RedisTemplate<String, String> redisTemplate) {
        return redisTemplate.opsForValue();
    }

    /**
     * 对链表类型的数据操作
     *
     * @param redisTemplate
     * @return
     */
    @Bean
    public ListOperations<String, Object> listOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForList();
    }

    /**
     * 对无序集合类型的数据操作
     *
     * @param redisTemplate
     * @return
     */
    @Bean
    public SetOperations<String, Object> setOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForSet();
    }

    /**
     * 对有序集合类型的数据操作
     *
     * @param redisTemplate
     * @return
     */
    @Bean
    public ZSetOperations<String, Object> zSetOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForZSet();
    }
}

创建工具类:

@Component
@Slf4j
public class RedisUtils {

    private static final Long SUCCESS = 1L;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;


    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    
    
    @PostConstruct
    public void add(){
        //队列方式:
        List<String> entriesList = new LinkedList<>();
        for (int i = 0; i < 100; i++){
            entriesList.add("笔记本-"+i);
        }
        addEntriesOnListLeft("P001:笔记本",entriesList);

        //分布式锁方式:
        redisTemplate.opsForValue().set("P002:圆珠笔""100");
    }


    /**
     * 获取锁
     * @param lockKey
     * @param value
     * @param expireTime:单位-秒
     * @return
     */
    public boolean getLock(String lockKey, Object value, int expireTime) {
        try {
            String script = "if redis.call('set',KEYS[1],ARGV[1],'NX') then " +
                    "if redis.call('get',KEYS[1])==ARGV[1] then " +
                    "return redis.call('expire',KEYS[1],ARGV[2]) " +
                    "else " +
                    "return 0 end " +
                    "else return 0 end";
            RedisScript<Long> redisScript = new DefaultRedisScript<Long>(script, Long.class);
            Object result = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), value+"", expireTime+"");
            if (SUCCESS.equals(result)) return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }

    /**
     * 释放锁
     * @param lockKey
     * @param value
     * @return
     */
    public boolean releaseLock(String lockKey, String value) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        Object result = stringRedisTemplate.execute(redisScript, Collections.singletonList(lockKey), value);
        if (SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }


    //在队列尾部减少一个对象
    public String removeOneEntryOnListRight(String listName) {
        return stringRedisTemplate.opsForList().rightPop(listName);
    }

    //在队列头部新增对象
    public Long addEntryOnListLeft(String listName, String args) {
        return stringRedisTemplate.opsForList().leftPush(listName, args);
    }

    //在队列头部批量新增对象
    public Long addEntriesOnListLeft(String listName, Collection<String> args) {
        return stringRedisTemplate.opsForList().leftPushAll(listName, args);
    }
    //在队列头部新增对象,存在才插入
    public Long addEntryOnListLeftIfPresent(String listName, String args) {
        return stringRedisTemplate.opsForList().leftPushIfPresent(listName, args);
    }
}

方案一:

利用redislist队列实现,利用redis的单线程特性,即可实现高并发下的秒杀:请求到达redis,每一次执行要么返回一个值,要么返回null,很显然,返回值的就是抢到了,没抢到返回null

核心代码:

    @Override
    public void skill(String productCode,String productName) {
        String redisResult  = redisUtils.removeOneEntryOnListRight(getCacheKey(productCode,productName));
        if (null == redisResult) {
            System.out.println("很遗憾,您没有抢到");
        }else {
            System.out.println("恭喜您成功抢到");

            //TODO 其他操作,比如下单 扣减账户余额....

        }
    }

    private String getCacheKey(String productCode,String productName) {
        return  productCode.concat(":"+productName);
    }

以上方式只能抢购单件商品,无法实现批量扣减。

方式二:

利用redisincrby特性来扣减库存,实现批量扣减。

核心代码:

    /**
     * 执行扣库存的脚本
     */
    public static final String STOCK_LUA;
    static {
        /**
         *
         * @desc 扣减库存Lua脚本
         * 库存(stock)-1:表示不限库存
         * 库存(stock)0:表示没有库存
         * 库存(stock)大于0:表示剩余库存
         *
         * @params 库存key
         * @return
         *   -3:库存未初始化
         *   -2:库存不足
         *   -1:不限库存
         *   大于等于0:剩余库存(扣减之后剩余的库存)
         *      redis缓存的库存(value)是-1表示不限库存,直接返回 -1
         */
        StringBuilder sb = new StringBuilder();
        sb.append("if (redis.call('exists', KEYS[1]) == 1) then");
        sb.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
        sb.append("    local num = tonumber(ARGV[1]);");
        sb.append("    if (stock == -1) then");
        sb.append("        return -1;");
        sb.append("    end;");
        sb.append("    if (stock >= num) then");
        sb.append("        return redis.call('incrby', KEYS[1], 0 - num);");
        sb.append("    end;");
        sb.append("    return -2;");
        sb.append("end;");
        sb.append("return -3;");
        STOCK_LUA = sb.toString();
    }
    
    private String getCacheKey(String productCode,String productName) {
        return  productCode.concat(":"+productName);
    }

    /**
     * @param productCode           库存编码
     * @param productName           库存名称
     * @param deductCount           扣减数量
     * @return -2:库存不足; -1:不限库存; 大于等于0:扣减库存之后的剩余库存
     */
    @Override
    public long skill2(String productCode,String productName, int deductCount) {
        long stock = stock(getCacheKey(productCode,productName), deductCount);
        if (stock == NOINIT_STOCK) {
            System.out.println("库存未初始化");
        }else if(stock == NOTENOUGH_STOCK){
            System.out.println("库存不足");
        }else {
            System.out.println("恭喜您成功抢到");

            //TODO 其他操作,比如下单 扣减账户余额....
        }
        return stock;
    }

    /**
     * 扣库存
     *
     * @param key 库存key
     * @param deductCount 扣减库存数量
     * @return 扣减之后剩余的库存【-3:库存未初始化; -2:库存不足; -1:不限库存; 大于等于0:扣减库存之后的剩余库存】
     */
    private Long stock(String key, int deductCount) {
        // 脚本里的KEYS参数
        List<String> keys = new ArrayList<>();
        keys.add(key);
        // 脚本里的ARGV参数
        List<String> args = new ArrayList<>();
        args.add(Integer.toString(deductCount));
        long result = redisTemplate.execute(new RedisCallback<Long>() {
            @Override
            public Long doInRedis(RedisConnection connection) throws DataAccessException {
                Object nativeConnection = connection.getNativeConnection();
                // 集群模式
                if (nativeConnection instanceof JedisCluster) {
                    return (Long) ((JedisCluster) nativeConnection).eval(STOCK_LUA, keys, args);
                }

                // 单机模式
                else if (nativeConnection instanceof Jedis) {
                    return (Long) ((Jedis) nativeConnection).eval(STOCK_LUA, keys, args);
                }
                return NOINIT_STOCK;
            }
        });
        return result;
    }

以上主要实现了商品的扣减功能,其他操作,比如下单 扣减账户余额……没有具体实现,认证功能之前篇已通过Spring Security OAuth2实现。

演示

启动初始化数据:SpringCloud Alibaba微服务实战之商品秒杀

SpringCloud Alibaba微服务实战之商品秒杀

效果展示:SpringCloud Alibaba微服务实战之商品秒杀

  • 通过密码模式获取请求token,并设置请求头。
  • 开启10个线程,循环5次,每个请求下单3件商品,理论100件库存只会卖出99件,也就是33请求会下单成功,17个请求下单失败。
  • 最后通过控制台打印统计,符合预期效果
至此我们已经实现了商品秒杀业务,具体的业务逻辑,需要大家自行优化补充。


如果这篇文章对你有所帮助,或者有所启发的话,帮忙 分享、收藏、点赞、在看,你的支持就是我坚持下去的最大动力!

SpringCloud Alibaba微服务实战之商品秒杀

面试官:你工作中做过 JVM 调优吗?怎么做的?


千万注意:线上慎用 BigDecimal ,坑的差点被开了


如何利用Nginx反向代理,实现HTTPS远程调试本地代码?

SpringCloud Alibaba微服务实战之商品秒杀

原文始发于微信公众号(一安未来):SpringCloud Alibaba微服务实战之商品秒杀

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/144970.html

(0)
青莲明月的头像青莲明月

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!