最佳实践: 构建事件驱动消息队列(可支持延时队列)

事件驱动实现延时队列

事件驱动的延时队列是一种常见的设计模式,用于在系统中处理延迟触发的任务或事件。以下是一个实现事件发布实现消息推送可支持延时队列的实践,包括主要组件和步骤。

本地事件交给ApplicationContext处理,远程事件交给RabbitMq处理.

组件

  1. 事件接口(IDomainEvent):定义需要发布的事件, 可以是本地事件也可以是远程事件(消息事件).
  2. 事件发布者(IEventPublisher):发布事件的抽象接口, 拥有发布事件的能力
  3. 消息发布者(IMessageSender):RabbitMQ的消息发送接口抽象, 可推送RabbitMQ消息, 被IEventPublisher所调用.

组件: 定义事件(IDomainEvent)

事件包含的属性

- **事件发送延迟时间**:事件接口包含一个 `delay` 字段,表示事件的延迟时间。默认情况下,延迟时间为 0,即立即发送事件。
- **事件唯一标识符**:通过 `getEventId()` 方法获取每个事件的唯一标识符。
- **事件类型**:使用事件类名作为事件类型。可以通过 `getEventType()` 方法获取事件的类型。
- **事件发生时间**:通过 `getOccurredOn()` 方法获取事件的发生时间。
- **是否是本地事件**:通过 `isLocalEvent()` 方法判断事件是否为本地事件。
- **交换机**:通过 `getExchange()` 方法获取事件在 RabbitMQ 中使用的交换机名称。
- **路由键**:通过 `getRoutingKey()` 方法获取事件在 RabbitMQ 中使用的路由键。在某些情况下,该字段是非必填的。
- **是否忽略事务**:默认情况下,事件发送需要在事务提交后执行。可以通过 `ignoreTransaction()` 方法设置是否忽略事务。 

事件IDomainEvent接口

import java.util.Date;

public interface IDomainEvent {

    /**
     * 事件发送延迟时间,目前支持远程事件
     */
    int delay = 0;

    /**
     * 获取每个event唯一的id
     * @return
     */
    String getEventId();

    /**
     * 使用事件类名作为事件类型
     * @return
     */
    default String getEventType() {
        return this.getClass().getName();
    }

    /**
     * 事件发生时间
     * @return
     */
    Date getOccurredOn();

    default int getDelay() {
        return delay;
    }

    /**
     * 是否是本地事件
     * @return
     */
    boolean isLocalEvent();

    /**
     * 交换机,rabbitmq需要
     * @return
     */
    String getExchange();

    /**
     * 路由key,rabbitmq需要,非必填
     * @return
     */
    String getRoutingKey();

    /**
     * 默认发送事件需要在事务提交后
     */
    default boolean ignoreTransaction() {
        return false;
    }

}

组件: 事件发布者(IEventPublisher)

public interface IEventPublisher {

    /**
     * 发布事件
     * @param domainEvent
     */
    void publish(final IDomainEvent domainEvent);
}

@Slf4j
@Component
public final class RabbitEventPublisher implements IEventPublisher {

    @Autowired
    private ApplicationContext applicationContext;

    @Autowired
    private IMessageSender messageSender;

    @Override
    public void publish(final IDomainEvent domainEvent) {
        if (!domainEvent.ignoreTransaction() && TransactionSynchronizationManager.isActualTransactionActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCommit() {
                    doPublish(domainEvent);
                }
            });
        } else {
            doPublish(domainEvent);
        }
    }

    private void doPublish(IDomainEvent domainEvent) {
        //本地事件
        if (domainEvent.isLocalEvent()) {
            applicationContext.publishEvent(domainEvent);
            log.info("【异步事件】发布事件: {}", JSON.toJSONString(domainEvent));
        } else {
        //消息事件
            messageSender.send(new PublishedEventContent(domainEvent));
        }
    }
}

  public PublishedEventContent(IDomainEvent domainEvent) {
        this.publishedTimes = 1;
        this.publishedTime = System.currentTimeMillis();
        this.domainEvent = domainEvent;
    }

组件: 消息发送方(IMessageSender)

public interface IMessageSender {

