秒杀项目真的是早有耳闻,可以说是大火有一阵子,因为这其中涉及高并发、数据库、缓存,更有甚者还有分布式、分库分表、集群等。
这次有机会跟着视频学习了一点秒杀系统,这里做个总结
参考
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
项目框架
-- ----------------------------
-- 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 |
订单表为空
一次请求后
sale+1,变为1,增加了一条订单,其他没有变化
JMeter测试
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
控制台
查看Redis,没有问题
看到设置了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 同样 失败
用户限制
限制用户访问频率
利用 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,还有好多要学习
继续努力吧