【005】从零开始实现一个秒杀系统并优化

项目源码 https://gitee.com/qiuyusy/small-project-study

听说你简历没有亮点?要不来个秒杀,一起来被面试官拷打
image-20230308192143525
本文使用乐观锁/悲观锁/分布式锁分别实现秒杀,并使用Redis + Lua + MQ 实现优化

1. 环境准备

1.1 MySQL

首先我们需要创建如下三张表

image-20230308192143525

CREATE TABLE `tb_goods` (
  `id` bigint NOT NULL,
  `good_name` varchar(255) NOT NULL,
  `price` bigint NOT NULL,
  `stock` int NOT NULL,
  `begin_time` timestamp NOT NULL,
  `end_time` timestamp NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE `tb_order` (
  `id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `good_id` bigint NOT NULL,
  `create_time` timestamp NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE `tb_order` (
  `id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `good_id` bigint NOT NULL,
  `create_time` timestamp NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

1.2 Entity & Mapper

image-20230308192948982

首先完成Entity,为了之后的分布式拓展,主键使用雪花算法

@Data
@TableName("tb_goods")
public class Good {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    private String goodName;
    private Long price;
    private Integer stock;
    private LocalDateTime beginTime;
    private LocalDateTime endTime;
}

@Data
@TableName("tb_order")
public class Order {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    private Long userId;
    private Long goodId;
    private LocalDateTime createTime;
}

@Data
@TableName("tb_user")
public class User {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    private String username;
    private String password;
    private Long point;
}

然后是Mapper层

注意这里有个坑,如果使用Mybatis-Plus的update方法

他生成的sql语句会把所有的字段都set一遍,返回的结果会一直是>0,所以这里update采用xml写

@Mapper
public interface GoodMapper extends BaseMapper<Good> {
    /**
     * 更新商品库存
     * @param goodId 商品ID
     * @param nums 减去的数量
     * @return
     */
    int updateStock(@Param("goodId") Long goodId, @Param("nums") Integer nums);
}
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}
@Mapper
public interface UserMapper extends BaseMapper<User> {
    /**
     * 
     * @param userId 用户ID
     * @param nums 减去点数数量
     * @return
     */
    int updatePoint(@Param("userId") Long userId, @Param("nums") Long nums);
}
<mapper namespace="com.qiuyu.mapper.GoodMapper">

    <update id="updateStock">
        update seckill.tb_goods
            set stock = stock - #{nums}
        where id = #{goodId};
	</update>
</mapper>

<mapper namespace="com.qiuyu.mapper.UserMapper">
    <update id="updatePoint">
        update seckill.tb_user
            set point = tb_user.point - #{nums}
        where id = #{userId};
    </update>
</mapper>

1.3 生成数据

直接使用SpringBootTest写几个例子插入即可

商品表插入数据

@SpringBootTest
class GoodMapperTest {
    @Resource
    private GoodMapper goodMapper;

    @Test
    void testInset(){
        Good good = new Good();
        good.setGoodName("皮卡丘头套");
        good.setStock(100);
        good.setPrice(10L);
        good.setBeginTime(LocalDateTime.of(2023,3,8,3,0,0));
        good.setEndTime(LocalDateTime.of(2023,3,15,3,0,0));
        goodMapper.insert(good);
    }

    @Test
    void updateStock() {
        goodMapper.updateStock(1633188654610382849L,1);
    }
}

用户表插入数据

class UserMapperTest {

    @Resource
    private UserMapper userMapper;

    @Test
    void testInsert(){
        for (int i = 0; i < 100; i++) {
            User user = new User();
            user.setUsername("user_" + i);
            user.setPassword("123456");
            user.setPoint(1000L);
            userMapper.insert(user);
        }

    }
}

2. 流程分析

首先,我们分析一下业务流程是什么样的

用户购买商品,所以我们需要知道用户ID和商品ID来找到他们,所以设计如下接口,获得用户ID和商品ID

image-20230308194402551

然后我们将具体业务放在Service层解决,所以暂时直接调用

@RestController
@RequestMapping("/good")
public class GoodController {
    @Resource
    private GoodService goodService;

    @PostMapping("/buy/{userId}/{goodId}")
    public String buyGood(
            @PathVariable("userId") Long userId,
            @PathVariable("goodId") Long goodId
    ){
        return goodService.buyGood(userId, goodId);
    }
}

然后我们就来设计Service层

Service层需要做的事情如下

  1. 根据用户ID找出用户
  2. 根据商品id找到商品
  3. 判断商品是否在售卖时间(秒杀有时间限制)
  4. 判断商品是否有库存
  5. 判断用户积分是否足够购买商品
  6. 扣除用户积分
  7. 购买商品
  8. 生成订单

那么我们就根据这个流程来编写代码吧

3. 超卖问题代码复现

我们为了方便使用Mybatis-Plus来完成CRUD

package com.qiuyu.service;
/**
 * @author QiuYuSY
 * @create 2023-03-08 3:43
 */
@Service
public class GoodService {
    @Resource
    private GoodMapper goodMapper;
    @Resource
    private UserMapper userMapper;
    @Resource
    private OrderMapper orderMapper;

    @Transactional
    public String buyGood(Long userId, Long goodId) {
        // 1.根据用户ID找出用户
        User user = userMapper.selectOne(new QueryWrapper<User>().eq("id", userId));
        if(user == null){
            return "用户未找到";
        }
        //2. 根据商品id找到商品
        Good good = goodMapper.selectOne(new QueryWrapper<Good>().eq("id", goodId));
        if(good == null){
            return "商品未找到";
        }

        //3.判断是否在售卖时间
        if (LocalDateTime.now().isBefore(good.getBeginTime())) {
            return "未到售卖时间";
        }
        if (LocalDateTime.now().isAfter(good.getEndTime())) {
            return "已过售卖时间";
        }

        // 4. 判断商品是否有库存
        if(good.getStock() <= 0) {
            return "商品售罄";
        }

        // 5. 判断用户积分是否足够
        if(user.getPoint() < good.getPrice()){
            return "积分不足,无法购买";
        }


        // 6. 扣除积分
        int id = userMapper.updatePoint(userId, good.getPrice());
        if(id <= 0){
            throw new RuntimeException("积分不足,无法购买");
        }

        // 7. 购买商品
        int update = goodMapper.updateStock(goodId, 1);

        if(update <= 0){
            throw new RuntimeException("库存发生变化,购买失败");
        }

        // 8. 生成订单
        Order order = new Order();
        order.setUserId(userId);
        order.setGoodId(goodId);
        order.setCreateTime(LocalDateTime.now());
        int insert = orderMapper.insert(order);
        if(insert <= 0){
            throw new RuntimeException("生成订单失败");
        }

        return "ok!";
    }
}

因为要保证扣除积分、购买商品、生成订单的一致性,所以时用@Transactional,并在修改失败的时候抛出异常,进行回滚,所以Controller层也需要处理一下异常。

@RestController
@RequestMapping("/good")
public class GoodController {
    @Resource
    private GoodService goodService;

    @PostMapping("/buy/{userId}/{goodId}")
    public String buyGood(
            @PathVariable("userId") Long userId,
            @PathVariable("goodId") Long goodId
    ){
        String result = null;
        try{
            result = goodService.buyGood(userId, goodId);
        }catch (Exception e){
            result = e.getMessage();
        }
        return result;
    }
}

我们发现一套流程下来行云流水,好像没有丝毫问题。但是真的这样吗

我们使用Jmeter来测试一下吧,商品库存设置100,200个线程直接开跑

image-20230308200118785

image-20230308200106963

跑之前数据库数据

商品表:商品库存为100个

image-20230308200225398

用户表:积分充足

image-20230308200252572

订单表为空:

跑之后数据

商品表:商品库存为-7个

image-20230308202924397
订单表:订单生成了107条

image-20230308202942150

用户表:

image-20230308203000731

为什么商品数量会为负数,只有100个商品却卖出了107单?

这就是秒杀中会出现的超卖问题

那么我们应该如何进行解决呢?接下来我主要从悲观锁、乐观锁、分布式锁三个方面来展示

4. 悲观锁解决

synchronized

Java中的悲观锁,大家最熟悉的肯定就是synchronized

最简单的方法就是直接方法上加锁

image-20230308201053457

但是这样的话,锁的粒度太大,不太推荐

所以我们使用商品的ID的字符串进行锁,intern()表示字符串常量池中的对象

 @Transactional
    public String buyGood(Long userId, Long goodId) {
        synchronized (goodId.toString().intern()) {
            
            // 1.根据用户ID找出用户
            User user = userMapper.selectOne(new QueryWrapper<User>().eq("id", userId));
            if (user == null) {
                return "用户未找到";
            }
            //2. 根据商品id找到商品
            Good good = goodMapper.selectOne(new QueryWrapper<Good>().eq("id", goodId));
            if (good == null) {
                return "商品未找到";
            }

            //3.判断是否在售卖时间
            if (LocalDateTime.now().isBefore(good.getBeginTime())) {
                return "未到售卖时间";
            }
            if (LocalDateTime.now().isAfter(good.getEndTime())) {
                return "已过售卖时间";
            }

            // 4. 判断商品是否有库存
            if (good.getStock() <= 0) {
                return "商品售罄";
            }

            // 5. 判断用户积分是否足够
            if (user.getPoint() < good.getPrice()) {
                return "积分不足,无法购买";
            }

            // 6. 扣除积分
            int id = userMapper.updatePoint(userId, good.getPrice());
            if (id <= 0) {
                throw new RuntimeException("积分不足,无法购买");
            }

            // 7. 购买商品
            int update = goodMapper.updateStock(goodId, 1);

            if (update <= 0) {
                throw new RuntimeException("库存发生变化,购买失败");
            }

            // 8. 生成订单
            Order order = new Order();
            order.setUserId(userId);
            order.setGoodId(goodId);
            order.setCreateTime(LocalDateTime.now());
            int insert = orderMapper.insert(order);
            if (insert <= 0) {
                throw new RuntimeException("生成订单失败");
            }
        }

        return "ok!";
    }

本以为万无一失,但是居然依旧发生了问题,WTF?

image-20230308203313862

分析下原因,我们使用了Spring事务来保证一致性,而锁在事务的内部,也就是说锁释放完后,事务还未提交,这也会产生问题捏。

鼠鼠受不了咧

那么如何解决呢?

在外层,也就是Controller中加锁

@PostMapping("/buy/{userId}/{goodId}")
public String buyGood(
    @PathVariable("userId") Long userId,
    @PathVariable("goodId") Long goodId
){
    String result = null;
    try{
        synchronized (goodId.toString().intern()) {
            result = goodService.buyGood(userId, goodId);
        }
    }catch (Exception e){
        result = e.getMessage();
    }
    return result;
}

ok 完成,成功把锁的粒度从goodService对象降低到了一个商品ID

image-20230308205318673

5. 乐观锁解决

方案一

说到乐观锁大家可能会想到CAS 和 version 字段

这里我就用stock库存字段来代替version字段了

第一步,修改一下Mapper接口,加入stock字段,进行stock的比对

int updateStock(@Param("goodId") Long goodId, @Param("nums") Integer nums, @Param("stock") Integer stock);
<update id="updateStock">
    update seckill.tb_goods
    set stock = stock - #{nums}
    where id = #{goodId} and stock = #{stock};
</update>

记得把悲观锁去掉,我们再来试一下

发现剩下54个库存

image-20230308210444915

居然没卖完,多跑几次,是可以的,也解决了超卖问题

image-20230308210556051

但是大家也都发现了,这种实现效率非常低。

  1. 200个线程进来,发现库存为100,都满足条件,开始准备购买商品。
  2. 但是第一个线程先购买成功,库存变为了99
  3. 其他线程一比较数量,卧槽之前库存为100现在为99,于是全部都购买失败了

也就是同时只有一个线程成功,这个并发太低了

方案二:乐观锁改进

之前是判断库存数和查询时的库存数相等才能购买,其实我们应该是判断库存>0就能购买

修改如下

@Mapper
public interface GoodMapper extends BaseMapper<Good> {
    /**
     * 更新商品库存
     * @param goodId 商品ID
     * @param nums 减去的数量
     * @return
     */
    int updateStock(@Param("goodId") Long goodId, @Param("nums") Integer nums);
}

判断库存>0时就能够购买

<mapper namespace="com.qiuyu.mapper.GoodMapper">

    <update id="updateStock">
        update seckill.tb_goods
            set stock = stock - #{nums}
        where id = #{goodId} and stock > 0;
    </update>
</mapper>

OK,我们再来试一下

跑一次就成功卖完了,鼠鼠哭死

image-20230308211803970

其实这里是利用到了MySQL RR 的排他锁,这样想来这个乐观锁也不是特别乐观捏

6. 分布式锁解决

为了展示分布式下的超卖问题,我们加一个端口8082服务

image-20230308221555454

Nginx配置如下

image-20230308222851379

乐观锁估计是因为加了数据库的锁,无法出现错误

改成悲观锁,测试,出现问题!

image-20230308224151177

方案一:自己实现Redis + Lua

先写一个接口

public interface ILock {
    /**
     * 加锁
     * @param timeoutSec 超时时间
     * @return
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}

这里的释放锁需要使用lua脚本来保证原子性

unock.lua

if (redis.call('GET', KEYS[1]) == ARGV[1]) then
    -- 当前线程的value与redis中value一致,直接删
    return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

然后去实现接口ILock

package com.qiuyu.util;

public class RedisLock implements ILock {
    private StringRedisTemplate stringRedisTemplate;
    private String name; //key

    //因为当前类没有被Spring接管,StringRedisTeplate让调用者传进来
    public RedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }
    private static final String KEY_PREFIX = "lock:"; //key的前缀
    //生成一个UUID随机数用来做value,这是为了防止误删别的线程的锁
    public static final String VALUE = UUID.randomUUID().toString().replaceAll("-", "");
	
    
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        // 静态代码块加载lua脚本
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class); //设置放回值类型
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
                KEY_PREFIX + name,
            	VALUE, 
            	timeoutSec, TimeUnit.SECONDS);
        // 防止拆箱NPE
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        //调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                VALUE);
    }
}

Controller再改下

@PostMapping("/buy/{userId}/{goodId}")
    public String buyGood(
            @PathVariable("userId") Long userId,
            @PathVariable("goodId") Long goodId
    ){
        String result = null;
        ILock lock = new RedisLock(stringRedisTemplate, "good:" + goodId); //拿到锁
        
        boolean isLock = lock.tryLock(10); //加锁
        if(!isLock){
            return "争抢锁失败";
        }
        try{
            result = goodService.buyGood(userId, goodId);
        }catch (Exception e){
            result = e.getMessage();
        }finally {
            lock.unlock(); //解锁
        }
        return result;
    }
}

出现找不到LUA文件的情况,加入如下配置即可

<build>
    <!--将resources目录下的配置文件编译进classes文件  -->
    <resources>
        <resource>
            <directory>src/main/resources</directory>
            <includes>
                <include>**/*.properties</include>
                <include>**/*.yml</include>
                <include>**/*.xml</include>
                <include>**/*.lua</include>
            </includes>
            <filtering>true</filtering>
        </resource>
        <resource>
            <directory>src/main/java</directory>
            <includes>
                <include>**/*.xml</include>
            </includes>
            <filtering>true</filtering>
        </resource>
    </resources>
</build>

测试下,成功

image-20230308232246273

方案二:使用Redisson

我们自己实现的Redis锁有以下问题

image-20230308233502035

Redisson用Hash结构解决了可重入的问题

image-20230308233554757

用看门狗解决了续约的问题

用MutiLock解决了Redis集群的主从一致性问题

实践

引入依赖:建议用这个而不是starter,因为starter会覆盖redis的配置

<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.13.6</version>
</dependency>

配置Redisson客户端:

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        // 配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.222.128:6379");
        // 创建RedissonClient对象
        return Redisson.create(config);
    }
}

