秒杀业务的设计

1 前言

秒杀是电商平台惯用的吸引用户和流量的营销手段。业务特点就是时间短、并发高,是一个爽了商家苦了开发的业务场景。 如果有一天我去电商公司面试,面试官问我如何去设计一个秒杀业务我该怎么回答呢?

2 问题分析

看了一些关于秒杀业务的技术文章基本离不开这几问题:

  • 超卖

    假设商家上架10个iPhone12 Pro 手机壳以1块钱的价格去参与秒杀,经过一场贪婪的厮杀后结果产生了100个订单!!商家还会参与平台的秒杀活动吗?如果将手机壳换成手机的话估要拿程序员祭天了...

  • 高并发

    这类活动一般通过精准推送、短信等来通知有相应需求的用户,所以一旦商品有足够的吸引力并发量可想而知。

  • 恶意请求

    商品有了一定吸引力一些用户肯定想尽一切办法去触发秒杀请求。

  • 链接暴露

    这里不乏有些懂点技术的用户或者同行的互相伤害通过F12抓包工具等手段获取请求链接写脚本进行模拟请求。

3 解决思路

3.1 创建demo
3.1.1 表

创建商品表订单表

CREATE TABLE `goods` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(100) NOT NULL DEFAULT '' COMMENT '商品名称',
  `price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '价格',
  `count` int(11) DEFAULT '0' COMMENT '库存',
  `sold` int(11) DEFAULT '0' COMMENT '已售数量',
  `version` int(11) DEFAULT '0' COMMENT '版本(乐观锁)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4

CREATE TABLE `order` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `goods_id` varchar(50) NOT NULL COMMENT '商品ID',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4

<!-- 添加一个秒杀商品 -->
INSERT INTO goods (`id`, `name`, `price`, `count`, `sold`, `version`) VALUES (1, 'iPhone12 Pro', 8499.00, 100, 0, 0);
3.1.2 Mapper
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.miaosha.mapper.GoodsMapper">
    <!-- 按ID查询 -->
    <select id="selectById" parameterType="Integer" resultType="Goods">
        SELECT id, `name`, `count`, sold, version, price FROM goods WHERE id = #{id}
    </select>
	 
	 <!-- 修改 -->
    <update id="updateById" parameterType="Integer">
        UPDATE goods
        <set>
            <if test="name != null and name != ''" > name = #{name},</if>
            <if test="count != null" > count = #{count},</if>
            <if test="sold != null" > sold = #{sold},</if>
            <if test="version != null" > version = #{version},</if>
        </set>
        WHERE id = #{id}
    </update>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.miaosha.mapper.OrderMapper">
    <!-- 添加  -->
    <insert id="insertOrder" parameterType="Order" useGeneratedKeys="true" keyProperty="id" >
        INSERT INTO `order` (
            <if test="id != null" >id,</if>
            <if test="goodsId != null" >goods_Id,</if>
            create_time
        )VALUES(
            <if test="id != null" >#{id},</if>
            <if test="goodsId != null" >#{goodsId},</if>
            sysdate()
		)
    </insert>
</mapper>
@Mapper
public interface GoodsMapper {
    Goods selectById(Integer id);

    int updateById(Goods goods);
}
@Mapper
public interface OrderMapper {
    int insertOrder(Order order);
}
3.1.3 Service
@Service
@Transactional
public class GoodsServiceImpl implements GoodsService {

    @Autowired
    private GoodsMapper goodsMapper;
    @Autowired
    private OrderMapper orderMapper;

    @Override
    public Integer kill(Integer id) {
        // 查询这个秒杀商品
        Goods goods = goodsMapper.selectById(id);
        // 销量大于等于库存直接抛异常
        if (goods.getSold() >= goods.getCount()) {
            throw new RuntimeException("库存不足!");
        } else {
            // 扣库存
            goods.setSold(goods.getSold() + 1);
            int result = goodsMapper.updateById(goods);
            // 添加订单
            if (result > 0) {
                Order order = Order.builder().goodsId(goods.getId()).build();
                orderMapper.insertOrder(order);
                return order.getId();
            } else {
                throw new RuntimeException("抢购失败!");
            }
        }
    }
  
}
3.1.4 Controller
@RestController
@RequestMapping("/goods")
public class KillController {

    @Autowired
    private GoodsService goodsService;

    @RequestMapping("/kill")
    public String kill(Integer goodsId) {
        try {
            Integer orderId = goodsService.kill(goodsId);
            return "订单创建成功,订单号:" + orderId;
        } catch (Exception e) {
            return e.getMessage();
        }
    }

}
3.1.5 运行

打开测压工具jmeter添加一组1000线程线程组 ,模拟请求我们的秒杀接口。

结果:1000个请求执行了9秒。 再看一下数据

SELECT * FROM goods;
SELECT count(*) '订单总数' FROM `order`;

WX20210316-095902

100个库存的商品卖出了100个,没有问题。看一眼订单

WX20210316-100221

居然产生了508个订单,超卖408个!!如果这发生在你负责的生产环境上就问你慌不慌。

3.2 如何解决超卖

解决超卖最简单的方法就是加,常见的就是使用synchronized关键字或在数据库表加上版本号作为乐观锁

3.2.1 悲观锁
@RestController
@RequestMapping("/goods")
public class KillController {
    @Autowired
    private GoodsService goodsService;

    @RequestMapping("/kill")
    public String killV1(Integer goodsId) {
       try {
            synchronized (this) {
                Integer orderId = goodsService.kill(goodsId);
                return "订单创建成功,订单号:" + orderId;
            }
        } catch (Exception e) {
            return e.getMessage();
        }
    }
}

测试

结果:1000个请求由于synchronized运行机制执行了40秒。 再看一下数据

WX20210316-095902

WX20210316-104635

证明synchronized是可以解决超卖问题的。

3.2.2 乐观锁

乐观锁使用更新数据版本号的方式实现,这里我们直接在数据库层面去进行扣库存操作而不是在业务层去进行。

<update id="updateById" parameterType="Integer">
   UPDATE goods
        <set>
            <if test="name != null and name != ''" > name = #{name},</if>
            <if test="count != null" > count = #{count},</if>
            <if test="sold != null" > sold = #{sold} + 1,</if>
            <if test="version != null" > version = #{version} + 1,</if>
        </set>
        WHERE id = #{id} and version = #{version}
</update>

测试:

结果:1000个请求执行了9秒。 再看一下数据

WX20210316-112510

WX20210316-112539

很明显使用乐观锁的执行效率比使用悲观锁要高得多。

3.3 如何解决高并发

试想如果营销到位秒杀的商品又具吸引力,比如1块钱秒iPhone、茅台、MacBook,一旦秒杀活动开始并发请求就如洪水猛兽的扑向服务器这样的后果轻则服务器宕机重则又是程序员祭天!

那么要想保住服务器又能正常进行业务怎么办呢?限流这是最简单直接的方法了,常见的限流算法木桶算法令牌桶算法 。接下来我利用google的guavajava类库使用令牌桶算法改造一下demo实现简单的限流。

@RestController
@RequestMapping("/goods")
public class KillController {

    @Autowired
    private GoodsService goodsService;
    // 每秒产生50个令牌
    private RateLimiter rateLimiter = RateLimiter.create(50);

    @RequestMapping("/kill")
    public String kill(Integer goodsId) {
        // 2秒内能拿到令牌的放行否则舍弃
        if (rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {
            try {
                Integer orderId = goodsService.killToVer(goodsId);
                return "订单创建成功,订单号:" + orderId;
            } catch (Exception e) {
                return e.getMessage();
            }
        } else {
            return "抢购失败,本次秒杀活动过于火爆!!";
        }
    }
}

测试:

结果:1000个请求执行了3秒。 再看一下数据

WX20210316-131545

WX20210316-131619

这里可以看到1000个请求只执行了3秒最终只产生了32个订单,这是因为令牌桶算法生效了,大多数的请求在2秒内无法获得令牌而被舍弃掉了,最终产生了32个幸运儿。

3.4 如何解决恶意请求

用户收到了秒杀活动的通知,为了能成功秒杀就开始动自己的小聪明了,开始疯狂点击、机器手点击、写脚本等等。这个时候要是没有防护的话估计秒杀活动还没有开始服务器就先躺哪了。

前端在没有开始秒杀活动之前是不能让用户触发请求的。

后端可以使用 redis 对这个秒杀商品设一个过期时间,请求进来了先找这个key存在就执行否则拒绝请求,然后同样是使用 redis 以用户的唯一标识作为key记录请求次数,在规定时间内请求次数超过阈值就拒绝。

这就是为什么在秒杀活动界面里没有开始时按钮都是不可点击的,然后就是倒计时。

3.5 如何解决链接暴露

请求链接通过 F12抓包工具都是能轻易获取的然后通过工具或脚本进行模拟请求,就像我们拿jmeter测压一样。解决思路就是让你的URL变成动态的。可以用用户的唯一标识加随机字符串利用 MD5 算法去做URL,先获取,后台进行校验才能通过。反正就是要让你的URL动起来。

4 总结

以上只是本人对于秒杀业务的一些粗略理解和解决思路。真正的秒杀业务场景和发生的问题远远要比这复杂的多,特别是在分布式系统上,像什么负载、网关、分布式事务、一致性、缓存问题等等,反正最终都难逃996和脱发的命运。

原文地址:https://www.cnblogs.com/pengjf/p/14549541.html