Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

kafka basic

Tung Bui Thanh (TungDaDev)29 mins read

Kafka basic.webp

Nếu microservices là các thành phố thì Kafka là đường cao tốc nối chúng lại với nhau.

Tôi làm với Kafka cũng nhiều năm rồi, từ hồi tự dựng cluster 3 broker cho một startup nhỏ, tới lúc vận hành hệ thống xử lý hàng triệu events/giây trên production. Bài này gom lại những thứ tôi ước gì mình được biết ngay từ ngày đầu.

# kafka là gì?

Apache Kafka là một distributed event streaming platform. Nghe thì to tát, chứ bản chất nó là hệ thống cho phép bạn:

  1. Publish (ghi) và Subscribe (đọc) các luồng dữ liệu (events/messages)
  2. Store dữ liệu đó bền vững, có hỏng một máy cũng không mất
  3. Process dữ liệu ngay khi nó tới, hoặc đọc lại dữ liệu cũ để xử lý sau

Cứ hình dung Kafka là một commit log phân tán: cái gì đã ghi vào thì không sửa được (immutable), có thứ tự, và đọc lại lúc nào cũng được.

# why not RabbitMQ or Redis Pub/Sub?

Câu này tôi bị hỏi nhiều nhất. Trả lời ngắn gọn:

Tiêu chíKafkaRabbitMQRedis Pub/Sub
ThroughputHàng triệu msg/sHàng chục nghìn msg/sHàng trăm nghìn msg/s
Message RetentionCó (configurable)Queue thường: không (consume xong là mất)Không
Consumer GroupsNative supportLimitedKhông
Replay MessagesCóQueue thường: khôngKhông
Ordering GuaranteePer-partitionPer-queueKhông
Use case chínhEvent streaming, Log aggregationTask queue, RPCReal-time notifications

Rule of thumb: cần một task queue kiểu fire-and-forget thì chọn RabbitMQ. Cần một event log cho nhiều service cùng đọc thì chọn Kafka.

Bảng trên so với queue truyền thống của RabbitMQ và Redis Pub/Sub. Ranh giới giờ đã mờ đi khá nhiều: RabbitMQ có thêm Streams (từ 3.9) lưu được message và đọc lại theo offset, Redis có Streams với consumer group. Ngược lại, Kafka 4.2 đưa share group (KIP-932, "Queues for Kafka") lên GA: nhiều consumer cùng xử lý một partition, ack từng message như task queue. Con số throughput trong bảng cũng chỉ là bậc độ lớn để hình dung, tuỳ phần cứng và cấu hình mà chênh nhau nhiều.

# kiến trúc kafka

# broker: trái tim của Kafka

Broker là một Kafka server instance. Một Kafka cluster thường có nhiều broker (production thì tối thiểu 3). Mỗi broker lo mấy việc:

  • Nhận messages từ producers
  • Lưu trữ messages trên disk
  • Phục vụ messages cho consumers
  • Tham gia vào quá trình replication
kafka-cluster

# topic: kênh dữ liệu

Topic là một logical channel để gom messages lại theo chủ đề. Cứ nghĩ nó giống một bảng trong database, hay một folder trong file system.

Topic: "order-events"
├── Partition 0: [msg0, msg1, msg4, msg7, ...]
├── Partition 1: [msg2, msg3, msg5, msg8, ...]
└── Partition 2: [msg6, msg9, msg10, ...]

Naming convention tôi hay dùng:

  • <domain>.<entity>.<action> → payment.order.created
  • Hoặc đơn giản: <entity>-events → order-events

# partition: chỗ quyết định Kafka scale được tới đâu

Nếu chỉ nhớ một khái niệm thì nhớ cái này. Mỗi topic được chia thành nhiều partition, và Kafka scale ngang (horizontally) chính nhờ chia partition.

Sao partition lại quan trọng?

  • Parallelism: mỗi partition có thể giao cho một consumer riêng đọc
  • Ordering: messages trong cùng một partition luôn giữ đúng thứ tự (FIFO)
  • Distribution: các partition được rải đều lên các broker

Bao nhiêu partition là đủ?

Công thức tôi hay dùng:

num_partitions = max(T/P, T/C)

Trong đó:
- T = target throughput (msg/s)
- P = throughput mỗi producer có thể đạt trên 1 partition
- C = throughput mỗi consumer có thể đạt trên 1 partition

Thực tế thì đa số use case cứ bắt đầu với 6-12 partitions là ổn. Cần thì tăng lên, nhưng nhớ là không giảm xuống được. Tăng cũng có giá: công thức hash(key) % num_partitions đổi mẫu số, nên sau khi tăng thì một key có thể rơi sang partition khác, thứ tự theo key bị xáo trong lúc chuyển tiếp.

# offset: cái bookmark của consumer

Mỗi message trong một partition có một offset, là số nguyên tăng dần bắt đầu từ 0. Consumer nhìn offset để biết mình đã đọc tới đâu.

kafka-offset

Offset đã xử lý tới đâu là do consumer quyết định và commit lên (Kafka lưu nó trong topic nội bộ __consumer_offsets), chứ broker không xoá message khi có người đọc. Nhờ vậy:

  • Muốn replay messages thì reset offset về đầu (--to-earliest) là xong. Lưu ý offset đầu chưa chắc là 0, vì message cũ hơn retention đã bị xoá
  • Nhiều consumer group đọc cùng một topic, mỗi group đứng ở một vị trí khác nhau, không ai ảnh hưởng ai
  • Consumer có crash thì lúc chạy lại nó đọc tiếp từ offset đã commit gần nhất

# consumer group: scale phía consumer