然后直接用就行

@RestController
@RequestMapping("/good")
public class GoodController {
    @Resource
    private GoodService goodService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RedissonClient redissonClient;

    @PostMapping("/buy/{userId}/{goodId}")
    public String buyGood(
            @PathVariable("userId") Long userId,
            @PathVariable("goodId") Long goodId
    ){
        String result = null;
        RLock lock = redissonClient.getLock("good:" + goodId);
        //不要给锁设定过期时间才会开启看门狗
        boolean isLock = lock.tryLock(); 
        if(!isLock){
            return "争抢锁失败";
        }
        try{
            result = goodService.buyGood(userId, goodId);
        }catch (Exception e){
            result = e.getMessage();
        }finally {
            lock.unlock();
        }
        return result;
    }
}

测试,也是没问题

image-20230308235027640

7. 异步秒杀优化

异步秒杀优化的思路就是将前面1-5步这些判断逻辑放到redis中,如果可以下单的话,就发送一个消息到MQ中去消费

发送完消息后就无需考虑之后数据库的修改了,这些数据库修改的工作就交给MQ的消费者去做。

此外还可使用Redis的set数据结构,加入限购(购买过无法再买)的功能

  • stock:goodid:110 => 100 库存
  • order:goodid:110 => 1,2,3,4,5,6,7… 购买者

