spring-boot下,Kafka 可靠性应用方案

章节目录

参考链接

  1. 《用 docker 部署 kafka》
  2. Spring for Apache Kafka - 2.5.17.RELEASE
  3. 10.3. Apache Kafka Support
  4. 批处理定时执行任务_记一次Spring定时任务非预期执行的解决与原理

需求说明

  1. 针对冗长的同步业务程序,进行异步化解耦优化,但是也要保证解耦后的程序尽量保证和同步执行一样的可靠性。
  2. 将实现过程记录下来,形成通用的实现模板,方便后续再次遇到这种情况能够快速应用

部署 kafka 服务

参考:《用 docker 部署 kafka》

1. 基本 Spring-kafka 应用

基本应用的实现,是为了后文做对比和代码模板

1.1. 在构建工具中声明依赖项

以下示例显示了如何使用 Maven 执行此操作:

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

以下示例显示了如何使用 Gradle 执行此操作:

compile 'org.springframework.kafka:spring-kafka:2.5.17.RELEASE'

警告:
使用Spring Boot时,省略版本,Boot将自动引入与您的启动版本兼容的正确版本:

如果是集成到 Spring Boot,它会自动识别类路径是否存在 spring-kafka,如果存在,则自动开启,不需要加上 @EnableKafka 注解。

1.2. 在配置文件中声明kafka相关配置

spring:
  kafka:
    # 配置 kafka 服务器地址和端口
    bootstrap-servers: "localhost:9092"
    producer:
      value-serializer: org.springframework.kafka.support.serializer.ToStringSerializer
    consumer:
      # 消费者id
      group-id: "myGroup"
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer

注意:
序列化器和反序列化器,这里根据本人使用习惯,采用用 String 字符串的序列化器,用户可以自行尝试其他序列化器。

1.3. 发送消息

Spring 的 KafkaTemplate 是自动配置的,您可以直接在自己的bean中自动连接(autowire),如下例所示:

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;

@Component
public class MyBean {

    private final KafkaTemplate<String, String> kafkaTemplate;

    public MyBean(KafkaTemplate<String, String> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void someMethod() {
        this.kafkaTemplate.send("someTopic", "Hello");
    }

}

笔记:
如果定义了属性 spring.kafka.producer.transaction-id-prefix,则会自动配置 KafkaTransactionManager。此外,如果定义了 RecordMessageConverter bean,它会自动关联到自动配置的 KafkaTemplate

1.4. 接收消息

当存在 Apache Kafka 基础设施时,可以用 @KafkaListener 注释任何bean,以创建侦听器端点。如果没有定义 KafkaListenerContainerFactory,则会使用 spring.kafka.listener.* 中定义的键自动配置默认值。

以下组件在 someTopic 主题上创建侦听器端点:

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class MyBean {

    @KafkaListener(topics = "someTopic")
    public void processMessage(String content) {
        // ...
    }

}

提示:
如果定义了 KafkaTransactionManager bean,它将自动与容器工厂关联。类似地,如果定义了 RecordFilterStrategyCommonErrorHandlerAfterRollbackProcessorConsumerAwareRebalanceListener bean,它会自动与默认工厂关联。

2. 应用 spring-kafka 重试机制

2.1. 背景

基本 kafka 应用实现,如果消费过程中遇到了异常(包括网络故障,业务不合理,代码结构等导致的异常),消费失败,kafka 默认在消费者端连续重试 3 次,3次过后,无论是否成功消费都提交偏移量,默认消费成功,或者表示消费过。

如此一来,就可以视为消息丢失,可能会导致业务流程直接中断,产生脏数据,严重点,导致系统崩坏,这明显是不行的。

2.2. 启用 spring-kafka 自己实现的重试机制

启用重试非常简单,只需要在 @KafkaListener 注解的方法上增加一个 @RetryableTopic 注解,然后另外增加一个死信队列的处理方法并加上 @DltHandler 注解即可。

    /**
     * 主题消费者
     * <p>
     * 默认情况下,spring-kafka 在消费逻辑抛出异常时,会快速重试 10 次(无间隔时间),如果重试完成后,依旧消费失败,spring-kafka 会 commit 这条记录。
     * {@code @RetryableTopic} 注解可以更改消费者重试机制:
     * 1. 重试3次
     * 2. 初始间隔时间未 3 秒
     * 3. 再之后每次间隔时间乘以 8(3秒,24秒,3.2分钟,25.6分钟,3.413小时,1.137天)
     * 4. 最大延迟时间,计算公式:{@code maxDelay = delay * multiplier ^ (attempts - 1)}
     * 5. 如果间隔时间相同,采用 SINGLE_TOPIC 策略,如果延迟时间指数上升,则需要使用 MULTIPLE_TOPICS 策略
     * 6. 自动根据原 topic 添加重试队列 topic 和死信队列 topic、:
     *     - [origin topic]-dlt
     *     - [origin topic]-retry-3000
     *     - [origin topic]-retry-24000
     *     - [origin topic]-retry-192000
     *
     * @param value  消息主体
     * @param topic  主题
     * @param offset 偏移量
     */
    @RetryableTopic(
        attempts = "3",
        backoff = @Backoff(
            delay = 3_000,
            multiplier = 8.0,
            maxDelay = 98_304_000L
        ),
        fixedDelayTopicStrategy = FixedDelayStrategy.MULTIPLE_TOPICS
    )
    @KafkaListener(topics = RetryableTopicKafkaConsumerGroup.TOPIC)
    public void processMessage(String content) {
        // ...
    }

    /**
     * 死信队列消费者。
     * 超过重试次数的消息,会进入死信队列,死信队列统一由一个消费者进行消费。
     *
     * @param value  消息主体
     * @param topic  主题
     * @param offset 偏移量
     */
    @DltHandler
    public void dltOne(String value,
                       @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
                       @Header(KafkaHeaders.OFFSET) long offset) {
        log.error("死信队列:{},from {} @ {}", value, topic, offset);
        if (topic.startsWith(RetryableTopicKafkaConsumerGroup.TOPIC)) {
            // 根据 topic 处理死信队列
        } else if (topic.startsWith(OtherRetryableTopicKafkaConsumerGroup.TOPIC)) {
            // 根据 topic 处理死信队列
        }
    }

3. 定时轮询器补偿消费失败消息

如果 spring-kafka 重试机制的死信队列依然无法成功消费怎么办呢,还是无法彻底解决消息丢失的问题。

所以需要一套定时轮询器来做最后的保障。

3.1. 设计一个消息记录表,记录每一个发送成功的消息

一个非常简单的示例:

create table if not exists kafka_msg_event
(
    id              varchar(36)  not null
        constraint kafka_msg_event_pk
            primary key,

    topic           varchar(100) not null,
    business_type   varchar(100) not null;
    business_data   varchar,
    title           varchar(200) not null,
    status          varchar(10)  not null,
    failure_message varchar,
    create_time     timestamp(3) default now(),
    update_time     timestamp(3) default now()
);

comment on table libra_process_event is '资源体系';
comment on column libra_process_event.id is '主键';
comment on column libra_process_event.topic is 'mq主题';
comment on column libra_process_event.business_type is '事件业务类型';
comment on column libra_process_event.business_data is '事件业务数据';
comment on column libra_process_event.title is '事件标题';
comment on column libra_process_event.status is '消费状态';
comment on column libra_process_event.failure_message is '错误信息';

3.2. 发送kafka消息前插入一条事件记录

@Component
public class KafkaMsgEventServiceWrap {

