文章目录
需求分析
现在日常购物或者餐饮消费,商家经常会有推出代金券功能,有些时候代金券的数量不多是需要抢购的,那么怎么设计可以保证代金券的消耗量和秒杀到的用户保持一致呢?怎么设计可以保证一个用户只能秒杀到一张代金券呢?
秒杀场景的解决方案
秒杀场景有以下几个特点:
- 大量用户同时进行抢购操作,系统流量激增,服务器瞬时压力很大;
- 请求数量远大于商品库存量,只有少数客户可以成功抢购;
- 业务流程不复杂,核心功能是下订单。
秒杀场景的应对,一般要从以下几个方面进行处理,如下:
限流
:从客户端层面考虑,限制单个客户抢购频率;服务端层面,加强校验,识别请求是否来源于真实的客户端,并限制请求频率,防止恶意刷单;应用层面,可以使用漏桶算法或令牌桶算法实现应用级限流。缓存
:热点数据都从缓存获得,尽可能减小数据库的访问压力;异步
:客户抢购成功后立即返回响应,之后通过消息队列,异步处理后续步骤,如发短信、更新数据库等,从而缓解服务器峰值压力。分流
:单台服务器肯定无法应对抢购期间大量请求造成的压力,需要集群部署服务器,通过负载均衡共同处理客户端请求,分散压力。
数据库表设计
本文以抢购代金券为例,来进行数据库表的设计。
代金券表
CREATETABLE`t_voucher`(`id`int(10)NOTNULLAUTO_INCREMENT,`title`varchar(255)CHARACTERSET utf8 COLLATE utf8_general_ci NULLDEFAULTNULLCOMMENT'代金券标题',`thumbnail`varchar(255)CHARACTERSET utf8 COLLATE utf8_general_ci NULLDEFAULTNULLCOMMENT'缩略图',`amount`int(11)NULLDEFAULTNULLCOMMENT'抵扣金额',`price`decimal(10,2)NULLDEFAULTNULLCOMMENT'售价',`status`int(10)NULLDEFAULTNULLCOMMENT'-1=过期 0=下架 1=上架',`expire_time`datetime(0)NULLDEFAULTNULLCOMMENT'过期时间',`redeem_restaurant_id`int(10)NULLDEFAULTNULLCOMMENT'验证餐厅',`stock`int(11)NULLDEFAULT0COMMENT'库存',`stock_left`int(11)NULLDEFAULT0COMMENT'剩余数量',`description`varchar(255)CHARACTERSET utf8 COLLATE utf8_general_ci NULLDEFAULTNULLCOMMENT'描述信息',`clause`varchar(255)CHARACTERSET utf8 COLLATE utf8_general_ci NULLDEFAULTNULLCOMMENT'使用条款',`create_date`datetime(0)NULLDEFAULTNULL,`update_date`datetime(0)NULLDEFAULTNULL,`is_valid`tinyint(1)NULLDEFAULTNULL,PRIMARYKEY(`id`)USINGBTREE)ENGINE=InnoDBCHARACTERSET= utf8 COLLATE= utf8_general_ci ROW_FORMAT = DYNAMIC;
抢购活动表
CREATETABLE`t_seckill_vouchers`(`id`int(11)NOTNULLAUTO_INCREMENT,`fk_voucher_id`int(11)NULLDEFAULTNULL,`amount`int(11)NULLDEFAULTNULL,`start_time`datetime(0)NULLDEFAULTNULL,`end_time`datetime(0)NULLDEFAULTNULL,`is_valid`int(11)NULLDEFAULTNULL,`create_date`datetime(0)NULLDEFAULTNULL,`update_date`datetime(0)NULLDEFAULTNULL,PRIMARYKEY(`id`)USINGBTREE)ENGINE=InnoDBCHARACTERSET= utf8 COLLATE= utf8_general_ci ROW_FORMAT = DYNAMIC;
订单表
CREATETABLE`t_voucher_order`(`id`int(11)NOTNULLAUTO_INCREMENT,`order_no`int(11)NULLDEFAULTNULL,`fk_voucher_id`int(11)NULLDEFAULTNULL,`fk_diner_id`int(11)NULLDEFAULTNULL,`qrcode`varchar(255)CHARACTERSET utf8 COLLATE utf8_general_ci NULLDEFAULTNULLCOMMENT'图片地址',`payment`tinyint(4)NULLDEFAULTNULLCOMMENT'0=微信支付 1=支付宝支付',`status`tinyint(1)NULLDEFAULTNULLCOMMENT'订单状态:-1=已取消 0=未支付 1=已支付 2=已消费 3=已过期',`fk_seckill_id`int(11)NULLDEFAULTNULLCOMMENT'如果是抢购订单时,抢购订单的id',`order_type`int(11)NULLDEFAULTNULLCOMMENT'订单类型:0=正常订单 1=抢购订单',`create_date`datetime(0)NULLDEFAULTNULL,`update_date`datetime(0)NULLDEFAULTNULL,`is_valid`int(11)NULLDEFAULTNULL,PRIMARYKEY(`id`)USINGBTREE)ENGINE=InnoDBCHARACTERSET= 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# Redisredis:port:6379host: localhost
timeout:3000password:123456# Swaggerswagger:base-package: com.zjq.seckill
title: 秒杀微服务API接口文档
# 配置 Eureka Server 注册中心eureka:instance:prefer-ip-address:trueinstance-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
*/@ConfigurationpublicclassRestTemplateConfiguration{@LoadBalanced@BeanpublicRestTemplaterestTemplate(){RestTemplate restTemplate =newRestTemplate();MappingJackson2HttpMessageConverter converter =newMappingJackson2HttpMessageConverter();
converter.setSupportedMediaTypes(Collections.singletonList(MediaType.TEXT_PLAIN));
restTemplate.getMessageConverters().add(converter);return restTemplate;}}
全局异常处理
/**
*
* 全局异常处理类
* @author zjq
*/// 将输出的内容写入 ResponseBody 中@RestControllerAdvice@Slf4jpublicclassGlobalExceptionHandler{@ResourceprivateHttpServletRequest request;@ExceptionHandler(ParameterException.class)publicResultInfo<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)publicResultInfo<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
*/publicinterfaceSeckillVouchersMapper{/**
* 新增秒杀活动
* @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")intsave(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}")SeckillVouchersselectVoucher(Integer voucherId);}
代金券活动Service->SeckillService
/**
* 秒杀业务逻辑层
* @author zjq
*/@ServicepublicclassSeckillService{@ResourceprivateSeckillVouchersMapper seckillVouchersMapper;/**
* 添加需要抢购的代金券
*
* @param seckillVouchers
*/@Transactional(rollbackFor =Exception.class)publicvoidaddSeckillVouchers(SeckillVouchers seckillVouchers){// 非空校验AssertUtil.isTrue(seckillVouchers.getFkVoucherId()==null,"请选择需要抢购的代金券");AssertUtil.isTrue(seckillVouchers.getAmount()==0,"请输入抢购总数量");Date now =newDate();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}")publicResultInfo<String>doSeckill(@PathVariableInteger voucherId,String access_token){ResultInfo resultInfo = seckillService.doSeckill(voucherId, access_token, request.getServletPath());return resultInfo;}
SeckillService
/**
* 抢购代金券
*
* @param voucherId 代金券 ID
* @param accessToken 登录token
* @Para path 访问路径
*/publicResultInfodoSeckill(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 =newDate();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,SignInDinerInfoSignInDinerInfo dinerInfo =BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),newSignInDinerInfo(),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 =newVoucherOrders();
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,"用户抢购失败");returnResultInfoUtil.buildSuccess(path,"抢购成功");}
代金券订单 VoucherOrdersMapper
/**
* 代金券订单 Mapper
* @author zjq
*/publicinterfaceVoucherOrdersMapper{/**
* 根据用户 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 ")VoucherOrdersfindDinerOrder(@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)")intsave(VoucherOrders voucherOrders);}
秒杀代金券活动 SeckillVouchersMapper
/**
* 减库存
* @param seckillId 秒杀id
* @return
*/@Update("update t_seckill_vouchers set amount = amount - 1 "+" where id = #{seckillId}")intstockDecrease(@Param("seckillId")int seckillId);
测试验证
压力测试
下载安装JMeter
JMeter安装和使用可以参考我这篇文章:压力测试工具-JMeter安装和使用
初始化2000个用户数据
数据库新增2000个用户数据,账号为test0到test1999,密码统一设置为123456。
认证微服务生产2000个token
初始化2000个token信息,存储在token.txt文件中。
代码如下:
@TestpublicvoidwriteToken()throwsException{String authorization =Base64Utils.encodeToString("appId:123456".getBytes());StringBuffer tokens =newStringBuffer();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来解决超卖和同一用户多次抢购的问题。
本文内容到此结束了,
如有收获欢迎点赞👍收藏💖关注✔️,您的鼓励是我最大的动力。
如有错误❌疑问💬欢迎各位指出。
主页:共饮一杯无的博客汇总👨💻保持热爱,奔赴下一场山海。🏃🏃🏃
版权归原作者 共饮一杯无 所有, 如有侵权,请联系我们删除。