7.1 商品库存存入Redis

添加商品时,加入Redis

@Transactional(rollbackFor = Exception.class)
public void addGood(Good good){
    //存入MySQL
    int result = goodMapper.insert(good);
    if(result <= 0){
        throw new RuntimeException("插入数据库失败");
    }

    // 保存商品到Redis中
    stringRedisTemplate.opsForValue().set("stock:" + good.getId(), String.valueOf(good.getStock()));
}

测试一下

@Test
void addGood() {
    Good good = new Good();
    good.setGoodName("白色帽子装饰");
    good.setPrice(10L);
    good.setStock(100);
    good.setBeginTime(LocalDateTime.now());
    good.setEndTime(LocalDateTime.of(2023,4,1,0,0,0));
    goodService.addGood(good);

}

可以看到加入成功

image-20230309003112844

7.2 LUA脚本编写

我们需要在脚本中对商品的库存和是否购买过进行判断

如果可以购买就返回 0

--- data
-- 商品ID
local goodId = ARGV[1]
-- 用户ID
local userId = ARGV[2]

--- key
-- 商品Key
local stockKey = 'stock:' .. goodId
-- 订单Key
local orderKey = 'order:' .. goodId

--- 业务
-- 判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 库存不足,返回1
    return 1
end
-- 判断用户是否下单 SISMEMBER orderKey userId
if (redis.call('sismember', orderKey, userId) == 1) then
    -- 存在,说明是重复下单,返回2
    return 2
