秒杀微服务实现抢购代金券功能

文章目录

*
需求分析
秒杀场景的解决方案
数据库表设计

+ 代金券表
+ 抢购活动表
+ 订单表
创建秒杀服务

+ pom依赖
+ 配置文件
关系型数据库实现代金券秒杀

+ 相关实体引入
+
* 抢购代金券活动信息
* 代金券订单信息
+ Rest配置类
+ 全局异常处理
+ 添加代金券秒杀活动
+
* 代金券活动实体
* 代金券活动Mapper->SeckillVouchersMapper
* 代金券活动Service->SeckillService
* 代金券活动Controller->SeckillController
* 在网关微服务中配置秒杀服务路由和白名单方向
* 接口测试
+ 对抢购的代金券下单
+
* SeckillController
* SeckillService
* 代金券订单 VoucherOrdersMapper
* 秒杀代金券活动 SeckillVouchersMapper
* 测试验证
压力测试

+ 下载安装JMeter
+ 初始化2000个用户数据
+ 认证微服务生产2000个token
+ 测试多人抢购代金券
+ 测试同一用户抢购多次代金券

需求分析

现在日常购物或者餐饮消费,商家经常会有推出代金券功能,有些时候代金券的数量不多是需要抢购的,那么怎么设计可以保证代金券的消耗量和秒杀到的用户保持一致呢?怎么设计可以保证一个用户只能秒杀到一张代金券呢?

秒杀场景的解决方案

秒杀场景有以下几个特点:

  • 大量用户同时进行抢购操作,系统流量激增,服务器瞬时压力很大;
  • 请求数量远大于商品库存量,只有少数客户可以成功抢购;
  • 业务流程不复杂,核心功能是下订单。

秒杀场景的应对,一般要从以下几个方面进行处理,如下:

  1. 限流:从客户端层面考虑,限制单个客户抢购频率;服务端层面,加强校验,识别请求是否来源于真实的客户端,并限制请求频率,防止恶意刷单;应用层面,可以使用漏桶算法或令牌桶算法实现应用级限流。
  2. 缓存:热点数据都从缓存获得,尽可能减小数据库的访问压力;
  3. 异步:客户抢购成功后立即返回响应,之后通过消息队列,异步处理后续步骤,如发短信、更新数据库等,从而缓解服务器峰值压力。
  4. 分流:单台服务器肯定无法应对抢购期间大量请求造成的压力,需要集群部署服务器,通过负载均衡共同处理客户端请求,分散压力。

数据库表设计

本文以抢购代金券为例,来进行数据库表的设计。

代金券表