    @Autowired
    private KafkaMsgEventService kafkaMsgEventService;
    
    /**
     * 推送 kafka 事件:插入数据库并发送消息
     * 无数据库事务调用有数据库事务的方法
     * 确保在数据库事务提交之后再发送 kafka 消息,不然消费太快会产生脏读错误
     */
    public void push(KafkaMsgEventPushDTO req) throws JsonProcessingException{

        // 调用有数据库事务的方法插入数据库
        KafkaMsgEvent entity = kafkaMsgEventService.push(req);

        // 数据库事务提交之后,发送mq消息
        KafkaMsgEventMessage message = new KafkaMsgEventMessage();
        message.setEventId(entity.getId());
        this.kafkaTemplate.send(event.getTopic().name(), objectMapper.writeValueAsString(message));
    }
}
@Service
public class KafkaMsgEventService {

    /**
     * 插入数据库
     */
    @Transactional(rollbackFor = Exception.class)
    public KafkaMsgEvent push(KafkaMsgEventPushDTO req) throws JsonProcessingException{
        KafkaMsgEvent entity = new KafkaMsgEvent();
        BeanUtils.copyProperties(req, entity);
        // 初始状态:待消费
        entity.setStatus(EventStatus.WAITING);
        // 插入数据库
        getBaseMapper().insert(entity);

        return entity;
    }
}

3.3. 在消费者重试期间捕获收集异常信息

    @RetryableTopic(
        attempts = "3",
        backoff = @Backoff(
            delay = 3_000,
            multiplier = 8.0,
            maxDelay = 98_304_000L
        ),
        fixedDelayTopicStrategy = FixedDelayStrategy.MULTIPLE_TOPICS
    )
    @KafkaListener(topics = RetryableTopicKafkaConsumerGroup.TOPIC)
    public void processMessage(String event, String topic, int partition, long offset) {
        log.info("接收kafka事件:[{}],from topic {} partition {} offset {}", event, topic, partition, offset);
        final KafkaMsgEventMessage obj = objectMapper.readerFor(KafkaMsgEventMessage.class).readValue(event);
        try {
            // 根据事件id,重新查出事件记录携带的业务类型和业务数据,进行业务处理
            kafkaMsgEventService.doEvent(obj.getEventId());
        } catch (Exception e) {
            log.error("接收kafka事件:业务程序执行异常", e);
            kafkaMsgEventService.saveFailureMessage(obj.getEventId(), e);
            throw new RuntimeException(e);
        }
    }
@Service
public class KafkaMsgEventService {

    /**
     * 根据事件id,重新查出事件记录携带的业务类型和业务数据,进行业务处理
     */
    @Transactional(rollbackFor = Exception.class)
    public void doEvent(String eventId){...}

    /**
     * 消费以及重试消费过程中,保存捕获的异常信息
     */
    @Transactional(rollbackFor = Exception.class)
    public void saveFailureMessage(String eventId, Exception exception) {
        if (StrUtil.isBlank(eventId)){
            return;
        }
        StringWriter sw = new StringWriter();
        PrintWriter pw = new PrintWriter(sw);
        exception.printStackTrace(pw);
        String msg = sw.toString();

        getBaseMapper().update(null, new UpdateWrapper<KafkaMsgEvent>().lambda()
            .eq(KafkaMsgEvent::getId, eventId)
            .set(KafkaMsgEvent::getFailureMessage, msg));
    }
}

3.4. 处理和收集死信队列事件

    /**
     * 死信队列消费者。
     * 超过重试次数的消息,会进入死信队列,死信队列统一由一个消费者进行消费。
     *
     * @param value  消息主体
     * @param topic  主题
     * @param offset 偏移量
     */
    @DltHandler
    public void dltOne(String value,
                       @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
                       @Header(KafkaHeaders.OFFSET) long offset) {
        log.error("死信队列:{},from {} @ {}", value, topic, offset);
        // 根据事件id,处理死信队列
        kafkaMsgEventService.dlt(obj.getTaskId())
    }
@Component
public class KafkaMsgEventServiceWrap {

    @Autowired
    private KafkaMsgEventService kafkaMsgEventService;