end
-- 扣库存
redis.call('incrby', stockKey, -1)
-- 下单
redis.call('sadd', orderKey, userId)

return 0

7.3 业务代码

需要生成一个雪花算法的订单ID,用于返回给用户

然后调用LUA脚本判断是否能购买

能购买的话,就发送消息给MQ

package com.qiuyu.service;

/**
 * @author QiuYuSY
 * @create 2023-03-08 3:43
 */
@Service
public class GoodService {
    @Resource
    private GoodMapper goodMapper;
    @Resource
    private UserMapper userMapper;
    @Resource
    private OrderMapper orderMapper;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        // 加载lua脚本
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

    @Transactional
    public String buyGood(Long userId, Long goodId) {
        //MP提供的雪花算法工具类,获取一个ID
        Long orderId = IdWorker.getId();

        // 1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                goodId.toString(), userId.toString()
        );
        // 判断有无购买资格
        if(result != 0){
            return result == 1 ? "库存不足" : "不能重复下单";
        }

        // 2. 发送到MQ

        // 3. 返回订单ID
        return orderId.toString();
    }

	//...
}

测试一下,可以看到库存成功-1

image-20230309014358580

再次购买时,无法购买

image-20230309014428116

7.4 Kafka

选一种喜欢的MQ完成消费即可,这里用Kafka演示