Đây là tính năng ăn tiền của Kafka. Consumer group là một nhóm consumer cùng đọc một topic, trong đó mỗi partition chỉ được giao cho đúng 1 consumer trong group.

kafka-scaling-consumers

Quy tắc:

  • Số consumer trong group ≤ số partition → tối ưu
  • Số consumer > số partition → mấy consumer dư ra sẽ ngồi chơi (idle)
  • Thêm consumer vào group → Kafka tự rebalance

# replication: không mất dữ liệu

Mỗi partition có thể có nhiều replica nằm trên các broker khác nhau:

  • Leader replica: nhận mọi writes, và mặc định cũng phục vụ reads (từ Kafka 2.4, KIP-392 cho phép consumer đọc từ follower cùng rack nếu cấu hình)
  • Follower replicas: sync data từ leader, leader mà down thì có đứa lên thay
Partition 0:
  Broker 0: Leader    ← Producer writes here, Consumer reads here
  Broker 1: Follower  ← Syncs from leader
  Broker 2: Follower  ← Syncs from leader

Nếu Broker 0 chết:
  Broker 1: Leader (promoted) ← Tự động failover
  Broker 2: Follower

ISR (In-Sync Replicas): nhóm các replica đang theo kịp leader. Khi producer gửi với acks=all, leader chỉ trả lời thành công sau khi mọi replica trong ISR đã có message. min.insync.replicas=2 đặt sàn cho chuyện đó: nếu ISR co lại còn dưới 2 replica (tính cả leader) thì broker từ chối ghi với lỗi NotEnoughReplicas, thà báo lỗi còn hơn nhận message mà chỉ nằm trên một máy. Cấu hình này chỉ có tác dụng với acks=all.

# kafka message anatomy

Một Kafka message (hay record) gồm mấy phần:
kafka-message-anatomy
  • Key: quyết định message vào partition nào. Cùng key = cùng partition = giữ được thứ tự
  • Value: payload chính (thường là JSON hoặc Avro)
  • Headers: metadata thêm (correlation ID, trace ID, ...)
  • Timestamp: lúc message được tạo hoặc được append

# hands-on: kafka với spring boot

Lý thuyết vậy đủ rồi, code thôi.

# project setup

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
 
    <!-- Test -->
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

# configuration: application.yml

spring:
  kafka:
    bootstrap-servers: localhost:9092
 
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all # Đợi tất cả ISR confirm → không mất data
      retries: 3 # Retry khi gặp transient errors
      properties:
        enable.idempotence: true # Tránh duplicate messages khi retry
        max.in.flight.requests.per.connection: 5
 
    consumer:
      group-id: order-service
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      auto-offset-reset: earliest # Đọc từ đầu nếu chưa có committed offset
      properties:
        spring.json.trusted.packages: '*'
 
    # KafkaAdmin: tạo các topic khai báo bằng bean NewTopic (xem phần topic configuration)
    admin:
      properties:
        bootstrap.servers: localhost:9092

⚠️ Production tip: đừng bao giờ bật auto.create.topics.enable=true trên production. Topic nào cũng nên tạo tay, ghi rõ số partition và replication factor.

Vài điểm trong config trên nên biết thêm. Từ Kafka 3.0, producer mặc định đã là acks=all và enable.idempotence=true, ghi rõ ra chỉ để người đọc config hiểu ý đồ. retries: 3 thực ra hạ thấp mặc định (mặc định là gần như vô hạn, giới hạn bằng delivery.timeout.ms 2 phút), nên nhiều team bỏ dòng này. spring.json.trusted.packages: '*' cho phép deserialize ra bất kỳ class nào theo type header của message, tiện cho demo nhưng lên production nên ghi đúng package của mình.