    /**
     * 统一处理事件类的死信队列信息
     */
    public void dlt(String eventId) {
        // 更新事件状态为消费失败
        final KafkaMsgEvent one = KafkaMsgEventService.dlt(eventId);
        try {
            if (one != null) {
                // 定时轮询器的失败事件缓存
                dltSchedulingCacheHelper.putDltEvent(one);
            }
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

3.4.1. 更新事件状态为消费失败

@Service
public class KafkaMsgEventService {

    /**
     * 处理死信队列事件:更新状态为消费失败
     */
    @Transactional(rollbackFor = Exception.class)
    public KafkaMsgEvent dlt(String eventId) {
        final KafkaMsgEvent one = getBaseMapper().selectById(eventId);
        if (one == null) {
            log.error("事件不存在");
            return null;
        }
        one.setStatus(EventStatus.FAILURE);
        getBaseMapper().updateById(one);
        log.info("更新事件状态:异常:{}", one);
        return one;
    }
}

3.4.2. 缓存失败事件数据

为什么要缓存?为了减少大量的数据库连接啊。

@Component
@Slf4j
public class DltSchedulingCacheHelper {

    private final JedisPool jedisPool;
    private final ObjectMapper objectMapper;

    @Autowired
    public DltSchedulingCacheHelper(JedisPool jedisPool, ObjectMapper objectMapper) {
        this.jedisPool = jedisPool;
        this.objectMapper = objectMapper;
    }

    private Jedis getJedis() {
        return jedisPool.getResource();
    }

    public static String getDltEventKey(String tenantId) {
        return "kafka_dlt_event:tenant_id:" + tenantId;
    }

    public static String getDltTenantsKey() {
        return "kafka_dlt_event:tenant_id_list";
    }

    private static String genDltTenantIdVal(String eventId, String tenantId, String postfix) {
        return tenantId + "-" + eventId + "-" + postfix;
    }

    public void putDltEvent(KafkaMsgEvent event) throws JsonProcessingException {

        // 清理无用的 错误信息字段值,避免redis空间浪费
        event.setFailureMessage(null);

        final String value = objectMapper.writeValueAsString(event);

        // 动态多租户数据源记录(如果需要的话)
        final String tenantId = DynamicDataSourceContextHolder.peek();
        if (StrUtil.isBlank(tenantId)) {
            log.error("租户id未找到:event={}", value);
            return;
        }
        String key = getDltEventKey(tenantId);
        String tenantKey = getDltTenantsKey();
        try (Jedis jedis = getJedis()) {
            // 缓存租户id作为索引
            jedis.hset(tenantKey, genDltTenantIdVal(event.getId(), tenantId, EVENT), "1");
            // 缓存失败事件对象用于定时轮询器补偿重试
            jedis.hset(key, event.getId(), value);

            // 更新过期时间:1h = 60min = 3600sec
            jedis.expire(tenantKey, 3600L);
            jedis.expire(key, 3600L);
        }
    }
}

3.4.3. 短时间内频繁重试的定时轮询器

    /**
     * cron: 间隔 20 秒执行一次
     * 重新处理死信队列事件(查 redis)
     */
    @Scheduled(fixedDelay = 20000)
    public void reHandleKafkaEventDlt() {
        log.info("死信事件(from redis)定时器执行");
        // 从缓存中获取失败事件所属数据源(租户)索引,降低数据库连接压力
        scheduledTaskHelper.doEachTenantIdFromRedis(tenantId -> {
            // 逐个数据源(租户)重试死信事件
            dltReHandleService.reHandleDltEventFromRedis(tenantId);
        });
    }
    /**
     * 重新处理死信队列事件
     */
    @Override
    public void reHandleDltEventFromRedis() {
        // 从缓存中获取失败事件,降低数据库连接压力
        final List<KafkaMsgEvent> dltEventList = dltSchedulingCacheHelper.getDltEvent();

        // 重新处理死信事件
        doRehandleDltEvent(dltEventList);
    }
3.4.3.1. 从缓存中获取失败事件所属数据源(租户)索引,降低数据库连接压力
@Service
@Slf4j
public class ScheduledTaskHelper {

    @Autowired
    private DltSchedulingCacheHelper dltSchedulingCacheHelper;

    public void doEachTenantIdFromRedis(Consumer<String> tenantIdConsumer) {
        // 从缓存中取出所有死信事件的数据源(租户)索引
        List<String> tenantIds = dltSchedulingCacheHelper.getDltTenantIds();
        if (CollUtil.isEmpty(tenantIds)) {
            return;
        }
        log.debug("查得租户数量:{}", tenantIds.size());
        // 遍历数据源(租户)索引
        for (String tenantId: tenantIds) {
            try {
                // 切换数据源(租户)
                DynamicDataSourceContextHolder.push(tenantId);
                // 在指定数据源数据库事务下执行重试程序
                tenantIdConsumer.accept(tenantId);
            } catch (Exception e) {
                log.error("租户级别事件执行失败:租户id=" + tenantId, e);
            } finally {
                // 清空数据源(租户)索引,等待下次切换数据源
                DynamicDataSourceContextHolder.clear();
            }
        }
    }
}
@Component
@Slf4j
public class DltSchedulingCacheHelper {

    private final JedisPool jedisPool;
    private final ObjectMapper objectMapper;

    @Autowired
    public DltSchedulingCacheHelper(JedisPool jedisPool, ObjectMapper objectMapper) {
        this.jedisPool = jedisPool;
        this.objectMapper = objectMapper;
    }

    private Jedis getJedis() {
        return jedisPool.getResource();
    }

    public static String getDltTenantsKey() {
        return "kafka_dlt_event:tenant_id_list";
    }

    public List<String> getDltTenantIds() {
        String tenantKey = RedisKeys.getDltTenantsKey();

        try (Jedis jedis = getJedis()) {
            // key拼接字符串格式:tenantId + "-" + eventId + "-" + postfix
            final Map<String, String> dltTenantIdMap = jedis.hgetAll(tenantKey);
            if (CollUtil.isNotEmpty(dltTenantIdMap)) {
                return dltTenantIdMap.keySet()
                    .stream()
                    .filter(StrUtil::isNotBlank)
                    .map(lr -> lr.split("-")[0])
                    .distinct()
                    .collect(Collectors.toList());
            } else {
                return Collections.emptyList();
            }
        }
    }
}
3.4.3.2. 从缓存中获取失败事件,降低数据库连接压力
@Component
@Slf4j
public class DltSchedulingCacheHelper {

    private final JedisPool jedisPool;
    private final ObjectMapper objectMapper;

    @Autowired
    public DltSchedulingCacheHelper(JedisPool jedisPool, ObjectMapper objectMapper) {
        this.jedisPool = jedisPool;
        this.objectMapper = objectMapper;
    }

    private Jedis getJedis() {
        return jedisPool.getResource();
    }

    public static String getDltEventKey(String tenantId) {
        return "kafka_dlt_event:tenant_id:" + tenantId;
    }

    public List<KafkaMsgEvent> getDltEvent() {
        // 切换数据源(租户)
        final String tenantId = DynamicDataSourceContextHolder.getTenantId();
        if (StrUtil.isBlank(tenantId)) {
            return Collections.emptyList();
        }
        try (Jedis jedis = getJedis()) {
            String key = RedisKeys.getDltEventKey(tenantId);
            // 从缓存中获取失败事件数据
            final Map<String, String> valueMap = jedis.hgetAll(key);
            if (CollUtil.isNotEmpty(valueMap)) {
                return valueMap.values().stream().map(val -> {
                    try {
                        return (KafkaMsgEvent)objectMapper.readerFor(KafkaMsgEvent.class).readValue(val);
                    } catch (JsonProcessingException e) {
                        throw new RuntimeException(e);
                    }
                }).collect(Collectors.toList());
            } else {
                return Collections.emptyList();
            }
        }
    }
}

3.4.3. 深夜定时轮询器:直接查数据库事件表

由于查数据库需要占用数据库连接数资源,特别是多租户数据库场景下,更是对数据库连接数产生莫大的挑战,所以不能频繁的轮询重试。

而 redis 缓存有过期时间,并不能确保所有失败消息都会成功重试,所以还是需要一个兜底的定时轮询器来确保数据库里所有失败事件或者长时间停留在待消费的事件进行补偿重试。

设定一个错开其他业务运行高峰时间段(例如凌晨时间)查询数据库内全部失败事件,进行逐个重试。

    /**
     * cron: 每天凌晨5点半执行一次
     * 重新处理死信队列事件(查数据库)
     */
    @Scheduled(cron = "0 30 5 * * ?")
    public void reHandleKafkaEventDltFromDatabase() {
        log.info("死信队列(from 数据库)定时器执行");
        // 遍历所有数据源(租户)索引(具体实现各异,省略。。。)
        scheduledTaskHelper.doEachTenantId(tenantId -> {
            // 查询数据库事件表,重试(具体实现各异,省略。。。)
            dltReHandleService.reHandleDltEvent(tenantId);
        });
    }

3.5. 定时器非预期间隔时间执行问题

请参阅:批处理定时执行任务_记一次Spring定时任务非预期执行的解决与原理

4. 数据一致性

为了避免同一个事件被重复消费,需要在消费者端增加阻塞锁机制,确保同一个事件同一时间只会被一个消费者消费。

分布式锁,也可以解决其他具体业务的数据一致性。


    public static String genProcessEventKey(String eventId) {
        Assert.isTrue(StringUtils.isNotBlank(eventId), "eventId 不能为空");
        return "process-event-lock:" + DynamicDataSourceContextHolder.peek() + ":" + eventId;
    }
   
    @RetryableTopic(
        attempts = "3",
        backoff = @Backoff(
            delay = 3_000,
            multiplier = 8.0,
            maxDelay = 98_304_000L
        ),
        fixedDelayTopicStrategy = FixedDelayStrategy.MULTIPLE_TOPICS
    )
    @KafkaListener(topics = RetryableTopicKafkaConsumerGroup.TOPIC)
    public void processMessage(String event, String topic, int partition, long offset) {
        log.info("接收kafka事件:[{}],from topic {} partition {} offset {}", event, topic, partition, offset);
        final KafkaMsgEventMessage obj = objectMapper.readerFor(KafkaMsgEventMessage.class).readValue(event);
        // 在分布式锁下执行
        final String eventLockKey = genProcessEventKey(event1.getId());
        redisLockHelper.doWithLock(eventLockKey, () -> {
            try {
                // 根据事件id,重新查出事件记录携带的业务类型和业务数据,进行业务处理
                kafkaMsgEventService.doEvent(obj.getEventId());
            } catch (Exception e) {
                log.error("接收kafka事件:业务程序执行异常", e);
                kafkaMsgEventService.saveFailureMessage(obj.getEventId(), e);
                throw new RuntimeException(e);
            }
        });
    }

4.1. 分布式阻塞锁助手类实现

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;

import java.util.Collections;
import java.util.UUID;

/**
 * redis 分布式锁助手
 */
@Component
public class RedisLockHelper {

    // 锁成功返回参数
    private static final String LOCK_SUCCESS = "OK";
    /**
     * 仅在key不存在时存入
     * `NX`                           -- Only set the key if it does not already exist.
     * `XX`                           -- Only set the key if it already exist.
     */
    private static final String SET_IF_NOT_EXIST = "NX";
    /**
     * 设置过期时间(单位秒)
     * `EX`   seconds                 -- Set the specified expire time, in seconds.
     * `PX`   milliseconds            -- Set the specified expire time, in milliseconds.
     */
    private static final String SET_WITH_EXPIRE_SECONDS = "EX";
    /**
     * 释放锁成功返回参数
     */
    private static final Long RELEASE_SUCCESS = 1L;
    /**
     * 过期时间(单位秒),默认1分钟
     */
    private static final long EXPIRE_SECONDS = 60L;

    @Autowired
    private JedisPool jedisPool;

    public Jedis getJedis() {
        return jedisPool.getResource();
    }

    public long getExpireSeconds() {
        return EXPIRE_SECONDS;
    }

    /**
     * 申请锁
     *
     * @param key       键
     * @param requestId 请求标识
     * @return 是否成功申请
     */
    public boolean lock(String key, String requestId) {
        Assert.isTrue(StringUtils.isNotBlank(key), "key不能为空");
        Assert.isTrue(StringUtils.isNotBlank(requestId), "请求标识不能为空");
        try (Jedis jedis = getJedis()) {
            String lockRes = jedis.set(key, "" + requestId, SetParams.setParams().nx().ex(EXPIRE_SECONDS));
            return LOCK_SUCCESS.equals(lockRes);
        }
    }

    /**
     * 释放分布式锁
     *
     * @param key       锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public boolean releaseLock(String key, String requestId) {
        Assert.isTrue(StringUtils.isNotBlank(key), "key不能为空");
        Assert.isTrue(StringUtils.isNotBlank(requestId), "请求标识不能为空");
        try (Jedis jedis = getJedis()) {
            // 脚本,判断并删除
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            // 执行脚本
            Object result = jedis.eval(script, Collections.singletonList(key), Collections.singletonList(requestId));
            return RELEASE_SUCCESS.equals(result);
        }
    }

    /**
     * 等待锁释放
     * <p>
     * 次方法并不一定适合所有场景,使用时应需考虑以下几种适用情况
     * 1. 业务层面上,并发的请求只需要确认其他线程执行过就行,而不需要重复执行同步代码,那么可以使用本方法
     *
     * @param key 锁key
     * @return 锁是否释放:true说明已经释放了,false说明还没有释放而且已经超时了
     */
    public boolean waitReleaseLock(String key) {
        log.info("轮询等待锁释放");
        Assert.isTrue(StringUtils.isNotBlank(key), "key不能为空");
        try (Jedis jedis = getJedis()) {
            final long startTime = System.currentTimeMillis();
            Boolean exists = jedis.exists(key);
            long timeConsuming = 0;
            // 在超时时间内每0.5秒轮询
            while (exists && timeConsuming < EXPIRE_SECONDS * 1000) {
                Thread.sleep(500);
                exists = jedis.exists(key);
                timeConsuming = System.currentTimeMillis() - startTime;
            }
            return !exists;
        } catch (InterruptedException e) {
            e.printStackTrace();
            // 如果线程报错,则直接当超时处理,返回false
            return false;
        }
    }

    public void doWithLock(String eventLockKey, Runnable runnable) {
        UUID uuid = UUID.randomUUID();
        try {
            // 申请锁
            log.info("流程处理锁:{}:尝试申请", eventLockKey);
            final boolean isLock = lock(eventLockKey, uuid.toString());
            if (!isLock) {
                log.warn("流程处理锁:{}:申请失败", eventLockKey);
                // 等待锁释放
                boolean isRelease = waitReleaseLock(eventLockKey);
                if (isRelease) {
                    log.warn("流程处理锁:{}:等待锁成功,重试", eventLockKey);
                    doWithLock(eventLockKey, runnable);
                } else {
                    log.error("流程处理锁:{}:等待锁超时", eventLockKey);
                    throw new RuntimeException("等待流程处理锁超时");
                }
            } else {
                log.info("流程处理锁:{}:申请成功", eventLockKey);
                // 业务处理
                runnable.run();
            }
        } catch (Exception e) {
            log.error("流程处理锁:{}:业务执行异常", eventLockKey, e);
            throw e;
        } finally {
            log.info("流程处理锁:{}:释放", eventLockKey);
            releaseLock(eventLockKey, uuid.toString());
        }
    }
}

5. 业务幂等性重复执行

5.1. 事件状态判断幂等

if (ConsumptionStatus.ALREADY.equals(event.getStatus())) {
    log.warn("该事件已经消费过。。。");
    return Collections.emptyList();
}

5.2. 如果业务实现难实现幂等

可以利用 redis 缓存业务成功状态,当重复执行时,业务状态为已成功执行,则跳过。

// 根据业务id,生成业务成功状态key
String redisKey = generateSuccessKey(business.getId())
// 幂等性执行
idempotentLockHelper.doWithIdempotent(redisKey, () -> {
    // 执行业务程序
});

5.2.1. 幂等性执行助手

import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;

import java.util.Collections;
import java.util.UUID;

/**
 * 幂等锁
 */
@Slf4j
@Component
public class IdempotentLockHelper {

    /**
     * 锁成功返回参数
     */
    private static final String LOCK_SUCCESS = "OK";
    /**
     * 释放锁成功返回参数
     */
    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 过期时间(单位秒),默认一天半(36小时),给予 mq 重试以及死信队列定时轮询重试充分的时间
     */
    private static final int EXPIRE_SECONDS = 36 * 60 * 60;

    @Autowired
    private JedisPool jedisPool;

    public Jedis getJedis() {
        return jedisPool.getResource();
    }

    /**
     * 在幂等性保证中执行
     *
     * @param key      幂等性key
     * @param runnable 幂等性程序
     */
    public void doWithIdempotent(String key, Runnable runnable) {
        doWithIdempotent(key, EXPIRE_SECONDS, runnable);
    }

    /**
     * 在幂等性保证中执行
     *
     * @param key           幂等性key
     * @param expireSeconds 幂等性保留时间
     * @param runnable      幂等性程序
     */
    public void doWithIdempotent(String key, int expireSeconds, Runnable runnable) {
        Assert.isTrue(StrUtil.isNotBlank(key), "key不能为空");
        Assert.notNull(expireSeconds, "请求标识不能为空");
        final String uuid = UUID.randomUUID().toString();
        try (Jedis jedis = getJedis()) {
            SetParams setParams = new SetParams();
            setParams.nx().ex(expireSeconds);
            String lockRes = jedis.set(key, uuid, setParams);
            final boolean hasGot = LOCK_SUCCESS.equals(lockRes);
            if (hasGot) {
                // 如果申请锁成功,说明同一时间没有其他线程在执行同一个任务
                try {
                    final Boolean exists = jedis.exists(key + ":idempotent_success_lock");
                    if (exists) {
                        // 结果标识存在,说明已经执行成功过,跳过
                        return;
                    }

                    // 业务执行
                    runnable.run();

                    // 执行成功后,标识已完成,幂等锁保留 36 小时,36 小时内重复执行时判断幂等性
                    jedis.set(key + ":idempotent_success_lock", uuid, setParams);
                } catch (Exception e) {
                    throw e;
                } finally {
                    // 释放互斥锁
                    releaseLock(key, uuid);
                }
            } else {
                // 如果申请锁失败,说明有其他线程在执行着同一个任务
                throw new RunTimeException("有其他线程在执行相同任务,请稍后重试");
            }
        }
    }

    /**
     * 释放分布式锁
     *
     * @param key       锁
     * @param requestId 请求标识
     */
    private void releaseLock(String key, String requestId) {
        Assert.isTrue(StrUtil.isNotBlank(key), "key不能为空");
        Assert.isTrue(StrUtil.isNotBlank(requestId), "请求标识不能为空");
        try (Jedis jedis = getJedis()) {
            // 脚本,判断并删除
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            // 执行脚本
            Object result = jedis.eval(script, Collections.singletonList(key), Collections.singletonList(requestId));
            final boolean success = RELEASE_SUCCESS.equals(result);
            if (success) {
                log.debug("释放互斥锁成功:{}:{}", key, requestId);
            } else {
                log.warn("释放互斥锁失败:{}:{}", key, requestId);
            }
        }
    }
}

6. topic资源有限,放弃spring-kafka重试机制

  1. 腾讯云购买kafka资源按topic数量计费,要控制成本,所以topic资源有限,而由于 spring-kafka 重试机制需要额外的 topic 来作为重试队列以及死信队列,需要采用更节省topic资源的实现方式。
  2. 原来所有topic都没有分区,默认只有一个 0 号分区,且消费者监听器实现没有声明分区,属于独立消费者模式,随着业务增加,不同业务在同一个队列里相互阻塞,可预见的性能瓶颈需要解决。

那怎么办呢?

6.1. 删除 @RetryableTopic 注解

取消 spring-kafka 的重试机制,节省 topic 资源。

6.2. 独立消费者改为消费者组分区消费模式

@KafkaListener 注解指定分区,即启用消费者组模式,

警告:
原来的独立消费者模式和消费者组分区消费模式不可以用相同 groupId 共存,需要将原来的消费者也改造为消费者组模式

    /**
     * 事件消费组(0分区)监听器
     *
     * @param event     事件消息体
     * @param topic     主题
     * @param partition 主题分区
     * @param offset    偏移量
     */
    @KafkaListener(topicPartitions = {
        @TopicPartition(topic = KafkaTopic.RE_BALANCE_EVENT_TOPIC, partitions = TopicPartitionId.PARTITION_ID_0)
    })
    public void processEventConsumer0(String event,
                                      @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
                                      @Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
                                      @Header(KafkaHeaders.OFFSET) long offset) {
        doConsume(event, topic, partition, offset);
    }

    /**
     * 事件消费组(1分区)监听器
     *
     * @param event     事件消息体
     * @param topic     主题
     * @param partition 主题分区
     * @param offset    偏移量
     */
    @KafkaListener(topicPartitions = {
        @TopicPartition(topic = KafkaTopic.RE_BALANCE_EVENT_TOPIC, partitions = TopicPartitionId.PARTITION_ID_1)
    })
    public void processEventConsumer1(String event,
                                      @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
                                      @Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
                                      @Header(KafkaHeaders.OFFSET) long offset) {
        doConsume(event, topic, partition, offset);
    }

    /**
     * 执行事件主题消费
     *
     * @param event     事件消息体
     * @param topic     主题
     * @param partition 主题分区
     * @param offset    偏移量
     */
    private void doConsume(String event, String topic, int partition, long offset) {
        log.info("接收流程事件kafka消息:[{}],from topic {} partition {} offset {}", event, topic, partition, offset);
        final KafkaMsgEventMessage obj = objectMapper.readerFor(KafkaMsgEventMessage.class).readValue(event);
        try {
            
            // 在分布式锁下执行
            final String eventLockKey = genProcessEventKey(event1.getId());
            redisLockHelper.doWithLock(eventLockKey, () -> {
                try {
                    // 根据事件id,重新查出事件记录携带的业务类型和业务数据,进行业务处理
                    kafkaMsgEventService.doEvent(obj.getEventId());
                } catch (Exception e) {
                    log.error("接收kafka事件:业务程序执行异常", e);
                    kafkaMsgEventService.saveFailureMessage(obj.getEventId(), e);
                    throw new RuntimeException(e);
                }
            });
        } catch (Exception e) {
            log.error("接收流程事件kafka消息:[{}],from {} @ {}", event, topic, offset, e);
            // 取消 retry 后,模拟死信队列消费,如果启用 retry,需要删除这行代码
            dltKafkaConsumerListener.dltOne(event, topic, offset);
            throw new RuntimeException(e);
        }
    }

6.2. 事件处理器业务策略识别和委派

6.2.1. 策略委派接口

import org.springframework.core.PriorityOrdered;

/**
 * 执行器策略委派接口
 */
public interface EventExecutor extends PriorityOrdered {

    /**
     * 判断执行器是否支持执行
     *
     * @return 是否支持
     */
    boolean isSupport(KafkaMsgEvent event);

    /**
     * 执行
     *
     * @param event 任务
     */
    void executor(KafkaMsgEvent event);
}

6.2.2. 策略委派管理者


import cn.hutool.core.collection.CollUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.Ordered;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

import java.util.Comparator;
import java.util.List;
import java.util.Map;

/**
 * 策略委派管理者
 */
@Component
@Slf4j
public class EventExecutorManager implements EventExecutor, InitializingBean, ApplicationContextAware {

    private transient ApplicationContext applicationContext;
    private final List<EventExecutor> eventExecutors;

    public EventExecutorManager() {
        this.eventExecutors = CollUtil.newArrayList();
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        final Map<String, EventExecutor> beansMap = applicationContext.getBeansOfType(EventExecutor.class);
        if (CollUtil.isNotEmpty(beansMap)) {
            beansMap.forEach((name, executor) -> eventExecutors.add(executor));
        }
    }

    @Override
    public void setApplicationContext(@Nullable ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @Override
    public void executor(KafkaMsgEvent event) {
        eventExecutors.stream()
            // 过滤
            .filter(exe -> exe.isSupport(event))
            // 排序
            .sorted(Comparator.comparingInt(Ordered::getOrder))
            // 执行
            .forEach(exe -> exe.executor(event));
    }

    @Override
    public boolean isSupport(KafkaMsgEvent event) {
        // 永远不作为实际策略执行
        return false;
    }

    @Override
    public int getOrder() {
        return 0;
    }
}


6.2.3. 自定义业务类型的事件执行策略委派实例

import com.alibaba.fastjson.JSON;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * 发短信通知事件执行器实例
 */
@Component
@Slf4j
public class SmsSenderEventExecutor implements EventExecutor {

    @Getter
    private final int order = 100;

    @Override
    public boolean isSupport(KafkaMsgEvent event) {
        // 自定义实现匹配事件支持的执行器
        return event != null && "发短信通知业务类型".equals(event.getBusinessType());
    }

    @Override
    public void executor(KafkaMsgEvent event) {
        log.info("任务执行器:节点开始执行器:执行:event={}", event);
        if (event == null) {
            log.error("任务未找到,跳过...");
            return;
        }
        // .....业务实现省略
    }
}

7. kafka事务

7.1. 背景

在基本应用示例中,默认没有开启 KafkaTransactionManager。在其他数据库事务管理器事务内发送 kafka 消息,会出现数据库数据 “脏读” 问题:

生产者数据库事务还未提交,消费者提前读取数据库数据。

而将 kafka 发送消息的时间,后移到数据库事务提交之后,又可能发生 kafka 发送失败却来不及回滚数据库事务,从而造成数据错乱问题。

7.2. 启用 kafka 事务管理器

application.yml 等配置文件中增加 spring.kafka.producer.transaction-id-prefix 配置,例如:

spring:
  kafka:
    producer:
      transaction-id-prefix: tx-

提示:
KafkaTransactionManager Bean 注册代码位置:org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration#kafkaTransactionManager

	@Bean
	@ConditionalOnProperty(name = "spring.kafka.producer.transaction-id-prefix")
	@ConditionalOnMissingBean
	public KafkaTransactionManager<?, ?> kafkaTransactionManager(ProducerFactory<?, ?> producerFactory) {
		return new KafkaTransactionManager<>(producerFactory);
	}

@Transactional 注解的方法内发送 kafka 消息,并期望第 2 个消息发送抛出异常时,消费者不消费第 1 个消息。

    @GetMapping("/tx-one")
    @Transactional(rollbackFor = Exception.class)
    public String sendTransactionOne(@RequestParam("message") String message) throws InterruptedException {
        // 在单个事务中发送 2 条消息
        for (int i = 0; i < 2; i++) {
            // 模拟第 2 个消息时抛异常
            if (i > 0){
                throw new RuntimeException("模拟抛出异常");
            }
            log.info("发送消息:{}", message + "-" + i);
            final ListenableFuture<SendResult<String, String>> result = this.template.send(
                TransactionTopicKafkaConsumerGroup.TOPIC, message + "-" + i);
            result.addCallback(transactionOneCallback);
            Thread.sleep(1000L);
        }
        return "send transaction-one";
    }

在消费者接受消息:

    @KafkaListener(topicPartitions = {
        @TopicPartition(topic = TransactionTopicKafkaConsumerGroup.TOPIC, partitions = TransactionTopicKafkaConsumerGroup.PARTITION_ID_1)
    })
    @Transactional(rollbackFor = Exception.class)
    public void listenZero(String value,
                           @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
                           @Header(KafkaHeaders.OFFSET) long offset) {
        log.info("listenZero:接收kafka消息:[{}],from {} @ {}", value, topic, offset);
    }

7.3. 解决默认@Transactional注解抛NoUniqueBeanDefinitionException异常问题

在上一章节启用 KafakTransactionManager 之后,如果同时存在(大概率会存在)其他数据库事务管理器,会在没有指定 transactionManager 属性值的 @Transactional 注解方法被调用的时候抛出异常:

org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'org.springframework.transaction.TransactionManager' available: expected single matching bean but found 2: transactionManager,kafkaTransactionManager
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveNamedBean(DefaultListableBeanFactory.java:1273) ~[spring-beans-5.3.23.jar:5.3.23]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveBean(DefaultListableBeanFactory.java:494) ~[spring-beans-5.3.23.jar:5.3.23]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:349) ~[spring-beans-5.3.23.jar:5.3.23]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:342) ~[spring-beans-5.3.23.jar:5.3.23]
	at org.springframework.transaction.interceptor.TransactionAspectSupport.determineTransactionManager(TransactionAspectSupport.java:503) ~[spring-tx-5.3.23.jar:5.3.23]
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:342) ~[spring-tx-5.3.23.jar:5.3.23]
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-5.3.23.jar:5.3.23]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.23.jar:5.3.23]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763) ~[spring-aop-5.3.23.jar:5.3.23]
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708) ~[spring-aop-5.3.23.jar:5.3.23]
  ......

