参考资料:编程不良人
视频教程:https://www.bilibili.com/video/BV13a4y1t7Wh
参考内容:https://github.com/engureguo/miaosha
项目源码:https://gitee.com/gengkunyuan/second-kill-case
导入依赖:
<dependencies>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<scope>runtimescope>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>druidartifactId>
<version>1.2.6version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
dependencies>
配置文件:
server.port=8999
server.servlet.context-path=/ms
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/ms
spring.datasource.username=root
spring.datasource.password=88888888
mybatis.mapper-locations=classpath:com/lut/mapper/*.xml
mybatis.type-aliases-package=com.lut.entity
logging.level.root=info
logging.level.com.lut.dao=debug
建立数据表和数据库表:
DROP TABLE IF EXISTS `order`;
CREATE TABLE `order` (
`id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`sid` int(11) NULL DEFAULT NULL,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`createDate` datetime(0) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
INSERT INTO `order` VALUES ('001', 1, 'zhangsan', '2021-11-12 00:00:00');
DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '名称',
`count` int(11) NOT NULL COMMENT '库存',
`sale` int(11) NOT NULL COMMENT '已售',
`version` int(11) NOT NULL COMMENT '乐观锁,版本号',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
INSERT INTO `stock` VALUES (1, 'IPhoneX', 100, 0, 0);
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
INSERT INTO `user` VALUES (1, 'admin', '123456');
SET FOREIGN_KEY_CHECKS = 1;
商品超卖现象,使用 ApacheJMeter 做压力测试,当多个请求过来时,会创建大量的订单
悲观锁:synchronized锁整个调用的方法
@GetMapping("/kill")
public synchronized String kill(Integer id){
System.out.println("商品ID为:"+id);
try {
//根据秒杀的商品ID,去调用秒杀业务
String orderid=orderService.kill(id);
return "秒杀成功,订单ID为: "+orderid;
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
坑:悲观锁 和 事务作用的范围
起因:使用悲观锁之后,做测试还会出现 “超卖”的现象。
注意!!!
错误说法:业务层加同步代码块
悲观锁大坑!多提交的问题: Transactional和synchronized同时使用初始并发问题。 事务同步范围要比线程同步范围大。 synchronized代码块执行是在事务之内执行的,可以推断在代码块执行完时,事务还未提交,因此其它线程进入synchronized代码块后,读取的数据库数据不是最新的。
解决方法: synchronized同步范围大于事务同步范围,在 业务层kill方法之外进行同步,保证释放锁的时候事务已经提交
改进:↓↓↓
悲观锁:synchronized锁实际调用的方法
@GetMapping("/kill")
public String kill(Integer id){
System.out.println("商品ID为:"+id);
try {
synchronized (this){
//根据秒杀的商品ID,去调用秒杀业务
String orderid=orderService.kill(id);
return "秒杀成功,订单ID为: "+orderid;
}
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
带来的问题:
1、造成线程阻塞,系统吞吐量下降
2、给用户带来的体验不好
说明:使用乐观锁解决商品的超卖问题,实际上是把主要防止超卖问题交给数据库,解决利用数据库中定义的version字段
,以及数据库中的事务
,实现在并发情况下商品的超卖问题
在数据库层面上
过滤掉某些请求
简单修改代码
@Service
@Transactional
public class OrderServiceImpl implements OrderService{
@Autowired
private StockDao stockDao;
@Autowired
private OrderDao orderDao;
@Override
public String kill(Integer id) {
Stock stock=checkStock(id);
updateSale(stock);
return createOrder(stock);
}
//校验库存
private Stock checkStock(Integer id){
Stock stock = stockDao.checkStock(id);
if (stock.getSale().equals(stock.getCount())){
throw new RuntimeException("库存不足!!!");
}
return stock;
}
//扣除库存
private void updateSale(Stock stock){
stock.setSale(stock.getSale()+1);
stockDao.updateSale(stock);
}
//创建订单
private String createOrder(Stock stock){
Order order=new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
order.setId(UUID.randomUUID().toString().substring(0,8));
orderDao.createOrder(order);
return order.getId();
}
}
Dao层:
@Component
public interface StockDao {
//检查库存
Stock checkStock(Integer id);
//扣除库存
void updateSale(Stock stock);
//扣除库存,使用版本号,返回值:数据库操作影响的条数
int updateSaleWithVersion(Stock stock);
}
Service层:
//扣除库存,使用版本号,返回值:数据库操作影响的条数
private void updateSaleWithVersion(Stock stock){
int updaterows=stockDao.updateSaleWithVersion(stock);
if (updaterows==0){
throw new RuntimeException("抢购失败,请重试");
}
}
Mapper文件:
<update id="updateSaleWithVersion" parameterType="Stock">
update stock set
sale=sale+1,version=version+1
where
id=#{id} and version=#{version}
update>
限流:是对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢和宕机
在面临高并发的抢购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大的压力。大量的请求抢购成功时需要调用下单的接口,过多的请求打到数据库会对系统的稳定性造成影响.
常用的限流算法有 令牌桶
和 漏桶(漏斗算法)
,而 Google 开源项目 Guava 中的 RateLimiter 使用的就是令牌桶控制算法。在开发高并发系统时有三把利器用来保护系统:缓存
、降级
和限流
。
直到把桶填满
。后面再产生的令牌就会从桶中溢出
。最后桶中可以保存的最大令牌数永远不会超过桶的大小。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。漏斗算法:平时不常用,因为超过桶的容量的请求会直接被抛弃
令牌桶算法:拿到令牌的请求去执行业务,拿不到令牌的请求可以一直等待直到获取令牌,或在一定的时间内尝试获取令牌,超时后再抛弃
导入依赖:
<dependency>
<groupId>com.google.guavagroupId>
<artifactId>guavaartifactId>
<version>30.1.1-jreversion>
dependency>
private RateLimiter rateLimiter= RateLimiter.create(30);
@GetMapping("/testLimiter")
public String TestRateLimiter(Integer id){
//方案1:没有获取到token请求一直等待获取到token令牌
//log.info("等待时间: "+rateLimiter.acquire());
//方案2:设置等待时间,如果指定时间内没有获取令牌,就抛弃
if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
System.out.println("当前请求被限流,直接抛弃,无法调用后序逻辑");
return "当前人数过多,请重试!";
}
System.out.println("处理业务.....");
return "抢购成功";
}
多个请求进来之后,部分请求被限流,导致数据库中的商品大部分被卖出,有一小部分商品不能卖出,属于正常现象,这些商品可用于后面的退换货
好处:库存备份
增大销售数量:1.更多的请求 2.增大超时时间 3.放行的请求增多
private RateLimiter rateLimiter= RateLimiter.create(30);
@GetMapping("killwithtoken")
public String killWithToken(Integer id){
System.out.println("秒杀商品的ID=" + id);
if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
log.info("抢购失败,当前活动过于火爆,请稍后重试");
return "抢购失败,当前活动过于火爆,请稍后重试";
}
try {
//根据商品ID去秒杀商品
String orderid=orderService.kill(id);
return "秒杀成功,订单ID为:"+orderid;
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
在前几次课程中,我们完成了防止超卖商品和抢购接口的限流,已经能够防止大流量把我们的服务器直接搞炸,这篇文章中,我们要开始关心一些细节问题。我们现在设计的系统还有一些问题:
1.我们应该在一定的时间内执行秒杀处理,不能再任意时间都接受秒杀请求。如何加入时间验证?
2.对于稍微懂点电脑的,又会动歪脑筋的人来说开始通过抓包方式获取我们的接口地址。然后通过脚本进行抢购怎么办?
3.秒杀开始之后如何限制单个用户的请求频率,即单位时间内限制访问次数?
这个章节主要讲解秒杀系统中,关于抢购(下单)接口相关的单用户防刷措施,主要说几块内容:
限时抢购
抢购接口隐藏(避免F12、抓包来获取接口)
单用户限制频率(单位时间内限制访问次数)
引入Redis配置:
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
<version>2.5.1version>
dependency>
spring.redis.port=6379
spring.redis.host=localhost
spring.redis.database=0
在项目中使用:
@Autowired
StringRedisTemplate stringRedisTemplate;
//秒杀的具体实现
@Override
public String kill(Integer id) {
if (!stringRedisTemplate.hasKey("kill"+id)){
throw new RuntimeException("当前商品的抢购活动已经结束了");
}
Stock stock=checkStock(id);
updateSaleWithVersion(stock);
return createOrder(stock);
}
在Redis-cli中设置 key 的存活时间:
set kill1 1 EX 10
//key: kill+商品ID value:1 EX 设置存活时间为 10s
对于稍微懂点电脑的,又会动歪脑筋的人来说,点击F12打开浏览器的控制台,就能在点击抢购按钮后,获取我们抢购接口的链接。(手机APP等其他客户端可以抓包来拿到)一旦坏蛋拿到了抢购的链接,只要稍微写点爬虫代码,模拟一个抢购请求,就可以不通过点击下单按钮,直接在代码中请求我们的接口,完成下单。所以就有了成千上万的菇羊毛军团,写一些脚本抢购各种秒杀商品。
他们只需要在抢购时刻的000毫秒,开始不间断发起大量请求,觉得比大家在APP上点抢购按钮要快,毕竟人的速度又极限,更别说APP说不定还要经过几层前端验证才会真正发出请求。
添加用户表:
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
INSERT INTO `user` VALUES (1, 'admin', '123456');
控制器:生成MD5
@Autowired
private OrderService orderService;
//根据商品ID和用户ID生成一个MD5
@RequestMapping("md5")
public String getMd5(Integer id,Integer userid){
String md5;
try {
md5=orderService.getMd5(id,userid);
}catch (Exception e){
e.printStackTrace();
return "获取MD5失败:"+e.getMessage();
}
return "获取到的MD5为:"+md5;
}
ServiceImpl:
@Override
public String getMd5(Integer goodid, Integer userid) {
//1.检验用户的合法性
User user=userDao.findById(userid);
if (user==null){
throw new RuntimeException("用户信息不存在!");
}
log.info("用户信息:"+ user);
//2.检验商品的合法性
Stock stock = stockDao.checkStock(goodid);
if (stock==null){
throw new RuntimeException("商品信息不存在!");
}
log.info("商品信息:"+stock);
//3.生成 hashkey:用户ID + 商品ID
String hashkey="KEY_"+userid+"_"+goodid;
//4.生成MD5,随机盐:#$%587!@SOLT ,并放入Redis
String md5Str=DigestUtils.md5DigestAsHex((userid+goodid+"#$%587!@SOLT").getBytes());
stringRedisTemplate.opsForValue().set(hashkey,md5Str,60, TimeUnit.SECONDS);
return md5Str;
}
Dao层:
@Mapper
public interface UserDao {
User findById(Integer id);
}
@Mapper
public interface StockDao {
//检查库存
Stock checkStock(Integer id);
//扣除库存
void updateSale(Stock stock);
//扣除库存,使用版本号,返回值:数据库操作影响的条数
int updateSaleWithVersion(Stock stock);
}
Mapper文件:UserDaoMapper.xml
DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lut.dao.UserDao">
<select id="findById" parameterType="Integer" resultType="User">
select id,name,password from user where id=#{id}
select>
mapper>
Controller:现在需要传递三个参数:商品ID,用户ID,MD5字符串
@Autowired
private OrderService orderService;
private RateLimiter rateLimiter= RateLimiter.create(30);
//乐观锁 + 令牌桶 + MD5
@GetMapping("killWithTokenMd5")
public String killWithTokenMD5(Integer stockid,Integer userId,String md5Str){
System.out.println("秒杀商品的ID=" + stockid);
//令牌桶限流
if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
log.info("抢购失败,当前活动过于火爆,请稍后重试");
return "抢购失败,当前活动过于火爆,请稍后重试";
}
try {
//根据商品ID去秒杀商品,在这里先 Redis中判断 MD5
String orderid=orderService.kill(stockid,userId,md5Str);
return "秒杀成功,订单ID为:"+orderid;
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
ServiceImpl:秒杀实现,传递三个参数
@Override
public String kill(Integer stockid, Integer userId, String md5Str) {
//校验Redis中的秒杀商品是否超时
// if (!stringRedisTemplate.hasKey("kill"+stockid)){
// throw new RuntimeException("当前商品的抢购活动已经结束了");
// }
//验证传递过来的签名是否有效
String hashkey="KEY_"+userId+"_"+stockid;
String redisStr=stringRedisTemplate.opsForValue().get(hashkey);
if (redisStr==null){
throw new RuntimeException("没有携带验证签名,请求不合法");
}
if (!redisStr.equals(md5Str)){
throw new RuntimeException("当前请求数据不合法,请重试!");
}
Stock stock=checkStock(stockid);
updateSaleWithVersion(stock);
return createOrder(stock);
}
先访问MD5接口,获取MD5字符串,并放到Redis中:
http://localhost:8999/ms/stock/md5?id=1&userid=1
再访问订单接口,传递参数:商品ID、用户ID、MD5字符串
http://localhost:8999/ms/stock/killWithTokenMd5?stockid=1&userId=1&md5Str=7177028f5b50bd46a448263d4cff1183
假设我们做好了接口隐藏,但是像我上面说的,总有无聊的人会写一个复杂的脚本,先请求hash值,再立刻请求购买,如果你的app下单按钮做的很差,大家都要开抢后0.5秒才能请求成功,那可能会让脚本依然能够在大家前面抢购成功。
我们需要在做一个额外的措施,来限制单个用户的抢购频率。
其实很简单的就能想到用redis给每个用户做访问统计,甚至是带上商品id,对单个商品做访问统计,这都是可行的。我们先实现一个对用户的访问频率限制,我们在用户申请下单时,检查用户的访问次数,超过访问次数,则不让他下单!
修改Controller:
//乐观锁 + 令牌桶 + MD5(接口隐藏) + 单用户访问频率限制
@GetMapping("/killWithTokenMD5Count")
public String killWithTokenMD5Count(Integer stockid,Integer userId,String md5Str){
System.out.println("秒杀商品的ID=" + stockid);
//1.令牌桶限流
if (!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
log.info("抢购失败,当前活动过于火爆,请稍后重试");
return "抢购失败,当前活动过于火爆,请稍后重试";
}
try {
//2.加入单用户限制调用频率
//2.1 Redis中加计数
int count=userService.saveUserCount(userId);
log.info("用户"+userId+"截止此次的访问次数为: "+count);
//2.2 判断是否超过次数
boolean isBanned=userService.getUserCount(userId);
if (isBanned){
return "您的点击过于频繁,请稍后再试!";
}
//3.根据商品ID去秒杀商品,在这里先 Redis中判断 MD5
String orderid=orderService.kill(stockid,userId,md5Str);
return "秒杀成功,订单ID为:"+orderid;
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
UserService:
public interface UserService {
boolean getUserCount(Integer userId);
int saveUserCount(Integer userId);
}
UserServiceImpl:在Redis中存放访问次数
/**
* @Author: GengKY
* @Date: 2021/11/11 18:14
*/
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService{
@Autowired
private StockDao stockDao;
@Autowired
private OrderDao orderDao;
@Autowired
private UserDao userDao;
@Autowired
StringRedisTemplate stringRedisTemplate;
@Override
public String kill(Integer stockid, Integer userId, String md5Str) {
//校验Redis中的秒杀商品是否超时
// if (!stringRedisTemplate.hasKey("kill"+stockid)){
// throw new RuntimeException("当前商品的抢购活动已经结束了");
// }
//验证传递过来的签名是否有效
String hashkey="KEY_"+userId+"_"+stockid;
String redisStr=stringRedisTemplate.opsForValue().get(hashkey);
if (redisStr==null){
throw new RuntimeException("没有携带验证签名,请求不合法");
}
if (!redisStr.equals(md5Str)){
throw new RuntimeException("当前请求数据不合法,请重试!");
}
Stock stock=checkStock(stockid);
updateSaleWithVersion(stock);
return createOrder(stock);
}
//秒杀的具体实现
@Override
public String kill(Integer id) {
if (!stringRedisTemplate.hasKey("kill"+id)){
throw new RuntimeException("当前商品的抢购活动已经结束了");
}
Stock stock=checkStock(id);
updateSaleWithVersion(stock);
return createOrder(stock);
}
@Override
public String getMd5(Integer goodid, Integer userid) {
//1.检验用户的合法性
User user=userDao.findById(userid);
if (user==null){
throw new RuntimeException("用户信息不存在!");
}
log.info("用户信息:"+ user);
//2.检验商品的合法性
Stock stock = stockDao.checkStock(goodid);
if (stock==null){
throw new RuntimeException("商品信息不存在!");
}
log.info("商品信息:"+stock);
//3.生成 hashkey:用户ID + 商品ID
String hashkey="KEY_"+userid+"_"+goodid;
//4.生成MD5,随机盐:#$%587!@SOLT ,并放入Redis
String md5Str=DigestUtils.md5DigestAsHex((userid+goodid+"#$%587!@SOLT").getBytes());
stringRedisTemplate.opsForValue().set(hashkey,md5Str,60, TimeUnit.SECONDS);
return md5Str;
}
//校验库存
private Stock checkStock(Integer id){
Stock stock = stockDao.checkStock(id);
if (stock.getSale().equals(stock.getCount())){
throw new RuntimeException("库存不足!!!");
}
return stock;
}
//扣除库存
private void updateSale(Stock stock){
stock.setSale(stock.getSale()+1);
stockDao.updateSale(stock);
}
//扣除库存,使用版本号,返回值:数据库操作影响的条数
private void updateSaleWithVersion(Stock stock){
int updaterows=stockDao.updateSaleWithVersion(stock);
if (updaterows==0){
throw new RuntimeException("抢购失败,请重试");
}
}
//创建订单
private String createOrder(Stock stock){
Order order=new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
order.setId(UUID.randomUUID().toString().substring(0,8));
orderDao.createOrder(order);
return order.getId();
}
}