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

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

文章目录

  • 说明
  • 避免空场无法预订
  • 接口性能提升
    • 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 then
    redis.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.getTimePeriodId());
            // 更新缓存中的库存、位图
            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> {

    @Override
    protected 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();
    }

    @Override
    protected 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,
        // 监听tag
        selectorType = 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
    @Override
    public 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 平台)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/957326.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

从零到上线:Node.js 项目的完整部署流程(包含 Docker 和 CICD)

从零到上线&#xff1a;Node.js 项目的完整部署流程&#xff08;包含 Docker 和 CI/CD&#xff09; 目录 项目初始化&#xff1a;构建一个简单的 Node.js 应用设置 Docker 环境&#xff1a;容器化你的应用配置 CI/CD&#xff1a;自动化构建与部署上线前的最后检查&#xff1a;…

stm32 L051 adc配置及代码实例解析

一 cude的设置&#xff1a; 1. 接口的基本设置&#xff1a; 2. 参数的设置&#xff1a; 二 代码的逻辑&#xff1a; 1. 上面的直接生成代码&#xff0c;然后使用下面源码即可读到adc的数据&#xff1a; void adc_battery_start(void) {uint32_t ADC_value 0;HAL_ADC_Start(&…

【2024 博客之星评选】请继续保持Passion

我尝试复盘自己2024年走的路&#xff0c;希望能给诸君一些借鉴。 文章目录 回头望感想与收获成长与教训今年计划感恩一些体己话 回头望 回望我的2024年&#xff0c;年初拿高绩效&#xff0c;但感觉逐渐被公司一点点剥离出中心&#xff1b;年中一直在学习防患于未然&#xff1b…

【第二十周】U-Net:用于生物图像分割的卷积神经网络

文章目录 摘要Abstract文章信息研究动机U-Net网络结构U-Net网络搭建数据增强损失函数转置卷积创新性与不足创新性&#xff1a;不足&#xff1a; 总结 摘要 U-Net&#xff08;Convolutional Networks for Biomedical Image Segmentation&#xff09;是一种用于图像分割的深度学…

GD32F303 GCC 环境搭建

一、引言 在嵌入式开发领域&#xff0c;GD32F303 微控制器以其出色的性能和丰富的功能被广泛应用。为了充分发挥其潜力&#xff0c;搭建一个高效的开发环境并深入理解项目构建过程至关重要。本文将详细介绍如何基于 GCC 工具链搭建 GD32F303 的开发环境&#xff0c;重点聚焦于…

【语言处理和机器学习】概述篇(基础小白入门篇)

前言 自学笔记&#xff0c;分享给语言学/语言教育学方向的&#xff0c;但对语言数据处理感兴趣但是尚未入门&#xff0c;却需要在论文中用到的小伙伴&#xff0c;欢迎大佬们补充或绕道。ps&#xff1a;本文不涉及公式讲解&#xff08;文科生小白友好体质&#xff09;&#xff…

智能系统的感知和决策

智能系统在感知和决策过程中具备的关键能力表现在智能感知/自主判定上&#xff0c;下面可以从感知的本质、自主判断的含义及其在智能系统中的作用进行深入分析。 1、智能感知&#xff1a;信息获取与理解 智能感知是指智能系统通过传感器或其他数据采集手段获取环境中的信息&…

AD7606, 逐次逼近型ADC以及一次被GPT坑了的过程.

首先, 我的项目中, 已有的一个ADC芯片, 8通道, 并行, Analog家的ad7606, 在采集高速的正弦信号的时候, 我发现采集到的值怎么都不太对. 但是宏观来看, 并没有太大问题, 首先我怀疑的是量程问题, 接入一个5伏直流, 得到的读数确实是接近16bit的正半量程的读数, 32xxx. 接着我用信…

16.5万煤气柜柜位计故障分析

一、事故经过&#xff1a; 2015年8月1&#xff14;日20点45分&#xff0c;16.5万立煤气柜柜顶油封溢流口有大量油液溢出&#xff0c;此时雷达柜位计在计算机上示值为63.79米&#xff0c;由于接近傍晚天色较暗&#xff0c;岗位操作员并未及时发现这一异常状况。22点45分左右&…

