再也不怕重复消费!教你用 Java 实现幂等性

700 阅读15分钟

在分布式系统中,消息重复消费、重复下单等问题是常见的挑战。要解决这些问题,我们需要找到一种方法来保证 幂等性(即相同的操作无论执行多少次,结果都一致)。


1. 什么是消息重复消费和重复下单?

  • 消息重复消费
    这是指在消息队列(比如 KafkaRabbitMQ)中,消费者有可能重复消费同一条消息。
    举例:
    你发了一条“下单成功”消息到消息队列,结果消费者因为网络问题处理失败,消息被重新投递了多次,最终导致系统处理了多次“下单成功”。

  • 重复下单
    这是指用户因为多次点击“下单”按钮,或者因为网络卡顿提交了多次请求,导致同一个商品被系统下了多次订单。
    举例:
    用户买书时点击了两次“订单确认”,系统收到两次请求,生成了两个订单。


2. 为什么会出现这些问题?

消息重复消费、重复下单的问题通常源于以下情况:

  1. 网络重试机制:消息队列或客户端会重试未确认的消息,可能导致重复消费。
  2. 用户重复操作:用户因为误操作或系统延迟,可能多次提交同一请求。
  3. 分布式系统特点:系统服务之间通信可能会因为故障导致重复调用。

3. 核心解决思路:幂等性

幂等性 是解决重复问题的关键,它的核心理念是:同一个操作无论执行多少次,结果都应该是一样的

通俗举例:

  • 刷卡支付:无论你怎么重复点击“支付”,银行系统只会扣款一次。
  • 电梯按钮:无论你按多少次“10楼”,电梯只会响应一次请求。

4. 如何解决消息重复消费、重复下单?

场景 1:解决消息重复消费

在消息队列的消费端,可以通过以下几种方式解决重复消费的问题:

  1. 唯一消息 ID

    • 每条消息都带有一个唯一的 ID,例如 UUID 或自定义订单号。
    • 消费者在处理消息时,先检查这个 ID 是否已经处理过(可存储在数据库或缓存中)。如果处理过,就跳过;如果没处理过,才继续处理。
    • 示例:
      消费者收到消息 MsgID=12345,查数据库发现这条消息已经处理过,则直接丢弃,不再重复执行。

    实现方式:

    • 使用 Redis 的 SETNX(Set If Not Exists)命令:
      SETNX MsgID_12345 true
      
      如果 SETNX 返回成功,则说明这条消息是新消息,可以处理。如果返回失败,则说明已经处理过。

    优点:

    • 简单、高效,适合场景:重复消费量较低,ID 唯一性强。
  2. 消息消费表

    • 在数据库中设计一张 “消息消费表”,用于记录每条消息的消费状态(已消费、未消费)。
    • 消费者每次处理消息时,先查询表中是否有这条消息的记录。如果有,跳过;没有,则处理并记录。

    优点:

    • 更可靠,适合高并发场景。
      缺点:
    • 数据库记录增加会略微影响性能。

如何设计“消息消费表”

在分布式系统中,为了解决消息重复消费的问题,可以设计一张专门的“消息消费表”,记录每一条消息的状态,以避免重复消费。

消息消费表设计

CREATE TABLE message_consume (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,       -- 消息消费记录 ID
    message_id VARCHAR(64) NOT NULL UNIQUE,     -- 消息的唯一标识(如 UUID)
    topic VARCHAR(64) NOT NULL,                 -- 消息的主题或类型
    status TINYINT NOT NULL,                    -- 消费状态 (0: 未消费, 1: 已消费, 2: 消费失败)
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 消费记录创建时间
    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 消费记录更新时间
);

表字段说明

  1. message_id:标识消息的唯一性,通常使用消息队列中自带的 Message ID 或业务唯一标识。
  2. topic:记录消息所属的主题/来源(例如订单支付通知、库存扣减通知)。
  3. status
    • 0:消息未消费。
    • 1:消息已成功消费。
    • 2:消息消费失败(可用于后续的重试机制)。
  4. create_timeupdate_time:记录消息处理的时间,便于追踪和日志排查。

