秒杀demo

秒杀项目真的是早有耳闻,可以说是大火有一阵子,因为这其中涉及高并发、数据库、缓存,更有甚者还有分布式、分库分表、集群等。

这次有机会跟着视频学习了一点秒杀系统,这里做个总结

参考

https://www.bilibili.com/video/BV1CE411s7xN

https://www.bilibili.com/video/BV13a4y1t7Wh

基础环境和工具

  • IDEA 2020 + JDK8
  • SpringBoot 2.x
  • 虚拟机CentOS上的 MySQL 5.7x + Redis 6.x
  • Mybatis
  • Lombok
  • Navicat (MySQL可视化工具)
  • Redis Desktop Manager (Redis可视化工具)
  • MobaXterm (SSH工具)
  • JMeter (压力测试工具)
  • Postman/Chrome

pom.xml 基础依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>

    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.4</version>
    </dependency>

    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.2.4</version>
    </dependency>


    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

application

server:
  port: 8090
spring:
  datasource:
    url: jdbc:mysql://192.168.1.106:3306/seckill
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root

  redis:
    host: 192.168.1.106
    port: 6379
    password: root
mybatis:
  mapper-locations: classpath:mapper/*.xml #sql映射文件位置
  type-aliases-package: com.wnh.entity #实体类别名
  configuration:
    map-underscore-to-camel-case: true
# 配置日志
logging.level.root=info
logging.level.com.wnh.dao=debug

项目框架

秒杀demo

-- ----------------------------
-- Table structure for stock
-- ----------------------------
DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock`  (
  `sid` int(11) NOT NULL AUTO_INCREMENT COMMENT '商品id',
  `name` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '商品名称',
  `total` int(11) NOT NULL COMMENT '库存',
  `sale` int(11) NOT NULL COMMENT '已售',
  `version` int(11) NOT NULL COMMENT '版本号',
  PRIMARY KEY (`sid`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for stock_order
-- ----------------------------
DROP TABLE IF EXISTS `stock_order`;
CREATE TABLE `stock_order`  (
  `oid` int(11) NOT NULL AUTO_INCREMENT,
  `sid` int(11) NOT NULL,
  `create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0),
  PRIMARY KEY (`oid`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4749 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

注意这里订单不能命名为order,MySQL保留字错误

Entity

Stock

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Stock {
    private Integer sid;
    private String name;
    private Integer total;
    private Integer sale;
    private Integer version;

}

Order

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Order {
    private Integer oid;
    private Integer sid;
    private Date createTime;
}

Mapper

商品Mapper

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wnh.dao.StockMapper">
    <update id="updateSale" parameterType="stock">
        update stock
        set sale=sale + 1
        where sid = #{sid}
          and total > sale
    </update>

    <update id="updateSaleWithVersion" parameterType="stock">
        update stock
        set sale=sale + 1,
            version=version + 1
        where sid = #{sid}
          and version = #{version}
    </update>

    <select id="checkStock" parameterType="int" resultType="stock">
        select sid, name, total, sale, version
        from stock
        where sid = #{id}
    </select>

    <select id="listStocks" resultType="stock">
        select sid, total, sale
        from stock
    </select>
</mapper>

可以发现这里有两个不同的更新操作,两个都是利用数据库的乐观锁实现的简单的并发处理

订单Mapper

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wnh.dao.OrderMapper">

    <!--useGeneratedKeys="true" 数据库自增生成 keyProperty="oid" 返回生成值到 -->
    <insert id="createOrder" parameterType="order" useGeneratedKeys="true" keyProperty="oid">
        insert into stock_order
        values (#{oid}, #{sid}, #{createTime})
    </insert>
</mapper>

Service

StockService

@Service@Transactionalpublic class StockServiceImpl implements StockService{    @Autowired    private StockMapper stockMapper;    @Override    public List<Stock> listStocks() {        return stockMapper.listStocks();    }    // 检查库存    @Override    public Stock checkStock(Integer id) {        Stock stock = stockMapper.checkStock(id);        if (stock.getSale().equals(stock.getTotal())) {            throw new RuntimeException("库存不足");        }        return stock;    }    // 已售增加    @Override    public int updateSale(Stock stock) {        return stockMapper.updateSale(stock);    }    }

OrderService

@Service@Transactionalpublic class OrderServiceImpl implements OrderService {    @Autowired    private StockService stockService;    @Autowired    private OrderMapper orderMapper;    @Override    public int kill(Integer id) {        // 校验库存        Stock stock = stockService.checkStock(id);        // 扣除库存        int up = stockService.updateSale(stock);        if (up == 0) {            throw new RuntimeException("库存不足");        }        // 创建订单        return createOrder(stock);    }    // 创建订单    private Integer createOrder(Stock stock) {        Order order = new Order();        order.setSid(stock.getSid()).setCreateTime(new Date());        orderMapper.createOrder(order);        return order.getOid();    }}

关键接口是 OrderService 的 kill 方法

Controller

@RestController@RequestMapping("stock")public class BuyController {    @Autowired    private StockService stockService;    @Autowired    private OrderService orderService;    // 秒杀    @GetMapping("kill")    public String kill(Integer id) {        System.out.println("秒杀商品id = " + id);        try {            int orderId = orderService.kill(id);            return "秒杀成功,订单id为:" + orderId;        } catch (Exception e) {            e.printStackTrace();            return e.getMessage();        }    }}

启动项目测试

初始数据库数据

商品表

sid name total sale version
1 iphone8 100 0 0
2 p40 5 0 0
3 k30 200 0 0

订单表为空

秒杀demo

一次请求后

sale+1,变为1,增加了一条订单,其他没有变化

JMeter测试

秒杀demo

sale变为100,订单总数100条,Throughput为100-200/sec

总体没出现大问题,但是Throughput不尽人意

恢复原数据把 JMeter 参数稍稍一改,1000不变,Loop Cout 变为10,也就是总共 10000 次请求,这次整个测试过程变得很慢,最终Throughput也停留在 30-40/sec,当然这需要优化

加入Redis

@RestController@RequestMapping("stock")public class BuyController {    @Autowired    private StockService stockService;    @Autowired    private StringRedisTemplate stringRedisTemplate;    @Autowired    private OrderService orderService;    @PostConstruct    public void init() {        List<Stock> stocks = stockService.listStocks();        for (Stock stock : stocks) {            stringRedisTemplate.opsForValue().set(Constants.REDIS_STOCK_LAST + stock.getSid(), stock.getTotal() - stock.getSale() + "");        }    }    // 秒杀    @GetMapping("kill")    public String kill(Integer id) {        Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + id);        if (increment < 0) {            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);            return "商品已售完";        }        System.out.println("秒杀商品id = " + id);        try {            int orderId = orderService.kill(id);            return "秒杀成功,订单id为:" + orderId;        } catch (Exception e) {            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);            e.printStackTrace();            return e.getMessage();        }    }}

启动系统

查到 Redis 里已经存入数据,Postman测试没有问题

JMeter 测试过程很快,但 Throughput 提升一点

二级缓存

@RestController@RequestMapping("stock")public class BuyController {    @Autowired    private StockService stockService;    @Autowired    private StringRedisTemplate stringRedisTemplate;    @Autowired    private OrderService orderService;    private static ConcurrentHashMap<Integer, Boolean> stockSoldOutMap = new ConcurrentHashMap<>();    @PostConstruct    public void init() {        List<Stock> stocks = stockService.listStocks();        for (Stock stock : stocks) {            stringRedisTemplate.opsForValue().set(Constants.REDIS_STOCK_LAST + stock.getSid(), stock.getTotal() - stock.getSale() + "");        }    }    // 秒杀    @GetMapping("kill")    public String kill(Integer id) {        if (stockSoldOutMap.get(id) != null) {            return "商品已售完";        }        Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + id);        if (increment < 0) {            stockSoldOutMap.put(id, true);            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);            return "商品已售完";        }        System.out.println("秒杀商品id = " + id);        try {            int orderId = orderService.kill(id);            return "秒杀成功,订单id为:" + orderId;        } catch (Exception e) {            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);            if (stockSoldOutMap.get(id) != null) {                stockSoldOutMap.remove(id);            }            e.printStackTrace();            return e.getMessage();        }    }}

再次测试,因为通过JVM内存缓存了是否售空,所以系统还能再提升一些。

令牌桶限流

依赖

<!-- google开源工具类RateLimiter令牌桶实现 --><dependency>    <groupId>com.google.guava</groupId>    <artifactId>guava</artifactId>    <version>30.1-jre</version></dependency>

Controller

// 创建令牌桶实例private RateLimiter rateLimiter = RateLimiter.create(40);@GetMapping("sale")public String sale(Integer id) {    // 1.没有获取 token 请一直到获取到 token 令牌    // log.info("等待时间:"+rateLimiter.acquire());    // 2.设置等待时间,如果在等待时间内获取到了 token 令牌,则处理业务,没有则抛弃    if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {        System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");    }    System.out.println("处理业务.............");    return "抢购成功";}

完整Controller

@RestController@RequestMapping("stock")@Slf4jpublic class BuyController {    @Autowired    private StockService stockService;    @Autowired    private StringRedisTemplate stringRedisTemplate;    @Autowired    private OrderService orderService;    private static ConcurrentHashMap<Integer, Boolean> stockSoldOutMap = new ConcurrentHashMap<>();    // 创建令牌桶实例    private RateLimiter rateLimiter = RateLimiter.create(20);    @GetMapping("sale")    public String sale(Integer id) {        // 1.没有获取 token 请一直到获取到 token 令牌        // log.info("等待时间:"+rateLimiter.acquire());        // 2.设置等待时间,如果在等待时间内获取到了 token 令牌,则处理业务,没有则抛弃        if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {            System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");            return "抢购失败";        }        System.out.println("处理业务.............");        return "抢购成功";    }    @GetMapping("killtoken")    public String killtoken(Integer id) {        if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {            System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");            return "抢购失败";        }        return kill(id);    }    @PostConstruct    public void init() {        List<Stock> stocks = stockService.listStocks();        for (Stock stock : stocks) {            stringRedisTemplate.opsForValue().set(Constants.REDIS_STOCK_LAST + stock.getSid(), stock.getTotal() - stock.getSale() + "");        }    }    // 秒杀    @GetMapping("kill")    public String kill(Integer id) {        if (stockSoldOutMap.get(id) != null) {            return "商品已售完";        }        Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + id);        if (increment < 0) {            stockSoldOutMap.put(id, true);            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);            return "商品已售完";        }        System.out.println("秒杀商品id = " + id);        try {            int orderId = orderService.kill(id);            return "秒杀成功,订单id为:" + orderId;        } catch (Exception e) {            stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);            if (stockSoldOutMap.get(id) != null) {                stockSoldOutMap.remove(id);            }            e.printStackTrace();            return e.getMessage();        }    }}

测试 killtoken 接口,可以发现在一定情况下是卖不完的,虽然请求数大于库存数,这时仍然没有超卖问题

问题

  • 规定时间段内可抢购,其他时间不能
  • 恶意抓包获取接口,脚本抢购
  • 单个用户限制抢购

限时抢购

利用 Redis 过期时间,设置抢购时间

@GetMapping("kill")public String kill(Integer id) {    // 存在则还是抢购时间段内,否则活动已结束    if (!stringRedisTemplate.hasKey(Constants.REDIS_STOCK_KILL + id)) {        System.out.println("秒杀活动已结束...");        return "秒杀活动已结束";    }    if (stockSoldOutMap.get(id) != null) {        return "商品已售完";    }    Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + id);    if (increment < 0) {        stockSoldOutMap.put(id, true);        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);        return "商品已售完";    }    System.out.println("秒杀商品id = " + id);    try {        int orderId = orderService.kill(id);        return "秒杀成功,订单id为:" + orderId;    } catch (Exception e) {        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + id);        if (stockSoldOutMap.get(id) != null) {            stockSoldOutMap.remove(id);        }        e.printStackTrace();        return e.getMessage();    }}

测试

设置1号商品5秒内可抢购

set stock_kill_1 1 EX 5

JMeter测试正常,部分因令牌桶限流,部分因超过抢购时间直接返回

防脚本验证

用户表

CREATE TABLE `user` (  `uid` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户id',  `name` varchar(20) NOT NULL COMMENT '用户名',  `password` varchar(20) NOT NULL COMMENT '密码',  PRIMARY KEY (`uid`)) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8

加入

uid name password
1 小杨 123456

User

@Data@ToStringpublic class User {    private Integer uid;    private String name;    private String password;}

UserMapper

@Mapperpublic interface UserMapper {    User findUserById(Integer id);}

UserMapper.xml

<!DOCTYPE mapper        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"        "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.wnh.dao.UserMapper">    <select id="findUserById" parameterType="int" resultType="user">        select uid, name, password        from user        where uid = #{id}    </select></mapper>

Controller新加入

// 生成MD5@RequestMapping("md5")public String getMD5(Integer sid, Integer uid) {    String md5;    try {        md5 = orderService.getMD5(sid, uid);    } catch (Exception e) {        e.printStackTrace();        return "获取md5失败:" + e.getMessage();    }    return "获取的MD5为:" + md5;}

OrderService

@Autowiredprivate UserMapper userMapper;@AutowiredStringRedisTemplate stringRedisTemplate;@Overridepublic String getMD5(Integer sid, Integer uid) {    // 验证用户    User user = userMapper.findUserById(uid);    if (user == null) {        throw new RuntimeException("用户不存在!");    }    log.info("用户信息:[{}]", user.toString());    // 验证商品    Stock stock = stockService.checkStock(sid);    if (stock == null) {        throw new RuntimeException("商品不存在!");    }    log.info("商品信息:[{}]", stock.toString());    // 生成hashkey    String hashKey = "KEY_" + uid + "_" + sid;    // 生成MD5 随机盐-"!Qr*#3"    String key = DigestUtils.md5DigestAsHex((uid + "!Qr*#3" + sid).getBytes());    stringRedisTemplate.opsForValue().set(hashKey, key, 120, TimeUnit.SECONDS);    log.info("Redis写入:[{}]-[{}]", hashKey, key);    return key;}

postman测试接口为http://localhost:8090/stock/md5?sid=1&uid=1

结果为:获取的MD5为:6f2c1a31e4b297c4f85c166c09009ec6

秒杀demo

控制台

秒杀demo

查看Redis,没有问题

秒杀demo

看到设置了120秒的过期时间

改造加入验证

Controller

@GetMapping("killtokenmd5")public String killtokenmd5(Integer sid, Integer uid, String md5) {    if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {        System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");        return "抢购失败";    }    // 测试验证用户 暂时注释    // if (!stringRedisTemplate.hasKey(Constants.REDIS_STOCK_KILL + sid)) {    //     System.out.println("秒杀活动已结束...");    //     return "秒杀活动已结束";    // }    if (stockSoldOutMap.get(sid) != null) {        return "商品已售完";    }    Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + sid);    if (increment < 0) {        stockSoldOutMap.put(sid, true);        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + sid);        return "商品已售完";    }    System.out.println("秒杀商品id = " + sid);    try {        int orderId = orderService.killtokenmd5(sid, uid, md5);        return "秒杀成功,订单id为:" + orderId;    } catch (Exception e) {        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + sid);        if (stockSoldOutMap.get(sid) != null) {            stockSoldOutMap.remove(sid);        }        e.printStackTrace();        return e.getMessage();    }}

Service

@Overridepublic int killtokenmd5(Integer sid, Integer uid, String md5) {    //验证签名    String hashKey = "KEY_" + uid + "_" + sid;    if (!md5.equals(stringRedisTemplate.opsForValue().get(hashKey))) {        throw new RuntimeException("当前请求不合法,请稍后再试!");    }    // 校验库存    Stock stock = stockService.checkStock(sid);    // 扣除库存    int up = stockService.updateSale(stock);    if (up == 0) {        throw new RuntimeException("库存不足");    }    // 创建订单    return createOrder(stock);}

测试,先请求获取 md5 接口使 Redis 存在该 md5,在过期时间内请求新的秒杀接口,不同于前,需要加上用户 id 和 md5 用以验证

正确结果如下,若不先请求 md5,则无法利用 Redis 验证导致失败,错误的 md5 同样 失败

秒杀demo

用户限制

限制用户访问频率

利用 Redis 超时时间和 incr 操作限制访问频率

UserService

@Service@Transactional@Slf4jpublic class UserServiceImpl implements UserService {    @Autowired    StringRedisTemplate stringRedisTemplate;    @Override    public long saveUserView(Integer uid) {        // 根据用户id生成调用次数key        String limitKey = "LIMIT" + "_" + uid;        // 获取Redis指定key的调用次数        String limitNum = stringRedisTemplate.opsForValue().get(limitKey);        long limit = -1;        if (limitNum == null) {            stringRedisTemplate.opsForValue().set(limitKey, "0", 3600, TimeUnit.SECONDS);        } else {            limit = stringRedisTemplate.opsForValue().increment(limitKey);        }        return limit;    }    @Override    public boolean getUserView(Integer uid) {        // 根据用户id生成调用次数key        String limitKey = "LIMIT" + "_" + uid;        // 获取Redis指定key的调用次数        String limitNum = stringRedisTemplate.opsForValue().get(limitKey);        if (limitNum == null) {            // 为空直接抛弃说明key异常            log.error("该用户没用申请验证值记录,疑似异常");        }        return Integer.parseInt(limitNum) <= 10;    }}

Controller

@GetMapping("killtokenmd5limit")public String killtokenmd5limit(Integer sid, Integer uid, String md5) {    if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {        System.out.println("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑...");        return "抢购失败";    }    // 测试验证用户 暂时注释    // if (!stringRedisTemplate.hasKey(Constants.REDIS_STOCK_KILL + sid)) {    //     System.out.println("秒杀活动已结束...");    //     return "秒杀活动已结束";    // }    if (stockSoldOutMap.get(sid) != null) {        return "商品已售完";    }    Long increment = stringRedisTemplate.opsForValue().decrement(Constants.REDIS_STOCK_LAST + sid);    if (increment < 0) {        stockSoldOutMap.put(sid, true);        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + sid);        return "商品已售完";    }    System.out.println("秒杀商品id = " + sid);    try {        // 加入用户访问频率限制        long view = userService.saveUserView(uid);        log.info("用户已访问次数:[{}]", view);        boolean isAllowed= userService.getUserView(uid);        if (!isAllowed) {            log.info("购买失败,超过访问频率!");            return "购买失败,超过访问频率!";        }        // 秒杀业务        int orderId = orderService.killtokenmd5(sid, uid, md5);        return "秒杀成功,订单id为:" + orderId;    } catch (Exception e) {        stringRedisTemplate.opsForValue().increment(Constants.REDIS_STOCK_LAST + sid);        if (stockSoldOutMap.get(sid) != null) {            stockSoldOutMap.remove(sid);        }        e.printStackTrace();        return e.getMessage();    }}

测试,依然先获取md5,在用 JMeter 测试,接口及参数/stock/killtokenmd5limit?sid=1&uid=1&md5=6f2c1a31e4b297c4f85c166c09009ec6访问 20 次结果如下,因为限制10次,所以10次后被限制,也就是限制为10/(h*u)单用户每小时限制访问10次

秒杀demo

总结

这仅仅是一个小demo,还有好多要学习

继续努力吧

上一篇:面试必备知识点:悲观锁和乐观锁的那些事儿


下一篇:python获取股票列表基础信息数据