成就与远见:2024年技术与思维的升华

个人主页&#xff1a;chian-ocean 前言: 2025年1月17日&#xff0c;2024年博客之星年度评选——创作影响力评审的入围名单公布。我很荣幸能够跻身Top 300&#xff0c;虽然与顶尖博主仍有一定差距&#xff0c;但这也为我提供了更加明确的发展方向与指引。展望崭新的2025年&…

Android 天气APP(二十七)增加地图天气的逐小时天气、太阳和月亮数据

imageId R.mipmap.icon_213d; break; case “300”: imageId R.mipmap.icon_300d; break; case “301”: imageId R.mipmap.icon_301d; break; case “302”: imageId R.mipmap.icon_302d; break; case “303”: imageId R.mipmap.icon_303d; break; case “…

vue2使用flv.js在浏览器打开flv格式视频

组件地址&#xff1a;GitHub - bilibili/flv.js: HTML5 FLV Player flv.js 仅支持 H.264 和 AAC/MP3 编码的 FLV 文件。如果视频文件使用了其他编码格式就打不开。 flv.vue <template><div><el-dialog :visible.sync"innerVisibleFlv" :close-on-pre…

Restormer模型代码解析

上一篇我们对Restormer的论文进行了解析。这篇对Restormer的代码进行解析。 论文地址&#xff1a;Restormer: Efficient Transformer for High-Resolution Image Restoration。代码地址&#xff1a;Restormer 以Deraining项目中的test.py文件为切入点&#xff0c;来分析其mod…

微信小程序怎么制作自己的小程序?手把手带你入门(适合新手小白观看)

对于初学者来说&#xff0c;制作一款微信小程序总感觉高大上&#xff0c;又害怕学不会。不过&#xff0c;今天我就用最简单、最有耐心的方式&#xff0c;一步一步给大家讲清楚!让你知道微信小程序的制作&#xff0c;居然可以这么轻松(希望你别吓跑啊!)。文中还加了实战经验&…

MPLS VPN 部署与应用

一.简介 MPLS&#xff0c;称之为多协议标签交换&#xff0c;在九十年代中期被提出来&#xff0c;用于解决传统IP报文依赖查表转发而产生的瓶颈&#xff0c;现多用于VPN技术&#xff0c;MPLS报头封装在数据链路层之上&#xff0c;网络层之下。本文为结合了华为技术和新华三技术…

定时器setTimeout和setInterval

setTimeOut()异步 setInterval()异步

PCL 部分点云视点问题【2025最新版】

目录 一、问题概述二、解决方案1、软件实现2、代码实现三、调整之后博客长期更新,本文最近更新时间为:2025年1月18日。 一、问题概述 针对CloudCompare软件处理过的pcd格式点云,在使用PCL进行特征点提取、配准等实验中最终显示结果出现点云位置偏差较大的问题,本博客给出解…

SpringCloud+Vue+Python人工智能(fastAPI,机器学习,深度学习)前后端架构各功能实现思路——主目录(持续更新)

随着公司业务的增加&#xff0c;公司需要一个javapython人工智能相互配合架构&#xff0c;正常网站业务用java来做&#xff0c;而ai&#xff0c;例如电价预测等回归任务&#xff0c;以及大模型预测全网负荷&#xff0c;新能源出力等任务&#xff0c;使用python通过fastapi暴露接…

Python数据可视化(够用版):懂基础 + 专业的图表抛给Tableau等专业绘图工具

我先说说文章标题中的“够用版”啥意思&#xff0c;为什么这么写。 按照我个人观点&#xff0c;在使用Python进行数据分析时&#xff0c;我们有时候肯定要结合到图表去进行分析&#xff0c;去直观展现数据的规律和特定&#xff0c;那么我们肯定要做一些简单的可视化&#xff0…

[微服务]注册中心优化

环境隔离 企业实际开发中&#xff0c;往往会搭建多个运行环境&#xff0c;例如&#xff1a; 开发环境测试环境预发布环境生产环境 这些不同环境之间的服务和数据之间需要隔离。 还有的企业中&#xff0c;会开发多个项目&#xff0c;共享nacos集群。此时&#xff0c;这些项目…