7.3.1. 标记一个数据库事务管理器实例为@PrimaryBean

如果不确定自己启用了哪些事务管理器具体类,可以加上下面的临时配置类,启动项目的时候断点观察:

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.Nullable;
import org.springframework.transaction.TransactionManager;

import java.util.Map;

@Configuration
@Slf4j
public class BeanObserveConfig implements InitializingBean, ApplicationContextAware  {

    private transient ApplicationContext applicationContext;

    @Override
    public void afterPropertiesSet() throws Exception {
        final Map<String, TransactionManager> transactionManagerMap = applicationContext.getBeansOfType(TransactionManager.class);
        final int size = transactionManagerMap.size();
        log.info("有几个事务管理器={}", size);
    }

    @Override
    public void setApplicationContext(@Nullable ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

而假设项目是注册的数据库事务管理器实例为: org.springframework.jdbc.datasource.DataSourceTransactionManager

通过断点构造方法,调试得知其 Bean 注册代码位置为:

org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration.DataSourceTransactionManagerConfiguration#transactionManager

    @Bean
    @ConditionalOnMissingBean({PlatformTransactionManager.class})
    DataSourceTransactionManager transactionManager(DataSource dataSource, ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        transactionManagerCustomizers.ifAvailable((customizers) -> {
            customizers.customize(transactionManager);
        });
        return transactionManager;
    }

提示:

而假设项目依赖了 spring-boot-starter-data-jpa,系统启动初始化时自动识别并注册 JpaTransactionManager 实例作为数据库事务管理器

    <dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>

JpaTransactionManager Bean 注册代码位置:org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration#transactionManager

	@Bean
	@ConditionalOnMissingBean(TransactionManager.class)
	public PlatformTransactionManager transactionManager(
			ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
		JpaTransactionManager transactionManager = new JpaTransactionManager();
		transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager));
		return transactionManager;
	}