Nếu bạn dùng Spring Boot 4 / Spring for Apache Kafka 4.x thì JsonSerializer/JsonDeserializer đã bị deprecate, thay bằng JacksonJsonSerializer/JacksonJsonDeserializer chạy trên Jackson 3 (what's new). Code trong bài viết theo Spring Kafka 3.x, đổi tên class là chạy.

# domain model: order event

// OrderEvent.java
public class OrderEvent {
 
    private String orderId;
    private String userId;
    private String product;
    private BigDecimal amount;
    private OrderStatus status;
    private LocalDateTime timestamp;
 
    public enum OrderStatus {
        CREATED, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
    }
 
    // Constructor mặc định cần cho deserialization
    public OrderEvent() {}
 
    public OrderEvent(String orderId, String userId, String product,
                      BigDecimal amount, OrderStatus status) {
        this.orderId = orderId;
        this.userId = userId;
        this.product = product;
        this.amount = amount;
        this.status = status;
        this.timestamp = LocalDateTime.now();
    }
 
    // Getters & Setters (hoặc dùng Lombok @Data)
    public String getOrderId() { return orderId; }
    public void setOrderId(String orderId) { this.orderId = orderId; }
    public String getUserId() { return userId; }
    public void setUserId(String userId) { this.userId = userId; }
    public String getProduct() { return product; }
    public void setProduct(String product) { this.product = product; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
 
    @Override
    public String toString() {
        return "OrderEvent{orderId='%s', userId='%s', status=%s, amount=%s}"
                .formatted(orderId, userId, status, amount);
    }
}

# topic configuration

// KafkaTopicConfig.java
@Configuration
public class KafkaTopicConfig {
 
    @Bean
    public NewTopic orderEventsTopic() {
        return TopicBuilder.name("order-events")
                .partitions(6)           // 6 partitions cho parallel processing
                .replicas(3)             // 3 replicas cho fault tolerance
                .config(TopicConfig.RETENTION_MS_CONFIG, "604800000") // 7 ngày
                .config(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, "2")
                .build();
    }
 
    @Bean
    public NewTopic orderEventsDLT() {
        // Dead Letter Topic — nơi chứa messages xử lý thất bại
        // Số partition phải >= topic gốc: DeadLetterPublishingRecoverer
        // mặc định gửi vào cùng số partition với record gốc
        return TopicBuilder.name("order-events.DLT")
                .partitions(6)
                .replicas(3)
                .build();
    }
}

# producer: gửi events

// OrderEventProducer.java
@Service
@Slf4j
public class OrderEventProducer {
 
    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
 
    // Constructor injection — best practice
    public OrderEventProducer(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }
 
    /**
     * Gửi order event lên Kafka.
     * Key = userId → tất cả events của cùng user vào cùng partition
     * → đảm bảo ordering per user.
     */
    public CompletableFuture<SendResult<String, OrderEvent>> publishOrderEvent(
            OrderEvent event) {
 
        log.info("Publishing event: {}", event);
 
        return kafkaTemplate
                .send("order-events", event.getUserId(), event)
                .whenComplete((result, ex) -> {
                    if (ex == null) {
                        log.info("Event sent successfully: topic={}, partition={}, offset={}",
                                result.getRecordMetadata().topic(),
                                result.getRecordMetadata().partition(),
                                result.getRecordMetadata().offset());
                    } else {
                        log.error("Failed to send event: {}", event, ex);
                        // Ở đây bạn có thể: retry, gửi vào DLQ, alert, ...
                    }
                });
    }
 
    /**
     * Gửi event với custom headers — hữu ích cho tracing.
     */
    public void publishWithHeaders(OrderEvent event, String correlationId) {
        ProducerRecord<String, OrderEvent> record =
                new ProducerRecord<>("order-events", event.getUserId(), event);
 
        record.headers()
                .add("correlationId", correlationId.getBytes(StandardCharsets.UTF_8))
                .add("source", "order-service".getBytes(StandardCharsets.UTF_8));
 
        kafkaTemplate.send(record);
    }
}

# consumer: nhận và xử lý events

// OrderEventConsumer.java
@Service
@Slf4j
public class OrderEventConsumer {
 
    private final OrderService orderService;
 
    public OrderEventConsumer(OrderService orderService) {
        this.orderService = orderService;
    }
 
    /**
     * Consumer cơ bản — đủ cho hầu hết use cases.
     *
     * concurrency = 3 → Spring tạo 3 consumer threads,
     * mỗi thread xử lý 1 hoặc nhiều partitions.
     */
    @KafkaListener(
            topics = "order-events",
            groupId = "order-service",
            concurrency = "3"
    )
    public void handleOrderEvent(
            @Payload OrderEvent event,
            @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
            @Header(KafkaHeaders.OFFSET) long offset,
            @Header(KafkaHeaders.RECEIVED_TIMESTAMP) long timestamp) {
 
        log.info("Received event: {} [partition={}, offset={}]",
                event, partition, offset);
 
        try {
            orderService.processOrder(event);
        } catch (Exception e) {
            log.error("Error processing event at offset {}: {}",
                    offset, e.getMessage());
            // Throw lại để Kafka retry hoặc gửi vào DLT
            throw e;
        }
    }
 
    /**
     * Consumer với manual acknowledgment — khi bạn cần kiểm soát
     * chính xác lúc nào offset được commit.
     */
    @KafkaListener(
            topics = "order-events",
            groupId = "order-service-manual-ack",
            containerFactory = "manualAckListenerFactory"
    )
    public void handleWithManualAck(
            OrderEvent event,
            Acknowledgment acknowledgment) {
 
        try {
            orderService.processOrder(event);
            // Chỉ commit offset khi xử lý thành công
            acknowledgment.acknowledge();
        } catch (Exception e) {
            // Chỉ "không acknowledge" thì KHÔNG đủ: consumer vẫn poll tiếp,
            // lần ack sau commit offset lớn hơn và message này bị bỏ qua.
            // nack() seek lại về message này, đợi 1s rồi giao lại.
            log.error("Processing failed, will retry: {}", e.getMessage());
            acknowledgment.nack(Duration.ofSeconds(1));
        }
    }
 
    /**
     * Batch consumer — xử lý nhiều messages cùng lúc.
     * Hiệu quả hơn khi cần bulk insert vào DB.
     */
    @KafkaListener(
            topics = "order-events",
            groupId = "order-service-batch",
            containerFactory = "batchListenerFactory"
    )
    public void handleBatch(List<OrderEvent> events) {
        log.info("Received batch of {} events", events.size());
        orderService.processBatch(events);
    }
}

# consumer factory configuration

// KafkaConsumerConfig.java
@Configuration
@EnableKafka
public class KafkaConsumerConfig {
 
    @Value("${spring.kafka.bootstrap-servers}")
    private String bootstrapServers;
 
    @Bean
    public ConsumerFactory<String, OrderEvent> consumerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                JsonDeserializer.class);
        props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        return new DefaultKafkaConsumerFactory<>(props);
    }
 
    /**
     * Factory cho manual acknowledgment.
     * AckMode.MANUAL_IMMEDIATE = commit ngay khi acknowledge() được gọi.
     */
    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, OrderEvent>
            manualAckListenerFactory() {
 
        ConcurrentKafkaListenerContainerFactory<String, OrderEvent> factory =
                new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory());
        factory.getContainerProperties()
                .setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
        return factory;
    }
 
    /**
     * Factory cho batch processing.
     * max.poll.records = số messages tối đa mỗi lần poll.
     */
    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, OrderEvent>
            batchListenerFactory() {
 
        ConcurrentKafkaListenerContainerFactory<String, OrderEvent> factory =
                new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory());
        factory.setBatchListener(true);
        factory.getContainerProperties()
                .setAckMode(ContainerProperties.AckMode.BATCH);
        return factory;
    }
}

