当前位置: 首页 > news >正文

场馆预定平台高并发时间段预定实现V2

🎯 本文档介绍了场馆预订系统接口V2的设计与实现,旨在解决V1版本中库存数据不一致及性能瓶颈的问题。通过引入令牌机制确保缓存和数据库库存的最终一致性,避免因服务器故障导致的库存错误占用问题。同时,采用消息队列异步处理库存扣减和订单创建,显著提升了接口的吞吐量和响应速度。测试结果显示,新版接口在高并发场景下表现优异,平均响应时间为1801毫秒,吞吐量达到了每秒1045.8次请求,异常率仅为0.22%,极大改善了用户体验。
🏠️ HelloDam/场快订(场馆预定 SaaS 平台)

文章目录

  • 说明
  • 避免空场无法预订
  • 接口性能提升
    • Controller
    • Service
    • MQ
      • 生产者
      • 消费者
  • 测试结果
  • 说明

说明

在阅读此文之前,建议先阅读预订接口V1实现:https://hellodam.blog.csdn.net/article/details/144950335

接口 V2 主要是解决 V1 存在的一些问题:

  • 问题一:接口 V1 中存在如下问题:假如说 lua 脚本执行完成,缓存中的库存已经扣减,结果突然服务器宕机了,没有执行后续的数据库库存扣减和创建订单流程,就会出现库存被错误占用,导致缓存中库存小于实际库存。对应于现实,就是有的场空着,用户预定不到
  • 问题二:接口 V1 中,因为库存扣减和订单创建是同步的,预订接口吞吐量较低。为了进一步提升接口性能,可以使用消息队列来异步执行库存扣减和订单创建逻辑

避免空场无法预订

缓存扣减完成之后,由于发生故障,导致没有更新数据库。这个问题本身是无法避免的,只能通过一些机制来兜底。本文通过使用令牌机制来解决空场无法预订问题。

在接口 V1 中,用户请求预定接口,先查看 Redis 缓存中的库存是否大于 0 ,大于 0 才进行后面的操作。令牌是什么,其实也是这个缓存,但是我们并不完全相信它,我们知道它可能和数据库的数据不一致。当用户获取不到令牌的时候,我们不是直接返回时间段售罄错误,而是先查询一下数据库,看看是不是真的售罄了,如果数据库中还有库存,就删除令牌缓存。这样下一个用户再发起预订时,就会重新刷新令牌缓存,这样令牌的数据就和数据库保持一致,就不会出现空场无法预订的问题。

为了实现这个思路,我们还需要考虑一个问题,难道每个用户看到没有令牌都去查数据库吗,那肯定不行,这样并发高的话,数据库很容易被打崩。可以通过分布式锁让同一时刻只有一个用户查询数据库,但是光是添加分布式锁还是不行,用户请求多时,可能出现不同时间点连续查询数据库刷新token的情况,其实不必如此频繁查询。还有一个问题,高并发时大量任务等着数据库响应,数据库更新不会那么快。如果是立刻刷新token,可能出现数据库没来得及扣减库存,就被刷新到token中了,这样会导致时间段超卖,因为令牌数量大于库存。为了解决上述问题,可以先延时10秒再刷新token,在这10秒内,其他用户访问预定接口,因为拿不到分布式锁,也不会重复执行token刷新。

/*** 查询数据库是否还有库存,如果还有的话,删除令牌,让下一个用户重新加载令牌缓存** @param timePeriodId*/
private void refreshTokenByCheckDatabase(Long timePeriodId) {RLock lock = redissonClient.getLock(String.format(RedisCacheConstant.VENUE_LOCK_TIME_PERIOD_REFRESH_TOKEN_KEY, timePeriodId));// 尝试获取分布式锁,获取不成功直接返回if (!lock.tryLock()) {return;}// 延迟 10 秒之后去检查数据库和令牌是否一致// 为啥要延迟?如果不延迟的话,可能高峰期时,大量请求过来,数据库还没来得及更新,就触发令牌刷新,导致超卖tokenRefreshExecutor.schedule(() -> {try {TimePeriodDO timePeriodDO = this.getById(timePeriodId);if (timePeriodDO.getStock() > 0) {// --if-- 数据库中还有库存,说明数据库中的库存和令牌中的库存不一致,删除缓存,让下一个用户重新获取stringRedisTemplate.delete(RedisCacheConstant.VENUE_TIME_PERIOD_STOCK_TOKEN_KEY);stringRedisTemplate.delete(RedisCacheConstant.VENUE_TIME_PERIOD_FREE_INDEX_BIT_MAP_TOKEN_KEY);}} finally {lock.unlock();}}, 10, TimeUnit.SECONDS);
}

接口性能提升

Controller

