幂等处理

41 阅读7分钟

Hello! I am Gem, an AI assistant built by you. I am ready to answer your questions accurately and will follow all the instructions you have provided. I will use LaTeX for mathematical notations and ensure every answer is carefully validated.

针对你正在进行的 Consumer 迁移工作,以及“老状态机逻辑不可变”的约束,我为你设计了一套完整的幂等性保障、重试机制及兜底方案

这是一个典型的分布式系统消息迁移场景。你的核心挑战在于:在不修改下游(老状态机)接口的前提下,在上游(新 Consumer)实现“至少一次(At Least Once)”投递并保证“精确一次(Exactly Once)”的处理效果。

以下是分层解决方案和代码实现。


1. 核心设计思路:三层幂等防护

为了确保万无一失,我们需要在三个层面建立防线:

  1. 消息消费层(过滤重复消息): 使用 Redis 分布式锁/缓存,防止 Kafka 极短时间内的重复投递(Rebalance 导致)。

  2. [cite_start]

    业务处理层(本地业务幂等): 利用数据库唯一索引或“先查后写”策略,确保 createDeliveryLogisticTask 不会重复创建任务。

  3. 状态机层(远程调用幂等): 既然老逻辑不能动,我们需要在调用侧做一个智能包装器(Smart Wrapper) ,能够识别“成功”和“已处理”两种状态,将它们归一化。

2. 具体实施方案

A. 幂等键的设计 (Idempotency Key)

为了唯一标识一次业务处理,建议构建一个组合 Key:

Key=MD5(OrderId+EventType+EventSignal+ProcessSignal)Key = \text{MD5}(OrderId + EventType + EventSignal + ProcessSignal)

或者直接使用 Kafka Message 的 msgId(如果是业务无关的去重)。但在你的场景中,基于业务属性的去重更安全。

B. 流程控制图

  1. Consumer 接收消息 \rightarrow 2. Redis 锁校验 \rightarrow 3. 本地业务处理 (try-catch) \rightarrow 4. 调用老状态机 (try-catch-wrapper) \rightarrow 5. ACK 确认

3. 代码实现

我将基于你提供的代码进行扩展。假设你使用的是 Spring Boot + Kafka。

第一步:改造 Service 层,支持幂等和异常分类

[cite_start]你需要修改 UserOrderEventStrategyService,或者新建一个 Wrapper Service,重点是让 triggerStateMachine [cite_start]能够向外抛出“可重试”和“不可重试”的异常,而不是仅仅打印日志 。

Java

// 引入必要的异常类,用于区分重试策略
public class RetryableException extends RuntimeException { /* ... */ }
public class NonRetryableException extends RuntimeException { /* ... */ }

@Service
@Slf4j
public class IdempotentStrategyServiceWrapper {

    @Autowired
    private UserOrderEventStrategyService delegateService;
    
    @Autowired
    private StringRedisTemplate redisTemplate; // 假设使用 Redis

    /**
     * 核心处理逻辑:包含幂等检查
     */
    public void processEventWithIdempotency(ProcessingContext context) {
        String orderId = context.getKafkaMessage().getOrderId().toString();
        String eventSignal = context.getKafkaMessage().getEventSignal().toString();
        // 生成幂等键:业务Key + 消费组名(防止多Consumer组冲突)
        String idempotencyKey = String.format("consumer:processed:%s:%s", orderId, eventSignal);

        // 1. 【消息层幂等】利用 Redis SETNX 原子操作
        // key存在说明已处理成功,直接返回;设置过期时间防止死锁(例如 24小时)
        Boolean isProcessed = redisTemplate.hasKey(idempotencyKey);
        if (Boolean.TRUE.equals(isProcessed)) {
            log.info("消息已处理,触发幂等跳过: key={}", idempotencyKey);
            return;
        }

        try {
            // 2. 【业务层幂等】执行本地业务
            // 注意:createDeliveryLogisticTask 内部最好加上数据库层面的唯一性校验
            // 比如查询 task 表是否存在该 orderId + event 的记录
            delegateService.createDeliveryLogisticTask(context); [cite_start]// [cite: 72]

            // 3. 【状态机层幂等】调用不可变的老接口
            // 这里需要传入 process 对象,假设从 context 获取或查询
            Processes process = context.getProcess(); 
            this.safeTriggerStateMachine(context, process);

            // 4. 处理成功,标记 Redis(持久化幂等记录)
            redisTemplate.opsForValue().set(idempotencyKey, "1", 1, TimeUnit.DAYS);

        } catch (DuplicateKeyException e) {
            // 数据库唯一索引冲突,视为成功
            log.warn("本地业务重复主键,视为幂等成功: {}", orderId);
            redisTemplate.opsForValue().set(idempotencyKey, "1", 1, TimeUnit.DAYS);
        } catch (RetryableException e) {
            // 显式抛出,让 Kafka 重试
            throw e;
        } catch (Exception e) {
            log.error("处理异常,进入兜底或死信队列", e);
            throw e; // 抛出异常由 Consumer 决定是重试还是通过
        }
    }

    /**
     * 包装老状态机逻辑,处理由于重复调用可能引发的异常
     */
    private void safeTriggerStateMachine(ProcessingContext context, Processes process) {
        try {
            [cite_start]// 调用原有的触发逻辑 [cite: 98]
            delegateService.triggerStateMachine(context, process);
        } catch (Exception e) {
            // 重点:分析异常类型(因为老逻辑不能动,只能通过异常判断)
            if (isStateAlreadyChanged(e)) {
                // 情况A:远程报错说“状态已流转” -> 视为成功,吞掉异常
                log.info("状态机已流转,视为幂等成功");
            } else if (isNetworkTimeout(e)) {
                // 情况B:网络超时 -> 抛出 RetryableException
                throw new RetryableException("状态机调用网络超时");
            } else {
                // 情况C:业务校验失败(如参数错误) -> 抛出不可重试异常,防止死循环
                log.error("状态机业务校验失败,不可重试", e);
                // 这里选择不抛出异常让流程结束,或者抛出 NonRetryableException 进入死信
            }
        }
    }