确定数据库事务类型之后,根据需要,自定义声明一个 Bean 并加上 @Primary 注解:

import javax.sql.DataSource;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

/**
 * 事务管理器配置类
 */
@Configuration
@ConditionalOnClass({JdbcTemplate.class, PlatformTransactionManager.class})
public class TransactionManagerConfig {

    @Bean
    @Primary
    DataSourceTransactionManager transactionManager(DataSource dataSource, ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        transactionManagerCustomizers.ifAvailable((customizers) -> {
            customizers.customize(transactionManager);
        });
        return transactionManager;
    }
}

7.4. 消费者端设置事务隔离级别

消费者端默认的事务隔离级别是:org.springframework.boot.autoconfigure.kafka.KafkaProperties.Consumer#isolationLevel = IsolationLevel.READ_UNCOMMITTED

说明消费者可以读到未提交的消息,这肯定不行,会导致脏读。生产者发送第一条消息后发生异常触发回滚,消费者已经提前消费了。

发生脏读的日志:

2022-12-20 17:25:09.514 TRACE 8164 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.leekitman.pangea.evolution.kafka.controller.SenderController.sendTransactionOne]
2022-12-20 17:25:09.514  INFO 8164 --- [nio-8080-exec-1] c.l.p.e.k.controller.SenderController    : 发送消息:success-success-0
2022-12-20 17:25:09.517 DEBUG 8164 --- [ntainer#8-0-C-1] o.s.k.t.KafkaTransactionManager          : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2022-12-20 17:25:09.517 DEBUG 8164 --- [ntainer#8-0-C-1] o.s.k.t.KafkaTransactionManager          : Created Kafka transaction on producer [CloseSafeProducer [delegate=org.apache.kafka.clients.producer.KafkaProducer@4f2afb4b]]
2022-12-20 17:25:09.518 TRACE 8164 --- [ntainer#8-0-C-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.leekitman.pangea.evolution.kafka.consumer.TransactionTopicKafkaConsumerListener.listenOne]
2022-12-20 17:25:09.518  INFO 8164 --- [ntainer#8-0-C-1] .c.TransactionTopicKafkaConsumerListener : listenOne:接收kafka消息:[success-success-0],from TRANSACTION-ONE-TOPIC-1 @ 1@ 28
2022-12-20 17:25:09.518 TRACE 8164 --- [ntainer#8-0-C-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.leekitman.pangea.evolution.kafka.consumer.TransactionTopicKafkaConsumerListener.listenOne]
2022-12-20 17:25:09.521 DEBUG 8164 --- [ntainer#8-0-C-1] o.s.k.t.KafkaTransactionManager          : Initiating transaction commit
2022-12-20 17:25:10.516 TRACE 8164 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.leekitman.pangea.evolution.kafka.controller.SenderController.sendTransactionOne] after exception: java.lang.RuntimeException: 模拟抛出异常
2022-12-20 17:25:10.516  INFO 8164 --- [nio-8080-exec-1] o.a.k.clients.producer.KafkaProducer     : [Producer clientId=producer-tx-kafka-0, transactionalId=tx-kafka-0] Aborting incomplete transaction
2022-12-20 17:25:10.520 ERROR 8164 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 模拟抛出异常] with root cause

所以我们要将消费者端的事务隔离级别更改为:IsolationLevel.READ_COMMITTED,这个事务隔离级别指的是读取已提交信息。

spring:
  kafka:
    consumer:
      isolation-level: READ_COMMITTED

符合期望的日志:

2022-12-20 17:20:03.049 TRACE 10832 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.leekitman.pangea.evolution.kafka.controller.SenderController.sendTransactionOne]
2022-12-20 17:20:03.049  INFO 10832 --- [nio-8080-exec-1] c.l.p.e.k.controller.SenderController    : 发送消息:success-success-0
2022-12-20 17:20:04.050 TRACE 10832 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.leekitman.pangea.evolution.kafka.controller.SenderController.sendTransactionOne] after exception: java.lang.RuntimeException: 模拟抛出异常
2022-12-20 17:20:04.050  INFO 10832 --- [nio-8080-exec-1] o.a.k.clients.producer.KafkaProducer     : [Producer clientId=producer-tx-kafka-0, transactionalId=tx-kafka-0] Aborting incomplete transaction
2022-12-20 17:20:04.056 ERROR 10832 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 模拟抛出异常] with root cause