# error handling & dead letter topic

Phần này hay bị bỏ qua, mà lên production thì lại cực kỳ quan trọng.

// KafkaErrorHandlingConfig.java
@Configuration
public class KafkaErrorHandlingConfig {
 
    /**
     * Retry 3 lần với backoff tăng dần, sau đó gửi vào Dead Letter Topic.
     *
     * Flow: message → retry 1 (1s) → retry 2 (2s) → retry 3 (4s) → DLT
     */
    @Bean
    public DefaultErrorHandler errorHandler(
            KafkaOperations<String, OrderEvent> kafkaOperations) {
 
        // Gửi message thất bại vào DLT
        DeadLetterPublishingRecoverer recoverer =
                new DeadLetterPublishingRecoverer(kafkaOperations,
                        (record, ex) -> new TopicPartition(
                                record.topic() + ".DLT",
                                record.partition()));
 
        // Retry đúng 3 lần với exponential backoff 1s, 2s, 4s
        ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
        backOff.setInitialInterval(1000L);
        backOff.setMultiplier(2.0);
 
        DefaultErrorHandler errorHandler =
                new DefaultErrorHandler(recoverer, backOff);
 
        // Không retry cho các lỗi không thể recover
        errorHandler.addNotRetryableExceptions(
                DeserializationException.class,
                ClassCastException.class,
                NullPointerException.class
        );
 
        return errorHandler;
    }
}

Mấy chỗ dễ vấp ở đoạn này. Một là lớp ExponentialBackOff của Spring dừng theo tổng thời gian chờ: với maxElapsedTime = 15s nó sẽ retry 4 lần (1 + 2 + 4 + 8 = 15s) chứ không phải 3, nên muốn đúng số lần thì dùng ExponentialBackOffWithMaxRetries như trên. Hai là DeadLetterPublishingRecoverer mặc định gửi record hỏng vào cùng số partition ở topic .DLT, nên DLT phải có ít nhất bằng số partition của topic gốc, không thì gửi DLT cũng lỗi. Ba là Spring Boot chỉ tự gắn bean DefaultErrorHandler vào container factory mặc định do Boot tạo. Mấy factory tự khai báo như manualAckListenerFactory, batchListenerFactory ở trên thì phải gọi factory.setCommonErrorHandler(errorHandler) bằng tay.

DeserializationException và ClassCastException thật ra đã nằm sẵn trong danh sách không retry mặc định của DefaultErrorHandler, ghi thêm cho rõ ý cũng không sao.

# rest controller: trigger events

// OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
 
    private final OrderEventProducer producer;
 
    public OrderController(OrderEventProducer producer) {
        this.producer = producer;
    }
 
    @PostMapping
    public ResponseEntity<Map<String, String>> createOrder(
            @RequestBody CreateOrderRequest request) {
 
        OrderEvent event = new OrderEvent(
                UUID.randomUUID().toString(),
                request.getUserId(),
                request.getProduct(),
                request.getAmount(),
                OrderEvent.OrderStatus.CREATED
        );
 
        producer.publishOrderEvent(event);
 
        return ResponseEntity.accepted()
                .body(Map.of(
                        "orderId", event.getOrderId(),
                        "status", "ACCEPTED",
                        "message", "Order event published to Kafka"
                ));
    }
}

# mấy pattern team tôi dùng hằng ngày

# pattern 1: transactional outbox

Bài toán kinh điển: bạn cần vừa save vào DB, vừa publish event lên Kafka. Một trong hai mà fail là data lệch nhau ngay.

❌ Cách sai:
1. Save to DB     ← Thành công
2. Publish Kafka  ← Fail → DB có data nhưng Kafka không có event

❌ Cách sai ngược:
1. Publish Kafka  ← Thành công
2. Save to DB     ← Fail → Kafka có event nhưng DB không có data

Cách xử lý: Transactional Outbox Pattern

// OutboxEvent entity — lưu event vào cùng DB transaction với business data
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
 
    @Id
    private String id;
    private String aggregateType;  // "Order"
    private String aggregateId;    // orderId
    private String eventType;      // "OrderCreated"
 
    @Column(columnDefinition = "TEXT")
    private String payload;        // JSON serialized event
 
    private LocalDateTime createdAt;
    private boolean published;
 
    // constructors, getters, setters...
}
 
// Service — save business data + outbox event trong cùng 1 transaction
@Service
@Transactional
@RequiredArgsConstructor
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final OutboxRepository outboxRepository;
    private final ObjectMapper objectMapper;
 
    public Order createOrder(CreateOrderRequest request) {
        // 1. Save business data
        Order order = new Order(request);
        orderRepository.save(order);
 
        // 2. Save outbox event (cùng transaction)
        OutboxEvent outboxEvent = new OutboxEvent();
        outboxEvent.setId(UUID.randomUUID().toString());
        outboxEvent.setAggregateType("Order");
        outboxEvent.setAggregateId(order.getId());
        outboxEvent.setEventType("OrderCreated");
        try {
            outboxEvent.setPayload(objectMapper.writeValueAsString(order));
        } catch (JsonProcessingException e) {
            // Checked exception: @Transactional mặc định KHÔNG rollback
            // với checked exception, nên bọc lại thành unchecked
            throw new IllegalStateException("Cannot serialize order", e);
        }
        outboxEvent.setCreatedAt(LocalDateTime.now());
        outboxEvent.setPublished(false);
        outboxRepository.save(outboxEvent);
 
        return order;
        // Cả hai save cùng commit hoặc cùng rollback
    }
}
 
