需求分析
现在日常购物或者餐饮消费,商家经常会有推出代金券功能,有些时候代金券的数量不多是需要抢购的,那么怎么设计可以保证代金券的消耗量和秒杀到的用户保持一致呢?怎么设计可以保证一个用户只能秒杀到一张代金券呢?
秒杀场景的解决方案
秒杀场景有以下几个特点:
- 大量用户同时进行抢购操作,系统流量激增,服务器瞬时压力很大;
- 请求数量远大于商品库存量,只有少数客户可以成功抢购;
- 业务流程不复杂,核心功能是下订单。
秒杀场景的应对,一般要从以下几个方面进行处理,如下:
-
限流
:从客户端层面考虑,限制单个客户抢购频率;服务端层面,加强校验,识别请求是否来源于真实的客户端,并限制请求频率,防止恶意刷单;应用层面,可以使用漏桶算法或令牌桶算法实现应用级限流。 -
缓存
:热点数据都从缓存获得,尽可能减小数据库的访问压力; -
异步
:客户抢购成功后立即返回响应,之后通过消息队列,异步处理后续步骤,如发短信、更新数据库等,从而缓解服务器峰值压力。 -
分流
:单台服务器肯定无法应对抢购期间大量请求造成的压力,需要集群部署服务器,通过负载均衡共同处理客户端请求,分散压力。
数据库表设计
本文以抢购代金券为例,来进行数据库表的设计。
代金券表
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
*/
@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;
}
}
全局异常处理
/**
*
* 全局异常处理类
* @author zjq
*/
// 将输出的内容写入 ResponseBody 中
@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
/**
* 秒杀代金券 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
*/
@Service
public 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来解决超卖和同一用户多次抢购的问题。
本文内容到此结束了,
如有收获欢迎点赞👍收藏💖关注✔️,您的鼓励是我最大的动力。
如有错误❌疑问💬欢迎各位指出。
主页:共饮一杯无的博客汇总👨💻文章来源:https://www.toymoban.com/news/detail-824252.html保持热爱,奔赴下一场山海。🏃🏃🏃文章来源地址https://www.toymoban.com/news/detail-824252.html
到了这里,关于秒杀微服务实现抢购代金券功能的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!