优惠券秒杀的背后原理

秒杀

架构图

准备数据库

sql 复制代码
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 复制代码
<?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

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

java 复制代码
@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 复制代码
<?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文件

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

逆向生成实体类等

修改启动类

java 复制代码
@SpringBootApplication  
@MapperScan(basePackages = {"com.xyf.mapper"})  
public class SpikeServiceApplication {  
  
    public static void main(String[] args) {  
        SpringApplication.run(SpikeServiceApplication.class, args);  
    }  
  
}

修改GoodsMapper

java 复制代码
List<Goods> selectSeckillGoods();

修改GoodsMapper.xml

xml 复制代码
<select id="selectSeckillGoods" resultType="com.xyf.domain.Goods">  
  select id, stocks from goods where `status` = 2  
</select>

同步mysql数据到redis

java 复制代码
/**  
 * 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());  
        });  
    }  
  
}

创建秒杀监听

java 复制代码
@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

java 复制代码
/**  
 * 真正处理秒杀的业务  
 * @param userId  
 * @param goodsId  
 */  
void realSeckill(Integer userId, Integer goodsId);

修改GoodsServiceImpl

java 复制代码
@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 ...

设计:(抢优惠券...)

  1. 设计seckill-web接收处理秒杀请求
  2. 设计seckill-service处理秒杀真实业务的

部署细节: 2C 2B

  1. 用户量:50w
  2. QPS:2w+ 自己打日志、Nginx(access.log)
  3. 日活量:1w-2w 1%-5%
  4. 几台服务器(什么配置):8C16G 6台 seckill-web:4台 seckill-service:2台
  5. 带宽:100M

技术要点:

  1. 通过Redis的setnx对用户和商品做去重判断,防止用户刷接口的行为
  2. 每天晚上八点通过定时任务 把mysql中参与秒杀的库存商品,同步到Redis中去,做库存的预扣减,提升接口性能
  3. 通过RocketMQ消息中间件的异步消息,来将秒杀的业务异步化,进一步提升性能
  4. seckill-service使用并发消费模式,并且设置合理的线程数量,快速处理队列中堆积的消息
  5. 使用Redis的分布式锁+自旋锁,对商品的库存进行并发控制,把并发压力转移到程序中和Redis中去,减少db的压力
  6. 使用声明式事务注解Transactional,并且设置异常回滚类型,控制数据库的原子操作
  7. 使用JMeter压测工具,对秒杀接口进行压力测试,在8C16G的服务器上,QPS2k+,达到压测预期
相关推荐
我叫啥都行1 分钟前
计算机基础复习12.22
java·jvm·redis·后端·mysql
寻找沙漠的人7 分钟前
JavaEE 导读与环境配置
java·spring boot·java-ee
Zmxcl-00717 分钟前
IIS解析漏洞
服务器·数据库·microsoft
taoyong00118 分钟前
Java线程核心01-中断线程的理论原理
java·开发语言
Yhame.44 分钟前
Java 集合框架中的 List、ArrayList 和 泛型 实例
java
coding侠客44 分钟前
Spring Boot 多数据源解决方案:dynamic-datasource-spring-boot-starter 的奥秘
java·spring boot·后端
明矾java1 小时前
Mysql-SQL执行流程解析
数据库·sql·mysql
委婉待续1 小时前
java抽奖系统(八)
java·开发语言·状态模式
蓬莱道人1 小时前
BenchmarkSQL使用教程
数据库