7.5. 问题分析:多实例事务id相同导致错误

7.5.1. 错误复现

模拟生产者发布消息

@GetMapping("/tx-two")
@Transactional(rollbackFor = Exception.class)
public String sendTransactionTwo(@RequestParam("message") String message) throws InterruptedException {
    log.info("发送消息:{}", message);
    final ListenableFuture<SendResult<String, String>> result = this.template.send(
        TransactionTopicKafkaConsumerGroup.TOPIC, message);
    result.addCallback(transactionOneCallback);
    return "send transaction-one doing...";
}

事务回调

import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Component;
import org.springframework.util.concurrent.ListenableFutureCallback;

/**
 * @author Leekitman Li
 * @version 2.0
 * @date 2022/12/20 15:16
 */
@Component
@Slf4j
public class TransactionOneCallback implements ListenableFutureCallback<SendResult<String, String>> {

    @Override
    public void onFailure(Throwable ex) {
        log.error("事务one发送kafka消息异常回调", ex);
    }

    @Override
    public void onSuccess(SendResult<String, String> result) {
        log.info("事务one发送kafka消息成功回调: {}", result);
    }
}

kafka配置:

spring:
  kafka:
    bootstrap-servers: local-postgresql:9092
    producer:
      # 两个实例事务id前缀保持一致
      transaction-id-prefix: tx-kafka-
      value-serializer: org.springframework.kafka.support.serializer.ToStringSerializer
    consumer:
      group-id: spring-kafka-evo-consumer-004
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      isolation-level: READ_COMMITTED