场景 2:解决重复下单

  1. 请求幂等性校验(防止重复提交)

    • 核心:为每个请求生成一个唯一的 RequestID,确保相同的请求只被处理一次。
    • 具体实现:
      • 用户生成订单时,前端向后端发送请求(附带 RequestID)。
      • 后端在处理订单请求前,先检查这个 RequestID 是否已经被处理过。如果处理过,直接返回订单结果;如果没处理过,则继续处理并记录这个 RequestID

    如何生成唯一的 RequestID

    • 使用 UUID 或订单号。
    • 或者直接使用用户 ID + 时间戳。

    使用 Redis 实现:

    • Redis 的分布式锁可以很好地解决并发问题。
      SETNX RequestID_12345 true
      
      如果成功,则说明是第一次下单,继续处理订单;如果失败,则说明已经下过单了,直接返回结果。
  2. 数据库唯一约束(强一致性保证)

    • 在订单表中设计一个唯一字段(如订单号、用户 ID + 商品 ID)。
    • 当用户下单时,数据库会自动检查是否有重复订单。如果重复,则抛出异常,防止重复下单。

    优点:

    • 简单可靠,从数据层面防止重复下单。
      缺点:
    • 数据库写压力较大,适合中低并发场景。
  3. 分布式锁

    • 当多个请求同时到达时,使用分布式锁确保只有一个请求可以继续执行,其他请求直接返回。
    • Redis 实现:
      SETNX OrderLock:user123_product456 true
      EXPIRE OrderLock:user123_product456 30  # 设置锁过期时间
      

    优点:

    • 适合高并发场景,特别是针对同一用户、同一商品的买卖操作。
  4. 前端防重按钮

    • 在用户界面中,通过禁用按钮的方式避免重复点击。
    • 用户点击“提交”按钮后,按钮会被禁用一段时间(比如 3 秒),阻止用户短时间内重复提交。

    优点:

    • 简单快速,从交互层面就避免了重复请求。

5. 通用解决方案总结

根据场景的不同,可以组合使用以下方法,全面解决重复消费和重复下单问题:

  1. 唯一 ID 机制

    • 无论是消息还是订单,都使用唯一 ID,并确保该 ID 的幂等性。
  2. 状态记录

    • 数据库、Redis 或其它存储系统中记录请求状态,确保相同请求不会被重复处理。
  3. 锁机制

    • 使用分布式锁(如 Redis 锁)来控制并发请求,防止多个线程/请求同时处理。
  4. 前端优化

    • 从用户交互层面优化,减少重复请求的可能性,比如禁用按钮、弹窗提示等。
  5. 消息队列配置

    • 如果场景涉及消息队列,确保使用“至少一次投递 + 消费幂等性保证”的组合。

6. 通俗比喻

  1. 消息重复消费
    相当于你点外卖,外卖员问你“真的要点这份餐吗?”,你说“是的”。如果你已经收到了外卖,再问你时,你会告诉他“我已经收到,不要再送了”。

  2. 重复下单
    类似于你去银行取钱,按了两次“确认”键。如果银行系统有防重机制,它只会给你一次钱,并提示你“已经取款成功,请不要重复操作”。


如何解决消息重复消费、重复下单问题?(代码案例)

在分布式系统中,消息重复消费和重复下单是常见问题。尤其在电商系统支付系统等场景中,用户重复请求、网络重试、分布式事务失败都会导致这种问题。下面我们将通过场景说明、核心思路以及实际的Java代码案例,直观地讲解如何解决这些问题。

1. 场景说明

场景 1:重复消费

问题:

  • 假设你开发了一套基于 RabbitMQKafka 的订单系统,用户下单成功后,生产者向消息队列发送“订单已支付”的消息。
  • 消费者(比如发货服务)在处理消息时,可能因为网络问题、系统故障等原因未能及时确认消息,导致消息被重新投递,消费者消费了多次,结果重复发货。

场景 2:重复下单

问题:

  • 用户在电商平台下单时,由于操作失误、页面卡顿等原因,可能多次点击“提交订单”按钮,导致生成多个订单。
  • 或者,用户发起订单请求后,因网络超时以为失败,手动重试,导致重复下单。

2. 核心解决思路

解决这类问题的核心是 保证幂等性。幂等性指:无论同一个请求执行一次还是多次,结果是一样的