导入依赖

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

配置

spring:  
  kafka:
    bootstrap-servers: 192.168.222.128:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
    consumer:
      group-id: test-consumer-group #根据comsumer.properties配置文件中填写
      enable-auto-commit: true #是否自动提交消费者的偏移量
      auto-commit-interval: 3000 #3秒提交一次
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer

编写Event类

采用链式方式编程,写数据的时候舒服点

package com.qiuyu.entity;

import lombok.Getter;

/**
 * @author QiuYuSY
 * @create 2023-03-09 2:24
 */
@Getter
public class Event {
    private String topic; //主题
    private Long userId; //用户ID
    private Long goodId; //商品ID
    private Long orderId; //订单ID

    public Event setTopic(String topic) {
        this.topic = topic;
        return this;
    }

    public Event setUserId(Long userId) {
        this.userId = userId;
        return this;
    }

    public Event setGoodId(Long goodId) {
        this.goodId = goodId;
        return this;
    }

    public Event setOrderId(Long orderId) {
        this.orderId = orderId;
        return this;
    }

    @Override
    public String toString() {
        return "Event{" +
                "topic='" + topic + '\'' +
                ", userId=" + userId +
                ", goodId=" + goodId +
                ", orderId=" + orderId +
                '}';
    }
}

编写生产者