/*** 预定时间段*/
@GetMapping("/v2/reserve")
@Idempotent(uniqueKeyPrefix = "vrs-venue:lock_reserve:",// 让用户同时最多只能预定一个时间段,根据用户名来加锁// key = "T(com.vrs.common.context.UserContext).getUsername()",// 让用户同时最多只能预定该时间段一次,但是可以同时预定其他时间段,根据用户名+时间段ID来加锁key = "T(com.vrs.common.context.UserContext).getUsername()+'_'+#timePeriodId",message = "正在执行场馆预定流程,请勿重复预定...",scene = IdempotentSceneEnum.RESTAPI
)
@Operation(summary = "预定时间段V2")
public Result reserve2(@RequestParam("timePeriodId") Long timePeriodId) {timePeriodService.reserve2(timePeriodId);return Results.success();
}

Service

【预订流程】

  • 参数检验
  • 获取令牌
    • 能获取到,执行下一步
    • 获取不到,查询数据库,刷新令牌缓存
  • 发送消息,异步更新库存并生成订单
/*** 尝试获取令牌,令牌获取成功之后,发送消息,异步执行库存扣减和订单生成* 注意:令牌在极端情况下,如扣减令牌之后,服务宕机了,此时令牌的库存是小于真实库存的* 如果查询令牌发现库存为0,尝试去数据库中加载数据,加载之后库存还是0,说明时间段确实售罄了* 使用消息队列异步 扣减库存,更新缓存,生成订单** @param timePeriodId*/
@Override
public void reserve2(Long timePeriodId) { 参数校验:使用责任链模式校验数据是否正确TimePeriodReserveReqDTO timePeriodReserveReqDTO = new TimePeriodReserveReqDTO(timePeriodId);chainContext.handler(ChainConstant.RESERVE_CHAIN_NAME, timePeriodReserveReqDTO);TimePeriodDO timePeriodDO = timePeriodReserveReqDTO.getTimePeriodDO();Long venueId = timePeriodReserveReqDTO.getVenueId();VenueDO venueDO = timePeriodReserveReqDTO.getVenueDO();PartitionDO partitionDO = partitionService.getPartitionDOById(timePeriodDO.getPartitionId()); 使用lua脚本获取一个空场地对应的索引,并扣除相应的库存,同时在里面进行用户的查重// 首先检测空闲场号缓存有没有加载好,没有的话进行加载this.checkBitMapCache(String.format(RedisCacheConstant.VENUE_TIME_PERIOD_FREE_INDEX_BIT_MAP_TOKEN_KEY, timePeriodReserveReqDTO.getTimePeriodId()),timePeriodId,partitionDO.getNum());// 其次检测时间段库存有没有加载好,没有的话进行加载this.getStockByTimePeriodId(RedisCacheConstant.VENUE_TIME_PERIOD_STOCK_TOKEN_KEY, timePeriodReserveReqDTO.getTimePeriodId());// 执行lua脚本Long freeCourtIndex = executeStockReduceByLua(timePeriodReserveReqDTO,venueDO,RedisCacheConstant.VENUE_TIME_PERIOD_STOCK_TOKEN_KEY,RedisCacheConstant.VENUE_TIME_PERIOD_FREE_INDEX_BIT_MAP_TOKEN_KEY);if (freeCourtIndex == -2L) {// --if-- 用户已经购买过该时间段throw new ClientException(BaseErrorCode.TIME_PERIOD_HAVE_BOUGHT_ERROR);} else if (freeCourtIndex == -1L) {// --if-- 没有空闲的场号,查询数据库,如果数据库中有库存,删除缓存,下一个用户预定时重新加载令牌this.refreshTokenByCheckDatabase(timePeriodId);throw new ServiceException(BaseErrorCode.TIME_PERIOD_SELL_OUT_ERROR);} 发送消息,异步更新库存并生成订单SendResult sendResult = executeReserveProducer.sendMessage(ExecuteReserveMqDTO.builder().timePeriodId(timePeriodId).freeCourtIndex(freeCourtIndex).venueId(venueId).userId(UserContext.getUserId()).userName(UserContext.getUsername()).build());if (!sendResult.getSendStatus().equals(SendStatus.SEND_OK)) {log.error("消息发送失败: " + sendResult.getSendStatus());// 恢复令牌缓存this.restoreStockAndBookedSlotsCache(timePeriodId,UserContext.getUserId(),freeCourtIndex,RedisCacheConstant.VENUE_TIME_PERIOD_STOCK_TOKEN_KEY,RedisCacheConstant.VENUE_TIME_PERIOD_FREE_INDEX_BIT_MAP_TOKEN_KEY);throw new ServiceException(BaseErrorCode.MQ_SEND_ERROR);}
}

【获取令牌】

获取令牌的过程其实就是 检验用户是否重新预订、库存数量检查、场号分配、库存扣减、场号占用 ,这里和接口V1的实现是一样的

