RocketMQ實現優惠券秒殺

一枚来自门头沟的码农發表於2024-11-05

秒殺

07-秒殺.jpg

架構圖

image.png

準備資料庫

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 ......

設計:(搶優惠券...)

  1. 設計seckill-web接收處理秒殺請求
  2. 設計seckill-service處理秒殺真實業務的

部署細節: 2C 2B

  1. 使用者量:50w
  2. QPS:2w+ 自己打日誌、Nginx(access.log)
  3. 日活量:1w-2w 1%-5%
  4. 幾臺伺服器(什麼配置)
  5. 頻寬

相關文章