CREATE TABLE t_voucher  (
  id int(10) NOT NULL AUTO_INCREMENT,
  title varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '代金券标题',
  thumbnail varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '缩略图',
  amount int(11) NULL DEFAULT NULL COMMENT '抵扣金额',
  price decimal(10, 2) NULL DEFAULT NULL COMMENT '售价',
  status int(10) NULL DEFAULT NULL COMMENT '-1=过期 0=下架 1=上架',
  expire_time datetime(0) NULL DEFAULT NULL COMMENT '过期时间',
  redeem_restaurant_id int(10) NULL DEFAULT NULL COMMENT '验证餐厅',
  stock int(11) NULL DEFAULT 0 COMMENT '库存',
  stock_left int(11) NULL DEFAULT 0 COMMENT '剩余数量',
  description varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述信息',
  clause varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '使用条款',
  create_date datetime(0) NULL DEFAULT NULL,
  update_date datetime(0) NULL DEFAULT NULL,
  is_valid tinyint(1) NULL DEFAULT NULL,
  PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC;

抢购活动表

CREATE TABLE t_seckill_vouchers  (
  id int(11) NOT NULL AUTO_INCREMENT,
  fk_voucher_id int(11) NULL DEFAULT NULL,
  amount int(11) NULL DEFAULT NULL,
  start_time datetime(0) NULL DEFAULT NULL,
  end_time datetime(0) NULL DEFAULT NULL,
  is_valid int(11) NULL DEFAULT NULL,
  create_date datetime(0) NULL DEFAULT NULL,
  update_date datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC;

订单表

CREATE TABLE t_voucher_order  (
  id int(11) NOT NULL AUTO_INCREMENT,
  order_no int(11) NULL DEFAULT NULL,
  fk_voucher_id int(11) NULL DEFAULT NULL,
  fk_diner_id int(11) NULL DEFAULT NULL,
  qrcode varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '图片地址',
  payment tinyint(4) NULL DEFAULT NULL COMMENT '0=微信支付 1=支付宝支付',
  status tinyint(1) NULL DEFAULT NULL COMMENT '订单状态:-1=已取消 0=未支付 1=已支付 2=已消费 3=已过期',
  fk_seckill_id int(11) NULL DEFAULT NULL COMMENT '如果是抢购订单时,抢购订单的id',
  order_type int(11) NULL DEFAULT NULL COMMENT '订单类型:0=正常订单 1=抢购订单',
  create_date datetime(0) NULL DEFAULT NULL,
  update_date datetime(0) NULL DEFAULT NULL,
  is_valid int(11) NULL DEFAULT NULL,
  PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC;

创建秒杀服务

pom依赖

引入相关依赖如下:

    <dependencies>

        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
        dependency>

        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>

        <dependency>
            <groupId>org.mybatis.spring.bootgroupId>
            <artifactId>mybatis-spring-boot-starterartifactId>
        dependency>

        <dependency>
            <groupId>mysqlgroupId>
            <artifactId>mysql-connector-javaartifactId>
        dependency>

        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-data-redisartifactId>
        dependency>

        <dependency>
            <groupId>com.zjqgroupId>
            <artifactId>commonsartifactId>
            <version>1.0-SNAPSHOTversion>
        dependency>
        <dependency>
            <groupId>org.redissongroupId>
            <artifactId>redisson-spring-boot-starterartifactId>
            <version>3.13.6version>
        dependency>
    dependencies>

配置文件

server:
  port: 7003

spring:
  application:
    name: ms-seckill

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/seckill?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false

  redis:
    port: 6379
    host: localhost
    timeout: 3000
    password: 123456

  swagger:
    base-package: com.zjq.seckill
    title: 秒杀微服务API接口文档

eureka:
  instance:
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
  client:
    service-url:
      defaultZone: http://localhost:8080/eureka/

mybatis:
  configuration:
    map-underscore-to-camel-case: true

service:
  name:
    ms-oauth-server: http://ms-oauth2-server/

logging:
  pattern:
    console: '%d{HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n'

关系型数据库实现代金券秒杀

相关实体引入

抢购代金券活动信息

秒杀微服务实现抢购代金券功能
; 代金券订单信息

秒杀微服务实现抢购代金券功能

Rest配置类


@Configuration
public class RestTemplateConfiguration {

    @LoadBalanced
    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        converter.setSupportedMediaTypes(Collections.singletonList(MediaType.TEXT_PLAIN));
        restTemplate.getMessageConverters().add(converter);
        return restTemplate;
    }

}

全局异常处理


@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @Resource
    private HttpServletRequest request;

    @ExceptionHandler(ParameterException.class)
    public ResultInfo<Map<String, String>> handlerParameterException(ParameterException ex) {
        String path = request.getRequestURI();
        ResultInfo<Map<String, String>> resultInfo =
                ResultInfoUtil.buildError(ex.getErrorCode(), ex.getMessage(), path);
        return resultInfo;
    }

    @ExceptionHandler(Exception.class)
    public ResultInfo<Map<String, String>> handlerException(Exception ex) {
        log.info("未知异常:{}", ex);
        String path = request.getRequestURI();
        ResultInfo<Map<String, String>> resultInfo =
                ResultInfoUtil.buildError(path);
        return resultInfo;
    }

}

添加代金券秒杀活动

代金券活动实体

上述已引入实体。

代金券活动Mapper->SeckillVouchersMapper

public interface SeckillVouchersMapper {

    @Insert("insert into t_seckill_vouchers (fk_voucher_id, amount, start_time, end_time, is_valid, create_date, update_date) " +
            " values (#{fkVoucherId}, #{amount}, #{startTime}, #{endTime}, 1, now(), now())")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int save(SeckillVouchers seckillVouchers);

    @Select("select id, fk_voucher_id, amount, start_time, end_time, is_valid " +
            " from t_seckill_vouchers where fk_voucher_id = #{voucherId}")
    SeckillVouchers selectVoucher(Integer voucherId);

}
代金券活动Service->SeckillService

@Service
public class SeckillService {

    @Resource
    private SeckillVouchersMapper seckillVouchersMapper;

    @Transactional(rollbackFor = Exception.class)
    public void addSeckillVouchers(SeckillVouchers seckillVouchers) {

        AssertUtil.isTrue(seckillVouchers.getFkVoucherId() == null, "请选择需要抢购的代金券");
        AssertUtil.isTrue(seckillVouchers.getAmount() == 0, "请输入抢购总数量");
        Date now = new Date();
        AssertUtil.isNotNull(seckillVouchers.getStartTime(), "请输入开始时间");

        AssertUtil.isNotNull(seckillVouchers.getEndTime(), "请输入结束时间");
        AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "结束时间不能早于当前时间");
        AssertUtil.isTrue(seckillVouchers.getStartTime().after(seckillVouchers.getEndTime()), "开始时间不能晚于结束时间");

         SeckillVouchers seckillVouchersFromDb = seckillVouchersMapper.selectVoucher(seckillVouchers.getFkVoucherId());
         AssertUtil.isTrue(seckillVouchersFromDb != null, "该券已经拥有了抢购活动");

         seckillVouchersMapper.save(seckillVouchers);
    }

}

