优惠券秒杀的背后原理
秒杀
架构图
准备数据库
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for goods
-- ----------------------------
DROP TABLE IF EXISTS `goods`;
CREATE TABLE `goods` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`goods_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`price` decimal(10, 2) NULL DEFAULT NULL,
`stocks` int(255) NULL DEFAULT NULL,
`status` int(255) NULL DEFAULT NULL,
`pic` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`create_time` datetime(0) NULL DEFAULT NULL,
`update_time` datetime(0) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of goods
-- ----------------------------
INSERT INTO `goods` VALUES (1, '小米12s', 4999.00, 1000, 2, 'xxxxxx', '2023-02-23 11:35:56', '2023-02-23 16:53:34');
INSERT INTO `goods` VALUES (2, '华为mate50', 6999.00, 10, 2, 'xxxx', '2023-02-23 11:35:56', '2023-02-23 11:35:56');
INSERT INTO `goods` VALUES (3, '锤子pro2', 1999.00, 100, 1, NULL, '2023-02-23 11:35:56', '2023-02-23 11:35:56');
-- ----------------------------
-- Table structure for order_records
-- ----------------------------
DROP TABLE IF EXISTS `order_records`;
CREATE TABLE `order_records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NULL DEFAULT NULL,
`order_sn` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`goods_id` int(11) NULL DEFAULT NULL,
`create_time` datetime(0) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
创建项目seckill-web(接收用户秒杀请求)
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.11</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.xyf</groupId>
<artifactId>e-seckill-web</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</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>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
修改配置文件application.yml
[server:
port: 8081
tomcat:
threads:
max: 400
spring:
redis:
host: localhost
port: 16379
database: 0
rocketmq:
name-server: 127.0.0.1:9876](<server:
port: 8001
tomcat:
threads:
max: 400
spring:
application:
name: seckill-web
redis:
host: 127.0.0.1
port: 16379
database: 0
lettuce:
pool:
enabled: true
max-active: 100
max-idle: 20
min-idle: 5
rocketmq:
name-server: 127.0.0.1:9876 # rocketMq的nameServer地址
producer:
group: seckill-producer-group # 生产者组别
send-message-timeout: 3000 # 消息发送的超时时间
retry-times-when-send-async-failed: 2 # 异步消息发送失败重试次数
max-message-size: 4194304 # 消息的最大长度>)
创建SecKillController
@RestController
public class SeckillController {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 压测时自动是生成用户id
*/ AtomicInteger ai = new AtomicInteger(0);
/**
* 1.一个用户针对一种商品只能抢购一次
* 2.做库存的预扣减 拦截掉大量无效请求
* 3.放入mq 异步化处理订单
*
* @return
*/
@GetMapping("doSeckill")
public String doSeckill(Integer goodsId /*, Integer userId*/) {
int userId = ai.incrementAndGet();
// unique key 唯一标记 去重
String uk = userId + "-" + goodsId;
// set nx set if not exist
Boolean flag = redisTemplate.opsForValue().setIfAbsent("seckillUk:" + uk, "");
if (!flag) {
return "您以及参与过该商品的抢购,请参与其他商品抢购!";
}
// 假设库存已经同步了 key:goods_stock:1 val:10 Long count = redisTemplate.opsForValue().decrement("goods_stock:" + goodsId);
// getkey java setkey 先查再写 再更新 有并发安全问题
if (count < 0) {
return "该商品已经被抢完,请下次早点来哦O(∩_∩)O";
}
// 放入mq
HashMap<String, Integer> map = new HashMap<>(4);
map.put("goodsId", goodsId);
map.put("userId", userId);
rocketMQTemplate.asyncSend("seckillTopic3", JSON.toJSONString(map), new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println("发送成功" + sendResult.getSendStatus());
}
@Override
public void onException(Throwable throwable) {
System.err.println("发送失败" + throwable);
}
});
return "拼命抢购中,请稍后去订单中心查看";
}
}
创建项目seckill-service(处理秒杀)
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.11</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.xyf</groupId>
<artifactId>f-seckill-service</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</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>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
修改yml文件
server:
port: 8002
spring:
application:
name: seckill-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:13306/spike?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: root
password: 123456
redis:
host: 127.0.0.1
port: 16379
database: 0
lettuce:
pool:
enabled: true
max-active: 100
max-idle: 20
min-idle: 5
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath*:mapper/*.xml
rocketmq:
name-server: 127.0.0.1:9876
逆向生成实体类等
修改启动类
@SpringBootApplication
@MapperScan(basePackages = {"com.xyf.mapper"})
public class SpikeServiceApplication {
public static void main(String[] args) {
SpringApplication.run(SpikeServiceApplication.class, args);
}
}
修改GoodsMapper
List<Goods> selectSeckillGoods();
修改GoodsMapper.xml
<select id="selectSeckillGoods" resultType="com.xyf.domain.Goods">
select id, stocks from goods where `status` = 2
</select>
同步mysql数据到redis
/**
* 1. 每天10点 晚上8点 通过定时任务 将mysql的库存 同步到redis中去
* 2. 为了测试方便 希望项目启动的时候 就同步数据
*/
@Component
public class DataSync {
@Resource
private GoodsMapper goodsMapper;
@Resource
private StringRedisTemplate redisTemplate;
// @Scheduled(cron = "* * 10 * * ? ")
// public void initData() {
//
// }
/**
* 我希望这个方法在项目启动之后
* 并且在这个类的属性注入完毕以后
*
* bean的生命周期
*
* 实例化 new
* 属性复制
* 初始化 (前PostConstruct/中InitializingBean/后BeanPostProcessor)自定义的一个initMethod方法
* 使用
* 销毁
* -------------
*/ @PostConstruct
public void initData() {
List<Goods> goodsList = goodsMapper.selectSeckillGoods();
if (CollectionUtils.isEmpty(goodsList)) {
return;
}
goodsList.forEach(goods -> {
redisTemplate.opsForValue().set("goodsId:" + goods.getId(), goods.getStocks().toString());
});
}
}
创建秒杀监听
@Component
@RocketMQMessageListener(topic = "seckillTopic3", consumerGroup = "seckill-consumer-group")
public class SeckillMsgListener implements RocketMQListener<MessageExt> {
@Autowired
private GoodsService goodsService;
@Autowired
private StringRedisTemplate redisTemplate;
// 20s
int time = 20000;
@Override
public void onMessage(MessageExt message) {
String s = new String(message.getBody());
JSONObject jsonObject = JSON.parseObject(s);
Integer goodsId = jsonObject.getInteger("goodsId");
Integer userId = jsonObject.getInteger("userId");
// 做真实的抢购业务 减库存 写订单表 todo 答案2 但是不符合分布式
// synchronized (SeckillMsgListener.class) {
// goodsService.realDoSeckill(goodsId, userId);
// }
// 自旋锁 一般 mysql 每秒1500/s写 看数量 合理的设置自旋时间 todo 答案3
int current = 0;
while (current <= time) {
// 一般在做分布式锁的情况下 会给锁一个过期时间 防止出现死锁的问题
Boolean flag = redisTemplate.opsForValue().setIfAbsent("goods_lock:" + goodsId, "", 10, TimeUnit.SECONDS);
if (flag) {
try {
goodsService.realSeckill(goodsId, userId);
return;
} finally {
redisTemplate.delete("goods_lock:" + goodsId);
}
} else {
current += 200;
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
修改GoodsService
/**
* 真正处理秒杀的业务
* @param userId
* @param goodsId
*/
void realSeckill(Integer userId, Integer goodsId);
修改GoodsServiceImpl
@Service
public class GoodsServiceImpl implements GoodsService {
@Autowired
private GoodsMapper goodsMapper;
@Autowired
private OrderRecordsMapper orderRecordsMapper;
@Override
public int deleteByPrimaryKey(Integer id) {
return goodsMapper.deleteByPrimaryKey(id);
}
@Override
public int insert(Goods record) {
return goodsMapper.insert(record);
}
@Override
public int insertSelective(Goods record) {
return goodsMapper.insertSelective(record);
}
@Override
public Goods selectByPrimaryKey(Integer id) {
return goodsMapper.selectByPrimaryKey(id);
}
@Override
public int updateByPrimaryKeySelective(Goods record) {
return goodsMapper.updateByPrimaryKeySelective(record);
}
@Override
public int updateByPrimaryKey(Goods record) {
return goodsMapper.updateByPrimaryKey(record);
}
/**
* @param goodsId
* @param userId
*/
@Override
@Transactional(rollbackFor = RuntimeException.class)
public void realSeckill(Integer goodsId, Integer userId) {
// 扣减库存 插入订单表
Goods goods = goodsMapper.selectByPrimaryKey(goodsId);
int finalStock = goods.getStocks() - 1;
if (finalStock < 0) {
// 只是记录日志 让代码停下来 这里的异常用户无法感知
throw new RuntimeException("库存不足:" + goodsId);
}
goods.setStocks(finalStock);
goods.setUpdateTime(new Date());
// insert 要么成功 要么报错 update 会出现i<=0的情况
// update goods set stocks = 1 where id = 1 没有行锁
int i = goodsMapper.updateByPrimaryKey(goods);
if (i > 0) {
// 写订单表
OrderRecords orderRecords = new OrderRecords();
orderRecords.setGoodsId(goodsId);
orderRecords.setUserId(userId);
orderRecords.setCreateTime(new Date());
// 时间戳生成订单号
orderRecords.setOrderSn(String.valueOf(System.currentTimeMillis()));
orderRecordsMapper.insert(orderRecords);
}
}
}
/**
* mysql行锁 innodb 行锁
* 分布式锁
* todo 答案1
*
* @param goodsId
* @param userId
*/
// @Override
// @Transactional(rollbackFor = RuntimeException.class)
// public void realDoSeckill(Integer goodsId, Integer userId) {
// // update goods set stocks = stocks - 1 ,update_time = now() where id = #{value}
// int i = goodsMapper.updateStocks(goodsId);
// if (i > 0) {
// // 写订单表
// OrderRecords orderRecords = new OrderRecords();
// orderRecords.setGoodsId(goodsId);
// orderRecords.setUserId(userId);
// orderRecords.setCreateTime(new Date());
// // 时间戳生成订单号
// orderRecords.setOrderSn(String.valueOf(System.currentTimeMillis()));
// orderRecordsMapper.insert(orderRecords);
// }
// }
秒杀总结
技术选型:SpringBoot + Redis + MySQL + RocketMQ + Security …
设计:(抢优惠券…)
- 设计seckill-web接收处理秒杀请求
- 设计seckill-service处理秒杀真实业务的
部署细节: 2C 2B
- 用户量:50w
- QPS:2w+ 自己打日志、Nginx(access.log)
- 日活量:1w-2w 1%-5%
- 几台服务器(什么配置):8C16G 6台 seckill-web:4台 seckill-service:2台
- 带宽:100M
技术要点:
- 通过Redis的setnx对用户和商品做去重判断,防止用户刷接口的行为
- 每天晚上八点通过定时任务 把mysql中参与秒杀的库存商品,同步到Redis中去,做库存的预扣减,提升接口性能
- 通过RocketMQ消息中间件的异步消息,来将秒杀的业务异步化,进一步提升性能
- seckill-service使用并发消费模式,并且设置合理的线程数量,快速处理队列中堆积的消息
- 使用Redis的分布式锁+自旋锁,对商品的库存进行并发控制,把并发压力转移到程序中和Redis中去,减少db的压力
- 使用声明式事务注解Transactional,并且设置异常回滚类型,控制数据库的原子操作
- 使用JMeter压测工具,对秒杀接口进行压力测试,在8C16G的服务器上,QPS2k+,达到压测预期