通用解决方案

  1. 唯一请求标识(Request ID)

    • 每个操作都带一个唯一 ID,重复的操作可以通过 ID 检测并跳过。
  2. 状态记录

    • 通过数据库、Redis 等存储操作状态,检查是否已处理过。
  3. 分布式锁

    • 在高并发场景下,使用分布式锁来确保同一时间只有一个操作被执行。
  4. 数据库唯一约束

    • 在数据库层面通过唯一键约束防止重复记录。

3. 解决消息重复消费、重复下单的流程

3.1 消息重复消费处理流程

以下是一个基于“消息消费表”的处理流程:

3.png

流程说明

  1. 消费者接收到消息后,首先查询 message_consume 表,判断该消息是否已处理过(通过 message_id 查找)。
  2. 如果消息记录已存在,直接跳过处理(防止重复消费)。
  3. 如果消息记录不存在:
    • 写入一条新的记录,同时将状态设置为“未消费”。
    • 执行消息处理逻辑(如发货、扣减库存等)。
  4. 根据处理逻辑结果:
    • 如果处理成功,更新记录状态为“已消费”。
    • 如果处理失败,更新状态为“消费失败”,便于后续重试。

3.2 重复下单处理流程

以下是通过前端防重 + 分布式锁解决重复下单的流程:

2.png

流程说明

  1. 用户点击下单按钮后,服务端接收到请求,生成唯一的订单 ID(如 user_id + product_id 组合)。
  2. 服务端尝试在 Redis 中加锁(使用 SETNX 实现分布式锁)。
    • 如果锁获取失败,说明已有请求在处理中,直接返回提示信息。
    • 如果锁获取成功,进入下一步。
  3. 检查数据库中是否已有该订单记录。
    • 如果订单已存在,直接返回订单信息。
    • 如果订单不存在,创建新订单并写入数据库。
  4. 最后释放 Redis 锁,返回下单成功信息。

3.3 确保“至少一次投递 + 消费幂等性保证”的流程图

在分布式消息系统中,消息的投递可能会因网络或服务异常被重复投递。通过以下流程可以确保“至少一次投递 + 消费幂等性保证”

1.png

流程说明

  1. 生产者发送消息: 消息被可靠地存储到消息队列(如 Kafka、RabbitMQ)。
  2. 消息投递: 消息队列将消息投递到消费者,消费者接收到消息后:
    • 查询消费表,判断消息是否已经处理。
    • 如果消息已处理,直接发送 ACK 确认跳过。
    • 如果消息没有处理过,写入消费表,状态为“未消费”。
  3. 处理消息:
    • 如果消息处理成功,更新消费表状态为“已消费”,并发送 ACK 确认。
    • 如果消息处理失败,更新状态为“消费失败”,消息重新投递。
  4. 消息重试与死信队列:
    • 如果消息多次重试仍然失败,超过最大重试次数后,将消息转移到死信队列,由人工介入检查。

通过 消息消费表流程设计,我们可以有效解决分布式系统中的重复消费与重复下单问题:

  1. 消息消费表 记录消费状态,结合幂等逻辑确保消息不会被重复处理。
  2. 流程设计(如 Redis 分布式锁)避免重复下单,提升系统可靠性。
  3. “至少一次投递 + 消费幂等性” 的组合方案,确保消息系统在高可用场景下的正确性和一致性。

复杂的业务,要把流程拆成模块,做好模块的管理,在衔接流程!


4. 实际代码

案例 1:解决消息重复消费(基于 Redis 和唯一消息 ID)

场景:
假设消息队列中有一个“订单已支付”的消息,消费者需要处理该消息并生成发货记录。我们通过 Redis 检查消息是否已经消费过,确保重复消费时不再重复操作。

代码实现:

import redis.clients.jedis.Jedis;

public class MessageConsumer {

    private static final Jedis redis = new Jedis("localhost", 6379); // 初始化 Redis 客户端

    public void consumeMessage(String messageId, String messageContent) {
        // 1. 检查消息是否已经处理过
        String redisKey = "message:processed:" + messageId;

        if (redis.setnx(redisKey, "true") == 1) {
            // Redis 返回 1 表示首次写入,未处理过
            
            // 设置过期时间,防止 Redis 数据长期占用空间
            redis.expire(redisKey, 3600); // 1小时过期

            // 2. 处理消息内容(例如生成发货记录)
            processMessage(messageContent);

            System.out.println("消息处理成功,Message ID: " + messageId);
        } else {
            // Redis 返回 0 表示消息已处理过
            System.out.println("消息已被处理过,Message ID: " + messageId);
        }
    }

