Skip to content

消费队列的常见语义

消息队列的消费语义是指消息队列系统在生产者、消费者和队列本身之间提供的关于消息投递次数的承诺。通常分为以下三种级别:

  1. At-most-once (最多一次)

    • 含义:消息最多被投递一次,但可能会丢失。也就是说,它要么被成功处理一次,要么就干脆丢失了。
    • 实现方式:消费者从队列中拉取消息后,在处理业务逻辑之前,立即向队列发送确认(ACK)。如果此时消费者应用崩溃,而业务逻辑尚未执行,那么这条消息就永久丢失了。
    • 优点:性能最高,因为没有重试等额外开销。
    • 缺点:会丢失数据。
    • 适用场景:对数据丢失不敏感的场景,如日志收集、监控指标上报等。
  2. At-least-once (最少一次)

    • 含义:消息保证至少被投递一次,绝不会丢失,但可能会被重复投递和处理。
    • 实现方式:这是大多数消息队列默认的语义。消费者拉取消息后,先执行业务逻辑,当业务逻辑成功完成后,再向队列发送确认(ACK)。如果在业务处理完但发送ACK前,消费者崩溃了,队列会认为该消息没有被成功消费,从而会重新投递这条消息给其他消费者。
    • 优点:保证数据不丢失。
    • 缺点:会产生重复消息,需要消费端有能力处理重复数据。
    • 适用场景:绝大多数要求数据完整性的业务场景,如订单处理、支付通知等。
  3. Exactly-once (精确一次)

    • 含义:这是最理想的状态,消息既不丢失,也不重复,每一条消息都被精确地处理一次。
    • 现实情况:严格意义上的“精确一次”在分布式系统中是极难实现的。我们通常所说的“精确一次”,实际上是通过“最少一次”的投递机制,再加上消费端的幂等性处理,共同达成的最终业务效果上的“精确一次”。

如何实现“消费且仅仅消费一次”

正如上面所说,实现“精确一次”消费的核心思想是:Exactly-Once = At-Least-Once + 幂等性处理

下面是实现这一目标的具体步骤和常见方案:

第一步:确保消息队列的投递语义为“最少一次”

这是实现精确一次消费的基础。你需要确保你的消息队列配置和消费者代码逻辑遵循“先处理业务,后确认消息”的原则。这保证了即使发生故障,消息也不会丢失。

第二步:在消费端实现幂等性

幂等性(Idempotence)指的是一个操作无论执行一次还是执行多次,其产生的影响和结果都是相同的。由于“最少一次”语义可能导致消息重复,消费端的业务逻辑必须被设计成幂等的,以应对重复消息。

以下是几种实现幂等性的常见方法:

  1. 使用唯一ID + 持久化存储 这是最通用和常见的方案。

    • 机制:为每一条消息生成一个全局唯一的业务ID(例如订单ID、支付流水号)。消费者在处理消息时,先查询这个唯一ID是否已经被处理过。
    • 流程:
      1. 消费者收到消息,提取出唯一ID。
      2. 去一个持久化存储(如Redis、数据库)中查询这个ID是否存在。
      3. 如果存在,说明是重复消息,直接忽略并手动ACK。
      4. 如果不存在,则执行业务逻辑,并将这个唯一ID存入持久化存储中。最后再ACK消息。
    • 注意:业务逻辑的执行和唯一ID的存储这两个步骤,最好能放在同一个本地事务中,以保证原子性。
  2. 利用数据库的唯一约束 如果你的业务逻辑是向数据库中插入一条数据,这个方法非常简单高效。

    • 机制:将消息的唯一ID作为数据库表的主键或唯一索引。
    • 流程:
      1. 消费者收到消息,直接尝试将数据插入数据库。
      2. 如果插入成功,说明是新消息,业务处理完成。
      3. 如果插入失败(因为主键或唯一键冲突),说明是重复消息。此时捕获这个异常,将它视为业务处理成功,然后ACK消息即可。
  3. 使用状态机或版本号(乐观锁) 这种方法适用于更新操作。

    • 机制:为需要更新的数据引入一个版本号(version)或者状态(status)。每次更新时,都要求版本号或状态必须是某个前置值。
    • 流程:
      1. 例如,一个订单有“待支付”、“已支付”、“已发货”等状态。一个支付成功的消息,只会将“待支付”状态的订单更新为“已支付”。
      2. SQL示例:UPDATE orders SET status = '已支付' WHERE order_id = ? AND status = '待支付';
      3. 当重复的支付消息到来时,由于订单状态已经不是“待支付”,这条SQL语句会执行成功但影响的行数为0。消费者检查受影响的行数,就知道这个重复操作没有产生副作用。

第三步:将业务操作和消息确认(或Offset提交)原子化

这是最可靠的方案,通常用于对一致性要求极高的金融等场景。

  • 问题点:在标准的“先处理业务,后ACK”流程中,如果在业务提交之后、ACK之前系统崩溃,就会导致消息重复。
  • 解决方案:将业务操作的数据库事务和消息的ACK(在Kafka中是提交Offset)绑定成一个原子操作。

    • 流程:
      1. 消费者开启数据库事务。
      2. 在事务中执行所有业务SQL。
      3. 在同一个事务中,将消息的消费状态(或Kafka的Offset)也记录到一张数据库表中。
      4. 提交数据库事务。
      5. 只有当数据库事务成功提交后,才认为消息处理成功(此时可以异步地ACK消息,即使ACK失败,由于消费状态已记录在数据库,下次重复消费时可以查询数据库得知已处理过)。

    这种方式利用了数据库事务的ACID特性,来保证业务处理和消费状态记录的最终一致性,从而实现了事实上的“精确一次”消费。许多现代的消息系统(如Kafka)的事务性消息功能,就是为了简化这种模式的实现。