启动两个实例,此文中将两个实例分别命名为 “实例1” 和 “实例2”。

  1. 实例1,首先发布kafka消息,发布成功

    日志中出现 ProducerId set to 12000 with epoch 0

  2. 实例2,接着发布kafka消息,也发布成功

    日志中出现 ProducerId set to 12000 with epoch 1

  3. 实例1,继续发布kafka消息,发布报错

    日志出现错误:[Producer clientId=producer-tx-kafka-0, transactionalId=tx-kafka-0] Transiting to fatal error state due to org.apache.kafka.common.errors.ProducerFencedException: There is a newer producer with the same transactionalId which fences the current one.

  4. 实例2,最后发布kafka消息,依然发布成功

对比第 1 步和第 2 步的日志,可以看到下面的细节:

kafka多实例事务id相同问题-前后两个实例发布消息日志对比

问题总结:

kafka服务在第 2 步的时候,根据最新 epoch 判定实例2是新启用的生产者,而原先发布过消息的实例1生产者是僵尸实例,禁止实例1生产者发布消息。

7.5.2. 解决方案

解决的主要思路就是让不同服务实例,给 spring.kafka.producer.transaction-id-prefix 配置不一样的值,这样就可以保证不同实例之间拥有独立的事务id。

最简单的莫过于直接在 yaml 配置文件中拼接 ${random.uuid} 来自动生成随机事务id前缀。