    private void processMessage(String messageContent) {
        // 模拟业务逻辑:生成发货记录
        System.out.println("生成发货记录: " + messageContent);
    }

    public static void main(String[] args) {
        MessageConsumer consumer = new MessageConsumer();

        // 模拟消息队列推送两次同样的消息
        consumer.consumeMessage("12345", "订单发货消息内容");
        consumer.consumeMessage("12345", "订单发货消息内容");
    }
}

运行结果:

生成发货记录: 订单发货消息内容
消息处理成功,Message ID: 12345
消息已被处理过,Message ID: 12345

案例 2:解决重复下单(基于分布式锁 + 唯一订单号)

场景:
用户下单时,后端会生成一个订单记录。通过分布式锁确保同一个用户对同一商品不会生成重复订单。

代码实现:

import redis.clients.jedis.Jedis;

public class OrderService {

    private static final Jedis redis = new Jedis("localhost", 6379); // 初始化 Redis 客户端

    public String placeOrder(String userId, String productId) {
        // 1. 构造分布式锁的 key
        String lockKey = "order:lock:" + userId + ":" + productId;

        try {
            // 2. 尝试获取分布式锁
            if (redis.setnx(lockKey, "locked") == 1) {
                // 设置锁的过期时间,防止死锁
                redis.expire(lockKey, 30);
                
                // 3. 生成订单(模拟)
                String orderId = generateOrder(userId, productId);
                System.out.println("订单生成成功,订单号: " + orderId);

                return orderId;
            } else {
                // 4. 如果未获取到锁,说明订单正在提交,直接返回
                System.out.println("订单已存在,请勿重复下单!");
                return null;
            }
        } finally {
            // 5. 释放分布式锁
            redis.del(lockKey);
        }
    }

    private String generateOrder(String userId, String productId) {
        // 模拟生成订单号
        return "ORDER_" + userId + "_" + productId + "_" + System.currentTimeMillis();
    }

    public static void main(String[] args) {
        OrderService orderService = new OrderService();

        // 模拟用户重复下单
        String userId = "user123";
        String productId = "product456";

        new Thread(() -> orderService.placeOrder(userId, productId)).start();
        new Thread(() -> orderService.placeOrder(userId, productId)).start();
    }
}

运行结果:

订单生成成功,订单号: ORDER_user123_product456_1698901234567
订单已存在,请勿重复下单!

案例 3:数据库唯一约束防重(适合中小型系统)

在订单表中添加唯一约束,例如 user_id + product_id 组合唯一,确保从数据库层面防止重复下单。

数据库表设计:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id VARCHAR(50) NOT NULL,
    product_id VARCHAR(50) NOT NULL,
    order_id VARCHAR(50) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY (user_id, product_id) -- 防止同一用户对同一商品重复下单
);

在 Java 中处理:

try {
    // 插入订单记录
    jdbcTemplate.update("INSERT INTO orders (user_id, product_id, order_id) VALUES (?, ?, ?)", userId, productId, orderId);
    System.out.println("订单生成成功!");
} catch (DuplicateKeyException e) {
    System.out.println("订单已存在,请勿重复下单!");
}

解决消息重复消费、重复下单的通用原则:

  1. 幂等性 是核心:无论操作执行一次还是多次,结果都必须一致。
  2. 结合业务场景选择合适方案:
    • 高并发下:优先使用 Redis 分布式锁唯一请求 ID
    • 数据一致性要求高:可以在数据库层面加唯一约束。
    • 低延迟场景:设计前端防重按钮,减少重复请求产生的概率。

5. 总结

解决重复消费和重复下单的问题,核心在于保证 操作的幂等性

  • 利用唯一 ID 确保请求唯一性。
  • 使用状态记录确保相同请求不会被重复处理。
  • 借助分布式锁或数据库唯一约束避免并发问题。

通过技术手段结合用户交互优化,可以有效避免重复消费和重复下单的问题,提高系统的可靠性和用户体验。