// Scheduler — poll outbox table và publish lên Kafka
@Component
@Slf4j
@RequiredArgsConstructor
public class OutboxPublisher {
 
    private final OutboxRepository outboxRepository;
    // KafkaTemplate riêng dùng StringSerializer: payload đã là JSON sẵn,
    // đi qua JsonSerializer nữa sẽ thành chuỗi JSON bị bọc trong dấu nháy
    private final KafkaTemplate<String, String> kafkaTemplate;
 
    @Scheduled(fixedDelay = 1000) // Poll mỗi giây
    @Transactional
    public void publishPendingEvents() {
        List<OutboxEvent> events =
                outboxRepository.findByPublishedFalseOrderByCreatedAtAsc();
 
        for (OutboxEvent event : events) {
            try {
                kafkaTemplate.send(
                        event.getAggregateType().toLowerCase() + "-events",
                        event.getAggregateId(),
                        event.getPayload()
                ).get(); // Blocking wait để đảm bảo gửi thành công
 
                event.setPublished(true);
                outboxRepository.save(event);
            } catch (Exception e) {
                log.error("Failed to publish outbox event: {}", event.getId(), e);
                break; // Dừng lại, retry ở lần poll tiếp theo
            }
        }
    }
}

Outbox cho bạn at-least-once chứ không phải exactly-once: nếu gửi Kafka xong mà commit DB lỗi (hoặc app chết giữa chừng), lần poll sau event được gửi lại. Nên consumer phía sau vẫn phải idempotent (xem phần idempotent consumer bên dưới). Chạy nhiều instance thì các scheduler sẽ giành nhau cùng một dòng, cần khoá kiểu SELECT ... FOR UPDATE SKIP LOCKED hoặc chỉ cho một instance chạy (ShedLock chẳng hạn). Muốn bỏ hẳn polling thì dùng CDC: Debezium đọc thẳng transaction log của DB và đẩy bảng outbox lên Kafka.

# pattern 2: event sourcing với Kafka

Thay vì lưu state hiện tại, bạn lưu lại mọi event đã xảy ra. Cần state thì replay events để dựng lại.

// Các events cho Order aggregate
public sealed interface OrderDomainEvent {
    String orderId();
 
    record OrderCreated(String orderId, String userId,
                        String product, BigDecimal amount) implements OrderDomainEvent {}
 
    record OrderConfirmed(String orderId, LocalDateTime confirmedAt)
            implements OrderDomainEvent {}
 
    record OrderShipped(String orderId, String trackingNumber)
            implements OrderDomainEvent {}
 
    record OrderCancelled(String orderId, String reason)
            implements OrderDomainEvent {}
}
 
// Rebuild state từ events
public class OrderAggregate {
 
    private String orderId;
    private String userId;
    private OrderStatus status;
    private BigDecimal amount;
    private String trackingNumber;
 
    public static OrderAggregate replayEvents(List<OrderDomainEvent> events) {
        OrderAggregate aggregate = new OrderAggregate();
        events.forEach(aggregate::apply);
        return aggregate;
    }
 
    private void apply(OrderDomainEvent event) {
        switch (event) {
            case OrderDomainEvent.OrderCreated e -> {
                this.orderId = e.orderId();
                this.userId = e.userId();
                this.amount = e.amount();
                this.status = OrderStatus.CREATED;
            }
            case OrderDomainEvent.OrderConfirmed e -> {
                this.status = OrderStatus.CONFIRMED;
            }
            case OrderDomainEvent.OrderShipped e -> {
                this.status = OrderStatus.SHIPPED;
                this.trackingNumber = e.trackingNumber();
            }
            case OrderDomainEvent.OrderCancelled e -> {
                this.status = OrderStatus.CANCELLED;
            }
        }
    }
}

Dùng Kafka làm event store có một cái vướng: Kafka không truy vấn được "mọi event của order X" một cách hiệu quả, và không có optimistic concurrency kiểu "chỉ ghi nếu version hiện tại là 5". Vì vậy nhiều hệ thống lưu event vào một event store riêng (bảng append-only trong DB, EventStoreDB...) rồi mới publish lên Kafka cho các service khác, còn Kafka đóng vai kênh phân phối event.

# pattern 3: saga pattern cho distributed transactions

Khi một nghiệp vụ phải chạy qua nhiều service, bạn sẽ cần tới Saga pattern.

kafka-distributed-transactions

Code Java thì trông thế này:

// Orchestrator-based Saga
@Service
@Slf4j
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
 
    private final KafkaTemplate<String, SagaEvent> kafkaTemplate;
 
    @KafkaListener(topics = "saga-order-reply", groupId = "saga-orchestrator")
    public void handleSagaReply(SagaEvent event) {
        switch (event.getStep()) {
            case "PAYMENT_SUCCESS" -> {
                log.info("Payment successful, reserving inventory...");
                kafkaTemplate.send("saga-inventory-request",
                        event.getOrderId(),
                        SagaEvent.of(event.getOrderId(), "RESERVE_INVENTORY"));
            }
            case "INVENTORY_RESERVED" -> {
                log.info("Inventory reserved, initiating shipping...");
                kafkaTemplate.send("saga-shipping-request",
                        event.getOrderId(),
                        SagaEvent.of(event.getOrderId(), "CREATE_SHIPMENT"));
            }
            case "PAYMENT_FAILED" -> {
                log.warn("Payment failed, cancelling order...");
                kafkaTemplate.send("saga-order-request",
                        event.getOrderId(),
                        SagaEvent.of(event.getOrderId(), "CANCEL_ORDER"));
            }
            case "INVENTORY_FAILED" -> {
                log.warn("Inventory failed, refunding payment...");
                kafkaTemplate.send("saga-payment-request",
                        event.getOrderId(),
                        SagaEvent.of(event.getOrderId(), "REFUND_PAYMENT"));
            }
        }
    }
}

