文章目录
- 需求分析
- 秒杀场景的解决方案
- 数据库表设计
- 代金券表
- 抢购活动表
- 订单表
- 创建秒杀服务
- pom依赖
- 配置文件
- 关系型数据库实现代金券秒杀
- 相关实体引入
- 抢购代金券活动信息
- 代金券订单信息
- Rest配置类
- 全局异常处理
- 添加代金券秒杀活动
- 代金券活动实体
- 代金券活动Mapper->SeckillVouchersMapper
- 代金券活动Service->SeckillService
- 代金券活动Controller->SeckillController
- 在网关微服务中配置秒杀服务路由和白名单方向
- 接口测试
- 对抢购的代金券下单
- SeckillController
- SeckillService
- 代金券订单 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> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</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> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <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" /> + " 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来解决超卖和同一用户多次抢购的问题。
本文内容到此结束了,
如有收获欢迎点赞收藏关注✔️,您的鼓励是我最大的动力。
如有错误❌疑问欢迎各位指出。
主页:共饮一杯无的博客汇总保持热爱,奔赴下一场山海。