spring:
  kafka:
    producer:
      transaction-id-prefix: tx-${random.uuid}-

注意:
这只是最图方便的解决方案,并不代表能符合所有应用场景。

8. 跨服务api延迟调用:Spring框架的事务绑定型事件监听器

在事务中发起的 feign api 请求,会出现事务回滚导致的脏数据,那么就要想办法让 feign api 延迟到业务方法执行完即将提交事务之前那一刻再进行 feign api 请求。

但是手动迁移 feign api 请求代码会非常的麻烦,不优雅,可读性差。

有没有办法让 feign api 请求保持在容易理解的代码位置,但是却让它延迟执行呢?

Spring 框架有一个事务绑定型事件监听器的实现。

警告:
这个方法并不能完美解决 api 调用脏读问题,如果业务过于复杂和庞大,commit操作本身也会需要一定的耗时。
只能说在找到更好的方法之前,这是一个大部分场景下可行的办法。

@Service
@Slf4j
public class BusinessServiceImpl implements ApplicationEventPublisherAware {


    /**
      * 事件发布器
      */
    private ApplicationEventPublisher publisher;

    @Override
    public void setApplicationEventPublisher(@NonNull ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }


    public void businessMethod(String solutionId, String entityId, List<String> instanceId) {

        // 省略其他业务代码......

        // 发布 Spring 应用事件
        publisher.publishEvent(new BusinessProcessEventEvent("业务事件"));

        // 省略其他业务代码......
    }
}
/**
  * 将 feign api 调用延迟到业务方法最后事务提交前执行
  */
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT, fallbackExecution = true, value = BusinessProcessEventEvent.class)
public void processNodeJobConversionEvent(BusinessProcessEventEvent event) throws JsonProcessingException {
    // 推送流程事件
    log.info("触发事件:流程新节点开启:{}", event);
    // 调用跨服务业务方法
    remoteProcessFeignApi.doSomething(event);
}
  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值