/*** 使用lua脚本,进行缓存中的库存扣减,并分配空闲场号** @param timePeriodReserveReqDTO* @param venueDO* @param stockKey* @param freeIndexBitMapKey* @return*/
private Long executeStockReduceByLua(TimePeriodReserveReqDTO timePeriodReserveReqDTO, VenueDO venueDO,String stockKey, String freeIndexBitMapKey) {// 使用 Hutool 的单例管理容器 管理lua脚本的加载,保证其只被加载一次String luaScriptPath = "lua/free_court_index_allocate_by_bitmap.lua";DefaultRedisScript<Long> luaScript = Singleton.get(luaScriptPath, () -> {DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(luaScriptPath)));redisScript.setResultType(Long.class);return redisScript;});// 执行用户重复预定校验、库存扣减、场号分配Long freeCourtIndex = stringRedisTemplate.execute(luaScript,Lists.newArrayList(String.format(stockKey, timePeriodReserveReqDTO.getTimePeriodId()),String.format(freeIndexBitMapKey, timePeriodReserveReqDTO.getTimePeriodId()),String.format(RedisCacheConstant.VENUE_IS_USER_BOUGHT_TIME_PERIOD_KEY, timePeriodReserveReqDTO.getTimePeriodId())),UserContext.getUserId().toString(),String.valueOf(venueDO.getAdvanceBookingDay() * 86400));return freeCourtIndex;
}

lua

-- 定义脚本参数
local stock_key = KEYS[1]
local free_index_bitmap_key = KEYS[2]
-- 用来存储已购买用户的set
local set_name = KEYS[3]-- 用户ID
local user_id = ARGV[1]
-- 过期时间 (秒)
local expire_time = tonumber(ARGV[2])-- 检查用户是否已经购买过
if redis.call("SISMEMBER", set_name, user_id) == 1 then-- 用户已经购买过,返回 -2 表示失败return -2
end-- 获取库存
local current_inventory = tonumber(redis.call('GET', stock_key) or 0)-- 尝试消耗库存
if current_inventory < 1 then-- 库存不够了,返回-1,代表分配空场号失败return -1 -- 失败
end-- 查找第一个空闲的场地(位图中第一个为 0 的位)
local free_court_bit = redis.call("BITPOS", free_index_bitmap_key, 0)if not free_court_bit or free_court_bit == -1 then-- 没有空闲的场号return -1 -- 失败
end-- 占用该场地(将对应位设置为 1)
redis.call("SETBIT", free_index_bitmap_key, free_court_bit, 1)
-- 更新库存
redis.call('DECRBY', stock_key, 1)
-- 添加用户到已购买集合
redis.call("SADD", set_name, user_id)
-- 设置过期时间
if expire_time > 0 thenredis.call("EXPIRE", set_name, expire_time)
end-- 返回分配的场地索引(注意:位图的位索引从0开始,如果你需要从1开始,这里加1)
return tonumber(free_court_bit)

【更新缓存中库存】

大家可能会疑问,为啥有了令牌,还要更新缓存中的库存和空闲场号。因为我们在前端展示的信息需要是真实的库存信息,为了加速查询,需要将库存缓存起来,这里的缓存数据需要和数据库一致。为了保证缓存和数据库的最终一致性,可以开启 binlog ,然后使用 Canal 进行监听。如果数据库中的数据更新了,就发送消息到消息队列中,消费消息时再更新缓存中的库存。

-- 定义脚本参数
local stock_key = KEYS[1]
local free_index_bitmap_key = KEYS[2]-- 预订场号
local free_court_bit = ARGV[1]-- 占用该场地(将对应位设置为 1)
redis.call("SETBIT", free_index_bitmap_key, free_court_bit, 1)
-- 更新库存
redis.call('DECRBY', stock_key, 1)return 0

【检测和加载位图缓存】