    /**
     * 发送mq消息
     * @param publishedEventContent
     */
    void send(PublishedEventContent publishedEventContent);
}

IMessageSender的实现体现MQ 消息机制

  1. 确认机制(Confirm Callback):

    • 当消息成功发送到交换机时,通过设置 rabbitTemplate.setConfirmCallback() 来监听确认回调。
    • 如果确认回调中的 ack 参数为 true,表示消息成功到达交换机。
    • 如果 ack 参数为 false,表示消息发送失败,可能由于交换机不存在或路由键错误等原因。
    • 当消息发送失败时,会执行重试逻辑或进行相应的处理。
  2. 返回机制(Return Callback):

    • 当消息无法路由到队列时,通过设置 rabbitTemplate.setReturnCallback() 来监听返回回调。
    • 如果消息无法路由到队列,会触发返回回调,可以根据回调中的信息进行相应的处理。
    • 在返回回调中,可以获取到消息的相关信息,如回复码(replyCode)、回复文本(replyText)、交换机(exchange)和路由键(routingKey)等。
  3. 重试机制(Retry):

    • 初始化: 在 RabbitMQ 消息发送方的初始化阶段,设置确认机制和返回机制的回调,确保在消息发送成功或失败时执行相应的逻辑。
    • 确认机制回调: 通过 setConfirmCallback 监听器,当消息成功发送到交换机时,判断 ack 参数是否为 true。如果为 true,表示消息成功到达交换机,从缓存中删除相应的缓存项。如果为 false,表示消息发送失败,执行重试逻辑。
    • 返回机制回调: 通过 setReturnCallback 监听器,当消息无法路由到队列时触发,根据回调中的信息执行相应的处理。这里主要用于处理消息无法到达队列的情况,例如交换机到队列的绑定错误。在返回回调中,可以获取消息的相关信息,如回复码、回复文本、交换机和路由键等。
    • 重试逻辑: 在确认机制回调中,如果 ackfalse,表示消息发送失败。通过 doRetry(String id, int times) 方法执行重试逻辑。根据缓存中的重试次数,如果未达到上限,增加重试次数并重新发送消息,否则放弃重试并进行相应的处理。重试次数上限可根据实际情况设置,这里示例设置为 2 次。
@Slf4j
@Component
public class RabbitMessageSender implements IMessageSender {
   //mq template
    @Autowired
    private RabbitTemplate rabbitTemplate;

    //重试机制
    @Autowired
    private IRetryCache retryCache;

    /**
     * Publisher Confirms and Returns配置,依赖开启配置
     * connectionFactory.setPublisherConfirms(true);
     * connectionFactory.setPublisherReturns(true);
     * spring.rabbitmq.template.mandatory=true
     *
     */
    @PostConstruct
    public void init() {
        // ack确认机制-是否到达exchange
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            // 判断correlationData是否为空,兼容部分老代码实现
            if (correlationData != null) {
                if (ack) {
                    log.info("【异步事件】事件确认成功eventId: {}", correlationData.getId());
                    retryCache.del(correlationData.getId());
                } else {
                    log.info("【异步事件】事件确认失败eventId: {}, cause: {}", correlationData.getId(), cause);
                    doRetry(correlationData.getId(), 2);
                }
            }
        });

        // exchange是否到达queue
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            Integer delay = message.getMessageProperties().getReceivedDelay();
            if (delay == null || delay == 0) {
                log.error("【异步事件】exchange到queue失败回调, replyCode {}, replyText {}, 需人工介入, eventId: {}", replyCode, replyText, message.getMessageProperties().getMessageId());
            } else {
                log.info("【异步事件】exchange到queue可能失败回调, replyCode {}, replyText {}, eventId: {}", replyCode, replyText, message.getMessageProperties().getMessageId());
            }
        });
    }

    @Override
    public void send(PublishedEventContent publishedEventContent) {
        IDomainEvent domainEvent = publishedEventContent.getDomainEvent();

        MessageProperties properties = new MessageProperties();
        properties.setContentType(MessageProperties.CONTENT_TYPE_JSON);
        properties.setDelay(domainEvent.getDelay());
        properties.setMessageId(domainEvent.getEventId());
        String eventJsonStr = JSON.toJSONString(domainEvent);
        Message message = rabbitTemplate.getMessageConverter().toMessage(eventJsonStr, properties);

        try {
            // 发送前先存一下消息,用于ack机制重发
            retryCache.add(domainEvent.getEventId(), publishedEventContent);

            rabbitTemplate.send(domainEvent.getExchange(), domainEvent.getRoutingKey(), message, new CorrelationData(domainEvent.getEventId()));
            log.info("【异步事件】发布事件: {}", eventJsonStr);
        } catch (Exception e) {
            log.error("【异步事件】发布事件失败需人工介入: {}", eventJsonStr, e);
        }
    }


    private void doRetry(String id, int times) {
        PublishedEventContent publishedEventContent = retryCache.get(id);
        if (publishedEventContent == null) {
            return;
        }
        if (publishedEventContent.getPublishedTimes() > times) {
            log.error("【异步事件】ACK重试达到上限,需人工介入: eventId: {}", publishedEventContent.getDomainEvent().getEventId());
            retryCache.del(id);
            return;
        }
        publishedEventContent.incrementPublishedTimes();
        this.send(publishedEventContent);
    }

}

