文章目录
需求分析秒杀场景的解决方案数据库表设计代金券表抢购活动表订单表 创建秒杀服务pom依赖配置文件 关系型数据库实现代金券秒杀相关实体引入抢购代金券活动信息代金券订单信息 Rest配置类全局异常处理添加代金券秒杀活动代金券活动实体代金券活动Mapper->SeckillVouchersMapper代金券活动Service->SeckillService代金券活动Controller->SeckillController在网关微服务中配置秒杀服务路由和白名单方向接口测试 对抢购的代金券下单SeckillControllerSeckillService代金券订单 VoucherOrdersMapper秒杀代金券活动 SeckillVouchersMapper测试验证 压力测试下载安装JMeter初始化2000个用户数据认证微服务生产2000个token测试多人抢购代金券测试同一用户抢购多次代金券
需求分析
现在日常购物或者餐饮消费,商家经常会有推出代金券功能,有些时候代金券的数量不多是需要抢购的,那么怎么设计可以保证代金券的消耗量和秒杀到的用户保持一致呢?怎么设计可以保证一个用户只能秒杀到一张代金券呢?
秒杀场景的解决方案
秒杀场景有以下几个特点:
大量用户同时进行抢购操作,系统流量激增,服务器瞬时压力很大;请求数量远大于商品库存量,只有少数客户可以成功抢购;业务流程不复杂,核心功能是下订单。秒杀场景的应对,一般要从以下几个方面进行处理,如下:
限流
:从客户端层面考虑,限制单个客户抢购频率;服务端层面,加强校验,识别请求是否来源于真实的客户端,并限制请求频率,防止恶意刷单;应用层面,可以使用漏桶算法或令牌桶算法实现应用级限流。缓存
:热点数据都从缓存获得,尽可能减小数据库的访问压力;异步
:客户抢购成功后立即返回响应,之后通过消息队列,异步处理后续步骤,如发短信、更新数据库等,从而缓解服务器峰值压力。分流
:单台服务器肯定无法应对抢购期间大量请求造成的压力,需要集群部署服务器,通过负载均衡共同处理客户端请求,分散压力。 数据库表设计
本文以抢购代金券为例,来进行数据库表的设计。
代金券表
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> <!-- eureka client --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <!-- spring web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- mybatis --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency> <!-- mysql --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!-- spring data redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- commons --> <dependency> <groupId>com.zjq</groupId> <artifactId>commons</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.13.6</version> </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'
关系型数据库实现代金券秒杀
相关实体引入
抢购代金券活动信息
代金券订单信息
Rest配置类
/** * RestTemplate 配置类 * @author zjq */@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; } }
全局异常处理
/** * * 全局异常处理类 * @author zjq */// 将输出的内容写入 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; }}
添加代金券秒杀活动
代金券活动实体
上述已引入实体。
代金券活动Mapper->SeckillVouchersMapper
/** * 秒杀代金券 Mapper * @author zjq */public interface SeckillVouchersMapper { /** * 新增秒杀活动 * @param seckillVouchers 代金券实体 * @return */ @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); /** * 根据代金券 ID 查询该代金券是否参与抢购活动 * @param voucherId 代金券id * @return */ @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
/** * 秒杀业务逻辑层 * @author zjq */@Servicepublic class SeckillService { @Resource private SeckillVouchersMapper seckillVouchersMapper; /** * 添加需要抢购的代金券 * * @param seckillVouchers */ @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 中是否已经存在该券的秒杀活动:
如果存在则抛出异常;如果不存在则将添加一个代金券抢购活动到 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
/** * 秒杀下单 * * @param voucherId 代金券id * @param access_token 请求token * @return */ @PostMapping("{voucherId}") public ResultInfo<String> doSeckill(@PathVariable Integer voucherId, String access_token) { ResultInfo resultInfo = seckillService.doSeckill(voucherId, access_token, request.getServletPath()); return resultInfo; }
SeckillService
/** * 抢购代金券 * * @param voucherId 代金券 ID * @param accessToken 登录token * @Para path 访问路径 */ 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, "抢购成功"); }
代金券订单 VoucherOrdersMapper
/** * 代金券订单 Mapper * @author zjq */public interface VoucherOrdersMapper { /** * 根据用户 ID 和秒杀 ID 查询代金券订单 * @param userId * @param voucherId * @return */ @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); /** * 新增代金券订单 * @param voucherOrders 代金券实体 * @return */ @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
/** * 减库存 * @param seckillId 秒杀id * @return */ @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()) // .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来解决超卖和同一用户多次抢购的问题。
本文内容到此结束了,
如有收获欢迎点赞?收藏?关注✔️,您的鼓励是我最大的动力。
如有错误❌疑问?欢迎各位指出。
主页:共饮一杯无的博客汇总??
保持热爱,奔赴下一场山海。???