把Event转json然后发过去就行

@Component
public class EventProducer {
    @Resource
    private KafkaTemplate kafkaTemplate;
    @Resource
    private ObjectMapper objectMapper;

    public void fireEvent(Event event) throws JsonProcessingException {
        // 将事件发布到指定的主题,内容为event对象转化的json格式字符串
        kafkaTemplate.send(event.getTopic(), objectMapper.writeValueAsString(event));
    }
}

编写消费者

拿到消息里的信息,然后消费,写到数据库中

package com.qiuyu.event;

@Component
@Slf4j
public class EventConsumer  {
    @Resource
    private ObjectMapper objectMapper;
    @Resource
    private GoodMapper goodMapper;
    @Resource
    private UserMapper userMapper;
    @Resource
    private OrderMapper orderMapper;

    @KafkaListener(topics = {"buy"})
    public void handleCommentMessage(ConsumerRecord record) throws JsonProcessingException {
        if (record == null || record.value() == null) {
            log.error("消息的内容为空!");
            return;
        }
        // 将record.value字符串格式转化为Event对象
        Event event = objectMapper.readValue((String) record.value(), Event.class);
        if (event == null) {
            log.error("消息格式错误!");
            return;
        }

        writeToDB(event);
    }

   	// 写入数据库
    @Transactional(rollbackFor = Exception.class)
    public void writeToDB(Event event) {
        Long goodId = event.getGoodId();
        Long userId = event.getUserId();
        Long orderId = event.getOrderId();

        // 根据商品id找到商品
        Good good = goodMapper.selectOne(new QueryWrapper<Good>().eq("id", goodId));
        if (good == null) {
            return;
        }

        //1. 扣除积分
        int id = userMapper.updatePoint(userId, good.getPrice());
        if (id <= 0) {
            throw new RuntimeException("积分不足,无法购买");
        }

        //2. 购买商品
        int update = goodMapper.updateStock(goodId, 1);

        if (update <= 0) {
            throw new RuntimeException("库存发生变化,购买失败");
        }

        //3. 生成订单
        Order order = new Order();
        order.setId(orderId);
        order.setUserId(userId);
        order.setGoodId(goodId);
        order.setCreateTime(LocalDateTime.now());
        int insert = orderMapper.insert(order);
        if (insert <= 0) {
            throw new RuntimeException("生成订单失败");
        }
    }
}

