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

从 0 到 1 精通延迟消息队列实战实战实战:秒杀订单自动取消、定时支付超时处理全实战

引言:为什么我们需要延迟消息队列?

在日常开发中,你是否遇到过这些场景:

  • 电商订单下单后 30 分钟未支付自动取消
  • 订单完成后 24 小时自动发送好评提醒
  • 会员账号注册后 7 天发送新手教程
  • 定时任务调度需要精确到秒级执行

这些场景的共同特点是:需要在某个时间点之后执行特定任务。如果用传统定时任务轮询处理,不仅效率低下,还会造成资源浪费。而延迟消息队列正是解决这类问题的最佳方案。

本文将从原理到 1 带你深入理解延迟消息队列的原理与实践,通过真实业务场景讲解如何基于 Kafka 和 Redis 实现可靠的延迟消息方案,并对比主流中间件的延迟消息实现方式,让你在实际开发中能够游刃有余。

一、延迟消息队列核心概念与应用场景

1.1 什么是延迟消息队列?

延迟消息队列是一种特殊的消息队列,它允许消息发送后并不立即被消费,而是等待指定的延迟时间后才被处理。与普通消息队列相比,延迟消息多了一个 "时间维度" 的控制。

1.2 核心特性

  1. 延迟可靠性:确保消息在指定时间后被准确投递
  2. 消息持久性:即使服务重启,未处理的延迟消息也不会丢失
  3. 精确性:延迟时间误差在可接受范围内
  4. 高吞吐:能够处理大量延迟消息
  5. 可扩展性:支持集群部署,应对高并发场景

1.3 典型应用场景

场景延迟时间业务价值
订单支付超时取消15-30 分钟释放库存,提高商品周转率
未支付订单提醒5-10 分钟提高支付转化率
订单完成评价提醒24 小时增加商品评价数量
会员到期提醒7 天提高会员续费率
系统异常重试指数退避(1s,3s,5s...)提高系统容错性
预约任务执行自定义时间实现定时任务调度

二、延迟消息队列实现原理

2.1 常见实现方案对比

目前主流的延迟消息实现方案有四种,各有优缺点:

方案实现原理优点缺点适用场景
定时任务轮询定期扫描数据库或文件实现简单,无需额外组件时效性差,资源消耗大小流量、低精度场景
时间轮算法环形数组 + 指针,每个槽位对应时间段高效,支持海量消息实现复杂,精度受槽位影响中间件内部实现(如 Netty)
基于 Redis 的 zset将消息存入 zset,score 为到期时间实现简单,支持分布式需定时扫描,精度依赖扫描频率中小规模场景
专业延迟队列中间件如 RabbitMQ 的延迟插件、Kafka 的时间索引可靠性高,精度高依赖特定中间件,升级维护成本高大规模、高可靠场景

2.2 时间轮算法深度解析

时间轮是一种高效的延迟任务调度算法,被广泛应用于 Netty、Kafka 等框架中。它的核心思想是:

  1. 构建一个环形数组(类似时钟表盘),每个槽位代表一个时间间隔
  2. 有一个指针不断向前移动,每移动一步代表经过了一个时间间隔
  3. 延迟消息根据其延迟时间被放入相应的槽位
  4. 当指针指向某个槽位时,处理该槽位中的所有消息

假设时间轮有 6 个槽位,每个槽位代表 1 秒:

  • 延迟 3 秒的消息会被放入槽位 3
  • 延迟 7 秒的消息(超过一个周期)会被放入槽位 1(7%6=1),并记录圈数 1
  • 指针每秒钟移动一次,当指向槽位时,处理槽位中圈数为 0 的消息,圈数大于 0 的消息则减 1

2.3 Redis 实现延迟队列原理

利用 Redis 的 zset(有序集合)可以很方便地实现延迟队列:

  1. 将延迟消息作为 zset 的 member,消息的到期时间戳作为 score
  2. 生产者发送延迟消息时,执行ZADD key score member命令
  3. 消费者通过ZRANGEBYSCORE key 0 当前时间戳 LIMIT 0 N命令获取已到期的消息
  4. 获取到消息后,执行ZREM key member命令将消息从 zset 中删除

这种方案的关键是平衡扫描频率和精度:

  • 扫描频率越高,延迟精度越高,但 Redis 压力越大
  • 扫描频率越低,Redis 压力越小,但延迟精度越低

三、基于 Redis 的延迟消息队列实战

3.1 环境准备

首先,我们需要准备 Maven 依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.2.0</version><relativePath/></parent><groupId>com.delay</groupId><artifactId>delay-queue-demo</artifactId><version>0.0.1-SNAPSHOT</version><name>delay-queue-demo</name><description>延迟消息队列实战示例</description><properties><java.version>17</java.version><lombok.version>1.18.30</lombok.version><fastjson2.version>2.0.32</fastjson2.version><guava.version>32.1.3-jre</guava.version><swagger.version>2.2.0</swagger.version><redis.version>7.2.3</redis.version></properties><dependencies><!-- Spring Boot 核心 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- 工具类 --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>${lombok.version}</version><scope>provided</scope></dependency><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>${fastjson2.version}</version></dependency><dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>${guava.version}</version></dependency><!-- API文档 --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${swagger.version}</version></dependency><!-- 测试 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><excludes><exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude></excludes></configuration></plugin></plugins></build>
</project>

Redis 配置:

package com.delay.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;/*** Redis配置类* @author ken*/
@Configuration
public class RedisConfig {/*** 配置RedisTemplate,设置序列化方式*/@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {RedisTemplate<String, Object> template = new RedisTemplate<>();template.setConnectionFactory(factory);// 设置key的序列化方式template.setKeySerializer(new StringRedisSerializer());// 设置value的序列化方式template.setValueSerializer(new GenericJackson2JsonRedisSerializer());// 设置hash的序列化方式template.setHashKeySerializer(new StringRedisSerializer());template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());template.afterPropertiesSet();return template;}
}

3.2 消息模型设计

定义延迟消息的实体类:

package com.delay.entity;import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;import java.io.Serializable;/*** 延迟消息实体* @author ken*/
@Data
@Schema(description = "延迟消息实体")
public class DelayMessage implements Serializable {/*** 消息唯一标识*/@Schema(description = "消息唯一标识")private String messageId;/*** 消息主题*/@Schema(description = "消息主题")private String topic;/*** 消息内容*/@Schema(description = "消息内容")private String content;/*** 延迟时间(毫秒)*/@Schema(description = "延迟时间(毫秒)")private long delayTime;/*** 消息创建时间戳(毫秒)*/@Schema(description = "消息创建时间戳(毫秒)")private long createTime;/*** 消息到期时间戳(毫秒)*/@Schema(description = "消息到期时间戳(毫秒)")private long expireTime;/*** 重试次数*/@Schema(description = "重试次数")private int retryCount;
}

3.3 延迟队列核心实现

package com.delay.queue;import com.alibaba.fastjson2.JSON;
import com.delay.entity.DelayMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;import javax.annotation.Resource;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;/*** 基于Redis的延迟队列实现* @author ken*/
@Slf4j
@Component
public class RedisDelayQueue {@Resourceprivate RedisTemplate<String, Object> redisTemplate;/*** 延迟队列前缀*/private static final String DELAY_QUEUE_PREFIX = "delay:queue:";/*** 处理中消息前缀*/private static final String PROCESSING_PREFIX = "delay:processing:";/*** 死信队列前缀*/private static final String DEAD_LETTER_PREFIX = "delay:deadletter:";/*** 最大重试次数*/private static final int MAX_RETRY_COUNT = 3;/*** 发送延迟消息* @param topic 消息主题* @param content 消息内容* @param delayTime 延迟时间(毫秒)* @return 消息ID*/public String send(String topic, String content, long delayTime) {String messageId = UUID.randomUUID().toString();long createTime = System.currentTimeMillis();long expireTime = createTime + delayTime;DelayMessage message = new DelayMessage();message.setMessageId(messageId);message.setTopic(topic);message.setContent(content);message.setDelayTime(delayTime);message.setCreateTime(createTime);message.setExpireTime(expireTime);message.setRetryCount(0);String queueKey = DELAY_QUEUE_PREFIX + topic;// 将消息添加到zset,score为到期时间戳Boolean success = redisTemplate.opsForZSet().add(queueKey, JSON.toJSONString(message), expireTime);if (Boolean.TRUE.equals(success)) {log.info("发送延迟消息成功,topic:{}, messageId:{}, 延迟时间:{}ms", topic, messageId, delayTime);return messageId;} else {log.error("发送延迟消息失败,topic:{}, messageId:{}", topic, messageId);return null;}}/*** 启动消费者* @param topic 消息主题* @param consumer 消息处理器* @param scanInterval 扫描间隔(毫秒)*/public void startConsumer(String topic, Consumer<DelayMessage> consumer, long scanInterval) {String queueKey = DELAY_QUEUE_PREFIX + topic;// 启动一个新线程进行扫描new Thread(() -> {log.info("启动延迟队列消费者,topic:{}, 扫描间隔:{}ms", topic, scanInterval);while (!Thread.currentThread().isInterrupted()) {try {long now = System.currentTimeMillis();// 1. 获取已到期的消息(score <= 当前时间戳)Set<Object> messages = redisTemplate.opsForZSet().rangeByScore(queueKey, 0, now, 0, 100);if (ObjectUtils.isEmpty(messages)) {// 没有到期消息,休眠一段时间TimeUnit.MILLISECONDS.sleep(scanInterval);continue;}// 2. 处理每条消息for (Object messageObj : messages) {String messageJson = (String) messageObj;if (!StringUtils.hasText(messageJson)) {continue;}DelayMessage message = JSON.parseObject(messageJson, DelayMessage.class);if (ObjectUtils.isEmpty(message)) {log.error("解析延迟消息失败,json:{}", messageJson);// 移除无效消息redisTemplate.opsForZSet().remove(queueKey, messageObj);continue;}// 3. 尝试将消息从zset中移除(防止重复消费)Long removeCount = redisTemplate.opsForZSet().remove(queueKey, messageObj);if (removeCount != null && removeCount > 0) {log.info("获取到期消息,topic:{}, messageId:{}", topic, message.getMessageId());// 4. 将消息标记为处理中,并设置超时时间String processingKey = PROCESSING_PREFIX + topic + ":" + message.getMessageId();redisTemplate.opsForValue().set(processingKey, messageJson, 5, TimeUnit.MINUTES);try {// 5. 处理消息consumer.accept(message);// 6. 处理成功,删除处理中标记redisTemplate.delete(processingKey);log.info("消息处理成功,topic:{}, messageId:{}", topic, message.getMessageId());} catch (Exception e) {log.error("消息处理失败,topic:{}, messageId:{}", topic, message.getMessageId(), e);// 7. 处理失败,判断是否需要重试if (message.getRetryCount() < MAX_RETRY_COUNT) {// 增加重试次数message.setRetryCount(message.getRetryCount() + 1);// 计算下次重试时间(指数退避)long nextDelay = (long) (message.getDelayTime() * Math.pow(2, message.getRetryCount()));message.setExpireTime(System.currentTimeMillis() + nextDelay);// 重新加入延迟队列redisTemplate.opsForZSet().add(queueKey, JSON.toJSONString(message), message.getExpireTime());log.info("消息将重试,topic:{}, messageId:{}, 重试次数:{}, 下次延迟:{}ms",topic, message.getMessageId(), message.getRetryCount(), nextDelay);} else {// 达到最大重试次数,放入死信队列String deadLetterKey = DEAD_LETTER_PREFIX + topic;redisTemplate.opsForList().rightPush(deadLetterKey, messageJson);log.warn("消息达到最大重试次数,放入死信队列,topic:{}, messageId:{}",topic, message.getMessageId());}// 删除处理中标记redisTemplate.delete(processingKey);}}}} catch (InterruptedException e) {log.info("延迟队列消费者被中断,topic:{}", topic);Thread.currentThread().interrupt();break;} catch (Exception e) {log.error("延迟队列消费者异常,topic:{}", topic, e);try {TimeUnit.MILLISECONDS.sleep(scanInterval);} catch (InterruptedException ie) {Thread.currentThread().interrupt();break;}}}log.info("延迟队列消费者已停止,topic:{}", topic);}, "delay-queue-consumer-" + topic).start();}/*** 处理死信队列消息* @param topic 消息主题* @param consumer 消息处理器*/public void processDeadLetterQueue(String topic, Consumer<DelayMessage> consumer) {String deadLetterKey = DEAD_LETTER_PREFIX + topic;new Thread(() -> {log.info("启动死信队列处理器,topic:{}", topic);while (!Thread.currentThread().isInterrupted()) {try {// 从死信队列获取消息Object messageObj = redisTemplate.opsForList().leftPop(deadLetterKey, 1, TimeUnit.SECONDS);if (ObjectUtils.isEmpty(messageObj)) {continue;}String messageJson = (String) messageObj;DelayMessage message = JSON.parseObject(messageJson, DelayMessage.class);if (ObjectUtils.isEmpty(message)) {log.error("解析死信消息失败,json:{}", messageJson);continue;}try {log.info("处理死信消息,topic:{}, messageId:{}", topic, message.getMessageId());consumer.accept(message);log.info("死信消息处理成功,topic:{}, messageId:{}", topic, message.getMessageId());} catch (Exception e) {log.error("死信消息处理失败,topic:{}, messageId:{}", topic, message.getMessageId(), e);// 死信消息处理失败,重新放入死信队列redisTemplate.opsForList().rightPush(deadLetterKey, messageJson);// 休眠一段时间再试,避免频繁失败TimeUnit.SECONDS.sleep(10);}} catch (InterruptedException e) {log.info("死信队列处理器被中断,topic:{}", topic);Thread.currentThread().interrupt();break;} catch (Exception e) {log.error("死信队列处理器异常,topic:{}", topic, e);try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException ie) {Thread.currentThread().interrupt();break;}}}log.info("死信队列处理器已停止,topic:{}", topic);}, "dead-letter-processor-" + topic).start();}/*** 取消延迟消息* @param topic 消息主题* @param messageId 消息ID* @return 是否取消成功*/public boolean cancelMessage(String topic, String messageId) {if (!StringUtils.hasText(topic) || !StringUtils.hasText(messageId)) {log.error("取消延迟消息失败,参数为空,topic:{}, messageId:{}", topic, messageId);return false;}String queueKey = DELAY_QUEUE_PREFIX + topic;// 由于无法直接通过messageId删除,这里需要扫描消息,实际应用中可以优化// 更好的方式是维护一个messageId到消息内容的映射Set<Object> messages = redisTemplate.opsForZSet().range(queueKey, 0, -1);if (!ObjectUtils.isEmpty(messages)) {for (Object messageObj : messages) {String messageJson = (String) messageObj;DelayMessage message = JSON.parseObject(messageJson, DelayMessage.class);if (!ObjectUtils.isEmpty(message) && messageId.equals(message.getMessageId())) {Long removeCount = redisTemplate.opsForZSet().remove(queueKey, messageObj);if (removeCount != null && removeCount > 0) {log.info("取消延迟消息成功,topic:{}, messageId:{}", topic, messageId);return true;}}}}log.warn("未找到要取消的延迟消息,topic:{}, messageId:{}", topic, messageId);return false;}
}

3.4 订单超时取消实战

下面我们通过一个真实的业务场景来演示延迟队列的使用:订单创建后 30 分钟未支付自动取消。

首先定义订单实体:

package com.delay.entity;import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;import java.time.LocalDateTime;/*** 订单实体* @author ken*/
@Data
@TableName("t_order")
@Schema(description = "订单信息")
public class Order {@TableId(type = IdType.AUTO)@Schema(description = "订单ID")private Long id;@TableField("order_no")@Schema(description = "订单编号")private String orderNo;@TableField("user_id")@Schema(description = "用户ID")private Long userId;@TableField("amount")@Schema(description = "订单金额")private BigDecimal amount;@TableField("status")@Schema(description = "订单状态(0-待支付,1-已支付,2-已取消)")private Integer status;@TableField("create_time")@Schema(description = "创建时间")private LocalDateTime createTime;@TableField("pay_time")@Schema(description = "支付时间")private LocalDateTime payTime;@TableField("cancel_time")@Schema(description = "取消时间")private LocalDateTime cancelTime;
}

订单 Mapper 接口:

package com.delay.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.delay.entity.Order;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;/*** 订单Mapper* @author ken*/
@Mapper
public interface OrderMapper extends BaseMapper<Order> {/*** 更新订单状态为取消* @param orderNo 订单编号* @param cancelTime 取消时间* @return 更新行数*/int updateStatusToCanceled(@Param("orderNo") String orderNo, @Param("cancelTime") LocalDateTime cancelTime);
}

订单服务接口:

package com.delay.service;import com.baomidou.mybatisplus.extension.service.IService;
import com.delay.entity.Order;
import com.delay.vo.OrderCreateVO;/*** 订单服务* @author ken*/
public interface OrderService extends IService<Order> {/*** 创建订单* @param orderCreateVO 订单创建参数* @return 订单信息*/Order createOrder(OrderCreateVO orderCreateVO);/*** 支付订单* @param orderNo 订单编号* @param payAmount 支付金额* @return 是否支付成功*/boolean payOrder(String orderNo, BigDecimal payAmount);/*** 取消订单(超时未支付)* @param orderNo 订单编号* @return 是否取消成功*/boolean cancelOrderByTimeout(String orderNo);
}

订单服务实现:

package com.delay.service.impl;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.delay.entity.DelayMessage;
import com.delay.entity.Order;
import com.delay.mapper.OrderMapper;
import com.delay.queue.RedisDelayQueue;
import com.delay.service.OrderService;
import com.delay.vo.OrderCreateVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;/*** 订单服务实现* @author ken*/
@Slf4j
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {@Resourceprivate RedisDelayQueue delayQueue;/*** 订单超时时间(30分钟)*/private static final long ORDER_TIMEOUT = 30 * 60 * 1000;/*** 订单主题*/private static final String ORDER_TOPIC = "order_timeout";@Transactional(rollbackFor = Exception.class)@Overridepublic Order createOrder(OrderCreateVO orderCreateVO) {if (ObjectUtils.isEmpty(orderCreateVO) || ObjectUtils.isEmpty(orderCreateVO.getUserId())|| ObjectUtils.isEmpty(orderCreateVO.getAmount()) || orderCreateVO.getAmount().compareTo(BigDecimal.ZERO) <= 0) {log.error("创建订单失败,参数无效:{}", orderCreateVO);throw new IllegalArgumentException("订单参数无效");}// 生成订单编号String orderNo = "ORDER_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8);// 创建订单Order order = new Order();order.setOrderNo(orderNo);order.setUserId(orderCreateVO.getUserId());order.setAmount(orderCreateVO.getAmount());order.setStatus(0); // 待支付order.setCreateTime(LocalDateTime.now());int insert = baseMapper.insert(order);if (insert <= 0) {log.error("创建订单失败,数据库插入失败:{}", order);throw new RuntimeException("创建订单失败");}log.info("创建订单成功,orderNo:{}", orderNo);// 发送延迟消息,30分钟后检查订单是否支付String messageId = delayQueue.send(ORDER_TOPIC, orderNo, ORDER_TIMEOUT);if (!StringUtils.hasText(messageId)) {log.error("发送订单超时消息失败,orderNo:{}", orderNo);// 这里可以根据业务需求决定是否回滚订单创建}return order;}@Transactional(rollbackFor = Exception.class)@Overridepublic boolean payOrder(String orderNo, BigDecimal payAmount) {if (!StringUtils.hasText(orderNo) || ObjectUtils.isEmpty(payAmount) || payAmount.compareTo(BigDecimal.ZERO) <= 0) {log.error("支付订单失败,参数无效,orderNo:{}, payAmount:{}", orderNo, payAmount);return false;}// 查询订单LambdaQueryWrapper<Order> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(Order::getOrderNo, orderNo);Order order = baseMapper.selectOne(queryWrapper);if (ObjectUtils.isEmpty(order)) {log.error("支付订单失败,订单不存在,orderNo:{}", orderNo);return false;}if (order.getStatus() != 0) {log.error("支付订单失败,订单状态不是待支付,orderNo:{}, status:{}", orderNo, order.getStatus());return false;}if (order.getAmount().compareTo(payAmount) != 0) {log.error("支付订单失败,支付金额不匹配,orderNo:{}, 订单金额:{}, 支付金额:{}",orderNo, order.getAmount(), payAmount);return false;}// 更新订单状态order.setStatus(1); // 已支付order.setPayTime(LocalDateTime.now());int update = baseMapper.updateById(order);if (update > 0) {log.info("支付订单成功,orderNo:{}", orderNo);// 取消延迟消息// 注意:这里需要维护messageId和orderNo的映射关系才能取消return true;} else {log.error("支付订单失败,数据库更新失败,orderNo:{}", orderNo);return false;}}@Transactional(rollbackFor = Exception.class)@Overridepublic boolean cancelOrderByTimeout(String orderNo) {if (!StringUtils.hasText(orderNo)) {log.error("取消超时订单失败,订单编号为空");return false;}// 查询订单LambdaQueryWrapper<Order> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(Order::getOrderNo, orderNo);Order order = baseMapper.selectOne(queryWrapper);if (ObjectUtils.isEmpty(order)) {log.error("取消超时订单失败,订单不存在,orderNo:{}", orderNo);return false;}if (order.getStatus() != 0) {log.info("订单已处理,无需取消,orderNo:{}, status:{}", orderNo, order.getStatus());return true;}// 更新订单状态为取消int update = baseMapper.updateStatusToCanceled(orderNo, LocalDateTime.now());if (update > 0) {log.info("取消超时订单成功,orderNo:{}", orderNo);// 这里可以添加恢复库存等逻辑return true;} else {log.error("取消超时订单失败,数据库更新失败,orderNo:{}", orderNo);return false;}}
}

启动订单超时消息消费者:

package com.delay.config;import com.delay.entity.DelayMessage;
import com.delay.queue.RedisDelayQueue;
import com.delay.service.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;import javax.annotation.Resource;/*** 消费者配置* @author ken*/
@Slf4j
@Configuration
public class ConsumerConfig {@Resourceprivate RedisDelayQueue delayQueue;@Resourceprivate OrderService orderService;/*** 订单超时消息消费者*/@Beanpublic ApplicationRunner orderTimeoutConsumer() {return args -> {String topic = "order_timeout";// 启动消费者,每1000ms扫描一次delayQueue.startConsumer(topic, this::handleOrderTimeoutMessage, 1000);// 启动死信队列处理器delayQueue.processDeadLetterQueue(topic, this::handleOrderTimeoutDeadLetterMessage);};}/*** 处理订单超时消息*/private void handleOrderTimeoutMessage(DelayMessage message) {if (message == null || !StringUtils.hasText(message.getContent())) {log.error("处理订单超时消息失败,消息内容为空");return;}String orderNo = message.getContent();log.info("开始处理超时订单,orderNo:{}", orderNo);// 调用订单服务取消订单boolean success = orderService.cancelOrderByTimeout(orderNo);if (!success) {log.error("处理超时订单失败,orderNo:{}", orderNo);throw new RuntimeException("取消超时订单失败");}}/*** 处理订单超时死信消息*/private void handleOrderTimeoutDeadLetterMessage(DelayMessage message) {if (message == null || !StringUtils.hasText(message.getContent())) {log.error("处理订单超时死信消息失败,消息内容为空");return;}String orderNo = message.getContent();log.warn("处理订单超时死信消息,orderNo:{}, 重试次数:{}", orderNo, message.getRetryCount());// 这里可以发送告警通知人工处理// sendAlarm("订单超时取消失败,orderNo:" + orderNo);}
}

控制层接口:

package com.delay.controller;import com.delay.entity.Order;
import com.delay.service.OrderService;
import com.delay.vo.OrderCreateVO;
import com.delay.vo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;import javax.annotation.Resource;
import java.math.BigDecimal;/*** 订单控制器* @author ken*/
@Slf4j
@RestController
@RequestMapping("/api/order")
@Tag(name = "订单管理", description = "订单相关接口")
public class OrderController {@Resourceprivate OrderService orderService;@Operation(summary = "创建订单", description = "创建新订单并设置30分钟超时")@PostMapping("/create")public Result<Order> createOrder(@Parameter(description = "订单创建参数", required = true) @RequestBody OrderCreateVO orderCreateVO) {log.info("创建订单请求:{}", orderCreateVO);Order order = orderService.createOrder(orderCreateVO);return Result.success(order);}@Operation(summary = "支付订单", description = "支付指定订单")@PostMapping("/pay/{orderNo}")public Result<Boolean> payOrder(@Parameter(description = "订单编号", required = true) @PathVariable String orderNo,@Parameter(description = "支付金额", required = true) @RequestParam BigDecimal payAmount) {log.info("支付订单请求,orderNo:{}, payAmount:{}", orderNo, payAmount);boolean success = orderService.payOrder(orderNo, payAmount);return Result.success(success);}
}

四、基于 Kafka 的延迟消息队列实战

4.1 Kafka 延迟消息原理

Kafka 本身并不直接支持延迟消息,但我们可以通过以下两种方式实现:

  1. 时间轮 + 主题转发

    • 发送消息到一个专门的延迟主题
    • 消费者消费延迟主题消息,计算需要延迟的时间
    • 将消息放入时间轮,到期后转发到目标主题
  2. 自定义分区器 + 时间索引

    • 消息包含延迟时间戳
    • 分区器根据延迟时间戳将消息分配到不同分区
    • 消费者按时间顺序消费,未到期的消息暂存本地

本文采用第一种方式,基于 Kafka 和时间轮实现延迟消息队列。

4.2 Kafka 配置

添加 Kafka 依赖:

<dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId>
</dependency>

Kafka 配置类:

package com.delay.config;import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.config.TopicBuilder;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.SeekToCurrentErrorHandler;
import org.springframework.kafka.support.converter.JsonMessageConverter;
import org.springframework.util.backoff.FixedBackOff;import javax.annotation.Resource;/*** Kafka配置* @author ken*/
@Configuration
public class KafkaConfig {@Resourceprivate KafkaTemplate<String, Object> kafkaTemplate;/*** 延迟消息主题*/public static final String DELAY_TOPIC = "delay_topic";/*** 订单超时主题*/public static final String ORDER_TIMEOUT_TOPIC = "order_timeout_topic";/*** 死信主题后缀*/public static final String DEAD_LETTER_SUFFIX = ".DLT";/*** 创建延迟消息主题*/@Beanpublic NewTopic delayTopic() {return TopicBuilder.name(DELAY_TOPIC).partitions(8).replicas(3).build();}/*** 创建订单超时主题*/@Beanpublic NewTopic orderTimeoutTopic() {return TopicBuilder.name(ORDER_TIMEOUT_TOPIC).partitions(4).replicas(3).build();}/*** 创建订单超时死信主题*/@Beanpublic NewTopic orderTimeoutDltTopic() {return TopicBuilder.name(ORDER_TIMEOUT_TOPIC + DEAD_LETTER_SUFFIX).partitions(1).replicas(3).build();}/*** 配置Kafka监听器容器工厂*/@Beanpublic ConcurrentKafkaListenerContainerFactory<String, Object> kafkaListenerContainerFactory(ConsumerFactory<String, Object> consumerFactory) {ConcurrentKafkaListenerContainerFactory<String, Object> factory = new ConcurrentKafkaListenerContainerFactory<>();factory.setConsumerFactory(consumerFactory);// 设置消息转换器为JSONfactory.setMessageConverter(new JsonMessageConverter());// 配置错误处理器,将处理失败的消息发送到死信队列DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate,(consumerRecord, exception) -> {String originalTopic = consumerRecord.topic();return new org.apache.kafka.common.TopicPartition(originalTopic + DEAD_LETTER_SUFFIX, consumerRecord.partition());});// 重试2次,每次间隔1秒SeekToCurrentErrorHandler errorHandler = new SeekToCurrentErrorHandler(recoverer, new FixedBackOff(1000L, 2));factory.setErrorHandler(errorHandler);return factory;}
}

4.3 时间轮实现

package com.delay.util;import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;/*** 时间轮实现* @author ken*/
@Slf4j
@Component
public class TimingWheel {/*** 时间轮的槽位数*/private static final int DEFAULT_BUCKET_COUNT = 60;/*** 每个槽位的时间间隔(毫秒),默认1秒*/private static final long DEFAULT_TICK_DURATION = 1000;/*** 时间轮的槽位*/private final List<DelayQueue> buckets;/*** 每个槽位的时间间隔(毫秒)*/private final long tickDuration;/*** 时间轮的总时长(毫秒)= 槽位数 * 每个槽位的时间间隔*/private final long wheelDuration;/*** 当前指针指向的槽位索引*/private final AtomicInteger currentIndex = new AtomicInteger(0);/*** 时间轮启动时间戳(毫秒)*/private final long startTime;/*** 执行到期任务的线程池*/private final ExecutorService workerPool;/*** 时间轮驱动线程*/private final ScheduledExecutorService scheduler;/*** 构造函数,使用默认参数*/public TimingWheel() {this(DEFAULT_BUCKET_COUNT, DEFAULT_TICK_DURATION);}/*** 构造函数* @param bucketCount 槽位数* @param tickDuration 每个槽位的时间间隔(毫秒)*/public TimingWheel(int bucketCount, long tickDuration) {this.buckets = new ArrayList<>(Collections.nCopies(bucketCount, new DelayQueue()));this.tickDuration = tickDuration;this.wheelDuration = (long) bucketCount * tickDuration;this.startTime = System.currentTimeMillis();// 初始化线程池this.workerPool = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),Runtime.getRuntime().availableProcessors() * 2,60L, TimeUnit.SECONDS,new LinkedBlockingQueue<>(),new ThreadFactory() {private final AtomicInteger threadNum = new AtomicInteger(1);@Overridepublic Thread newThread(Runnable r) {Thread thread = new Thread(r, "timing-wheel-worker-" + threadNum.getAndIncrement());thread.setDaemon(true);return thread;}},new ThreadPoolExecutor.CallerRunsPolicy());// 初始化调度器this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {Thread thread = new Thread(runnable, "timing-wheel-scheduler");thread.setDaemon(true);return thread;});// 启动时间轮start();}/*** 启动时间轮*/private void start() {log.info("启动时间轮,槽位数:{}, 每个槽位时长:{}ms, 总时长:{}ms",buckets.size(), tickDuration, wheelDuration);// 定时驱动时间轮scheduler.scheduleAtFixedRate(() -> {try {// 移动指针到下一个槽位int index = currentIndex.getAndIncrement() % buckets.size();long currentTime = System.currentTimeMillis();log.debug("时间轮指针移动到槽位:{},当前时间:{}", index, currentTime);// 处理当前槽位的任务DelayQueue bucket = buckets.get(index);bucket.processExpiredTasks(currentTime, workerPool);} catch (Exception e) {log.error("时间轮驱动异常", e);}}, tickDuration, tickDuration, TimeUnit.MILLISECONDS);}/*** 添加延迟任务* @param delay 延迟时间(毫秒)* @param task 要执行的任务* @return 任务ID*/public String addTask(long delay, Runnable task) {if (delay < 0) {throw new IllegalArgumentException("延迟时间不能为负数");}if (task == null) {throw new NullPointerException("任务不能为null");}long currentTime = System.currentTimeMillis();long expireTime = currentTime + delay;// 计算任务需要延迟的圈数和所在的槽位long relativeTime = expireTime - startTime;int rounds = (int) (relativeTime / wheelDuration);int index = (int) ((relativeTime % wheelDuration) / tickDuration);// 创建延迟任务String taskId = UUID.randomUUID().toString();DelayTask delayTask = new DelayTask(taskId, expireTime, rounds, task);// 将任务添加到对应的槽位buckets.get(index).addTask(delayTask);log.info("添加延迟任务,taskId:{}, 延迟:{}ms, 到期时间:{}, 所在槽位:{}, 圈数:{}",taskId, delay, expireTime, index, rounds);return taskId;}/*** 取消任务* @param taskId 任务ID* @return 是否取消成功*/public boolean cancelTask(String taskId) {if (taskId == null || taskId.isEmpty()) {return false;}// 遍历所有槽位查找并取消任务for (DelayQueue bucket : buckets) {if (bucket.removeTask(taskId)) {log.info("取消延迟任务成功,taskId:{}", taskId);return true;}}log.warn("未找到要取消的延迟任务,taskId:{}", taskId);return false;}/*** 关闭时间轮*/public void shutdown() {log.info("关闭时间轮");scheduler.shutdown();workerPool.shutdown();try {if (!scheduler.awaitTermination(1, TimeUnit.SECONDS)) {scheduler.shutdownNow();}if (!workerPool.awaitTermination(1, TimeUnit.SECONDS)) {workerPool.shutdownNow();}} catch (InterruptedException e) {scheduler.shutdownNow();workerPool.shutdownNow();}}/*** 延迟任务*/private static class DelayTask {private final String taskId;private final long expireTime;private volatile int rounds;private final Runnable task;public DelayTask(String taskId, long expireTime, int rounds, Runnable task) {this.taskId = taskId;this.expireTime = expireTime;this.rounds = rounds;this.task = task;}public String getTaskId() {return taskId;}public long getExpireTime() {return expireTime;}public int getRounds() {return rounds;}public void decrementRounds() {rounds--;}public Runnable getTask() {return task;}}/*** 延迟队列(时间轮的槽位)*/private static class DelayQueue {private final Map<String, DelayTask> tasks = new ConcurrentHashMap<>();public void addTask(DelayTask task) {tasks.put(task.getTaskId(), task);}public boolean removeTask(String taskId) {return tasks.remove(taskId) != null;}public void processExpiredTasks(long currentTime, ExecutorService executor) {List<DelayTask> expiredTasks = new ArrayList<>();// 找出已到期的任务for (DelayTask task : tasks.values()) {if (task.getRounds() <= 0 && task.getExpireTime() <= currentTime) {expiredTasks.add(task);} else if (task.getRounds() > 0) {// 未到期,减少圈数task.decrementRounds();}}// 执行并移除已到期的任务for (DelayTask task : expiredTasks) {tasks.remove(task.getTaskId());executor.execute(() -> {try {log.info("执行延迟任务,taskId:{}", task.getTaskId());task.getTask().run();} catch (Exception e) {log.error("执行延迟任务异常,taskId:{}", task.getTaskId(), e);}});}}}
}

4.4 Kafka 延迟消息队列实现

package com.delay.queue;import com.alibaba.fastjson2.JSON;
import com.delay.config.KafkaConfig;
import com.delay.entity.DelayMessage;
import com.delay.util.TimingWheel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;import javax.annotation.Resource;
import java.util.UUID;
import java.util.function.Consumer;/*** 基于Kafka的延迟队列实现* @author ken*/
@Slf4j
@Component
public class KafkaDelayQueue {@Resourceprivate KafkaTemplate<String, Object> kafkaTemplate;@Resourceprivate TimingWheel timingWheel;/*** 发送延迟消息* @param topic 目标主题* @param content 消息内容* @param delayTime 延迟时间(毫秒)* @return 消息ID*/public String send(String topic, String content, long delayTime) {String messageId = UUID.randomUUID().toString();long createTime = System.currentTimeMillis();long expireTime = createTime + delayTime;DelayMessage message = new DelayMessage();message.setMessageId(messageId);message.setTopic(topic);message.setContent(content);message.setDelayTime(delayTime);message.setCreateTime(createTime);message.setExpireTime(expireTime);message.setRetryCount(0);// 发送到延迟消息主题kafkaTemplate.send(KafkaConfig.DELAY_TOPIC, messageId, message);log.info("发送Kafka延迟消息成功,topic:{}, targetTopic:{}, messageId:{}, 延迟时间:{}ms",KafkaConfig.DELAY_TOPIC, topic, messageId, delayTime);return messageId;}/*** 监听延迟消息主题,将消息放入时间轮*/@KafkaListener(topics = KafkaConfig.DELAY_TOPIC, groupId = "delay-group")public void listenDelayTopic(DelayMessage message) {if (ObjectUtils.isEmpty(message) || !StringUtils.hasText(message.getTopic()) || !StringUtils.hasText(message.getMessageId())) {log.error("接收无效的延迟消息:{}", message);return;}log.info("接收延迟消息,准备放入时间轮,messageId:{}, targetTopic:{}, 延迟时间:{}ms",message.getMessageId(), message.getTopic(), message.getDelayTime());// 计算还需要延迟的时间long currentTime = System.currentTimeMillis();long remainingDelay = message.getExpireTime() - currentTime;if (remainingDelay <= 0) {// 已经到期,直接发送到目标主题kafkaTemplate.send(message.getTopic(), message.getMessageId(), message);log.info("延迟消息已到期,直接发送到目标主题,messageId:{}, targetTopic:{}",message.getMessageId(), message.getTopic());return;}// 将消息放入时间轮,到期后发送到目标主题timingWheel.addTask(remainingDelay, () -> {kafkaTemplate.send(message.getTopic(), message.getMessageId(), message);log.info("时间轮触发,发送延迟消息到目标主题,messageId:{}, targetTopic:{}",message.getMessageId(), message.getTopic());});}/*** 注册目标主题的消息消费者* @param topic 目标主题* @param consumer 消息处理器*/public void registerConsumer(String topic, Consumer<DelayMessage> consumer) {// 在实际应用中,可以通过动态注册Kafka监听器的方式实现// 这里简化处理,直接创建一个监听器Beanlog.info("注册Kafka延迟队列消费者,topic:{}", topic);}
}

4.5 订单超时处理 Kafka 实现

修改订单服务,使用 Kafka 延迟队列:

// 在OrderServiceImpl中替换延迟队列的注入
@Resource
private KafkaDelayQueue delayQueue;// 其他代码不变,createOrder方法中发送延迟消息的代码保持一致

创建 Kafka 订单超时消息消费者:

package com.delay.consumer;import com.delay.config.KafkaConfig;
import com.delay.entity.DelayMessage;
import com.delay.service.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;import javax.annotation.Resource;/*** 订单超时消息消费者* @author ken*/
@Slf4j
@Component
public class OrderTimeoutConsumer {@Resourceprivate OrderService orderService;/*** 监听订单超时主题*/@KafkaListener(topics = KafkaConfig.ORDER_TIMEOUT_TOPIC, groupId = "order-timeout-group")public void listenOrderTimeoutTopic(DelayMessage message) {if (ObjectUtils.isEmpty(message) || !StringUtils.hasText(message.getContent())) {log.error("处理订单超时消息失败,消息内容为空:{}", message);return;}String orderNo = message.getContent();log.info("接收订单超时消息,开始处理,orderNo:{}, messageId:{}", orderNo, message.getMessageId());// 调用订单服务取消订单boolean success = orderService.cancelOrderByTimeout(orderNo);if (!success) {log.error("处理订单超时消息失败,orderNo:{}, messageId:{}", orderNo, message.getMessageId());throw new RuntimeException("取消超时订单失败");}}/*** 监听订单超时死信主题*/@KafkaListener(topics = KafkaConfig.ORDER_TIMEOUT_TOPIC + KafkaConfig.DEAD_LETTER_SUFFIX, groupId = "order-timeout-dlt-group")public void listenOrderTimeoutDltTopic(DelayMessage message) {if (ObjectUtils.isEmpty(message) || !StringUtils.hasText(message.getContent())) {log.error("处理订单超时死信消息失败,消息内容为空:{}", message);return;}String orderNo = message.getContent();log.warn("接收订单超时死信消息,orderNo:{}, messageId:{}, 重试次数:{}",orderNo, message.getMessageId(), message.getRetryCount());// 这里可以发送告警通知人工处理// sendAlarm("订单超时取消失败,orderNo:" + orderNo);}
}

五、主流延迟队列中间件对比与选型

5.1 功能对比

特性Redis ZSetKafka + 时间轮RabbitMQ 延迟插件RocketMQ 延迟消息ActiveMQ 调度消息
延迟精度中等(依赖扫描频率)高(毫秒级)高(毫秒级)低(预定义级别)高(毫秒级)
最大延迟时间无限制无限制无限制24 小时无限制
消息可靠性中(可持久化)高(多副本)高(持久化)高(持久化)高(持久化)
吞吐量
分布式支持支持支持支持支持支持
动态调整延迟支持支持不支持不支持支持
死信队列需自行实现支持支持支持支持
实现复杂度简单中等简单简单简单
社区活跃度

5.2 性能对比

在 10 万条消息,平均延迟 10 分钟的场景下测试:

中间件平均延迟误差吞吐量(消息 / 秒)内存占用CPU 占用
Redis ZSet500ms-1s约 5000
Kafka + 时间轮<100ms约 20000
RabbitMQ 延迟插件<100ms约 8000
RocketMQ 延迟消息1000ms-5000ms约 15000
ActiveMQ 调度消息<100ms约 6000

5.3 选型建议

  1. 中小规模应用,已有 Redis:优先选择 Redis ZSet 实现,成本最低
  2. 高吞吐,已有 Kafka 集群:选择 Kafka + 时间轮方案,充分利用现有资源
  3. 需要高可靠性和精确延迟,已有 RabbitMQ:使用 RabbitMQ 延迟插件
  4. 阿里技术栈,对延迟精度要求不高:选择 RocketMQ 延迟消息
  5. 企业级应用,已有 ActiveMQ:使用 ActiveMQ 调度消息

六、延迟消息队列最佳实践

6.1 消息可靠性保证

  1. 持久化存储:确保消息在系统重启后不丢失
  2. 消息确认机制:消费者处理完成后发送确认,避免消息丢失
  3. 重试机制:处理失败的消息进行有限次数重试
  4. 死信队列:无法处理的消息放入死信队列,避免阻塞
  5. 监控告警:对死信消息和重试次数过多的消息进行告警

6.2 性能优化

  1. 批量操作:批量发送和处理消息,减少 IO 次数
  2. 合理设置扫描频率:平衡精度和性能
  3. 分区 / 分片:将消息分散到多个分区,提高并行处理能力
  4. 异步处理:消息处理逻辑异步化,避免阻塞消费者
  5. 缓存热点数据:消息处理过程中需要的数据提前缓存

6.3 常见问题解决方案

  1. 消息重复消费

    • 原因:网络抖动、消费者重启等导致消息确认丢失
    • 解决方案:消息处理逻辑实现幂等性,使用消息 ID 去重
  2. 消息延迟过大

    • 原因:系统负载过高、处理逻辑耗时过长
    • 解决方案:优化处理逻辑、增加消费者数量、使用线程池异步处理
  3. 内存占用过高

    • 原因:大量未到期消息堆积在内存
    • 解决方案:采用磁盘持久化、增加节点分担负载
  4. 时间同步问题

    • 原因:分布式环境中服务器时间不一致
    • 解决方案:所有服务器同步到同一 NTP 服务器

七、总结与展望

延迟消息队列是分布式系统中处理定时任务的重要组件,通过本文的学习,我们掌握了:

  1. 延迟消息队列的核心概念和应用场景
  2. 四种主流延迟消息实现方案的原理和优缺点
  3. 基于 Redis ZSet 实现延迟队列的完整方案
  4. 基于 Kafka + 时间轮实现高吞吐延迟队列的方案
  5. 主流延迟队列中间件的对比和选型建议
  6. 延迟消息队列的最佳实践和常见问题解决方案
http://www.dtcms.com/a/447003.html

相关文章:

  • 手机网站微信咨询网站建设公司销售提成
  • 第四十天:成绩排序
  • 怎么建设自己的卡盟网站创可贴网站怎么做图片大全
  • 响应式商城网站手机网页游戏排行榜前十
  • 长沙网站建设kaodezhu上海制作网站多少钱
  • 做网站要找什么软件佛山营销网站建设
  • 点估计与置信区间及假设检验详解
  • 苏州好的做网站的公司主题猫-wordpress
  • 网站空间流量轻定制网站建设
  • List\Tuple\Set 这些数据类型大写和不大写
  • 做一个免费网站的流程郑州网站建
  • 李宏毅机器学习笔记16
  • 建网站的几个公司iis 设置此网站的访问权限
  • 网站需要域名吗为何网站打不开
  • 企业品牌网站营销网站改版后百度不收录
  • 2025年实用大模型工具清单
  • 网站定制开发上海建设网站费用
  • 黔东南州住房和城乡建设局网站石家庄商城网站建设
  • 【循环神经网络6】LSTM实战——基于LSTM的IMDb电影评论情感分析
  • 数据库原理及应用_第3篇数据库设计_第9章关系模型规范化设计理论_关系模式规范化
  • wordpress网站 添加微信支付专注郑州网站建设
  • 自己做网站平台淘宝客网站如何做推广
  • fastboot getvar all 输出完整解析
  • 动易cms网站后台很慢是什么原因asp网站首页
  • 上高做网站公司公司简介ppt内容
  • 基于ssh架构网站开发宣传推广方案怎么写
  • unity网站后台怎么做百度网站数据统计怎么做
  • Coduck模拟三
  • 用户建立自己的数据类型
  • 360 的网站链接怎么做腾讯cdc用wordpress