验证数据库表 t_seckill_vouchers 中是否已经存在该券的秒杀活动:

  • 如果存在则抛出异常;
  • 如果不存在则将添加一个代金券抢购活动到 t_seckill_vouchers 表中;
代金券活动Controller->SeckillController

秒杀微服务实现抢购代金券功能
; 在网关微服务中配置秒杀服务路由和白名单方向
spring:
  application:
    name: ms-gateway
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
      routes:
        - id: ms-seckill
          uri: lb://ms-seckill
          predicates:
            - Path=/seckill/**
          filters:
            - StripPrefix=1

secure:
  ignore:
    urls:

      - /seckill/add
接口测试

对抢购的代金券下单

SeckillController

    @PostMapping("{voucherId}")
    public ResultInfo<String> doSeckill(@PathVariable Integer voucherId, String access_token) {
        ResultInfo resultInfo = seckillService.doSeckill(voucherId, access_token, request.getServletPath());
        return resultInfo;
    }
SeckillService

    public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {

        AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券");
        AssertUtil.isNotEmpty(accessToken, "请登录");

        SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId);
        AssertUtil.isTrue(seckillVouchers == null, "该代金券并未有抢购活动");

        AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束");

        Date now = new Date();
        AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");
        AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");

        AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完了");

        String url = oauthServerName + "user/me?access_token={accessToken}";
        ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
            resultInfo.setPath(path);
            return resultInfo;
        }

        SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
                new SignInDinerInfo(), false);

        VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),
                seckillVouchers.getId());
        AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢");

        int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId());
        AssertUtil.isTrue(count == 0, "该券已经卖完了");

        VoucherOrders voucherOrders = new VoucherOrders();
        voucherOrders.setFkDinerId(dinerInfo.getId());
        voucherOrders.setFkSeckillId(seckillVouchers.getId());
        voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());
        String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();
        voucherOrders.setOrderNo(orderNo);
        voucherOrders.setOrderType(1);
        voucherOrders.setStatus(0);
        count = voucherOrdersMapper.save(voucherOrders);
        AssertUtil.isTrue(count == 0, "用户抢购失败");

        return ResultInfoUtil.buildSuccess(path, "抢购成功");
    }
代金券订单 VoucherOrdersMapper

public interface VoucherOrdersMapper {

    @Select("select id, order_no, fk_voucher_id, fk_diner_id, qrcode, payment," +
            " status, fk_seckill_id, order_type, create_date, update_date, " +
            " is_valid from t_voucher_orders where fk_diner_id = #{userId} " +
            " and fk_voucher_id = #{voucherId} and is_valid = 1 and status between 0 and 1 ")
    VoucherOrders findDinerOrder(@Param("userId") Integer userId,
                                 @Param("voucherId") Integer voucherId);

    @Insert("insert into t_voucher_orders (order_no, fk_voucher_id, fk_diner_id, " +
            " status, fk_seckill_id, order_type, create_date, update_date,  is_valid)" +
            " values (#{orderNo}, #{fkVoucherId}, #{fkDinerId}, #{status}, #{fkSeckillId}, " +
            " #{orderType}, now(), now(), 1)")
    int save(VoucherOrders voucherOrders);

}
秒杀代金券活动 SeckillVouchersMapper

    @Update("update t_seckill_vouchers set amount = amount - 1 " +
            " where id = #{seckillId}")
    int stockDecrease(@Param("seckillId") int seckillId);
测试验证

压力测试

下载安装JMeter

JMeter安装和使用可以参考我这篇文章:压力测试工具-JMeter安装和使用

初始化2000个用户数据

秒杀微服务实现抢购代金券功能
数据库新增2000个用户数据,账号为test0到test1999,密码统一设置为123456。

; 认证微服务生产2000个token

初始化2000个token信息,存储在token.txt文件中。
代码如下:

    @Test
    public void writeToken() throws Exception {
        String authorization = Base64Utils.encodeToString("appId:123456".getBytes());
        StringBuffer tokens = new StringBuffer();
        for (int i = 0; i < 2000; i++) {
            MvcResult mvcResult = super.mockMvc.perform(MockMvcRequestBuilders.post("/oauth/token")
                    .header("Authorization", "Basic " + authorization)
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .param("username", "test" + i)
                    .param("password", "123456")
                    .param("grant_type", "password")
                    .param("scope", "api")
            )
                    .andExpect(status().isOk())

                    .andReturn();
            String contentAsString = mvcResult.getResponse().getContentAsString();
            ResultInfo resultInfo = (ResultInfo) JSONUtil.toBean(contentAsString, ResultInfo.class);
            JSONObject result = (JSONObject) resultInfo.getData();
            String token = result.getStr("accessToken");
            tokens.append(token).append("\r\n");
        }

        Files.write(Paths.get("tokens.txt"), tokens.toString().getBytes());
    }

秒杀微服务实现抢购代金券功能

测试多人抢购代金券

添加一个代金券抢购活动信息:

秒杀微服务实现抢购代金券功能
通过jmeter添加用户测试计划,3000个线程同时发起两千个用户执行测试:
秒杀微服务实现抢购代金券功能
秒杀微服务实现抢购代金券功能
测试后结果如下:
秒杀微服务实现抢购代金券功能
可以看到有些请求是失败的,因为没有做优化,抗不了这么大的并发。然后查看数据库情况发现库存已经超卖,100个库存,卖了230单,库存成了负数😰😰😰。
秒杀微服务实现抢购代金券功能
秒杀微服务实现抢购代金券功能

; 测试同一用户抢购多次代金券

重置数据库数据后,测试同一个用户,1000个线程发起并发请求。

秒杀微服务实现抢购代金券功能
秒杀微服务实现抢购代金券功能
查看数据库发现这一个用户就下了10单。。。
秒杀微服务实现抢购代金券功能

很明显出现了超卖和同一个用户可以多次抢购同一代金券的问题,再后续博客中我会提供基于Redis来解决超卖和同一用户多次抢购的问题。

本文内容到此结束了,
如有收获欢迎点赞👍收藏💖关注✔️,您的鼓励是我最大的动力。
如有错误❌疑问💬欢迎各位指出。
主页共饮一杯无的博客汇总👨‍💻
保持热爱,奔赴下一场山海。🏃🏃🏃

Original: https://blog.csdn.net/qq_35427589/article/details/128062138
Author: 共饮一杯无
Title: 秒杀微服务实现抢购代金券功能

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/785472/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球