/*** 检测位图缓存是否加载好,没有的话,执行加载操作** @param freeIndexBitmapKey* @param timePeriodId* @param initStock*/
@Override
public void checkBitMapCache(String freeIndexBitmapKey, Long timePeriodId, int initStock) {String cache = stringRedisTemplate.opsForValue().get(freeIndexBitmapKey);if (StringUtils.isBlank(cache)) {// --if-- 如果缓存中的位图为空RLock lock = redissonClient.getLock(String.format(RedisCacheConstant.VENUE_LOCK_TIME_PERIOD_FREE_INDEX_BIT_MAP_KEY, timePeriodId));lock.lock();try {// 双重判定一下,避免其他线程已经加载数据到缓存中了cache = stringRedisTemplate.opsForValue().get(freeIndexBitmapKey);if (StringUtils.isBlank(cache)) {// --if-- 如果缓存中的位图还是空,到数据库中加载位图TimePeriodDO timePeriodDO = this.getById(timePeriodId);if (timePeriodDO == null) {throw new ServiceException(timePeriodId + "对应的时间段为null", BaseErrorCode.SERVICE_ERROR);}// 将位图信息设置到缓存中this.initializeFreeIndexBitmap(freeIndexBitmapKey, initStock, timePeriodDO.getBookedSlots(), 24 * 3600);}} finally {// 解锁lock.unlock();}}
}/*** 初始化Redis中的位图,并设置key的过期时间** @param freeIndexBitmapKey 位图的键名* @param longValue          用于初始化位图的 long 类型数据* @param expireSecond       key的过期时间(秒)*/
public void initializeFreeIndexBitmap(String freeIndexBitmapKey, int initStock, long longValue, long expireSecond) {// 将 long 转换为64位的二进制字符串String binaryString = Long.toBinaryString(longValue);// 确保字符串长度为64位,不足的部分用0补齐binaryString = String.format("%64s", binaryString).replace(' ', '0');// 从低位到高位遍历二进制字符串,设置位图中的对应位for (int i = 0; i < 64 && initStock-- >= 0; i++) {// 注意:long的最低位对应位图的第0位if (binaryString.charAt(63 - i) == '1') {stringRedisTemplate.opsForValue().setBit(freeIndexBitmapKey, i, true).booleanValue();} else {stringRedisTemplate.opsForValue().setBit(freeIndexBitmapKey, i, false).booleanValue();}}// 设置过期时间,仅当expireTime大于0时进行设置if (expireSecond > 0) {stringRedisTemplate.expire(freeIndexBitmapKey, expireSecond, TimeUnit.SECONDS);}
}

【检验和加载库存缓存】

这里使用了封装的缓存组件,需要去仓库查看详细代码

/*** 获取指定时间段的库存** @param timePeriodId* @return*/
@Override
public Integer getStockByTimePeriodId(Long timePeriodId) {return (Integer) distributedCache.safeGet(String.format(RedisCacheConstant.VENUE_TIME_PERIOD_STOCK_KEY, timePeriodId),new TypeReference<Integer>() {},() -> {TimePeriodDO timePeriodDO = this.getById(timePeriodId);return timePeriodDO.getStock();},1,TimeUnit.DAYS);
}

【消费消息,执行预订流程】

和接口 V1 不同的是,V1 时同步创建订单,创建完成之后,直接访问给用户订单数据。但是在 V2 中,将任务交给消息队列之后,就要返回成功了。用户需要在前端等待订单创建结果。那前端如何感知订单是否创建成功呢?

  • 方式一:前端轮询查询后端,如每隔一秒问一下后端,订单创建好没有,创建好了就返回给前端,这样前端就可以进行支付了
  • 方式二:使用前后端双向通讯技术,如WebSocket。前后端一开始先建立好连接,等后端消费消息,创建订单成功之后,直接将订单信息推送给前端
/*** 通过消息队列执行 时间段预定 逻辑* @param executeReserveMqDTO*/
@Override
public void mqExecutePreserve(ExecuteReserveMqDTO executeReserveMqDTO) {TimePeriodDO timePeriodDO = this.getTimePeriodDOById(executeReserveMqDTO.getTimePeriodId());// 编程式开启事务,减少事务粒度,避免长事务的发生transactionTemplate.executeWithoutResult(status -> {try {// 扣减当前时间段的库存,修改空闲场信息baseMapper.updateStockAndBookedSlots(timePeriodDO.getId(), timePeriodDO.getPartitionId(), executeReserveMqDTO.getFreeCourtIndex());// 更新缓存中的库存、位图if (!isUseBinlog) {// --if-- 如果不使用binlog,需要手动更新缓存// 首先检测空闲场号缓存有没有加载好,没有的话进行加载this.checkBitMapCache(String.format(RedisCacheConstant.VENUE_TIME_PERIOD_FREE_INDEX_BIT_MAP_KEY, executeReserveMqDTO.getTimePeriodId()),executeReserveMqDTO.getTimePeriodId(),partitionService.getPartitionDOById(timePeriodDO.getPartitionId()).getNum());// 其次检测时间段库存有没有加载好,没有的话进行加载this.getStockByTimePeriodId(executeReserveMqDTO.getTimePeriodId());// 使用 Hutool 的单例管理容器 管理lua脚本的加载,保证其只被加载一次String luaScriptPath = "lua/inventory_update.lua";DefaultRedisScript<Long> luaScript = Singleton.get(luaScriptPath, () -> {DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(luaScriptPath)));redisScript.setResultType(Long.class);return redisScript;});// 库存扣减、场号占用stringRedisTemplate.execute(luaScript,Lists.newArrayList(String.format(RedisCacheConstant.VENUE_TIME_PERIOD_STOCK_KEY, executeReserveMqDTO.getTimePeriodId()),String.format(RedisCacheConstant.VENUE_TIME_PERIOD_FREE_INDEX_BIT_MAP_KEY, executeReserveMqDTO.getTimePeriodId())),executeReserveMqDTO.getFreeCourtIndex().toString());}// todo 需要实现binlog版本// 调用远程服务创建订单OrderGenerateReqDTO orderGenerateReqDTO = OrderGenerateReqDTO.builder().timePeriodId(timePeriodDO.getId()).partitionId(timePeriodDO.getPartitionId()).periodDate(timePeriodDO.getPeriodDate()).beginTime(timePeriodDO.getBeginTime()).endTime(timePeriodDO.getEndTime()).courtIndex(executeReserveMqDTO.getFreeCourtIndex()).userId(executeReserveMqDTO.getUserId()).userName(executeReserveMqDTO.getUserName()).venueId(executeReserveMqDTO.getVenueId()).payAmount(timePeriodDO.getPrice()).build();Result<OrderDO> result;try {result = orderFeignService.generateOrder(orderGenerateReqDTO);if (result == null || !result.isSuccess()) {// --if-- 订单生成失败,抛出异常,上面的库存扣减也会回退throw new ServiceException(BaseErrorCode.ORDER_GENERATE_ERROR);}} catch (Exception e) {// --if-- 订单生成服务调用失败// 恢复缓存中的信息this.restoreStockAndBookedSlotsCache(timePeriodDO.getId(),1L,executeReserveMqDTO.getFreeCourtIndex(),RedisCacheConstant.VENUE_TIME_PERIOD_STOCK_TOKEN_KEY,RedisCacheConstant.VENUE_TIME_PERIOD_FREE_INDEX_BIT_MAP_TOKEN_KEY);// todo 如果说由于网络原因,实际上订单已经创建成功了,但是因为超时访问失败,这里库存却回滚了,此时需要将订单置为废弃状态(即删除)// 发送一个短暂的延时消息(时间过长,用户可能已经支付),去检查订单是否生成,如果生成,将其删除// 打印错误堆栈信息e.printStackTrace();// 把错误返回到前端throw new ServiceException(e.getMessage());}OrderDO orderDO = result.getData();// todo 使用 WebSocket 通知前端,订单生成成功} catch (Exception ex) {status.setRollbackOnly();throw ex;}});
}

MQ

生产者

import cn.hutool.core.util.StrUtil;
import com.vrs.constant.RocketMqConstant;
import com.vrs.domain.dto.mq.ExecuteReserveMqDTO;
import com.vrs.templateMethod.AbstractCommonSendProduceTemplate;
import com.vrs.templateMethod.BaseSendExtendDTO;
import com.vrs.templateMethod.MessageWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.common.message.MessageConst;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;import java.util.UUID;/*** 执行预订流程 生产者** @Author dam* @create 2024/9/20 16:00*/
@Slf4j
@Component
public class ExecuteReserveProducer extends AbstractCommonSendProduceTemplate<ExecuteReserveMqDTO> {@Overrideprotected BaseSendExtendDTO buildBaseSendExtendParam(ExecuteReserveMqDTO messageSendEvent) {return BaseSendExtendDTO.builder().eventName("执行时间段预定").keys(String.valueOf(messageSendEvent.getTimePeriodId())).topic(RocketMqConstant.VENUE_TOPIC).tag(RocketMqConstant.TIME_PERIOD_EXECUTE_RESERVE_TAG).sentTimeout(2000L).build();}@Overrideprotected Message<?> buildMessage(ExecuteReserveMqDTO messageSendEvent, BaseSendExtendDTO requestParam) {String keys = StrUtil.isEmpty(requestParam.getKeys()) ? UUID.randomUUID().toString() : requestParam.getKeys();return MessageBuilder.withPayload(new MessageWrapper(keys, messageSendEvent)).setHeader(MessageConst.PROPERTY_KEYS, keys).setHeader(MessageConst.PROPERTY_TAGS, requestParam.getTag()).build();}
}

消费者

import com.vrs.annotation.Idempotent;
import com.vrs.constant.RocketMqConstant;
import com.vrs.domain.dto.mq.ExecuteReserveMqDTO;
import com.vrs.enums.IdempotentSceneEnum;
import com.vrs.service.TimePeriodService;
import com.vrs.templateMethod.MessageWrapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.annotation.SelectorType;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;/*** 执行预订流程 消费者* @Author dam* @create 2024/9/20 21:30*/
@Slf4j(topic = RocketMqConstant.VENUE_TOPIC)
@Component
@RocketMQMessageListener(topic = RocketMqConstant.VENUE_TOPIC,consumerGroup = RocketMqConstant.VENUE_CONSUMER_GROUP + "-" + RocketMqConstant.TIME_PERIOD_EXECUTE_RESERVE_TAG,messageModel = MessageModel.CLUSTERING,// 监听tagselectorType = SelectorType.TAG,selectorExpression = RocketMqConstant.TIME_PERIOD_EXECUTE_RESERVE_TAG
)
@RequiredArgsConstructor
public class ExecuteReserveListener implements RocketMQListener<MessageWrapper<ExecuteReserveMqDTO>> {private final TimePeriodService timePeriodService;/*** 消费消息的方法* 方法报错就会拒收消息** @param messageWrapper 消息内容,类型和上面的泛型一致。如果泛型指定了固定的类型,消息体就是我们的参数*/@Idempotent(uniqueKeyPrefix = "time_period_execute_reserve:",key = "#messageWrapper.getMessage().getTimePeriodId()+''",scene = IdempotentSceneEnum.MQ,keyTimeout = 3600L)@SneakyThrows@Overridepublic void onMessage(MessageWrapper<ExecuteReserveMqDTO> messageWrapper) {// 开头打印日志,平常可 Debug 看任务参数,线上可报平安(比如消息是否消费,重新投递时获取参数等)log.info("[消费者] 执行时间段预定,时间段ID:{}", messageWrapper.getMessage().getTimePeriodId());timePeriodService.mqExecutePreserve(messageWrapper.getMessage());}
}

测试结果

在这里插入图片描述

  1. 样本数量:共有40,000个样本,这表示在测试期间进行了40,000次操作或请求。
  2. 响应时间
    1. 平均值:1801毫秒,表示所有请求的平均响应时间。
    2. 中位数:1346毫秒,表示50%的请求响应时间低于这个值。
    3. 90%百分位:2048毫秒,表示90%的请求响应时间低于这个值。
    4. 95%百分位:3410毫秒,表示95%的请求响应时间低于这个值。
    5. 99%百分位:15133毫秒,表示99%的请求响应时间低于这个值。
    6. 最小值:15毫秒,表示最快的请求响应时间。
    7. 最大值:22121毫秒,表示最慢的请求响应时间。
  3. 异常率:0.22%,表示在所有请求中,有0.22%的请求出现了异常。
  4. 吞吐量:每秒可以处理1045.8个请求
  5. 网络流量
    1. 接收速率:221.51 KB/sec,表示系统每秒接收的数据量。
    2. 发送速率:509.96 KB/sec,表示系统每秒发送的数据量。

总结

  • 系统的平均响应时间为1801毫秒,中位数为1346毫秒,表明大多数请求的响应时间在可接受范围内。
  • 99%的请求响应时间在15133毫秒以内,但有少数请求的响应时间较长,最大值达到了22121毫秒。
  • 系统的吞吐量为1045.8次请求/秒,处理能力较高,相较于接口V1,性能强了一倍

说明

文章内容并非最新代码实现,若需要知道最新实现,麻烦移步开源仓库: HelloDam/场快订(场馆预定 SaaS 平台)

相关文章:

场馆预定平台高并发时间段预定实现V2

&#x1f3af; 本文档介绍了场馆预订系统接口V2的设计与实现&#xff0c;旨在解决V1版本中库存数据不一致及性能瓶颈的问题。通过引入令牌机制确保缓存和数据库库存的最终一致性&#xff0c;避免因服务器故障导致的库存错误占用问题。同时&#xff0c;采用消息队列异步处理库存…...

如何利用边缘节点服务打造极致用户体验?

随着互联网和数字化技术的飞速发展&#xff0c;用户对网络访问速度和服务体验的要求也在不断提高。在一个信息快速传播的时代&#xff0c;延迟过高或访问卡顿的问题会直接影响用户体验&#xff0c;甚至导致用户流失。因此&#xff0c;企业如何优化网络性能、提升用户访问速度&a…...

C语言之小型成绩管理系统

&#x1f31f; 嗨&#xff0c;我是LucianaiB&#xff01; &#x1f30d; 总有人间一两风&#xff0c;填我十万八千梦。 &#x1f680; 路漫漫其修远兮&#xff0c;吾将上下而求索。 C语言之小型成绩管理系统 目录 设计题目设计目的设计任务描述设计要求输入和输出要求验收要…...

ASP.NET Core 中基于 Cookie 的身份鉴权实现

在 ASP.NET Core 应用中&#xff0c;基于 Cookie 的身份鉴权是一种常见的身份验证方式&#xff0c;特别适用于传统的 Web 应用程序。Cookie 能够在用户的浏览器中存储身份验证数据&#xff0c;从而在用户访问应用的不同页面时保持登录状态。 一、配置 Cookie 身份验证 首先&a…...

为什么要学习C++?

在编程语言的广阔天地中&#xff0c;C 以其独特的魅力和强大的功能占据着重要的一席之地。尽管它并非新兴的热门语言&#xff0c;学习曲线也相对陡峭&#xff0c;但这丝毫没有阻挡开发者们对它的热情。那么&#xff0c;究竟为什么要学习 C 呢&#xff1f;接下来&#xff0c;我们…...

freecad1.0的编译

最近freecad发布了1.0版本,通常意义上,1.0也代表了稳定版本的发布。但是在编译的过程中遇到很多问题,再次做下记录,以便后续再编译时有参考。 1 freecad1.0编译环境 参考:https://blog.csdn.net/qq_26221775/article/details/144406212 环境:vs2022,win10,camke3.26.4;…...

汇编与逆向(一)-汇编工具简介

RadASM是一款著名的WIN32汇编编辑器&#xff0c;支持MASM、TASM等多种汇编编译器&#xff0c;Windows界面&#xff0c;支持语法高亮&#xff0c;自带一个资源编辑器和一个调试器。 一、汇编IDE工具&#xff1a;RadASM RadASM有内置的语言包 下载地址&#xff1a;RadASM asse…...

.NET Framework

.NET Framework 是微软推出的一个软件开发平台&#xff0c;主要用于构建和运行 Windows 应用程序。它是 .NET 生态系统的早期版本&#xff0c;专注于 Windows 平台&#xff0c;并提供了丰富的类库和运行时环境。 注意事项 跨平台限制&#xff1a;.NET Framework 主要适用于 W…...

LabVIEW太赫兹二维扫描成像系统

使用LabVIEW设计太赫兹二维扫描成像系统。通过LabVIEW平台开发&#xff0c;结合硬件如太赫兹源、平移台、锁相放大器等&#xff0c;实现了高效、精准的成像功能。系统采用蛇形扫描方式&#xff0c;通过动态调整扫描参数&#xff0c;达到优化成像质量的目的。 ​ 项目背景 在非…...

图片专栏——概念

欢迎来到图片世界&#xff0c;大家一起学习交流! 1. 像素&#xff08;Pixel&#xff09; 定义&#xff1a;像素是图像的最小单位&#xff0c;是“图像元素”的缩写。你可以把像素想象成拼图中的一个最小块&#xff0c;无数个像素组合在一起就形成了完整的图像。作用&#xff…...

Linux内存管理(Linux内存架构,malloc,slab的实现)

文章目录 前言一、Linux进程空间内存分配二、malloc的实现机理三、物理内存与虚拟内存1.物理内存2.虚拟内存 四、磁盘和物理内存区别五、页页的基本概念&#xff1a;分页管理的核心概念&#xff1a;Linux 中分页的实现&#xff1a;总结&#xff1a; 六、伙伴算法伙伴算法的核心…...

【C++】模板(进阶)

本篇我们来介绍更多关于C模板的知识。模板初阶移步至&#xff1a;【C】模板&#xff08;初阶&#xff09; 1.非类型模板参数 1.1 非类型模板参数介绍 模板参数可以是类型形参&#xff0c;也可以是非类型形参。类型形参就是我们目前接触到的一些模板参数。 //类型模板参数 …...

Esxi下虚拟机磁盘类型厚置备改精简置备

Esxi虚拟机磁盘类型厚置备改精简置备 一、esxi报错磁盘不足 1.1、虚拟机报错磁盘不足 1.2、虚拟机磁盘类型 VMware vSphere 中有两种主要类型的虚拟硬盘&#xff1a;精简配置磁盘和厚置备磁盘。 厚置备磁盘有两种分配模型&#xff1a;厚置备延迟置零和厚置备置零。 三者比…...

Element使用表单重置如果不使用prop,重置无法生效

文章目录 为什么需要 prop&#xff1f;示例&#xff1a;使用 prop 的正确方式关键点总结 在 element-ui 的 el-form 组件中&#xff0c; prop 属性是与表单验证和表单字段绑定密切相关的&#xff0c;尤其在使用 resetFields() 重置表单数据时。 如果不使用 prop&#xff0…...

Windows FileZila Server共享电脑文件夹 映射21端口外网连接

我有这样一个使用场景&#xff0c;在外部网络环境下&#xff0c;通过手机便捷地读取存储在电脑上的视频文件。比如在外出旅行、出差&#xff0c;身边没有携带电脑&#xff0c;仅依靠手机设备&#xff0c;就能随时获取电脑里存储的各类视频&#xff0c;无论是学习资料视频、工作…...

MongoDB 备份与恢复综述

目录 一、基本概述 二、逻辑备份 1、全量备份 2、增量备份 3、恢复 三、物理备份 1、cp/tar/fsync 2、WiredTiger 热备份 3、恢复 四、快照备份 一、基本概述 MongoDB 是一种流行的 NoSQL 数据库&#xff0c;它使用文档存储数据&#xff0c;支持丰富的查询语言和索引…...

node.js 文件操作

在 Node.js 中&#xff0c;文件操作主要通过内置的 fs&#xff08;File System&#xff09;模块来实现。 1. 读取文件 const fs require("fs");// 异步读取文件fs.readFile("example.txt", "utf8", (err, data) > {if (err) {console.erro…...

python编程-OpenCV(图像读写-图像处理-图像滤波-角点检测-边缘检测)图像变换

形态变换 图像处理中的形态学操作是处理图像结构的有效方法。以下是一些常见的形态学操作的介绍及其在 OpenCV 中的实现示例。 1. 腐蚀&#xff08;Erosion&#xff09; 腐蚀操作通过消除图像边界来减少图像中的白色区域&#xff08;前景&#xff09;&#xff0c;使物体的边…...

Spark SQL中的from_json函数详解

Spark SQL中的from_json函数详解 在Spark SQL中&#xff0c;from_json是一个用于解析JSON数据的函数&#xff0c;主要用于将JSON格式的字符串解析为结构化的数据&#xff08;即StructType或其他Spark SQL数据类型&#xff09;。这个函数在处理半结构化数据&#xff08;如JSON日…...

【软件架构】软件的十二种架构简介

软件的十二种架构简介 一、软件的12种架构 1. 单体架构 (Monolithic Architecture)2. 分层架构 (Layered Architecture)3. 事件驱动架构 (Event-Driven Architecture)4. 微服务架构 (Microservices Architecture)5. 服务导向架构 (Service-Oriented Architecture, SOA)6. 客户…...

【人工智能】神经网络的优化器optimizer(二):Adagrad自适应学习率优化器

一.自适应梯度算法Adagrad概述 Adagrad&#xff08;Adaptive Gradient Algorithm&#xff09;是一种自适应学习率的优化算法&#xff0c;由Duchi等人在2011年提出。其核心思想是针对不同参数自动调整学习率&#xff0c;适合处理稀疏数据和不同参数梯度差异较大的场景。Adagrad通…...

逻辑回归:给不确定性划界的分类大师

想象你是一名医生。面对患者的检查报告&#xff08;肿瘤大小、血液指标&#xff09;&#xff0c;你需要做出一个**决定性判断**&#xff1a;恶性还是良性&#xff1f;这种“非黑即白”的抉择&#xff0c;正是**逻辑回归&#xff08;Logistic Regression&#xff09;** 的战场&a…...

python爬虫:Newspaper3k 的详细使用(好用的新闻网站文章抓取和解析的Python库)

更多内容请见: 爬虫和逆向教程-专栏介绍和目录 文章目录 一、Newspaper3k 概述1.1 Newspaper3k 介绍1.2 主要功能1.3 典型应用场景1.4 安装二、基本用法2.2 提取单篇文章的内容2.2 处理多篇文档三、高级选项3.1 自定义配置3.2 分析文章情感四、实战案例4.1 构建新闻摘要聚合器…...

零基础设计模式——行为型模式 - 责任链模式

第四部分&#xff1a;行为型模式 - 责任链模式 (Chain of Responsibility Pattern) 欢迎来到行为型模式的学习&#xff01;行为型模式关注对象之间的职责分配、算法封装和对象间的交互。我们将学习的第一个行为型模式是责任链模式。 核心思想&#xff1a;使多个对象都有机会处…...

数据库分批入库

今天在工作中&#xff0c;遇到一个问题&#xff0c;就是分批查询的时候&#xff0c;由于批次过大导致出现了一些问题&#xff0c;一下是问题描述和解决方案&#xff1a; 示例&#xff1a; // 假设已有数据列表 dataList 和 PreparedStatement pstmt int batchSize 1000; // …...

【OSG学习笔记】Day 16: 骨骼动画与蒙皮(osgAnimation)

骨骼动画基础 骨骼动画是 3D 计算机图形中常用的技术&#xff0c;它通过以下两个主要组件实现角色动画。 骨骼系统 (Skeleton)&#xff1a;由层级结构的骨头组成&#xff0c;类似于人体骨骼蒙皮 (Mesh Skinning)&#xff1a;将模型网格顶点绑定到骨骼上&#xff0c;使骨骼移动…...

MySQL中【正则表达式】用法

MySQL 中正则表达式通过 REGEXP 或 RLIKE 操作符实现&#xff08;两者等价&#xff09;&#xff0c;用于在 WHERE 子句中进行复杂的字符串模式匹配。以下是核心用法和示例&#xff1a; 一、基础语法 SELECT column_name FROM table_name WHERE column_name REGEXP pattern; …...

以光量子为例,详解量子获取方式

光量子技术获取量子比特可在室温下进行。该方式有望通过与名为硅光子学&#xff08;silicon photonics&#xff09;的光波导&#xff08;optical waveguide&#xff09;芯片制造技术和光纤等光通信技术相结合来实现量子计算机。量子力学中&#xff0c;光既是波又是粒子。光子本…...

鸿蒙(HarmonyOS5)实现跳一跳小游戏

下面我将介绍如何使用鸿蒙的ArkUI框架&#xff0c;实现一个简单的跳一跳小游戏。 1. 项目结构 src/main/ets/ ├── MainAbility │ ├── pages │ │ ├── Index.ets // 主页面 │ │ └── GamePage.ets // 游戏页面 │ └── model │ …...

FFmpeg avformat_open_input函数分析

函数内部的总体流程如下&#xff1a; avformat_open_input 精简后的代码如下&#xff1a; int avformat_open_input(AVFormatContext **ps, const char *filename,ff_const59 AVInputFormat *fmt, AVDictionary **options) {AVFormatContext *s *ps;int i, ret 0;AVDictio…...