存储缓存机制: 重试缓存接口

  1. 添加缓存项:通过 add(String id, PublishedEventContent publishedEventContent) 方法将事件的唯一标识符 id 与包含事件内容的 PublishedEventContent 存储到缓存中。

  2. 删除缓存项:通过 del(String id) 方法可以从缓存中删除特定标识符的缓存项。

  3. 获取缓存项:通过 get(String id) 方法可以根据唯一标识符获取相应的 PublishedEventContent 缓存项。

  4. 获取过期缓存项:通过 getExpiredObjects() 方法获取所有过期的缓存项列表,过期时间定义为超过指定时间(CACHE_EXPIRE_TIME)未被处理。

public interface IRetryCache {

    void add(String id, PublishedEventContent publishedEventContent);

    void del(String id);

    PublishedEventContent get(String id);

    List<PublishedEventContent> getExpiredObjects();

}
@Slf4j
@Component
public class RedisRetryCache implements IRetryCache {

    private static final String CACHE_KEY = "EVENT_PUBLISHED";

    private static final long CACHE_EXPIRE_TIME = 300000;

    private StringRedisTemplate redisTemplate;

    private String eventPublishedCacheKey;

    public RedisRetryCache(StringRedisTemplate redisTemplate, @Value("${spring.application.name:}") String appName) {
        this.redisTemplate = redisTemplate;
        this.eventPublishedCacheKey = new StringJoiner(":").add(CACHE_KEY).add(appName).toString();
    }

    @Override
    public void add(String id, PublishedEventContent publishedEventContent) {
        redisTemplate.opsForHash().put(eventPublishedCacheKey, id, JSON.toJSONString(publishedEventContent));
    }

    @Override
    public void del(String id) {
        redisTemplate.opsForHash().delete(eventPublishedCacheKey, id);
    }

    @Override
    public PublishedEventContent get(String id) {
        Object value = redisTemplate.opsForHash().get(eventPublishedCacheKey, id);
        if (value != null) {
            return PublishedEventContent.convertTo(value.toString());
        }
        return null;
    }


    @Override
    public List<PublishedEventContent> getExpiredObjects() {
        List<PublishedEventContent> list = new ArrayList<>();
        List<Object> objs = redisTemplate.opsForHash().values(eventPublishedCacheKey);
        if (objs != null) {
            long now = System.currentTimeMillis();
            for (Object obj : objs) {
                PublishedEventContent publishedEventContent = PublishedEventContent.convertTo(obj.toString());
                if (now - publishedEventContent.getPublishedTime() > CACHE_EXPIRE_TIME) {
                    list.add(publishedEventContent);
                }
            }
        }
        return list;
    }
}

总结:

  • 解耦:发布者和消费者之间解耦,它们不需要直接交互,通过消息队列进行通信。
  • 可靠性:保证事件或任务能够按照预定的延时时间触发,即使消费者不可用或发生故障。
  • 可扩展性:可以根据需求添加新的发布者、消费者或消息队列来处理不同类型的事件或任务。
  • 灵活性:可以根据业务需求设定不同的延时时间,以满足各种场景下的需求。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值