# kafka streams: xử lý ngay trong kafka

Kafka Streams là một thư viện Java chạy ngay trong app của bạn: đọc từ topic, xử lý (filter, join, aggregate theo window), rồi ghi kết quả ra topic khác. Nó không chạy bên trong broker, nhưng cũng không cần dựng cluster xử lý riêng như Flink hay Spark. State (ví dụ tổng doanh thu theo user) nằm ở RocksDB local và được backup vào changelog topic trên Kafka, app chết thì instance khác dựng lại từ đó.

// Real-time order analytics với Kafka Streams
@Configuration
@EnableKafkaStreams
public class OrderAnalyticsStream {
 
    @Bean
    public KStream<String, OrderEvent> orderAnalytics(
            StreamsBuilder streamsBuilder) {
 
        // Đọc từ topic
        KStream<String, OrderEvent> orderStream = streamsBuilder
                .stream("order-events",
                        Consumed.with(Serdes.String(), orderEventSerde()));
 
        // 1. Filter: chỉ lấy orders đã confirmed
        KStream<String, OrderEvent> confirmedOrders = orderStream
                .filter((key, event) ->
                        event.getStatus() == OrderEvent.OrderStatus.CONFIRMED);
 
        // 2. Tính tổng revenue theo user (windowed aggregation)
        KTable<Windowed<String>, BigDecimal> revenuePerUser = confirmedOrders
                .groupByKey()
                .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofHours(1)))
                .aggregate(
                        () -> BigDecimal.ZERO,
                        (userId, event, total) -> total.add(event.getAmount()),
                        Materialized.with(Serdes.String(), bigDecimalSerde())
                );
 
        // 3. Detect high-value orders → gửi alert
        orderStream
                .filter((key, event) ->
                        event.getAmount().compareTo(new BigDecimal("10000")) > 0)
                .to("high-value-order-alerts",
                        Produced.with(Serdes.String(), orderEventSerde()));
 
        // 4. Branch: phân loại orders theo status
        Map<String, KStream<String, OrderEvent>> branches = orderStream
                .split(Named.as("order-"))
                .branch((key, event) ->
                        event.getStatus() == OrderEvent.OrderStatus.CANCELLED,
                        Branched.as("cancelled"))
                .branch((key, event) ->
                        event.getStatus() == OrderEvent.OrderStatus.SHIPPED,
                        Branched.as("shipped"))
                .defaultBranch(Branched.as("other"));
 
        branches.get("order-cancelled")
                .to("cancelled-orders");
 
        return orderStream;
    }
}

# production checklist: mấy bài học phải trả giá mới có

# monitoring: metrics nhất định phải theo dõi

# Prometheus metrics quan trọng nhất
# (tên metric cụ thể tuỳ exporter: kafka_exporter, JMX exporter, Micrometer...)
 
# Consumer lag — số messages chưa được xử lý (tên theo kafka_exporter)
# Nếu lag tăng liên tục → consumer không kịp xử lý
kafka_consumergroup_lag{consumergroup="order-service", topic="order-events"}
 
# Producer request latency
kafka_producer_request_latency_avg
 
# Khoảng cách giữa 2 lần poll: nếu max tiến sát max.poll.interval.ms
# là sắp bị kick khỏi group
kafka_consumer_time_between_poll_max
 
# Under-replicated partitions — DẤU HIỆU NGUY HIỂM (tên theo JMX exporter)
kafka_server_replicamanager_underreplicatedpartitions
// Spring Boot Actuator + Micrometer: Boot tự gắn metrics cho producer/consumer
// factory do Boot tạo. Factory tự khai báo (như consumerFactory ở trên)
// thì phải gắn listener bằng tay, khai báo listener thành bean riêng là không đủ.
@Bean
public ConsumerFactory<String, OrderEvent> consumerFactory(MeterRegistry registry) {
    DefaultKafkaConsumerFactory<String, OrderEvent> factory =
            new DefaultKafkaConsumerFactory<>(consumerProps());
    factory.addListener(new MicrometerConsumerListener<>(registry));
    return factory;
}

# idempotent consumer: xử lý message trùng

Với cách dùng thông thường (xử lý xong mới commit offset), Kafka cho bạn at-least-once delivery, tức là một message có thể tới tay consumer nhiều lần: consumer xử lý xong nhưng chết trước khi commit, rebalance giữa chừng, hay producer/outbox gửi lại. Vì vậy consumer của bạn bắt buộc phải idempotent.

@Service
@Slf4j
@RequiredArgsConstructor
public class IdempotentOrderConsumer {
 
    private final ProcessedEventRepository processedEventRepo;
    private final OrderService orderService;
 
    @KafkaListener(topics = "order-events", groupId = "order-service")
    @Transactional
    public void handle(OrderEvent event,
                       @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
                       @Header(KafkaHeaders.OFFSET) long offset) {
 
        // Tạo unique ID từ partition + offset (hoặc dùng event ID)
        String eventId = "order-events-%d-%d".formatted(partition, offset);
 
        // Check đã xử lý chưa
        if (processedEventRepo.existsById(eventId)) {
            log.warn("Duplicate event detected, skipping: {}", eventId);
            return;
        }
 
        // Xử lý business logic
        orderService.processOrder(event);
 
        // Đánh dấu đã xử lý (cùng transaction với business logic)
        processedEventRepo.save(new ProcessedEvent(eventId, LocalDateTime.now()));
    }
}