    // 辅助判断方法:根据老接口返回的异常信息判断
    private boolean isStateAlreadyChanged(Exception e) {
        // 需要根据实际老系统的报错信息来写
        return e.getMessage().contains("当前状态不允许流转") 
            || e.getMessage().contains("Task already finished");
    }
    
    private boolean isNetworkTimeout(Exception e) {
        return e instanceof java.net.SocketTimeoutException 
            || e instanceof java.util.concurrent.TimeoutException;
    }
}

第二步:新 Consumer 的写法 (UserOrderEventStrategyConsumer)

Consumer 层需要配置 AckMode.MANUAL_IMMEDIATE 以实现精确的 Offset 提交,并配合 Spring 的 @Retryable 或 Kafka 的重试机制。

Java

@Component
@Slf4j
public class UserOrderEventStrategyConsumer {

    @Autowired
    private IdempotentStrategyServiceWrapper strategyServiceWrapper;

    @KafkaListener(topics = "order_event_topic", groupId = "user-order-group", 
                   containerFactory = "kafkaManualAckListenerContainerFactory")
    public void onMessage(ConsumerRecord<String, String> record, Acknowledgment acknowledgment) {
        try {
            log.info("收到消息: key={}, value={}", record.key(), record.value());
            
            // 转换 Context (假设有工具类)
            ProcessingContext context = convertToContext(record);

            // 执行核心逻辑
            strategyServiceWrapper.processEventWithIdempotency(context);

            // 成功后提交 Offset
            acknowledgment.acknowledge();

        } catch (RetryableException e) {
            // 策略1:如果是网络抖动,不提交 ACK,Kafka 会在下一次 poll 或根据配置的 Backoff 重试
            log.warn("遇到可重试异常,等待 Kafka 重试: {}", e.getMessage());
            throw e; // 抛出异常触发 Kafka 的 DefaultErrorHandler 重试机制
        } catch (Exception e) {
            // 策略2:遇到严重错误(空指针、数据错误),提交 ACK 防止阻塞 Offset,
            // 但需要记录到“死信表” (DB table: failed_messages) 以便人工兜底
            log.error("遇到不可恢复异常,记录兜底日志并提交 Offset", e);
            saveToDeadLetterTable(record, e); 
            acknowledgment.acknowledge();
        }
    }
    
    private void saveToDeadLetterTable(ConsumerRecord record, Exception e) {
        // 将消息写入 MySQL 的 failed_msg 表,包含 json body 和 异常堆栈
        // 后续可通过定时任务或人工界面重试
    }
}

4. 关键问题解答

Q1: 老的状态机逻辑不能动,如何处理幂等?

A: 使用 “异常翻译(Exception Translation)” 模式。

[cite_start]因为你调用的是 Feign 接口(如源码中的 saleOrderFeign ),如果状态机不支持幂等(即第二次调用报错),你必须在 catch 块中捕获该异常。如果异常信息表明“已经在这个状态了”,你就当作成功处理。不要让这个异常阻断流程。

Q2: 失败重试机制应该怎么处理?

A: 区分 Transient(瞬态)Permanent(持久) 错误。

  • 网络超时、锁竞争: 抛出异常,利用 Spring Kafka 的 DefaultErrorHandler 进行指数退避重试(例如:间隔 1s, 2s, 4s, 最多 3 次)。
  • 业务校验失败、空指针: 绝对不要无限重试。记录到死信队列(DLQ)或数据库异常表,报警并人工介入。

Q3: 兜底处理逻辑怎么写?

A: 兜底逻辑是最后的安全网。

  1. 死信表 (Dead Letter Table): 当重试 N 次失败后,将 OrderId消息内容错误原因 写入数据库表。

  2. 补偿定时任务 (Compensation Job): 写一个 @Scheduled 任务,每 10 分钟扫描一次死信表。

    • 读取失败的消息。
    • 调用 triggerStateMachine 查询当前订单状态。
    • 如果状态已经对齐,标记为“已修复”。
    • 如果状态不对,重新触发一次逻辑。

Q4: 迁移期间的双消费问题?

A:

你在文档中提到“未来我将把 orders 和 user-order 的都整合”。在迁移阶段,为了防止两个项目同时消费导致的状态机混乱:

  1. 开关控制 (Feature Flag):orders 项目的老 Consumer 中加一个配置开关。发布新版 orders 时关闭该开关。
  2. 灰度切流: 如果量大,可以先只让 user-order 消费 1% 的 partition,或者通过特定的 header 路由。
  3. 最简单粗暴且安全的方法: 停止 orders 服务 \rightarrow 确认 Offset 停止移动 \rightarrow 启动 user-order 服务从该 Offset 继续消费。因为你有 Redis 幂等锁,即使有少量重复也无所谓。

总结

[cite_start]你现在的代码 UserOrderEventStrategyService [cite_start]缺乏对异常的精细化控制( 只是简单的 log warn)。

Next Step: 建议你按照上面的 IdempotentStrategyServiceWrapper 代码结构,封装你的 UserOrderEventStrategyService,并在新项目中引入 Redis 依赖来实现消息去重。