Schema Registry và Avro/Protobuf trong Kafka với Spring Boot
1. Tổng quan
Khi nhiều service cùng đọc và ghi một topic Kafka, rủi ro lớn nhất là định dạng message. Chỉ cần một producer đổi tên field trong JSON, các consumer phía sau có thể hỏng ngay. Confluent Schema Registry giải quyết vấn đề này. Nó lưu schema tập trung, gắn mỗi message với một schema ID và chặn những thay đổi phá vỡ tương thích.
Bài này hướng dẫn bạn dựng Schema Registry, gửi và nhận message bằng Avro và Protobuf trong Spring Boot 3.x, cấu hình compatibility, và viết test không cần Kafka thật.
2. Schema Registry hoạt động thế nào
Schema Registry là một REST service riêng, đứng cạnh Kafka cluster. Bên trong, nó lưu schema vào topic _schemas. Mỗi schema thuộc về một subject. Theo mặc định (TopicNameStrategy), subject của value trong topic orders có tên orders-value.
Khi producer gửi message, serializer làm các bước sau:
- Đăng ký hoặc tra cứu schema trên Registry để lấy schema ID. Kết quả được cache lại.
- Ghi payload theo wire format của Confluent:
| byte 0: magic byte (0) | byte 1-4: schema ID (int) | byte 5..: dữ liệu Avro/Protobuf nhị phân |
Consumer đọc 5 byte đầu, lấy schema ID, tải schema về một lần rồi cache, sau đó giải mã payload. Message không phải mang theo toàn bộ schema, nên kích thước nhỏ hơn JSON rất nhiều.
3. Dựng môi trường local
Dùng Docker Compose với Kafka chạy ở chế độ KRaft và Schema Registry:
services:
kafka:
image: confluentinc/cp-kafka:7.7.1
ports: ["9092:9092"]
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
KAFKA_LISTENERS: PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,EXTERNAL://0.0.0.0:9092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,EXTERNAL://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
schema-registry:
image: confluentinc/cp-schema-registry:7.7.1
depends_on: [kafka]
ports: ["8081:8081"]
environment:
SCHEMA_REGISTRY_HOST_NAME: schema-registry
SCHEMA_REGISTRY_KAFKA_STORE_BOOTSTRAP_SERVERS: kafka:29092
SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081
Sau khi chạy docker compose up -d, bạn kiểm tra Registry:
curl -s http://localhost:8081/subjects
[]
Kết quả là danh sách rỗng vì chưa có subject nào được đăng ký.
4. Avro với Spring Boot
4.1. Dependency và plugin
Các serializer của Confluent không có trên Maven Central. Bạn cần khai báo thêm repository của Confluent:
<repositories>
<repository>
<id>confluent</id>
<url>https://packages.confluent.io/maven/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-avro-serializer</artifactId>
<version>7.7.1</version>
</dependency>
<dependency>
<groupId>org.apache.avro</groupId>
<artifactId>avro</artifactId>
<version>1.12.0</version>
</dependency>
</dependencies>
Plugin avro-maven-plugin sinh class Java từ file .avsc:
<plugin>
<groupId>org.apache.avro</groupId>
<artifactId>avro-maven-plugin</artifactId>
<version>1.12.0</version>
<executions>
<execution>
<phase>generate-sources</phase>
<goals><goal>schema</goal></goals>
<configuration>
<sourceDirectory>${project.basedir}/src/main/avro</sourceDirectory>
<outputDirectory>${project.build.directory}/generated-sources/avro</outputDirectory>
<stringType>String</stringType>
</configuration>
</execution>
</executions>
</plugin>
Tùy chọn stringType=String giúp field chuỗi được sinh ra kiểu java.lang.String thay vì CharSequence. Nhờ vậy, so sánh trong code và trong test đơn giản hơn.
4.2. Định nghĩa schema
Tạo file src/main/avro/Order.avsc:
{
"type": "record",
"name": "Order",
"namespace": "dev.nonla.order.avro",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "customerId", "type": "string" },
{ "name": "amount", "type": "double" }
]
}
Chạy mvn generate-sources. Plugin sẽ sinh class dev.nonla.order.avro.Order, kèm builder và schema nhúng sẵn trong class.
4.3. Cấu hình producer và consumer
spring:
kafka:
bootstrap-servers: localhost:9092
properties:
schema.registry.url: http://localhost:8081
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
consumer:
group-id: shipping-service
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
properties:
specific.avro.reader: true
Thuộc tính schema.registry.url đặt dưới spring.kafka.properties nên áp dụng cho cả producer lẫn consumer. Thuộc tính specific.avro.reader: true rất quan trọng. Nếu thiếu nó, consumer nhận về GenericRecord thay vì class Order đã sinh.
4.4. Gửi và nhận message
@Service
public class OrderPublisher {
private final KafkaTemplate<String, Order> kafkaTemplate;
public OrderPublisher(KafkaTemplate<String, Order> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publish(String orderId, String customerId, double amount) {
Order order = Order.newBuilder()
.setId(orderId)
.setCustomerId(customerId)
.setAmount(amount)
.build();
kafkaTemplate.send("orders", order.getId(), order);
}
}
@Component
public class ShippingListener {
private static final Logger log = LoggerFactory.getLogger(ShippingListener.class);
@KafkaListener(topics = "orders")
public void onOrder(Order order) {
log.info("Chuẩn bị giao đơn {} cho khách {}, giá trị {}",
order.getId(), order.getCustomerId(), order.getAmount());
}
}
Lần gửi đầu tiên, KafkaAvroSerializer tự đăng ký schema vào subject orders-value. Bạn có thể kiểm tra lại:
curl -s http://localhost:8081/subjects/orders-value/versions/latest
{"subject":"orders-value","version":1,"id":1,"schema":"{\"type\":\"record\",\"name\":\"Order\",...}"}
Lúc này message trên topic chỉ mang schema ID là 1, không mang toàn bộ schema.
5. Protobuf với Spring Boot
Các bước với Protobuf gần như giống hệt Avro, chỉ khác serializer và cách sinh code. Thêm dependency sau:
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-protobuf-serializer</artifactId>
<version>7.7.1</version>
</dependency>
Định nghĩa src/main/proto/order_event.proto:
syntax = "proto3";
package dev.nonla.order;
option java_package = "dev.nonla.order.proto";
option java_multiple_files = true;
message OrderEvent {
string id = 1;
string customer_id = 2;
double amount = 3;
}
Bạn sinh class Java bằng một plugin protoc cho Maven, ví dụ io.github.ascopes:protobuf-maven-plugin. Sau đó cấu hình serializer:
spring:
kafka:
producer:
value-serializer: io.confluent.kafka.serializers.protobuf.KafkaProtobufSerializer
consumer:
value-deserializer: io.confluent.kafka.serializers.protobuf.KafkaProtobufDeserializer
properties:
specific.protobuf.value.type: dev.nonla.order.proto.OrderEvent
Thuộc tính specific.protobuf.value.type cho deserializer biết class đích. Nếu thiếu, bạn nhận về DynamicMessage.
Nên chọn định dạng nào?
- Avro hợp với hệ sinh thái Kafka và data pipeline như Kafka Connect hay ksqlDB. Schema dạng JSON, quy tắc tiến hóa dựa trên tên field và giá trị mặc định.
- Protobuf hợp khi tổ chức của bạn đã dùng gRPC. Bạn tái sử dụng được file
.protosẵn có. Quy tắc tiến hóa dựa trên số thứ tự field.
6. Tiến hóa schema và compatibility
6.1. Các mức compatibility
Mỗi subject có một mức compatibility. Mức mặc định là BACKWARD.
| Mức | Ý nghĩa | Thứ tự deploy an toàn |
|---|---|---|
BACKWARD |
Schema mới đọc được dữ liệu cũ | Consumer trước, producer sau |
FORWARD |
Schema cũ đọc được dữ liệu mới | Producer trước, consumer sau |
FULL |
Thỏa cả hai chiều | Thứ tự nào cũng được |
*_TRANSITIVE |
Kiểm tra với mọi version cũ, không chỉ version liền trước | Như trên |
Bạn đặt mức cho từng subject qua REST API:
curl -s -X PUT -H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"compatibility": "FULL_TRANSITIVE"}' \
http://localhost:8081/config/orders-value
6.2. Một thay đổi hợp lệ
Giả sử bạn muốn thêm field currency vào Order. Với Avro, field mới phải có giá trị mặc định thì schema mới đạt FULL:
{ "name": "currency", "type": "string", "default": "VND" }
Trước khi merge code, bạn nên kiểm tra schema mới với Registry:
curl -s -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"schema": "{\"type\":\"record\",\"name\":\"Order\",\"namespace\":\"dev.nonla.order.avro\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"customerId\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"},{\"name\":\"currency\",\"type\":\"string\",\"default\":\"VND\"}]}"}' \
http://localhost:8081/compatibility/subjects/orders-value/versions/latest
{"is_compatible":true}
Nếu bỏ phần "default": "VND", kết quả trả về false. Khi đó producer sẽ nhận lỗi 409 Conflict lúc cố đăng ký schema.
Với Protobuf, quy tắc tương ứng là: không bao giờ đổi hoặc tái sử dụng số thứ tự field. Khi xóa một field, hãy đánh dấu reserved để không ai dùng lại số đó:
message OrderEvent {
reserved 2;
reserved "customer_id";
string id = 1;
double amount = 3;
string currency = 4;
}
7. Viết test với mock Schema Registry
Serializer của Confluent hỗ trợ URL dạng mock://<scope>. Với URL này, serializer dùng một Registry giả lập trong bộ nhớ. Bạn test được việc serialize mà không cần Docker:
import static org.assertj.core.api.Assertions.assertThat;
class OrderAvroSerdeTest {
private static final Map<String, Object> CONFIG = Map.of(
"schema.registry.url", "mock://order-serde-test",
"specific.avro.reader", true);
@Test
void shouldSerializeWithWireFormatAndDeserializeBack() {
try (var serializer = new KafkaAvroSerializer();
var deserializer = new KafkaAvroDeserializer()) {
serializer.configure(CONFIG, false);
deserializer.configure(CONFIG, false);
Order order = Order.newBuilder()
.setId("ORD-1001")
.setCustomerId("CUS-42")
.setAmount(250_000.0)
.build();
byte[] bytes = serializer.serialize("orders", order);
assertThat(bytes[0]).isZero(); // magic byte
assertThat(ByteBuffer.wrap(bytes, 1, 4).getInt()).isPositive(); // schema ID
Object result = deserializer.deserialize("orders", bytes);
assertThat(result).isInstanceOf(Order.class).isEqualTo(order);
}
}
}
Test này kiểm tra ba điều:
- Byte đầu tiên là magic byte
0. - Bốn byte tiếp theo là một schema ID hợp lệ.
- Deserializer trả về đúng class
Order, không phảiGenericRecord. Điều này xác nhậnspecific.avro.readerđã có hiệu lực.
Muốn có test tích hợp đầy đủ, bạn có thể dùng Testcontainers để chạy Kafka cùng Schema Registry. Tuy vậy, test với mock:// chạy nhanh và đủ để bắt phần lớn lỗi cấu hình.
Lưu ý: Các serializer dùng chung một scope
mock://sẽ chia sẻ cùng một Registry giả lập. Mỗi test class nên dùng một scope riêng để tránh ảnh hưởng lẫn nhau.
8. Lỗi hay gặp và khi nào không nên dùng
Poison pill. Một message không đúng wire format sẽ khiến KafkaAvroDeserializer ném SerializationException trước khi tới listener. Consumer có thể kẹt mãi ở offset đó. Bạn nên bọc deserializer bằng ErrorHandlingDeserializer:
spring:
kafka:
consumer:
value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
properties:
spring.deserializer.value.delegate.class: io.confluent.kafka.serializers.KafkaAvroDeserializer
Tự đăng ký schema trên production. Mặc định auto.register.schemas=true, tức producer nào cũng có thể đăng ký schema mới. Trên production, bạn nên đặt auto.register.schemas: false và use.latest.version: true. Schema chỉ nên được đăng ký qua pipeline CI/CD, sau bước kiểm tra compatibility.
Nhận GenericRecord hoặc DynamicMessage ngoài ý muốn. Nguyên nhân thường là quên specific.avro.reader hoặc specific.protobuf.value.type. Hậu quả là ClassCastException xảy ra ngay trong listener.
Đổi namespace hoặc tên record trong Avro. Với Avro, đây là một schema hoàn toàn khác. Specific reader sẽ không tìm thấy class tương ứng. Hãy coi namespace là phần không được đổi của hợp đồng.
Đặt compatibility NONE cho tiện. Làm vậy là mất toàn bộ lợi ích của Registry. Chỉ dùng NONE cho topic thử nghiệm.
Lưu ý: Schema Registry là một phụ thuộc runtime. Serializer có cache nên Registry chết tạm thời ít khi ảnh hưởng tới schema đã biết. Nhưng một producer mới khởi động, hoặc một schema chưa có trong cache, sẽ lỗi ngay. Hãy chạy Registry ở chế độ nhiều instance trên production.
Khi nào không nên dùng Schema Registry:
- Hệ thống nhỏ, chỉ một team sở hữu cả producer lẫn consumer, ít thay đổi. Khi đó JSON kèm
JsonSerializercủa Spring đơn giản hơn nhiều. - Message cần được đọc trực tiếp bởi các client không hiểu wire format của Confluent, ví dụ một công cụ bên thứ ba chỉ đọc JSON thuần.
- Bạn không thể vận hành thêm một service hạ tầng và cũng không có Registry dạng managed.
9. Kết luận
Schema Registry biến định dạng message Kafka thành một hợp đồng có version và có kiểm tra tự động. Bạn đã thấy cách:
- Dựng Registry bằng Docker.
- Gửi và nhận message bằng
KafkaAvroSerializervàKafkaProtobufSerializertrong Spring Boot 3. - Hiểu wire format gồm magic byte và schema ID.
- Đặt mức compatibility và tiến hóa schema an toàn: field mới có default với Avro,
reservedvới Protobuf. - Viết test nhanh bằng
mock://Registry.
Khi áp dụng vào production, hãy tắt tự đăng ký schema, đưa bước kiểm tra compatibility vào CI, và luôn bọc deserializer bằng ErrorHandlingDeserializer. Như vậy, một thay đổi sai schema sẽ bị chặn ngay từ lúc review, thay vì làm hỏng consumer lúc nửa đêm.