Lấy partition + offset làm khoá chỉ chặn được trường hợp cùng một record bị giao lại. Nếu producer hay outbox gửi trùng thì hai bản ghi có offset khác nhau, khoá kiểu này không bắt được. Nên trong thực tế hãy để producer gắn một event ID duy nhất (UUID trong payload hoặc header) và dedupe theo nó, bảng processed_events đặt khoá chính trên cột đó để DB chặn luôn cả trường hợp hai luồng chạy đua.

# performance tuning

# Producer tuning
spring:
  kafka:
    producer:
      # Batch messages trước khi gửi → tăng throughput
      batch-size: 32768 # 32KB
      # Đợi tối đa 5ms để gom batch
      properties:
        linger.ms: 5 # Kafka 4.0 trở đi mặc định đã là 5ms (trước đó là 0)
        # Compression — giảm network bandwidth
        compression.type: lz4 # lz4 = fast, snappy = balanced, zstd = best ratio
        # Buffer memory cho producer
        buffer.memory: 67108864 # 64MB
 
    consumer:
      # Số messages tối đa mỗi lần poll
      properties:
        max.poll.records: 500
        # Thời gian tối đa giữa 2 lần poll
        # Nếu vượt quá → consumer bị coi là dead → rebalance
        max.poll.interval.ms: 300000 # 5 phút
        # Fetch size
        fetch.min.bytes: 1024
        fetch.max.wait.ms: 500

Docker Compose cho Local Development

# docker-compose.yml
services:
  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
 
  kafka:
    image: confluentinc/cp-kafka:7.5.0
    depends_on:
      - zookeeper
    ports:
      - '9092:9092'
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      # 2 listener: app trên máy host gọi localhost:9092,
      # container khác (kafka-ui) gọi kafka:29092
      KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092
      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
 
  # UI để visualize Kafka — rất hữu ích khi debug
  # (provectuslabs/kafka-ui đã ngừng phát triển, kafbat/kafka-ui là bản fork tiếp tục)
  kafka-ui:
    image: ghcr.io/kafbat/kafka-ui:latest
    ports:
      - '8080:8080'
    environment:
      KAFKA_CLUSTERS_0_NAME: local
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092

Chỗ hay vấp nhất với compose kiểu này là ADVERTISED_LISTENERS. Client kết nối lần đầu xong sẽ nhận lại địa chỉ broker "quảng cáo" và gọi tiếp vào địa chỉ đó. Nếu chỉ quảng cáo localhost:9092 thì container kafka-ui sẽ gọi vào localhost của chính nó và không bao giờ kết nối được, nên cần tách listener trong/ngoài như trên.

💡 Tip: dùng KRaft mode (production-ready từ Kafka 3.3) là bỏ được Zookeeper, chạy local dev nhẹ đầu hơn nhiều. Từ Kafka 4.0 (tháng 3/2025), ZooKeeper bị gỡ hẳn, KRaft là lựa chọn duy nhất. Compose trên dùng Confluent Platform 7.5 (Kafka 3.5) nên vẫn chạy được ZooKeeper; dự án mới thì nên đi thẳng KRaft, ví dụ image apache/kafka chạy một node KRaft với cấu hình mặc định.


# common pitfalls: mấy lỗi tôi từng dính

# consumer rebalance storm

Triệu chứng: consumer cứ join rồi leave group liên tục, không xử lý được message nào.

Nguyên nhân: max.poll.interval.ms quá nhỏ so với thời gian xử lý.

// Sai: xử lý mất 10 phút nhưng max.poll.interval chỉ 5 phút
@KafkaListener(topics = "heavy-processing")
public void handle(Event event) {
    heavyProcessing(event); // Mất 10 phút
    // → Consumer bị kick khỏi group → rebalance → message bị re-deliver
}
 
// Đúng: max.poll.interval phải lớn hơn (max.poll.records × thời gian xử lý 1 record)
// Ở đây mỗi record mất 10 phút → poll 1 record mỗi lần, interval 15 phút
@KafkaListener(topics = "heavy-processing",
        properties = {
            "max.poll.interval.ms=900000",  // 15 phút
            "max.poll.records=1"             // 1 record mỗi lần poll
        })
public void handle(Event event) {
    heavyProcessing(event);
}

Cái bẫy là max.poll.interval.ms tính cho cả batch của một lần poll chứ không phải từng record. Poll về 500 record, mỗi cái 1 giây là đã hơn 8 phút, vượt mặc định 5 phút. Còn chuyện đẩy việc nặng sang thread pool riêng thì được, nhưng khi đó listener trả về ngay và offset bị commit trước khi xử lý xong; app chết giữa chừng là mất việc. Muốn làm vậy thì phải tự quản lý commit (manual ack sau khi việc xong) hoặc chuyển việc nặng sang một hàng đợi job đúng nghĩa.

# message ordering bị phá vỡ

// Sai: retries có thể làm đảo thứ tự messages
// Message A gửi → fail → retry
// Message B gửi → success
// Message A retry → success
// Kết quả: B trước A → sai thứ tự!
 
// Đúng: bật idempotence (mặc định bật từ Kafka 3.0), giữ đúng thứ tự
// với max.in.flight.requests.per.connection tối đa 5
spring.kafka.producer.properties.enable.idempotence=true
// Hoặc set max.in.flight.requests.per.connection=1 (nhưng giảm throughput)

# poison pill message

Chỉ cần một message bị hỏng hoặc không deserialize được là consumer kẹt ở đó mãi.

