文章目录 需求分析秒杀场景的解决方案数据库表设计代金券表抢购活动表订单表 创建秒杀服务pom依赖配置文件 关系型数据库实现代金券秒杀相关实体引入抢购代金券活动信息代金券订单信息 Rest配置类全局异常处理添加代金
现在日常购物或者餐饮消费,商家经常会有推出代金券功能,有些时候代金券的数量不多是需要抢购的,那么怎么设计可以保证代金券的消耗量和秒杀到的用户保持一致呢?怎么设计可以保证一个用户只能秒杀到一张代金券呢?
秒杀场景有以下几个特点:
秒杀场景的应对,一般要从以下几个方面进行处理,如下:
限流
:从客户端层面考虑,限制单个客户抢购频率;服务端层面,加强校验,识别请求是否来源于真实的客户端,并限制请求频率,防止恶意刷单;应用层面,可以使用漏桶算法或令牌桶算法实现应用级限流。缓存
:热点数据都从缓存获得,尽可能减小数据库的访问压力;异步
:客户抢购成功后立即返回响应,之后通过消息队列,异步处理后续步骤,如发短信、更新数据库等,从而缓解服务器峰值压力。分流
:单台服务器肯定无法应对抢购期间大量请求造成的压力,需要集群部署服务器,通过负载均衡共同处理客户端请求,分散压力。本文以抢购代金券为例,来进行数据库表的设计。
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;
引入相关依赖如下:
<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 redis: port: 6379 host: localhost timeout: 3000 password: 123456 # swagger swagger: base-package: com.zjq.seckill title: 秒杀微服务api接口文档# 配置 Eureka Server 注册中心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'
@Configurationpublic 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; } }
// 将输出的内容写入 ResponseBody 中@RestControllerAdvice @Slf4jpublic 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; }}
上述已引入实体。
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);}
@Servicepublic 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.isTrue(now.after(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 中是否已经存在该券的秒杀活动:
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
@PostMapping("{voucherId}") public ResultInfo<String> doSeckill(@PathVariable Integer voucherId, String access_token) { ResultInfo resultInfo = seckillService.doSeckill(voucherId, access_token, request.getServletPath()); return resultInfo; }
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; } // 这里的data是一个LinkedHashMap,SignInDinerInfo 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, "抢购成功"); }
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);}
@Update("update t_seckill_vouchers set amount = amount - 1 " + " where id = #{seckillId}") int stockDecrease(@Param("seckillId") int seckillId);
JMeter安装和使用可以参考我这篇文章:压力测试工具-JMeter安装和使用
数据库新增2000个用户数据,账号为test0到test1999,密码统一设置为123456。
初始化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()) // .andDo(print()) .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来解决超卖和同一用户多次抢购的问题。
本文内容到此结束了,
如有收获欢迎点赞👍收藏💖关注✔️,您的鼓励是我最大的动力。
如有错误❌疑问💬欢迎各位指出。
主页:共饮一杯无的博客汇总👨💻保持热爱,奔赴下一场山海。🏃🏃🏃
来源地址:https://blog.csdn.net/qq_35427589/article/details/128062138
--结束END--
本文标题: 秒杀微服务实现抢购代金券功能
本文链接: https://www.lsjlt.com/news/374335.html(转载时请注明来源链接)
有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341
下载Word文档到电脑,方便收藏和打印~
2024-04-03
2024-04-03
2024-04-01
2024-01-21
2024-01-21
2024-01-21
2024-01-21
2023-12-23
回答
回答
回答
回答
回答
回答
回答
回答
回答
回答
0