Service层

加入发送消息即可

@Transactional
public String buyGood(Long userId, Long goodId) throws JsonProcessingException {
    //MP提供的雪花算法工具类,获取一个ID
    Long orderId = IdWorker.getId();

    // 1.执行lua脚本
    Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(),
        goodId.toString(), userId.toString()
    );
    // 判断有无购买资格
    if(result != 0){
        return result == 1 ? "库存不足" : "不能重复下单";
    }

    // 2. 发送到MQ
    Event event = new Event();
    event.setTopic("buy")
        .setGoodId(goodId)
        .setOrderId(orderId)
        .setUserId(userId);

    eventProducer.fireEvent(event);

    // 3. 返回订单ID
    return orderId.toString();
}

测试

发送一个post请求

image-20230309025859907

订单ID相同,成功被消费

image-20230309025923793

再测测并发的情况

写一个测试类,获取user表中所有user的userId,存入userId.txt中

package com.qiuyu;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.qiuyu.entity.User;
import com.qiuyu.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.io.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * @author QiuYuSY
 * @create 2023-03-09 3:05
 */
@SpringBootTest
public class FileTest {
    @Resource
    private UserMapper userMapper;

    @Test
    void writeFile(){
        List<User> users = userMapper.selectList(new QueryWrapper<User>().select("id"));
        List<String> result = new ArrayList<>();
        users.forEach((user)->{
            result.add(user.getId().toString());
        });
        writeDataToTxtFile("userId.txt", "utf-8", result);
    }

    public static String writeDataToTxtFile(String filePath, String charset, List<String> dataList) {
        File txtFile = new File(filePath);// txt文件
        PrintWriter txtWriter = null;// 输出流
        FileOutputStream fo = null;
        OutputStreamWriter os = null;
        try {
            if (dataList.size() != 0) {
                // 如果文件不存在就新建文件
                if (!txtFile.exists()) {
                    txtFile.createNewFile();
                }

                // 获取流
                fo = new FileOutputStream(txtFile);
                os = new OutputStreamWriter(fo, charset);
                txtWriter = new PrintWriter(os);

                // 遍历并输出数据到文件,并防止空行
                for (int i = 0; i < dataList.size(); i++) {
                    if (i == dataList.size() - 1) {
                        txtWriter.print(dataList.get(i));
                    } else {
                        txtWriter.println(dataList.get(i));
                    }
                }

                txtWriter.flush();
                return "写入成功";
            } else {
                System.err.println("数据为空");
                return null;
            }
        } catch (IOException e) {
            e.printStackTrace();
            System.err.println("IOException异常:" + e.getCause().getMessage());
            return null;
        } finally {
            try {
                if (txtWriter != null) {
                    txtWriter.close();
                }
                if (fo != null) {
                    fo.close();
                }
                if (os != null) {
                    os.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
                System.err.println("文件流关闭异常:"+e.getCause().getMessage());
            }
        }
    }
}

image-20230309031636759

然后在jmeter中加入txt

image-20230309032015226

image-20230309032229267

搞了半天鼠鼠要开测了 嘿嘿

image-20230309032340127

成功!

再来看看redis中

image-20230309032445313

image-20230309032453939

也没毛病老铁们

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值