# ✅ Giải pháp: ErrorHandlingDeserializer
spring:
  kafka:
    consumer:
      key-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      properties:
        spring.deserializer.key.delegate.class: org.apache.kafka.common.serialization.StringDeserializer
        spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer

ErrorHandlingDeserializer bắt lỗi deserialize và chuyển thành DeserializationException cho error handler, thay vì để lỗi nổ ra ngay trong vòng poll. DefaultErrorHandler coi lỗi này là không retry được, nên kết hợp với DeadLetterPublishingRecoverer ở trên thì message hỏng đi thẳng vào DLT, consumer chạy tiếp.

# không set consumer group id

// Không có groupId: Spring lấy spring.kafka.consumer.group-id chung.
// Nếu cũng không có thì consumer lỗi (InvalidGroupIdException) khi subscribe.
// Nhiều listener khác mục đích dùng chung một group cũng là bug khó thấy.
@KafkaListener(topics = "events")
 
// Luôn set groupId cố định, mỗi mục đích xử lý một group
@KafkaListener(topics = "events", groupId = "my-service")
 
// Đừng sinh groupId ngẫu nhiên mỗi lần chạy: mỗi lần restart là một group mới,
// không có offset đã commit → đọc lại từ đầu (earliest) hoặc bỏ lỡ message (latest)

# topic partition count không thay đổi được (giảm)

Bạn tạo topic với 3 partitions.
Traffic tăng → bạn tăng lên 12 partitions. OK.
Traffic giảm → bạn muốn giảm về 6 partitions. KHÔNG ĐƯỢC.

→ Plan ahead. Bắt đầu với số partitions hợp lý.
→ Nếu cần giảm: tạo topic mới, migrate data.

# schema evolution: khi event thay đổi

Hệ thống lớn dần thì kiểu gì bạn cũng gặp chuyện này. Event schema đổi theo thời gian, mà consumer cũ thì vẫn phải đọc được.

// Version 1: OrderEvent ban đầu
{
    "orderId": "abc-123",
    "amount": 99.99
}
 
// Version 2: Thêm field mới (consumer cũ vẫn đọc được ✅)
{
    "orderId": "abc-123",
    "amount": 99.99,
    "currency": "USD"    // Mới — consumer cũ bỏ qua field này (nếu deserializer không fail on unknown)
}
 
// Version 3: Đổi tên field (BREAKING CHANGE ❌)
{
    "orderId": "abc-123",
    "totalAmount": 99.99  // Consumer cũ tìm "amount" → null
}

Best practices:

  1. Chỉ thêm field mới (nhớ có default value)
  2. Không xoá, không đổi tên field
  3. Lên production thì dùng Apache Avro + Schema Registry

"Consumer cũ bỏ qua field lạ" không phải tự nhiên mà có. JsonDeserializer của Spring Kafka tắt FAIL_ON_UNKNOWN_PROPERTIES sẵn, nhưng một ObjectMapper Jackson 2 tạo tay thì mặc định sẽ ném lỗi khi gặp field không biết, và version 2 ở trên lập tức thành breaking change. Schema Registry giải quyết chuyện này ở tầng hạ tầng: mỗi lần đăng ký schema mới, registry kiểm tra nó có tương thích với schema cũ theo mode đã chọn (mặc định BACKWARD) không, sai là chặn từ lúc deploy producer. Thêm field có default thì qua; đổi tên field thì bị chặn.

// Avro schema evolution với Spring
// (KafkaAvroSerializer nằm trong io.confluent:kafka-avro-serializer, repo Maven của Confluent)
@Configuration
public class AvroConfig {
 
    @Bean
    public ProducerFactory<String, GenericRecord> avroProducerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
                StringSerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
                KafkaAvroSerializer.class);
        props.put("schema.registry.url", "http://localhost:8081");
        return new DefaultKafkaProducerFactory<>(props);
    }
}

# khi nào KHÔNG nên dùng Kafka?

Kafka không phải thuốc chữa bách bệnh. Mấy trường hợp sau thì đừng dùng:

  1. Request-response đơn giản: REST/gRPC gọn hơn nhiều
  2. Ít data, ít traffic: công vận hành Kafka không đáng
  3. Cần exactly-once delivery mà muốn đơn giản: Kafka có hỗ trợ nhưng làm khá rối
  4. Chat/notification real-time: WebSocket + Redis Pub/Sub hợp hơn
  5. Chỉ chạy batch processing: Spark/Flink đọc thẳng từ storage được

Nên dùng Kafka khi:

  • Nhiều service cần đọc chung một luồng events
  • Cần replay hoặc audit trail
  • High throughput (>10K msg/s)
  • Event-driven architecture
  • Decoupling services trong microservices

# kết luận

Kafka Cheat Sheet:

Topic      = Logical channel cho messages
Partition  = Unit of parallelism & ordering
Offset     = Position của message trong partition
Broker     = Kafka server instance
Consumer Group = Nhóm consumers chia nhau partitions
ISR        = In-Sync Replicas — đảm bảo durability

Producer: acks=all + idempotence=true + min.insync.replicas=2 (RF=3) → không mất data
Consumer: idempotent processing + DLT → không sợ failures
Partition key: cùng key = cùng partition = ordering guarantee

Kafka làm được nhiều thứ, nhưng đổi lại là độ phức tạp đi kèm. Cứ bắt đầu nhỏ thôi: một producer, một consumer, một topic. Hiểu chắc từng khái niệm rồi hẵng scale.

Và nhớ giùm tôi: Kafka không phải message queue. Kafka là distributed commit log. Thấm được câu này thì mấy thứ còn lại tự nhiên sẽ thông.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.